aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-29 12:53:41 +0100
committerAgando <roduccomm@gmail.com>2026-10-29 12:53:41 +0100
commit6daf236b585dc47ad8ffe293cdefd5de8231c5d8 (patch)
treedf2f7c72def23f4664090689c3a9f54f28d5cb69
parent1a27ba0e2ec736aa9b30c69a3aa2ee2ab86d0709 (diff)
feat(newos4): register `test` / `[` so chains decide locally instead of asking a model (v4.6.135)
`[ -f "/qt.txt" ] && echo exists` — the probe that closed the v4.6.132 batch — was not a shell test at all: both commands were unregistered, so the line fell through to the AI fallback and a model was asked whether a file exists. `_evalTest()` has implemented `-f -d -e -z -n ! = !=` for the if/while parser since the script runner was written; only the command was missing. * `test` and `[` registered in ShellModule. Success resolves silently; false rejects with a `silent` error so `&&`/`||` skip it — POSIX prints nothing on exit 1. * `[` requires the closing bracket (`[ : missing ']'`), and that message is NOT silent. * `test` already names the on-demand regression-suite runner ("test auto" / "test list" in help.html). A argv that does not look like a test expression (`-f`/`-d`/`-e`/`-z`/`-n`/`!`, or `a = b` / `a != b`) is delegated to the handler registered before us, so `test auto` still runs the suite and the two meanings cannot collide. * terminal.dispatchCommand() now honours `err.silent` — without it a top-level `test -f /nope` printed `Error in ShellModule.test:` (measured on v4.6.134, fixed in v4.6.135). * help.html documents the bracket form next to the existing `test` row. Three tags were minted minutes apart — v4.6.133 (commands), v4.6.134 (suite delegation), v4.6.135 (silent top-level) — each a real cache-bust for a fix that superseded the previous build; the net change is this commit. Verification node modules/test/test-shell-test-cmd.js -> 24/24 pass (RED against HEAD: "test command registered" fails, "m.commands.test is not a function") node modules/test/test-pipe-guard.js -> all seven flags true node modules/test/test-parse-args.js -> 28/28 · daily job -> node tests 22/22 pass newos4-maintain.py -> atlas 355 commands (was 354), modules-index 349, help 364/364 live v4.6.135, read from the terminal DOM: [ -f /qt.txt ] && echo F135 -> F135 test -f /nope || echo M135 -> M135 [ -d /nope ] && echo BAD || echo N134-> N134 (false branch skipped, nothing printed) test -> no output (suite not loaded; POSIX false) test -f /nope -> no output js "a b".length | wc -c -> 1 (v4.6.132 pipe fix still intact) The AI fallback is no longer reachable for a bracketed file test.
-rw-r--r--course/atlas.json158
-rw-r--r--help.html1
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js3
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/terminal.js3
-rw-r--r--modules/ext/critical-ext-bundle.js44
-rw-r--r--modules/ext/shell.js44
-rw-r--r--modules/test/test-shell-test-cmd.js98
9 files changed, 321 insertions, 38 deletions
diff --git a/course/atlas.json b/course/atlas.json
index 4eeeff4..341ddb3 100644
--- a/course/atlas.json
+++ b/course/atlas.json
@@ -1,6 +1,6 @@
{
- "version": "2026-10-21T23:46:07Z",
- "source_hash": "fdf2b4d2aaf3dbc0",
+ "version": "2026-10-29T11:53:19Z",
+ "source_hash": "9f5f08a8dd1b7f05",
"commands": {
"a2a-ack": {
"module": "module-agent-registry",
@@ -587,6 +587,15 @@
"builtin": false,
"example": "contract-register"
},
+ "cost": {
+ "module": "module-billing",
+ "file": "modules/core/module-billing.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "cost [on|off] — AI prices, today's free-message allowance and this session's spend",
+ "builtin": false,
+ "example": "cost"
+ },
"course": {
"module": "course",
"file": "modules/ext/course.js",
@@ -938,6 +947,33 @@
"builtin": false,
"example": "factory-reset-old"
},
+ "feedback": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "feedback <message> — Send feedback to the WebX11 maintenance agent",
+ "builtin": false,
+ "example": "feedback"
+ },
+ "feedback-admin": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "feedback-admin — command from module-debug-supervisor",
+ "builtin": false,
+ "example": "feedback-admin"
+ },
+ "feedback-inbox": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "feedback-inbox — Read replies from the maintenance agent (admin session)",
+ "builtin": false,
+ "example": "feedback-inbox"
+ },
"fetch": {
"module": "module-fetcher",
"file": "modules/core/module-fetcher.js",
@@ -1118,6 +1154,15 @@
"builtin": false,
"example": "gui-text"
},
+ "guides": {
+ "module": "module-fs",
+ "file": "modules/core/module-fs.js",
+ "tier": "core-bundle",
+ "category": "filesystem",
+ "desc": "Use trash for recursive dir removal too",
+ "builtin": false,
+ "example": "guides"
+ },
"gunzip": {
"module": "cryptogzip",
"file": "modules/ext/cryptogzip.js",
@@ -1163,6 +1208,33 @@
"builtin": false,
"example": "help"
},
+ "hermes": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "hermes — command from module-debug-supervisor",
+ "builtin": false,
+ "example": "hermes"
+ },
+ "hermes-poll": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "hermes-poll — command from module-debug-supervisor",
+ "builtin": false,
+ "example": "hermes-poll"
+ },
+ "hermes-reset": {
+ "module": "module-debug-supervisor",
+ "file": "modules/core/module-debug-supervisor.js",
+ "tier": "core-bundle",
+ "category": "general",
+ "desc": "hermes-reset — command from module-debug-supervisor",
+ "builtin": false,
+ "example": "hermes-reset"
+ },
"history": {
"module": "terminal",
"file": "modules/core/terminal.js",
@@ -1942,7 +2014,7 @@
"file": "modules/core/module-predictor.js",
"tier": "core-bundle",
"category": "general",
- "desc": "predictor-config <key> <value> — Tune: confidence-threshold, min-data-points",
+ "desc": "predictor-config <key> <value> — Tune: confidence-threshold, min-data-points, pair-ttl",
"builtin": false,
"example": "predictor-config"
},
@@ -1951,7 +2023,7 @@
"file": "modules/core/module-predictor.js",
"tier": "core-bundle",
"category": "general",
- "desc": "predictor-status — Model accuracy, data points, prediction count",
+ "desc": "predictor-status — Paired accuracy, pending predictions, data points",
"builtin": false,
"example": "predictor-status"
},
@@ -2419,7 +2491,7 @@
"file": "modules/ext/skill-pack-core.js",
"tier": "ext-auto",
"category": "ai",
- "desc": "skill-create <name> <desc> \"<steps>\" [tags] — Create a new skill",
+ "desc": "skill-create <name> <desc> <path-or-text> [--tags=a,b] [--category=x] — Create a new skill",
"builtin": false,
"example": "skill-create"
},
@@ -2621,6 +2693,15 @@
"builtin": false,
"example": "tar"
},
+ "test": {
+ "module": "shell",
+ "file": "modules/ext/shell.js",
+ "tier": "critical-ext-bundle",
+ "category": "shell",
+ "desc": "32 automated tests: filesystem, busybox, shell, cryptogzip, skills, security, modules. Load manually with load-local then test",
+ "builtin": false,
+ "example": "test"
+ },
"test-net": {
"module": "module-servermon",
"file": "modules/core/module-servermon.js",
@@ -2748,12 +2829,12 @@
"example": "uptime"
},
"usage": {
- "module": "terminal",
- "file": "modules/core/terminal.js",
+ "module": "module-billing",
+ "file": "modules/core/module-billing.js",
"tier": "core-bundle",
- "category": "system",
- "desc": "usage — command from terminal",
- "builtin": true,
+ "category": "general",
+ "desc": "usage — same as cost",
+ "builtin": false,
"example": "usage"
},
"vaultmind-connect": {
@@ -3166,7 +3247,7 @@
"js-run",
"node"
],
- "lines": 96
+ "lines": 139
},
"course": {
"file": "modules/ext/course.js",
@@ -3232,7 +3313,7 @@
"files",
"preview"
],
- "lines": 1151
+ "lines": 1278
},
"finder": {
"file": "modules/ext/finder.js",
@@ -3296,7 +3377,7 @@
"contract-list",
"contract-register"
],
- "lines": 474
+ "lines": 506
},
"module-aiasm": {
"file": "modules/core/module-aiasm.js",
@@ -3350,7 +3431,16 @@
"vaultmind-status",
"wallet-lookup"
],
- "lines": 4678
+ "lines": 4934
+ },
+ "module-billing": {
+ "file": "modules/core/module-billing.js",
+ "tier": "core-bundle",
+ "commands": [
+ "cost",
+ "usage"
+ ],
+ "lines": 185
},
"module-chatstudio": {
"file": "modules/core/module-chatstudio.js",
@@ -3384,9 +3474,15 @@
"debug-stats",
"debug-sync",
"debug-sync-url",
- "debug-trends"
+ "debug-trends",
+ "feedback",
+ "feedback-admin",
+ "feedback-inbox",
+ "hermes",
+ "hermes-poll",
+ "hermes-reset"
],
- "lines": 890
+ "lines": 1228
},
"module-fetcher": {
"file": "modules/core/module-fetcher.js",
@@ -3410,6 +3506,7 @@
"empty-trash",
"find",
"grep",
+ "guides",
"head",
"ls",
"ls-trash",
@@ -3439,7 +3536,7 @@
"vfs-size",
"wc"
],
- "lines": 823
+ "lines": 982
},
"module-leases": {
"file": "modules/core/module-leases.js",
@@ -3466,7 +3563,7 @@
"memgraph-stats",
"memgraph-status"
],
- "lines": 663
+ "lines": 703
},
"module-modmgr": {
"file": "modules/core/module-modmgr.js",
@@ -3553,7 +3650,7 @@
"orchestrator-wake",
"pipeline"
],
- "lines": 1442
+ "lines": 1485
},
"module-predictor": {
"file": "modules/core/module-predictor.js",
@@ -3563,7 +3660,7 @@
"predictor-config",
"predictor-status"
],
- "lines": 282
+ "lines": 509
},
"module-sandbox": {
"file": "modules/core/module-sandbox.js",
@@ -3656,7 +3753,7 @@
"uptime",
"version"
],
- "lines": 815
+ "lines": 834
},
"module-world-model": {
"file": "modules/core/module-world-model.js",
@@ -3668,7 +3765,7 @@
"world-model-refresh",
"world-model-summary"
],
- "lines": 600
+ "lines": 647
},
"secret-vault": {
"file": "modules/core/secret-vault.js",
@@ -3693,9 +3790,10 @@
"script",
"set",
"sh",
+ "test",
"unset"
],
- "lines": 627
+ "lines": 956
},
"skill-pack-core": {
"file": "modules/ext/skill-pack-core.js",
@@ -3716,7 +3814,7 @@
"skill-view",
"skill-webdav-sync"
],
- "lines": 1150
+ "lines": 1334
},
"theme": {
"file": "modules/ext/theme.js",
@@ -3770,7 +3868,7 @@
"webdav-rm",
"webdav-status"
],
- "lines": 2368
+ "lines": 2450
},
"wpm": {
"file": "modules/ext/wpm.js",
@@ -3782,12 +3880,12 @@
}
},
"coverage": {
- "commands": 346,
- "described": 346,
- "modules": 47,
+ "commands": 355,
+ "described": 355,
+ "modules": 48,
"tiers": {
- "core-bundle": 258,
- "critical-ext-bundle": 28,
+ "core-bundle": 266,
+ "critical-ext-bundle": 29,
"ext-auto": 60,
"ext-manual": 0
}
diff --git a/help.html b/help.html
index 33c6601..b2caf27 100644
--- a/help.html
+++ b/help.html
@@ -525,6 +525,7 @@ diff a.txt b.txt</pre>
<tr><td><code>checkpoint-del</code></td><td>Delete a checkpoint</td><td><code>checkpoint-del</code></td></tr>
<tr><td><code>smart-prompt</code></td><td>Toggle context-aware prompt</td><td><code>smart-prompt</code></td></tr>
<tr><td><code>test</code></td><td>Run test suite</td><td><code>test auto</code></td></tr>
+ <tr><td><code>[ … ]</code> / <code>test …</code></td><td>POSIX file and string test for chains — silent, combines with <code>&amp;&amp;</code> / <code>||</code>. Operators: <code>-f</code> <code>-d</code> <code>-e</code> <code>-z</code> <code>-n</code> <code>!</code> <code>=</code> <code>!=</code>. The expression form of <code>test</code> is routed to this; <code>test</code> with no expression still runs the suite.</td><td><code>[ -f /notes.txt ] &amp;&amp; echo yes</code></td></tr>
</table></div>
<h3>AI Assistant</h3>
diff --git a/main.html b/main.html
index 66c1d76..6b1aed4 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.132"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.135"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.132"></script>
+<script src="modules/core/module-loader.js?v=v4.6.135"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.132"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.135"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index b070e65..07609e3 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -4553,6 +4553,9 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}
return; }
catch (err) {
+ // v4.6.134: `silent` errors are control flow, not failures — `test`/`[`
+ // reject to tell `&&`/`||` to skip, and must print nothing (POSIX).
+ if (err && err.silent) return;
this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error');
console.error(`[Terminal] Error in ${modName}.${cmd}:`, err);
return;
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index 0d39123..52cee36 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.132';
+ const LATEST_VER = 'v4.6.135';
const CACHE = '?v=' + LATEST_VER;
// Exposed so ext modules can register their own URL with the same cache-busting version
// instead of an unversioned path the browser may already have cached (shell.js did).
diff --git a/modules/core/terminal.js b/modules/core/terminal.js
index 4122b3c..ca6bb61 100644
--- a/modules/core/terminal.js
+++ b/modules/core/terminal.js
@@ -1961,6 +1961,9 @@
}
return; }
catch (err) {
+ // v4.6.134: `silent` errors are control flow, not failures — `test`/`[`
+ // reject to tell `&&`/`||` to skip, and must print nothing (POSIX).
+ if (err && err.silent) return;
this.print(`Error in ${modName}.${cmd}: ${err.message}`, 'error');
console.error(`[Terminal] Error in ${modName}.${cmd}:`, err);
return;
diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js
index 0f2e1b9..53f1205 100644
--- a/modules/ext/critical-ext-bundle.js
+++ b/modules/ext/critical-ext-bundle.js
@@ -170,6 +170,46 @@ class ShellModule {
this.commands['script'] = (args) => this._cmdScript(args);
this.commands['sh'] = (args) => this._cmdScript(args);
this.commands['set'] = () => this._cmdSet();
+ // v4.6.133: `test` / `[` were not registered, so `[ -f /x ] && echo yes` fell through to the
+ // AI fallback and asked a model whether a file exists. _evalTest() already implemented the
+ // operators (-f -d -e -z -n ! = !=) for the if/while parser; the command was simply missing.
+ // `test` already means "run the regression suite" in the on-demand test-suite module, so a
+ // non-expression argument list is delegated to whatever handler was registered before us.
+ const prevTest = this.commands['test'];
+ this.commands['test'] = (args) => this._cmdTest(args, 'test', prevTest);
+ this.commands['['] = (args) => this._cmdTest(args, '[');
+ }
+
+ /** True when the argv looks like a POSIX test expression rather than a suite-runner subcommand. */
+ _looksLikeTest(expr) {
+ if (!expr) return false;
+ if (/^(?:!(?:\s|$)|-[fderwxszn]\b)/.test(expr)) return true; // `-f x`, `! -d y`
+ if (/\s(?:=|==|!=)\s/.test(expr)) return true; // `a = b`
+ return false;
+ }
+
+ /** POSIX-ish `test`/`[`: silent on success, throws a silent error on false so `&&`/`||` skip. */
+ _cmdTest(args, name, fallback) {
+ let expr = (args || []).join(' ').trim();
+ if (name === 'test' && !this._looksLikeTest(expr) && typeof fallback === 'function') {
+ return fallback(args); // `test`, `test auto`, `test list` -> the suite runner
+ }
+ if (name === '[') {
+ if (!expr.endsWith(']')) throw new Error("[ : missing ']'");
+ expr = expr.slice(0, -1).trim();
+ }
+ if (!expr) { // `test` with no expression: false, and no output
+ const e = new Error(''); e.silent = true; throw e;
+ }
+ if (!this._evalTest(expr)) {
+ const e = new Error(''); e.silent = true; throw e; // POSIX: exit 1 prints nothing
+ }
+ }
+
+ /** Error printer shared by the shell paths: `silent` errors are control flow (a false test), not failures. */
+ _printErr(term, e) {
+ if (!e || e.silent) return;
+ (term || this.terminal).print('Error: ' + (e.message || e), 'error');
}
// ── Hook into terminal.executeCommand ──
@@ -259,7 +299,7 @@ class ShellModule {
await self.commands[cmd](args);
lastOk = true;
} catch(e) {
- this.print('Error: ' + e.message, 'error');
+ self._printErr(this, e);
lastOk = false;
}
} else {
@@ -267,7 +307,7 @@ class ShellModule {
await this.security.checkAndRun(expandedCmd);
lastOk = true;
} catch(e) {
- this.print('Error: ' + e.message, 'error');
+ self._printErr(this, e);
lastOk = false;
}
}
diff --git a/modules/ext/shell.js b/modules/ext/shell.js
index cb26484..ff07d92 100644
--- a/modules/ext/shell.js
+++ b/modules/ext/shell.js
@@ -34,6 +34,46 @@ class ShellModule {
this.commands['script'] = (args) => this._cmdScript(args);
this.commands['sh'] = (args) => this._cmdScript(args);
this.commands['set'] = () => this._cmdSet();
+ // v4.6.133: `test` / `[` were not registered, so `[ -f /x ] && echo yes` fell through to the
+ // AI fallback and asked a model whether a file exists. _evalTest() already implemented the
+ // operators (-f -d -e -z -n ! = !=) for the if/while parser; the command was simply missing.
+ // `test` already means "run the regression suite" in the on-demand test-suite module, so a
+ // non-expression argument list is delegated to whatever handler was registered before us.
+ const prevTest = this.commands['test'];
+ this.commands['test'] = (args) => this._cmdTest(args, 'test', prevTest);
+ this.commands['['] = (args) => this._cmdTest(args, '[');
+ }
+
+ /** True when the argv looks like a POSIX test expression rather than a suite-runner subcommand. */
+ _looksLikeTest(expr) {
+ if (!expr) return false;
+ if (/^(?:!(?:\s|$)|-[fderwxszn]\b)/.test(expr)) return true; // `-f x`, `! -d y`
+ if (/\s(?:=|==|!=)\s/.test(expr)) return true; // `a = b`
+ return false;
+ }
+
+ /** POSIX-ish `test`/`[`: silent on success, throws a silent error on false so `&&`/`||` skip. */
+ _cmdTest(args, name, fallback) {
+ let expr = (args || []).join(' ').trim();
+ if (name === 'test' && !this._looksLikeTest(expr) && typeof fallback === 'function') {
+ return fallback(args); // `test`, `test auto`, `test list` -> the suite runner
+ }
+ if (name === '[') {
+ if (!expr.endsWith(']')) throw new Error("[ : missing ']'");
+ expr = expr.slice(0, -1).trim();
+ }
+ if (!expr) { // `test` with no expression: false, and no output
+ const e = new Error(''); e.silent = true; throw e;
+ }
+ if (!this._evalTest(expr)) {
+ const e = new Error(''); e.silent = true; throw e; // POSIX: exit 1 prints nothing
+ }
+ }
+
+ /** Error printer shared by the shell paths: `silent` errors are control flow (a false test), not failures. */
+ _printErr(term, e) {
+ if (!e || e.silent) return;
+ (term || this.terminal).print('Error: ' + (e.message || e), 'error');
}
// ── Hook into terminal.executeCommand ──
@@ -123,7 +163,7 @@ class ShellModule {
await self.commands[cmd](args);
lastOk = true;
} catch(e) {
- this.print('Error: ' + e.message, 'error');
+ self._printErr(this, e);
lastOk = false;
}
} else {
@@ -131,7 +171,7 @@ class ShellModule {
await this.security.checkAndRun(expandedCmd);
lastOk = true;
} catch(e) {
- this.print('Error: ' + e.message, 'error');
+ self._printErr(this, e);
lastOk = false;
}
}
diff --git a/modules/test/test-shell-test-cmd.js b/modules/test/test-shell-test-cmd.js
new file mode 100644
index 0000000..6abdd11
--- /dev/null
+++ b/modules/test/test-shell-test-cmd.js
@@ -0,0 +1,98 @@
+// node modules/test/test-shell-test-cmd.js
+//
+// `test` / `[` were not registered, so `[ -f /x ] && echo yes` fell through to the AI fallback and
+// asked a model whether a file exists (measured 2026-10-29 on v4.6.132). _evalTest() already had the
+// operators; the command was missing. Contract pinned here:
+// success -> resolves silently
+// false -> rejects with a `silent` error (so `&&`/`||` skip without printing "Error: ")
+// `[` -> requires the closing bracket, never delegates
+// `test` -> a non-POSIX argv still reaches the regression-suite runner registered before it
+const fs = require('fs'), path = require('path');
+// shell.js ends with `if (typeof window.webx11 !== 'undefined' && window.webx11) { … }` — and
+// `typeof window.webx11` throws when `window` itself is undeclared, so give it an empty object.
+global.window = {};
+const src = fs.readFileSync(path.join(__dirname, '../ext/shell.js'), 'utf8');
+const ShellModule = new Function(src + '\n;return typeof ShellModule !== "undefined" ? ShellModule : null;')();
+
+let pass = 0, fail = 0;
+const ok = (name, cond, detail) => { cond ? pass++ : (fail++, console.log('FAIL ' + name + (detail ? ' — ' + detail : ''))); };
+const eq = (name, got, want) => ok(name, JSON.stringify(got) === JSON.stringify(want), 'got ' + JSON.stringify(got) + ' want ' + JSON.stringify(want));
+
+function mk(filesArg) {
+ const printed = [];
+ const files = filesArg || { '/a.txt': 'x', '/dir': null }; // null => directory
+ const term = {
+ print: (m, lvl) => printed.push({ m: String(m), lvl: lvl || 'plain' }),
+ markDirty: () => {},
+ fs: {
+ currentPath: [],
+ readFile: p => (typeof files[p] === 'string' ? files[p] : null),
+ readDir: p => (p in files && files[p] === null ? [] : null)
+ },
+ // A real (simple) parser: the assertions prove _tokenize delegates by counting the calls.
+ security: { calls: 0, _parseArgs: function (s) { this.calls++; return s.trim() ? s.trim().split(/\s+/) : []; } }
+ };
+ const m = new ShellModule();
+ m.terminal = term;
+ m.registerCommands();
+ return { m, term, printed };
+}
+
+const throws = async (fn) => { try { await fn(); return null; } catch (e) { return e; } };
+
+(async () => {
+ const { m, term, printed } = mk();
+
+ ok('test command registered', typeof m.commands['test'] === 'function');
+ ok('[ command registered', typeof m.commands['['] === 'function');
+
+ ok('test -f existing file -> resolves', await throws(() => m.commands['test'](['-f', '/a.txt'])) === null);
+
+ const missing = await throws(() => m.commands['test'](['-f', '/nope']));
+ ok('test -f missing file -> rejects', !!missing);
+ ok('rejection is silent', !!missing && missing.silent === true, 'silent=' + (missing && missing.silent));
+ ok('rejection carries no message', !!missing && !missing.message, 'msg=' + JSON.stringify(missing && missing.message));
+
+ ok('[ -f /a.txt ] -> resolves', await throws(() => m.commands['['](['-f', '/a.txt', ']'])) === null);
+
+ const noBracket = await throws(() => m.commands['['](['-f', '/a.txt']));
+ ok('[ without ] -> rejects', !!noBracket && /missing '\]'/.test(noBracket.message), noBracket && noBracket.message);
+ ok('[ without ] is NOT silent (a syntax error must be visible)', !!noBracket && !noBracket.silent);
+
+ ok('test -d dir -> resolves', await throws(() => m.commands['test'](['-d', '/dir'])) === null);
+ ok('test -d file -> rejects', !!(await throws(() => m.commands['test'](['-d', '/a.txt']))));
+
+ const noExpr = await throws(() => m.commands['test']([]));
+ ok('test with no fallback and no args -> silent reject (POSIX false)', !!noExpr && noExpr.silent === true);
+
+ // shared printer: silent errors print nothing, real ones print "Error: …"
+ printed.length = 0;
+ m._printErr(term, Object.assign(new Error(''), { silent: true }));
+ ok('_printErr swallows silent errors', printed.length === 0, JSON.stringify(printed));
+ m._printErr(term, new Error('boom'));
+ eq('_printErr prints real errors', printed, [{ m: 'Error: boom', lvl: 'error' }]);
+
+ // _tokenize must delegate to the security parser, never to split(/\s+/) of its own
+ const before = term.security.calls;
+ eq('_tokenize delegates to security._parseArgs', m._tokenize('anything at all'), ['anything', 'at', 'all']);
+ ok('_tokenize actually called the parser', term.security.calls > before, 'calls=' + term.security.calls);
+ eq('_tokenize empty -> []', m._tokenize(' '), []);
+ eq('_tokenize undefined -> []', m._tokenize(undefined), []);
+
+ // `test` also names the on-demand regression-suite runner ("test auto" / "test list").
+ const { m: d } = mk();
+ const seen = [];
+ const prev = d.commands['test'];
+ d.commands['test'] = (args) => d._cmdTest(args, 'test', (a) => { seen.push(a); return 'suite'; });
+ ok('previous handler is what delegation falls back to', typeof prev === 'function');
+
+ ok('test auto delegates to the suite runner', (await d.commands['test'](['auto'])) === 'suite' && JSON.stringify(seen) === '[["auto"]]', JSON.stringify(seen));
+ ok('bare test delegates to the suite runner', (await d.commands['test']([])) === 'suite' && seen.length === 2, JSON.stringify(seen));
+ ok('test -f still runs POSIX (no delegation)', (await throws(() => d.commands['test'](['-f', '/a.txt']))) === null && seen.length === 2, 'seen=' + JSON.stringify(seen));
+ ok('string equality form runs POSIX', (await throws(() => d.commands['test'](['/a.txt', '=', '/a.txt']))) === null && seen.length === 2, 'seen=' + JSON.stringify(seen));
+ const t2 = await throws(() => d.commands['['](['-f', '/nope', ']']));
+ ok('[ never delegates', !!t2 && t2.silent === true, JSON.stringify(seen));
+
+ console.log(pass + '/' + (pass + fail) + ' pass');
+ process.exit(fail ? 1 : 0);
+})();