diff options
| author | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
|---|---|---|
| committer | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-09 23:58:35 +0100 |
| commit | 9f6744dd5cb99b904f9a23fce6f2d98e8332bbf8 (patch) | |
| tree | d3f3a525e8594db010bbf0e2aa6e4c2590445339 /modules/test/test-ai-path-args.js | |
| parent | 8e3a6155f6a5a1266ff1fa801c8864484bcdbda9 (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/test-ai-path-args.js')
| -rw-r--r-- | modules/test/test-ai-path-args.js | 143 |
1 files changed, 143 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); +})(); |