diff options
Diffstat (limited to 'modules/test/test-doctor-thresholds.js')
| -rw-r--r-- | modules/test/test-doctor-thresholds.js | 341 |
1 files changed, 341 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); } +})(); |