diff options
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); +})(); |