aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-ai-path-args.js
blob: 878f6b0e3674b0e29ea103212e0c78fe660866cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
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);
})();