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