Files
LEDMatrix/test/js/dom/test_general_page.js
T
ChuckandClaude Opus 5.5 5a7893b11a 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>
2026-10-04 23:18:02 -04:00

273 lines
15 KiB
JavaScript

// 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); });