mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 14:55:08 +00:00
feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) (#771)
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) - core/visibility.js: each page gets ctx.visibility (whileVisible, every, isVisible). Work registered there runs only while the page's tab is active and the browser tab visible, and ends when the page is swapped out. It reads the active tab from window.LEDVisibility, so it agrees with the classic partials. The registry gained a mountContext option for per-mount services. - pages/display.js replaces display.html's two inline scripts. The 5 s sync status poll runs through ctx.visibility.every; the status and scroll-speed hint requests go through ctx.api with ctx.signal, as does the Vegas order widget's plugin-list request. The Advanced toggle is a delegated data-action; window.updateSyncUI is a deprecated alias. - New DOM suites test_visibility_service.js and test_display_page.js; test_display_partial_ids.js imports the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): no computed keys in the Display page's readout and destroy Codacy's object-injection rule flagged v[id] and ctx.state[name]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -59,7 +59,7 @@ server has none.
|
||||
| `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_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
|
||||
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
|
||||
| `unit/test_display_partial_ids.js` | no | `js/pages/display.js` started on a fake root that answers only for the ids `partials/display.html` renders: every id it looks up (with every listener and timer it set fired) exists, and moving the brightness slider updates its label without throwing |
|
||||
| `unit/test_general_web_login_token.js` | no | `createToken` from `js/pages/general.js`, imported with a fake DOM and fetch: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
|
||||
| `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 |
|
||||
@@ -70,6 +70,8 @@ server has none.
|
||||
| `dom/test_operation_history_page.js` | yes | The Operation History tab (`js/pages/operation-history.js`): one request per swap and per Refresh, the plugin filter filled once, paging, filters, search, Clear, error/login states, hostile values stay text |
|
||||
| `dom/test_raw_json_page.js` | yes | The Config Editor tab (`js/pages/raw-json.js`): one POST per Save after repeated swaps, Format/Validate, invalid JSON never sent, a save survives a swap, the old global entry points |
|
||||
| `dom/test_schedule_page.js` | yes | The Schedule tab (`js/pages/schedule.js`) with the real `schedule-picker` widget: both pickers drawn once per swap from the saved config, one notification per save answer after repeated swaps, the brightness label, a late widget waited for, the old global entry points |
|
||||
| `dom/test_visibility_service.js` | yes (no server) | `js/core/visibility.js` with the real `LEDVisibility` from `app-shell.js` and the real registry: start/stop with the active tab and the browser tab's visibility, no interval while hidden or after a swap-out, registrations independent, the no-`LEDVisibility` fallback |
|
||||
| `dom/test_display_page.js` | yes | The Display tab (`js/pages/display.js`) with the real `plugin-order-list` widget and `LEDVisibility`: one page, one sync interval and one action per control after repeated swaps, the sync poll only while on screen and never after a swap-out, sync states as text, the debounced scroll-speed hint, `updateSyncUI`'s entry point |
|
||||
| `dom/test_general_page.js` | yes | The General tab (`js/pages/general.js`) with the real `timezone-selector` widget: the picker drawn once per swap, one request per Security action after repeated swaps, hostile token names stay text, refused/network/login answers, a write survives a swap, `webLogin`'s entry points |
|
||||
| `dom/test_backup_restore_page.js` | yes | The Backup & Restore tab (`js/pages/backup-restore.js`): one request per action after repeated swaps, the upload and restore options, reads cancelled and writes not on a swap, hostile names stay text, the old global entry points |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
// The Display tab as a page module (static/v3/js/pages/display.js), in a
|
||||
// real DOM (jsdom) with the real server-rendered partial, the real
|
||||
// plugin-order-list widget, the real window.LEDVisibility (app-shell.js)
|
||||
// behind ctx.visibility, and the real API's answer shapes. Built like
|
||||
// test_cache_page.js:
|
||||
//
|
||||
// * the partial ships no <script> and no inline handlers; its root is
|
||||
// data-page="display"
|
||||
// * after five swaps: one mounted page, the Vegas order drawn once, each
|
||||
// control acting once (brightness, resolution, Vegas and double-sided
|
||||
// toggles, the Advanced section toggle, one debounced scroll-speed hint
|
||||
// request)
|
||||
// * the sync status is polled only while the Display tab is on screen and
|
||||
// the browser tab visible: no interval runs while hidden, and none after
|
||||
// the partial is swapped out
|
||||
// * sync states drawn as text; a failed poll says "unavailable", a login
|
||||
// redirect draws nothing
|
||||
// * a widget that loads late is waited for, and a page swapped away while
|
||||
// waiting starts nothing
|
||||
// * window.updateSyncUI's entry point still works
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
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/display');
|
||||
const realSync = JSON.parse(await get('/api/v3/sync/status'));
|
||||
const smooth = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=50&min=1&max=200'));
|
||||
const rough = JSON.parse(await get('/api/v3/config/scroll-speed-advice?speed=37&min=1&max=200'));
|
||||
const { createRegistry } = await load('core/registry.js');
|
||||
const { createApi } = await load('core/api.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
const displayPage = await load('pages/display.js');
|
||||
|
||||
console.log('\n── Display tab: page module (real DOM) ──');
|
||||
ok('the partial ships no inline script', !/<script/i.test(partial));
|
||||
ok('the partial has no inline handlers', !/\son(click|change|input)=/i.test(partial));
|
||||
ok('the partial root is data-page="display"', /data-page="display"/.test(partial));
|
||||
ok('the Advanced toggle names its action',
|
||||
/data-action="toggle-section"\s+data-section="display-section-advanced-hardware"/.test(partial));
|
||||
ok('the real sync status answers in the shape the page reads',
|
||||
realSync.status === 'success' && realSync.data && typeof realSync.data.state === 'string', realSync);
|
||||
ok('the real scroll-speed advice answers in the shape the page reads',
|
||||
smooth.status === 'success' && smooth.data.applied && Array.isArray(rough.data.alternatives)
|
||||
&& rough.data.alternatives.length > 0, rough);
|
||||
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM(`<!doctype html><html><body><div id="display-content">${partial}</div></body></html>`,
|
||||
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
const panel = doc.getElementById('display-content');
|
||||
|
||||
// The browser tab's visibility and the app's active tab, under test control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
const setHidden = v => { hidden = v; doc.dispatchEvent(new window.Event('visibilitychange')); };
|
||||
const setTab = tab => doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
// Intervals, counted. Timeouts (the hint's debounce, the widget retry) are real.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
const HOSTILE = '<img src=x onerror="window.pwned=1">';
|
||||
const plugins = [
|
||||
{ id: 'clock', name: 'Clock', enabled: true },
|
||||
{ id: 'weather', name: HOSTILE, enabled: true },
|
||||
];
|
||||
let syncAnswer = { status: 'success', data: { role: 'leader', state: 'no_peer' } };
|
||||
let syncMode = 'ok';
|
||||
let advice = smooth;
|
||||
const requests = [];
|
||||
function fakeFetch(url, init = {}) {
|
||||
requests.push(url);
|
||||
const respond = (status, body, headers) => Promise.resolve({
|
||||
status, ok: status >= 200 && status < 300,
|
||||
headers: { get: h => (headers || {})[h] || null },
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
if (url === '/api/v3/plugins/installed') return respond(200, { status: 'success', data: { plugins } });
|
||||
if (url.startsWith('/api/v3/config/scroll-speed-advice?')) return respond(200, advice);
|
||||
if (url === '/api/v3/sync/status') {
|
||||
if (syncMode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
|
||||
if (syncMode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
|
||||
return respond(200, syncAnswer);
|
||||
}
|
||||
return respond(404, { status: 'error', message: 'unexpected ' + url });
|
||||
}
|
||||
window.fetch = fakeFetch;
|
||||
|
||||
// The shell: LEDVisibility (no Alpine here, so the active tab is the last
|
||||
// ledmatrix:tab-changed; the SSE streams open stand-in EventSources), and
|
||||
// the shared toggleSection the Advanced button calls.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
const toggled = [];
|
||||
window.toggleSection = id => toggled.push(id);
|
||||
window.eval(fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8'));
|
||||
const widget = window.PluginOrderList;
|
||||
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
registry.register('display', displayPage);
|
||||
|
||||
const $ = id => doc.getElementById(id);
|
||||
const root = () => doc.querySelector('[data-page="display"]');
|
||||
const count = prefix => requests.filter(u => u.startsWith(prefix)).length;
|
||||
const syncPolls = () => count('/api/v3/sync/status');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html === undefined ? partial : html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(20);
|
||||
}
|
||||
function fire(el, type) { el.dispatchEvent(new window.Event(type, { bubbles: true })); }
|
||||
function setRole(role) { $('sync_role').value = role; fire($('sync_role'), 'change'); }
|
||||
|
||||
setTab('display');
|
||||
await registry.start();
|
||||
await tick(200);
|
||||
|
||||
// ── first load ──────────────────────────────────────────────────────────
|
||||
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1, requests);
|
||||
ok('one scroll-speed hint request on start (after the debounce)', count('/api/v3/config/scroll-speed-advice') === 1, requests);
|
||||
ok('the saved role is standalone: no sync request, no interval work',
|
||||
$('sync_role').value === 'standalone' && syncPolls() === 0, [$('sync_role').value, syncPolls()]);
|
||||
ok('the sync poll interval runs while the tab is on screen', intervals.size === 1
|
||||
&& [...intervals.values()][0].ms === 5000, intervals.size);
|
||||
ok('the status bar is hidden for standalone', $('sync_status_bar').classList.contains('hidden'));
|
||||
|
||||
// ── five swaps ──────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap();
|
||||
await tick(200);
|
||||
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
|
||||
ok('one sync interval, not six', intervals.size === 1, intervals.size);
|
||||
ok('one plugin-list request per swap', count('/api/v3/plugins/installed') === 6, count('/api/v3/plugins/installed'));
|
||||
ok('the Vegas order drawn once, not stacked', doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length === 2,
|
||||
doc.querySelectorAll('#vegas_plugin_order .plugin-order-item').length);
|
||||
ok('a hostile plugin name is shown as text', $('vegas_plugin_order').textContent.includes(HOSTILE)
|
||||
&& !doc.querySelector('#vegas_plugin_order img') && !window.pwned);
|
||||
|
||||
// ── the controls ────────────────────────────────────────────────────────
|
||||
$('brightness').value = '42';
|
||||
fire($('brightness'), 'input');
|
||||
ok('the brightness value follows the slider', $('brightness-value').textContent === '42', $('brightness-value').textContent);
|
||||
|
||||
$('rows').value = '32'; $('cols').value = '64'; $('chain_length').value = '3'; $('parallel').value = '2';
|
||||
fire($('parallel'), 'input');
|
||||
ok('the resolution readout is cols x chain by rows x parallel',
|
||||
$('display-resolution-value').textContent === '192 × 64 pixels', $('display-resolution-value').textContent);
|
||||
$('orientation').value = '90';
|
||||
fire($('orientation'), 'change');
|
||||
ok('...swapped for a 90-degree orientation', $('display-resolution-value').textContent === '64 × 192 pixels',
|
||||
$('display-resolution-value').textContent);
|
||||
$('rows').value = '';
|
||||
fire($('rows'), 'input');
|
||||
ok('...and a dash while a field is empty', $('display-resolution-value').textContent === '—');
|
||||
|
||||
for (const [box, settings, shown] of [['vegas_scroll_enabled', 'vegas_scroll_settings', 'block'],
|
||||
['double_sided_enabled', 'double_sided_settings', 'grid']]) {
|
||||
$(box).checked = true; fire($(box), 'change');
|
||||
const on = $(settings).style.display;
|
||||
$(box).checked = false; fire($(box), 'change');
|
||||
ok(`${box} shows and hides its settings`, on === shown && $(settings).style.display === 'none',
|
||||
[on, $(settings).style.display]);
|
||||
}
|
||||
|
||||
root().querySelector('[data-action="toggle-section"]').click();
|
||||
ok('the Advanced button toggles its section once', toggled.join() === 'display-section-advanced-hardware', toggled);
|
||||
|
||||
// ── the scroll-speed hint ───────────────────────────────────────────────
|
||||
const hints = count('/api/v3/config/scroll-speed-advice');
|
||||
advice = rough;
|
||||
for (const v of ['36', '37', '38']) { $('vegas_scroll_speed').value = v; fire($('vegas_scroll_speed'), 'input'); }
|
||||
ok('the speed value follows the slider', $('vegas_scroll_speed_value').textContent === '38');
|
||||
await tick(250);
|
||||
ok('three quick moves make one hint request', count('/api/v3/config/scroll-speed-advice') === hints + 1,
|
||||
count('/api/v3/config/scroll-speed-advice') - hints);
|
||||
ok('...for the last speed', requests.filter(u => u.includes('advice')).pop().includes('speed=38'));
|
||||
const buttons = $('vegas_scroll_speed_hint').querySelectorAll('button');
|
||||
ok('a rough speed offers the smooth ones', buttons.length === rough.data.alternatives.length
|
||||
&& /will run as/.test($('vegas_scroll_speed_hint').textContent), $('vegas_scroll_speed_hint').textContent);
|
||||
buttons[0].click();
|
||||
ok('picking one sets the slider', $('vegas_scroll_speed').value === String(Math.round(rough.data.alternatives[0].pixels_per_second))
|
||||
&& $('vegas_scroll_speed_value').textContent === $('vegas_scroll_speed').value, $('vegas_scroll_speed').value);
|
||||
advice = smooth;
|
||||
await tick(250);
|
||||
ok('...and asks again', count('/api/v3/config/scroll-speed-advice') === hints + 2);
|
||||
ok('a smooth speed says so', /^Smooth on this panel/.test($('vegas_scroll_speed_hint').textContent),
|
||||
$('vegas_scroll_speed_hint').textContent);
|
||||
|
||||
// ── sync: the role ──────────────────────────────────────────────────────
|
||||
setRole('leader');
|
||||
await tick(20);
|
||||
ok('choosing Leader shows the status bar', !$('sync_status_bar').classList.contains('hidden'));
|
||||
ok('...hides Position', $('setting-display-sync_follower_position').style.display === 'none');
|
||||
ok('...and asks for the status once', syncPolls() === 1, syncPolls());
|
||||
ok('...drawn as text', $('sync_status_content').textContent.includes('No follower detected'),
|
||||
$('sync_status_content').textContent);
|
||||
setRole('follower');
|
||||
await tick(20);
|
||||
ok('choosing Follower shows Position', $('setting-display-sync_follower_position').style.display === '');
|
||||
|
||||
// ── sync: the poll runs only while on screen ────────────────────────────
|
||||
let polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('each interval tick polls once', syncPolls() === polls + 1, syncPolls() - polls);
|
||||
setTab('logs');
|
||||
ok('switching to another tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('display');
|
||||
await tick(20);
|
||||
ok('switching back polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab clears the interval', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('showing it polls at once', syncPolls() === polls + 1 && intervals.size === 1, [syncPolls() - polls, intervals.size]);
|
||||
|
||||
// ── sync: states ────────────────────────────────────────────────────────
|
||||
async function poll() { fireIntervals(); await tick(20); return $('sync_status_content').textContent; }
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'connected', peer_ip: HOSTILE, peer_chain: 2 } };
|
||||
ok('a connected follower, its address as text', (await poll()).includes('Follower connected — ' + HOSTILE)
|
||||
&& !$('sync_status_content').querySelector('img'), $('sync_status_content').textContent);
|
||||
syncAnswer = { status: 'success', data: { role: 'leader', state: 'incompatible', error: 'rows differ ' + HOSTILE } };
|
||||
ok('incompatible panels show the reason as text', (await poll()).includes('incompatible')
|
||||
&& !$('sync_error_detail').classList.contains('hidden') && $('sync_error_text').textContent === 'rows differ ' + HOSTILE
|
||||
&& !$('sync_error_detail').querySelector('img'));
|
||||
syncAnswer = { status: 'success', data: realSync.data };
|
||||
ok('the real server\'s answer is drawn', (await poll()).length > 0, $('sync_status_content').textContent);
|
||||
syncMode = 'network';
|
||||
ok('a failed poll says unavailable', (await poll()).includes('Sync status unavailable'));
|
||||
syncAnswer = { status: 'success', data: { role: 'follower', state: 'follower', leader_ip: '10.0.0.2' } };
|
||||
syncMode = 'ok';
|
||||
ok('receiving from a leader', (await poll()).includes('Receiving from leader — 10.0.0.2'));
|
||||
syncMode = 'login';
|
||||
ok('a login redirect draws nothing', (await poll()).includes('Receiving from leader'));
|
||||
syncMode = 'ok';
|
||||
|
||||
// ── standalone stops asking ─────────────────────────────────────────────
|
||||
setRole('standalone');
|
||||
polls = syncPolls();
|
||||
fireIntervals();
|
||||
await tick(20);
|
||||
ok('standalone hides the bar and the poll asks nothing',
|
||||
$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls, syncPolls() - polls);
|
||||
|
||||
// ── window.updateSyncUI ─────────────────────────────────────────────────
|
||||
$('sync_role').value = 'leader';
|
||||
polls = syncPolls();
|
||||
displayPage.updateSyncUI();
|
||||
await tick(20);
|
||||
ok('updateSyncUI() applies the role and asks once',
|
||||
!$('sync_status_bar').classList.contains('hidden') && syncPolls() === polls + 1, syncPolls() - polls);
|
||||
|
||||
// ── swapped out ─────────────────────────────────────────────────────────
|
||||
await swap('<p>another tab</p>');
|
||||
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
|
||||
ok('no interval left running', intervals.size === 0, intervals.size);
|
||||
polls = syncPolls();
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
await tick(20);
|
||||
ok('a swapped-out page never polls again', syncPolls() === polls && intervals.size === 0, syncPolls() - polls);
|
||||
const hintsGone = count('/api/v3/config/scroll-speed-advice');
|
||||
await swap();
|
||||
$('vegas_scroll_speed').value = '40';
|
||||
fire($('vegas_scroll_speed'), 'input');
|
||||
await swap('<p>another tab</p>');
|
||||
await tick(250);
|
||||
ok('a hint still waiting out its debounce at the swap is never asked for',
|
||||
count('/api/v3/config/scroll-speed-advice') === hintsGone, count('/api/v3/config/scroll-speed-advice') - hintsGone);
|
||||
|
||||
// ── the widget loads late ───────────────────────────────────────────────
|
||||
delete window.PluginOrderList;
|
||||
const beforeLate = count('/api/v3/plugins/installed');
|
||||
await swap();
|
||||
ok('no list request while the widget is missing', count('/api/v3/plugins/installed') === beforeLate);
|
||||
window.PluginOrderList = widget;
|
||||
await tick(150);
|
||||
ok('the list starts once the widget arrives', count('/api/v3/plugins/installed') === beforeLate + 1);
|
||||
delete window.PluginOrderList;
|
||||
await swap();
|
||||
const beforeGone = count('/api/v3/plugins/installed');
|
||||
await swap('<p>another tab</p>');
|
||||
window.PluginOrderList = widget;
|
||||
await tick(250);
|
||||
ok('a page swapped away while waiting starts nothing', count('/api/v3/plugins/installed') === beforeGone);
|
||||
|
||||
ok('no console errors', logged.length === 0, logged);
|
||||
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); });
|
||||
@@ -0,0 +1,211 @@
|
||||
// The page-visibility service (static/v3/js/core/visibility.js), in a real
|
||||
// DOM (jsdom) with the real window.LEDVisibility from app-shell.js and the
|
||||
// real page registry, wired the way core/boot.js wires them (each mount gets
|
||||
// ctx.visibility from mountContext):
|
||||
//
|
||||
// * whileVisible(start, stop) runs start() only while the page's tab is the
|
||||
// active tab AND the browser tab is visible, stop() when either changes
|
||||
// * every(ms, fn) calls fn at once and then on an interval while visible;
|
||||
// no interval is left running while hidden
|
||||
// * everything a page registered stops when the page is swapped out, and a
|
||||
// page that registers after it was destroyed starts nothing
|
||||
// * registrations never replace each other (two timers on one page, two
|
||||
// pages, or a classic partial's own LEDVisibility key)
|
||||
// * without LEDVisibility, the browser tab's visibility alone decides
|
||||
//
|
||||
// Needs jsdom but no server.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
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 { createRegistry } = await load('core/registry.js');
|
||||
const { createVisibility } = await load('core/visibility.js');
|
||||
|
||||
console.log('\n── Page visibility service (real DOM, real LEDVisibility) ──');
|
||||
const errs = [];
|
||||
const logged = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
vc.on('error', (...a) => logged.push(a.join(' ')));
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="display-content"></div><div id="logs-content"></div></body></html>',
|
||||
{ url: 'http://localhost/', virtualConsole: vc, runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
const doc = window.document;
|
||||
|
||||
// The browser tab's visibility, under the test's control.
|
||||
let hidden = false;
|
||||
Object.defineProperty(doc, 'hidden', { get: () => hidden, configurable: true });
|
||||
function setHidden(value) {
|
||||
hidden = value;
|
||||
doc.dispatchEvent(new window.Event('visibilitychange'));
|
||||
}
|
||||
function setTab(tab) {
|
||||
doc.dispatchEvent(new window.CustomEvent('ledmatrix:tab-changed', { detail: { tab } }));
|
||||
}
|
||||
|
||||
// Intervals, counted: the point is that none is left running.
|
||||
const intervals = new Map();
|
||||
let nextInterval = 1;
|
||||
window.setInterval = (fn, ms) => { const id = nextInterval++; intervals.set(id, { fn, ms }); return id; };
|
||||
window.clearInterval = id => { intervals.delete(id); };
|
||||
const fireIntervals = () => [...intervals.values()].forEach(i => i.fn());
|
||||
|
||||
// The real LEDVisibility (app-shell.js). Alpine is absent, so the active
|
||||
// tab is the last ledmatrix:tab-changed. The SSE streams are not under
|
||||
// test: they open stand-in EventSources.
|
||||
window.getApp = () => null;
|
||||
window.EventSource = class { addEventListener() {} removeEventListener() {} close() {} };
|
||||
window.eval(fs.readFileSync(path.join(JS, 'app-shell.js'), 'utf8'));
|
||||
ok('app-shell.js defines LEDVisibility', !!(window.LEDVisibility && window.LEDVisibility.onActive));
|
||||
|
||||
const visibility = createVisibility({ window });
|
||||
const registry = createRegistry({
|
||||
document: doc,
|
||||
context: {},
|
||||
mountContext: ctx => ({ visibility: visibility.forPage(ctx) }),
|
||||
});
|
||||
|
||||
const log = [];
|
||||
let polls = 0;
|
||||
const handles = [];
|
||||
registry.register('display', {
|
||||
init(root, ctx) {
|
||||
handles.push(ctx.visibility);
|
||||
ctx.visibility.whileVisible(() => log.push('start'), () => log.push('stop'));
|
||||
ctx.visibility.every(5000, () => { polls++; });
|
||||
},
|
||||
});
|
||||
let otherRuns = 0;
|
||||
registry.register('logs', {
|
||||
init(root, ctx) { ctx.visibility.every(1000, () => { otherRuns++; }); },
|
||||
});
|
||||
|
||||
const panel = doc.getElementById('display-content');
|
||||
async function swap(html) {
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
|
||||
panel.innerHTML = html;
|
||||
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
|
||||
await tick(10);
|
||||
}
|
||||
|
||||
// A classic partial's own registration, keyed by its tab name: the
|
||||
// service's registrations must not replace it, nor it them.
|
||||
let classic = 0;
|
||||
window.LEDVisibility.onActive('display', () => { classic++; }, () => {});
|
||||
|
||||
setTab('overview');
|
||||
await registry.start();
|
||||
await swap('<div data-page="display"></div>');
|
||||
|
||||
// ── mounted on another tab ──────────────────────────────────────────────
|
||||
ok('mounted while another tab is active: nothing starts', log.length === 0 && polls === 0, [log, polls]);
|
||||
ok('...and no interval runs', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false', handles[0].isVisible() === false);
|
||||
ok('the page\'s tab is its name', handles[0].tab === 'display');
|
||||
|
||||
// ── its tab comes on screen ─────────────────────────────────────────────
|
||||
setTab('display');
|
||||
ok('switching to the tab runs start()', log.join() === 'start', log);
|
||||
ok('every() calls fn at once', polls === 1, polls);
|
||||
ok('...and sets one interval at the asked period', intervals.size === 1 && [...intervals.values()][0].ms === 5000,
|
||||
[...intervals.values()].map(i => i.ms));
|
||||
ok('isVisible() is true', handles[0].isVisible() === true);
|
||||
ok('the classic registration still runs alongside', classic === 1, classic);
|
||||
fireIntervals();
|
||||
fireIntervals();
|
||||
ok('fn runs on each interval', polls === 3, polls);
|
||||
|
||||
// ── the browser tab is hidden, then shown ───────────────────────────────
|
||||
setHidden(true);
|
||||
ok('hiding the browser tab runs stop()', log.join() === 'start,stop', log);
|
||||
ok('...and clears the interval', intervals.size === 0, intervals.size);
|
||||
ok('isVisible() is false while hidden', handles[0].isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('showing it again runs start()', log.join() === 'start,stop,start', log);
|
||||
ok('...and fn at once, with one interval again', polls === 4 && intervals.size === 1, [polls, intervals.size]);
|
||||
|
||||
// ── another tab ─────────────────────────────────────────────────────────
|
||||
setTab('logs');
|
||||
ok('switching away runs stop() and clears the interval', log.join() === 'start,stop,start,stop' && intervals.size === 0,
|
||||
[log, intervals.size]);
|
||||
setTab('display');
|
||||
ok('switching back restarts it', log.length === 5 && polls === 5 && intervals.size === 1, [log, polls]);
|
||||
|
||||
// ── the partial is swapped out while on screen ──────────────────────────
|
||||
await swap('<p>no page here</p>');
|
||||
ok('a swap-out stops it', log[log.length - 1] === 'stop', log);
|
||||
ok('...and leaves no interval running', intervals.size === 0, intervals.size);
|
||||
const before = [log.length, polls];
|
||||
setTab('overview');
|
||||
setTab('display');
|
||||
setHidden(true);
|
||||
setHidden(false);
|
||||
ok('a destroyed page never starts again', log.length === before[0] && polls === before[1], [log, polls]);
|
||||
ok('the classic registration keeps running after the swap', classic === 5, classic);
|
||||
|
||||
// ── five swaps, then one page ───────────────────────────────────────────
|
||||
for (let i = 0; i < 5; i++) await swap('<div data-page="display"></div>');
|
||||
ok('after five swaps one interval runs, not five', intervals.size === 1, intervals.size);
|
||||
const pollsBefore = polls;
|
||||
fireIntervals();
|
||||
ok('...and one poll per tick', polls === pollsBefore + 1, polls - pollsBefore);
|
||||
|
||||
// ── two timers on one page, and an end function ─────────────────────────
|
||||
const h = handles[handles.length - 1];
|
||||
let a = 0, b = 0;
|
||||
const endA = h.every(100, () => { a++; });
|
||||
h.every(200, () => { b++; });
|
||||
ok('two more timers on one page both start', a === 1 && b === 1 && intervals.size === 3, [a, b, intervals.size]);
|
||||
endA();
|
||||
endA();
|
||||
ok('an end function stops just its own timer (twice is harmless)', intervals.size === 2, intervals.size);
|
||||
|
||||
// ── a page that registers after it was destroyed ────────────────────────
|
||||
const gone = handles[handles.length - 1];
|
||||
await swap('<p>gone</p>');
|
||||
ok('the swap-out clears every timer the page had', intervals.size === 0, intervals.size);
|
||||
let late = 0;
|
||||
const end = gone.every(1000, () => { late++; });
|
||||
ok('registering after destroy starts nothing', late === 0 && intervals.size === 0 && typeof end === 'function');
|
||||
|
||||
// ── a start() that throws ───────────────────────────────────────────────
|
||||
await swap('<div data-page="display"></div>');
|
||||
const h2 = handles[handles.length - 1];
|
||||
const loggedBefore = logged.length;
|
||||
let afterThrow = 0;
|
||||
h2.whileVisible(() => { throw new Error('boom'); }, () => {});
|
||||
h2.whileVisible(() => { afterThrow++; }, () => {});
|
||||
ok('a throwing start() is logged', logged.length === loggedBefore + 1 && /boom|start failed/.test(logged.join()),
|
||||
logged.slice(loggedBefore));
|
||||
ok('...and the next registration still starts', afterThrow === 1);
|
||||
await swap('<p>gone</p>');
|
||||
|
||||
// ── without LEDVisibility (a page outside base.html) ────────────────────
|
||||
const bare = createVisibility({ window, tracker: () => null }).forPage({ name: 'standalone', signal: new window.AbortController().signal });
|
||||
const seen = [];
|
||||
bare.whileVisible(() => seen.push('start'), () => seen.push('stop'));
|
||||
ok('without LEDVisibility, a visible document starts at once', seen.join() === 'start', seen);
|
||||
setHidden(true);
|
||||
ok('...and hiding it stops', seen.join() === 'start,stop', seen);
|
||||
ok('...isVisible() follows the document', bare.isVisible() === false);
|
||||
setHidden(false);
|
||||
ok('...and showing it starts again', seen.join() === 'start,stop,start', seen);
|
||||
|
||||
ok('registrations need functions', (() => { try { h2.whileVisible(null, null); return false; } catch { return true; } })());
|
||||
ok('every() needs a positive period', (() => { try { h2.every(0, () => {}); return false; } catch { return true; } })());
|
||||
ok('the logs page was never on screen: it never ran', otherRuns === 0, otherRuns);
|
||||
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); });
|
||||
+2
-1
@@ -37,7 +37,8 @@ const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_
|
||||
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
|
||||
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
|
||||
'dom/test_raw_json_page.js', 'dom/test_backup_restore_page.js',
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js'];
|
||||
'dom/test_schedule_page.js', 'dom/test_general_page.js',
|
||||
'dom/test_visibility_service.js', 'dom/test_display_page.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// The Display tab's inline script must only look up elements the partial
|
||||
// The Display tab's page module must only look up elements the partial
|
||||
// renders.
|
||||
//
|
||||
// Its brightness slider handler also wrote to #brightness-display, a "LED
|
||||
// brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// Its brightness slider handler once also wrote to #brightness-display, a
|
||||
// "LED brightness: N%" line that #387 removed from partials/display.html. The
|
||||
// lookup returned null, so every movement of the slider threw a TypeError.
|
||||
// This checks every literal getElementById() in the partial's inline scripts
|
||||
// against the ids its markup renders, and runs the shipped script in a vm
|
||||
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
|
||||
// the slider.
|
||||
// This imports the shipped module (static/v3/js/pages/display.js), starts it
|
||||
// on a fake root that answers only for the ids the partial's markup renders
|
||||
// (null for any other, as in a browser), fires every listener it registered,
|
||||
// and checks that every id it asked for exists. Then it moves the slider.
|
||||
//
|
||||
// No jsdom and no server needed.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/display.html');
|
||||
const JS = path.resolve(__dirname, '../../../web_interface/static/v3/js');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (label, cond, extra) => cond
|
||||
@@ -23,86 +24,93 @@ const ok = (label, cond, extra) => cond
|
||||
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
||||
|
||||
const html = fs.readFileSync(PARTIAL, 'utf8');
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
|
||||
const scripts = blocks.map(m => m[1]);
|
||||
// The markup is what lies between the script blocks (sliced around them, not
|
||||
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
|
||||
let markup = '';
|
||||
let from = 0;
|
||||
for (const m of blocks) {
|
||||
markup += html.slice(from, m.index);
|
||||
from = m.index + m[0].length;
|
||||
}
|
||||
markup += html.slice(from);
|
||||
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
const rendered = new Set([...html.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));
|
||||
|
||||
console.log('\n── Display partial: element lookups ──');
|
||||
|
||||
// 1. Static: every literal lookup names an id the partial renders.
|
||||
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
|
||||
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
|
||||
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
|
||||
function fakeElement(id) {
|
||||
const listeners = {};
|
||||
const classes = new Set();
|
||||
return {
|
||||
id, value: '', textContent: '', min: '', max: '', checked: false,
|
||||
id, value: '1', textContent: '', min: '', max: '', checked: false,
|
||||
style: {}, dataset: {}, className: '',
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
|
||||
contains: c => classes.has(c),
|
||||
},
|
||||
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
|
||||
dispatchEvent() { return true; },
|
||||
appendChild() {},
|
||||
getAttribute: () => null,
|
||||
listeners,
|
||||
};
|
||||
}
|
||||
|
||||
const main = scripts.find(s => s.includes("getElementById('brightness')"));
|
||||
ok('found the script that wires the brightness slider', !!main);
|
||||
if (main) {
|
||||
const elements = new Map();
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
hidden: false,
|
||||
getElementById: id => {
|
||||
if (!rendered.has(id)) return null;
|
||||
if (!elements.has(id)) elements.set(id, fakeElement(id));
|
||||
return elements.get(id);
|
||||
},
|
||||
createElement: () => fakeElement(''),
|
||||
createTextNode: () => ({}),
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
LEDEscape: { html: v => String(v), attr: v => String(v) },
|
||||
LEDVisibility: { onActive() {} },
|
||||
};
|
||||
const context = {
|
||||
window, document, console, URLSearchParams,
|
||||
fetch: () => new Promise(() => {}),
|
||||
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
let loadError = null;
|
||||
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
|
||||
ok('the script loads', !loadError, loadError && String(loadError));
|
||||
(async () => {
|
||||
console.log('\n── Display page module: element lookups ──');
|
||||
const display = await import(pathToFileURL(path.join(JS, 'pages/display.js')).href);
|
||||
|
||||
const slider = elements.get('brightness');
|
||||
const handlers = (slider && slider.listeners.input) || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
const asked = new Set();
|
||||
const elements = new Map();
|
||||
const timers = [];
|
||||
const win = {
|
||||
setTimeout: fn => { timers.push(fn); return timers.length; },
|
||||
clearTimeout() {},
|
||||
URLSearchParams,
|
||||
Event: class { constructor(type) { this.type = type; } },
|
||||
PluginOrderList: { init() {} },
|
||||
};
|
||||
const doc = { defaultView: win, createElement: () => fakeElement(''), createTextNode: () => ({}) };
|
||||
const rootListeners = {};
|
||||
const root = {
|
||||
ownerDocument: doc,
|
||||
querySelector(sel) {
|
||||
const m = /^#([\w-]+)$/.exec(sel);
|
||||
if (!m) throw new Error('unexpected selector ' + sel);
|
||||
asked.add(m[1]);
|
||||
if (!rendered.has(m[1])) return null;
|
||||
if (!elements.has(m[1])) elements.set(m[1], fakeElement(m[1]));
|
||||
return elements.get(m[1]);
|
||||
},
|
||||
addEventListener: (type, fn) => { (rootListeners[type] ||= []).push(fn); },
|
||||
contains: () => true,
|
||||
};
|
||||
const never = () => new Promise(() => {});
|
||||
const polls = [];
|
||||
const ctx = {
|
||||
root, name: 'display', state: {}, signal: { aborted: false },
|
||||
api: { get: never },
|
||||
visibility: { every: (ms, fn) => { polls.push(ms); fn(); return () => {}; } },
|
||||
};
|
||||
|
||||
let loadError = null;
|
||||
try { display.init(root, ctx); } catch (e) { loadError = e; }
|
||||
ok('init() runs', !loadError, loadError && String(loadError));
|
||||
ok('the sync status is polled through ctx.visibility', polls.length === 1 && polls[0] === 5000, polls);
|
||||
|
||||
// Fire everything it wired, so every lookup it can make is made.
|
||||
let thrown = null;
|
||||
try {
|
||||
for (const el of elements.values()) {
|
||||
for (const fns of Object.values(el.listeners)) fns.forEach(fn => fn.call(el, { target: el }));
|
||||
}
|
||||
while (timers.length) timers.shift()();
|
||||
} catch (e) { thrown = e; }
|
||||
ok('its listeners and timers run without throwing', !thrown, thrown && String(thrown));
|
||||
|
||||
const missing = [...asked].filter(id => !rendered.has(id));
|
||||
ok('it looks elements up', asked.size > 10, asked.size);
|
||||
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);
|
||||
|
||||
const slider = elements.get('brightness') || fakeElement('brightness');
|
||||
const handlers = slider.listeners.input || [];
|
||||
ok('the slider has an input handler', handlers.length > 0);
|
||||
slider.value = '42';
|
||||
thrown = null;
|
||||
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
|
||||
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
|
||||
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
|
||||
elements.get('brightness-value').textContent);
|
||||
}
|
||||
const label = elements.get('brightness-value');
|
||||
ok('...and shows the new value', !!label && label.textContent === '42', label && label.textContent);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
display.destroy(root, ctx);
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -103,17 +103,16 @@ const ESCAPERS = [
|
||||
'static/v3/js/widgets/text-input.js', 'function escapeHtml(text) {', 'escapeHtml', false],
|
||||
['slider.js (escapeAttr)',
|
||||
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['display.html (escapeAttr)',
|
||||
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
|
||||
['tools.html (escHtml)',
|
||||
'templates/v3/partials/tools.html', 'function escHtml(s) {', 'escHtml', false],
|
||||
['tools.html (phEscape)',
|
||||
'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, backup_restore.html and operation_history.html have no
|
||||
// script any more: their js/pages/ modules draw server data with
|
||||
// textContent, and each page's suite in test/js/dom/ checks a hostile value.
|
||||
// cache.html, backup_restore.html, operation_history.html and display.html
|
||||
// have no script any more (display.html's two escapers were never called):
|
||||
// their js/pages/ modules draw server data with textContent, and each
|
||||
// page's suite in test/js/dom/ checks a hostile value.
|
||||
];
|
||||
|
||||
// The breakout payload: closes a double-quoted attribute and opens an event
|
||||
|
||||
@@ -242,6 +242,46 @@ function recorder(log) {
|
||||
ok('has()', reg.has('dup') && !reg.has('nope'));
|
||||
}
|
||||
|
||||
console.log('\n10. mountContext adds per-mount fields, after the shared ones');
|
||||
{
|
||||
const doc = new Doc();
|
||||
const panel = doc.body.appendChild(new El('div', { id: 'panel' }));
|
||||
panel.appendChild(new El('div', { id: 'a', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const seen = [];
|
||||
const made = [];
|
||||
const reg = createRegistry({
|
||||
document: doc, context: { api: 'shared' }, logger: quiet,
|
||||
mountContext(ctx) {
|
||||
made.push([ctx.name, ctx.root.getAttribute('id'), !!ctx.signal, ctx.api]);
|
||||
return { bound: { root: ctx.root, signal: ctx.signal } };
|
||||
},
|
||||
});
|
||||
reg.register('demo', { init(root, ctx) { seen.push(ctx); } });
|
||||
await reg.start();
|
||||
await tick();
|
||||
ok('mountContext sees the mount\'s name, root, signal and the shared services',
|
||||
made.length === 1 && made[0].join() === 'demo,a,true,shared', made);
|
||||
ok('...and its fields reach init()', seen.length === 1 && seen[0].bound && seen[0].bound.root === seen[0].root, seen.length);
|
||||
fire(panel, 'htmx:beforeSwap', { target: panel, shouldSwap: true });
|
||||
panel.replaceChildren(new El('div', { id: 'b', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
fire(panel, 'htmx:afterSwap', { target: panel });
|
||||
await tick();
|
||||
ok('called again for each new mount, with that mount\'s signal',
|
||||
made.length === 2 && seen.length === 2 && !!seen[1].bound && seen[1].bound.signal === seen[1].signal
|
||||
&& seen[0].signal.aborted, made);
|
||||
|
||||
const errors = [];
|
||||
const doc2 = new Doc();
|
||||
doc2.body.appendChild(new El('div', { id: 'c', [PAGE_ATTRIBUTE]: 'demo' }));
|
||||
const reg2 = createRegistry({ document: doc2, logger: { error: (...a) => errors.push(a.join(' ')) },
|
||||
mountContext() { throw new Error('boom'); } });
|
||||
let started = 0;
|
||||
reg2.register('demo', { init() { started++; } });
|
||||
await reg2.start();
|
||||
await tick();
|
||||
ok('a throwing mountContext is logged and the page still starts', started === 1 && errors.length === 1, errors);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
Reference in New Issue
Block a user