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:
@@ -15,6 +15,10 @@ doubles. It reuses the HTTPD stub text from `tests/web_login_ui/run.py`, without
|
||||
importing/executing that runner. Node executes the actual C-rendered application
|
||||
and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Current 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
||||
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. Full evidence/limits and target checklist: `docs/phase8d17_implementation.md`.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Resource selection, NULL/invalid input, setter/send failure propagation,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -45,6 +45,17 @@ def check_layout(html):
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for domain in ('serial', 'network'):
|
||||
trigger = ids['quick-' + domain]
|
||||
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
|
||||
assert trigger['attrs']['aria-controls'] == 'serial-settings'
|
||||
assert trigger['attrs']['aria-expanded'] == 'false'
|
||||
assert trigger['attrs']['aria-haspopup'] == 'dialog'
|
||||
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
|
||||
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
@@ -103,8 +114,10 @@ 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', 'display-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
|
||||
const height = quick ? 360 : 900;
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
@@ -122,6 +135,17 @@ def check_browser_layout(html, tmp, executable):
|
||||
});
|
||||
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
|
||||
const errors = [];
|
||||
if (quick) {
|
||||
const host = d.getElementById('serial-settings'); host.dataset.quick = 'true';
|
||||
d.getElementById('quick-header').hidden = false;
|
||||
const rect = host.getBoundingClientRect();
|
||||
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
|
||||
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
|
||||
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
|
||||
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
|
||||
}
|
||||
}
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!rect.width) continue;
|
||||
@@ -132,7 +156,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
}
|
||||
// Measure glyph ranges, not the full-width grid cell or textContent.
|
||||
// The normal-whitespace control proves the pair detects collapsing.
|
||||
for (const dd of section.querySelectorAll('dl dd:first-of-type')) {
|
||||
for (const dd of quick ? [] : section.querySelectorAll('dl dd:first-of-type')) {
|
||||
const original = dd.textContent;
|
||||
const measureSSID = spaces => {
|
||||
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
|
||||
@@ -147,7 +171,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
|
||||
dd.style.removeProperty('white-space'); dd.textContent = original;
|
||||
}
|
||||
resolve({width, view, errors});
|
||||
resolve({width, height, view, quick, errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
@@ -166,6 +190,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) == 12
|
||||
assert len(results) == 18
|
||||
assert all(not case['errors'] for case in results), results
|
||||
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)')
|
||||
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
|
||||
@@ -28,6 +28,34 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
||||
b.click('quick-network'); await tick();
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
|
||||
assert.ok(n(b,'password-mode').disabled);
|
||||
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
|
||||
input(b,'ssid','new network');
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(posts(b).length,1);
|
||||
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
|
||||
assert.ok(!Object.hasOwn(body,'password')); clean(b);
|
||||
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
|
||||
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
|
||||
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
|
||||
});
|
||||
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
|
||||
const b = await open(); secret(b); const count = b.calls.length;
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.calls.length,count); assert.equal(n(b,'password').value,'a safe PSK');
|
||||
b.click('select-serial'); clean(b);
|
||||
b.queues[path].push(json({...fixture(), password:'SECRET'})); b.click('quick-network'); await tick();
|
||||
assert.equal(n(b,'edit').hidden,true); safe(b); clean(b);
|
||||
b.click('quick-close'); b.queues[path].push(failure(401)); b.click('quick-network'); await tick();
|
||||
assert.deepEqual(b.redirects,['/login']); assert.equal(b.nodes['serial-settings']['data-quick'],'false'); clean(b);
|
||||
assert.ok(b.sockets.every(s => s.closed));
|
||||
});
|
||||
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
|
||||
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
|
||||
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
|
||||
|
||||
Reference in New Issue
Block a user