mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(web): ES-module page lifecycle and one schema field model (stage 1) (#703)
Adds a native ES-module layer to the web UI (core/boot, registry, api, facade; window.LEDMatrix as the one global), a page lifecycle that the Cache tab is converted to as the reference, text/javascript serving and revalidation for unversioned module requests, and src/plugin_system/field_model.py with a parity test against the render_field macro. Also: the cache page toggles its grey 'Not configured' style instead of only adding it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// core/api.js and core/facade.js (web_interface/static/v3/js/core/).
|
||||
//
|
||||
// api.js: one fetch wrapper. Resolves to the parsed JSON body; rejects with
|
||||
// an ApiError for HTTP errors, {"status": "error"} bodies, unreadable bodies
|
||||
// and network failures; passes an AbortError through untouched; and turns the
|
||||
// optional web login's 401 + X-LEDMatrix-Login (#683) into a quiet
|
||||
// `loginRequired` error, since base.html's fetch wrapper is already sending
|
||||
// the browser to the login page.
|
||||
//
|
||||
// facade.js: window.LEDMatrix, and deprecated aliases for moved globals.
|
||||
//
|
||||
// Plain node: imports the shipped ES modules, no DOM needed.
|
||||
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const CORE = path.resolve(__dirname, '../../../web_interface/static/v3/js/core');
|
||||
const load = f => import(pathToFileURL(path.join(CORE, f)).href);
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra) : '')));
|
||||
|
||||
function response(status, body, headers = {}) {
|
||||
const text = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
return {
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: n => headers[n] !== undefined ? headers[n] : null },
|
||||
text: () => Promise.resolve(text),
|
||||
};
|
||||
}
|
||||
async function rejection(promise) {
|
||||
try { await promise; return null; } catch (e) { return e; }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createApi, ApiError, isLoginRedirect, isAbort } = await load('api.js');
|
||||
const { createFacade, installFacade, defineDeprecatedAlias, FACADE_VERSION } = await load('facade.js');
|
||||
const { createRegistry } = await load('registry.js');
|
||||
|
||||
console.log('\n1. api: requests go out as JSON, through fetch at call time');
|
||||
{
|
||||
const calls = [];
|
||||
const api = createApi({ fetch: (url, init) => { calls.push([url, init]); return Promise.resolve(response(200, { status: 'success', data: { n: 1 } })); } });
|
||||
const body = await api.get('/api/v3/cache/list');
|
||||
ok('resolves to the parsed body', body.data.n === 1, body);
|
||||
ok('GET has no body', calls[0][1].method === 'GET' && calls[0][1].body === undefined);
|
||||
await api.post('/api/v3/cache/delete', { key: 'a"b' });
|
||||
ok('POST sends JSON', calls[1][1].headers['Content-Type'] === 'application/json' && JSON.parse(calls[1][1].body).key === 'a"b');
|
||||
const controller = new AbortController();
|
||||
await api.get('/api/v3/x', { signal: controller.signal });
|
||||
ok('the signal is passed to fetch', calls[2][1].signal === controller.signal);
|
||||
|
||||
// Default: window.fetch looked up per call, so base.html's login wrapper
|
||||
// (installed before any module runs, or replaced later) is the one used.
|
||||
const seen = [];
|
||||
globalThis.fetch = () => { seen.push('first'); return Promise.resolve(response(200, { status: 'success' })); };
|
||||
const live = createApi();
|
||||
await live.get('/api/v3/a');
|
||||
globalThis.fetch = () => { seen.push('second'); return Promise.resolve(response(200, { status: 'success' })); };
|
||||
await live.get('/api/v3/b');
|
||||
ok('uses whatever window.fetch is at call time', seen.join() === 'first,second', seen);
|
||||
}
|
||||
|
||||
console.log('\n2. api: errors');
|
||||
{
|
||||
const api = r => createApi({ fetch: () => (r instanceof Error ? Promise.reject(r) : Promise.resolve(r)) });
|
||||
let e = await rejection(api(response(500, { status: 'error', message: 'Disk full' })).get('/api/v3/x'));
|
||||
ok('HTTP error carries status and message', e instanceof ApiError && e.status === 500 && e.message === 'Disk full', e && e.message);
|
||||
e = await rejection(api(response(200, { status: 'error', message: 'Nope' })).get('/api/v3/x'));
|
||||
ok('a 200 with status "error" is an error', e instanceof ApiError && e.status === 200 && e.message === 'Nope' && e.body.status === 'error');
|
||||
e = await rejection(api(response(502, '<html>Bad gateway</html>')).get('/api/v3/x'));
|
||||
ok('a non-JSON error page says the status', e instanceof ApiError && e.status === 502 && e.message === 'HTTP 502', e && e.message);
|
||||
e = await rejection(api(response(200, 'not json')).get('/api/v3/x'));
|
||||
ok('an unreadable 200 is an error', e instanceof ApiError && /Unreadable/.test(e.message));
|
||||
e = await rejection(api(new TypeError('Failed to fetch')).get('/api/v3/x'));
|
||||
ok('a network failure is flagged', e instanceof ApiError && e.network && e.status === 0 && e.message === 'Failed to fetch');
|
||||
const abort = new Error('aborted'); abort.name = 'AbortError';
|
||||
e = await rejection(api(abort).get('/api/v3/x'));
|
||||
ok('an abort passes through untouched', e === abort && isAbort(e));
|
||||
}
|
||||
|
||||
console.log('\n3. api: the optional web login (#683)');
|
||||
{
|
||||
const login = response(401, { status: 'error', message: 'Login required' }, { 'X-LEDMatrix-Login': '/login?next=/' });
|
||||
ok('isLoginRedirect matches the wrapper in base.html', isLoginRedirect(login));
|
||||
ok('...not a protocol-relative URL', !isLoginRedirect(response(401, {}, { 'X-LEDMatrix-Login': '//evil.example/' })));
|
||||
ok('...not a 401 without the header', !isLoginRedirect(response(401, {})));
|
||||
ok('...not another status', !isLoginRedirect(response(403, {}, { 'X-LEDMatrix-Login': '/login' })));
|
||||
const e = await rejection(createApi({ fetch: () => Promise.resolve(login) }).get('/api/v3/x'));
|
||||
ok('rejects quietly with loginRequired', e instanceof ApiError && e.loginRequired && e.status === 401);
|
||||
}
|
||||
|
||||
console.log('\n4. api: only this server\'s paths');
|
||||
{
|
||||
const api = createApi({ fetch: () => Promise.resolve(response(200, { status: 'success' })) });
|
||||
for (const bad of ['//evil.example/x', 'https://evil.example/x', 'api/v3/x', '/a b', '/a\\b']) {
|
||||
const e = await rejection(api.get(bad));
|
||||
ok(`refuses ${JSON.stringify(bad)}`, e instanceof TypeError, e && e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n5. facade: window.LEDMatrix');
|
||||
{
|
||||
const warnings = [];
|
||||
const win = { console: { warn: m => warnings.push(m), log() {}, error() {} } };
|
||||
const api = createApi({ fetch: () => Promise.resolve(response(200, { status: 'success' })) });
|
||||
const reg = createRegistry({ document: { addEventListener() {}, removeEventListener() {}, querySelectorAll: () => [] } });
|
||||
const facade = installFacade(win, createFacade(win, api, reg));
|
||||
ok('installed as window.LEDMatrix', win.LEDMatrix === facade && facade.version === FACADE_VERSION);
|
||||
ok('exposes api and pages', facade.api === api && typeof facade.pages.register === 'function' && typeof facade.pages.refresh === 'function');
|
||||
ok('is frozen', Object.isFrozen(facade) && Object.isFrozen(facade.pages));
|
||||
win.LEDEscape = { html: s => s };
|
||||
win.LEDMatrixWidgets = { get() {} };
|
||||
ok('escape and widgets read through at call time', facade.escape === win.LEDEscape && facade.widgets === win.LEDMatrixWidgets);
|
||||
const notes = [];
|
||||
win.showNotification = (m, t) => notes.push([m, t]);
|
||||
facade.notify('saved', 'success');
|
||||
win.showNotification = (m, t) => notes.push(['replaced', m, t]);
|
||||
facade.notify('again');
|
||||
ok('notify uses the current showNotification', JSON.stringify(notes) === JSON.stringify([['saved', 'success'], ['replaced', 'again', 'info']]), notes);
|
||||
}
|
||||
|
||||
console.log('\n6. facade: deprecated aliases keep old globals working');
|
||||
{
|
||||
const warnings = [];
|
||||
const win = {};
|
||||
const logger = { warn: m => warnings.push(m) };
|
||||
const calls = [];
|
||||
defineDeprecatedAlias(win, 'deleteCacheFile', function(key) { calls.push([this, key]); return 'done'; }, 'the Delete buttons', logger);
|
||||
ok('a function alias forwards its arguments and result', win.deleteCacheFile('k1') === 'done' && calls[0][1] === 'k1');
|
||||
win.deleteCacheFile('k2');
|
||||
ok('warns once, naming the replacement', warnings.length === 1 && /deleteCacheFile/.test(warnings[0]) && /the Delete buttons/.test(warnings[0]), warnings);
|
||||
defineDeprecatedAlias(win, 'oldThing', { a: 1 }, 'LEDMatrix.thing', logger);
|
||||
ok('a value alias is a getter', win.oldThing.a === 1 && warnings.length === 2);
|
||||
Object.defineProperty(win, 'locked', { value: 1, configurable: false });
|
||||
ok('a non-configurable global is left alone', defineDeprecatedAlias(win, 'locked', () => 2, null, logger) === false && win.locked === 1);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
@@ -115,8 +115,8 @@ const ESCAPERS = [
|
||||
'templates/v3/partials/tools.html', 'function phEscape(s) {', 'phEscape', false],
|
||||
['logs.html (escapeHtml)',
|
||||
'templates/v3/partials/logs.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['cache.html (escapeHtml)',
|
||||
'templates/v3/partials/cache.html', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
// cache.html has no script any more: js/pages/cache.js builds its rows with
|
||||
// textContent, and test/js/dom/test_cache_page.js checks a hostile key.
|
||||
];
|
||||
|
||||
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// The page lifecycle (web_interface/static/v3/js/core/registry.js).
|
||||
//
|
||||
// A converted partial's root carries data-page="<name>"; the registry calls
|
||||
// the page module's init(root, ctx) once when the root appears and
|
||||
// destroy(root, ctx) when htmx swaps it away, aborting ctx.signal so every
|
||||
// listener the page registered with it goes too. This is what replaces the
|
||||
// inline <script> blocks that htmx-config.js re-ran on every swap.
|
||||
//
|
||||
// Imports the shipped ES module directly (js/core/package.json marks the
|
||||
// directory "type": "module"). The DOM is a minimal shim, so this needs only
|
||||
// node and runs under test/test_js_unit_suites.py as well as run_all.js.
|
||||
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const CORE = path.resolve(__dirname, '../../../web_interface/static/v3/js/core');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
? (pass++, console.log(' ok ' + label))
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra) : '')));
|
||||
|
||||
// ── DOM shim: just what the registry touches ───────────────────────────────
|
||||
class El extends EventTarget {
|
||||
constructor(tag, attrs = {}) {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.attrs = new Map(Object.entries(attrs));
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
}
|
||||
getAttribute(n) { return this.attrs.has(n) ? this.attrs.get(n) : null; }
|
||||
setAttribute(n, v) { this.attrs.set(n, String(v)); }
|
||||
appendChild(c) { if (c.parentNode) c.remove(); c.parentNode = this; this.children.push(c); return c; }
|
||||
remove() { if (this.parentNode) { this.parentNode.children = this.parentNode.children.filter(x => x !== this); this.parentNode = null; } }
|
||||
replaceChildren(...nodes) { this.children.slice().forEach(c => c.remove()); nodes.forEach(n => this.appendChild(n)); }
|
||||
*descendants() { for (const c of this.children) { yield c; yield* c.descendants(); } }
|
||||
// Only the one selector the registry uses: [attr]
|
||||
matches(sel) { const m = /^\[([\w-]+)\]$/.exec(sel); return !!m && this.attrs.has(m[1]); }
|
||||
querySelectorAll(sel) { return [...this.descendants()].filter(e => e.matches(sel)); }
|
||||
contains(other) { for (let n = other; n; n = n.parentNode) if (n === this) return true; return false; }
|
||||
get isConnected() { let n = this; while (n.parentNode) n = n.parentNode; return n instanceof Doc; }
|
||||
}
|
||||
class Doc extends El {
|
||||
constructor() { super('#document'); this.documentElement = this.appendChild(new El('html')); this.body = this.documentElement.appendChild(new El('body')); }
|
||||
}
|
||||
const event = (type, detail) => { const e = new Event(type); e.detail = detail; return e; };
|
||||
// htmx fires its events on the target and they bubble to the document, where
|
||||
// the registry listens. Node's EventTarget has no tree, so walk it here.
|
||||
function fire(target, type, detail) {
|
||||
for (let n = target; n; n = n.parentNode) n.dispatchEvent(event(type, detail));
|
||||
}
|
||||
const tick = () => new Promise(r => setTimeout(r, 0));
|
||||
|
||||
// A page module that records its lifecycle, and checks ctx.signal works.
|
||||
function recorder(log) {
|
||||
return {
|
||||
init(root, ctx) {
|
||||
log.push(['init', root.getAttribute('id'), ctx.name]);
|
||||
ctx.state.clicks = 0;
|
||||
root.addEventListener('click', () => { ctx.state.clicks++; log.push(['click', root.getAttribute('id')]); }, { signal: ctx.signal });
|
||||
ctx.signal.addEventListener('abort', () => log.push(['aborted', root.getAttribute('id')]));
|
||||
if (ctx.service) log.push(['service', ctx.service]);
|
||||
},
|
||||
destroy(root, ctx) { log.push(['destroy', root.getAttribute('id'), ctx.signal.aborted]); },
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { createRegistry, PAGE_ATTRIBUTE } = await import(pathToFileURL(path.join(CORE, 'registry.js')).href);
|
||||
const quiet = { error: () => {}, warn: () => {} };
|
||||
|
||||
console.log('\n1. mounts on start, once per root, with the shared context');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div', { id: 'cache-content' }));
|
||||
const root = panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, context: { service: 'api' }, logger: quiet });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
ok('init ran once on start', log.filter(e => e[0] === 'init').length === 1, log);
|
||||
ok('ctx carries the page name', log[0][2] === 'demo', log);
|
||||
ok('ctx carries the shared services', log.some(e => e[0] === 'service' && e[1] === 'api'), log);
|
||||
await reg.refresh(); await reg.scan(); await reg.mount(root);
|
||||
ok('refresh/scan/mount again do not re-init', log.filter(e => e[0] === 'init').length === 1, log);
|
||||
root.dispatchEvent(new Event('click'));
|
||||
ok('the page listener works', log.filter(e => e[0] === 'click').length === 1, log);
|
||||
ok('list() reports the mounted page', reg.list().length === 1 && reg.list()[0].initialised === true, reg.list().length);
|
||||
}
|
||||
|
||||
console.log('\n2. an htmx swap destroys the old page and starts the new one');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
|
||||
const first = panel.appendChild(new El('div', { id: 'first', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 'swap' + i, [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
await tick();
|
||||
}
|
||||
const inits = log.filter(e => e[0] === 'init').map(e => e[1]);
|
||||
const destroys = log.filter(e => e[0] === 'destroy').map(e => e[1]);
|
||||
ok('one init per swapped-in root', JSON.stringify(inits) === JSON.stringify(['first', 'swap0', 'swap1', 'swap2', 'swap3', 'swap4']), inits);
|
||||
ok('one destroy per swapped-out root', JSON.stringify(destroys) === JSON.stringify(['first', 'swap0', 'swap1', 'swap2', 'swap3']), destroys);
|
||||
ok('destroy runs before the signal is aborted', log.filter(e => e[0] === 'destroy').every(e => e[2] === false), log);
|
||||
ok('every destroyed page had its signal aborted', log.filter(e => e[0] === 'aborted').length === 5, log);
|
||||
ok('only the live page is mounted', reg.list().length === 1 && reg.list()[0].root.getAttribute('id') === 'swap4');
|
||||
// The old root's listener was registered with ctx.signal: gone.
|
||||
first.dispatchEvent(new Event('click'));
|
||||
ok('a destroyed page no longer hears its own events', !log.some(e => e[0] === 'click' && e[1] === 'first'), log);
|
||||
}
|
||||
|
||||
console.log('\n3. a vetoed swap (shouldSwap false, e.g. an error response) keeps the page');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div'));
|
||||
panel.appendChild(new El('div', { id: 'keep', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: false });
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
ok('not destroyed', !log.some(e => e[0] === 'destroy'), log);
|
||||
ok('still mounted', reg.list().length === 1);
|
||||
}
|
||||
|
||||
console.log('\n4. a swap elsewhere leaves the page alone');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const a = doc.body.appendChild(new El('div'));
|
||||
const b = doc.body.appendChild(new El('div'));
|
||||
a.appendChild(new El('div', { id: 'a-page', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
fire(b, 'htmx:beforeSwap', { target: b, shouldSwap: true });
|
||||
b.replaceChildren(new El('p'));
|
||||
fire(b, 'htmx:afterSwap', { target: b });
|
||||
ok('the other panel\'s page is untouched', log.filter(e => e[0] !== 'service').map(e => e[0]).join() === 'init', log);
|
||||
}
|
||||
|
||||
console.log('\n5. content removed without htmx (Alpine x-if, outerHTML) is swept on the next swap or refresh');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div'));
|
||||
const root = panel.appendChild(new El('div', { id: 'gone', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
root.remove();
|
||||
ok('nothing happens until the registry looks', !log.some(e => e[0] === 'destroy'));
|
||||
await reg.refresh();
|
||||
ok('refresh() destroys a detached root', log.some(e => e[0] === 'destroy' && e[1] === 'gone'), log);
|
||||
// loadPartialDirect inserts HTML without htmx events and calls refresh().
|
||||
panel.appendChild(new El('div', { id: 'direct', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
await reg.refresh();
|
||||
ok('refresh() starts a root inserted without htmx', log.some(e => e[0] === 'init' && e[1] === 'direct'), log);
|
||||
}
|
||||
|
||||
console.log('\n6. lazy page modules: loaded on first use, once');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div'));
|
||||
const log = [];
|
||||
let loads = 0;
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
reg.register('lazy', () => { loads++; return Promise.resolve({ default: recorder(log) }); });
|
||||
await reg.start();
|
||||
ok('not loaded while no partial uses it', loads === 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 'l' + i, [PAGE_ATTRIBUTE]: 'lazy' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
await tick(); await tick();
|
||||
}
|
||||
ok('loader called once', loads === 1, loads);
|
||||
ok('a default export works', log.filter(e => e[0] === 'init').length === 3, log);
|
||||
|
||||
// Swapped away while its module is still loading: never initialised.
|
||||
let release;
|
||||
const slowLog = [];
|
||||
reg.register('slow', () => new Promise(r => { release = r; }));
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 's', [PAGE_ATTRIBUTE]: 'slow' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('p'));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
release(recorder(slowLog));
|
||||
await tick(); await tick();
|
||||
ok('a page destroyed before its module arrived never runs init', slowLog.length === 0, slowLog);
|
||||
}
|
||||
|
||||
console.log('\n7. a page registered after its partial arrived still starts');
|
||||
{
|
||||
const doc = new Doc();
|
||||
doc.body.appendChild(new El('div', { id: 'early', [PAGE_ATTRIBUTE]: 'late' }));
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: quiet });
|
||||
await reg.start();
|
||||
reg.register('late', recorder(log));
|
||||
await tick();
|
||||
ok('init ran on register', log.some(e => e[0] === 'init' && e[1] === 'early'), log);
|
||||
}
|
||||
|
||||
console.log('\n8. a failing page is contained');
|
||||
{
|
||||
const doc = new Doc();
|
||||
doc.body.appendChild(new El('div', { id: 'bad', [PAGE_ATTRIBUTE]: 'bad' }));
|
||||
doc.body.appendChild(new El('div', { id: 'good', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const errors = [];
|
||||
const log = [];
|
||||
const reg = createRegistry({ document: doc, logger: { error: (...a) => errors.push(a.join(' ')), warn() {} } });
|
||||
reg.register('bad', { init() { throw new Error('boom'); }, destroy() { log.push(['bad-destroy']); } });
|
||||
reg.register('demo', recorder(log));
|
||||
await reg.start();
|
||||
ok('the error is logged with the page name', errors.length === 1 && /bad/.test(errors[0]), errors);
|
||||
ok('the other page still started', log.some(e => e[0] === 'init' && e[1] === 'good'), log);
|
||||
reg.stop();
|
||||
ok('destroy is not called for a page whose init failed', !log.some(e => e[0] === 'bad-destroy'), log);
|
||||
ok('stop() destroys every page', log.some(e => e[0] === 'destroy' && e[1] === 'good') && reg.list().length === 0, log);
|
||||
}
|
||||
|
||||
console.log('\n9. register() rejects mistakes loudly');
|
||||
{
|
||||
const reg = createRegistry({ document: new Doc(), logger: quiet });
|
||||
const throws = fn => { try { fn(); return false; } catch (e) { return true; } };
|
||||
ok('no name', throws(() => reg.register('', { init() {} })));
|
||||
ok('no init and not a loader', throws(() => reg.register('x', {})));
|
||||
reg.register('dup', { init() {} });
|
||||
ok('a duplicate name', throws(() => reg.register('dup', { init() {} })));
|
||||
ok('has()', reg.has('dup') && !reg.has('nope'));
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user