ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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;
|
||||
})();
|
||||
Reference in New Issue
Block a user