302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
/**
|
|
* 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 <hoover-root>
|
|
*/
|
|
|
|
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=<new array>
|
|
* 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;
|
|
})();
|