/** * Tests for the 3-second HTTP-fallback contract (Phase 3e). * * app.js cannot be imported under node (it imports every page and touches * the DOM), so this covers the model-layer contract the deferred fetch * decides on: * * - a model registered with schema defaults keeps `loading: true` * (data is never null — that's why the guard is `if (model.loading)`) * - `modelSet()` or a completed `modelFetch()` clears `loading` * - the `if (model.loading) modelFetch(name)` decision fires ONLY for * still-loading models — a WS-delivered snapshot suppresses the HTTP * fallback for that model * * Uses a fake setTimeout queue and recording fetch stubs (no timers, no * network) — same pattern as test-auth-model.js. * * Run with `node tests/test-reconnect-fallback.js`. */ import { modelRegister, modelFetch, getModel, modelSet } from '../webui/static/hoover/model.js'; import { SUBSYSTEMS } from '../webui/static/hoover/schema.js'; let passed = 0; let failed = 0; const tests = []; function test(name, fn) { tests.push({ name, fn }); } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } function assertEq(a, b, msg) { if (a !== b) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /** Deep equality for objects/arrays (assertEq is reference-based). */ function assertDeep(a, b, msg) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error((msg || 'Assertion failed') + `: got ${JSON.stringify(a)}, want ${JSON.stringify(b)}`); } /* ── Stubs ─────────────────────────────────────────────────── */ /** Fake setTimeout queue — captures scheduled fallbacks, never runs them. */ const _timers = []; globalThis.setTimeout = (fn, ms) => { _timers.push({ fn, ms }); return _timers.length; }; globalThis.clearTimeout = (id) => { if (id && _timers[id - 1]) _timers[id - 1] = undefined; }; /** Drain pending timers in order, like a real 3s elapse. */ async function runTimers() { while (_timers.length) { const t = _timers.shift(); if (t) await t.fn(); } } /** Register one state-backed model with recording fetch. */ function registerModel(name) { const calls = { count: 0 }; modelRegister(name, { subsystem: name, defaultData: SUBSYSTEMS[name].defaults, fetch: async () => { calls.count++; return { fetched: true, name }; }, }); return calls; } /* ── Mirrors app.js fetchInitialData() decision logic ───────── */ /** Schedule the per-model 3s fallback timers (as app.js does). */ function scheduleFallbacks(names) { for (const name of names) { setTimeout(() => { const model = getModel(name); if (model.loading) { modelFetch(name); } }, 3000); } } /* ── Tests ─────────────────────────────────────────────────── */ test('schema-defaulted model: loading stays true until modelSet or fetch completes', () => { registerModel('firewall'); const m = getModel('firewall'); assertEq(m.loading, true, 'fresh model is loading'); assertEq(m.data, SUBSYSTEMS.firewall.defaults, 'data is schema defaults (never null)'); assertEq(m.error, null, 'no error yet'); modelSet('firewall', { zones: { public: {} } }); assertEq(m.loading, false, 'modelSet clears loading'); registerModel('dnsmasq'); const d = getModel('dnsmasq'); assertEq(d.loading, true, 'a different fresh model is still loading'); }); test('3s fallback fetches only models still loading (snapshot suppresses HTTP)', async () => { const firewallCalls = registerModel('firewall'); const dnsmasqCalls = registerModel('dnsmasq'); scheduleFallbacks(['firewall', 'dnsmasq']); // The WS snapshot arrived first for firewall only. modelSet('firewall', { zones: { internal: {} } }); await runTimers(); assertEq(firewallCalls.count, 0, 'snapshot-delivered model: no HTTP fallback'); assertEq(dnsmasqCalls.count, 1, 'still-loading model: HTTP fallback fired'); const fw = getModel('firewall'); assertEq(fw.loading, false, 'firewall not loading'); assertDeep(fw.data.zones?.internal, {}, 'firewall keeps the snapshot data, not fetch output'); const dm = getModel('dnsmasq'); assertEq(dm.loading, false, 'completed fetch clears loading'); assertEq(dm.data.fetched, true, 'dnsmasq got the fallback data'); }); test('a re-fired decision never double-fetches a settled model', async () => { const calls = registerModel('acme'); scheduleFallbacks(['acme']); await runTimers(); assertEq(calls.count, 1, 'first fallback fetch'); // A later decision pass (e.g. reconnect path) must not re-fetch. const model = getModel('acme'); if (model.loading) modelFetch('acme'); await runTimers(); assertEq(calls.count, 1, 'no double fetch once settled'); }); test('fallback fetch failure lands in model.error, self-heals on next data', async () => { modelRegister('wireguard', { subsystem: 'wireguard', defaultData: SUBSYSTEMS.wireguard.defaults, fetch: async () => { throw new Error('state not populated yet'); }, }); const m = getModel('wireguard'); setTimeout(() => { if (m.loading) modelFetch('wireguard'); }, 3000); await runTimers(); assertEq(m.error, 'state not populated yet', 'failure sets model.error'); assertEq(m.loading, false, 'failure still clears loading (finally)'); assertEq(m.data, SUBSYSTEMS.wireguard.defaults, 'schema defaults preserved on failure'); modelSet('wireguard', { up: false }); assertEq(m.error, null, 'next real data clears the error'); assertEq(m.data.up, false, 'real data lands'); }); /* ── Runner ────────────────────────────────────────────────── */ (async () => { for (const { name, fn } of tests) { try { await fn(); console.log(` \u2713 ${name}`); passed++; } catch (e) { console.error(` \u2717 ${name}: ${e.message}`); failed++; } } console.log(`${passed + failed} tests: ${passed} passed, ${failed} failed`); process.exitCode = failed ? 1 : 0; })();