summaryrefslogtreecommitdiff
path: root/modules/test
diff options
context:
space:
mode:
authorHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:39:12 +0100
committerHermes SysOp <sysop@webx11.duckdns.org>2026-11-12 16:39:12 +0100
commit228ad621e2631487e74acdf9bf58e797f60ab745 (patch)
tree6372c40b0ec6ec1470999b0e8e39cd267f24e979 /modules/test
parentcbaa678ae624860ce4f918428a7b09d5f0e0ae2f (diff)
fix(newos4): honest size/network measurements + checkpoint and phantom-delete fixes (v4.6.160)
Applies the three 2026-11-09 patches after review, and fixes two more navigator.onLine lies found while verifying them. Each patch was checked against the live code first; one of its claims did not survive. 1. virtual-fs — phantom deletes and checkpoint bloat - deleteCheckpoint('nope') returned true and printed "Deleted checkpoint" for a name that did not exist: _resolvePath() validates intermediate segments only, so the leaf was never checked. New _requireLeaf() refuses a resolved path whose leaf is absent, and deleteCheckpoint uses it. - CHECKPOINT SIZE: checkpoint() cloned the whole root minus _snapshots/_sessions/.trash, so _mods (module originals) and .semantic_index were duplicated into EVERY checkpoint — measured 2,101,686 bytes for a 2 MiB _mods. Both are now excluded on write and preserved across rollback (without the keep-list change a rollback would delete the live module originals). Measured after: 185 bytes. - Claim corrected: the patch also claimed deleteFile() reported success on a phantom path. It did not — live code already required `node && node.type === 'file'` and returned false. The guard is kept as defence-in-depth, and the test asserts the real behaviour rather than the claimed one. 2. module-fs — vfs-size reported ~2x the real size - It measured JSON.stringify(root).length: UTF-16 code units PLUS every structural character of the serialisation. A 1046-byte tree printed as 1961; anything under ~5 KB printed as "0.00 MB". - Every number is now stored bytes (UTF-8 of file content): one measure() walk for the total, the per-file table, the top-directory table and the system dirs, so the rows add up to the total. The patch's arithmetic was still off in seven places, all fixed here: a UTF-16 fallback that reintroduced the bug when Blob was missing, a top-5 table that could never sum to the total (now top 10 + an aggregated remainder row), the top-dirs table dropping files stored at root (now a "/(root files)" row), a 2-decimal MB total that printed 0.00 for small VFS, three duplicate hand-rolled walks, readDir() used for dir sizes, and "1 files". 3. module-system — status/doctor no longer trust navigator.onLine - status printed "Network: Offline" from navigator.onLine, which an Android WebView reports false while fully connected. - doctor now awaits a real HEAD probe (AbortController, 4 s): only 'reachable' is healthy, a throw is HIGH "Network: unreachable", a non-ok answer is MEDIUM and names the status (405 to HEAD is not "offline"). - THE PATCH'S THRESHOLDS COULD NEVER FIRE: it compared localStorage bytes against navigator.storage.estimate().quota, which is the whole-origin quota (GBs, mostly Cache Storage), while localStorage has its own ~5 MB cap — a 4.5 MB localStorage reported "All systems healthy" against a 2 GB quota. Now: localStorage measured as (key+value)*2 UTF-16 bytes with HIGH > 4 MiB / MEDIUM > 3 MiB, VFS measured in UTF-8 bytes with HIGH > 3 MiB and > 80% of the measured localStorage bytes. The origin quota appears only in one informational line and decides no severity. - Also removed navigator.onLine from version, bug-report and bug (a grep for it is now 0 in the module), and added a "Measured:" line so a clean report still shows what was measured. 4. terminal.js / module-servermon.js — the same lie, where the user looks - The status bar printed "❌ Offline" in red from navigator.onLine; on a WebView that is false while online. It now shows "📶 Network?" with a title pointing at `doctor` unless the browser claims online. - `net` test line now reads "Browser reports: offline (unreliable)" as a warning instead of a red failure — the real verdict comes from the probes that follow. Verified: 40/40 node tests (3 new files: 36 + 53 + 34 assertions, each proven red against the pre-patch code; the doctor test runs against the PATCH's threshold logic and shows the 4.5 MB case staying silent). An independent orchestrator probe re-derived all of it: phantom delete false, 2 MB _mods absent from the checkpoint and preserved through rollback, vfs-size exact bytes for ASCII/é/☃ nested trees, 4.5 MB localStorage HIGH despite a 2 GB quota, failing probe unreachable, status never claiming Online/Offline. Served bytes identical for core-bundle.js / main.html / module-system.js / virtual-fs.js. Live Chromium on v4.6.160: vfs-size "85.8 KB (87880 bytes) across 40 files", doctor "Measured: VFS 0.08 MB UTF-8 · localStorage 0.07 MB UTF-16 in 14 keys", a stubbed failing probe reports HIGH unreachable, the status bar reads "📶 Network?" when the browser lies, deleteCheckpoint on a missing name returns false, and the console has 0 warnings and 0 JS errors.
Diffstat (limited to 'modules/test')
-rw-r--r--modules/test/test-doctor-thresholds.js341
-rw-r--r--modules/test/test-vfs-size-bytes.js216
-rw-r--r--modules/test/test-virtual-fs-require-leaf.js160
3 files changed, 717 insertions, 0 deletions
diff --git a/modules/test/test-doctor-thresholds.js b/modules/test/test-doctor-thresholds.js
new file mode 100644
index 0000000..120b941
--- /dev/null
+++ b/modules/test/test-doctor-thresholds.js
@@ -0,0 +1,341 @@
+// node modules/test/test-doctor-thresholds.js
+// Proves `doctor` measures real storage cost and can actually fire:
+// · network comes from a live HEAD probe, never from the legacy onLine boolean;
+// · localStorage is charged as UTF-16 (Σ (key+value) × 2), against its own ~5 MB cap;
+// · VFS is summed as UTF-8 bytes of file content (Blob), the same unit vfs-size uses;
+// · navigator.storage.estimate() is CONTEXT ONLY — a 2 GB origin quota must NOT suppress a
+// 4.5 MB localStorage finding. That is the exact defect in the proposed patch, whose
+// `lsBytes > quota * 0.8` comparison could never be true on a real browser.
+// Run through the real class loaded from modules/core/module-system.js.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+// N4_SRC lets the same assertions run against another build of the file (mutation check).
+const SRC_PATH = process.env.N4_SRC || path.join(ROOT, 'module-system.js');
+
+global.window = global;
+
+const src = fsMod.readFileSync(SRC_PATH, 'utf8');
+const SystemModule = new Function(src + '\nreturn SystemModule;')();
+
+// ── a localStorage that charges what a browser charges ──────────────────────
+// Values are held as given; the module must do the ×2 itself, so this fake reports
+// .length (UTF-16 code units) exactly like the real Storage object does.
+function makeLS(pairs) {
+ const map = new Map(Object.entries(pairs || {}));
+ return {
+ get length() { return map.size; },
+ key(i) { return Array.from(map.keys())[i] ?? null; },
+ getItem(k) { return map.has(k) ? map.get(k) : null; },
+ setItem(k, v) { map.set(k, String(v)); },
+ removeItem(k) { map.delete(k); },
+ clear() { map.clear(); },
+ _map: map,
+ };
+}
+
+// ── terminal stub ──────────────────────────────────────────────────────────
+function mkTerminal(opts) {
+ const out = [];
+ const files = (opts && opts.files) || {};
+ const dirs = (opts && opts.dirs) || { '/': [] };
+ return {
+ out,
+ text: () => out.map(l => l.msg).join('\n'),
+ fs: {
+ root: (opts && opts.root) || { type: 'dir', content: {} },
+ ls: () => (dirs['/'] || []),
+ readDir: (p) => dirs[p] || null,
+ readFile: (p) => (p in files ? files[p] : null),
+ },
+ ollamaManager: { connected: (opts && opts.ollama !== undefined) ? opts.ollama : true },
+ moduleManager: { modules: { SystemModule: {}, VirtualFSModule: {} } },
+ print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }),
+ };
+}
+
+function mkModule(opts) {
+ const t = mkTerminal(opts);
+ const m = new SystemModule();
+ m.terminal = t;
+ m.registerCommands();
+ return { m, t };
+}
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+
+// ── helpers to build payloads of an exact byte size ────────────────────────
+const kb = 1024;
+function asciiFile(mb) { return 'a'.repeat(Math.round(mb * 1024 * 1024)); }
+
+// A VFS file node tree holding one big ASCII file.
+function rootWithFile(content) {
+ return { type: 'dir', content: { tmp: { type: 'dir', content: { 'big.txt': { type: 'file', content } } } } };
+}
+
+// Drive `doctor` with a given fake browser surface.
+async function doctor(opts) {
+ const { m, t } = mkModule(opts);
+ const o = opts || {};
+ const savedNavigator = global.navigator;
+ const savedFetch = global.fetch;
+ const savedLS = global.localStorage;
+ global.localStorage = o.ls || makeLS({});
+ if ('fetch' in o) {
+ if (o.fetch === undefined) delete global.fetch;
+ else global.fetch = o.fetch;
+ }
+ if ('storage' in o) {
+ const nav = Object.assign({}, savedNavigator);
+ if (o.storage === undefined) delete nav.storage;
+ else nav.storage = o.storage;
+ Object.defineProperty(global, 'navigator', { value: nav, configurable: true, writable: true });
+ }
+ try {
+ const r = m.commands['doctor']();
+ if (r && typeof r.then === 'function') await r;
+ } finally {
+ global.fetch = savedFetch;
+ global.localStorage = savedLS;
+ Object.defineProperty(global, 'navigator', { value: savedNavigator, configurable: true, writable: true });
+ }
+ return t;
+}
+
+// Extract the issue lines the report printed (top 3) plus the full issue count line.
+function issueLines(t) {
+ return t.out.map(l => l.msg).filter(s => /^\s{4}\S+ \[(HIGH|MEDIUM|LOW)\]/.test(s));
+}
+function report(t) { return t.text(); }
+
+function okFetch(status) {
+ return () => Promise.resolve({ ok: status >= 200 && status < 300, status });
+}
+
+// ══════════════════════════════════════════════════════════════════════════
+// A. Network — live probe, not the legacy boolean
+// ══════════════════════════════════════════════════════════════════════════
+(async () => {
+ // A1: a healthy fetch must produce NO Network issue at all.
+ {
+ const t = await doctor({ fetch: okFetch(200), files: {} });
+ ok('A1 fetch ok → no Network issue', !/Network: (unreachable|HTTP probe)/.test(report(t)), report(t));
+ ok('A1b report says reachable', /Network:\s+reachable/.test(report(t)), report(t));
+ }
+
+ // A2: a rejected fetch must be a HIGH unreachable finding.
+ {
+ const t = await doctor({ fetch: () => Promise.reject(new Error('net::ERR_NAME_NOT_RESOLVED')) });
+ const txt = report(t);
+ ok('A2 fetch reject → high Network: unreachable',
+ /\[HIGH\] Network: unreachable/.test(txt), txt);
+ }
+
+ // A3: a 503 must be named, not flattened to "offline".
+ {
+ const t = await doctor({ fetch: okFetch(503) });
+ const txt = report(t);
+ ok('A3 fetch 503 → Network issue naming http-503',
+ /Network:.*http-503/.test(txt), txt);
+ ok('A3b 503 is not reported as unreachable',
+ !/Network: unreachable/.test(txt), txt);
+ }
+
+ // A4: legacy boolean must not exist in the source at all.
+ {
+ const hits = src.split('\n').filter(l => /navigator\s*\.\s*onLine/.test(l)).length;
+ ok('A4 no navigator.onLine reference in module-system.js', hits === 0, hits + ' hit(s)');
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // B. localStorage — UTF-16 cost against its own ~5 MB cap
+ // ══════════════════════════════════════════════════════════════════════
+ // 4.5 MB of UTF-16 = 2.25 M code units of value + key.
+ {
+ const t = await doctor({
+ fetch: okFetch(200),
+ ls: makeLS({ webx11_big: 'a'.repeat(Math.round(4.5 * 1024 * 1024 / 2)) }),
+ });
+ const txt = report(t);
+ ok('B1 localStorage 4.5 MB → HIGH localStorage issue',
+ /\[HIGH\] localStorage: 4\.5 MB/.test(txt), txt);
+ ok('B1b message names the ~5 MB per-origin cap',
+ /~5 MB per-origin cap/.test(txt), txt);
+ }
+ {
+ // 3.2 MB of UTF-16 = 1.6 M code units.
+ const t = await doctor({
+ fetch: okFetch(200),
+ ls: makeLS({ webx11_mid: 'b'.repeat(Math.round(3.2 * 1024 * 1024 / 2)) }),
+ });
+ const txt = report(t);
+ ok('B2 localStorage 3.2 MB → MEDIUM', /\[MEDIUM\] localStorage: 3\.2 MB/.test(txt), txt);
+ ok('B2b no HIGH localStorage at 3.2 MB', !/\[HIGH\] localStorage/.test(txt), txt);
+ }
+ {
+ const t = await doctor({
+ fetch: okFetch(200),
+ ls: makeLS({ webx11_small: 'c'.repeat(Math.round(1 * 1024 * 1024 / 2)) }),
+ });
+ ok('B3 localStorage 1 MB → no localStorage issue',
+ !/localStorage:/.test(report(t)), report(t));
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // C. VFS — UTF-8 bytes of content, share of the localStorage budget
+ // ══════════════════════════════════════════════════════════════════════
+ {
+ // 3.5 MB of ASCII VFS + 4 MB localStorage budget → 87% share → HIGH.
+ const t = await doctor({
+ fetch: okFetch(200),
+ root: rootWithFile(asciiFile(3.5)),
+ ls: makeLS({ webx11_vfs: 'd'.repeat(Math.round(4 * 1024 * 1024 / 2)) }),
+ });
+ const txt = report(t);
+ ok('C1 VFS 3.5 MB of 4 MB budget → HIGH VFS issue',
+ /\[HIGH\] VFS: 3\.5 MB/.test(txt), txt);
+ ok('C1b message states the measured share', /87% of the 4\.0 MB localStorage budget/.test(txt), txt);
+ ok('C1c VFS does not claim a JSON limit', !/512KB|256KB/.test(txt), txt);
+ }
+ {
+ // 1 MB VFS → under both thresholds, nothing.
+ const t = await doctor({
+ fetch: okFetch(200),
+ root: rootWithFile(asciiFile(1)),
+ ls: makeLS({ webx11_vfs: 'e'.repeat(Math.round(4 * 1024 * 1024 / 2)) }),
+ });
+ ok('C2 VFS 1 MB → no VFS issue', !/VFS: /.test(report(t)), report(t));
+ }
+ {
+ // 2.5 MB VFS → MEDIUM (large alone, but not a big share of an 8 MB budget).
+ const t = await doctor({
+ fetch: okFetch(200),
+ root: rootWithFile(asciiFile(2.5)),
+ ls: makeLS({ webx11_vfs: 'f'.repeat(Math.round(8 * 1024 * 1024 / 2)) }),
+ });
+ const txt = report(t);
+ ok('C3 VFS 2.5 MB → MEDIUM', /\[MEDIUM\] VFS: 2\.5 MB/.test(txt), txt);
+ }
+ {
+ // UTF-8, not chars: 1 MiB of 'é' is 2.00 MB of UTF-8 (chars would read 1.00 MB).
+ const t = await doctor({
+ fetch: okFetch(200),
+ root: rootWithFile('é'.repeat(1024 * 1024)),
+ ls: makeLS({ webx11_vfs: 'g'.repeat(Math.round(8 * 1024 * 1024 / 2)) }),
+ });
+ ok('C4 multibyte counted as UTF-8 bytes (1M é = 2.00 MB)',
+ /Measured: VFS 2\.00 MB UTF-8/.test(report(t)), report(t));
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // D. The patch's bug: origin quota must not suppress a real finding
+ // ══════════════════════════════════════════════════════════════════════
+ {
+ const est = () => Promise.resolve({ usage: 40 * 1024 * 1024, quota: 2 * 1024 * 1024 * 1024 });
+ const t = await doctor({
+ fetch: okFetch(200),
+ storage: { estimate: est },
+ ls: makeLS({ webx11_big: 'h'.repeat(Math.round(4.5 * 1024 * 1024 / 2)) }),
+ });
+ const txt = report(t);
+ ok('D1 2 GB origin quota does NOT suppress a 4.5 MB localStorage finding',
+ /\[HIGH\] localStorage: 4\.5 MB/.test(txt), txt);
+ ok('D2 quota is printed as context only',
+ /Origin usage 40\.0 MB of ~2048 MB quota/.test(txt), txt);
+ ok('D3 context line states localStorage has its own cap',
+ /localStorage has its own ~5 MB cap/.test(txt), txt);
+ ok('D4 quota does not turn a big VFS healthy either', !/All systems healthy/.test(txt), txt);
+ }
+ {
+ // And a big quota must not invent an issue where none exists.
+ const t = await doctor({
+ fetch: okFetch(200),
+ storage: { estimate: () => Promise.resolve({ usage: 10 * 1024 * 1024, quota: 2 * 1024 * 1024 * 1024 }) },
+ ls: makeLS({ webx11_a: 'tiny' }),
+ });
+ ok('D5 healthy storage + huge quota → all healthy',
+ /All systems healthy/.test(report(t)), report(t));
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // E. Degraded browsers — every probe is individually guarded
+ // ══════════════════════════════════════════════════════════════════════
+ {
+ const t = await doctor({ fetch: undefined, files: {} });
+ const txt = report(t);
+ ok('E1 fetch undefined → completes and prints a report',
+ /Doctor Report/.test(txt) && /Measured: VFS/.test(txt), txt);
+ ok('E1b no Network issue invented without a probe', !/Network: unreachable/.test(txt), txt);
+ }
+ {
+ // Blob removed: the UTF-8 fallback must still measure 2 bytes per 'é'.
+ const savedBlob = global.Blob;
+ delete global.Blob;
+ try {
+ const t = await doctor({
+ fetch: okFetch(200),
+ root: rootWithFile('é'.repeat(1024 * 1024)),
+ ls: makeLS({ webx11_vfs: 'i'.repeat(Math.round(8 * 1024 * 1024 / 2)) }),
+ });
+ ok('E2 Blob undefined → completes and still counts UTF-8 (2.00 MB)',
+ /Doctor Report/.test(report(t)) && /Measured: VFS 2\.00 MB UTF-8/.test(report(t)), report(t));
+ } finally {
+ global.Blob = savedBlob;
+ }
+ }
+ {
+ const t = await doctor({ fetch: okFetch(200), storage: undefined, files: {} });
+ const txt = report(t);
+ ok('E3 storage.estimate undefined → completes, no quota line',
+ /Doctor Report/.test(txt) && !/Origin usage/.test(txt), txt);
+ }
+ {
+ // estimate() that throws must not kill the report.
+ const t = await doctor({
+ fetch: okFetch(200),
+ storage: { estimate: () => Promise.reject(new Error('SecurityError')) },
+ });
+ ok('E4 estimate() rejecting → completes', /Doctor Report/.test(report(t)), report(t));
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // F. status must not claim Online/Offline
+ // ══════════════════════════════════════════════════════════════════════
+ {
+ const { m, t } = mkModule({ fetch: okFetch(200) });
+ m.commands['status']();
+ const txt = report(t);
+ const netLine = t.out.map(l => l.msg).find(s => /^Network:/.test(s)) || '';
+ ok('F1 status prints a Network line', netLine.length > 0, txt);
+ ok('F2 status line says neither Online nor Offline',
+ !/\b(Online|Offline)\b/.test(netLine), netLine);
+ ok('F3 status points at doctor', /doctor/.test(netLine), netLine);
+ ok('F4 status still reports files/modules/ollama',
+ /^Files:/m.test(txt) && /^Modules:/m.test(txt) && /^Ollama:/m.test(txt), txt);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // G. Output shape kept: top 3 issues + Recommended actions
+ // ══════════════════════════════════════════════════════════════════════
+ {
+ const t = await doctor({
+ fetch: () => Promise.reject(new Error('down')),
+ ollama: false,
+ root: rootWithFile(asciiFile(3.5)),
+ ls: makeLS({ webx11_big: 'j'.repeat(Math.round(4.5 * 1024 * 1024 / 2)) }),
+ files: { '/logs/current.log': Array.from({ length: 900 }, () => 'x').join('\n') },
+ });
+ const txt = report(t);
+ ok('G1 at most 3 issue lines printed', issueLines(t).length <= 3, issueLines(t).length + ' lines');
+ ok('G2 remainder is summarised', /\.\.\. and \d+ more/.test(txt), txt);
+ ok('G3 Recommended actions section present', /Recommended actions:/.test(txt), txt);
+ }
+
+ // ── summary ───────────────────────────────────────────────────────────
+ console.log('\n' + passes + '/' + (passes + fails) + ' assertions passed');
+ if (fails > 0) { console.log(fails + ' FAILED'); process.exit(1); }
+})();
diff --git a/modules/test/test-vfs-size-bytes.js b/modules/test/test-vfs-size-bytes.js
new file mode 100644
index 0000000..109c58e
--- /dev/null
+++ b/modules/test/test-vfs-size-bytes.js
@@ -0,0 +1,216 @@
+// node modules/test/test-vfs-size-bytes.js
+// test-vfs-size-bytes — `vfs-size` must report REAL UTF-8 STORED BYTES, one unit, everywhere.
+//
+// RED on the pre-patch command (v4.6.159): it printed three different units —
+// total = JSON.stringify(fs.root).length / 1MB
+// per file = child.content.length (UTF-16 code units)
+// system dir = JSON.stringify(readDir(dir)).length (a list of entries, not bytes)
+// The total also carried every structural character of the serialisation, so a text
+// tree reported ~2x its real stored size, while 'é' and '☃' were under-counted.
+//
+// This test pins the NUMBERS THE COMMAND PRINTS, never a helper:
+// 1. unit facts on one-file trees: 'abcd' 4 B, 'é' 2 B, '☃' 3 B, 'é☃' 5 B,
+// 1000 ASCII chars 1000 B, '' 0 B;
+// 2. a 12-file nested tree: total == sum of the per-file rows == sum of the
+// top-directory rows (each file counted once, no structure overhead);
+// 3. every system-dir line == a manual sum of that dir's files;
+// 4. a tree of directories with no files at all neither throws nor prints NaN.
+//
+// Set FS_MODULE_SRC=<path> to point the harness at another copy of module-fs.js
+// (used to prove the test is RED against the pre-fix / mutated file).
+const fsMod = require('fs');
+const path = require('path');
+const SRC = process.env.FS_MODULE_SRC || path.join(__dirname, '..', 'core', 'module-fs.js');
+
+// ── harness ────────────────────────────────────────────────────────────────
+let pass = 0;
+const fails = [];
+function ok(name, cond, detail) {
+ if (cond) { pass++; console.log(' ok ' + name); }
+ else { fails.push(name + (detail ? ' — ' + detail : '')); console.log(' FAIL ' + name + (detail ? ' — ' + detail : '')); }
+}
+function eq(name, actual, expected) {
+ ok(name, actual === expected, 'got ' + JSON.stringify(actual) + ', expected ' + JSON.stringify(expected));
+}
+
+// ── load the real class out of the real file (no re-implementation) ────────
+const src = fsMod.readFileSync(SRC, 'utf8');
+const FileSystemModule = new Function(src + '\nreturn FileSystemModule;')();
+
+// ── tree builders ──────────────────────────────────────────────────────────
+function dir(children) { return { type: 'dir', content: children }; }
+function file(text) { return { type: 'file', content: text }; }
+const B = (s) => Buffer.byteLength(s, 'utf8'); // independent of Blob
+
+// ── drive the shipped command and capture what it prints ───────────────────
+function run(tree) {
+ const out = [];
+ const mod = new FileSystemModule();
+ mod.terminal = { fs: { root: tree }, print: (text) => out.push(String(text)) };
+ mod.registerCommands();
+ mod.commands['vfs-size'](); // a throw escapes here and fails the test loudly
+ return out;
+}
+
+const UNITS = { B: 1, KB: 1024, MB: 1024 * 1024 };
+function toBytes(human) {
+ const m = /^([\d.]+) (B|KB|MB)$/.exec(human);
+ if (!m) return null;
+ return Math.round(parseFloat(m[1]) * UNITS[m[2]]);
+}
+function parse(out) {
+ const r = { totalHuman: null, totalExact: null, files: null, rows: [], rest: null, dirs: [], sys: [] };
+ for (const line of out) {
+ let m;
+ if ((m = /^ {2}Total: ([\d.]+ (?:B|KB|MB)) \((\d+) bytes\) across (\d+) files?$/.exec(line))) {
+ r.totalHuman = toBytes(m[1]); r.totalExact = Number(m[2]); r.files = Number(m[3]);
+ } else if ((m = /^ {4}\d+\. (\S+) — ([\d.]+ (?:B|KB|MB))$/.exec(line))) {
+ r.rows.push({ path: m[1], bytes: toBytes(m[2]) });
+ } else if ((m = /^ {4}\(\+(\d+) more files? — ([\d.]+ (?:B|KB|MB))\)$/.exec(line))) {
+ r.rest = { count: Number(m[1]), bytes: toBytes(m[2]) };
+ } else if ((m = /^ {4}(.+?) — ([\d.]+ (?:B|KB|MB)) \((\d+) files?\)$/.exec(line))) {
+ r.dirs.push({ name: m[1], bytes: toBytes(m[2]), files: Number(m[3]) });
+ } else if ((m = /^ {2}(\/[\w.]+): (\d+) files?, ([\d.]+ (?:B|KB|MB))$/.exec(line))) {
+ r.sys.push({ name: m[1], files: Number(m[2]), bytes: toBytes(m[3]) });
+ }
+ }
+ return r;
+}
+const sum = (a) => a.reduce((x, y) => x + y, 0);
+
+console.log('module: ' + SRC);
+console.log('node ' + process.version + ', Blob is a global: ' + (typeof Blob === 'function'));
+
+// ── 1. unit facts, one file per tree (always row 1, never truncated) ───────
+console.log('\n── 1. one-file trees: bytes actually counted ──');
+const CASES = [
+ ['abcd', 'abcd', 4, 'a file containing "abcd" counts 4 bytes'],
+ ['é', 'é', 2, '"é" counts 2 bytes (1 UTF-16 unit, 2 UTF-8 bytes)'],
+ ['☃', '☃', 3, '"☃" counts 3 bytes'],
+ ['é☃', 'é☃', 5, '"é☃" counts 5 bytes'],
+ ['x'.repeat(1000), '1000 ASCII chars', 1000, 'a 1000-char ASCII file counts 1000 bytes'],
+ ['', '(empty)', 0, 'an empty file counts 0 bytes'],
+];
+for (const [content, label, want, name] of CASES) {
+ const p = parse(run(dir({ 'f.txt': file(content) })));
+ console.log(' ' + String(label).slice(0, 24).padEnd(26) + String(p.totalExact).padStart(6) + ' B row1=' + (p.rows[0] ? p.rows[0].bytes : 'MISSING'));
+ eq(name, p.totalExact, want);
+ eq(' row 1 for ' + label + ' is the same number', p.rows[0] && p.rows[0].bytes, want);
+ eq(' file count for ' + label, p.files, 1);
+}
+
+// ── 2. a 12-file nested tree ──────────────────────────────────────────────
+const tree = dir({
+ 'a.txt': file('abcd'), // 4 (root-level file -> own group row)
+ 'dirA': dir({
+ 'e.txt': file('é'), // 2
+ 'sub': dir({ 'c.txt': file('☃') }), // 3 (three levels deep)
+ }),
+ 'dirB': dir({
+ 'f.txt': file('x'.repeat(1000)), // 1000
+ 'deep': dir({ 'g.txt': file('é☃') }), // 5
+ }),
+ 'dirC': dir({ 'onlySub': dir({ 'deeper': dir({}) }) }), // 0 (no files at any depth)
+ '_snapshots': dir({ 'cp1.json': file('{"a":1}'), 'cp2.json': file('{"b":"é"}') }), // 7 + 11
+ '.trash': dir({ 'old.txt': file('☃☃') }), // 6
+ '_mods': dir({ 'calc.js': file('é'), 'nested': dir({ 'x.js': file('ab☃') }) }), // 2 + 5
+ '.semantic_index': dir({ 'index.json': file('[]') }), // 2
+ 'empty.txt': file(''), // 0
+ // '_sessions' deliberately absent -> line skipped, must not throw
+});
+const EXPECT = B('abcd') + B('é') + B('☃') + B('x'.repeat(1000)) + B('é☃') + B('')
+ + B('{"a":1}') + B('{"b":"é"}') + B('☃☃') + B('é') + B('ab☃') + B('[]');
+const EXPECT_FILES = 12;
+
+let out, p;
+try { out = run(tree); p = parse(out); }
+catch (e) { console.log(' FAIL vfs-size threw on the nested tree: ' + e.message); process.exit(1); }
+const text = out.join('\n');
+const rowSum = sum(p.rows.map(r => r.bytes)) + (p.rest ? p.rest.bytes : 0);
+const dirSum = sum(p.dirs.map(d => d.bytes));
+
+console.log('\n── 2. nested tree totals ──');
+console.log(' printed total (exact) ' + p.totalExact + ' B');
+console.log(' printed total (human) ' + p.totalHuman + ' B (1-decimal, rounded from the exact field)');
+console.log(' per-file rows (' + p.rows.length + ') + remainder ' + (p.rest ? p.rest.bytes : 0) + ' = ' + rowSum);
+console.log(' top-dir rows ' + dirSum);
+console.log(' independent expectation ' + EXPECT + ' B over ' + EXPECT_FILES + ' files');
+console.log(' old metric JSON.stringify(root).length = ' + JSON.stringify(tree).length + ' (would have been printed as MB)');
+eq('file count', p.files, EXPECT_FILES);
+eq('printed total is the UTF-8 byte sum', p.totalExact, EXPECT);
+ok('human total is the same number (1-decimal KB tolerance)',
+ Math.abs(p.totalHuman - p.totalExact) <= 51, 'human=' + p.totalHuman + ' exact=' + p.totalExact);
+eq('per-file rows + remainder sum to the printed total', rowSum, p.totalExact);
+eq('top-directory rows sum to the printed total', dirSum, p.totalExact);
+ok('printed total is NOT the old JSON.stringify(root).length',
+ p.totalExact !== JSON.stringify(tree).length,
+ 'old metric = ' + JSON.stringify(tree).length);
+
+console.log('\n── 2b. per-file table ──');
+for (const r of p.rows) console.log(' ' + r.path.padEnd(22) + String(r.bytes).padStart(6) + ' B');
+if (p.rest) console.log(' (+' + p.rest.count + ' more files — ' + p.rest.bytes + ' B)');
+const rowFor = (path) => (p.rows.find(r => r.path === path) || {}).bytes;
+eq('largest file wins', rowFor('/dirB/f.txt'), 1000);
+eq('nested file is listed once at its own path', rowFor('/dirA/sub/c.txt'), 3);
+eq('truncated tail is one row, not silently dropped', p.rest && p.rest.count, EXPECT_FILES - p.rows.length);
+
+console.log('\n── 2c. top directories ──');
+for (const d of p.dirs) console.log(' ' + d.name.padEnd(22) + String(d.bytes).padStart(6) + ' B ' + d.files + ' files');
+const dirFor = (n) => p.dirs.find(d => d.name === n) || {};
+eq('/dirA counts its subdir once (2 + 3)', dirFor('/dirA').bytes, 5);
+eq('/dirB counts deep/g.txt once (1000 + 5)', dirFor('/dirB').bytes, 1005);
+eq('/dirB file count is 2, not 3', dirFor('/dirB').files, 2);
+eq('root-level files get their own row', dirFor('/(root files)').bytes, 4);
+eq('directory with only subdirs is 0 bytes', dirFor('/dirC').bytes, 0);
+eq('every top-level dir appears (7 dirs + the root-files row)', p.dirs.length, 8);
+
+// ── 3. system dirs: real bytes, equal to a manual sum ─────────────────────
+console.log('\n── 3. system dirs ──');
+for (const s of p.sys) console.log(' ' + s.name.padEnd(22) + String(s.bytes).padStart(6) + ' B ' + s.files + ' files');
+const sysFor = (n) => p.sys.find(s => s.name === n) || {};
+const SYS_EXPECT = {
+ '/_snapshots': { files: 2, bytes: B('{"a":1}') + B('{"b":"é"}') },
+ '/.trash': { files: 1, bytes: B('☃☃') },
+ '/_mods': { files: 2, bytes: B('é') + B('ab☃') },
+ '/.semantic_index': { files: 1, bytes: B('[]') },
+};
+for (const [name, want] of Object.entries(SYS_EXPECT)) {
+ eq(name + ' bytes == manual sum', sysFor(name).bytes, want.bytes);
+ eq(name + ' file count (recursive)', sysFor(name).files, want.files);
+}
+ok('missing /_sessions prints no line instead of throwing', !sysFor('/_sessions').name);
+eq('exactly the four present system dirs printed', p.sys.length, 4);
+ok('_mods and .semantic_index are covered (the two dirs that dominate a checkpoint)',
+ !!sysFor('/_mods').name && !!sysFor('/.semantic_index').name);
+
+// ── 4. a tree of directories with no files ────────────────────────────────
+console.log('\n── 4. directories only, no files ──');
+let out2;
+try {
+ out2 = run(dir({ 'a': dir({ 'b': dir({ 'c': dir({}) }) }), 'd': dir({}) }));
+ ok('does not throw', true);
+} catch (e) {
+ ok('does not throw', false, e.message);
+ out2 = [];
+}
+const text2 = out2.join('\n');
+const p2 = parse(out2);
+eq('total is 0 bytes', p2.totalExact, 0);
+eq('file count is 0', p2.files, 0);
+ok('no "Largest:" block when there are no files', !/Largest:/.test(text2));
+eq('dir rows still sum to the total', sum(p2.dirs.map(d => d.bytes)), 0);
+
+// ── 5. no undefined / NaN anywhere ────────────────────────────────────────
+ok('main output has no "undefined"', !/undefined/.test(text));
+ok('main output has no "NaN"', !/NaN/.test(text));
+ok('dirs-only output has no "undefined"', !/undefined/.test(text2));
+ok('dirs-only output has no "NaN"', !/NaN/.test(text2));
+
+// ── verdict ───────────────────────────────────────────────────────────────
+console.log('\n' + (pass + fails.length) + ' assertions, ' + pass + ' passed, ' + fails.length + ' failed');
+if (fails.length) {
+ fails.forEach(f => console.log(' FAILED: ' + f));
+ console.log('RESULT: FAIL');
+ process.exit(1);
+}
+console.log('RESULT: PASS — vfs-size reports UTF-8 stored bytes in one unit everywhere');
diff --git a/modules/test/test-virtual-fs-require-leaf.js b/modules/test/test-virtual-fs-require-leaf.js
new file mode 100644
index 0000000..7b62d0e
--- /dev/null
+++ b/modules/test/test-virtual-fs-require-leaf.js
@@ -0,0 +1,160 @@
+// node modules/test/test-virtual-fs-require-leaf.js
+// Proves the 2026-11-09 virtual-fs patch: phantom deletes are refused (_requireLeaf) and
+// checkpoints stop cloning _mods / .semantic_index (while rollback keeps the live copies).
+// Offline: no browser, no real localStorage, no IndexedDB, no timers.
+const fsMod = require('fs'), path = require('path');
+const ROOT = path.join(__dirname, '..', 'core');
+
+// ── persistence stub: in-memory localStorage, IndexedDB that refuses to exist ──
+const ls = {};
+global.localStorage = { getItem: k => (k in ls ? ls[k] : null), setItem: (k, v) => { ls[k] = String(v); }, removeItem: k => { delete ls[k]; } };
+global.indexedDB = { open() { throw new Error('no idb in tests'); }, deleteDatabase() { throw new Error('no idb in tests'); } };
+global.window = global;
+
+// The file ends with `window.VirtualFileSystem = VirtualFileSystem;` — so the class is a
+// window global *and* is in scope at the bottom of the script. Return the real class object.
+function load(file, cls) {
+ const src = fsMod.readFileSync(path.join(ROOT, file), 'utf8');
+ return new Function(src + '\nreturn ' + cls + ';')();
+}
+const VFS = load('virtual-fs.js', 'VirtualFileSystem');
+
+let fails = 0, passes = 0;
+function ok(name, cond, detail) {
+ if (cond) { passes++; return; }
+ fails++;
+ console.log('FAIL ' + name + (detail ? ' — ' + detail : ''));
+}
+function mk() {
+ const vfs = new VFS();
+ vfs.saveToStorage = () => true; // keep the 2s setTimeout saver out of the test
+ vfs.root = { type: 'dir', content: {} };
+ vfs.currentPath = [];
+ if (vfs._ready && typeof vfs._ready.catch === 'function') vfs._ready.catch(() => {});
+ return vfs;
+}
+const keysOf = node => Object.keys(node.content).sort();
+
+// ── 0. export shape ────────────────────────────────────────────────────────────
+ok('0 export shape: window.VirtualFileSystem is the class', typeof VFS === 'function' && global.window.VirtualFileSystem === VFS);
+ok('0 constructor name', VFS.name === 'VirtualFileSystem', VFS.name);
+ok('0 has _requireLeaf guard', typeof VFS.prototype._requireLeaf === 'function');
+
+// ── A. deleteFile of a missing leaf must be refused, sibling untouched ─────────
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/docs');
+ vfs.writeFile('/docs/real.txt', 'keep me');
+ ok('A1 deleteFile("/docs/ghost.txt") returns false', vfs.deleteFile('/docs/ghost.txt') === false);
+ ok('A2 sibling survived', vfs.readFile('/docs/real.txt') === 'keep me');
+ ok('A3 nothing was trashed', !vfs.root.content['.trash'] || keysOf(vfs.root.content['.trash']).length === 0);
+ ok('A4 deleteFile("/no/such/dir/x.txt") returns false', vfs.deleteFile('/no/such/dir/x.txt') === false);
+
+ // ── B. deleteFile of a real file removes exactly that file ────────────────
+ vfs.writeFile('/docs/other.txt', 'other');
+ ok('B1 deleteFile of a real file returns true', vfs.deleteFile('/docs/real.txt') === true);
+ ok('B2 the file is gone', vfs.readFile('/docs/real.txt') === null);
+ ok('B3 sibling intact', vfs.readFile('/docs/other.txt') === 'other');
+ ok('B4 only that entry was removed', JSON.stringify(keysOf(vfs.root.content.docs)) === '["other.txt"]', JSON.stringify(keysOf(vfs.root.content.docs)));
+}
+
+// ── C. deleteCheckpoint of a nonexistent checkpoint is refused ────────────────
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/_snapshots');
+ vfs.writeFile('/_snapshots/checkpoint-keep.json', JSON.stringify({ createdAt: '2026-01-01T00:00:00.000Z' }));
+ ok('C1 deleteCheckpoint("nope") is falsy', !vfs.deleteCheckpoint('nope'));
+ ok('C2 _snapshots unchanged', JSON.stringify(keysOf(vfs.root.content['_snapshots'])) === '["checkpoint-keep.json"]', JSON.stringify(keysOf(vfs.root.content['_snapshots'])));
+ ok('C3 existing checkpoint still readable', !!vfs.fsReadJSON('/_snapshots/checkpoint-keep.json'));
+ ok('C4 real checkpoint delete still returns true', vfs.deleteCheckpoint('keep') === true);
+ ok('C5 it was actually removed', keysOf(vfs.root.content['_snapshots']).length === 0);
+}
+
+// ── D. a checkpoint must not carry _mods or .semantic_index ───────────────────
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/_mods'); vfs.writeFile('/_mods/mod-a.js', 'MOD-ORIGINAL');
+ vfs.mkdirRecursive('/.semantic_index'); vfs.writeFile('/.semantic_index/idx.json', '{"nodes":1}');
+ vfs.writeFile('/notes.txt', 'plain');
+ const name = vfs.checkpoint('excl');
+ const snap = vfs.fsReadJSON('/_snapshots/checkpoint-' + name + '.json');
+ ok('D1 checkpoint written', !!snap && !!snap.root);
+ ok('D2 _mods absent from the snapshot root', !('_mods' in snap.root.content), JSON.stringify(Object.keys(snap.root.content)));
+ ok('D3 .semantic_index absent from the snapshot root', !('.semantic_index' in snap.root.content));
+ ok('D4 ordinary files are still snapshotted', snap.root.content['notes.txt'] && snap.root.content['notes.txt'].content === 'plain');
+ ok('D5 scaffolding still excluded (_snapshots/_sessions/.trash)',
+ !('_snapshots' in snap.root.content) && !('_sessions' in snap.root.content) && !('.trash' in snap.root.content));
+
+ // ── E. rollback keeps the LIVE _mods / .semantic_index ────────────────────
+ vfs.writeFile('/notes.txt', 'edited after checkpoint');
+ ok('E0 pre-rollback edit in place', vfs.readFile('/notes.txt') === 'edited after checkpoint');
+ ok('E1 rollback returns true', vfs.rollback(name) === true);
+ ok('E2 live _mods survives rollback', vfs.readFile('/_mods/mod-a.js') === 'MOD-ORIGINAL', String(vfs.readFile('/_mods/mod-a.js')));
+ ok('E3 live .semantic_index survives rollback', vfs.readFile('/.semantic_index/idx.json') === '{"nodes":1}');
+ ok('E4 ordinary file rolled back to the snapshot content', vfs.readFile('/notes.txt') === 'plain', String(vfs.readFile('/notes.txt')));
+ ok('E5 snapshot bookkeeping survived rollback', keysOf(vfs.root.content['_snapshots']).indexOf('checkpoint-' + name + '.json') !== -1);
+}
+
+// ── F. a legacy checkpoint that *does* embed _mods must not clobber the live one ──
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/_mods'); vfs.writeFile('/_mods/live.js', 'LIVE');
+ vfs.mkdirRecursive('/.semantic_index'); vfs.writeFile('/.semantic_index/live.json', '{"live":true}');
+ vfs.writeFile('/f.txt', 'v1');
+ const name = vfs.checkpoint('legacy');
+ const p = '/_snapshots/checkpoint-' + name + '.json';
+ const snap = vfs.fsReadJSON(p); // simulate a pre-patch 4MB snapshot
+ snap.root.content['_mods'] = { type: 'dir', content: { 'stale.js': { type: 'file', content: 'STALE' } } };
+ snap.root.content['.semantic_index'] = { type: 'dir', content: { 'stale.json': { type: 'file', content: 'STALE' } } };
+ vfs.writeFile(p, JSON.stringify(snap));
+ ok('F1 rollback of a legacy snapshot succeeds', vfs.rollback(name) === true);
+ ok('F2 live _mods wins over the stale copy', vfs.readFile('/_mods/live.js') === 'LIVE' && vfs.readFile('/_mods/stale.js') === null);
+ ok('F3 live .semantic_index wins over the stale copy',
+ vfs.readFile('/.semantic_index/live.json') === '{"live":true}' && vfs.readFile('/.semantic_index/stale.json') === null);
+}
+
+// ── G. plain checkpoint/restore round trip is unbroken ───────────────────────
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/work');
+ vfs.writeFile('/work/a.txt', 'version-1');
+ vfs.writeFile('/work/b.txt', 'b-1');
+ const name = vfs.checkpoint('roundtrip');
+ vfs.writeFile('/work/a.txt', 'version-2');
+ vfs.deleteFile('/work/b.txt');
+ vfs.writeFile('/work/new.txt', 'added-later');
+ ok('G1 rollback returns true', vfs.rollback(name) === true);
+ ok('G2 modified file restored', vfs.readFile('/work/a.txt') === 'version-1', String(vfs.readFile('/work/a.txt')));
+ ok('G3 deleted file restored', vfs.readFile('/work/b.txt') === 'b-1');
+ ok('G4 post-checkpoint file removed by the rollback', vfs.readFile('/work/new.txt') === null);
+}
+
+// ── H. measured saving on a realistic 2 MB _mods ────────────────────────────
+{
+ const vfs = mk();
+ vfs.mkdirRecursive('/_mods');
+ vfs.writeFile('/_mods/bundle.js', 'x'.repeat(2 * 1024 * 1024)); // 2 MiB module originals
+ vfs.mkdirRecursive('/.semantic_index');
+ vfs.writeFile('/.semantic_index/idx.json', JSON.stringify({ nodes: 'y'.repeat(4096) }));
+ vfs.writeFile('/doc.txt', 'hello');
+ const name = vfs.checkpoint('big');
+ const raw = vfs.readFile('/_snapshots/checkpoint-' + name + '.json');
+ const after = raw.length;
+ // what the old code produced: the same snapshot with the two dirs cloned back in
+ const snap = JSON.parse(raw);
+ snap.root.content['_mods'] = JSON.parse(JSON.stringify(vfs.root.content['_mods']));
+ snap.root.content['.semantic_index'] = JSON.parse(JSON.stringify(vfs.root.content['.semantic_index']));
+ const before = JSON.stringify(snap).length;
+ console.log('MEASURE checkpoint bytes before exclusion: ' + before.toLocaleString('en-US'));
+ console.log('MEASURE checkpoint bytes after exclusion: ' + after.toLocaleString('en-US'));
+ console.log('MEASURE saved: ' + (before - after).toLocaleString('en-US') + ' bytes (' + (100 * (before - after) / before).toFixed(1) + '%)');
+ ok('H1 excluded snapshot stays small (< 200 KB)', after < 200 * 1024, after + ' bytes');
+ ok('H2 the old shape would have carried the 2 MiB _mods', before > 2 * 1024 * 1024, before + ' bytes');
+}
+
+if (fails === 0) {
+ console.log('test-virtual-fs-require-leaf: ' + passes + ' passed, 0 failed');
+} else {
+ console.log('test-virtual-fs-require-leaf: ' + passes + ' passed, ' + fails + ' FAILED');
+ process.exit(1);
+}