diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-27 16:23:21 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-27 16:23:21 +0100 |
| commit | f325b6d15179bb02147039c89e9fcfac6d8e067b (patch) | |
| tree | bdd839cbed1efa77c0b896c6a3511d1b575f41e1 /modules/test/test-shell-stdin.js | |
| parent | 2e9401a5587b0a45daaf0af116bb7da573b0988d (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/test-shell-stdin.js')
| -rw-r--r-- | modules/test/test-shell-stdin.js | 115 |
1 files changed, 115 insertions, 0 deletions
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); |