summaryrefslogtreecommitdiff
path: root/modules/test
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-09 23:58:35 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-09 23:58:35 +0100
commit9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 (patch)
treed3f3a525e8594db010bbf0e2aa6e4c2590445339 /modules/test
parent8e3a6155f6a5a1266ff1fa801c8864484bcdbda9 (diff)
fix(newos4): six crash classes from the feedback queue (v4.6.158)
Each item is symptom -> cause -> fix, re-verified against the served bytes. 1. debug-ai panel threw "Cannot read properties of undefined (reading 'substring')" (RPT-20261108-231622). _updateDebugPanel and two sibling previews called JSON.stringify(entry.args).substring(); JSON.stringify(undefined) returns undefined, and _toolCallLog entries carry args: undefined for any tool call with no arguments. All three previews now go through _formatToolArgs(), which renders "null" and never throws. 2. test_module threw "Cannot convert undefined or null to object" (RPT-20261108-232127). Object.keys(instance.commands) assumed every module initialises a command map. The throw landed after loadModuleFromCode had already succeeded, so the rolledBack:true branches were skipped and an unverified module stayed loaded. Both sites now require instance.commands. 3. list_files threw "originalPath.replace is not a function" (RPT-20261109-172742, 3 occurrences in errors.json). A model can send a non-string path despite the schema. The path family (list_files/read_file/write_file/make_directory/ delete_file) now coerces through _pathArg() and answers the tool's own honest error instead of a TypeError. 4. wpm search failed for EVERY keyword (RPT-20261109-123907): packages.json entry `kanban` used `description` while the other 31 use `desc`, and search() called pkg.desc.toLowerCase() mid-loop. Catalog reads now go through _pkg() / _packageMap() (desc || description, values coerced to strings), and the kanban entry carries name/version/desc/commands. Re-running the original loop for pdf/git/zzz is clean. 5. wx/webxai/webx turned a chat-lock timeout into an unhandled rejection (RPT-20261107-150726): forwardToAI() called chat(args) in a synchronous function without returning or catching the promise, so the 10 s busy throw never reached dispatchCommand's catch. It now returns the promise with a .catch that prints "AI error: ..." through terminal.print — console.error is hooked by the debug supervisor and would re-file the same report. 6. CryptoGzip._base64ToBytes threw a raw DOMException on non-base64 input (RPT-20261102-203616). It now validates first and throws Error("not valid base64 input (<reason>)"). The duplicate shadowing definitions at the bottom of the class were deleted — the later copy is the one that carried the unguarded atob(). Also regenerated modules/core/core-bundle.js (generated, never hand-edited) and bumped the build v4.6.157 -> v4.6.158 (main.html x3 + module-loader.js). Verified: 35/35 node tests, including 6 new regression tests each proven red against the pre-fix source; an independent orchestrator harness 36/36; and newos4-maintain.py --fix all PASS after regenerating the stale changelog page. Served bytes are byte-identical for the bundle, main.html and both ext modules, and a live headless-Chromium run against v4.6.158 shows WpmModule.search('pdf') returning 1 result, the debug panel rendering a no-arg entry as "null", _base64ToBytes('grüße') raising a plain Error that names base64, and 0 JS errors on load. m.pull (RPT-20261102-211438) is closed as a usage error: no module in the tree defines pull(); the real interfaces are the webdav-pull and model-pull commands.
Diffstat (limited to 'modules/test')
-rw-r--r--modules/test/test-ai-path-args.js143
-rw-r--r--modules/test/test-ai-test-module-commands.js105
-rw-r--r--modules/test/test-aiasm-rejection.js95
-rw-r--r--modules/test/test-cryptogzip-guard.js136
-rw-r--r--modules/test/test-debug-args-preview.js112
-rw-r--r--modules/test/test-wpm-index.js164
6 files changed, 755 insertions, 0 deletions
diff --git a/modules/test/test-ai-path-args.js b/modules/test/test-ai-path-args.js
new file mode 100644
index 0000000..878f6b0
--- /dev/null
+++ b/modules/test/test-ai-path-args.js
@@ -0,0 +1,143 @@
+// node modules/test/test-ai-path-args.js
+// Regression for the filed report "originalPath.replace is not a function"
+// (data/debug/errors.json, 3 occurrences, last 2026-11-09T19:46Z).
+// The tool schema says path is a string, but a model can send a number/array/
+// object. The old `const p = a.path || ''` handed that straight to .replace()/
+// .startsWith(), which threw inside runTool and turned every such call into a
+// TypeError instead of a usable answer. _pathArg() coerces first.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
+global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } };
+global.navigator = global.navigator || {};
+
+const load = (file, cls) => new Function(fsMod.readFileSync(path.join(ROOT, file), 'utf8') + '\nreturn ' + cls + ';')();
+const VFS = load('virtual-fs.js', 'VirtualFileSystem');
+const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+
+function mk() {
+ const out = [];
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true;
+ const ai = Object.create(AI.prototype); // skip the DOM-touching constructor
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {},
+ fs: vfs, moduleManager: { modules: {} }
+ };
+ ai.checkGuardrail = () => null;
+ ai._errorLog = [];
+ ai._toolCallLog = [];
+ ai._toolCache = new Map();
+ ai._toolEpoch = 0;
+ ai._currentTag = 'main';
+ ai._toolLimit = () => 8000;
+ return { ai, vfs, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// The old defect surfaced through runTool's catch-all as
+// {"error":"originalPath.replace is not a function"} — so "did not throw" alone
+// is not enough; the result must also be free of that TypeError.
+function parse(label, raw) {
+ let out = null, threw = null;
+ try { out = JSON.parse(raw); } catch (e) { threw = e; }
+ ok(label + ': returned a JSON string', threw === null && out !== null, threw ? threw.message : String(raw).slice(0, 200));
+ ok(label + ': no string-method TypeError leaked',
+ typeof raw === 'string' && !/is not a function/.test(raw), String(raw).slice(0, 200));
+ return out;
+}
+function isEntriesOrError(v) {
+ return Array.isArray(v) || (v && typeof v === 'object' && typeof v.error === 'string');
+}
+const call = async (ai, name, args) => {
+ let res, threw = null;
+ try { res = await ai.runTool(name, args); } catch (e) { threw = e; }
+ ok(name + ' ' + JSON.stringify(args) + ': runTool did not throw', threw === null, threw && threw.message);
+ return res;
+};
+
+(async () => {
+ // A. list_files with a numeric path
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: 123 }));
+ const v = parse('list_files path:123', res);
+ ok('list_files path:123 is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // B. list_files with an object path
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: {} }));
+ const v = parse('list_files path:{}', res);
+ ok('list_files path:{} is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // C. list_files with an explicitly undefined path (JSON.stringify drops the
+ // key, so this must behave like "list the current directory")
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', JSON.stringify({ path: undefined }));
+ const v = parse('list_files path:undefined', res);
+ ok('list_files path:undefined still lists the current dir', Array.isArray(v), String(res).slice(0, 200));
+ }
+
+ // D. an object passed directly (runTool accepts an object as argsStr)
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'list_files', { path: [1, 2] });
+ const v = parse('list_files path:[1,2]', res);
+ ok('list_files path:[1,2] is an entry list or {error}', isEntriesOrError(v), String(res).slice(0, 200));
+ }
+
+ // E. read_file with a numeric path must answer "not found", not TypeError
+ {
+ const { ai } = mk();
+ const res = await call(ai, 'read_file', JSON.stringify({ path: 123 }));
+ const v = parse('read_file path:123', res);
+ ok('read_file path:123 reports File not found', !!v && /File not found/.test(String(v.error)), String(res).slice(0, 200));
+ }
+
+ // F. the other path-family tools must survive the same input
+ for (const tool of ['delete_file', 'make_directory', 'write_file']) {
+ const { ai } = mk();
+ const args = tool === 'write_file' ? { path: 123, content: 'x' } : { path: 123 };
+ const res = await call(ai, tool, JSON.stringify(args));
+ ok(tool + ' path:123 has no string-method TypeError',
+ typeof res === 'string' && !/is not a function/.test(res), String(res).slice(0, 200));
+ }
+
+ // G. control: a real string path still works exactly as before
+ {
+ const { ai, vfs } = mk();
+ vfs.mkdirRecursive('/demo');
+ vfs.writeFile('/demo/keep.txt', 'hello');
+ const res = await call(ai, 'list_files', JSON.stringify({ path: '/demo' }));
+ const v = parse('list_files path:/demo', res);
+ ok('string path still lists entries', Array.isArray(v) && v.some(e => e.name === 'keep.txt'),
+ String(res).slice(0, 200));
+ const read = await call(ai, 'read_file', JSON.stringify({ path: '/demo/keep.txt' }));
+ const rv = parse('read_file path:/demo/keep.txt', read);
+ ok('string path still reads content', !!rv && rv.content === 'hello', String(read).slice(0, 200));
+ }
+
+ console.log('test-ai-path-args: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-ai-test-module-commands.js b/modules/test/test-ai-test-module-commands.js
new file mode 100644
index 0000000..9683011
--- /dev/null
+++ b/modules/test/test-ai-test-module-commands.js
@@ -0,0 +1,105 @@
+// node modules/test/test-ai-test-module-commands.js
+// test_module used `oldInstance ? Object.keys(oldInstance.commands) : []`. A module
+// that has no command map (or whose reloaded instance does not re-register one)
+// made Object.keys throw "Cannot convert undefined or null to object". The throw
+// happened AFTER loadModuleFromCode() succeeded, so runTool's catch returned an
+// error and the rollback branches were skipped: the unverified module stayed
+// loaded. The guards keep the rollback path reachable.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+global.document = { getElementById: () => null, createElement: () => ({ style: {} }), body: { appendChild() {}, removeChild() {} } };
+global.navigator = global.navigator || {};
+
+const AI = new Function(fsMod.readFileSync(SRC, 'utf8') + '\nreturn WebX11AIAssistantModule;')();
+
+// `next` is what loadModuleFromCode installs as the reloaded instance.
+function mk(oldInstance, next) {
+ const out = [];
+ const modules = { Demo: oldInstance };
+ const ai = Object.create(AI.prototype);
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {},
+ fs: { readFile: () => '// demo module source', currentPath: [] },
+ moduleManager: {
+ modules,
+ loadModuleFromCode: async (code, url) => { modules.Demo = next; return true; }
+ }
+ };
+ ai.checkGuardrail = () => null;
+ ai._errorLog = [];
+ ai._toolCallLog = [];
+ ai._captureConsoleErrors = async () => [];
+ ai._captureCommandOutput = async () => 'ok';
+ ai._captureShellCommand = async () => 'ok';
+ return { ai, modules, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+(async () => {
+ // A. reloaded instance lost its command map: must report + roll back,
+ // not throw and leave the bad instance installed.
+ {
+ const oldInstance = { commands: { 'demo-a': {}, 'demo-b': {} } };
+ const next = { /* no commands property at all */ };
+ const { ai, modules } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('A1 result is JSON', res !== null, String(raw).slice(0, 200));
+ ok('A2 reports Commands lost after edit', !!res && res.error === 'Commands lost after edit',
+ String(raw).slice(0, 200));
+ ok('A3 lists both lost commands', !!res && Array.isArray(res.lost) && res.lost.length === 2,
+ JSON.stringify(res && res.lost));
+ ok('A4 marks rolledBack', !!res && res.rolledBack === true, String(raw).slice(0, 200));
+ ok('A5 old instance restored', modules.Demo === oldInstance, 'modules.Demo is ' + (modules.Demo === next ? 'the new instance' : 'neither'));
+ ok('A6 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)),
+ String(raw).slice(0, 200));
+ }
+
+ // B. the OLD instance itself has no command map: Object.keys(old.commands)
+ // used to throw before the new module was even loaded.
+ {
+ const oldInstance = { /* no commands */ };
+ const next = { commands: { 'demo-a': {} } };
+ const { ai } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('B1 does not throw on a command-less old instance', res !== null, String(raw).slice(0, 200));
+ ok('B2 reports success (nothing was lost)', !!res && res.success === true, String(raw).slice(0, 200));
+ ok('B3 no Object.keys TypeError', !/Cannot convert undefined or null to object/.test(String(raw)),
+ String(raw).slice(0, 200));
+ }
+
+ // C. control: unchanged command map still passes the verification
+ {
+ const oldInstance = { commands: { 'demo-a': {} } };
+ const next = { commands: { 'demo-a': {} } };
+ const { ai, modules } = mk(oldInstance, next);
+ const raw = await ai.runTool('test_module', JSON.stringify({ name: 'Demo' }));
+ let res = null;
+ try { res = JSON.parse(raw); } catch (e) {}
+ ok('C1 success', !!res && res.success === true, String(raw).slice(0, 200));
+ ok('C2 new instance stays loaded', modules.Demo === next);
+ }
+
+ console.log('test-ai-test-module-commands: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-aiasm-rejection.js b/modules/test/test-aiasm-rejection.js
new file mode 100644
index 0000000..d6e18f6
--- /dev/null
+++ b/modules/test/test-aiasm-rejection.js
@@ -0,0 +1,95 @@
+// node modules/test/test-aiasm-rejection.js
+// test-aiasm-rejection — the wx / webxai / webx aliases must not turn the AI chat-lock timeout
+// into an unhandled promise rejection (RPT-20261107-150726-bf727247).
+//
+// Why this file exists: forwardToAI() used to call the assistant's chat(args) with no await and no
+// .catch() inside a non-async function. When the assistant was already mid-turn, chat() waited
+// _chatLockTimeoutMs (10s) and then rejected with "AI is still busy with another turn…". Nothing
+// was attached to that promise, so it surfaced on window.unhandledrejection and the debug
+// supervisor auto-filed a bug report for what is normal "the user typed twice" UX.
+const fs = require('fs');
+const path = require('path');
+
+const SRC = path.join(__dirname, '..', 'core', 'module-aiasm.js');
+const Alias = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn WebXAIAliasModule;')();
+
+const BUSY = 'AI is still busy with another turn (waited 10s). Wait for it to finish, or run '
+ + "'ai-stop' and retry — the running turn's lock is left alone.";
+
+const unhandled = [];
+process.on('unhandledRejection', (e) => unhandled.push(e));
+
+let passes = 0, fails = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+function mk(chatImpl) {
+ const printed = [];
+ const term = {
+ print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }),
+ moduleManager: { modules: { WebX11AIAssistantModule: chatImpl ? { chat: chatImpl } : null } },
+ ollamaManager: { chat: () => Promise.resolve('ok') }
+ };
+ const m = new Alias();
+ m.terminal = term;
+ m.registerCommands();
+ return { m, printed, term };
+}
+
+const tick = (ms) => new Promise(r => setTimeout(r, ms || 25));
+
+(async () => {
+ // A1-A5: the busy rejection is printed, the returned promise settles, nothing escapes
+ const a = mk(() => Promise.reject(new Error(BUSY)));
+ const r = a.m.forwardToAI(['hi']);
+ ok('A1 forwardToAI returns a promise (callers can await it)', !!r && typeof r.then === 'function', typeof r);
+ let awaited;
+ try { awaited = await r; } catch (e) { awaited = 'REJECTED:' + e.message; }
+ ok('A2 the returned promise settles instead of rejecting', String(awaited).indexOf('REJECTED') !== 0, String(awaited));
+ const errLine = a.printed.find(p => /^AI error: /.test(p.msg));
+ ok('A3 the busy timeout is printed to the terminal', !!errLine && /still busy/.test(errLine.msg),
+ JSON.stringify(a.printed));
+ ok('A4 it is printed as an error line', !!errLine && errLine.kind === 'error', errLine && errLine.kind);
+ await tick();
+ ok('A5 no unhandledRejection escaped', unhandled.length === 0,
+ unhandled.map(e => (e && e.message) || String(e)).join('; '));
+
+ // A6-A7: the same through the command map the terminal dispatcher actually awaits
+ const b = mk(() => Promise.reject(new Error(BUSY)));
+ let cmdErr = null;
+ try { await b.m.commands['wx'](['second question']); } catch (e) { cmdErr = e; }
+ ok('A6 `wx` command does not reject', !cmdErr, cmdErr && cmdErr.message);
+ await tick();
+ ok('A7 `wx` prints the busy error', b.printed.some(p => /AI error: .*still busy/.test(p.msg)),
+ JSON.stringify(b.printed));
+
+ // A8-A10: happy path — a resolving chat must print nothing at all
+ const c = mk(() => Promise.resolve({ role: 'assistant', content: 'pong' }));
+ let res;
+ try { res = await c.m.forwardToAI(['ping']); } catch (e) { res = 'REJECTED:' + e.message; }
+ await tick();
+ ok('A8 a resolving chat prints nothing', c.printed.length === 0, JSON.stringify(c.printed));
+ ok('A9 happy path does not throw', String(res).indexOf('REJECTED') !== 0, String(res));
+ ok('A10 happy path leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
+
+ // A11-A12: ollama fallback (no assistant module) is handled the same way
+ const d = mk(null);
+ d.term.ollamaManager.chat = () => Promise.reject(new Error('ollama offline'));
+ await d.m.forwardToAI(['hi']);
+ await tick();
+ ok('A11 ollama fallback failure is printed', d.printed.some(p => /AI error: ollama offline/.test(p.msg)),
+ JSON.stringify(d.printed));
+ ok('A12 ollama fallback leaks no unhandled rejection', unhandled.length === 0, String(unhandled.length));
+
+ // A13: usage is still printed when the question is missing
+ const e = mk(null);
+ e.m.forwardToAI([]);
+ ok('A13 usage printed when args are empty', e.printed.some(p => /Usage: webxai/.test(p.msg)),
+ JSON.stringify(e.printed));
+
+ console.log('test-aiasm-rejection: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-cryptogzip-guard.js b/modules/test/test-cryptogzip-guard.js
new file mode 100644
index 0000000..14f50d3
--- /dev/null
+++ b/modules/test/test-cryptogzip-guard.js
@@ -0,0 +1,136 @@
+// node modules/test/test-cryptogzip-guard.js
+// test-cryptogzip-guard — CryptoGzipModule._base64ToBytes must reject non-base64 input with a
+// plain, actionable Error, not a raw DOMException out of atob() (RPT-20261102-203616-e2641bff:
+// "Failed to execute 'atob' on 'Window': The string to be decoded contains characters outside of
+// the Latin1 range"), which escaped as an unhandledrejection when called from the node console.
+//
+// It also pins the happy path byte-for-byte (including unpadded base64 and newline-wrapped
+// base64, both of which atob already accepted) and the two gunzip callers.
+const fs = require('fs');
+const path = require('path');
+const zlib = require('zlib');
+
+global.window = {}; // the file's auto-registration tail is window-guarded
+
+const SRC = path.join(__dirname, '..', 'ext', 'cryptogzip.js');
+const CryptoGzipModule = new Function(fs.readFileSync(SRC, 'utf8') + '\nreturn CryptoGzipModule;')();
+
+let passes = 0, fails = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+const m = new CryptoGzipModule();
+const isDomException = (e) => typeof DOMException === 'function' && e instanceof DOMException;
+
+function mkTerm(files) {
+ const store = Object.assign({}, files || {});
+ const printed = [];
+ const term = {
+ print: (msg, kind) => printed.push({ msg: String(msg), kind: kind || '' }),
+ fs: {
+ readFile: (p) => (p in store ? store[p] : null),
+ writeFile: (p, c) => { store[p] = c; return true; },
+ deleteFile: (p) => { delete store[p]; },
+ readDir: () => []
+ },
+ inputCallback: null,
+ _onCommand: null
+ };
+ const mod = new CryptoGzipModule();
+ mod.terminal = term;
+ mod.registerCommands();
+ return { mod, term, printed, store };
+}
+
+(async () => {
+ // C1-C2: happy path
+ const hello = m._base64ToBytes('aGVsbG8=');
+ ok('C1 decodes base64 to the right bytes', Buffer.from(hello).toString() === 'hello',
+ Buffer.from(hello).toString());
+ ok('C2 returns a 5-byte Uint8Array', hello instanceof Uint8Array && hello.length === 5,
+ hello.constructor && hello.constructor.name + ' len ' + hello.length);
+
+ // C3: every input that used to reach atob() must now be a plain Error mentioning base64
+ const bad = [
+ ['hello world!', 'non-base64 text'],
+ ['☃', 'non-Latin1 character'],
+ [null, 'null'],
+ [undefined, 'undefined'],
+ [123, 'number'],
+ [{}, 'object'],
+ ['abcde=', 'bad padding'],
+ ['ab=', 'incomplete padding']
+ ];
+ for (const [value, label] of bad) {
+ let err = null;
+ try { m._base64ToBytes(value); } catch (e) { err = e; }
+ const plainError = err instanceof Error && !isDomException(err) && err.name !== 'InvalidCharacterError';
+ ok('C3 ' + label + ' throws a plain Error that mentions base64',
+ plainError && /base64/.test(err.message),
+ err ? err.name + ': ' + err.message : 'did not throw');
+ }
+
+ // C4: long valid base64 with CRLF/LF wrapping still decodes (atob ignores ASCII whitespace)
+ const long = 'The quick brown fox jumps over the lazy dog. '.repeat(40);
+ const b64 = Buffer.from(long, 'utf8').toString('base64');
+ const wrapped = b64.replace(/(.{60})/g, '$1\r\n') + '\n';
+ const out = Buffer.from(m._base64ToBytes(wrapped)).toString('utf8');
+ ok('C4 newline-wrapped base64 decodes', out === long, out.length + ' vs ' + long.length + ' chars');
+
+ // C5: byte-identical to atob() for every form atob accepted before (incl. unpadded)
+ const corpus = ['aGVsbG8=', 'aGVsbG8', 'ab==', 'abc=', 'abcd', '', ' aGVs bG8=\n', 'aGVsbG8g\td29ybGQ='];
+ let same = true, why = '';
+ for (const c of corpus) {
+ let want, got;
+ try { want = Buffer.from(atob(c), 'binary').toString('hex'); } catch (e) { want = 'THROW'; }
+ try { got = Buffer.from(m._base64ToBytes(c)).toString('hex'); } catch (e) { got = 'THROW'; }
+ if (want !== got) { same = false; why = JSON.stringify(c) + ' atob=' + want + ' guard=' + got; break; }
+ }
+ ok('C5 happy path is byte-identical to atob()', same, why);
+
+ // C6: _gunzipString round-trip (base64 gzip payload)
+ const gz = zlib.gzipSync(Buffer.from('hi hi hi', 'utf8'));
+ const text = await m._gunzipString(gz.toString('base64'));
+ ok('C6 _gunzipString round-trips a real gzip payload', text === 'hi hi hi', JSON.stringify(text));
+
+ // C7: _gunzipString on garbage rejects with the guarded Error, not a DOMException
+ let gzErr = null;
+ try { await m._gunzipString('hello world!'); } catch (e) { gzErr = e; }
+ ok('C7 _gunzipString on non-base64 throws a guarded Error',
+ !!gzErr && gzErr instanceof Error && !isDomException(gzErr) && /base64/.test(gzErr.message),
+ gzErr ? gzErr.name + ': ' + gzErr.message : 'did not throw');
+
+ // C8: `gunzip` command path prints its existing "gunzip error:" line instead of crashing
+ const t8 = mkTerm({ 'notbase64.gz': 'hello world!' });
+ let cmdThrew = null;
+ try { await t8.mod.commands['gunzip'](['notbase64.gz']); } catch (e) { cmdThrew = e; }
+ const line = t8.printed.find(p => /^gunzip error: /.test(p.msg));
+ ok('C8 gunzip prints "gunzip error: … not valid base64 …" and does not throw',
+ !cmdThrew && !!line && /base64/.test(line.msg) && t8.store['notbase64.gz'] === 'hello world!',
+ cmdThrew ? 'threw ' + cmdThrew.message : JSON.stringify(t8.printed));
+
+ // C9: legacy path — a base64 .gz written by `gzip` still decompresses
+ const t9 = mkTerm({ 'note.txt.gz': zlib.gzipSync(Buffer.from('gunzip me', 'utf8')).toString('base64') });
+ await t9.mod.commands['gunzip'](['note.txt.gz']);
+ ok('C9 gunzip still decompresses a base64 .gz', t9.store['note.txt'] === 'gunzip me'
+ && !('note.txt.gz' in t9.store), JSON.stringify(Object.keys(t9.store)));
+
+ // C10-C11: the VFS helpers that call _base64ToBytes keep their behaviour
+ const gzRaw = zlib.gzipSync(Buffer.from('latin1 payload', 'utf8'));
+ const latin1 = Buffer.from(gzRaw).toString('latin1'); // what _vfsWriteBytes stores
+ const t10 = mkTerm({ 'blob.gz': latin1 });
+ const raw = t10.mod._vfsReadBytes('blob.gz'); // guard throws -> Latin-1 fallback
+ ok('C10 _vfsReadBytes falls back to raw Latin-1 bytes (guard rejection is swallowed)',
+ t10.mod._isGzip(raw) && Buffer.from(raw).equals(gzRaw), raw.length + ' bytes');
+
+ const t11 = mkTerm({ 'b64.gz': gzRaw.toString('base64') });
+ const dec = t11.mod._vfsReadBytes('b64.gz'); // heuristic -> base64 decode
+ ok('C11 _vfsReadBytes still base64-decodes a stored payload',
+ t11.mod._isGzip(dec) && Buffer.from(dec).equals(gzRaw), dec.length + ' bytes');
+
+ console.log('test-cryptogzip-guard: ' + passes + ' passed, ' + fails + ' failed');
+ process.exit(fails ? 1 : 0);
+})();
diff --git a/modules/test/test-debug-args-preview.js b/modules/test/test-debug-args-preview.js
new file mode 100644
index 0000000..12292eb
--- /dev/null
+++ b/modules/test/test-debug-args-preview.js
@@ -0,0 +1,112 @@
+// node modules/test/test-debug-args-preview.js
+// Regression for the filed report:
+// "Error in WebX11AIAssistantModule.debug-ai: Cannot read properties of undefined (reading 'substring')"
+// Cause: _toolCallLog entries carry `args: undefined` (a tool call with no
+// arguments) or no `args` key at all (_recordToolCall never sets one), and both
+// the debug panel and the tool-call log did `JSON.stringify(args).substring(...)`.
+// JSON.stringify(undefined) === undefined, so .substring threw inside the render
+// path and the panel died before printing anything.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+const SRC = process.env.AIASSIST_SRC || path.join(ROOT, 'module-aiassist.js');
+
+// --- browser stubs (the class is a browser module) -------------------------
+const store = {};
+global.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: k => { delete store[k]; },
+ clear: () => { for (const k of Object.keys(store)) delete store[k]; }
+};
+global.window = global;
+const panelEl = {
+ attrs: {}, innerHTML: '',
+ getAttribute(k) { return (k in this.attrs) ? this.attrs[k] : null; },
+ setAttribute(k, v) { this.attrs[k] = String(v); }
+};
+global.document = {
+ getElementById: id => (id === 'debug-ai-panel' ? panelEl : null),
+ createElement: () => ({ style: {}, click() {}, setAttribute() {} }),
+ body: { appendChild() {}, removeChild() {} }
+};
+
+const src = fsMod.readFileSync(SRC, 'utf8');
+const AI = new Function(src + '\nreturn WebX11AIAssistantModule;')();
+
+// Bypass the constructor (it builds prompts / touches DOM) but keep the real
+// prototype methods under test.
+function mk() {
+ const out = [];
+ const ai = Object.create(AI.prototype);
+ ai._toolCallLog = [];
+ ai._debugPanelEnabled = true;
+ ai._lastRawResponse = null;
+ ai._lastRawMessages = null;
+ ai._errorLog = [];
+ ai.terminal = {
+ print: (m, t) => out.push({ text: String(m), type: t || '' }),
+ log: () => {}, printHTML: () => {}
+ };
+ return { ai, out };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// 1-4: helper contract (guarded so a missing helper reports FAIL instead of
+// aborting the run — this is what proves the test catches the old code).
+{
+ const { ai } = mk();
+ const fmt = (v, m) => (typeof ai._formatToolArgs === 'function' ? ai._formatToolArgs(v, m) : '<no _formatToolArgs>');
+ ok('1 _formatToolArgs(undefined) === "null"', fmt(undefined, 60) === 'null', JSON.stringify(fmt(undefined, 60)));
+ ok('2 _formatToolArgs(null) === "null"', fmt(null, 60) === 'null', JSON.stringify(fmt(null, 60)));
+ ok('3 _formatToolArgs({a:1}) === \'{"a":1}\'', fmt({ a: 1 }, 60) === '{"a":1}', fmt({ a: 1 }, 60));
+ const long = 'x'.repeat(200);
+ const cut = fmt(long, 10);
+ ok('4 truncates to max', cut === 'xxxxxxxxxx', JSON.stringify(cut));
+}
+
+// 5: the debug panel renders an entry whose args is undefined (the live shape
+// pushed at the tool-call site) and one with no args key at all (_recordToolCall).
+{
+ const { ai } = mk();
+ ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false });
+ ai._toolCallLog.push({ turn: 2, name: 'get_status', duration: 0.1, ok: true });
+ panelEl.attrs['data-tab'] = 'tools';
+ panelEl.innerHTML = '';
+ let threw = null;
+ try { ai._updateDebugPanel(); } catch (e) { threw = e; }
+ ok('5 _updateDebugPanel does not throw on args:undefined', threw === null, threw && threw.message);
+ ok('5b panel rendered the "null" preview', !threw && /debug-args">null</.test(panelEl.innerHTML),
+ String(panelEl.innerHTML).slice(0, 400));
+}
+
+// 6: the terminal tool-call log (var e = this._toolCallLog[i]) does not throw.
+{
+ const { ai, out } = mk();
+ ai._toolCallLog.push({ turn: 1, name: 'list_files', args: undefined, duration: 0.2, error: false });
+ ai._toolCallLog.push({ turn: 2, name: 'read_file', args: '{"path":"/a.txt"}', duration: 0.1 });
+ let threw = null;
+ try { ai._showToolCallLog(); } catch (e) { threw = e; }
+ const printed = out.map(o => o.text).join('\n');
+ ok('6 _showToolCallLog does not throw on args:undefined', threw === null, threw && threw.message);
+ ok('6b log shows null for the argument-less call', /list_files\(null\)/.test(printed), printed.slice(0, 300));
+ ok('6c real args still shown', /read_file\(\{"path":"\/a\.txt"\}\)/.test(printed), printed.slice(0, 300));
+}
+
+// 7: a circular argument object must not throw either (JSON.stringify throws there).
+{
+ const { ai } = mk();
+ const circ = {}; circ.self = circ;
+ let threw = null, got = '';
+ try { got = ai._formatToolArgs(circ, 40); } catch (e) { threw = e; }
+ ok('7 circular args degrade instead of throwing', threw === null && typeof got === 'string' && got.length > 0,
+ threw ? threw.message : got);
+}
+
+console.log('test-debug-args-preview: ' + passes + ' passed, ' + fails + ' failed');
+process.exit(fails ? 1 : 0);
diff --git a/modules/test/test-wpm-index.js b/modules/test/test-wpm-index.js
new file mode 100644
index 0000000..ae9cc35
--- /dev/null
+++ b/modules/test/test-wpm-index.js
@@ -0,0 +1,164 @@
+// node modules/test/test-wpm-index.js
+'use strict';
+// Regression test — RPT-20261109-123907-5a6273ab (source tool: wpm_search)
+//
+// packages.json `kanban` was authored with `description` while the other 31 entries
+// use `desc`, and modules/ext/wpm.js read `pkg.desc.toLowerCase()` inside search().
+// Result: EVERY `wpm search <kw>` threw
+// TypeError: Cannot read properties of undefined (reading 'toLowerCase')
+// for any keyword (pdf, git, zzz) because the bad entry sits mid-loop.
+//
+// This test loads the real served catalog + the real wpm.js (no network, no browser)
+// and asserts that search/list/info/remove/install cannot throw on it, and that an
+// entry with no desc/category at all is also survivable.
+//
+// Run: node modules/test/test-wpm-index.js
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', '..');
+const WPM_SRC = path.join(ROOT, 'modules', 'ext', 'wpm.js');
+const CATALOG = path.join(ROOT, 'packages.json');
+
+let checks = 0;
+let failures = 0;
+function ok(cond, label) {
+ checks++;
+ if (cond) { console.log(' ok ' + label); return true; }
+ failures++;
+ console.error(' FAIL ' + label);
+ return false;
+}
+
+// ── Load wpm.js exactly as the browser would, minus the browser ──────────────
+// window is an empty object (a real browser has window.webx11 undefined until
+// module-loader.js runs), so the self-registration block at the bottom is skipped.
+const code = fs.readFileSync(WPM_SRC, 'utf8');
+const store = {};
+const fakeLocalStorage = {
+ getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
+ setItem: (k, v) => { store[k] = String(v); },
+ removeItem: (k) => { delete store[k]; },
+};
+const noNetwork = () => Promise.reject(new Error('network disabled in test'));
+const WpmModule = new Function('window', 'localStorage', 'fetch', code + '\n;return WpmModule;')
+ ({}, fakeLocalStorage, noNetwork);
+
+function makeWpm(catalog) {
+ const logs = [];
+ const m = new WpmModule();
+ m.terminal = {
+ print: (...args) => { logs.push(args.map((a) => String(a)).join(' ')); },
+ moduleManager: {
+ loadModuleFromURL: async () => true,
+ unregisterModule: () => true,
+ },
+ };
+ m.installed = {};
+ m.saveInstalled = () => {};
+ // Pre-seed the index: loadIndex() returns this.index and never fetches.
+ m.index = catalog || JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
+ return { m, logs };
+}
+
+async function main() {
+ const raw = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
+ const catalog = raw.packages;
+ const names = Object.keys(catalog);
+
+ console.log('\n[1] catalog shape + normalisation (' + names.length + ' packages)');
+ ok(names.length === 32, 'catalog still has 32 packages');
+ ok(!!catalog.kanban, 'kanban entry exists (the entry that broke search)');
+
+ const { m: probe } = makeWpm();
+ const map = probe._packageMap(probe.index);
+
+ const missingDesc = names.filter((n) => !map[n] || typeof map[n].desc !== 'string' || map[n].desc.length === 0);
+ const missingCat = names.filter((n) => !map[n] || typeof map[n].category !== 'string' || map[n].category.length === 0);
+ ok(missingDesc.length === 0, 'every entry normalises to a non-empty string desc' + (missingDesc.length ? ' — bad: ' + missingDesc.join(',') : ''));
+ ok(missingCat.length === 0, 'every entry normalises to a non-empty string category' + (missingCat.length ? ' — bad: ' + missingCat.join(',') : ''));
+ names.forEach((n) => {
+ ok(typeof catalog[n].url === 'string' && catalog[n].url.length > 0, n + ': raw url present');
+ });
+ ok(typeof catalog.kanban.desc === 'string' && catalog.kanban.desc.length > 0,
+ 'kanban now carries the canonical `desc` field');
+ ok(catalog.kanban.description === undefined || catalog.kanban.description === catalog.kanban.desc,
+ 'kanban legacy `description` (kept for other readers) agrees with desc');
+
+ console.log('\n[2] search over the real catalog for the keywords that used to throw');
+ for (const kw of ['pdf', 'git', 'zzz']) {
+ const { m, logs } = makeWpm();
+ let err = null;
+ try { await m.search(kw); } catch (e) { err = e; }
+ ok(!err, 'search("' + kw + '") did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
+ ok(logs.length > 0, 'search("' + kw + '") printed something (' + logs.length + ' lines)');
+ ok(!logs.some((l) => l.includes('undefined')), 'search("' + kw + '") printed no "undefined"');
+ if (kw === 'pdf') {
+ // search() prints the entry's display `name` ("File Manager"), not the map key.
+ ok(logs.some((l) => /pdf/i.test(l) && l.includes('File Manager')), 'search("pdf") finds the PDF-previewing file manager');
+ } else {
+ ok(logs.some((l) => l.includes('No packages matching')), 'search("' + kw + '") reports no match cleanly');
+ }
+ }
+
+ console.log('\n[3] the other catalog readers (list / info / remove / install) survive kanban');
+ {
+ const { m, logs } = makeWpm();
+ let err = null;
+ try {
+ await m.list();
+ await m.info('kanban');
+ } catch (e) { err = e; }
+ ok(!err, 'list()+info("kanban") did not throw' + (err ? ' — ' + err.message : ''));
+ ok(!logs.some((l) => l.includes('undefined')), 'list()+info("kanban") printed no "undefined"');
+ ok(logs.some((l) => l.includes('kanban - ')), 'list() renders the kanban line');
+
+ m.installed.kanban = { version: '1.0.0', installed: '2026-11-09T00:00:00Z' };
+ let rmErr = null;
+ try { await m.remove('kanban'); } catch (e) { rmErr = e; }
+ ok(!rmErr, 'remove("kanban") did not throw (name falls back to the map key)' + (rmErr ? ' — ' + rmErr.message : ''));
+
+ const inst = makeWpm();
+ let inErr = null;
+ try { await inst.m.install('kanban'); } catch (e) { inErr = e; }
+ ok(!inErr, 'install("kanban") did not throw (catalog fetch is stubbed to fail)');
+ ok(!inst.logs.some((l) => l.includes('undefined')), 'install("kanban") printed no "undefined"');
+ ok(inst.logs.some((l) => l.includes('Installing Kanban Board v1.0.0')), 'install("kanban") prints the normalised name/version');
+ }
+
+ console.log('\n[4] an entry with no desc/category/name/version at all cannot throw');
+ {
+ const { m, logs } = makeWpm();
+ m.index.packages.brokenpkg = { url: 'modules/ext/brokenpkg.js' };
+ let err = null;
+ try {
+ await m.search('zzz');
+ await m.search('broken');
+ await m.list();
+ await m.info('brokenpkg');
+ } catch (e) { err = e; }
+ ok(!err, 'field-less entry: search/list/info did not throw' + (err ? ' — ' + err.name + ': ' + err.message : ''));
+ ok(logs.length > 0, 'field-less entry: something was printed');
+ ok(!logs.some((l) => l.includes('undefined')), 'field-less entry: no "undefined" leaked to the terminal');
+ ok(logs.some((l) => l.includes('brokenpkg [')), 'field-less entry: search hit renders empty category, not a crash');
+ }
+
+ // also: an index whose `packages` key is missing entirely
+ console.log('\n[5] malformed index (no `packages` object) cannot throw');
+ {
+ const { m, logs } = makeWpm({ name: 'broken-index' });
+ let err = null;
+ try { await m.search('pdf'); await m.list(); } catch (e) { err = e; }
+ ok(!err, 'index without packages: search/list did not throw' + (err ? ' — ' + err.message : ''));
+ ok(logs.some((l) => l.includes('No packages matching')), 'index without packages: clean "no match" output');
+ }
+
+ console.log('\n' + (failures ? 'FAIL' : 'PASS') + ' — ' + (checks - failures) + '/' + checks + ' checks');
+ process.exit(failures ? 1 : 0);
+}
+
+main().catch((e) => {
+ console.error('FAIL — unexpected error: ' + (e && e.stack || e));
+ process.exit(1);
+});