// 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); })();