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.
This commit is contained in:
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -1251,5 +1251,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
});
|
||||
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'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, /0–86400/);
|
||||
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));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -45,10 +45,10 @@ def check_layout(html):
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings'):
|
||||
nodes = list(descendants(ids[ident]))
|
||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
||||
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||
@@ -59,9 +59,9 @@ def check_layout(html):
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result'):
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result'):
|
||||
assert ids[ident]['text'] == 'Check Operation Result'
|
||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||
@@ -90,7 +90,7 @@ def check_layout(html):
|
||||
):
|
||||
assert rule in css, rule
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all three settings views')
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
@@ -103,13 +103,13 @@ def check_browser_layout(html, tmp, executable):
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings']) {
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
d.getElementById('serial-settings').hidden = false;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
|
||||
const section = d.getElementById(view);
|
||||
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
|
||||
section.querySelectorAll('dl').forEach(dl => {
|
||||
@@ -166,6 +166,6 @@ def check_browser_layout(html, tmp, executable):
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 9
|
||||
assert len(results) == 12
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
Reference in New Issue
Block a user