Files
ESP32_Serial_Swiss_Army_Knife/tests/web_ui_session/display.cjs
T
Commander1024 d9ec3c08de Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked
Apply, Save, Load, Defaults, and Reset operations across the web UI,
CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling,
session-isolated operation results, browser lifecycle support, and
comprehensive host tests and documentation.
2026-09-09 10:15:23 +02:00

121 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const path = '/api/settings/display', op = path + '-operation';
const fixture = (extra = {}) => ({generation: 7, dim_seconds: 300, off_seconds: 600, ...extra});
const reply = (action = 'apply', state = 'pending', id = 42, status = 200) => new Response(JSON.stringify({id, action, state}), {status});
const ack = action => reply(action, 'pending', 42, 202);
const n = (b, id) => b.nodes['display-' + id];
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
const reads = b => b.calls.filter(c => c.url === path);
async function open(value = fixture()) {
const b = await adminBrowser(); b.click('select-settings'); await tick();
b.queues[path].push(json(value)); b.click('settings-display'); await tick(); return b;
}
async function complete(b, action, state = 'ok') {
b.queues[op].push(reply(action, state)); b.queues[path].push(json(fixture({generation: 8})));
b.fire(1000); await tick();
}
await test('Display admin-only entry, actual label/value controls, absent-panel policy and navigation preserves terminals/lease', async () => {
for (const id of ['settings-display','display-values','display-edit-dim_seconds','display-edit-off_seconds','display-refresh','display-result','display-apply','display-save','display-load','display-defaults','display-reset']) assert.ok(html.includes('id="' + id + '"'));
assert.match(html, /absent panel/); assert.match(html, /086400/);
const u = browser(); u.start(); await tick(); u.click('settings-display'); await tick(); assert.equal(reads(u).length, 0);
const b = await open(); assert.equal(n(b,'dim_seconds').textContent, '300'); assert.equal(n(b,'edit-off_seconds').value, '600');
assert.equal(n(b,'settings').hidden, false); assert.equal(b.nodes['network-settings'].hidden, true);
const count = b.calls.length; b.click('settings-display'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
for (let i = 0; i < 2; ++i) {
b.sockets[i].emit('message', {data: Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1), [0,255,i]);
b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length, 0);
}
b.click('settings-serial'); await tick(); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.sockets.length, 2);
});
await test('Display strict snapshots reject extra/missing/type/range/order/status/oversized data and disable stale edits', async () => {
for (const value of [null, {}, fixture({generation:0}), fixture({generation:4294967296}), fixture({dim_seconds:'0'}), fixture({off_seconds:86401}), fixture({dim_seconds:600}), fixture({extra:1})]) {
const b = await open(value); assert.ok(n(b,'apply').disabled); assert.match(n(b,'detail').textContent, /unavailable|invalid/);
}
const b = await open(); b.queues[path].push(new Response('x'.repeat(129))); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
b.queues[path].push(new Response(JSON.stringify(fixture()), {status:202})); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
});
await test('Display typed Apply validates integer/zero/timeout order; bounded POST carries selected generation and CSRF', async () => {
const b = await open();
for (const [dim,off] of [['-1','600'],['1.5','600'],['1e2','600'],['01','600'],['86401','0'],['600','600'],['601','600'],['','600']]) {
n(b,'edit-dim_seconds').value = dim; n(b,'edit-off_seconds').value = off; b.click('display-apply'); await tick(); assert.equal(posts(b).length,0);
}
n(b,'edit-dim_seconds').value = '0'; n(b,'edit-off_seconds').value = '86400'; b.queues[op].push(ack('apply')); b.click('display-apply'); await tick();
const post = posts(b)[0]; assert.deepEqual(JSON.parse(post.body), {action:'apply',generation:7,dim_seconds:0,off_seconds:86400});
assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.mode,'cors'); assert.ok(post.body.length <= 256);
assert.ok(n(b,'apply').disabled); assert.equal(n(b,'values').hidden,false);
b.click('display-apply'); await tick(); assert.equal(posts(b).length,1);
await complete(b,'apply'); assert.equal(reads(b).length,2); assert.equal(n(b,'apply').disabled,false); assert.match(n(b,'operation-detail').textContent,/completed/);
});
await test('Display explicit Save/Load/Defaults/Reset use working generation not drafts; only Reset confirms', async () => {
for (const action of ['save','load','defaults','reset']) {
const b = await open(); n(b,'edit-dim_seconds').value='123'; let confirmations=0;
b.window.confirm=()=>{++confirmations; return false;};
if (action==='reset') {b.click('display-reset'); await tick(); assert.equal(posts(b).length,0); assert.equal(confirmations,1);}
b.window.confirm=()=>{++confirmations; return true;}; b.queues[op].push(ack(action)); b.click('display-'+action); await tick();
assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7}); assert.equal(confirmations,action==='reset'?2:0);
await complete(b,action,action==='load'?'loaded_defaults':'ok'); assert.equal(reads(b).length,2);
}
});
await test('Display terminal failure/conflict/cancellation refreshes once without replay or success claims', async () => {
for (const state of ['failed','conflict','cancelled']) {
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick(); await complete(b,'save',state);
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.doesNotMatch(n(b,'operation-detail').textContent,/Operation completed/);
}
});
await test('Display ten-poll and fifteen-second deadline bounds include delayed session work', async () => {
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick();
for(let i=0;i<10;i++){b.queues[op].push(reply('save')); b.fire(1000); await tick();}
assert.equal(gets(b).length,10); assert.equal(posts(b).length,1); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick();
const d=deferred(); c.queues['/api/session'].push(d.promise); c.fire(1000); await tick(); c.elapse(15000); c.fire(15000); await tick();
d.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(gets(c).length,0); assert.equal(posts(c).length,1);
});
await test('Display lost ACK/result replacement/same-ID action mismatch preserve uncertainty and stop automatic following', async () => {
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('display-save'); await tick();
assert.equal(posts(b).length,1); assert.match(n(b,'operation-detail').textContent,/unknown/);
b.queues[op].push(reply('save','ok')); b.queues[path].push(json(fixture())); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
for(const changed of [reply('save','pending',43), reply('reset','ok',42)]) {
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick(); c.queues[op].push(changed); c.fire(1000); await tick();
assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000)); assert.match(n(c,'operation-detail').textContent,/unknown/); assert.equal(posts(c).length,1);
}
});
await test('Display operation rejects malformed status/schema/ID/action/state and impossible loaded-defaults replies', async () => {
for(const r of [reply('save','pending',42,200),reply('save','ok',42,202),reply('reset','pending',42,202),new Response(JSON.stringify({id:42,action:'save',state:'pending',extra:1}),{status:202})]) {
const b=await open(); b.queues[op].push(r); b.click('display-save'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/); assert.equal(gets(b).length,0);
}
const b=await open(); b.queues[op].push(reply('save','loaded_defaults')); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/);
});
await test('Display navigation fences pending read/POST/result and never resumes/replays on return', async () => {
for(const stage of ['snapshot','post','result']) {
const b=await open(); const d=deferred();
if(stage==='snapshot'){b.queues[path].push(d.promise); b.click('display-refresh');}
else if(stage==='post'){b.queues[op].push(d.promise); b.click('display-save');}
else {b.queues[op].push(ack('save')); b.click('display-save'); await tick(); b.queues[op].push(d.promise); b.fire(1000);}
await tick(); b.click('settings-serial'); await tick();
const count=posts(b).length; d.resolve(stage==='snapshot'?json(fixture({generation:99})):stage==='post'?ack('save'):reply('save','ok')); await tick();
assert.equal(n(b,'edit-dim_seconds').value,''); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
b.queues[path].push(json(fixture())); b.click('settings-display'); await tick(); assert.equal(posts(b).length,count); assert.ok(b.sockets.every(s=>!s.closed));
}
});
await test('Display endpoint401 and identity replacement close both routes; stale401 after navigation cannot expire current view', async () => {
for(const route of [path,op]) {
const b=await open(); b.queues[route].push(failure(401)); b.click(route===path?'display-refresh':'display-result'); await tick();
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,'');
}
const b=await open(); const d=deferred(); b.queues[path].push(d.promise); b.click('display-refresh'); await tick(); b.click('settings-serial'); await tick(); d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0);
const c=await open(); c.queues['/api/session'].push(session({role:'admin',username:'replacement'})); c.click('display-save'); await tick(); assert.deepEqual(c.redirects,['/']); assert.equal(posts(c).length,0);
});
await test('Display pagehide/expiry/logout fence drafts and in-flight work without backend cancellation claims', async () => {
for(const event of ['pagehide','expiry','logout']) {
const b=await open(); const d=deferred(); b.queues[op].push(d.promise); b.click('display-save'); await tick();
if(event==='pagehide') b.emit('pagehide'); else if(event==='expiry') b.window.sakSessionExpired(); else {b.queues['/api/logout'].push(new Response(null,{status:204})); b.click('sign-out');}
await tick(); d.resolve(ack('save')); await tick(); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,''); assert.equal(posts(b).length,1);
assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
}
});
};