aboutsummaryrefslogtreecommitdiff
path: root/modules/test
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-27 16:23:21 +0100
committerAgando <roduccomm@gmail.com>2026-10-27 16:23:21 +0100
commitcd71cc814f0d51c2d71fca4231690228a1e1da83 (patch)
treec123b3695bc34c6bba87201f448f160872efb33e /modules/test
parent7e4e30a1790ced2b745a73e9c83e41d6e224bac6 (diff)
feat(newos4): the script runner runs scripts — control flow, variables, globs, redirects, stdin
MAINT-021 and MAINT-017, executed from .hermes/plans/2026-10-27_145500-newos4-script-runner.md. - shell.js: new block parser (_parseBlocks) and executor (_runInstructions/_runSimple/_runCommand/ _dispatchOne). `for`/`while`/`if` bodies now run, including one-line `if [ -f x ]; then y; fi`. Variables expand through _expandVars ($VAR, ${VAR}, $?, single quotes literal), globs expand through _expandGlobs (unquoted args only, per-command), and `>`/`>>` capture the command's output into the VFS instead of being passed to the command as text. - shell.js: a script never answers an unknown command by calling the AI any more. It prints `script:<path>:<line>: <cmd>: command not found` and sets $? — the AI fallback stays for typing. - shell.js: unclosed blocks and bad `for` headers surface as `Script <path> failed: …` (the parse used to sit outside the try, so a bad script crashed the runner). - shell.js: interactive path gets the same treatment — prompt globbing and `echo hi > /f` now work. Cause of the asymmetry: the two code paths had separate dispatch. - shell.js: `_isPipeAware` no longer injects the __pipebuf__ file into wc (it reads _pipeInput), and the module registers itself with a versioned URL and calls its own init() — registerModule() does not call init(), so the executeCommand wrapper was never installed from the boot bundle. - module-fs.js: wc/head/tail/grep read piped stdin when no file operand is given; `cat` accepts several operands (`cat *.txt`); `ls <file>` names the file instead of printing "(empty)". - main.html: shell.js removed from the extension list — it is already inside critical-ext-bundle.js, and loading it twice produced two ShellModule instances that could disagree about their version. Verification: - test-shell-script.js 27 asserts (parser, variables, globs, executor, redirect, command-not-found, one-line if, parse error). RED on HEAD: `TypeError: shell._parseBlocks is not a function`. - test-shell-stdin.js 16 asserts (added by a subagent): wc -l/-c, head -n, tail -n over stdin, and the file-argument path unchanged. - Suite 18/18 files; maintain --fix "node tests 18/18 pass". - Live on v4.6.115, through the real executeCommand: `ls /n*.txt` -> /n1.txt · `cat /n*.txt` -> one|two · `echo hi > /r.txt` then `cat /r.txt` -> hi · `cat /probe.txt | wc -l` -> 3 (bare, no __pipebuf__) · `script /run.sh` -> item 1 / item 2 / item 3 / exists / command not found, exit 1, no AI routing.
Diffstat (limited to 'modules/test')
-rw-r--r--modules/test/test-shell-script.js150
-rw-r--r--modules/test/test-shell-stdin.js115
2 files changed, 265 insertions, 0 deletions
diff --git a/modules/test/test-shell-script.js b/modules/test/test-shell-script.js
new file mode 100644
index 0000000..578b186
--- /dev/null
+++ b/modules/test/test-shell-script.js
@@ -0,0 +1,150 @@
+// modules/test/test-shell-script.js — run: node
+// Covers the script runner: block parser (A), variables (B), globs (C), and the executor (D).
+// D is the part that used to be impossible — a loop body could not run at all.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..');
+const store = {};
+global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.window = global;
+const load = (rel, cls) => new Function(fsMod.readFileSync(path.join(ROOT, rel), 'utf8') + '\nreturn ' + cls + ';')();
+const Shell = load('ext/shell.js', 'ShellModule');
+const VFS = load('core/virtual-fs.js', 'VirtualFileSystem');
+
+let fails = 0, passes = 0;
+const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); };
+
+const shell = Object.create(Shell.prototype);
+
+// A. block parser
+{
+ const tree = shell._parseBlocks(['for i in 1 2 3', 'do', ' echo item $i', 'done']);
+ ok('A1 one top-level instruction', tree.length === 1, JSON.stringify(tree));
+ ok('A2 it is a for loop over 3 items', tree[0].op === 'for' && tree[0].items.length === 3, JSON.stringify(tree[0]));
+ ok('A3 the body holds the echo', tree[0].body.length === 1 && /echo item \$i/.test(tree[0].body[0].text));
+}
+{
+ const tree = shell._parseBlocks(['if test -f /x', 'then', ' echo yes', 'else', ' echo no', 'fi']);
+ ok('A4 if/else parsed', tree[0].op === 'if' && tree[0].body.length === 1 && tree[0].elseBody.length === 1, JSON.stringify(tree));
+}
+{
+ let threw = null;
+ try { shell._parseBlocks(['for i in 1 2', ' echo $i']); } catch (e) { threw = e.message; }
+ ok('A5 an unclosed block is an error, not a silent run', !!threw && /done/.test(threw), String(threw));
+}
+
+// B. variables
+{
+ const env = { i: '2', name: 'webx11' };
+ ok('B1 $VAR expands', shell._expandVars('echo $name', env) === 'echo webx11');
+ ok('B2 ${VAR} expands', shell._expandVars('echo ${name}!', env) === 'echo webx11!');
+ ok('B3 single quotes are literal', shell._expandVars("echo '$name'", env) === "echo '$name'");
+ ok('B4 unknown var becomes empty', shell._expandVars('echo [$nope]', env) === 'echo []');
+}
+
+// C. globbing (against a fake fs)
+{
+ const fake = { ls: () => [{ name: 'a.txt', type: 'file' }, { name: 'b.txt', type: 'file' }, { name: 'c.log', type: 'file' }], readDir: () => [] };
+ shell.terminal = { fs: fake, moduleManager: { getAllCommands: () => ({}) } };
+ const out = shell._expandGlobs(['*.txt'], ['/work']);
+ ok('C1 *.txt expands to two files', out.length === 2 && out.join(',') === '/work/a.txt,/work/b.txt', JSON.stringify(out));
+ ok('C2 a literal arg is untouched', shell._expandGlobs(['plain'], ['/work']).join() === 'plain');
+ ok('C3 quoted wildcard stays literal', shell._expandGlobs(["'*.txt'"], ['/work']).join() === "'*.txt'");
+}
+
+// D. the executor (a fake terminal, a real VFS)
+function mkShell(seed) {
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true;
+ Object.entries(seed || {}).forEach(([p, body]) => { vfs.writeFile(p, body); });
+ const printed = [];
+ const term = {
+ fs: vfs,
+ print: (t) => {
+ const st = term._printCaptureStack || [];
+ if (st.length) { st[st.length - 1].output += String(t) + '\n'; return; }
+ printed.push(String(t));
+ },
+ log: () => {}, markDirty: () => {},
+ moduleManager: { getAllCommands: () => ({ ls: {}, echo: {}, cat: {} }) },
+ security: { checkAndRun: async () => { printed.push('AI-ROUTED'); } },
+ _printCaptureStack: [],
+ splitPipe: (s) => s.split('|').map(x => x.trim()),
+ executePipeChain: async () => {},
+ };
+ const sh = Object.create(Shell.prototype);
+ sh.terminal = term;
+ sh.commands = { echo: (args) => term.print(args.join(' ')) };
+ return { sh, term, printed, vfs };
+}
+
+async function runScript(seed, lines) {
+ const env = { $: 0 };
+ const ctx = { path: '/t.sh', inScript: true };
+ const tree = (await (async () => { const { sh } = mkShell(seed); return sh._parseBlocks(lines); })());
+ const { sh, printed, vfs, term } = mkShell(seed);
+ await sh._runInstructions(tree, env, ctx);
+ return { printed, vfs, env, term };
+}
+
+(async () => {
+ {
+ const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo item $i', 'done']);
+ ok('D1 the loop body ran 3 times', r.printed.join('|') === 'item 1|item 2|item 3', r.printed.join('|'));
+ ok('D2 $i is not left over as a literal', !r.printed.some(l => /\$i/.test(l)), r.printed.join('|'));
+ }
+ {
+ const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]', 'then', 'echo yes', 'else', 'echo no', 'fi']);
+ ok('D3 bracket test takes the then-branch', r.printed.join('|') === 'yes', r.printed.join('|'));
+ const r2 = await runScript({}, ['if test -f /absent.txt', 'then', 'echo yes', 'else', 'echo no', 'fi']);
+ ok('D4 a failing test takes the else-branch', r2.printed.join('|') === 'no', r2.printed.join('|'));
+ }
+ {
+ const r = await runScript({}, ['echo hi > /o.txt']);
+ ok('D5 redirect writes the file', r.vfs.readFile('/o.txt') === 'hi\n', JSON.stringify(r.vfs.readFile('/o.txt')));
+ ok('D6 redirect does not echo to the screen', !r.printed.includes('hi > /o.txt'), r.printed.join('|'));
+ }
+ {
+ const vfs = new VFS(); vfs.saveToStorage = () => true;
+ vfs.mkdirRecursive('/work');
+ vfs.writeFile('/work/a.txt', '1'); vfs.writeFile('/work/b.txt', '2'); vfs.writeFile('/work/c.log', '3');
+ const seen = [];
+ const term = { fs: vfs, print: () => {}, log: () => {}, markDirty: () => {},
+ moduleManager: { getAllCommands: () => ({ ls: {} }) },
+ security: { checkAndRun: async (s) => { seen.push(s); } },
+ _printCaptureStack: [], splitPipe: (s) => [s], executePipeChain: async () => {} };
+ const sh = Object.create(Shell.prototype);
+ sh.terminal = term; sh.commands = {};
+ vfs.currentPath = ['work'];
+ await sh._runSimple('ls *.txt', { $: 0 }, { path: '/t.sh', inScript: true });
+ ok('D15 glob results reach the dispatched command', seen[0] === 'ls /work/a.txt /work/b.txt', JSON.stringify(seen));
+ }
+ {
+ const r = await runScript({}, ['no-such-cmd arg']);
+ ok('D7 unknown command is reported, not answered by the AI', r.printed.join('|').includes('command not found'), r.printed.join('|'));
+ ok('D8 the AI fallback was not used', !r.printed.includes('AI-ROUTED'), r.printed.join('|'));
+ ok('D9 exit status is non-zero', r.env.$ !== 0, String(r.env.$));
+ }
+ {
+ const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]; then echo one-line; fi']);
+ ok('D11 a one-line if runs its then-branch', r.printed.join('|') === 'one-line', r.printed.join('|'));
+ }
+ {
+ const { sh, term, printed } = mkShell({});
+ sh.terminal.fs.writeFile('/bad.sh', 'for i in 1 2\necho $i\n');
+ await sh._cmdScript(['/bad.sh']);
+ ok('D12 an unclosed block fails as a script error', printed.join('|').includes('failed:'), printed.join('|'));
+ ok('D13 the failure names the missing terminator', /done/.test(printed.join('|')), printed.join('|'));
+ }
+ {
+ const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo n $i', 'done', 'echo after']);
+ ok('D14 a statement after the loop still runs', r.printed[r.printed.length - 1] === 'after', r.printed.join('|'));
+ }
+ {
+ const r = await runScript({}, ['echo a', 'echo b']);
+ ok('D10 sequential statements all run', r.printed.join('|') === 'a|b', r.printed.join('|'));
+ }
+
+ if (fails === 0) console.log('test-shell-script: ' + passes + ' passed, 0 failed');
+ else { console.log('test-shell-script: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); }
+})();
diff --git a/modules/test/test-shell-stdin.js b/modules/test/test-shell-stdin.js
new file mode 100644
index 0000000..10ba190
--- /dev/null
+++ b/modules/test/test-shell-stdin.js
@@ -0,0 +1,115 @@
+// modules/test/test-shell-stdin.js — run: node
+// Pipes: wc/head/tail must read stdin when no file operand is given, and must still read the file
+// when one is (ignoring a stale pipe buffer). The channel is terminal._pipeInput — the property
+// the pipe wrapper in modules/ext/shell.js sets before dispatching each stage.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..');
+const src = fsMod.readFileSync(path.join(ROOT, 'core/module-fs.js'), 'utf8');
+const FileSystemModule = new Function(src + '\nreturn FileSystemModule;')();
+
+let fails = 0, passes = 0;
+const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); };
+
+// A fake terminal whose fs knows only the files it is handed: there is no __pipebuf__ magic here,
+// so a handler that quietly searched the cwd instead of reading stdin would fail, not pass.
+const mk = (files, pipeInput) => {
+ const out = [];
+ const term = {
+ fs: {
+ readFile: (p) => (files && Object.prototype.hasOwnProperty.call(files, p)) ? files[p] : null,
+ getCurrentDir: () => ({ name: 'fake', content: {} }),
+ },
+ _pipeInput: pipeInput === undefined ? null : pipeInput,
+ print: (msg) => out.push(String(msg)),
+ };
+ const m = new FileSystemModule();
+ m.terminal = term;
+ m.registerCommands();
+ return { m, out };
+};
+const last = (out) => out.length ? out[out.length - 1] : '<nothing printed>';
+
+// (a) wc -l over 3 lines of stdin
+{
+ const { m, out } = mk({}, 'alpha\nbeta\ngamma');
+ m.commands.wc(['-l']);
+ ok('A1 wc -l on 3 piped lines prints 3', out.length === 1 && out[0].trim() === '3', JSON.stringify(out));
+}
+// (b) wc -c counts characters, not lines
+{
+ const { m, out } = mk({}, 'abcdef');
+ m.commands.wc(['-c']);
+ ok('B1 wc -c on "abcdef" piped prints 6', out.length === 1 && out[0].trim() === '6', JSON.stringify(out));
+
+ const w = mk({}, 'abcdef');
+ w.m.commands.wc(['-w']);
+ ok('B2 wc -w on one piped word prints 1', w.out.length === 1 && w.out[0].trim() === '1', JSON.stringify(w.out));
+
+ const l = mk({}, 'abcdef');
+ l.m.commands.wc(['-l']);
+ ok('B3 wc -l on the same piped text still prints 1 (so -c is not counting lines)', l.out.length === 1 && l.out[0].trim() === '1', JSON.stringify(l.out));
+}
+// (c) head -n 2 returns the first 2 piped lines
+{
+ const { m, out } = mk({}, 'alpha\nbeta\ngamma');
+ m.commands.head(['-n', '2']);
+ ok('C1 head -n 2 on piped stdin prints 2 lines', out.length === 1 && out[0] === 'alpha\nbeta', JSON.stringify(out));
+
+ const d = mk({}, 'alpha\nbeta\ngamma');
+ d.m.commands.head([]);
+ ok('C2 head with no operand/number defaults to 10 piped lines', d.out.length === 1 && d.out[0] === 'alpha\nbeta\ngamma', JSON.stringify(d.out));
+}
+// (d) tail -n 1 returns the last piped line
+{
+ const { m, out } = mk({}, 'alpha\nbeta\ngamma');
+ m.commands.tail(['-n', '1']);
+ ok('D1 tail -n 1 on piped stdin prints the last line', out.length === 1 && out[0] === 'gamma', JSON.stringify(out));
+
+ const t = mk({}, 'alpha\nbeta\ngamma');
+ t.m.commands.tail(['-2']);
+ ok('D2 tail -2 on piped stdin prints the last 2 lines', t.out.length === 1 && t.out[0] === 'beta\ngamma', JSON.stringify(t.out));
+
+ const nl = mk({}, 'alpha\nbeta\ngamma\n');
+ nl.m.commands.tail(['-n', '1']);
+ ok('D3 tail -n 1 ignores a trailing newline (no blank line)', nl.out.length === 1 && nl.out[0] === 'gamma', JSON.stringify(nl.out));
+}
+// (e) a real file operand still wins: the file is read and the pipe buffer is ignored
+{
+ const files = { '/probe.txt': 'one\ntwo\nthree\nfour' };
+ const { m, out } = mk(files, 'PIPED\nJUNK\nHERE');
+ m.commands.wc(['-l', '/probe.txt']);
+ ok('E1 wc -l <file> counts the file (4), not stdin', out.length === 1 && out[0].trim() === '4 /probe.txt', JSON.stringify(out));
+
+ const h = mk(files, 'PIPED\nJUNK\nHERE');
+ h.m.commands.head(['-n', '1', '/probe.txt']);
+ ok('E2 head -n 1 <file> prints the file\'s first line', h.out.length === 1 && h.out[0] === 'one', JSON.stringify(h.out));
+
+ const t = mk(files, 'PIPED\nJUNK\nHERE');
+ t.m.commands.tail(['-n', '1', '/probe.txt']);
+ ok('E3 tail -n 1 <file> prints the file\'s last line', t.out.length === 1 && t.out[0] === 'four', JSON.stringify(t.out));
+
+ const d = mk(files, 'PIPED\nJUNK\nHERE');
+ d.m.commands.wc(['/probe.txt']);
+ // Real behaviour (verified identical on the pre-change file): with no flag and a single
+ // file, wc prints the bare counts and no name — the name is only appended when there are
+ // several rows or when a flag was given.
+ ok('E4 wc <file> with no flags keeps its long format (l w c, no name for one file)',
+ d.out.length === 1 && d.out[0] === '4 4 18' && d.out[0].indexOf('PIPED') === -1, JSON.stringify(d.out));
+}
+// (f) no operand and no pipe must still be usage, not a crash or a silent empty read
+{
+ const w = mk({}, null);
+ w.m.commands.wc(['-l']);
+ ok('F1 wc with no file and no stdin prints usage', /^Usage: wc/.test(w.out[0] || ''), JSON.stringify(w.out));
+
+ const h = mk({});
+ h.m.commands.head(['-n', '2']);
+ ok('F2 head with no file and no stdin prints usage', /^Usage: head/.test(h.out[0] || ''), JSON.stringify(h.out));
+
+ const t = mk({}, null);
+ t.m.commands.tail(['-n', '1']);
+ ok('F3 tail with no file and no stdin prints usage', /^Usage: tail/.test(t.out[0] || ''), JSON.stringify(t.out));
+}
+
+console.log(passes + ' passed, ' + fails + ' failed');
+if (fails) process.exit(1);