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
144
145
146
147
148
149
150
|
// modules/test/test-shell-script.js — run: node
// Covers the script runner: block parser (A), variables (B), globs (C), and the executor (D).
// D is the part that used to be impossible — a loop body could not run at all.
const fsMod = require('fs'), path = require('path');
const ROOT = path.join(__dirname, '..');
const store = {};
global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } };
global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } };
global.window = global;
const load = (rel, cls) => new Function(fsMod.readFileSync(path.join(ROOT, rel), 'utf8') + '\nreturn ' + cls + ';')();
const Shell = load('ext/shell.js', 'ShellModule');
const VFS = load('core/virtual-fs.js', 'VirtualFileSystem');
let fails = 0, passes = 0;
const ok = (name, cond, detail) => { if (cond) { passes++; return; } fails++; console.log('FAIL ' + name + (detail ? ' — ' + detail : '')); };
const shell = Object.create(Shell.prototype);
// A. block parser
{
const tree = shell._parseBlocks(['for i in 1 2 3', 'do', ' echo item $i', 'done']);
ok('A1 one top-level instruction', tree.length === 1, JSON.stringify(tree));
ok('A2 it is a for loop over 3 items', tree[0].op === 'for' && tree[0].items.length === 3, JSON.stringify(tree[0]));
ok('A3 the body holds the echo', tree[0].body.length === 1 && /echo item \$i/.test(tree[0].body[0].text));
}
{
const tree = shell._parseBlocks(['if test -f /x', 'then', ' echo yes', 'else', ' echo no', 'fi']);
ok('A4 if/else parsed', tree[0].op === 'if' && tree[0].body.length === 1 && tree[0].elseBody.length === 1, JSON.stringify(tree));
}
{
let threw = null;
try { shell._parseBlocks(['for i in 1 2', ' echo $i']); } catch (e) { threw = e.message; }
ok('A5 an unclosed block is an error, not a silent run', !!threw && /done/.test(threw), String(threw));
}
// B. variables
{
const env = { i: '2', name: 'webx11' };
ok('B1 $VAR expands', shell._expandVars('echo $name', env) === 'echo webx11');
ok('B2 ${VAR} expands', shell._expandVars('echo ${name}!', env) === 'echo webx11!');
ok('B3 single quotes are literal', shell._expandVars("echo '$name'", env) === "echo '$name'");
ok('B4 unknown var becomes empty', shell._expandVars('echo [$nope]', env) === 'echo []');
}
// C. globbing (against a fake fs)
{
const fake = { ls: () => [{ name: 'a.txt', type: 'file' }, { name: 'b.txt', type: 'file' }, { name: 'c.log', type: 'file' }], readDir: () => [] };
shell.terminal = { fs: fake, moduleManager: { getAllCommands: () => ({}) } };
const out = shell._expandGlobs(['*.txt'], ['/work']);
ok('C1 *.txt expands to two files', out.length === 2 && out.join(',') === '/work/a.txt,/work/b.txt', JSON.stringify(out));
ok('C2 a literal arg is untouched', shell._expandGlobs(['plain'], ['/work']).join() === 'plain');
ok('C3 quoted wildcard stays literal', shell._expandGlobs(["'*.txt'"], ['/work']).join() === "'*.txt'");
}
// D. the executor (a fake terminal, a real VFS)
function mkShell(seed) {
const vfs = new VFS();
vfs.saveToStorage = () => true;
Object.entries(seed || {}).forEach(([p, body]) => { vfs.writeFile(p, body); });
const printed = [];
const term = {
fs: vfs,
print: (t) => {
const st = term._printCaptureStack || [];
if (st.length) { st[st.length - 1].output += String(t) + '\n'; return; }
printed.push(String(t));
},
log: () => {}, markDirty: () => {},
moduleManager: { getAllCommands: () => ({ ls: {}, echo: {}, cat: {} }) },
security: { checkAndRun: async () => { printed.push('AI-ROUTED'); } },
_printCaptureStack: [],
splitPipe: (s) => s.split('|').map(x => x.trim()),
executePipeChain: async () => {},
};
const sh = Object.create(Shell.prototype);
sh.terminal = term;
sh.commands = { echo: (args) => term.print(args.join(' ')) };
return { sh, term, printed, vfs };
}
async function runScript(seed, lines) {
const env = { $: 0 };
const ctx = { path: '/t.sh', inScript: true };
const tree = (await (async () => { const { sh } = mkShell(seed); return sh._parseBlocks(lines); })());
const { sh, printed, vfs, term } = mkShell(seed);
await sh._runInstructions(tree, env, ctx);
return { printed, vfs, env, term };
}
(async () => {
{
const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo item $i', 'done']);
ok('D1 the loop body ran 3 times', r.printed.join('|') === 'item 1|item 2|item 3', r.printed.join('|'));
ok('D2 $i is not left over as a literal', !r.printed.some(l => /\$i/.test(l)), r.printed.join('|'));
}
{
const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]', 'then', 'echo yes', 'else', 'echo no', 'fi']);
ok('D3 bracket test takes the then-branch', r.printed.join('|') === 'yes', r.printed.join('|'));
const r2 = await runScript({}, ['if test -f /absent.txt', 'then', 'echo yes', 'else', 'echo no', 'fi']);
ok('D4 a failing test takes the else-branch', r2.printed.join('|') === 'no', r2.printed.join('|'));
}
{
const r = await runScript({}, ['echo hi > /o.txt']);
ok('D5 redirect writes the file', r.vfs.readFile('/o.txt') === 'hi\n', JSON.stringify(r.vfs.readFile('/o.txt')));
ok('D6 redirect does not echo to the screen', !r.printed.includes('hi > /o.txt'), r.printed.join('|'));
}
{
const vfs = new VFS(); vfs.saveToStorage = () => true;
vfs.mkdirRecursive('/work');
vfs.writeFile('/work/a.txt', '1'); vfs.writeFile('/work/b.txt', '2'); vfs.writeFile('/work/c.log', '3');
const seen = [];
const term = { fs: vfs, print: () => {}, log: () => {}, markDirty: () => {},
moduleManager: { getAllCommands: () => ({ ls: {} }) },
security: { checkAndRun: async (s) => { seen.push(s); } },
_printCaptureStack: [], splitPipe: (s) => [s], executePipeChain: async () => {} };
const sh = Object.create(Shell.prototype);
sh.terminal = term; sh.commands = {};
vfs.currentPath = ['work'];
await sh._runSimple('ls *.txt', { $: 0 }, { path: '/t.sh', inScript: true });
ok('D15 glob results reach the dispatched command', seen[0] === 'ls /work/a.txt /work/b.txt', JSON.stringify(seen));
}
{
const r = await runScript({}, ['no-such-cmd arg']);
ok('D7 unknown command is reported, not answered by the AI', r.printed.join('|').includes('command not found'), r.printed.join('|'));
ok('D8 the AI fallback was not used', !r.printed.includes('AI-ROUTED'), r.printed.join('|'));
ok('D9 exit status is non-zero', r.env.$ !== 0, String(r.env.$));
}
{
const r = await runScript({ '/present.txt': 'x' }, ['if [ -f /present.txt ]; then echo one-line; fi']);
ok('D11 a one-line if runs its then-branch', r.printed.join('|') === 'one-line', r.printed.join('|'));
}
{
const { sh, term, printed } = mkShell({});
sh.terminal.fs.writeFile('/bad.sh', 'for i in 1 2\necho $i\n');
await sh._cmdScript(['/bad.sh']);
ok('D12 an unclosed block fails as a script error', printed.join('|').includes('failed:'), printed.join('|'));
ok('D13 the failure names the missing terminator', /done/.test(printed.join('|')), printed.join('|'));
}
{
const r = await runScript({}, ['for i in 1 2 3', 'do', 'echo n $i', 'done', 'echo after']);
ok('D14 a statement after the loop still runs', r.printed[r.printed.length - 1] === 'after', r.printed.join('|'));
}
{
const r = await runScript({}, ['echo a', 'echo b']);
ok('D10 sequential statements all run', r.printed.join('|') === 'a|b', r.printed.join('|'));
}
if (fails === 0) console.log('test-shell-script: ' + passes + ' passed, 0 failed');
else { console.log('test-shell-script: ' + passes + ' passed, ' + fails + ' FAILED'); process.exit(1); }
})();
|