#!/usr/bin/env node // Test harness for newOS4 core FileSystemModule commands (tail, sed, cat/head regression). // Loads module-fs.js in a vm sandbox with stub terminal/fs — no browser needed. // Run: node scripts/test-fs-commands.mjs (exit 1 on failure) import { readFileSync } from 'node:fs'; import vm from 'node:vm'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const SRC = readFileSync(path.join(ROOT, 'modules/core/module-fs.js'), 'utf8'); function makeCtx() { const ctx = { setInterval, clearInterval, setTimeout, clearTimeout, console, __test: { files: new Map(), writes: [] }, }; vm.createContext(ctx); vm.runInContext(SRC + '\n;globalThis.FileSystemModule = FileSystemModule;', ctx, { filename: 'module-fs.js' }); return ctx; } function makeMod(ctx) { const out = []; const files = ctx.__test.files; const terminal = { print: (text, type) => out.push({ text: String(text), type: type || '' }), fs: { readFile: (p) => (files.has(p) ? files.get(p) : null), writeFile: (p, c) => { files.set(p, String(c)); ctx.__test.writes.push([p, String(c)]); }, }, _pipeInput: null, _activeFollow: null, stopFollow(msg) { if (!this._activeFollow) return false; clearInterval(this._activeFollow.interval); this._activeFollow = null; this.print(msg || '^C — stopped following', 'warning'); return true; }, }; const mod = new ctx.FileSystemModule(); mod.terminal = terminal; mod.registerCommands(); return { mod, terminal, out }; } function scenario(filesInit) { const ctx = makeCtx(); for (const [k, v] of Object.entries(filesInit || {})) ctx.__test.files.set(k, v); const s = makeMod(ctx); s.ctx = ctx; return s; } function runCmd(mod, name, args) { if (typeof mod.commands[name] !== 'function') { mod.terminal.print(name + ': command not defined', 'error'); return; } mod.commands[name](args); } let pass = 0, fail = 0; const check = (name, cond, got) => { if (cond) { pass++; console.log(' ok ' + name); } else { fail++; console.log(' FAIL ' + name + (got !== undefined ? ' — got: ' + JSON.stringify(got) : '')); } }; const text = (out) => out.map(o => o.text).join('\n'); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const L12 = ['l1','l2','l3','l4','l5','l6','l7','l8','l9','l10','l11','l12'].join('\n'); // ─── regression: cat / head unchanged ─── { const { mod, out } = scenario({ 'a.txt': 'hello' }); runCmd(mod, 'cat', ['a.txt']); check('cat regression', text(out) === 'hello', text(out)); } { const { mod, out } = scenario({ 'a.txt': '1\n2\n3' }); runCmd(mod, 'head', ['a.txt', '2']); check('head regression', text(out) === '1\n2', text(out)); } // ─── tail ─── { const { mod, out } = scenario({ 'log.txt': L12 }); runCmd(mod, 'tail', ['log.txt']); check('tail default last 10', text(out) === 'l3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12', text(out)); } { const { mod, out } = scenario({ 'log.txt': L12 }); runCmd(mod, 'tail', ['log.txt', '2']); check('tail legacy count arg', text(out) === 'l11\nl12', text(out)); } { const { mod, out } = scenario({ 'log.txt': L12 }); runCmd(mod, 'tail', ['-n', '3', 'log.txt']); check('tail -n 3', text(out) === 'l10\nl11\nl12', text(out)); } { const { mod, out } = scenario({ 'log.txt': L12 }); runCmd(mod, 'tail', ['-4', 'log.txt']); check('tail -4 attached flag', text(out) === 'l9\nl10\nl11\nl12', text(out)); } { const { mod, out } = scenario({ 'log.txt': L12 }); runCmd(mod, 'tail', ['-n', 'abc', 'log.txt']); check('tail -n abc errors', out[0].type === 'error', out); } { const { mod, out } = scenario({}); runCmd(mod, 'tail', ['nope.txt']); check('tail missing file errors', out[0].type === 'error' && text(out).includes('No such file'), text(out)); } { const { mod, out } = scenario({}); runCmd(mod, 'tail', []); check('tail no args usage', out[0].type === 'warning', out); } { const { mod, terminal, out, ctx } = scenario({ 'log.txt': 'a\nb' }); runCmd(mod, 'tail', ['-f', 'log.txt']); check('tail -f prints initial content', out.some(o => o.text === 'a\nb'), out.map(o => o.text)); check('tail -f registers follow timer', !!(terminal._activeFollow && terminal._activeFollow.file === 'log.txt'), terminal._activeFollow); ctx.__test.files.set('log.txt', 'a\nb\nc'); await sleep(1700); check('tail -f prints appended line', out.some(o => o.text === '\nc'), out.map(o => o.text)); check('tail -f stopFollow clears', terminal.stopFollow() === true && terminal._activeFollow === null); ctx.__test.files.set('log.txt', 'a\nb\nc\nd'); await sleep(1700); check('no output after stopFollow', !out.some(o => o.text.includes('d')), out.map(o => o.text)); } { const { mod, terminal, out, ctx } = scenario({ 'log.txt': 'aaaa' }); runCmd(mod, 'tail', ['-f', 'log.txt']); ctx.__test.files.set('log.txt', 'xy'); await sleep(1700); check('tail -f detects truncation', out.some(o => o.text === 'xy') && out.some(o => o.text.includes('truncated')), out.map(o => o.text)); terminal.stopFollow(); } // ─── sed ─── { const { mod, out } = scenario({ 'a.txt': 'foo bar\nfoo baz' }); runCmd(mod, 'sed', ['s/foo/X/', 'a.txt']); check('sed s/foo/X/ first per line', text(out) === 'X bar\nfoo baz', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'foo bar foo' }); runCmd(mod, 'sed', ['s/foo/X/g', 'a.txt']); check('sed global flag', text(out) === 'X bar X', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'Hello HELLO' }); runCmd(mod, 'sed', ['s/hello/hey/i', 'a.txt']); check('sed case-insensitive', text(out) === 'hey HELLO', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'ab' }); runCmd(mod, 'sed', ['s/a/$&/', 'a.txt']); check('sed $& replacement', text(out) === 'ab', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'a/b path' }); runCmd(mod, 'sed', ['s/a\\/b/X/', 'a.txt']); check('sed escaped slash', text(out) === 'X path', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'keep\ndelete me\nkeep2' }); runCmd(mod, 'sed', ['/delete/d', 'a.txt']); check('sed /pat/d', text(out) === 'keep\nkeep2', text(out)); } { const { mod, out } = scenario({ 'a.txt': '1\n2\n3' }); runCmd(mod, 'sed', ['2d', 'a.txt']); check('sed 2d', text(out) === '1\n3', text(out)); } { const { mod, out } = scenario({ 'a.txt': '1\n2\n3' }); runCmd(mod, 'sed', ['$d', 'a.txt']); check('sed $d', text(out) === '1\n2', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'foo\nbar\nfoo' }); runCmd(mod, 'sed', ['-n', 's/foo/X/p', 'a.txt']); check('sed -n s///p prints only changed', text(out) === 'X\nX', text(out)); } { const { mod, out, ctx } = scenario({ 'a.txt': 'old line' }); runCmd(mod, 'sed', ['-i', 's/old/new/', 'a.txt']); check('sed -i writes file', ctx.__test.files.get('a.txt') === 'new line', ctx.__test.files.get('a.txt')); check('sed -i success msg', out.some(o => o.type === 'success'), out); } { const { mod, ctx } = scenario({ 'a.txt': '1\n2\n' }); runCmd(mod, 'sed', ['-i', '2d', 'a.txt']); check('sed -i keeps trailing newline', ctx.__test.files.get('a.txt') === '1\n', JSON.stringify(ctx.__test.files.get('a.txt'))); } { const { mod, out, terminal } = scenario({}); terminal._pipeInput = 'x1\nx2'; runCmd(mod, 'sed', ['s/x/y/']); check('sed reads pipe input', text(out) === 'y1\nx2', text(out)); } { const { mod, out } = scenario({}); runCmd(mod, 'sed', []); check('sed no args usage', out[0].type === 'warning', out); } { const { mod, out } = scenario({ 'a.txt': 'x' }); runCmd(mod, 'sed', ['s/a', 'a.txt']); check('sed malformed expr errors', out[0].type === 'error', text(out)); } { const { mod, out } = scenario({ 'a.txt': 'x' }); runCmd(mod, 'sed', ['s/[/y/', 'a.txt']); check('sed invalid regex errors', out[0].type === 'error', text(out)); } { const { mod, out } = scenario({}); runCmd(mod, 'sed', ['s/a/b/', 'missing.txt']); check('sed missing file errors', out[0].type === 'error', text(out)); } { const { mod, out } = scenario({}); runCmd(mod, 'sed', ['s/a/b/']); check('sed no operand errors', out[0].type === 'error', text(out)); } console.log(''); console.log(`${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);