mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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); });
|
||||
@@ -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")))
|
||||
@@ -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/")
|
||||
Reference in New Issue
Block a user