aboutsummaryrefslogtreecommitdiff
path: root/scripts/test-fs-commands.mjs
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-09-29 13:10:14 +0200
committerAgando <roduccomm@gmail.com>2026-09-29 13:10:14 +0200
commit534fc0f767b681e7025188092251a12fce77dc74 (patch)
treefb58d591f8f2ee4047644b6da48aae0009e5be3d /scripts/test-fs-commands.mjs
parenta7399a9df6caab582c07f09ee544d31e2a729508 (diff)
newOS4: tail -n/-N flags + live follow mode (-f) with test harness
Diffstat (limited to 'scripts/test-fs-commands.mjs')
-rw-r--r--scripts/test-fs-commands.mjs236
1 files changed, 236 insertions, 0 deletions
diff --git a/scripts/test-fs-commands.mjs b/scripts/test-fs-commands.mjs
new file mode 100644
index 0000000..6f87708
--- /dev/null
+++ b/scripts/test-fs-commands.mjs
@@ -0,0 +1,236 @@
+#!/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); \ No newline at end of file