diff options
| author | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-12 17:02:47 +0100 |
|---|---|---|
| committer | Hermes SysOp <sysop@webx11.duckdns.org> | 2026-11-12 17:02:47 +0100 |
| commit | e452ce7785e5e47ec01a409d6da1f59564f8f365 (patch) | |
| tree | e2dc45ecf479e55df571045390bba3d927d8a4e2 /modules/test | |
| parent | f140aa7f920c687d0c576763da956dd7017c1a27 (diff) | |
fix(newos4): stop reporting network and VFS size as facts we do not have (v4.6.161)
1. The last two `navigator.onLine`-as-fact payloads
- module-aiassist.js: the debug/health payload carried
`online: navigator.onLine`, consumed by downloads/dashboard.html, which rendered
"๐ด Offline" โ on an Android WebView (reports false while fully connected) that
told working users they were offline. The key is gone; the payload carries
`network:'unknown'`, `networkKnown:false` and `networkHint`.
- module-world-model.js: the snapshot had the same field, and it is also handed to the
LLM through `_modelDigest()`/`ask()`, so the model was told the user was offline.
Now `network:'unknown'` + `networkKnown:false`.
- `_loadFromVFS()` scrubs `system.online` from a model persisted by an older build โ
otherwise the stale boolean would survive inside the digest for the life of the VFS.
- downloads/dashboard.html: `networkText()` renders reachable/unreachable/http-* only
when doctor produced them ("(doctor)"), `unprobed` as "โ not probed", and everything
else as "โ unknown (run doctor for a live probe)". A legacy payload's `online:false`
lands in "unknown", never "Offline". The row label is "Network", not "Online".
- Neither payload invents a probe: SystemModule stores no last-doctor verdict, so
'unknown' is the honest value until one exists.
2. `vfsSize` in both payloads was a different unit again
- aiassist: `JSON.stringify(fs.root).length / 1024`; world-model:
`JSON.stringify(fs.root).length`. Same lie v4.6.160 fixed in `vfs-size` โ UTF-16 code
units plus serialisation structure. Both now sum UTF-8 bytes of file content
(`_utf8Len`/`_measureVfs`, Blob โ TextEncoder โ code-point fallback, depth/node
capped) and carry `sizeBytes` beside the human string so the rows reconcile.
- world-model's `_gatherFileInfo().size` used a THIRD unit on an adjacent line
(`JSON.stringify(fs.root).length/1024`); now the same measurement, plus `sizeBytes`.
- `showModel()` renders exact bytes and tolerates a pre-fix persisted string.
3. Predictor made a guaranteed-401 request on every boot (UI-plan finding F6)
- `_wdConfig()` returns `user:''`/`pass:''` when the saved WebDAV config has a URL but
no credentials, and `_pullFromServer`/`_pushToServer` fetched the auth-gated
`/spaces/<user>/_data/predictor.json` anyway โ 107 ร 401 in one day, logged by the
browser as console errors. Both now refuse without credentials
(`cache-only (no credentials)`).
Verified: 42/42 node tests, 2 new files (38 payload/honesty assertions, 8 predictor-store
assertions); the predictor suite is 4/8 against the pre-fix source, and the payload suite
fails 14 assertions against HEAD (`online:false` present, vfsSize === JSON.stringify
length). Live Chromium on v4.6.161, on the served dashboard: a legacy payload with
`online:false` renders "Network โ unknown (run doctor for a live probe)", the current
payload renders "85.8 KB (87880 B)" and the same unknown, and a doctor-produced
`unreachable` renders "โ ๏ธ unreachable (doctor)". Served bytes identical for
core-bundle.js, main.html and downloads/dashboard.html. `maintain.py --fix` all PASS.
Diffstat (limited to 'modules/test')
| -rw-r--r-- | modules/test/test-network-payload-honesty.js | 298 | ||||
| -rw-r--r-- | modules/test/test-predictor-store-auth.js | 80 |
2 files changed, 378 insertions, 0 deletions
diff --git a/modules/test/test-network-payload-honesty.js b/modules/test/test-network-payload-honesty.js new file mode 100644 index 0000000..1f18725 --- /dev/null +++ b/modules/test/test-network-payload-honesty.js @@ -0,0 +1,298 @@ +// node modules/test/test-network-payload-honesty.js +// Payload honesty: no payload may turn `navigator.onLine` into a network verdict, and the +// world-model VFS size must be STORED UTF-8 BYTES. +// +// Why: an Android WebView reports navigator.onLine === false while the page is fully connected. +// v4.6.159/160 removed that boolean from `status`, `doctor`, the status bar and the net test; +// these are the two payloads that were left, plus every reader that printed "Offline" from them. +// +// Asserts (all on OUTPUT, never on a regex over source): +// ยท navigator.onLine === false must not produce `online: false` in the aiassist health payload, +// nor any online/offline claim in the world-model snapshot or its LLM digest; +// ยท world-model `vfsSize` == UTF-8 byte sum ('รฉ' 2 + 'โ' 3 + 1000 ASCII + 'abc' 3 = 1008), +// and != JSON.stringify(root).length (UTF-16 units + serialisation structure); +// ยท the consumer renders 'unknown': the real dashboard script is executed against a stub DOM +// and the actual health panel HTML is inspected; world-model's showModel output is captured; +// ยท a missing `navigator` does not throw. +// +// N4_CORE / N4_DASH point the same assertions at another build (mutation check). +const fsMod = require('fs'), path = require('path'); + +const CORE = process.env.N4_CORE || path.join(__dirname, '..', 'core'); +const DASH = process.env.N4_DASH || path.join(__dirname, '..', '..', 'downloads', 'dashboard.html'); + +// โโ browser globals the modules reach for โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +const store = {}; +global.window = global; +global.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: k => { delete store[k]; }, +}; +global.indexedDB = { open() { throw new Error('no idb'); }, deleteDatabase() { throw new Error('no idb'); } }; +global.NEWOS4_VERSION = 'v4.6.160-test'; + +function setNavigator(onLine) { + if (onLine === 'missing') { + try { delete globalThis.navigator; } catch (e) { /* older node: shadow below */ } + try { Object.defineProperty(globalThis, 'navigator', { configurable: true, value: undefined }); } catch (e) {} + return; + } + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { onLine: onLine, userAgent: 'test-agent', storage: undefined }, + }); +} + +// โโ classes, loaded from source exactly as the bundle does โโโโโโโโโโโโโโโโโ +const WorldModelModule = new Function( + fsMod.readFileSync(path.join(CORE, 'module-world-model.js'), 'utf8') + '\nreturn WorldModelModule;')(); +const AI = new Function( + fsMod.readFileSync(path.join(CORE, 'module-aiassist.js'), 'utf8') + '\nreturn WebX11AIAssistantModule;')(); + +let fails = 0, passes = 0; +function ok(name, cond, detail) { + if (cond) { passes++; return; } + fails++; + console.log('FAIL ' + name + (detail ? ' โ ' + detail : '')); +} + +// โโ the honest tree: 'รฉ' = 2 B, 'โ' = 3 B, 1000 ASCII, nested 'abc' = 3 B โโ +const EXPECTED_BYTES = 2 + 3 + 1000 + 3; // 1008 stored bytes +function makeTree() { + return { + type: 'dir', + content: { + 'e.txt': { type: 'file', content: 'รฉ' }, + 's.txt': { type: 'file', content: 'โ' }, + 'ascii.txt': { type: 'file', content: 'x'.repeat(1000) }, + 'sub': { type: 'dir', content: { 'n.txt': { type: 'file', content: 'abc' } } }, + }, + }; +} + +// โโ an in-memory VFS with only the methods these modules call โโโโโโโโโโโโโโ +function mkFs(root) { + const writes = new Map(), json = new Map(); + const resolve = (p) => { + if (!p || p === '/') return root; + const parts = String(p).replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); + let node = root; + for (const part of parts) { + node = node && node.content && node.content[part]; + if (!node) return null; + } + return node || null; + }; + const fs = { + root, + currentPath: [], + writes, + ls(p) { + const node = resolve(p || '/'); + if (!node || !node.content) return []; + return Object.keys(node.content).map(name => ({ + name, type: node.content[name].type, _virtual: false, + })); + }, + readDir(p) { return fs.ls(p); }, + readFile(p) { + if (writes.has(p)) return writes.get(p); + const n = resolve(p); + return n && n.type === 'file' ? (n.content || '') : null; + }, + writeFile(p, c) { writes.set(p, String(c)); return true; }, + deleteFile(p) { writes.delete(p); return true; }, + mkdir() { return true; }, + fsWriteJSON(p, obj) { json.set(p, obj); writes.set(p, JSON.stringify(obj)); return true; }, + fsReadJSON(p) { + if (json.has(p)) return json.get(p); + if (writes.has(p)) { try { return JSON.parse(writes.get(p)); } catch (e) { return null; } } + const n = resolve(p); + if (n && n.type === 'file') { try { return JSON.parse(n.content); } catch (e) { return null; } } + return null; + }, + }; + return fs; +} + +function mkTerminal(fs, wm) { + const out = []; + return { + out, + text: () => out.map(l => l.msg).join('\n'), + fs, + ollamaManager: { connected: false, currentModel: null, currentProvider: null }, + moduleManager: { + modules: wm ? { WorldModelModule: wm } : {}, + moduleRegistry: [{ name: 'A' }, { name: 'B' }], + getAllCommands: () => ({}), + }, + countErrors: () => ({ total: 0, debug: 0, latency: 0, log: 0 }), + print: (msg, cls) => out.push({ msg: String(msg), cls: cls || '' }), + }; +} + +function newWorldModel() { + const wm = new WorldModelModule(); + wm.terminal = mkTerminal(mkFs(makeTree())); + wm._synthesize(); + return wm; +} + +async function aiassistPayload(wm) { + const ai = Object.create(AI.prototype); + ai.terminal = mkTerminal(mkFs(makeTree()), wm); + return await ai._collectDashboardData(); +} + +// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +// A/B. navigator.onLine === false must not become a payload fact +// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ +(async function main() { + setNavigator(false); + const wm = newWorldModel(); + const data = await aiassistPayload(wm); + + // โโ A. aiassist health payload (debug panel / dashboard source) โโ + const health = (data && data.health) || {}; + ok('A1 health payload has no `online` key', + !Object.prototype.hasOwnProperty.call(health, 'online'), JSON.stringify(health)); + ok('A2 serialised health carries no "online"', + JSON.stringify(health).indexOf('"online"') === -1, JSON.stringify(health)); + ok('A3 health.network is "unknown"', health.network === 'unknown', String(health.network)); + ok('A4 health.networkKnown is false', health.networkKnown === false, String(health.networkKnown)); + ok('A5 health.vfsSizeBytes is the UTF-8 sum', + health.vfsSizeBytes === EXPECTED_BYTES, String(health.vfsSizeBytes)); + ok('A6 health.vfsSize is a byte figure, not JSON length', + typeof health.vfsSize === 'string' && /B$/.test(health.vfsSize.trim()), String(health.vfsSize)); + ok('A7 whole payload contains no "online":false', + JSON.stringify(data).indexOf('"online":false') === -1); + ok('A8 aiassist vfs bytes differ from JSON.stringify(root).length', + health.vfsSizeBytes !== JSON.stringify(makeTree()).length, + health.vfsSizeBytes + ' vs ' + JSON.stringify(makeTree()).length); + + // โโ B. world-model snapshot + LLM digest โโ + const sys = (wm._model && wm._model.system) || {}; + ok('B1 world-model system has no `online` key', + !Object.prototype.hasOwnProperty.call(sys, 'online'), JSON.stringify(sys)); + ok('B2 world-model network is "unknown"', sys.network === 'unknown', String(sys.network)); + ok('B3 world-model networkKnown is false', sys.networkKnown === false, String(sys.networkKnown)); + const snap = JSON.stringify(wm._modelSnapshot()); + ok('B4 snapshot has no "online" field', snap.indexOf('"online"') === -1); + ok('B5 snapshot states network unknown', /"network":"unknown"/.test(snap)); + const digest = wm._modelDigest(); + ok('B6 digest makes no Online/Offline claim', + !/\b(Online|Offline)\b/.test(digest), digest.slice(0, 200)); + ok('B7 world-model vfsSize is the UTF-8 sum', + sys.vfsSize === EXPECTED_BYTES, String(sys.vfsSize)); + ok('B8 vfsSize differs from JSON.stringify(root).length', + sys.vfsSize !== JSON.stringify(makeTree()).length, + sys.vfsSize + ' vs ' + JSON.stringify(makeTree()).length); + ok('B9 files.sizeBytes reconciles with system.vfsSize', + wm._model.files.sizeBytes === sys.vfsSize, wm._model.files.sizeBytes + ' vs ' + sys.vfsSize); + + // โโ C. the UTF-8 unit itself, one file at a time (no averaging) โโ + // Guarded so a missing helper FAILS an assertion instead of aborting the run. + const u8len = (s) => (typeof wm._utf8Len === 'function' ? wm._utf8Len(s) : NaN); + const measure = (n) => (typeof wm._measureVfs === 'function' ? wm._measureVfs(n) : { bytes: NaN, files: NaN }); + ok('C1 รฉ counts as 2 bytes', u8len('รฉ') === 2, String(u8len('รฉ'))); + ok('C2 โ counts as 3 bytes', u8len('โ') === 3, String(u8len('โ'))); + ok('C3 a nested dir adds only its own file content', + measure(makeTree().content['sub']).bytes === 3, String(measure(makeTree().content['sub']).bytes)); + ok('C4 measure counts files, not directories', + measure(makeTree()).files === 4, String(measure(makeTree()).files)); + + // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + // D. the consumer renders 'unknown' โ real dashboard script, stub DOM + // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + const dashHtml = fsMod.readFileSync(DASH, 'utf8'); + let dashScript = null; + for (const m of dashHtml.matchAll(/<script>([\s\S]*?)<\/script>/g)) { + if (m[1].includes('function networkText')) { dashScript = m[1]; break; } + } + ok('D0 dashboard script with networkText found', !!dashScript, DASH); + + function renderHealth(healthPayload) { + const els = new Map(); + const el = (id) => { + if (!els.has(id)) els.set(id, { id, textContent: '', innerHTML: '', className: '', style: {} }); + return els.get(id); + }; + const payload = { + version: 'v4.6.160-test', + timestamp: Date.now(), + modules: { loaded: 2, total: 2, modifiedCount: 0 }, + agents: { total: 0, busy: 0, idle: 0, list: [] }, + memory: { heap: '1MB', graphNodes: 0 }, + vfs: { files: 4, dirs: 2, size: healthPayload.vfsSize, sizeBytes: healthPayload.vfsSizeBytes }, + ollama: { connected: false }, + skills: { count: 0 }, + latency: [], + health: healthPayload, + }; + const store2 = { webx11_dashboard_data: JSON.stringify(payload) }; + const ls = { + getItem: k => (k in store2 ? store2[k] : null), + setItem: (k, v) => { store2[k] = String(v); }, + removeItem: k => { delete store2[k]; }, + }; + const win = { addEventListener() {}, refreshNow: null }; + const run = new Function('document', 'window', 'localStorage', 'setInterval', 'clearInterval', dashScript); + run({ getElementById: el }, win, ls, () => 0, () => {}); + return el('health-metrics').innerHTML; + } + + if (dashScript) { + const h1 = renderHealth({ modules: '2/2', vfsSize: '1008 B', vfsSizeBytes: 1008, network: 'unknown', networkKnown: false, errors: 0 }); + ok('D1 unknown renders as unknown', /unknown/.test(h1), h1); + ok('D2 unknown never renders Offline', !/Offline/.test(h1), h1); + ok('D3 unknown never renders Online', !/Online/.test(h1), h1); + ok('D4 health panel still labels the row Network', /<span>Network<\/span>/.test(h1), h1); + ok('D5 VFS row shows the byte figure', /1008 B/.test(h1), h1); + + // A payload from an older bundle: `online: false` must not become "Offline". + const h2 = renderHealth({ modules: '2/2', vfsSize: '1.9KB', online: false, errors: 0 }); + ok('D6 legacy online:false does not render Offline', !/Offline/.test(h2), h2); + ok('D7 legacy online:false renders unknown', /unknown/.test(h2), h2); + + // The doctor verdict path still works when a verdict exists. + const h3 = renderHealth({ modules: '2/2', vfsSize: '1008 B', network: 'reachable', networkKnown: true, errors: 0 }); + ok('D8 a doctor verdict is shown as reachable', /reachable/.test(h3), h3); + ok('D9 reachable is not rendered as Offline', !/Offline/.test(h3), h3); + } + + // โโ E. world-model's own renderer โโ + wm.showModel(); + const shown = wm.terminal.text(); + ok('E1 world-model output makes no Online/Offline claim', + !/\b(Online|Offline)\b/.test(shown), shown); + ok('E2 world-model output prints the byte count', /1008 bytes/.test(shown), shown); + + // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + // F. no navigator at all must not throw, and must not invent a verdict + // โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + setNavigator('missing'); + ok('F0 navigator really is absent', typeof navigator === 'undefined', typeof navigator); + let thrown = null, data2 = null, wm2 = null; + try { + wm2 = newWorldModel(); + data2 = await aiassistPayload(wm2); + } catch (e) { thrown = e; } + ok('F1 missing navigator does not throw', thrown === null, thrown && thrown.message); + ok('F2 aiassist health still says unknown without navigator', + data2 && data2.health && data2.health.network === 'unknown' && data2.health.networkKnown === false); + ok('F3 world-model snapshot still says unknown without navigator', + wm2 && wm2._model && wm2._model.system.network === 'unknown' && wm2._model.system.networkKnown === false); + ok('F4 no online key appears when navigator is absent', + !data2 || JSON.stringify(data2).indexOf('"online"') === -1); + + console.log(''); + console.log(passes + '/' + (passes + fails) + ' assertions passed'); + if (fails > 0) { + console.log('RESULT: FAIL โ ' + fails + ' assertion(s) failed'); + process.exit(1); + } + console.log('RESULT: PASS โ no payload or reader turns navigator.onLine into a verdict, vfsSize is UTF-8 bytes'); + process.exit(0); +})(); diff --git a/modules/test/test-predictor-store-auth.js b/modules/test/test-predictor-store-auth.js new file mode 100644 index 0000000..6c37812 --- /dev/null +++ b/modules/test/test-predictor-store-auth.js @@ -0,0 +1,80 @@ +// node modules/test/test-predictor-store-auth.js +// F6 (2026-11-09): the predictor store is an auth-gated /spaces/<user>/_data path, but _wdConfig() +// returns user:''/pass:'' whenever the saved WebDAV config has no credentials โ so every page load +// made a guaranteed-401 request (measured 107 in one day) and the console reported it as an error. +// No credentials must mean no request. +const fs = require('fs'); +const path = require('path'); +const SRC = process.env.PRED_SRC || path.join(__dirname, '..', 'core', 'module-predictor.js'); +const src = fs.readFileSync(SRC, 'utf8'); + +let pass = 0, fail = 0; +const ok = (name, cond, detail) => { + if (cond) { pass++; console.log('ok ' + name); } + else { fail++; console.log('FAIL ' + name + (detail !== undefined ? ' โ ' + detail : '')); } +}; + +function load(localStore) { + const store = Object.assign({}, localStore); + global.localStorage = { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + removeItem: (k) => { delete store[k]; }, + }; + global.window = global; + global.setTimeout = global.setTimeout; + const Mod = new Function(src + '\nreturn PredictorModule;')(); + const m = new Mod(); + m.terminal = { + print: () => {}, + fs: { + readFile: () => '{}', + fsReadJSON: () => null, + fsWriteJSON: () => true, + readDir: () => [], + mkdirRecursive: () => true, + }, + }; + return m; +} + +(async () => { + const cfgNoCreds = JSON.stringify({ url: 'https://webx11.duckdns.org/spaces/agando', user: '', password: '' }); + const cfgWithCreds = JSON.stringify({ url: 'https://webx11.duckdns.org/spaces/agando', user: 'agando', password: 'pw' }); + + // ---- 1. no credentials โ zero requests, both directions ---- + let calls = 0; + global.fetch = () => { calls++; return Promise.resolve({ ok: true, status: 200, json: async () => ({}) }); }; + let m = load({ webx11_webdav_config: cfgNoCreds }); + const pulled = await m._pullFromServer(true); + const pushed = await m._pushToServer(); + ok('pull without credentials returns false', pulled === false); + ok('push without credentials returns false', pushed === false); + ok('no fetch was made without credentials', calls === 0, 'calls=' + calls); + ok('store mode names the reason', /no credentials/.test(m._storeMode), m._storeMode); + + // ---- 2. credentials present โ still talks to the server ---- + calls = 0; + let sawAuth = null; + global.fetch = (url, opts) => { + calls++; + sawAuth = opts && opts.headers && opts.headers.Authorization; + return Promise.resolve({ ok: true, status: 200, json: async () => ({ dataPoints: [], accuracy: { total: 0 } }) }); + }; + m = load({ webx11_webdav_config: cfgWithCreds }); + await m._pullFromServer(true); + ok('pull with credentials reaches the server', calls === 1, 'calls=' + calls); + ok('pull sends an Authorization header', /^Basic /.test(String(sawAuth)), String(sawAuth)); + ok('store mode becomes server', m._storeMode === 'server', m._storeMode); + + // ---- 3. no config at all โ no request (unchanged behaviour) ---- + calls = 0; + global.fetch = () => { calls++; return Promise.resolve({ ok: true, status: 200 }); }; + m = load({}); + await m._pullFromServer(true); + await m._pushToServer(); + ok('no config still makes no request', calls === 0, 'calls=' + calls); + + console.log('\ntest-predictor-store-auth: ' + pass + ' passed, ' + fail + ' failed'); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error('HARNESS ERROR', e); process.exit(2); }); |