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:
Chuck
2026-10-01 10:00:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 795834811f
commit c6701ac00d
27 changed files with 3006 additions and 175 deletions
+3
View File
@@ -51,10 +51,13 @@ server has none.
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
| `dom/test_cache_page.js` | yes | The Cache tab as a page module (`js/pages/cache.js`) on the real partial: no inline script, one request per swap and per Refresh after repeated swaps, a cancelled request draws nothing, hostile keys stay text, delete/empty/error/login states |
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
+196
View File
@@ -0,0 +1,196 @@
// The Cache tab as a page module (static/v3/js/pages/cache.js), in a real DOM
// (jsdom) with the real server-rendered partial and the real API's payload
// shape. The reference conversion for docs/WEB_FRONTEND_ARCHITECTURE.md, so
// this pins what every converted page must do:
//
// * the partial ships no <script>; its root is data-page="cache"
// * the page starts once per swap-in and stops on swap-out: repeated htmx
// swaps leave exactly one live set of listeners (one request per Refresh
// click, however many times the tab was reloaded)
// * a request still in flight when the page is swapped away is cancelled
// and draws nothing
// * server data reaches the page as text, never as markup
const http = require('http');
const path = require('path');
const { pathToFileURL } = require('url');
const { JSDOM, VirtualConsole } = require('jsdom');
const BASE = process.env.BASE || 'http://localhost:5000';
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
const get = p => new Promise((res, rej) =>
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
const load = f => import(pathToFileURL(path.join(JS, f)).href);
const tick = ms => new Promise(r => setTimeout(r, ms || 0));
let pass = 0, fail = 0;
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' -> ' + JSON.stringify(x).slice(0, 300) : '')));
(async () => {
const partial = await get('/partials/cache');
const real = JSON.parse(await get('/api/v3/cache/list'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const cachePage = await load('pages/cache.js');
console.log('\n── Cache tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="cache"', /data-page="cache"/.test(partial));
ok('the real API answers in the shape the page reads',
real.status === 'success' && real.data && Array.isArray(real.data.cache_files), real);
// Real shape, plus entries the page must treat as text.
const HOSTILE = '<img src=x onerror="window.pwned=1">\'"&';
const sample = Object.assign({}, real.data, {
cache_dir: real.data.cache_dir || '/var/cache/ledmatrix',
cache_files: [
{ key: 'weather_current', filename: 'weather_current.json', age_seconds: 12,
age_display: '12s', size_display: '1.2 KB', modified_datetime: '2026-09-30T12:00:00' },
{ key: HOSTILE, filename: HOSTILE + '.json', age_seconds: 7200,
age_display: '2h', size_display: '3 B', modified_datetime: '2026-09-30T10:00:00' },
],
});
const errs = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="cache-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('cache-content');
// Controllable API.
let listBody = { status: 'success', data: sample };
let listMode = 'ok';
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body });
const respond = (status, body, headers = {}) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: n => headers[n] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (url === '/api/v3/cache/delete') return respond(200, { status: 'success', message: 'Deleted it' });
if (listMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (listMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (listMode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(resolve);
init.signal.addEventListener('abort', () => {
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, listBody);
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('cache', cachePage);
const lists = () => requests.filter(r => r.url === '/api/v3/cache/list').length;
const $ = id => doc.getElementById(id);
const visible = id => !$(id).classList.contains('hidden');
// What htmx does around a swap of the tab panel.
async function swap() {
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = partial;
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
await tick(20);
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one list request on start', lists() === 1, lists());
const rows = doc.querySelectorAll('#cache-files-tbody tr');
ok('one row per cache file', rows.length === 2, rows.length);
ok('cache directory shown', $('cache-dir').textContent === sample.cache_dir, $('cache-dir').textContent);
ok('hostile key is shown as text', rows[1].textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#cache-files-tbody img') && !window.pwned);
const buttons = [...doc.querySelectorAll('#cache-files-tbody button[data-cache-key]')];
ok('delete buttons carry the exact key', buttons.map(b => b.dataset.cacheKey).join('|') === 'weather_current|' + HOSTILE);
ok('delete buttons have no inline handler', buttons.every(b => !b.getAttribute('onclick')));
ok('fresh entries are green, old ones red',
rows[0].querySelector('.text-green-600') && rows[1].querySelector('.text-red-600'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = $('refresh-cache-btn');
for (let i = 0; i < 5; i++) await swap();
ok('one list request per swap', lists() === 6, lists());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
const before = lists();
$('refresh-cache-btn').click();
await tick(20);
ok('Refresh makes exactly one request (no duplicate listeners)', lists() === before + 1, lists() - before);
oldRefresh.click();
await tick(20);
ok('a swapped-out button does nothing', lists() === before + 1, lists() - before);
// ── delete ──────────────────────────────────────────────────────────────
let asked = null;
window.confirm = msg => { asked = msg; return false; };
doc.querySelector('#cache-files-tbody button[data-cache-key]').click();
await tick(20);
ok('delete asks first', asked && asked.includes('weather_current'), asked);
ok('cancel sends nothing', !requests.some(r => r.url === '/api/v3/cache/delete'));
window.confirm = () => true;
const listsBeforeDelete = lists();
doc.querySelectorAll('#cache-files-tbody button[data-cache-key]')[1].click();
await tick(30);
const del = requests.filter(r => r.url === '/api/v3/cache/delete');
ok('one delete request', del.length === 1, del.length);
ok('it posts the exact key as JSON', del[0] && del[0].method === 'POST' && JSON.parse(del[0].body).key === HOSTILE);
ok('the server\'s message is shown', notes.some(n => n[0] === 'Deleted it' && n[1] === 'success'), notes);
ok('the list reloads after a delete', lists() === listsBeforeDelete + 1, lists() - listsBeforeDelete);
const viaAlias = await cachePage.deleteCacheFile('weather_current');
ok('the old deleteCacheFile(key) entry point still works', viaAlias === true);
// ── states ──────────────────────────────────────────────────────────────
listBody = { status: 'success', data: { cache_dir: null, cache_files: [] } };
$('refresh-cache-btn').click(); await tick(20);
ok('empty state shown', visible('cache-empty') && !visible('cache-error') && !doc.querySelector('#cache-files-tbody tr'));
ok('a missing cache directory says so', $('cache-dir').textContent === 'Not configured');
ok('a missing cache directory is greyed', $('cache-dir').classList.contains('text-gray-500'));
listBody = { status: 'success', data: { cache_dir: '/var/cache/ledmatrix', cache_files: [] } };
$('refresh-cache-btn').click(); await tick(20);
ok('a directory that appears later is not greyed', $('cache-dir').textContent === '/var/cache/ledmatrix'
&& !$('cache-dir').classList.contains('text-gray-500'));
listBody = { status: 'error', message: 'Cache unavailable' };
$('refresh-cache-btn').click(); await tick(20);
ok('an API error shows its message', visible('cache-error') && $('cache-error-message').textContent === 'Cache unavailable',
$('cache-error-message').textContent);
listMode = 'network';
$('refresh-cache-btn').click(); await tick(20);
ok('a network failure says so', $('cache-error-message').textContent === 'Error loading cache files: Failed to fetch',
$('cache-error-message').textContent);
listMode = 'ok'; listBody = { status: 'success', data: sample };
await swap();
listMode = 'login';
$('cache-error').classList.add('hidden');
$('refresh-cache-btn').click(); await tick(20);
ok('the login redirect draws no error', !visible('cache-error'));
// ── in flight when swapped away ─────────────────────────────────────────
listMode = 'hang';
$('refresh-cache-btn').click(); await tick(5);
ok('a request is in flight', pending.length >= 1);
listMode = 'ok';
await swap();
ok('the new page drew its own list', doc.querySelectorAll('#cache-files-tbody tr').length === 2);
ok('the cancelled request drew nothing', !visible('cache-error'));
ok('no DOM errors', errs.length === 0, errs);
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });
+3 -2
View File
@@ -22,9 +22,10 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js'];
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
'dom/test_tools_sections.js', 'dom/test_cache_page.js'];
function reachable(url) {
return new Promise(res => {
+143
View File
@@ -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); });
+2 -2
View File
@@ -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
+247
View File
@@ -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); });
+480
View File
@@ -0,0 +1,480 @@
"""
build_field_model() against the real render_field macro, for every schema.
The field model (src/plugin_system/field_model.py) is meant to replace the
1,100-line ``render_field`` macro in plugin_config.html as the one description
of a plugin's config form. Before anything renders from it, it has to be
complete: for each schema, the model must name exactly the form controls the
macro draws today, with the same starting values, and the same JS widgets
with the same names and values.
This test renders the macro (the real template, through a Flask Jinja
environment so ``tojson`` behaves as in the app) and parses the form:
* every named control inside the <form>: (name, control, submitted text,
checked) in document order. A <select> contributes the option a browser
would submit (the last ``selected`` one, else the first).
* every inline widget script: (widget, name, JSON value).
and checks both lists equal what the model predicts, in order.
Schemas covered:
* every plugin under plugin-repos/ and test/fixtures/plugins/,
* the official plugins monorepo, read-only, when a checkout is found: the
directory named by $LEDMATRIX_MONOREPO_PLUGINS, else
../ledmatrix-plugins/plugins next to this checkout, else
~/.ledmatrix-dev-plugins/ledmatrix-plugins/plugins (dev_plugin_setup.sh),
* SYNTHETIC below: one schema reaching every branch of the macro, with a
config that fills its tables, so CI covers every widget without the
monorepo.
Each schema is rendered twice: with nothing stored (the macro's own default
fallback) and with the config the route really renders -- schema defaults
merged (prepare_plugin_config) and secrets masked.
"""
import html as html_lib
import json
import os
import re
from html.parser import HTMLParser
from pathlib import Path
import pytest
from flask import Flask
from src.element_style import expand_style_elements
from src.plugin_system.field_model import (
build_field_model, field_names, form_inputs, iter_fields, widget_mounts,
)
from src.plugin_system.schema_manager import plugin_config_defaults, prepare_plugin_config
from src.web_interface.secret_helpers import mask_secret_fields
PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES = PROJECT_ROOT / "web_interface" / "templates"
# ── schema sources ──────────────────────────────────────────────────────────
def _monorepo_plugins_dir():
candidates = []
if os.environ.get("LEDMATRIX_MONOREPO_PLUGINS"):
candidates.append(Path(os.environ["LEDMATRIX_MONOREPO_PLUGINS"]))
candidates.append(PROJECT_ROOT.parent / "ledmatrix-plugins" / "plugins")
candidates.append(Path.home() / ".ledmatrix-dev-plugins" / "ledmatrix-plugins" / "plugins")
for candidate in candidates:
if candidate.is_dir() and any(candidate.glob("*/config_schema.json")):
return candidate
return None
MONOREPO = _monorepo_plugins_dir()
def _schema_files():
found = []
for base, label in ((PROJECT_ROOT / "plugin-repos", "plugin-repos"),
(PROJECT_ROOT / "test" / "fixtures" / "plugins", "fixtures"),
(MONOREPO, "monorepo")):
if base is None or not base.is_dir():
continue
for path in sorted(base.glob("*/config_schema.json")):
found.append((f"{label}/{path.parent.name}", path))
return found
SCHEMA_FILES = _schema_files()
# Every branch of render_field / render_nested_section, plus a config that
# gives the row-based widgets rows to draw.
SYNTHETIC = {
"type": "object",
"x-propertyOrder": ["display_duration", "label", "mode", "count", "ratio",
"brightness", "zoom", "dup_enum", "tags", "days", "teams", "calendars",
"images", "feeds", "bad_feeds", "rows", "events", "colour", "credentials",
"files", "password", "picker", "plugin_widget", "nullable",
"nullable_number", "toggle", "flag", "schedule", "window",
"customization", "nested", "legacy", "empty_object", "hidden_one",
"hidden_object", "fancy_advanced", "not_advanced_object", "union"],
"properties": {
"enabled": {"type": "boolean", "default": True},
"display_duration": {"type": "number", "default": 15, "minimum": 1},
"label": {"type": "string", "default": "Hello \"world\" & <you>", "title": "Label"},
"mode": {"type": "string", "enum": ["vs", "abbrev", "full_name"], "default": "abbrev",
"x-options": {"labels": {"vs": "vs."}}},
"count": {"type": "integer", "default": 3, "enum": [1, 3, 5]},
"ratio": {"type": "number", "minimum": 0, "maximum": 1},
"brightness": {"type": "integer", "default": 50, "x-widget": "slider",
"minimum": 0, "maximum": 100},
"zoom": {"type": "number", "x-widget": "number-input", "default": None},
# 1 == 1.0, so both options are marked selected; a browser submits the last.
"dup_enum": {"type": "number", "enum": [1, 1.0, 2], "default": 1},
"tags": {"type": "array", "items": {"type": "string"}, "default": ["a", "b"]},
"days": {"type": "array", "x-widget": "day-selector", "items": {"type": "string"},
"default": ["mon", "fri"]},
"teams": {"type": "array", "x-widget": "checkbox-group",
"items": {"type": "string", "enum": ["NYY", "BOS", "LAD"]},
"x-options": {"labels": {"NYY": "Yankees"}}, "default": ["NYY"]},
"calendars": {"type": "array", "x-widget": "google-calendar-picker",
"default": "primary, work"},
"images": {"type": "array", "x-widget": "file-upload",
"x-upload-config": {"max_files": 3}, "items": {"type": "object"}},
"feeds": {"type": "array", "x-widget": "custom-feeds", "items": {
"type": "object", "properties": {
"name": {"type": "string"}, "url": {"type": "string"},
"logo": {"type": "object", "properties": {
"path": {"type": "string"}, "id": {"type": "string"}}},
"enabled": {"type": "boolean", "default": True}}}},
"bad_feeds": {"type": "array", "x-widget": "custom-feeds",
"items": {"type": "object", "properties": {"title": {"type": "string"}}}},
"rows": {"type": "array", "items": {"type": "object", "properties": {
"id": {"type": "string", "x-display": "hidden"},
"symbol": {"type": "string", "description": "Ticker"},
"shares": {"type": ["null", "integer"], "minimum": 0},
"side": {"type": "string", "enum": ["buy", "sell", None], "default": "buy"},
"active": {"type": "boolean", "default": True},
"on": {"type": "string", "x-widget": "date-picker"},
"at": {"type": "string", "x-widget": "time-picker"},
"logo": {"type": "string", "x-widget": "file-upload-single"},
"layout": {"type": "object", "properties": {
"x": {"type": "integer", "default": 0},
"secret_offset": {"type": "integer", "x-display": "hidden"},
"y": {"type": "integer"}}},
"note": {"type": "string", "default": "n/a"},
"odd": {"type": ["object", "null"], "properties": {"a": {"type": "string"}}},
}}},
"events": {"type": "array", "x-columns": ["title", "on", "at", "logo", "kind", "gone"],
"items": {"type": "object", "properties": {
"title": {"type": "string", "default": "Untitled"},
"on": {"type": "string", "x-widget": "date-picker"},
"at": {"type": "string", "x-widget": "time-picker"},
"logo": {"type": "string", "x-widget": "file-upload-single"},
"kind": {"type": "string", "enum": ["a", "b"], "default": "b"},
"secret": {"type": "string", "x-display": "hidden"}}}},
"colour": {"type": "array", "x-widget": "color-picker", "default": [10, 20, 30]},
"credentials": {"type": "string", "x-widget": "file-upload",
"x-upload-config": {"target_filename": "creds.json"}},
"files": {"type": "string", "x-widget": "json-file-manager"},
"password": {"type": "string", "x-widget": "password-input", "x-secret": True,
"default": "hunter2"},
"picker": {"type": "string", "x-widget": "font-selector", "default": "4x6"},
"plugin_widget": {"type": "string", "x-widget": "custom-leagues", "default": "eng.1"},
"nullable": {"type": ["null", "string"], "default": None},
"nullable_number": {"type": "integer", "default": None},
"toggle": {"type": "boolean", "x-widget": "toggle-switch"},
"flag": {"type": "boolean", "default": False, "x-advanced": True},
"schedule": {"type": "object", "x-widget": "schedule-picker",
"properties": {"enabled": {"type": "boolean"}}},
"window": {"type": "object", "x-widget": "time-range", "default": {"start": "07:00"}},
"customization": {"type": "object", "x-widget": "style-editor", "properties": {
"score_text": {"type": "object", "properties": {
"font": {"type": "string", "default": "PressStart2P"},
"text_color": {"type": "array", "x-widget": "color-picker",
"default": [255, 0, 0]}}},
"favorite_result_colors": {"type": "boolean", "default": True}}},
"nested": {"type": "object", "title": "Nested", "x-propertyOrder": ["b", "a", "missing"],
"properties": {
"a": {"type": "string", "default": "x"},
"b": {"type": "object", "properties": {
"deep": {"type": "integer", "default": 7}}}}},
"legacy": {"type": "object", "properties": {
"enabled": {"type": "boolean"}, "seconds": {"type": "integer", "default": 30}}},
"empty_object": {"type": "object"},
"hidden_one": {"type": "string", "x-display": "hidden", "default": "zzz"},
"hidden_object": {"type": "object", "properties": {
"inner": {"type": "string", "x-display": "hidden"}}},
"fancy_advanced": {"type": "integer", "default": 1, "x-advanced": True},
"not_advanced_object": {"type": "object", "x-advanced": True, "properties": {
"inner": {"type": "boolean", "default": True}}},
"union": {"type": ["boolean", "object"], "properties": {
"enabled": {"type": "boolean"}}},
},
}
SYNTHETIC_CONFIG = {
"label": "stored 'quote'",
"teams": ["BOS", "SEA"], # SEA is no longer an option
"images": [{"id": "img-1", "path": "assets/a.png", "filename": "a.png",
"schedule": {"enabled": True, "mode": "weekly"}}],
"feeds": [
{"name": "News", "url": "https://example.com/rss",
"logo": {"path": "assets/logo.png", "id": "logo-1"}, "enabled": False},
{"name": "Blog", "url": "https://example.com/blog"},
],
"bad_feeds": [{"title": "ignored"}],
"rows": [
{"id": "row-1", "symbol": "AAPL", "shares": 10, "side": "sell", "active": False,
"on": "2026-01-02", "layout": {"x": 3, "secret_offset": 9}, "odd": {"a": "b"}},
{"symbol": "MSFT", "shares": None, "at": "09:30", "logo": "assets/m.png"},
],
"events": [
{"title": "Launch", "on": "2026-03-04", "at": "18:00", "logo": "assets/l.png",
"kind": "a", "secret": "s3"},
{"on": None, "at": None, "logo": None, "kind": None},
],
"colour": [1, 2],
"legacy": True,
"union": True,
"zoom": 2.5,
}
# Every branch the macro has, so a schema set that stops reaching one fails.
MACRO_WIDGETS = {
"checkbox", "toggle-switch", "select", "number", "slider", "number-input",
"file-upload", "checkbox-group", "google-calendar-picker", "day-selector",
"custom-feeds", "array-table", "color-picker", "csv-text", "text",
"json-file-manager", "password-input", "font-selector", "custom-leagues",
"schedule-picker", "time-range", "style-editor", "section",
}
# ── rendering the macro ─────────────────────────────────────────────────────
_app = Flask("field_model_parity", template_folder=str(TEMPLATES))
def _render(schema, config, plugin_id):
plugin = {"id": plugin_id, "name": plugin_id, "description": "", "enabled": True,
"author": "test", "version": "1.0.0"}
with _app.app_context():
return _app.jinja_env.get_template("v3/partials/plugin_config.html").render(
plugin=plugin, schema=schema, config=config, web_ui_actions=[])
class _FormParser(HTMLParser):
"""Named controls and widget scripts inside the config <form>."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0
self.controls = []
self.scripts = []
self._select = None
self._in_script = False
self._script = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form" and (a.get("id") or "").startswith("plugin-config-form-"):
self.depth += 1
return
if not self.depth:
return
if tag == "script":
self._in_script, self._script = True, []
elif tag == "input" and a.get("name") is not None:
kind = (a.get("type") or "text").lower()
if kind == "checkbox":
value = a.get("value") if a.get("value") is not None else "on"
else:
value = a.get("value") if a.get("value") is not None else ""
self.controls.append({"name": a["name"], "control": kind, "text": value,
"checked": "checked" in a if kind == "checkbox" else None})
elif tag == "select" and a.get("name") is not None:
self._select = {"name": a["name"], "control": "select", "options": [],
"selected": [], "checked": None}
elif tag == "option" and self._select is not None:
self._select["options"].append(a.get("value"))
if "selected" in a:
self._select["selected"].append(a.get("value"))
def handle_endtag(self, tag):
if tag == "form" and self.depth:
self.depth -= 1
elif tag == "script" and self._in_script:
self._in_script = False
self.scripts.append("".join(self._script))
elif tag == "select" and self._select is not None:
s = self._select
text = s["selected"][-1] if s["selected"] else (s["options"][0] if s["options"] else "")
self.controls.append({"name": s["name"], "control": "select", "text": text,
"checked": None, "options": s["options"]})
self._select = None
def handle_data(self, data):
if self._in_script:
self._script.append(data)
_VALUE_RE = re.compile(r"^\s*var value = (?:fallback \? fallback\.value : )?(.*);\s*$", re.M)
_NAME_RE = re.compile(r"\bname: '([^']*)'")
_WIDGET_RE = re.compile(r"LEDMatrixWidgets\.get\('([^']+)'\)")
_PLUGIN_WIDGET_RE = re.compile(r"var WIDGET = (\".*?\");")
def _script_mount(script):
plugin = _PLUGIN_WIDGET_RE.search(script)
widget = json.loads(plugin.group(1)) if plugin else None
if widget is None:
found = _WIDGET_RE.search(script)
widget = found.group(1) if found else None
if widget is None:
return None
name = _NAME_RE.search(script)
value = _VALUE_RE.search(script)
return (widget,
html_lib.unescape(name.group(1)) if name else None,
_canon(json.loads(value.group(1))) if value else None)
def _parse_form(markup):
parser = _FormParser()
parser.feed(markup)
parser.close()
controls = [(c["name"], c["control"], c["text"], c["checked"], tuple(c.get("options") or ()))
for c in parser.controls]
mounts = [m for m in (_script_mount(s) for s in parser.scripts) if m]
return controls, mounts
# ── what the model predicts ─────────────────────────────────────────────────
def _canon(value):
"""JSON round trip: tuples become lists, so equality is JSON equality."""
return json.loads(json.dumps(value))
def _as_text(item):
value, encoding = item["value"], item["encoding"]
if encoding == "json":
return value # compared after parsing, see _expected_controls
if encoding == "csv":
return ", ".join(str(v) for v in value)
if encoding == "bool":
return "true" if value else "false"
return str(value)
def _expected_controls(model):
out = []
for item in form_inputs(model):
options = tuple(str(o) for o in item.get("options") or ())
out.append((item["name"], item["control"], _as_text(item),
item.get("checked") if item["control"] == "checkbox" else None, options))
return out
def _normalise_json_controls(controls, model_inputs):
"""Compare JSON-encoded inputs by value, not by spelling."""
result = []
for control, item in zip(controls, model_inputs):
if item["encoding"] == "json" and control[0] == item["name"]:
try:
parsed = json.loads(control[2])
except ValueError:
parsed = control[2]
control = (control[0], control[1], parsed, control[3], control[4])
result.append(control)
return result + list(controls[len(model_inputs):])
def _expected_mounts(model):
return [(m["widget"], m["name"], _canon(m["value"])) for m in widget_mounts(model)]
# ── cases ───────────────────────────────────────────────────────────────────
def _route_config(schema, stored):
"""The config plugin_config.html is rendered with (pages_v3)."""
config = prepare_plugin_config(stored, schema, plugin_config_defaults(schema))
return mask_secret_fields(config, schema.get("properties") or {})
def _cases():
cases = [("synthetic", "stored", SYNTHETIC, SYNTHETIC_CONFIG),
("synthetic", "route", SYNTHETIC, _route_config(SYNTHETIC, SYNTHETIC_CONFIG)),
("synthetic", "empty", SYNTHETIC, {}),
("schemaless", "stored", {}, {"enabled": True, "a": True, "b": 2.5, "c": "x"})]
for label, path in SCHEMA_FILES:
schema = expand_style_elements(json.loads(path.read_text(encoding="utf-8")))
cases.append((label, "empty", schema, {}))
cases.append((label, "route", schema, _route_config(schema, {})))
return cases
CASES = _cases()
def _check(schema, config, plugin_id):
markup = _render(schema, config, plugin_id)
model = build_field_model(schema, config, plugin_id)
controls, mounts = _parse_form(markup)
inputs = form_inputs(model)
expected = _expected_controls(model)
expected = [(n, c, _canon(t) if i["encoding"] == "json" else t, k, o)
for (n, c, t, k, o), i in zip(expected, inputs)]
assert _normalise_json_controls(controls, inputs) == expected
assert mounts == _expected_mounts(model)
# The headline property: the same set of posted names.
rendered = {c[0] for c in controls} | {m[1] for m in mounts if m[1]}
assert rendered == {name for name, _ in field_names(model)}
return model
@pytest.mark.parametrize("label,variant,schema,config", CASES,
ids=[f"{c[0]}[{c[1]}]" for c in CASES])
def test_model_matches_the_macro(label, variant, schema, config):
plugin_id = label.split("/")[-1]
_check(schema, json.loads(json.dumps(config)), plugin_id)
def test_every_macro_branch_is_reached():
"""The cases above must exercise every widget path the macro has."""
seen = set()
for _label, _variant, schema, config in CASES:
model = build_field_model(schema, json.loads(json.dumps(config)), "p")
seen |= {node["widget"] for node in iter_fields(model)}
assert MACRO_WIDGETS <= seen, sorted(MACRO_WIDGETS - seen)
def test_the_local_schemas_are_all_covered():
"""plugin-repos/ and the fixtures are always in the parity set."""
labels = {label for label, _ in SCHEMA_FILES}
for base, prefix in ((PROJECT_ROOT / "plugin-repos", "plugin-repos"),
(PROJECT_ROOT / "test" / "fixtures" / "plugins", "fixtures")):
for path in base.glob("*/config_schema.json"):
assert f"{prefix}/{path.parent.name}" in labels
def test_the_synthetic_model_reads_as_documented():
"""Spot checks of the model itself, beyond parity with the HTML."""
model = build_field_model(SYNTHETIC, json.loads(json.dumps(SYNTHETIC_CONFIG)), "demo")
by_path = {}
for node in iter_fields(model):
# First wins: a style-editor shares its path with its fallback section.
by_path.setdefault(node["path"], node)
assert "enabled" not in by_path # the header toggle owns it
assert "hidden_one" not in by_path and "hidden_object" not in by_path
assert model["rendered_sections"][-2:] == ["flag", "fancy_advanced"]
assert [n["path"] for n in model["advanced_fields"]] == ["flag", "fancy_advanced"]
assert by_path["not_advanced_object"]["advanced"] is False
assert by_path["mode"]["widget"] == "select"
assert by_path["mode"]["options"][0] == {"value": "vs", "label": "vs."}
assert by_path["count"]["widget"] == "select" # enum wins over integer
assert by_path["teams"]["stale_values"] == ["SEA"]
assert by_path["teams"]["value"] == ["BOS"]
assert by_path["calendars"]["mount"]["value"] == ["primary", "work"]
assert by_path["legacy"]["value"] == {"enabled": True}
assert by_path["legacy.enabled"]["inputs"][0]["checked"] is True
assert by_path["nested.b.deep"]["value"] == 7
assert [c["key"] for c in by_path["nested"]["children"]] == ["b", "a"]
assert by_path["password"]["secret"] is True
assert by_path["plugin_widget"]["mount"]["plugin_widget"] is True
assert by_path["customization"]["widget"] == "style-editor"
assert by_path["customization.score_text.text_color"]["widget"] == "color-picker"
assert [c["key"] for c in by_path["rows"]["columns"]] == ["symbol", "shares", "side", "active"]
assert by_path["rows"]["advanced_columns"] == ["on", "at", "logo", "layout", "note", "odd"]
assert by_path["bad_feeds"]["error"]
assert "default" not in by_path["ratio"] and by_path["ratio"]["value"] is None
json.dumps(model) # plain JSON all the way down
def test_monorepo_coverage_is_reported():
"""Not a gate: say which monorepo the parity run used (or that it was absent)."""
count = sum(1 for label, _ in SCHEMA_FILES if label.startswith("monorepo/"))
if MONOREPO is None:
pytest.skip("no ledmatrix-plugins checkout found; set LEDMATRIX_MONOREPO_PLUGINS")
assert count == len(list(MONOREPO.glob("*/config_schema.json")))
+119
View File
@@ -0,0 +1,119 @@
"""The ES-module layer of the web UI is served the way browsers need it.
static/v3/js/core/ and js/pages/ are native ES modules, loaded with
<script type="module"> and no bundler (docs/WEB_FRONTEND_ARCHITECTURE.md):
* A module script runs only when served with a JavaScript MIME type, so the
app pins .js to text/javascript instead of trusting the host's table.
* Modules import each other by plain relative URL, without the ?v= content
version url_for adds. Those requests must revalidate rather than be cached
as immutable for a year, or an update would keep running old modules.
* Every import resolves to a file that exists, every registered page has its
module and a partial whose root names it, and a converted partial carries
no inline <script> (which htmx-config.js would re-run on every swap).
"""
import re
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
JS = PROJECT_ROOT / "web_interface" / "static" / "v3" / "js"
PARTIALS = PROJECT_ROOT / "web_interface" / "templates" / "v3" / "partials"
MODULE_DIRS = (JS / "core", JS / "pages")
MODULES = sorted(p for d in MODULE_DIRS for p in d.glob("*.js"))
JS_TYPES = {"text/javascript", "application/javascript"}
_IMPORT = re.compile(r"""(?:\bimport\s*\(\s*|\bfrom\s+|^\s*import\s+)['"]([^'"]+)['"]""", re.M)
@pytest.fixture(scope="module")
def client():
import web_interface.app as web_app
web_app.app.config["TESTING"] = True
with web_app.app.test_client() as c:
yield c
def _url(path):
return "/static/" + path.relative_to(PROJECT_ROOT / "web_interface" / "static").as_posix()
def test_the_module_directories_hold_modules():
assert {p.name for p in MODULES} >= {"boot.js", "registry.js", "api.js", "facade.js", "cache.js"}
for directory in MODULE_DIRS:
# node needs this to import them in the JS tests; browsers ignore it.
assert '"type": "module"' in (directory / "package.json").read_text(encoding="utf-8")
@pytest.mark.parametrize("module", MODULES, ids=[m.name for m in MODULES])
def test_each_module_is_served_as_javascript_and_revalidated(client, module):
resp = client.get(_url(module))
assert resp.status_code == 200
assert resp.mimetype in JS_TYPES
# Requested as a relative import would request it: no ?v=.
assert resp.headers["Cache-Control"] == "no-cache"
assert resp.headers.get("ETag") or resp.headers.get("Last-Modified")
def test_a_versioned_script_is_still_cached_for_good(client):
resp = client.get(_url(JS / "core" / "boot.js") + "?v=123")
assert "immutable" in resp.headers["Cache-Control"]
def test_an_unchanged_module_revalidates_to_a_304(client):
first = client.get(_url(JS / "core" / "registry.js"))
again = client.get(_url(JS / "core" / "registry.js"),
headers={"If-None-Match": first.headers["ETag"]})
assert again.status_code == 304
def test_classic_scripts_keep_a_javascript_type(client):
resp = client.get("/static/v3/js/app-early.js")
assert resp.mimetype in JS_TYPES
def test_base_html_loads_the_entry_module_last(client):
page = client.get("/").get_data(as_text=True)
tag = re.search(r'<script type="module" src="(/static/v3/js/core/boot\.js\?v=\d+)"></script>', page)
assert tag, "base.html must load js/core/boot.js as a versioned module script"
# After every classic script, so nothing classic can depend on it at load.
assert page.index(tag.group(0)) > page.index("plugins_manager.js")
@pytest.mark.parametrize("module", MODULES, ids=[m.name for m in MODULES])
def test_every_import_resolves_inside_the_module_tree(module):
source = module.read_text(encoding="utf-8")
for spec in _IMPORT.findall(source):
assert spec.startswith("./") or spec.startswith("../"), (
f"{module.name}: {spec!r} -- no bundler, so only relative imports work")
target = (module.parent / spec).resolve()
assert target.is_file(), f"{module.name}: {spec!r} does not exist"
assert any(target.parent == d.resolve() for d in MODULE_DIRS), (
f"{module.name}: {spec!r} leaves js/core and js/pages")
def _registered_pages():
boot = (JS / "core" / "boot.js").read_text(encoding="utf-8")
return re.findall(r"registry\.register\('([\w-]+)',\s*function\(\)\s*\{\s*return import\('\.\./pages/([\w-]+)\.js'\)",
boot)
def test_every_registered_page_has_its_module_and_partial():
pages = _registered_pages()
assert ("cache", "cache") in pages
for name, module in pages:
assert name == module, "a page is named after its module"
assert (JS / "pages" / f"{module}.js").is_file()
partials = [p for p in PARTIALS.glob("*.html")
if f'data-page="{name}"' in p.read_text(encoding="utf-8")]
assert len(partials) == 1, f"one partial roots page {name!r}: {partials}"
def test_converted_partials_carry_no_inline_script():
for partial in PARTIALS.glob("*.html"):
text = partial.read_text(encoding="utf-8")
if "data-page=" in text:
assert "<script" not in text.lower(), (
f"{partial.name} is a page module now; its code belongs in js/pages/")