/** * Tests for hoover/render.js component lifecycle (per-container #comp registry). * * Regression: the #comp lifecycle registry and expanded-content cache were * module-globals, pruned per-container inside normalizeVNodesWithLifecycle(). * Because commitAll() commits #sidebar (no #comp) before #main (the page * #comp), every sidebar commit unmounted+pruned the page from the global * registry, so the following #main commit treated the page as newly mounted * and re-ran load(). For pages whose load() re-mutates reactive state with * fresh values each run (passkeys.js, users.js), every re-run scheduled * another commit — an infinite unmount/remount/load loop (~100 fetches/s), * leaving the page stuck on "Loading...". * * render.js pulls in vdom.js + component.js — DOM-only at commit time, so the * tests run under plain node with a minimal fake DOM (same pattern as * test-auth-model.js / test-model-set.js). * * Run with `node tests/test-render-lifecycle.js` * (optional arg 1: hoover root, defaults to ../webui/static/hoover). * * NOTE: against buggy (global-registry) code the self-mutation test spins the * infinite remount loop and saturates the event loop — the process hangs * instead of failing an assertion (mirrors the live symptom). Run under an * external `timeout` when checking old checkouts: * timeout 30 node tests/test-render-lifecycle.js */ import { pathToFileURL } from 'node:url'; import path from 'node:path'; const HOOVER_ROOT = process.argv[2] ? pathToFileURL(path.resolve(process.argv[2])).href + '/' : new URL('../webui/static/hoover/', import.meta.url).href; /* ── Minimal fake DOM ───────────────────────────────────────── */ class FakeEl { constructor(tag) { this.tagName = String(tag || 'div').toUpperCase(); this.nodeType = 1; this.childNodes = []; this.parentNode = null; this.style = { cssText: '' }; this.attributes = {}; this._listeners = {}; this.className = ''; this.value = ''; this.checked = false; this.selected = false; this.disabled = false; } get firstChild() { return this.childNodes[0] || null; } setAttribute(k, v) { this.attributes[k] = String(v); } removeAttribute(k) { delete this.attributes[k]; } appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.childNodes.push(c); return c; } insertBefore(c, ref) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length; this.childNodes.splice(i === -1 ? this.childNodes.length : i, 0, c); return c; } removeChild(c) { const i = this.childNodes.indexOf(c); if (i !== -1) this.childNodes.splice(i, 1); c.parentNode = null; return c; } replaceChild(nd, od) { const i = this.childNodes.indexOf(od); if (i !== -1) this.childNodes[i] = nd; od.parentNode = null; nd.parentNode = this; return od; } addEventListener(ev, fn) { (this._listeners[ev] ||= []).push(fn); } removeEventListener(ev, fn) { const arr = this._listeners[ev] || []; const i = arr.indexOf(fn); if (i !== -1) arr.splice(i, 1); } } class FakeText { constructor(text) { this.nodeType = 3; this.nodeValue = String(text); this.parentNode = null; } } globalThis.document = { createElement: (tag) => new FakeEl(tag), createTextNode: (t) => new FakeText(t), }; globalThis.window = { addEventListener: () => {} }; /* ── Imports (dynamic: hoover root is injectable) ───────────── */ const { reactive } = await import(HOOVER_ROOT + 'reactivity.js'); const { h } = await import(HOOVER_ROOT + 'vdom.js'); const { render } = await import(HOOVER_ROOT + 'render.js'); const { definePage, hComp } = await import(HOOVER_ROOT + 'component.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 ${a}, want ${b}`); } const flush = () => new Promise(r => setTimeout(r, 20)); /** * Build a page whose load() mutates reactive state (like passkeys.js * loadCredentials: refreshing=true before the fetch, credentials= * and refreshing=false after — fresh values on every run). */ function makePage(label, counters, title) { const state = reactive({ loading: true, done: 0 }); return { state, page: definePage({ title: title || undefined, init: () => state, async load(s) { counters.loads++; counters.loadKeys.push(label); s.done = (s.done || 0) + 1; // fresh value every run → schedules a commit s.loading = false; }, onUnmount: () => { counters.unmounts++; counters.unmountKeys.push(label); }, render: () => h('div', { class: 'card' }, `${label}-body`), }), }; } const freshCounters = () => ({ loads: 0, unmounts: 0, loadKeys: [], unmountKeys: [] }); test('initial mount runs load() exactly once', async () => { const c = freshCounters(); const { page } = makePage('A', c); const sidebar = new FakeEl('div'); const main = new FakeEl('div'); render(sidebar, () => h('div', { class: 'sidebar' }, 'nav')); render(main, () => hComp(page, '/page-a')); await flush(); assertEq(c.loads, 1, 'load ran once'); assertEq(c.unmounts, 0, 'no unmounts'); }); test('external reactive update does NOT re-mount the page', async () => { const c = freshCounters(); const { page } = makePage('A', c); const sidebar = new FakeEl('div'); const main = new FakeEl('div'); render(sidebar, () => h('div', { class: 'sidebar' }, 'nav')); render(main, () => hComp(page, '/page-a')); await flush(); assertEq(c.loads, 1, 'baseline'); // Simulate a WS tick / toast / any reactive mutation outside the page. const external = reactive({ n: 1 }); for (let i = 0; i < 3; i++) { external.n += 1; await flush(); } assertEq(c.loads, 1, 'load still ran exactly once after 3 external updates'); assertEq(c.unmounts, 0, 'page was never unmounted'); }); test('page load() self-mutations do not re-trigger load (no infinite loop)', async () => { const c = freshCounters(); const { page } = makePage('A', c); const sidebar = new FakeEl('div'); const main = new FakeEl('div'); render(sidebar, () => h('div', { class: 'sidebar' }, 'nav')); render(main, () => hComp(page, '/page-a')); // load() mutates reactive state on every run — give the (buggy) loop time // to spin. With the per-container registry it must stay at exactly one run. await flush(); await flush(); await flush(); assertEq(c.loads, 1, 'no remount loop driven by load\'s own state mutations'); assertEq(c.unmounts, 0, 'no spurious unmounts'); }); test('navigation unmounts the old page once and mounts the new page once', async () => { const c = freshCounters(); const a = makePage('A', c); const b = makePage('B', c); const nav = reactive({ path: '/page-a' }); const sidebar = new FakeEl('div'); const main = new FakeEl('div'); render(sidebar, () => h('div', { class: 'sidebar' }, 'nav')); render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path)); await flush(); assertEq(c.loads, 1, 'A mounted'); nav.path = '/page-b'; await flush(); assertEq(c.loads, 2, 'B mounted once'); assertEq(c.unmounts, 1, 'A unmounted once'); assertEq(c.unmountKeys[0], 'A', 'A was the unmounted page'); // navigate back — A mounts again with preserved state (load re-runs by design) nav.path = '/page-a'; await flush(); assertEq(c.loads, 3, 'A re-mounted after navigation back'); assertEq(c.unmounts, 2, 'B unmounted'); assertEq(a.state.done, 2, 'A state preserved across unmount (2 loads total)'); }); test('two #comp containers: updates in one root do not disturb the other', async () => { const c = freshCounters(); const left = makePage('L', c); const right = makePage('R', c); const l = new FakeEl('div'); const r = new FakeEl('div'); render(l, () => hComp(left.page, '/left')); render(r, () => hComp(right.page, '/right')); await flush(); assertEq(c.loads, 2, 'both pages mounted'); const external = reactive({ n: 1 }); for (let i = 0; i < 3; i++) { external.n += 1; await flush(); } assertEq(c.loads, 2, 'neither page re-mounted'); assertEq(c.unmounts, 0, 'neither page unmounted'); }); /* ── Tab title (definePage `title`) ─────────────────────────── */ test('mounting a titled page sets document.title', async () => { const c = freshCounters(); const { page } = makePage('T', c, 'Titled - Vacuum Wall'); const main = new FakeEl('div'); document.title = 'base'; render(main, () => hComp(page, '/titled')); await flush(); assertEq(document.title, 'Titled - Vacuum Wall', 'title applied on mount'); }); test('a page without a title leaves document.title untouched', async () => { const c = freshCounters(); const { page } = makePage('U', c); const main = new FakeEl('div'); document.title = 'unchanged'; render(main, () => hComp(page, '/untitled')); await flush(); assertEq(document.title, 'unchanged', 'no title → document.title untouched'); }); test('navigation updates document.title; remount re-applies idempotently', async () => { const c = freshCounters(); const a = makePage('A', c, 'Alpha - Vacuum Wall'); const b = makePage('B', c, 'Beta - Vacuum Wall'); const nav = reactive({ path: '/page-a' }); const main = new FakeEl('div'); render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path)); await flush(); assertEq(document.title, 'Alpha - Vacuum Wall', 'A title on first mount'); nav.path = '/page-b'; await flush(); assertEq(document.title, 'Beta - Vacuum Wall', 'B title after navigation'); nav.path = '/page-a'; await flush(); assertEq(document.title, 'Alpha - Vacuum Wall', 'A title re-applied on remount'); }); test('mounting an untitled page does not reset a previously set title', async () => { const c = freshCounters(); const a = makePage('A', c, 'Alpha - Vacuum Wall'); const b = makePage('B', c); const nav = reactive({ path: '/page-a' }); const main = new FakeEl('div'); render(main, () => hComp(nav.path === '/page-a' ? a.page : b.page, nav.path)); await flush(); assertEq(document.title, 'Alpha - Vacuum Wall', 'baseline'); nav.path = '/page-b'; await flush(); assertEq(document.title, 'Alpha - Vacuum Wall', 'untitled mount keeps prior title'); }); /* ── 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; })();