Files
uutils.github.io/static/js/wasm-terminal.test.html
Sylvestre Ledru 77efde6da3 Add uutils grep to the playground
grep ships as its own standalone WASM module (not part of the coreutils
multicall binary) and depends on the Oniguruma C library, so the website
workflow now checks out uutils/grep, installs the WASI SDK to provide a
wasm sysroot for the onig_sys C sources, builds grep.wasm, and appends
"grep" to the generated command list.

The terminal loads grep.wasm as an optional second module and dispatches
grep directly (argv[0] = "grep"). grep defaults to --color=never since the
WASI shim reports stdout as a TTY, which would otherwise emit match-
highlight escapes that corrupt piped output; explicit --color is honored.

Adds a grep emoji example and grep WASM integration tests.
2026-06-02 13:47:45 +02:00

577 lines
18 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>wasm-terminal unit tests</title>
<style>
body { font-family: monospace; margin: 2em; background: #1e1e2e; color: #cdd6f4; }
.pass { color: #a6e3a1; }
.fail { color: #f38ba8; font-weight: bold; }
.section { color: #f9e2af; margin-top: 1.5em; font-size: 1.1em; }
#summary { margin-top: 2em; font-size: 1.2em; padding: 0.5em; border-top: 1px solid #45475a; }
</style>
</head>
<body>
<h1>wasm-terminal unit tests</h1>
<div id="results"></div>
<div id="summary"></div>
<script src="wasm-terminal.js"></script>
<script>
(async function() {
const T = window._uutilsTestInternals;
const { parseCommandLine, executeCommandLine, resolvePath, lookupDir, getPersistentDir, readVirtualFile, writeVirtualFile, SAMPLE_FILES, AVAILABLE_COMMANDS } = T;
let passed = 0;
let failed = 0;
const results = document.getElementById("results");
function section(name) {
const el = document.createElement("div");
el.className = "section";
el.textContent = "--- " + name + " ---";
results.appendChild(el);
}
function assert(name, actual, expected) {
const el = document.createElement("div");
if (actual === expected) {
el.className = "pass";
el.textContent = " PASS: " + name;
passed++;
} else {
el.className = "fail";
el.textContent = " FAIL: " + name + "\n expected: " + JSON.stringify(expected) + "\n actual: " + JSON.stringify(actual);
console.log("FAIL: " + name + " | expected: " + JSON.stringify(expected) + " | actual: " + JSON.stringify(actual)); // log for headless runners
failed++;
}
results.appendChild(el);
}
function assertDeep(name, actual, expected) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
assert(name, a, e);
}
async function assertAsync(name, promise, expected) {
const actual = await promise;
assert(name, actual, expected);
}
async function runTests() {
// Try to load WASM binary for integration tests
try {
await T.initWasm();
} catch (e) {
console.log("WASM load failed (integration tests will be skipped):", e.message);
}
// ===== parseCommandLine =====
section("parseCommandLine");
// Helper: extract just args arrays from parsed pipeline for simpler assertions
const argsOf = (p) => p.map(s => s.args);
assertDeep("simple command",
argsOf(parseCommandLine("echo hello")),
[["echo", "hello"]]);
assertDeep("multiple args",
argsOf(parseCommandLine("wc -l fruits.txt")),
[["wc", "-l", "fruits.txt"]]);
assertDeep("pipe",
argsOf(parseCommandLine("echo hello | wc -c")),
[["echo", "hello"], ["wc", "-c"]]);
assertDeep("double pipe",
argsOf(parseCommandLine("cat file | sort | uniq")),
[["cat", "file"], ["sort"], ["uniq"]]);
assertDeep("single quotes",
argsOf(parseCommandLine("echo 'hello world'")),
[["echo", "hello world"]]);
assertDeep("double quotes",
argsOf(parseCommandLine('echo "hello world"')),
[["echo", "hello world"]]);
assertDeep("escaped space",
argsOf(parseCommandLine("echo hello\\ world")),
[["echo", "hello world"]]);
assertDeep("mixed quotes",
argsOf(parseCommandLine("echo 'it\"s' \"a test\"")),
[["echo", 'it"s', "a test"]]);
assertDeep("empty input",
argsOf(parseCommandLine("")),
[[]]);
assertDeep("multiple spaces",
argsOf(parseCommandLine("echo hello world")),
[["echo", "hello", "world"]]);
assertDeep("pipe inside single quotes is literal",
argsOf(parseCommandLine("echo 'a|b'")),
[["echo", "a|b"]]);
assertDeep("pipe inside double quotes is literal",
argsOf(parseCommandLine('echo "a|b"')),
[["echo", "a|b"]]);
// Redirection parsing
let p = parseCommandLine("sort < input.txt");
assertDeep("stdin redirect args", p[0].args, ["sort"]);
assert("stdin redirect file", p[0].stdin, "input.txt");
p = parseCommandLine("echo hello > output.txt");
assertDeep("stdout redirect args", p[0].args, ["echo", "hello"]);
assert("stdout redirect file", p[0].stdout, "output.txt");
assert("stdout redirect not append", p[0].append, false);
p = parseCommandLine("echo hello >> output.txt");
assert("append redirect file", p[0].stdout, "output.txt");
assert("append redirect flag", p[0].append, true);
p = parseCommandLine("sort < in.txt | head -3 > out.txt");
assert("pipe + redirect: stdin", p[0].stdin, "in.txt");
assertDeep("pipe + redirect: first args", p[0].args, ["sort"]);
assert("pipe + redirect: stdout", p[1].stdout, "out.txt");
assertDeep("pipe + redirect: second args", p[1].args, ["head", "-3"]);
// ===== executeCommandLine: builtins =====
section("executeCommandLine: builtins");
await assertAsync("empty line",
executeCommandLine(""), "");
await assertAsync("whitespace only",
executeCommandLine(" "), "");
const helpOutput = await executeCommandLine("help");
assert("help contains Available",
helpOutput.includes("Available commands"), true);
assert("help contains examples",
helpOutput.includes("Examples"), true);
await assertAsync("clear returns empty",
executeCommandLine("clear"), "");
// ls output format depends on whether WASM is loaded (WASM ls uses tabs
// and sorts alphabetically; JS fallback joins with double spaces in
// insertion order). Just verify all sample files appear.
const lsOutput = await executeCommandLine("ls");
for (const f of Object.keys(SAMPLE_FILES)) {
assert("ls contains " + f, lsOutput.includes(f), true);
}
const lsDotOutput = await executeCommandLine("ls .");
for (const f of Object.keys(SAMPLE_FILES)) {
assert("ls . contains " + f, lsDotOutput.includes(f), true);
}
// ===== executeCommandLine: JS fallback commands =====
section("executeCommandLine: JS fallback commands");
await assertAsync("echo via execute",
executeCommandLine("echo test"),
"test\n");
await assertAsync("seq via execute",
executeCommandLine("seq 3"),
"1\n2\n3\n");
await assertAsync("cat via execute",
executeCommandLine("cat names.txt"),
SAMPLE_FILES["names.txt"]);
// ===== executeCommandLine: pipes =====
section("executeCommandLine: pipes");
await assertAsync("echo | wc -w",
executeCommandLine("echo one two three | wc -w"),
"3\n");
await assertAsync("seq | factor",
executeCommandLine("seq 4 | factor"),
"1:\n2: 2\n3: 3\n4: 2 2\n");
// ===== AVAILABLE_COMMANDS: sort =====
section("sort availability");
assert("sort in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("sort"), true);
// ===== executeCommandLine: unknown command =====
section("executeCommandLine: error handling");
const notFound = await executeCommandLine("foobar");
assert("unknown command error",
notFound.includes("command not found"), true);
// ===== AVAILABLE_COMMANDS sanity =====
section("AVAILABLE_COMMANDS");
assert("echo in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("echo"), true);
assert("wc in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("wc"), true);
assert("seq in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("seq"), true);
assert("ls in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("ls"), true);
// ===== SAMPLE_FILES sanity =====
section("SAMPLE_FILES");
assert("names.txt exists",
SAMPLE_FILES["names.txt"] !== undefined, true);
assert("names.txt has content",
SAMPLE_FILES["names.txt"].includes("Alice"), true);
assert("numbers.txt has numbers",
SAMPLE_FILES["numbers.txt"].includes("42"), true);
assert("fruits.txt has fruits",
SAMPLE_FILES["fruits.txt"].includes("banana"), true);
// ===== resolvePath =====
section("resolvePath");
T.cwd = "";
assert("resolve simple file at root",
resolvePath("foo.txt"), "foo.txt");
assert("resolve . at root",
resolvePath("."), ".");
assert("resolve subdir/file at root",
resolvePath("sub/file.txt"), "sub/file.txt");
assert("resolve flags unchanged",
resolvePath("-n"), "-n");
assert("resolve empty unchanged",
resolvePath(""), "");
T.cwd = "mydir";
assert("resolve file in cwd",
resolvePath("foo.txt"), "mydir/foo.txt");
assert("resolve .. from cwd",
resolvePath(".."), ".");
assert("resolve ../other from cwd",
resolvePath("../other"), "other");
assert("resolve sub/file from cwd",
resolvePath("sub/file"), "mydir/sub/file");
assert("resolve . from cwd",
resolvePath("."), "mydir");
T.cwd = "a/b/c";
assert("resolve ../../x from deep cwd",
resolvePath("../../x"), "a/x");
assert("resolve ../../../ from deep cwd",
resolvePath("../../.."), ".");
T.cwd = "";
// ===== cd builtin =====
section("cd builtin");
T.cwd = "";
// cd to nonexistent dir uses lookupDir which needs wasiShim.
// When WASM isn't loaded, cd falls back to checking the JS SAMPLE_FILES
// which don't have directories, so all cd <dir> should fail.
const cdResult = await executeCommandLine("cd nonexistent");
assert("cd to nonexistent dir",
cdResult.includes("No such directory"), true);
assert("cwd unchanged after failed cd",
T.cwd, "");
await assertAsync("cd with no args stays at root",
executeCommandLine("cd"), "");
assert("cwd is root after cd",
T.cwd, "");
await assertAsync("cd / goes to root",
executeCommandLine("cd /"), "");
assert("cwd is root after cd /",
T.cwd, "");
await assertAsync("cd ~ goes to root",
executeCommandLine("cd ~"), "");
assert("cwd is root after cd ~",
T.cwd, "");
// ===== pwd builtin =====
section("pwd builtin");
T.cwd = "";
await assertAsync("pwd at root",
executeCommandLine("pwd"), "/\n");
T.cwd = "mydir";
await assertAsync("pwd in subdir",
executeCommandLine("pwd"), "/mydir\n");
T.cwd = "a/b";
await assertAsync("pwd in deep subdir",
executeCommandLine("pwd"), "/a/b\n");
T.cwd = "";
// ===== locale builtin =====
section("locale builtin");
T.locale = "en-US";
await assertAsync("locale shows current",
executeCommandLine("locale"), "LANG=en-US.UTF-8\n");
await assertAsync("locale set full form",
executeCommandLine("locale fr-FR"), "Locale set to fr-FR\n");
assert("locale changed to fr-FR",
T.locale, "fr-FR");
await assertAsync("locale set shortcut",
executeCommandLine("locale de"), "Locale set to de-DE\n");
assert("locale changed to de-DE",
T.locale, "de-DE");
await assertAsync("locale set shortcut ja",
executeCommandLine("locale ja"), "Locale set to ja-JP\n");
assert("locale changed to ja-JP",
T.locale, "ja-JP");
// Unknown shortcut passes through as-is
await assertAsync("locale unknown passes through",
executeCommandLine("locale xx-YY"), "Locale set to xx-YY\n");
assert("locale set to xx-YY",
T.locale, "xx-YY");
T.locale = "en-US";
// ===== l10n WASM integration (only when WASM binary is loaded) =====
if (T.wasmReady) {
section("l10n WASM integration");
T.locale = "en-US";
const enHelp = await executeCommandLine("arch --help");
assert("arch --help in English contains English text",
enHelp.includes("Display machine architecture"), true);
T.locale = "fr-FR";
const frHelp = await executeCommandLine("arch --help");
assert("arch --help in French contains French text",
frHelp.includes("Afficher l'architecture de la machine"), true);
T.locale = "en-US";
// ===== ls fixes =====
section("ls WASI fixes");
const lsAl = await executeCommandLine("ls -al");
assert("ls -al does not contain 'Capabilities insufficient'",
lsAl.includes("Capabilities insufficient"), false);
assert("ls -al shows '..' entry",
lsAl.includes(".."), true);
assert("ls -al does not show 1970 date",
lsAl.includes("1970"), false);
// File sizes should be reasonable (< 1000 bytes for sample files)
const sizeMatch = lsAl.match(/(\d+)\s+\w+\s+\d+.*csv\.txt/);
assert("csv.txt size is reasonable",
sizeMatch && parseInt(sizeMatch[1]) < 1000, true);
// ===== UTF-8 / multibyte WASM tests =====
section("UTF-8 multibyte WASM");
const echoJp = await executeCommandLine("echo 'こんにちは世界'");
assert("echo Japanese text",
echoJp.trimEnd(), "こんにちは世界");
const b64Round = await executeCommandLine("echo 'こんにちは世界' | base64 | base64 -d");
assert("base64 round-trip Japanese",
b64Round.trimEnd(), "こんにちは世界");
const echoEmoji = await executeCommandLine("echo '🍎🍌🍒'");
assert("echo emoji",
echoEmoji.trimEnd(), "🍎🍌🍒");
const b64Emoji = await executeCommandLine("echo '🍎🍌🍒' | base64 | base64 -d");
assert("base64 round-trip emoji",
b64Emoji.trimEnd(), "🍎🍌🍒");
const echoArabic = await executeCommandLine("echo 'مرحبا'");
assert("echo Arabic text",
echoArabic.trimEnd(), "مرحبا");
// ===== WASM pipe tests =====
section("WASM pipe commands");
// Test sort reading from stdin — sort may hit "unreachable" in some
// WASM builds, so guard these tests and skip if sort traps.
const sortProbe = await executeCommandLine("echo 'b\na' | sort");
const sortWorks = sortProbe === "a\nb\n";
if (sortWorks) {
await assertAsync("echo | sort",
executeCommandLine("echo 'c\nb\na' | sort"),
"a\nb\nc\n");
await assertAsync("echo | sort -r",
executeCommandLine("echo 'a\nb\nc' | sort -r"),
"c\nb\na\n");
} else {
section("WASM sort tests (SKIPPED - sort traps in this build)");
}
// Test uniq with piped stdin
await assertAsync("echo | uniq",
executeCommandLine("echo 'a\na\nb\nb\nc' | uniq"),
"a\nb\nc\n");
// Test tail with piped stdin
await assertAsync("seq | tail -2",
executeCommandLine("seq 5 | tail -2"),
"4\n5\n");
// Test cut with piped stdin
await assertAsync("echo | cut -d: -f1",
executeCommandLine("echo 'a:b:c' | cut -d: -f1"),
"a\n");
// Test multi-command pipes (depend on sort)
if (sortWorks) {
await assertAsync("echo | sort | uniq -c",
executeCommandLine("echo 'b\na\nb\na\na' | sort | uniq -c"),
" 3 a\n 2 b\n");
await assertAsync("sort | uniq -c | sort -rn",
executeCommandLine("echo 'b\na\nb\na\na' | sort | uniq -c | sort -rn"),
" 3 a\n 2 b\n");
await assertAsync("echo csv | cut | tail | sort",
executeCommandLine("echo '1,z\n2,y\n3,x\n4,w' | cut -d, -f2 | tail -2 | sort"),
"w\nx\n");
}
// ===== grep (standalone WASM module) =====
if (T.grepReady) {
section("grep WASM module");
assert("grep in AVAILABLE_COMMANDS",
AVAILABLE_COMMANDS.includes("grep"), true);
await assertAsync("grep from file",
executeCommandLine("grep Alice names.txt"),
"Alice\n");
await assertAsync("grep -i from stdin",
executeCommandLine("echo 'APPLE\npear\nApricot' | grep -i ap"),
"APPLE\nApricot\n");
await assertAsync("grep regex via pipe",
executeCommandLine("echo 'foo1\nbar\nfoo2' | grep 'foo[0-9]'"),
"foo1\nfoo2\n");
} else {
section("grep WASM module (SKIPPED - grep.wasm not loaded)");
}
} else {
section("l10n WASM integration (SKIPPED - WASM not loaded)");
}
// ===== LOCALE_SHORTCUTS =====
section("LOCALE_SHORTCUTS");
assert("fr shortcut", T.LOCALE_SHORTCUTS["fr"], "fr-FR");
assert("en shortcut", T.LOCALE_SHORTCUTS["en"], "en-US");
assert("de shortcut", T.LOCALE_SHORTCUTS["de"], "de-DE");
assert("ja shortcut", T.LOCALE_SHORTCUTS["ja"], "ja-JP");
assert("pt shortcut", T.LOCALE_SHORTCUTS["pt"], "pt-BR");
// ===== Tab completion =====
section("Tab completion");
const tc = T.tabComplete;
// Single command match
let r = tc("fac", 3);
assert("complete 'fac' -> 'factor '", r.buffer, "factor ");
assert("complete 'fac' completed", r.completed, true);
// Single filename match
r = tc("cat na", 6);
assert("complete 'cat na' -> 'cat names.txt '", r.buffer, "cat names.txt ");
assert("complete filename completed", r.completed, true);
// Multiple matches with common prefix
r = tc("sha2", 4);
assert("complete 'sha2' partial -> 'sha2'", r.buffer.startsWith("sha2"), true);
assert("complete 'sha2' has candidates", r.candidates.length > 1, true);
// No match
r = tc("zzzzz", 5);
assert("complete 'zzzzz' no match", r.candidates.length, 0);
assert("complete 'zzzzz' not completed", r.completed, false);
// Builtin completion
r = tc("hel", 3);
assert("complete 'hel' -> 'help '", r.buffer, "help ");
// Multiple command matches shown
r = tc("sh", 2);
assert("complete 'sh' multiple candidates", r.candidates.length > 1, true);
assert("complete 'sh' includes shuf", r.candidates.includes("shuf"), true);
assert("complete 'sh' includes shred", r.candidates.includes("shred"), true);
// ===== I/O redirection =====
section("I/O redirection");
// > creates a file
await executeCommandLine("echo 'hello redirect' > redir_test.txt");
assert("> creates file",
readVirtualFile("redir_test.txt"), "hello redirect\n");
// < reads from file
await assertAsync("< reads file as stdin",
executeCommandLine("wc -c < redir_test.txt"),
"15\n");
// >> appends
await executeCommandLine("echo 'line2' >> redir_test.txt");
assert(">> appends to file",
readVirtualFile("redir_test.txt"), "hello redirect\nline2\n");
// > overwrites
await executeCommandLine("echo 'overwritten' > redir_test.txt");
assert("> overwrites file",
readVirtualFile("redir_test.txt"), "overwritten\n");
// pipe + redirect
await executeCommandLine("echo '3\n1\n2' | sort > sorted.txt");
assert("pipe | sort > file",
readVirtualFile("sorted.txt"), "1\n2\n3\n");
// < with nonexistent file
await assertAsync("< nonexistent file",
executeCommandLine("sort < no_such_file.txt"),
"no_such_file.txt: No such file\n");
// ===== Summary =====
const summary = document.getElementById("summary");
const total = passed + failed;
if (failed === 0) {
summary.className = "pass";
summary.textContent = `All ${total} tests passed.`;
} else {
summary.className = "fail";
summary.textContent = `${failed} of ${total} tests FAILED.`;
}
// Set exit code for headless runners
window.__testsPassed = passed;
window.__testsFailed = failed;
}
await runTests();
})();
</script>
</body>
</html>