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