aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ai-path-args.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/test/test-ai-path-args.js')
-rw-r--r--modules/test/test-ai-path-args.js143
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);
+})();