Add typed serial settings operations
Route bounded admin mutations through the existing administration dispatcher, covering apply, lifecycle, persistence, authorization, and result tracking. Add the browser controls, automatic result refresh, regression coverage, and phase documentation.
This commit is contained in:
@@ -48,11 +48,60 @@ Coverage:
|
||||
- Read-only admin Settings/Serial: 10 selection cycles preserve both sockets/IDs,
|
||||
drain hidden output and block terminal input; exact eight-field/256-byte schema,
|
||||
explicit refresh, error/timeout containment, late cancellation/restore/identity,
|
||||
and concurrent serial reconnect without superseding admission. **21 Node groups total.**
|
||||
and concurrent serial reconnect without superseding admission.
|
||||
- 8D.9 typed Serial actions: draft validation, Reset-only confirmation, bounded
|
||||
JSON/CSRF, explicit working/persisted effects, automatic bounded result checks,
|
||||
failure/timeout containment, session fencing and preserved sockets/writer identity.
|
||||
- Lost-acknowledgement and replaced-result uncertainty survives repeated result
|
||||
checks, failed reads, refresh and navigation; a newly acknowledged explicit
|
||||
submission starts a new result context.
|
||||
- Immediate completion on the first GET, pending then completion, 10-attempt
|
||||
exhaustion and manual recovery, 15-second overall abort during fetch/body reads,
|
||||
delayed timers/replies, automatic read errors, visible stale snapshots during
|
||||
refresh and after refresh failure for every terminal outcome, late refresh
|
||||
cancellation, no routine confirmations and Reset cancellation. Navigation,
|
||||
pagehide/restore, logout, expiry and changed identity cancel checks without
|
||||
automatic resumption.
|
||||
- Repeated current Settings selection is a no-op during submission, between and
|
||||
during result checks, and during completion refresh: requests, timers, visible
|
||||
values/control state, final outcome and socket/writer identity remain intact.
|
||||
**35 Node groups total.**
|
||||
|
||||
## Automatic result-check budget
|
||||
|
||||
After a valid POST acknowledgement, the UI waits **1,000 ms** before the first
|
||||
result GET and between completed pending-result checks. It makes **at most 10
|
||||
GET attempts** and uses an independent **15,000 ms overall deadline**, measured
|
||||
with the monotonic browser clock from acknowledgement. Each attempt first
|
||||
revalidates the session; that time is included in the deadline. There is only
|
||||
one automatic check in flight. Delayed timer callbacks and replies also check
|
||||
this deadline. Expiry actively aborts the in-flight request and releases the UI
|
||||
for manual recovery; late completions cannot update the view.
|
||||
|
||||
The first limit reached stops automatic checking. A read error also stops it.
|
||||
The budget does not cancel backend work and is not a server execution deadline.
|
||||
**POST is never automatically retried.** Lost acknowledgement requires explicit
|
||||
Check Result recovery; manual checks do not restart automatic polling. Exhausted
|
||||
or cancelled polling never resumes on navigation or bfcache restoration.
|
||||
|
||||
Every known terminal result, including failure/cancellation, triggers one working
|
||||
snapshot refresh while retaining the operation outcome and any uncertainty
|
||||
warning. Snapshot refresh is outside the auto-check budget and retains the
|
||||
existing 15-second per-request bound (session validation and snapshot GET are
|
||||
separate requests). Settings remain visible but conflicting controls are disabled
|
||||
during work; old snapshots are explicitly stale during pending/uncertain work or
|
||||
a failed refresh. A successful refresh replaces the browser draft. Only Reset
|
||||
asks for confirmation, specifically because it overwrites saved configuration.
|
||||
|
||||
Tests use a deterministic clock and individually fired timer callbacks, including
|
||||
callbacks invoked after cancellation and fetch/body doubles that ignore abort.
|
||||
These deliberately exercise fences beyond normal browser cancellation behavior.
|
||||
|
||||
## Integration and known gaps
|
||||
|
||||
This covers 8D.3 session behavior, the 8D.6 selector and 8D.8 Settings. The renderer
|
||||
This covers 8D.3 session behavior, the 8D.6 selector, 8D.8 Settings and the 8D.9
|
||||
Serial UI. Operation responses are fetch doubles, not end-to-end execution of
|
||||
`web_serial_settings.c`, dispatcher work, serial reconfiguration or NVS persistence. The renderer
|
||||
still relies on its caller to authenticate resources; protected asset failures
|
||||
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
|
||||
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
|
||||
|
||||
@@ -13,9 +13,10 @@ 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'} = {}) {
|
||||
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': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0;
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
|
||||
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
|
||||
@@ -32,7 +33,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
|
||||
}
|
||||
const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
setTimeout: timeout, clearTimeout: id => timers.delete(id),
|
||||
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
@@ -46,7 +47,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
||||
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
||||
}},
|
||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket,
|
||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
|
||||
fetch: async (url, options) => {
|
||||
// Apply the Origin regression guard to every mutation, including logout.
|
||||
assert.ok(Object.hasOwn(queues, url));
|
||||
@@ -69,7 +70,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||
};
|
||||
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
click: id => nodes[id].click(), window};
|
||||
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
let passed = 0;
|
||||
@@ -458,5 +459,342 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/);
|
||||
c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false);
|
||||
});
|
||||
await test('Serial typed actions: draft, confirmation, CSRF, explicit results and working/persisted semantics', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick(); b.sockets[0].emit('open');
|
||||
b.click('select-settings'); await tick();
|
||||
const path = '/api/settings/serial-operation';
|
||||
assert.equal(b.nodes['edit-baud'].value, '230400');
|
||||
const baseline = b.calls.length;
|
||||
b.nodes['edit-baud'].value = '460800'; assert.equal(b.calls.length, baseline);
|
||||
b.window.confirm = () => false; b.click('serial-reset'); await tick(); assert.equal(b.calls.length, baseline);
|
||||
b.window.confirm = () => true;
|
||||
for (const [i, action] of ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'].entries()) {
|
||||
if (i) { b.click('refresh-settings'); await tick(); }
|
||||
b.queues[path].push(json({id: i + 1, action, state: 'pending'}));
|
||||
b.click('serial-' + action); await tick();
|
||||
const post = b.calls.filter(c => c.url === path && c.method === 'POST').at(-1);
|
||||
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
assert.ok(Buffer.byteLength(post.body) <= 256);
|
||||
const payload = JSON.parse(post.body); assert.equal(payload.action, action);
|
||||
if (action === 'apply') {
|
||||
assert.equal(payload.baud, 460800); assert.equal(payload.rts_threshold, 96); assert.equal(Object.keys(payload).length, 8);
|
||||
} else assert.deepEqual(payload, {action});
|
||||
assert.ok(b.nodes['serial-apply'].disabled && b.nodes['serial-result'].disabled);
|
||||
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /stale/);
|
||||
const count = b.calls.length; b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
|
||||
b.queues[path].push(json({id: i + 1, action, state: 'ok'})); b.fire(1000); await tick();
|
||||
assert.ok(!b.nodes['serial-apply'].disabled && !b.nodes['serial-edit'].hidden);
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed.*RAM.*NVS/);
|
||||
}
|
||||
assert.equal(b.sockets.length, 1); assert.ok(!b.sockets[0].closed && !b.sockets[0].sent.length);
|
||||
});
|
||||
await test('Serial validation and capacity failures never auto-retry or transmit command strings', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
|
||||
const path = '/api/settings/serial-operation';
|
||||
for (const [key, value] of [['baud', '0'], ['baud', '1000001'], ['baud', '1e3'], ['baud', '-1'], ['parity', 'mark'], ['rts_threshold', '128']]) {
|
||||
b.click('refresh-settings'); await tick(); b.nodes['edit-' + key].value = value;
|
||||
const count = b.calls.length; b.click('serial-apply'); await tick(); assert.equal(b.calls.length, count);
|
||||
}
|
||||
b.click('refresh-settings'); await tick();
|
||||
for (const status of [400, 403, 429, 503]) {
|
||||
b.queues[path].push(failure(status)); b.click('serial-save'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result/);
|
||||
const count = b.calls.length; await tick(); b.click('serial-save'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('SECRET'));
|
||||
b.queues[path].push(json({id: 0, action: 'none', state: 'idle'})); b.click('serial-result'); await tick();
|
||||
b.click('refresh-settings'); await tick();
|
||||
}
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 4);
|
||||
b.queues[path].push(json({id: 1, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
|
||||
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.fire(1000); await tick();
|
||||
b.click('refresh-settings'); await tick();
|
||||
b.queues[path].push(() => { throw new Error('lost'); }); b.click('serial-reset'); await tick();
|
||||
b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /acknowledgement was lost.*earlier operation/);
|
||||
assert.ok(b.sockets.every(s => !s.closed));
|
||||
});
|
||||
await test('Serial navigation fences delayed acknowledgement without losing sockets, lease or uncertain-work gate', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick(); const serial = b.sockets[0]; serial.emit('open');
|
||||
serial.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open');
|
||||
b.click('select-settings'); await tick(); const d = deferred(), path = '/api/settings/serial-operation';
|
||||
b.queues[path].push(d.promise); b.click('serial-reset'); await tick();
|
||||
const post = b.calls.find(c => c.url === path); b.click('select-serial'); assert.ok(post.signal.aborted);
|
||||
d.resolve(json({id: 44, action: 'reset', state: 'pending'})); await tick();
|
||||
b.click('select-settings'); await tick(); assert.ok(b.nodes['serial-reset'].disabled);
|
||||
b.queues[path].push(json({id: 44, action: 'reset', state: 'ok'})); b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
|
||||
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
|
||||
assert.deepEqual(serial.sent, []);
|
||||
});
|
||||
await test('Serial result bounds, failure explanations, timeout and no result replay', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
|
||||
const path = '/api/settings/serial-operation';
|
||||
for (const state of ['loaded_defaults', 'failed', 'rollback_failed', 'cancelled', 'pending']) {
|
||||
b.queues[path].push(json({id: 42, action: 'reset', state})); b.click('serial-result'); await tick();
|
||||
assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('unknown. Check'));
|
||||
}
|
||||
for (const response of [new Response(' '.repeat(97)), json({id: 0, action: 'save', state: 'ok'}),
|
||||
json({id: 42, action: 'save', state: 'ok', extra: 1}), json({id: 42, action: '<img>', state: 'ok'}),
|
||||
json({id: -1, action: 'none', state: 'idle'}), new Response(Uint8Array.of(255))]) {
|
||||
b.queues[path].push(response); b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /outcome unknown/);
|
||||
}
|
||||
b.queues[path].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout')))));
|
||||
b.click('serial-result'); await tick(); b.fire(15000); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 0);
|
||||
});
|
||||
await test('Serial mutation security: current-session identity, 401 and pagehide cancel work safely', async () => {
|
||||
for (const mode of ['identity', '401', 'pagehide']) {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick();
|
||||
const path = '/api/settings/serial-operation', d = deferred();
|
||||
if (mode === 'identity') b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
|
||||
else b.queues[path].push(mode === '401' ? failure(401) : d.promise);
|
||||
b.click('serial-save'); await tick();
|
||||
if (mode === 'identity') { assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.filter(c => c.url === path).length, 0); }
|
||||
if (mode === '401') assert.deepEqual(b.redirects, ['/login']);
|
||||
if (mode === 'pagehide') {
|
||||
b.emit('pagehide'); const text = b.nodes['serial-operation-detail'].textContent;
|
||||
d.resolve(json({id: 42, action: 'save', state: 'pending'})); await tick();
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
|
||||
}
|
||||
assert.ok(b.sockets.every(s => s.closed) && b.nodes['serial-settings'].hidden);
|
||||
}
|
||||
});
|
||||
await test('Serial uncertain outcomes survive repeated result reads, refresh and navigation', async () => {
|
||||
for (const lostAck of [true, false]) {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
b.queues[path].push(lostAck ? () => { throw new Error('lost'); } : json({id: 41, action: 'save', state: 'pending'}));
|
||||
b.click('serial-save'); await tick();
|
||||
const warning = lostAck ? /acknowledgement was lost/ : /Previous result was replaced.*unknown/;
|
||||
for (let i = 0; i < 2; ++i) {
|
||||
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
|
||||
if (!lostAck && i === 0) b.fire(1000); else b.click('serial-result');
|
||||
await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
}
|
||||
b.queues[path].push(failure(503)); b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/);
|
||||
b.click('refresh-settings'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
b.click('select-serial'); b.click('select-settings'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'}));
|
||||
b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
b.click('refresh-settings'); await tick();
|
||||
b.queues[path].push(json({id: 43, action: 'save', state: 'pending'}));
|
||||
b.click('serial-save'); await tick();
|
||||
assert.doesNotMatch(b.nodes['serial-operation-detail'].textContent, warning);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 2);
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed));
|
||||
assert.ok(b.sockets.every(s => !s.sent.length));
|
||||
}
|
||||
});
|
||||
await test('Automatic checks: pending then completion refreshes working config, retains outcome and isolates sockets', async () => {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
const before = b.calls.filter(c => c.url === '/api/settings/serial').length;
|
||||
b.queues[path].push(json({id: 50, action: 'apply', state: 'pending'}));
|
||||
b.nodes['edit-baud'].value = '460800'; b.click('serial-apply');
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/); await tick();
|
||||
for (const state of ['pending', 'pending', 'ok']) {
|
||||
assert.ok(b.nodes['edit-baud'].disabled && b.nodes['serial-stop'].disabled);
|
||||
assert.ok(!b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
|
||||
b.queues[path].push(json({id: 50, action: 'apply', state}));
|
||||
if (state === 'ok') b.queues['/api/settings/serial'].push(json({...serialSettings(), baud: 460800}));
|
||||
b.elapse(1000); b.fire(1000); await tick();
|
||||
}
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 3);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, before + 1);
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '460800');
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
|
||||
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
|
||||
assert.ok(!b.nodes['edit-baud'].disabled && !b.nodes['serial-apply'].disabled);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
});
|
||||
await test('Repeated current Settings selection preserves submission, polling and completion refresh', async () => {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick(); b.nodes['edit-baud'].value = '460800';
|
||||
const repeatedSelection = async request => {
|
||||
const count = b.calls.length, timers = [...b.timers];
|
||||
const view = () => Object.fromEntries(Object.entries(b.nodes).map(([id, node]) =>
|
||||
[id, [node.hidden, node.disabled, node.value, node.textContent, node['aria-pressed']]]));
|
||||
const before = view();
|
||||
for (let i = 0; i < 3; ++i) { b.click('select-settings'); await tick(); }
|
||||
assert.equal(b.calls.length, count);
|
||||
assert.deepEqual([...b.timers], timers);
|
||||
assert.deepEqual(view(), before);
|
||||
if (request) assert.ok(!request.signal.aborted);
|
||||
assert.ok(!b.nodes['serial-settings'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden);
|
||||
};
|
||||
const post = deferred(); b.queues[path].push(post.promise);
|
||||
b.click('serial-apply'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/);
|
||||
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
|
||||
post.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
|
||||
await repeatedSelection();
|
||||
const pending = deferred(); b.queues[path].push(pending.promise);
|
||||
b.fire(1000); await tick();
|
||||
await repeatedSelection(b.calls.filter(c => c.url === path).at(-1));
|
||||
pending.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick();
|
||||
await repeatedSelection();
|
||||
const refresh = deferred(); b.queues['/api/settings/serial'].push(refresh.promise);
|
||||
b.queues[path].push(json({id: 57, action: 'apply', state: 'ok'}));
|
||||
b.fire(1000); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /Reading.*stale/);
|
||||
await repeatedSelection(b.calls.filter(c => c.url === '/api/settings/serial').at(-1));
|
||||
refresh.resolve(json({...serialSettings(), baud: 460800})); await tick();
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '460800');
|
||||
assert.equal(b.nodes['edit-baud'].value, '460800');
|
||||
assert.ok(!b.nodes['serial-apply'].disabled);
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
|
||||
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
|
||||
await repeatedSelection();
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 2);
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 2);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
|
||||
});
|
||||
await test('Ten automatic GET attempts exhaust budget; manual recovery completes without POST retry', async () => {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'})); b.click('serial-save'); await tick();
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
b.queues[path].push(json({id: 51, action: 'save', state: 'pending'}));
|
||||
b.elapse(1000); b.fire(1000); await tick();
|
||||
}
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 10);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /Automatic checking stopped.*uncertain.*Check Result/);
|
||||
assert.ok(b.nodes['serial-save'].disabled && !b.nodes['serial-result'].disabled);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /stale/);
|
||||
b.queues[path].push(json({id: 51, action: 'save', state: 'ok'})); b.click('serial-result'); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /completed/);
|
||||
assert.ok(!b.nodes['serial-save'].disabled);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
|
||||
});
|
||||
await test('15s overall deadline aborts slow checks and fences late bodies and delayed timers', async () => {
|
||||
for (const mode of ['fetch', 'body', 'delayed-timer', 'late-response']) {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json({id: 52, action: 'stop', state: 'pending'})); b.click('serial-stop'); await tick();
|
||||
const d = deferred(); let stream;
|
||||
if (mode !== 'delayed-timer') {
|
||||
b.queues[path].push(mode === 'body' ? new Response(new ReadableStream({start(c) { stream = c; }})) : d.promise);
|
||||
b.elapse(1000); b.fire(1000); await tick(); b.elapse(14000);
|
||||
if (mode === 'late-response') { d.resolve(json({id: 52, action: 'stop', state: 'ok'})); await tick(); }
|
||||
else b.fire(15000);
|
||||
assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
|
||||
} else { b.elapse(15000); b.fire(1000); }
|
||||
await tick();
|
||||
const text = b.nodes['serial-operation-detail'].textContent;
|
||||
assert.match(text, /Automatic checking stopped/);
|
||||
assert.ok(!b.nodes['serial-result'].disabled);
|
||||
b.queues[path].push(json({id: 52, action: 'stop', state: 'ok'})); b.click('serial-result'); await tick();
|
||||
const recovered = b.nodes['serial-operation-detail'].textContent;
|
||||
if (stream) { stream.enqueue(new TextEncoder().encode(JSON.stringify({id: 52, action: 'stop', state: 'failed'}))); stream.close(); }
|
||||
else d.resolve(json({id: 52, action: 'stop', state: 'failed'}));
|
||||
await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, recovered);
|
||||
assert.match(recovered, /completed/);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
|
||||
}
|
||||
});
|
||||
await test('Known terminal outcomes always refresh; failed refresh preserves visible stale snapshot and outcome', async () => {
|
||||
for (const state of ['ok', 'failed', 'rollback_failed', 'cancelled', 'loaded_defaults']) {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json({id: 53, action: 'reset', state: 'pending'})); b.click('serial-reset'); await tick();
|
||||
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
|
||||
b.queues[path].push(json({id: 53, action: 'reset', state})); b.fire(1000); await tick();
|
||||
const outcome = b.nodes['serial-operation-detail'].textContent;
|
||||
assert.ok(b.nodes['edit-baud'].disabled && !b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /stale/);
|
||||
d.resolve(failure(503)); await tick();
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '230400');
|
||||
assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['refresh-settings'].disabled);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /stale.*Refresh/);
|
||||
b.click('refresh-settings'); await tick();
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
|
||||
assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/);
|
||||
}
|
||||
});
|
||||
await test('Automatic timers and in-flight checks cancel on navigation, pagehide, logout and identity change', async () => {
|
||||
for (const mode of ['navigation', 'pagehide', 'logout', 'identity', 'expiry']) for (const inFlight of [false, true]) {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation';
|
||||
b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json({id: 54, action: 'start', state: 'pending'})); b.click('serial-start'); await tick();
|
||||
const callbacks = [...b.timers.values()].filter(t => t.ms === 1000 || t.ms === 15000).map(t => t.fn);
|
||||
const d = deferred();
|
||||
if (inFlight) { b.queues[path].push(d.promise); b.fire(1000); await tick(); }
|
||||
if (mode === 'navigation') b.click('select-serial');
|
||||
if (mode === 'pagehide') b.emit('pagehide');
|
||||
if (mode === 'expiry') b.window.sakSessionExpired();
|
||||
if (mode === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); b.click('sign-out'); }
|
||||
if (mode === 'identity') {
|
||||
b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'}));
|
||||
b.click('connection-toggle'); b.click('connection-toggle');
|
||||
}
|
||||
await tick();
|
||||
const text = b.nodes['serial-operation-detail'].textContent, count = b.calls.filter(c => c.url === path).length;
|
||||
if (inFlight) assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted);
|
||||
for (const callback of callbacks) callback();
|
||||
d.resolve(json({id: 54, action: 'start', state: 'ok'})); await tick();
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
|
||||
assert.equal(b.calls.filter(c => c.url === path).length, count);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
|
||||
if (mode === 'navigation') { b.click('select-settings'); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
|
||||
if (mode === 'pagehide') { b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); }
|
||||
}
|
||||
});
|
||||
await test('Automatic read errors stop checking; cancelled completion refresh cannot overwrite a newer view', async () => {
|
||||
const path = '/api/settings/serial-operation';
|
||||
for (const response of [failure(503), new Response(' '.repeat(97)), () => { throw new Error('network'); }]) {
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json({id: 55, action: 'save', state: 'pending'}), response);
|
||||
b.click('serial-save'); await tick(); b.fire(1000); await tick();
|
||||
assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result.*No automatic retry/);
|
||||
assert.match(b.nodes['settings-detail'].textContent, /stale/);
|
||||
assert.ok(!b.nodes['serial-result'].disabled && b.nodes['serial-save'].disabled);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000));
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1);
|
||||
}
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
const d = deferred(); b.queues['/api/settings/serial'].push(d.promise);
|
||||
b.queues[path].push(json({id: 56, action: 'load', state: 'pending'}), json({id: 56, action: 'load', state: 'ok'}));
|
||||
b.click('serial-load'); await tick(); b.fire(1000); await tick();
|
||||
const outcome = b.nodes['serial-operation-detail'].textContent;
|
||||
const read = b.calls.filter(c => c.url === '/api/settings/serial').at(-1);
|
||||
b.click('select-serial'); assert.ok(read.signal.aborted);
|
||||
b.click('select-settings'); await tick();
|
||||
d.resolve(json({...serialSettings(), baud: 110})); await tick();
|
||||
assert.equal(b.nodes['setting-baud'].textContent, '230400');
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, outcome);
|
||||
});
|
||||
await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => {
|
||||
const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick();
|
||||
const confirms = []; b.window.confirm = message => { confirms.push(message); return false; };
|
||||
for (const [i, action] of ['apply', 'start', 'stop', 'load', 'defaults', 'save'].entries()) {
|
||||
b.queues[path].push(json({id: i + 1, action, state: 'pending'}), json({id: i + 1, action, state: 'ok'}));
|
||||
b.click('serial-' + action); await tick(); b.fire(1000); await tick();
|
||||
}
|
||||
assert.deepEqual(confirms, []);
|
||||
const count = b.calls.length, text = b.nodes['serial-operation-detail'].textContent;
|
||||
b.click('serial-reset'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.equal(b.nodes['serial-operation-detail'].textContent, text);
|
||||
assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/);
|
||||
assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6);
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
Reference in New Issue
Block a user