feat(web): Rotation, Operation History, Config Editor and Backup & Restore become ES-module pages (stage 2) (#727)

Rotation, Operation History, Config Editor and Backup & Restore become ES-module pages (stage 2): no inline scripts or onclick in the four partials, delegated data-action listeners, reads cancelled on swap-out, old globals kept as deprecated aliases through window.LEDMatrix, and four new DOM suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-02 18:02:03 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c14002edc3
commit a7b3f33952
22 changed files with 2019 additions and 819 deletions
+4
View File
@@ -58,6 +58,10 @@ server has none.
| `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_durations_page.js` | yes | The Rotation tab (`js/pages/durations.js`) with the real `plugin-order-list.js` widget: one plugin-list request per swap, one move per click after repeated swaps, a swap cancels the request in flight, a late widget is waited for |
| `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_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 |
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
+305
View File
@@ -0,0 +1,305 @@
// The Backup & Restore tab as a page module
// (static/v3/js/pages/backup-restore.js), in a real DOM (jsdom) with the real
// server-rendered partial and the real API's payload shapes. Built like
// test_cache_page.js:
//
// * the partial ships no <script> and no onclick; its root is
// data-page="backup-restore" and its buttons name an action
// * after five swaps each button makes exactly one request
// * reads in flight are cancelled by a swap; writes (export, delete,
// restore) are not, and their result is still reported
// * file names, host names, plugin ids and messages are shown as text
// * the five old globals' entry points still work
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/backup-restore');
const realPreview = JSON.parse(await get('/api/v3/backup/preview'));
const realList = JSON.parse(await get('/api/v3/backup/list'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const backupPage = await load('pages/backup-restore.js');
console.log('\n── Backup & Restore tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/onclick=/i.test(partial));
ok('the partial root is data-page="backup-restore"', /data-page="backup-restore"/.test(partial));
ok('its buttons name an action', ['export', 'inspect', 'restore', 'cancel', 'refresh']
.every(a => partial.includes(`data-action="${a}"`)));
ok('the real preview answers in the shape the page reads', realPreview.status === 'success'
&& ['has_config', 'has_secrets', 'has_wifi', 'user_fonts', 'plugin_uploads', 'plugins'].every(k => k in realPreview.data),
realPreview);
ok('the real list answers in the shape the page reads', realList.status === 'success' && Array.isArray(realList.data), realList);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const ZIP = 'ledmatrix-backup-host-20260930.zip';
const ODD = 'odd name #1 ' + HOSTILE + '.zip';
const preview = Object.assign({}, realPreview.data, {
has_config: true, has_secrets: false, has_wifi: true, user_fonts: ['a.bdf', HOSTILE], plugin_uploads: 3, plugins: [1, 2],
});
const listing = [
{ filename: ZIP, size: 2048, created_at: '2026-09-30 12:00:00' },
{ filename: ODD, size: 0, created_at: HOSTILE },
];
const manifest = {
created_at: '2026-09-30T12:00:00', hostname: HOSTILE, ledmatrix_version: '3.8.0',
detected_contents: ['config', 'secrets'], plugins: [{ plugin_id: 'clock' }, { plugin_id: HOSTILE }],
};
const errs = [];
let navigations = 0;
const vc = new VirtualConsole();
vc.on('jsdomError', e => {
const msg = String(e.message || e).split('\n')[0];
// jsdom does not navigate; the export's download is that navigation.
if (/Not implemented: navigation/.test(msg)) { navigations++; return; }
errs.push(msg);
});
vc.on('error', (...a) => errs.push(a.join(' ')));
const dom = new JSDOM(`<!doctype html><html><body><div id="backup-restore-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('backup-restore-content');
let mode = 'ok';
let restoreAnswer = null;
let aborted = 0;
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body, signal: init.signal });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(() => resolve(respond(200, { status: 'success', data: [] })));
if (init.signal) init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
if (url === '/api/v3/backup/preview') return respond(200, { status: 'success', data: preview });
if (url === '/api/v3/backup/list') return respond(200, { status: 'success', data: listing });
if (url === '/api/v3/backup/export') return respond(200, { status: 'success', filename: ZIP });
if (url === '/api/v3/backup/validate') return respond(200, { status: 'success', data: manifest });
if (url === '/api/v3/backup/restore') return restoreAnswer();
if (init.method === 'DELETE') return respond(200, { status: 'success' });
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('backup-restore', backupPage);
const count = (url, method = 'GET') => requests.filter(r => r.url === url && r.method === method).length;
const lists = () => count('/api/v3/backup/list');
const $ = id => doc.getElementById(id);
const action = a => doc.querySelector(`button[data-action="${a}"]`);
const hidden = id => $(id).classList.contains('hidden');
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);
}
function pick(file) {
Object.defineProperty($('restore-file-input'), 'files', { value: file ? [file] : [], configurable: true });
$('restore-file-input').dispatchEvent(new window.Event('change', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one preview and one list request on start',
count('/api/v3/backup/preview') === 1 && lists() === 1, requests.map(r => r.url));
ok('the summary is drawn', /Main config: yes/.test($('export-preview').textContent)
&& /Secrets: no/.test($('export-preview').textContent) && /Plugin image uploads: 3 file\(s\)/.test($('export-preview').textContent),
$('export-preview').textContent);
ok('a hostile font name is text', $('export-preview').textContent.includes(HOSTILE));
const rows = () => doc.querySelectorAll('#backup-history tbody tr');
ok('one history row per backup', rows().length === 2, rows().length);
ok('a hostile file name and date are text', rows()[1].textContent.includes(ODD) && rows()[1].textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#backup-restore-content img') && !window.pwned);
ok('download links encode the name', rows()[1].querySelector('a').getAttribute('href')
=== '/api/v3/backup/download/' + encodeURIComponent(ODD), rows()[1].querySelector('a').getAttribute('href'));
ok('delete buttons carry the exact name and no handler',
rows()[1].querySelector('button[data-action="delete"]').dataset.filename === ODD
&& !rows()[1].querySelector('button').getAttribute('onclick'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = action('refresh');
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);
let before = lists();
action('refresh').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; };
rows()[1].querySelector('button[data-action="delete"]').click();
await tick(20);
ok('Delete asks first, naming the file', asked === 'Delete ' + ODD + '?', asked);
ok('cancel sends nothing', !requests.some(r => r.method === 'DELETE'));
window.confirm = () => true;
before = lists();
rows()[1].querySelector('button[data-action="delete"]').click();
await tick(20);
const deletes = requests.filter(r => r.method === 'DELETE');
ok('one DELETE request, to the encoded name', deletes.length === 1
&& deletes[0].url === '/api/v3/backup/' + encodeURIComponent(ODD), deletes.map(d => d.url));
ok('the list reloads once', lists() === before + 1, lists() - before);
ok('the deletion is reported', notes.some(n => n[0] === 'Backup deleted' && n[1] === 'success'), notes);
// ── export ──────────────────────────────────────────────────────────────
before = lists();
action('export').click();
action('export').click(); // a second click while it is busy
ok('the export button is busy while it runs', $('export-backup-btn').disabled
&& $('export-backup-btn').textContent.includes('Creating'));
await tick(30);
ok('one export request', count('/api/v3/backup/export', 'POST') === 1, count('/api/v3/backup/export', 'POST'));
ok('the new backup is reported', notes.some(n => n[0] === 'Backup created: ' + ZIP), notes);
ok('its download starts once', navigations === 1, navigations);
ok('the list reloads once', lists() === before + 1, lists() - before);
ok('the button comes back', !$('export-backup-btn').disabled
&& $('export-backup-btn').textContent.trim() === 'Download backup', $('export-backup-btn').textContent);
// ── inspect ─────────────────────────────────────────────────────────────
notes.length = 0;
action('inspect').click();
await tick(20);
ok('Inspect with no file asks for one', notes[0] && notes[0][0] === 'Choose a backup file first', notes);
ok('...and sends nothing', count('/api/v3/backup/validate', 'POST') === 0);
const file = new window.File(['PK'], 'backup.zip', { type: 'application/zip' });
pick(file);
action('inspect').click();
await tick(20);
const validate = requests.filter(r => r.url === '/api/v3/backup/validate');
ok('one validate request', validate.length === 1, validate.length);
ok('it uploads the file as backup_file', validate[0] && validate[0].body instanceof window.FormData
&& validate[0].body.get('backup_file').name === 'backup.zip');
ok('the contents are shown', !hidden('restore-preview')
&& /Includes: config, secrets/.test($('restore-preview-body').textContent), $('restore-preview-body').textContent);
ok('a hostile host name and plugin id are text', $('restore-preview-body').textContent.includes(HOSTILE)
&& !doc.querySelector('#restore-preview-body img'));
pick(new window.File(['PK2'], 'other.zip'));
ok('picking another file hides the old contents', hidden('restore-preview'));
notes.length = 0;
window.confirm = () => true;
action('restore').click();
await tick(20);
ok('Restore needs an inspected file', notes[0] && notes[0][0] === 'Inspect the file before restoring'
&& count('/api/v3/backup/restore', 'POST') === 0, notes);
// ── restore ─────────────────────────────────────────────────────────────
pick(file);
action('inspect').click();
await tick(20);
$('opt-secrets').checked = false;
restoreAnswer = () => Promise.resolve({
status: 200, ok: true, headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify({ status: 'success', data: {
success: true, restored: ['config', HOSTILE], skipped: ['secrets'], plugins_installed: [], plugins_failed: [], errors: [],
} })),
});
window.confirm = () => false;
action('restore').click();
await tick(20);
ok('Restore asks first', count('/api/v3/backup/restore', 'POST') === 0);
window.confirm = () => true;
notes.length = 0;
action('restore').click();
await tick(30);
const restores = requests.filter(r => r.url === '/api/v3/backup/restore');
ok('one restore request', restores.length === 1, restores.length);
const options = restores[0] && JSON.parse(restores[0].body.get('options'));
ok('it sends exactly the six options, as booleans', options && Object.keys(options).sort().join() ===
'reinstall_plugins,restore_config,restore_fonts,restore_plugin_uploads,restore_secrets,restore_wifi'
&& options.restore_secrets === false && options.restore_config === true, options);
ok('...with the inspected file', restores[0] && restores[0].body.get('backup_file').name === 'backup.zip');
ok('a restore is not cancelled by a swap', restores[0] && restores[0].signal === undefined);
ok('the result is drawn', !hidden('restore-result') && /Restore complete/.test($('restore-result').textContent)
&& /Skipped: secrets/.test($('restore-result').textContent) && /Restart the display service/.test($('restore-result').textContent),
$('restore-result').textContent);
ok('a hostile restored name is text', $('restore-result').textContent.includes(HOSTILE) && !doc.querySelector('#restore-result img'));
ok('...in green, reported once', $('restore-result').classList.contains('bg-green-50')
&& notes.length === 1 && notes[0][0] === 'Restore complete' && notes[0][1] === 'success', notes);
ok('the restore button comes back', !$('run-restore-btn').disabled && $('run-restore-btn').textContent.trim() === 'Restore now');
restoreAnswer = () => Promise.resolve({
status: 500, ok: false, headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify({ status: 'error', message: 'Restore incomplete — failed: secrets',
data: { errors: ['secrets'] } })),
});
notes.length = 0;
action('restore').click();
await tick(30);
ok('a failed restore reports the server\'s message', notes[0] && notes[0][0] === 'Restore failed: Restore incomplete — failed: secrets'
&& notes[0][1] === 'error', notes);
action('cancel').click();
ok('Cancel hides the contents and forgets the file', hidden('restore-preview') && hidden('restore-result')
&& $('restore-file-input').value === '');
notes.length = 0;
action('restore').click();
await tick(20);
ok('...so Restore asks for an inspection again', notes[0] && notes[0][0] === 'Inspect the file before restoring', notes);
// ── reads in flight are cancelled by a swap ─────────────────────────────
mode = 'hang';
action('refresh').click();
await tick(5);
mode = 'ok';
await swap();
ok('the swap cancelled the list request', aborted === 1, aborted);
pending.forEach(resolve => resolve());
await tick(20);
ok('the new page drew its own list', rows().length === 2, rows().length);
// ── the old globals ─────────────────────────────────────────────────────
before = lists();
await backupPage.loadBackupList();
ok('loadBackupList() reloads once', lists() === before + 1, lists() - before);
notes.length = 0;
await backupPage.validateRestoreFile();
ok('validateRestoreFile() inspects', notes[0] && notes[0][0] === 'Choose a backup file first', notes);
const exportsBefore = count('/api/v3/backup/export', 'POST');
await backupPage.exportBackup();
ok('exportBackup() exports once', count('/api/v3/backup/export', 'POST') === exportsBefore + 1);
ok('clearRestore and runRestore are there', typeof backupPage.clearRestore === 'function'
&& typeof backupPage.runRestore === 'function');
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); });
+178
View File
@@ -0,0 +1,178 @@
// The Rotation & Durations tab as a page module
// (static/v3/js/pages/durations.js), in a real DOM (jsdom) with the real
// server-rendered partial, the real plugin-order-list widget and the real
// /api/v3/plugins/installed payload shape. Built like test_cache_page.js:
//
// * the partial ships no <script>; its root is data-page="durations"
// * the rotation list loads once per swap-in, however many swaps came first
// * a list request still in flight when the page is swapped away is
// cancelled and draws nothing
// * a widget that loads late is waited for, and a page swapped away while
// waiting starts nothing
// * plugin names reach the page as text
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/durations');
const real = JSON.parse(await get('/api/v3/plugins/installed'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const durationsPage = await load('pages/durations.js');
const widgetSource = fs.readFileSync(path.join(JS, 'widgets/plugin-order-list.js'), 'utf8');
console.log('\n── Rotation & Durations tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="durations"', /data-page="durations"/.test(partial));
ok('the rotation list and its hidden input are in the partial',
/id="rotation_plugin_order"/.test(partial) && /id="rotation_plugin_order_value"/.test(partial));
ok('the real API answers in the shape the widget reads',
real.status === 'success' && real.data && Array.isArray(real.data.plugins), real);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
const plugins = [
{ id: 'clock', name: 'Clock', enabled: true },
{ id: 'weather', name: HOSTILE, enabled: true },
{ id: 'stocks', name: 'Stocks', enabled: true },
{ id: 'off', name: 'Disabled one', enabled: false },
];
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="durations-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc, runScripts: 'outside-only' });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('durations-content');
let mode = 'ok';
const requests = [];
const pending = [];
let aborted = 0;
function fakeFetch(url, init = {}) {
requests.push({ url, method: init.method || 'GET' });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
json: () => Promise.resolve(body),
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'hang') {
return new Promise((resolve, reject) => {
pending.push(resolve);
if (init.signal) init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, { status: 'success', data: { plugins } });
}
// The widget is a classic script: it calls the window's own fetch.
window.fetch = fakeFetch;
window.eval(widgetSource);
const widget = window.PluginOrderList;
ok('the widget script defines PluginOrderList', !!(widget && widget.init));
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: () => {} },
});
registry.register('durations', durationsPage);
const lists = () => requests.filter(r => r.url === '/api/v3/plugins/installed').length;
const $ = id => doc.getElementById(id);
const order = () => JSON.parse($('rotation_plugin_order_value').value || '[]');
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 plugin-list request on start', lists() === 1, lists());
let rows = doc.querySelectorAll('#rotation_plugin_order .plugin-order-item');
ok('one row per enabled plugin', rows.length === 3, rows.length);
ok('the hidden input holds the order', order().length === 3 && order().includes('clock'), order());
ok('a hostile plugin name is shown as text', $('rotation_plugin_order').textContent.includes(HOSTILE));
ok('...and created no element', !doc.querySelector('#rotation_plugin_order img') && !window.pwned);
// ── repeated swaps ──────────────────────────────────────────────────────
for (let i = 0; i < 5; i++) await swap();
ok('one plugin-list request per swap', lists() === 6, lists());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
rows = doc.querySelectorAll('#rotation_plugin_order .plugin-order-item');
ok('the list is drawn once, not stacked', rows.length === 3, rows.length);
const before = order();
const down = rows[0].querySelector('button[aria-label^="Move"][aria-label$="down"]');
down.click();
await tick(5);
const after = order();
ok('Move down moves one place (one listener)',
after[0] === before[1] && after[1] === before[0] && after[2] === before[2], [before, after]);
ok('reordering makes no request', lists() === 6, lists());
// ── in flight when swapped away ─────────────────────────────────────────
mode = 'hang';
await swap();
ok('a request is in flight', pending.length === 1, pending.length);
mode = 'ok';
await swap();
await tick(20);
ok('the new page drew its own list', doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length === 3);
ok('the cancelled request drew no error', !/Error loading plugins/.test($('rotation_plugin_order').textContent));
ok('the swap cancelled the request (ctx.signal reaches the widget)', aborted === 1, aborted);
// Answer it late anyway, with one plugin: the new page's list is untouched.
pending[0]({ status: 200, ok: true, headers: { get: () => null },
json: () => Promise.resolve({ status: 'success', data: { plugins: [plugins[0]] } }) });
await tick(20);
ok('a late answer to the cancelled request redraws nothing',
doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length === 3,
doc.querySelectorAll('#rotation_plugin_order .plugin-order-item').length);
// ── the widget loads late ───────────────────────────────────────────────
delete window.PluginOrderList;
const beforeLate = lists();
await swap();
ok('no request while the widget is missing', lists() === beforeLate, lists() - beforeLate);
window.PluginOrderList = widget;
await tick(150);
ok('starts once the widget arrives', lists() === beforeLate + 1, lists() - beforeLate);
delete window.PluginOrderList;
await swap();
const beforeGone = lists();
// Swapped to another tab's content while still waiting for the widget.
panel.dispatchEvent(new window.CustomEvent('htmx:beforeSwap', { bubbles: true, detail: { target: panel, shouldSwap: true } }));
panel.innerHTML = '<p>another tab</p>';
panel.dispatchEvent(new window.CustomEvent('htmx:afterSwap', { bubbles: true, detail: { target: panel } }));
window.PluginOrderList = widget;
await tick(250);
ok('a page swapped away while waiting starts nothing', lists() === beforeGone, lists() - beforeGone);
ok('nothing left mounted', registry.list().length === 0, registry.list().length);
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); });
+231
View File
@@ -0,0 +1,231 @@
// The Operation History tab as a page module
// (static/v3/js/pages/operation-history.js), in a real DOM (jsdom) with the
// real server-rendered partial and the real API's payload shape. Built like
// test_cache_page.js:
//
// * the partial ships no <script>; its root is data-page="operation-history"
// * one history request per swap-in, and after five swaps Refresh, Clear,
// Next and the filters each act exactly once
// * the plugin filter is filled once per swap-in, not once per swap so far
// * a request in flight when the page is swapped away is cancelled
// * plugin ids, users and error messages from the log are shown as text
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/operation-history');
const real = JSON.parse(await get('/api/v3/plugins/operation/history?limit=1000'));
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const historyPage = await load('pages/operation-history.js');
console.log('\n── Operation History tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial root is data-page="operation-history"', /data-page="operation-history"/.test(partial));
ok('the real API answers in the shape the page reads',
real.status === 'success' && Array.isArray(real.data), real);
const realKeys = real.data.length ? Object.keys(real.data[0]) : null;
ok('a real record has the fields the page reads',
!realKeys || ['operation_type', 'plugin_id', 'status', 'timestamp'].every(k => realKeys.includes(k)), realKeys);
const HOSTILE = '<img src=x onerror="window.pwned=1">';
function record(i, extra) {
return Object.assign({
operation_id: 'op' + i, operation_type: ['install', 'update', 'enable'][i % 3],
plugin_id: i % 2 ? 'clock' : 'weather', status: i % 4 === 0 ? 'error' : 'completed',
user: 'web', timestamp: 1790000000 * 1000 - i * 60000, details: { version: '1.' + i },
}, extra || {});
}
let records = [
record(0, { plugin_id: HOSTILE, error: HOSTILE + ' went wrong', user: HOSTILE,
details: { commit: 'abcdef1234', previous_commit: '1234567890' } }),
].concat(Array.from({ length: 119 }, (_, i) => record(i + 1)));
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="operation-history-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('operation-history-content');
let mode = 'ok';
let aborted = 0;
const requests = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method });
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/plugins/installed') {
return respond(200, { status: 'success', data: { plugins: [{ id: 'clock' }, { id: 'weather' }, { id: HOSTILE }] } });
}
if (init.method === 'DELETE') return respond(200, { status: 'success', message: 'Operation history cleared' });
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'error') return respond(500, { status: 'error', message: 'Operation history not initialized' });
if (mode === 'login') return respond(401, { status: 'error' }, { 'X-LEDMatrix-Login': '/login' });
if (mode === 'hang') {
return new Promise((resolve, reject) => {
init.signal.addEventListener('abort', () => {
aborted++;
const e = new Error('aborted'); e.name = 'AbortError'; reject(e);
});
});
}
return respond(200, { status: 'success', data: records });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('operation-history', historyPage);
const count = (url, method = 'GET') => requests.filter(r => r.url === url && r.method === method).length;
const HISTORY = '/api/v3/plugins/operation/history?limit=1000';
const histories = () => count(HISTORY);
const $ = id => doc.getElementById(id);
const rows = () => doc.querySelectorAll('#history-table-body tr');
const showing = () => [$('history-start').textContent, $('history-end').textContent, $('history-total').textContent].join('/');
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);
}
function change(id, value) {
$(id).value = value;
$(id).dispatchEvent(new window.Event('change', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('one history request on start', histories() === 1, histories());
ok('one plugin-list request on start', count('/api/v3/plugins/installed') === 1);
ok('the first page holds 50 rows', rows().length === 50, rows().length);
ok('the counters say 1 to 50 of 120', showing() === '1/50/120', showing());
ok('Previous is off on the first page', $('history-prev-btn').disabled && !$('history-next-btn').disabled);
const first = rows()[0];
ok('a hostile plugin id, user and error are shown as text',
first.textContent.includes(HOSTILE) && first.textContent.includes(HOSTILE + ' went wrong'));
ok('...and created no element', !doc.querySelector('#history-table-body img') && !window.pwned);
ok('a hostile plugin id in the filter is text too',
[...$('history-plugin-filter').options].some(o => o.value === HOSTILE && o.textContent === HOSTILE));
ok('details are summarised', first.textContent.includes('commit: abcdef1') && first.textContent.includes('from: 1234567'));
ok('"error" is shown as failed, in red',
first.querySelectorAll('span')[1].textContent === 'failed' && first.querySelector('.bg-red-100'));
// ── repeated swaps ──────────────────────────────────────────────────────
const oldRefresh = $('refresh-history-btn');
for (let i = 0; i < 5; i++) await swap();
ok('one history request per swap', histories() === 6, histories());
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
ok('the plugin filter is filled once', $('history-plugin-filter').options.length === 4,
$('history-plugin-filter').options.length);
let before = histories();
$('refresh-history-btn').click();
await tick(20);
ok('Refresh makes exactly one request (no duplicate listeners)', histories() === before + 1, histories() - before);
oldRefresh.click();
await tick(20);
ok('a swapped-out button does nothing', histories() === before + 1, histories() - before);
$('history-next-btn').click();
ok('Next moves one page', showing() === '51/100/120', showing());
$('history-next-btn').click();
ok('...and the last page is short', showing() === '101/120/120' && rows().length === 20, showing());
ok('Next is off on the last page', $('history-next-btn').disabled);
$('refresh-history-btn').click();
await tick(20);
ok('Refresh keeps the page', showing() === '101/120/120', showing());
$('history-prev-btn').click();
ok('Previous moves one page', showing() === '51/100/120', showing());
// ── filters ─────────────────────────────────────────────────────────────
change('history-status-filter', 'failed');
ok('the status filter matches "error" records as failed', showing() === '1/30/30', showing());
change('history-status-filter', '');
change('history-plugin-filter', 'weather');
ok('the plugin filter', [...rows()].every(r => r.children[2].textContent === 'weather') && showing() === '1/50/59', showing());
change('history-plugin-filter', '');
$('history-search').value = 'no such thing';
$('history-search').dispatchEvent(new window.Event('input', { bubbles: true }));
ok('search waits for the typing to stop', rows().length === 50);
await tick(350);
ok('a search with no match says so', rows().length === 1 && rows()[0].textContent.includes('No operations found'));
ok('...and the counters follow', showing() === '0/0/0', showing());
$('history-search').value = '';
$('history-search').dispatchEvent(new window.Event('input', { bubbles: true }));
await swap(); // swapped away with the search still pending
await tick(350);
ok('a pending search on a swapped-out page does nothing', errs.length === 0, errs);
// ── clear ───────────────────────────────────────────────────────────────
let asked = null;
window.confirm = msg => { asked = msg; return false; };
$('clear-history-btn').click();
await tick(20);
ok('Clear asks first', asked && /clear the operation history/.test(asked), asked);
ok('cancel sends nothing', count('/api/v3/plugins/operation/history', 'DELETE') === 0);
window.confirm = () => true;
$('clear-history-btn').click();
await tick(20);
ok('one DELETE request', count('/api/v3/plugins/operation/history', 'DELETE') === 1,
count('/api/v3/plugins/operation/history', 'DELETE'));
ok('the table empties', rows().length === 1 && showing() === '0/0/0', showing());
// ── states ──────────────────────────────────────────────────────────────
notes.length = 0;
mode = 'error';
$('refresh-history-btn').click(); await tick(20);
ok('an API error is reported', notes.length === 1 && notes[0][0] === 'Failed to load operation history' && notes[0][1] === 'error', notes);
mode = 'network';
$('refresh-history-btn').click(); await tick(20);
ok('a network failure is reported', notes.length === 2 && notes[1][0] === 'Error loading operation history', notes);
mode = 'login';
$('refresh-history-btn').click(); await tick(20);
ok('the login redirect reports nothing', notes.length === 2, notes);
// ── in flight when swapped away ─────────────────────────────────────────
mode = 'hang';
$('refresh-history-btn').click(); await tick(5);
mode = 'ok';
await swap();
ok('the swap cancelled the request', aborted === 1, aborted);
ok('the new page drew its own list', rows().length === 50 && notes.length === 2, [rows().length, notes]);
// ── PluginAPI's cached installed list, when it is loaded ────────────────
let pluginApiCalls = 0;
window.PluginAPI = { getInstalledPlugins: () => { pluginApiCalls++; return Promise.resolve([{ id: 'cached' }]); } };
const directBefore = count('/api/v3/plugins/installed');
await swap();
ok('the plugin filter uses PluginAPI when it is there', pluginApiCalls === 1
&& count('/api/v3/plugins/installed') === directBefore, [pluginApiCalls, count('/api/v3/plugins/installed') - directBefore]);
ok('...and is filled from it', [...$('history-plugin-filter').options].map(o => o.value).join() === ',cached');
delete window.PluginAPI;
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); });
+187
View File
@@ -0,0 +1,187 @@
// The Config Editor tab as a page module (static/v3/js/pages/raw-json.js), in
// a real DOM (jsdom) with the real server-rendered partial and the real
// endpoints' answers. Built like test_cache_page.js:
//
// * the partial ships no <script> and no onclick; its root is
// data-page="raw-json" and its buttons name an action and an editor
// * after five swaps, each Save makes exactly one request and each
// Format / Validate acts once
// * invalid JSON is never sent, and the parser's message is shown as text
// * a save is a write: a swap does not cancel it, and its result is still
// reported
// * the old globals' entry points still work
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/raw-json');
const { createRegistry } = await load('core/registry.js');
const { createApi } = await load('core/api.js');
const rawPage = await load('pages/raw-json.js');
console.log('\n── Config Editor tab: page module (real DOM) ──');
ok('the partial ships no inline script', !/<script/i.test(partial));
ok('the partial has no inline handlers', !/onclick=/i.test(partial));
ok('the partial root is data-page="raw-json"', /data-page="raw-json"/.test(partial));
ok('six buttons name an action and an editor',
(partial.match(/data-action="(format|validate|save)" data-editor="(main|secrets)"/g) || []).length === 6);
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="config-editor-content">${partial}</div></body></html>`,
{ url: BASE + '/', virtualConsole: vc });
const { window } = dom;
const doc = window.document;
const panel = doc.getElementById('config-editor-content');
let mode = 'ok';
const requests = [];
const pending = [];
function fakeFetch(url, init) {
requests.push({ url, method: init.method, body: init.body, signal: init.signal });
const respond = (status, body) => Promise.resolve({
status, ok: status >= 200 && status < 300,
headers: { get: () => null },
text: () => Promise.resolve(JSON.stringify(body)),
});
if (mode === 'network') return Promise.reject(new TypeError('Failed to fetch'));
if (mode === 'error') return respond(400, { status: 'error', message: 'Configuration must be a JSON object' });
if (mode === 'hang') return new Promise(resolve => pending.push(() => resolve(respond(200, { status: 'success' }))));
return respond(200, { status: 'success', message: 'Main configuration saved successfully' });
}
const notes = [];
const registry = createRegistry({
document: doc,
context: { api: createApi({ fetch: fakeFetch }), notify: (m, t) => notes.push([m, t]) },
});
registry.register('raw-json', rawPage);
const $ = id => doc.getElementById(id);
const button = (action, editor) => doc.querySelector(`button[data-action="${action}"][data-editor="${editor}"]`);
const posts = url => requests.filter(r => r.url === url && r.method === 'POST');
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);
}
function type(id, text) {
$(id).value = text;
$(id).dispatchEvent(new window.Event('input', { bubbles: true }));
}
await registry.start();
await tick(20);
// ── first load ──────────────────────────────────────────────────────────
ok('the real files parse', (() => { try { JSON.parse($('main-config-editor').value); JSON.parse($('secrets-config-editor').value); return true; } catch (e) { return false; } })());
ok('both editors are validated on start',
$('main-config-validation').textContent.trim() === 'Valid JSON' && $('secrets-config-validation').textContent.trim() === 'Valid JSON',
[$('main-config-validation').textContent, $('secrets-config-validation').textContent]);
ok('nothing is requested on start', requests.length === 0, requests.length);
// ── repeated swaps ──────────────────────────────────────────────────────
const oldSave = button('save', 'main');
for (let i = 0; i < 5; i++) await swap();
ok('one mounted page after five swaps', registry.list().length === 1, registry.list().length);
type('main-config-editor', '{"display": {"brightness": 50}, "note": "a\\"b"}');
button('save', 'main').click();
await tick(20);
ok('Save makes exactly one request (no duplicate listeners)', posts('/api/v3/config/raw/main').length === 1,
posts('/api/v3/config/raw/main').length);
const sent = posts('/api/v3/config/raw/main')[0];
ok('it posts the parsed file as JSON', sent && JSON.parse(sent.body).note === 'a"b' && JSON.parse(sent.body).display.brightness === 50);
ok('success is reported once', notes.length === 1 && notes[0][0] === 'config.json saved successfully!' && notes[0][1] === 'success', notes);
oldSave.click();
await tick(20);
ok('a swapped-out button does nothing', posts('/api/v3/config/raw/main').length === 1);
button('save', 'secrets').click();
await tick(20);
ok('Save on the secrets editor posts to the secrets endpoint, once', posts('/api/v3/config/raw/secrets').length === 1);
ok('...and names that file', notes[1] && notes[1][0] === 'config_secrets.json saved successfully!', notes);
// ── format and validate ─────────────────────────────────────────────────
notes.length = 0;
type('main-config-editor', '{"a":1,"b":[1,2]}');
button('format', 'main').click();
ok('Format re-indents by four spaces', $('main-config-editor').value === '{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}',
$('main-config-editor').value);
ok('...and says so once', notes.length === 1 && notes[0][0] === 'JSON formatted successfully!', notes);
button('validate', 'main').click();
ok('Validate shows the detailed box', $('main-config-validation').textContent.includes('JSON is valid!'));
ok('...and says so once', notes.length === 2 && notes[1][0] === 'JSON validation successful!', notes);
// ── invalid JSON ────────────────────────────────────────────────────────
const HOSTILE = '{"x": <img src=x onerror="window.pwned=1">';
type('main-config-editor', HOSTILE);
ok('typing re-validates', /^Invalid JSON: /.test($('main-config-validation').textContent.trim()),
$('main-config-validation').textContent);
ok('the parser message is text, not markup', !doc.querySelector('#main-config-validation img') && !window.pwned);
notes.length = 0;
const postsBefore = requests.length;
button('save', 'main').click();
await tick(20);
ok('invalid JSON is not sent', requests.length === postsBefore);
ok('...and the user is told', notes.length === 1 && notes[0][0] === 'Invalid JSON! Please fix errors before saving.', notes);
button('format', 'main').click();
ok('Format refuses invalid JSON', $('main-config-editor').value === HOSTILE && /^Cannot format invalid JSON/.test(notes[1][0]), notes);
button('validate', 'main').click();
ok('Validate shows the error box', $('main-config-validation').textContent.includes('Invalid JSON syntax')
&& !doc.querySelector('#main-config-validation img'));
// ── server answers ──────────────────────────────────────────────────────
type('main-config-editor', '{"a": 1}');
notes.length = 0;
mode = 'error';
button('save', 'main').click(); await tick(20);
ok('a refused save shows the server\'s message', notes[0] && notes[0][0] === 'Error saving config.json: Configuration must be a JSON object', notes);
mode = 'network';
button('save', 'main').click(); await tick(20);
ok('a network failure says so', notes[1] && notes[1][0] === 'Error saving config.json: Failed to fetch', notes);
// ── a save is a write: a swap does not cancel it ────────────────────────
mode = 'hang';
notes.length = 0;
button('save', 'main').click(); await tick(5);
const inFlight = requests[requests.length - 1];
ok('the save carries no page signal', inFlight.signal === undefined);
mode = 'ok';
await swap();
pending.forEach(resolve => resolve());
await tick(20);
ok('its result is still reported after the swap', notes.length === 1 && notes[0][1] === 'success', notes);
// ── the old globals ─────────────────────────────────────────────────────
type('main-config-editor', '[1,2]');
ok('validateJSON(editorId) answers synchronously', rawPage.validateJSON('main-config-editor') === true);
type('main-config-editor', '[1,');
ok('...false for invalid JSON', rawPage.validateJSON('main-config-editor') === false);
type('secrets-config-editor', '{"k":"v"}');
rawPage.formatJson('secrets-config-editor', 'secrets-config-validation');
ok('formatJson(editorId, validationId) formats that editor', $('secrets-config-editor').value === '{\n "k": "v"\n}');
const before = posts('/api/v3/config/raw/secrets').length;
await rawPage.saveSecretsConfig();
ok('saveSecretsConfig() saves once', posts('/api/v3/config/raw/secrets').length === before + 1);
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 -1
View File
@@ -25,7 +25,9 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.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_cache_page.js'];
'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'];
function reachable(url) {
return new Promise(res => {
+4
View File
@@ -51,6 +51,10 @@ async function rejection(promise) {
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);
const upload = { kind: 'form-data' };
await api.request('POST', '/api/v3/backup/validate', { body: upload });
ok('a raw body (an upload) is sent as it is, with no JSON Content-Type',
calls[3][1].body === upload && calls[3][1].headers['Content-Type'] === undefined, calls[3][1].headers);
// 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.
+3 -6
View File
@@ -105,18 +105,15 @@ const ESCAPERS = [
'static/v3/js/widgets/slider.js', 'function escapeAttr(text) {', 'escapeAttr', false],
['display.html (escapeAttr)',
'templates/v3/partials/display.html', 'function escapeAttr(text) {', 'escapeAttr', false],
['backup_restore.html (escapeHtml)',
'templates/v3/partials/backup_restore.html', 'function escapeHtml(value) {', 'escapeHtml', false],
['operation_history.html (escapeHtml)',
'templates/v3/partials/operation_history.html', 'function escapeHtml(text) {', 'escapeHtml', 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 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.
// 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.
];
// The breakout payload: closes a double-quoted attribute and opens an event