From 9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 Mon Sep 17 00:00:00 2001 From: Hermes SysOp Date: Mon, 9 Nov 2026 23:58:35 +0100 Subject: fix(newos4): six crash classes from the feedback queue (v4.6.158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ()"). 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. --- modules/test/test-ai-path-args.js | 143 +++++++++++++++++++++++ modules/test/test-ai-test-module-commands.js | 105 +++++++++++++++++ modules/test/test-aiasm-rejection.js | 95 ++++++++++++++++ modules/test/test-cryptogzip-guard.js | 136 ++++++++++++++++++++++ modules/test/test-debug-args-preview.js | 112 ++++++++++++++++++ modules/test/test-wpm-index.js | 164 +++++++++++++++++++++++++++ 6 files changed, 755 insertions(+) create mode 100644 modules/test/test-ai-path-args.js create mode 100644 modules/test/test-ai-test-module-commands.js create mode 100644 modules/test/test-aiasm-rejection.js create mode 100644 modules/test/test-cryptogzip-guard.js create mode 100644 modules/test/test-debug-args-preview.js create mode 100644 modules/test/test-wpm-index.js (limited to 'modules/test') 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) : ''); + 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 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 ` 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); +}); -- cgit v1.2.3