aboutsummaryrefslogtreecommitdiff
path: root/modules/test/test-doctor-thresholds.js
blob: 120b941fe33d3e87936bfff81cfebfc2982e49b6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
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); }
})();