feat(web): Schedule and General become ES-module pages (stage 3) (#767)

* feat(web): Schedule and General become ES-module pages (stage 3)

Schedule and General follow stage 2 (#727): no inline scripts or inline
handlers in either partial. Their code moves to static/v3/js/pages/schedule.js
and pages/general.js, started per swap-in by the page registry.

- Schedule: both pickers are drawn from the saved config carried as JSON in
  data-* attributes. The forms' hx-on save handlers become one
  htmx:afterRequest listener on the page; the forms are marked
  data-reports-result, which app.js now treats like an hx-on after-request
  handler, so a save still shows one notification.
- General: the timezone picker reads data-timezone. The Security section's
  forms and buttons are delegated data-actions; requests go through
  core/api.js, so the login redirect is quiet, and a change made just
  before a swap is still reported.
- handleScheduleResponse, handleDimScheduleResponse and webLogin stay as
  deprecated aliases through window.LEDMatrix.
- New DOM suites test_schedule_page.js and test_general_page.js; the web
  login unit suite imports the module; test_es_modules.py pins the pages,
  the aliases, and the schedule config's round trip through its attribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(web): no unused catch bindings or computed writes in the stage 3 modules

Codacy flagged two unused catch variables and dynamic-key writes in
pages/schedule.js and boot.js. The schedule config is read with
getAttribute, and the default days and the webLogin alias object are built
with Object.fromEntries. No behaviour change.

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:
Chuck
2026-10-04 23:18:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3866aa4519
commit 5a7893b11a
15 changed files with 1069 additions and 421 deletions
+272
View File
@@ -0,0 +1,272 @@
// The General tab as a page module (static/v3/js/pages/general.js), in a real
// DOM (jsdom) with the real server-rendered partial, the real timezone
// widget and the real web-login endpoints' answer shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="general" and the Security section's forms and buttons name
// an action
// * the timezone picker is drawn once per swap-in, with the saved zone
// * after five swaps, each Security action makes exactly one request
// * a login change is a write: a swap does not cancel it, its result is
// still reported, and nothing is drawn into the page that has gone
// * token names reach the page as text
// * the settings form itself is left to htmx
// * window.webLogin's entry points still work
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/general');
const realTokens = JSON.parse(await get('/api/v3/auth/tokens'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const generalPage = await load('pages/general.js');
console.log('\n── General tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline click or submit handlers', !/\son(click|submit|input)=/i.test(partial));
ok('the partial root is data-page="general"', /data-page="general"/.test(partial));
const security = /id="web-login-settings"/.test(partial);
ok('the server renders the Security section (it has a login store)', security);
ok('the real token list answers in the shape the section shows',
realTokens.status === 'success' && realTokens.data && Array.isArray(realTokens.data.tokens), realTokens);
ok('the Security forms name their action',
/<form[^>]*data-action="set-password"/.test(partial) && /<form[^>]*data-action="create-token"/.test(partial));
ok('the Copy button names its action', /data-action="copy-token"/.test(partial));
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="general-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('general-content');
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/timezone-selector.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register timezone-selector', !!(widgets && widgets.get('timezone-selector')));
let confirmAnswer = true;
const confirms = [];
window.confirm = m => { confirms.push(m); return confirmAnswer; };
const reloads = [];
window.htmx = { ajax: (method, url, opts) => reloads.push([method, url, opts.target]) };
const HOSTILE = '<img src=x onerror="window.pwned=1">';
let mode = 'ok';
let nextId = 1;
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
const respond = (status, body, headers) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: h => (headers || {})[h] || null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (mode === 'refuse') return respond(400, { status: 'error', message: 'Give the token a name.' });
if (url === '/api/v3/auth/tokens' && init.method === 'POST') {
const id = 'tok' + (nextId++);
const name = JSON.parse(init.body).name;
const answer = () => respond(201, {
status: 'success', message: 'Token created. Copy it now: it is not shown again.',
data: { token: 'lmx_' + id, record: { id, name, prefix: 'lmx_' + id.slice(0, 3), created_at: '2026-10-04T00:00:00' } },
});
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(answer())));
return answer();
}
if (url.startsWith('/api/v3/auth/tokens/') && init.method === 'DELETE') {
return respond(200, { status: 'success', message: 'Token revoked.', data: { tokens: [] } });
}
if (url === '/api/v3/auth/password') {
return respond(200, { status: 'success', message: 'Login is on. Other browsers now need the password.' });
}
return respond(404, { status: 'error', message: 'unexpected ' + url });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('general', generalPage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="general"]');
const timezoneWidgets = () => $('timezone_container').querySelectorAll('.timezone-selector-widget').length;
const rows = () => doc.querySelectorAll('#web-login-tokens [data-token-id]');
const calls = (method, prefix) => requests.filter(r => r.method === method && r.url.startsWith(prefix));
const lastNote = () => notes[notes.length - 1] || [];
function submit(form) {
const event = new window.Event('submit', { bubbles: true, cancelable: true });
form.dispatchEvent(event);
return event;
}
const form = action => root().querySelector(`form[data-action="${action}"]`);
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);
}
await registry.start();
await tick(20);
// ── the timezone picker ─────────────────────────────────────────────────
const savedZone = $('timezone_container').dataset.timezone;
ok('the partial carries the saved timezone', !!savedZone, savedZone);
ok('the timezone picker is drawn once', timezoneWidgets() === 1, timezoneWidgets());
ok('...holding the saved zone', $('timezone_data') && $('timezone_data').value === savedZone,
$('timezone_data') && $('timezone_data').value);
ok('...posted as "timezone"', $('timezone_data') && $('timezone_data').name === 'timezone');
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('the timezone picker is drawn once, not stacked', timezoneWidgets() === 1, timezoneWidgets());
// ── the settings form is htmx's ─────────────────────────────────────────
const settings = root().querySelector('form[hx-post="/api/v3/config/main"]');
ok('submitting the settings form is not prevented', settings && !submit(settings).defaultPrevented);
ok('...and makes no request of the page\'s own', requests.length === 0, requests.length);
if (security) {
// ── create a token ────────────────────────────────────────────────────
const before = rows().length;
const create = form('create-token');
create.querySelector('[name="name"]').value = HOSTILE;
create.setAttribute('data-dirty', '');
ok('Create token is handled by the page', submit(create).defaultPrevented);
await tick(20);
ok('one POST to /api/v3/auth/tokens', calls('POST', '/api/v3/auth/tokens').length === 1, requests);
ok('...with the name typed', calls('POST', '/api/v3/auth/tokens')[0].body.name === HOSTILE);
ok('a row is added', rows().length === before + 1, rows().length);
ok('the hostile token name is shown as text', root().querySelector('#web-login-tokens').textContent.includes(HOSTILE));
ok('...and created no element', !root().querySelector('#web-login-tokens img') && !window.pwned);
ok('the "No tokens yet" line is gone', !root().querySelector('#web-login-tokens [data-empty]'));
ok('the token is shown once', $('web-login-new-token-value').textContent === 'lmx_tok1'
&& !$('web-login-new-token').classList.contains('hidden'));
ok('the form is clean again (no "Leave site?")', !create.hasAttribute('data-dirty'));
ok('one success notification', lastNote()[1] === 'success' && /Token created/.test(lastNote()[0]), notes);
// ── copy it (plain http: not a secure context, so it is selected) ────
root().querySelector('button[data-action="copy-token"]').click();
ok('Copy selects the token where the clipboard API is unavailable',
window.getSelection().toString() === 'lmx_tok1' && /Selected/.test(lastNote()[0]), lastNote());
// ── revoke it (the row drawn by the page, so delegation covers it) ───
confirmAnswer = false;
const added = rows()[rows().length - 1];
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('a cancelled Revoke sends nothing', calls('DELETE', '/api/v3/auth/tokens/').length === 0);
ok('...after asking with the token name as written', confirms.length === 1 && confirms[0].includes(HOSTILE), confirms);
confirmAnswer = true;
added.querySelector('button[data-action="revoke-token"]').click();
await tick(20);
ok('Revoke sends one DELETE for that token',
calls('DELETE', '/api/v3/auth/tokens/').length === 1 && calls('DELETE', '/api/v3/auth/tokens/')[0].url === '/api/v3/auth/tokens/tok1',
calls('DELETE', '/api/v3/auth/tokens/'));
ok('...and removes its row', rows().length === before, rows().length);
// ── the password ──────────────────────────────────────────────────────
const pw = form('set-password');
pw.querySelector('[name="new_password"]').value = 'correct horse battery';
pw.querySelector('[name="confirm_password"]').value = 'correct horse batterY';
submit(pw);
await tick(20);
ok('mismatched passwords are never sent', calls('POST', '/api/v3/auth/password').length === 0);
ok('...and say so', lastNote()[1] === 'error' && /do not match/.test(lastNote()[0]), lastNote());
pw.querySelector('[name="confirm_password"]').value = 'correct horse battery';
submit(pw);
await tick(20);
const sent = calls('POST', '/api/v3/auth/password');
ok('a matching password is sent once', sent.length === 1, sent.length);
ok('...with the current password only when the form has one',
sent[0] && sent[0].body.new_password === 'correct horse battery'
&& (('current_password' in sent[0].body) === !!pw.querySelector('[name="current_password"]')), sent[0]);
ok('...and the section is reloaded once', reloads.length === 1 && reloads[0][1] === '/v3/partials/general'
&& reloads[0][2] === '#general-content', reloads);
// ── refused, network failure, login redirect ──────────────────────────
mode = 'refuse';
submit(form('create-token'));
await tick(20);
ok('a refused request shows the server message', lastNote()[1] === 'error' && lastNote()[0] === 'Give the token a name.', lastNote());
mode = 'network';
submit(form('create-token'));
await tick(20);
ok('a network failure says the request failed', lastNote()[1] === 'error' && /^Request failed: /.test(lastNote()[0]), lastNote());
mode = 'login';
const quiet = notes.length;
submit(form('create-token'));
await tick(20);
ok('the login redirect shows nothing (the page is leaving)', notes.length === quiet, notes.slice(quiet));
// ── a write survives a swap ───────────────────────────────────────────
mode = 'hang';
await swap();
const rowsBefore = rows().length;
form('create-token').querySelector('[name="name"]').value = 'Late';
submit(form('create-token'));
await tick(5);
await swap();
pending.shift()();
await tick(20);
ok('a token created before a swap is still reported', lastNote()[1] === 'success', lastNote());
ok('...and draws nothing into the new page', rows().length === rowsBefore
&& $('web-login-new-token').classList.contains('hidden'), rows().length);
mode = 'ok';
// ── window.webLogin ────────────────────────────────────────────────────
const viaAlias = calls('POST', '/api/v3/auth/tokens').length;
form('create-token').querySelector('[name="name"]').value = 'Alias';
await generalPage.webLogin.createToken(form('create-token'));
ok('webLogin.createToken(form) creates one token', calls('POST', '/api/v3/auth/tokens').length === viaAlias + 1);
ok('webLogin has the five old methods',
['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'].every(m => typeof generalPage.webLogin[m] === 'function'));
}
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', timezoneWidgets() === 0, timezoneWidgets());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', timezoneWidgets() === 1, timezoneWidgets());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing', kept.querySelectorAll('.timezone-selector-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
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); });
+167
View File
@@ -0,0 +1,167 @@
// The Schedule tab as a page module (static/v3/js/pages/schedule.js), in a
// real DOM (jsdom) with the real server-rendered partial and the real
// widget registry and schedule-picker widget. Built like test_cache_page.js:
//
// * the partial ships no <script> and no inline handlers; its root is
// data-page="schedule" and carries both saved schedules as JSON
// * both pickers are drawn once per swap-in, from the saved config, however
// many swaps came first
// * each form's save is reported in exactly one notification (the forms
// are marked data-reports-result so app.js stays quiet)
// * the dim brightness label follows the slider
// * a widget that loads late is waited for, and a page swapped away while
// waiting draws nothing
// * the old globals' entry points still work
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/schedule');
const { createRegistry } = await load('core/registry.js');
const schedulePage = await load('pages/schedule.js');
console.log('\n── Schedule 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|input|change|submit)=/i.test(partial));
ok('the forms carry no hx-on handler', !/hx-on/i.test(partial));
ok('the partial root is data-page="schedule"', /data-page="schedule"/.test(partial));
ok('both forms are marked data-reports-result',
(partial.match(/<form[^>]*data-reports-result/g) || []).length === 2);
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="schedule-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('schedule-content');
// base.html defines LEDEscape (app-early.js) before any tab loads; the
// widget escapes with it.
require('../led_escape').install(window);
window.eval(fs.readFileSync(path.join(JS, 'widgets/registry.js'), 'utf8'));
window.eval(fs.readFileSync(path.join(JS, 'widgets/schedule-picker.js'), 'utf8'));
const widgets = window.LEDMatrixWidgets;
ok('the widget scripts register schedule-picker', !!(widgets && widgets.get('schedule-picker')));
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: null, notify: (m, t) => notes.push([m, t]) },
});
registry.register('schedule', schedulePage);
const $ = id => doc.getElementById(id);
const root = () => doc.querySelector('[data-page="schedule"]');
const saved = key => JSON.parse(root().dataset[key]);
const drawn = () => ['schedule_picker_container', 'dim_schedule_picker_container']
.map(id => $(id).querySelectorAll('.schedule-picker-widget').length);
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 answer(formId, xhr) {
$(formId).dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr, elt: $(formId), successful: xhr.status < 300 } }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('both pickers drawn once', drawn().join() === '1,1', drawn());
const schedule = saved('scheduleConfig');
const dim = saved('dimScheduleConfig');
ok('the saved config reaches the page as JSON', schedule && typeof schedule === 'object' && dim && typeof dim === 'object');
const mode = cfg => cfg.mode ? cfg.mode.replace('-', '_') : (cfg.days ? 'per_day' : 'global');
ok('the display picker shows the saved mode', $('schedule_mode_value').value === mode(schedule),
[$('schedule_mode_value').value, schedule.mode]);
ok('the dim picker shows the saved mode', $('dim_schedule_mode_value').value === mode(dim),
[$('dim_schedule_mode_value').value, dim.mode]);
ok('the dim picker shows the saved start time',
$('dim_schedule_start_time_hidden').value === (dim.start_time || '20:00'), $('dim_schedule_start_time_hidden').value);
// ── repeated swaps ──────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('each picker drawn once, not stacked', drawn().join() === '1,1', drawn());
const okXhr = body => ({ status: 200, responseText: JSON.stringify(body) });
answer('schedule_form', okXhr({ status: 'success', message: 'Schedule configuration saved successfully' }));
ok('a schedule save is reported once', notes.length === 1, notes);
ok('...with the server message and status',
notes[0] && notes[0][0] === 'Schedule configuration saved successfully' && notes[0][1] === 'success', notes[0]);
answer('dim_schedule_form', okXhr({ status: 'success' }));
ok('a dim schedule save without a message says so',
notes.length === 2 && notes[1][0] === 'Dim schedule settings saved' && notes[1][1] === 'success', notes[1]);
answer('schedule_form', { status: 400, responseText: JSON.stringify({ status: 'error' }) });
ok('a refused save without a message says so',
notes.length === 3 && notes[2][0] === 'Error saving schedule' && notes[2][1] === 'error', notes[2]);
answer('dim_schedule_form', { status: 502, responseText: '<html>Bad gateway</html>' });
ok('a non-JSON answer is an error',
notes.length === 4 && notes[3][0] === 'Invalid response from server' && notes[3][1] === 'error', notes[3]);
answer('schedule_form', { status: 200, responseText: 'null' });
ok('a JSON null answer is an error, not a crash',
notes.length === 5 && notes[4][1] === 'error', notes[4]);
// An htmx request from elsewhere on the page (outside both forms) is not a save.
root().querySelector('.settings-filter').dispatchEvent(new window.CustomEvent('htmx:afterRequest', {
bubbles: true, detail: { xhr: okXhr({ status: 'success', message: 'x' }) } }));
ok('a request from outside the two forms reports nothing', notes.length === 5, notes.length);
// ── the brightness label ────────────────────────────────────────────────
$('dim_brightness').value = '42';
$('dim_brightness').dispatchEvent(new window.Event('input', { bubbles: true }));
ok('the dim brightness label follows the slider', $('dim_brightness_display').textContent === '42%',
$('dim_brightness_display').textContent);
// ── the widget loads late ───────────────────────────────────────────────
delete window.LEDMatrixWidgets;
await swap();
ok('nothing drawn while the widget is missing', drawn().join() === '0,0', drawn());
window.LEDMatrixWidgets = widgets;
await tick(150);
ok('drawn once the widget arrives', drawn().join() === '1,1', drawn());
delete window.LEDMatrixWidgets;
await swap();
const kept = root();
await swap('<p>another tab</p>');
window.LEDMatrixWidgets = widgets;
await tick(250);
ok('a page swapped away while waiting draws nothing',
kept.querySelectorAll('.schedule-picker-widget').length === 0);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
// ── the old globals ─────────────────────────────────────────────────────
await swap();
const before = notes.length;
schedulePage.handleScheduleResponse({ target: $('schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
schedulePage.handleDimScheduleResponse({ target: $('dim_schedule_form'), detail: { xhr: okXhr({ status: 'success' }) } });
ok('handleScheduleResponse(event) and handleDimScheduleResponse(event) report once each',
notes.length === before + 2 && notes[before][0] === 'Schedule settings saved'
&& notes[before + 1][0] === 'Dim schedule settings saved', notes.slice(before));
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); });