Add Serial and Wi-Fi quick settings

Reuse the existing settings view and typed controllers for accessible
quick editing while keeping network credentials out of quick mode.
Expand browser and layout coverage for focus, dismissal, bounds, and
expiry.
This commit is contained in:
2026-09-13 14:22:31 +02:00
parent add399908a
commit 9f6ebf2053
10 changed files with 355 additions and 21 deletions
+121 -3
View File
@@ -30,7 +30,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
}
class Terminal {
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
loadAddon() {} open(host) { this.host = host; this.focusCalls = 0; } focus() { ++this.focusCalls; this.host.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 = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
@@ -48,7 +48,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
setAttribute(k, v) { this[k] = v; }
getBoundingClientRect() { return {width: 100, height: 100}; }
addEventListener(k, fn) { this[k] = fn; }
addEventListener(k, fn) { this[k === 'focus' ? 'onfocus' : k] = fn; }
focus() { context.document.activeElement = this; this.onfocus?.(); }
contains(node) { return node === this || this.children.some(child => child.contains(node)); }
matches() { return !!this.hovered; }
}
const context = vm.createContext({window, document: {
createElement: tag => new Element(tag),
@@ -80,13 +83,128 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
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,
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
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;
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
(async () => {
await test('Quick Serial hover/focus/tap reuses one form, opens read-only and preserves socket/lease and binary drains', async () => {
const b = browser({role: 'admin'}); b.start(); await tick();
const trigger = b.nodes['quick-serial'], host = b.nodes['serial-settings'];
host.appendChild(b.nodes['quick-close']);
trigger.pointerenter({pointerType: 'touch'}); await tick();
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 0);
trigger.pointerenter({pointerType: 'mouse'}); await tick();
assert.equal(host['data-quick'], 'true'); assert.equal(trigger['aria-expanded'], 'true');
assert.equal(host.role, 'dialog'); assert.equal(b.nodes.terminal.hidden, false);
const reads = b.calls.filter(c => c.url === '/api/settings/serial').length;
trigger.focus(); b.click('quick-serial'); await tick();
assert.equal(b.document.activeElement, b.nodes['quick-close']);
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, reads);
trigger.pointerleave(); b.fire(250); assert.equal(host['data-quick'], 'true');
assert.equal(b.calls.filter(c => c.url.includes('settings') && c.method === 'POST').length, 0);
const ws = b.sockets[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type:'hello',clientId:8,writerId:8,role:'writer'})});
b.terminals[0].input('not serial input'); assert.equal(ws.sent.length, 0);
ws.emit('message', {data: Uint8Array.of(0,255).buffer}); assert.deepEqual(b.terminals[0].writes.at(-1), [0,255]);
b.nodes['edit-baud'].value = '999';
b.emit('keydown', {key:'Escape',preventDefault(){},stopPropagation(){}});
assert.equal(host['data-quick'], 'false'); assert.equal(b.document.activeElement, trigger);
assert.equal(b.nodes['edit-baud'].value, ''); assert.equal(trigger['aria-expanded'], 'false');
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
});
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
const b = browser({role:'admin'}); b.start(); await tick();
b.nodes['quick-network'].focus(); await tick();
const term = b.terminals[0], before = term.focusCalls;
b.nodes['quick-network'].focusout(); b.nodes['quick-serial'].focus(); await tick();
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');
assert.equal(b.nodes['quick-network']['aria-expanded'],'false');
assert.equal(term.focusCalls,before);
b.nodes['quick-serial'].focusout(); b.nodes['quick-network'].focus(); await tick();
assert.equal(b.document.activeElement,b.nodes['quick-network']);
assert.equal(term.focusCalls,before);
});
await test('Quick automatic dismissal restores Serial/Admin state without stealing outside focus; Close/Escape focus only trigger', async () => {
for (const mode of ['serial','admin']) {
const b = browser({role:'admin'}); b.start(); await tick();
if (mode === 'admin') b.click('select-admin');
const terminal = b.terminals[mode === 'admin' ? 1 : 0];
b.nodes['quick-serial'].focus(); await tick();
const before = b.terminals.map(t => t.focusCalls);
b.nodes['quick-serial'].focusout(); b.nodes['connection-toggle'].focus();
b.fire(250); await tick();
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
assert.equal(b.nodes['serial-settings']['data-quick'],'false');
assert.equal(b.nodes['select-' + mode]['aria-pressed'],'true');
assert.equal(terminal.host.hidden,false);
b.click('quick-serial'); await tick(); b.nodes['connection-toggle'].focus();
b.emit('pointerdown',{target:b.nodes['connection-toggle']});
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
for (const dismiss of ['close','escape']) {
b.click('quick-serial'); await tick();
if (dismiss === 'close') b.click('quick-close');
else b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
}
assert.ok(b.sockets.every(s => !s.closed));
}
});
await test('Quick dismissal fences late snapshots, hover bridge/outside click and expiry without replay', async () => {
const b = browser({role:'admin'}); b.start(); await tick();
const late = deferred(); b.queues['/api/settings/serial'].push(late.promise);
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
const request = b.calls.find(c => c.url === '/api/settings/serial');
b.nodes['quick-serial'].pointerleave(); b.nodes['serial-settings'].hovered = true;
b.fire(250); assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
b.emit('pointerdown', {target:b.nodes['connection-toggle']});
assert.ok(request.signal.aborted); late.resolve(json(serialSettings())); await tick();
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '');
b.click('quick-serial'); await tick(); b.emit('pagehide');
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.ok(b.sockets.every(s => s.closed));
const count = b.calls.length; b.click('quick-serial'); await tick(); assert.equal(b.calls.length,count);
});
await test('Quick full-page promotion preserves the sole draft/controller and full-page hover cannot discard drafts', async () => {
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
b.nodes['edit-baud'].value = '115200'; const reads = b.calls.length;
b.nodes['quick-full'].click({preventDefault(){}});
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '115200');
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
assert.equal(b.calls.length,reads); assert.equal(b.nodes['edit-baud'].value, '115200');
const u = await connected(); u.click('quick-serial'); u.nodes['quick-network'].focus(); await tick();
assert.ok(!u.calls.some(c => c.url.startsWith('/api/settings/')));
});
await test('Quick Serial shares validation, explicit apply/save and pending result recovery without replay', async () => {
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
b.nodes['edit-baud'].value = 'bad'; b.click('serial-apply'); await tick();
assert.ok(!b.calls.some(c => c.url.endsWith('serial-operation')));
b.nodes['edit-baud'].value = '115200';
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:4,action:'apply',state:'pending'}),{status:202}));
b.click('serial-apply'); await tick(); b.click('quick-close'); await tick(); b.click('quick-serial'); await tick();
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
assert.ok(b.nodes['serial-apply'].disabled); assert.match(b.nodes['serial-operation-detail'].textContent,/uncertain|unknown|pending/);
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
});
await test('Quick Save sends only the explicit persistence action, and active editor focus resists unrelated hover', async () => {
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
const host = b.nodes['serial-settings']; host.appendChild(b.nodes['edit-baud']);
b.nodes['edit-baud'].value = '9600'; b.nodes['edit-baud'].focus();
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true'); assert.equal(b.nodes['edit-baud'].value,'9600');
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:5,action:'save',state:'pending'}),{status:202}));
b.click('serial-save'); await tick();
const posts = b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST');
assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action:'save'});
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
assert.ok([...b.timers.values()].some(t => t.ms === 1000));
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
});
await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => {
const b = await connected();
assert.equal(b.calls[0].url, '/api/session');