Refine web quick panels and Wi-Fi controls
This commit is contained in:
@@ -17,7 +17,7 @@ and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Historical 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. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
|
||||
|
||||
Coverage:
|
||||
|
||||
|
||||
@@ -115,6 +115,32 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
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('Every quick card retains Serial/Admin title, toolbar and selected-view semantics until full promotion', async () => {
|
||||
for (const mode of ['serial', 'admin']) for (const domain of ['serial', 'network', 'broker', 'writer']) {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
if (mode === 'admin') { b.click('select-admin'); await tick(); }
|
||||
const assertView = view => {
|
||||
assert.equal(b.nodes['terminal-title'].textContent, {serial:'Live serial stream', admin:'Administration shell', settings:'Settings'}[view], domain);
|
||||
for (const id of ['admin-toggle', 'admin-detail']) assert.equal(b.nodes[id].hidden, view !== 'admin', id);
|
||||
for (const choice of ['serial', 'admin', 'settings']) assert.equal(b.nodes['select-' + choice]['aria-pressed'], String(choice === view), choice);
|
||||
assert.equal(b.nodes.terminal.hidden, view !== 'serial');
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, view !== 'admin');
|
||||
};
|
||||
b.click('quick-' + domain); await tick(); assertView(mode);
|
||||
assert.equal(b.nodes['serial-settings'].hidden, false);
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
b.click('quick-close'); await tick(); assertView(mode);
|
||||
b.click('quick-' + domain); await tick();
|
||||
const calls = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
assertView('settings');
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false');
|
||||
assert.equal(b.nodes['quick-header'].hidden, true);
|
||||
assert.equal(b.nodes['quick-' + domain]['aria-expanded'], 'false');
|
||||
assert.equal(b.document.activeElement, b.nodes['settings-' + (domain === 'writer' ? 'broker' : domain)]);
|
||||
assert.equal(b.calls.length, calls, 'promotion must not reload the shared controller');
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -52,6 +52,19 @@ def check_layout(html):
|
||||
assert trigger['attrs']['aria-expanded'] == 'false'
|
||||
assert trigger['attrs']['aria-haspopup'] == 'dialog'
|
||||
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
|
||||
assert {'status-item', 'quick-trigger'} <= set(classes(trigger)), domain
|
||||
assert 'status-grid' in classes(trigger['parent']), 'the button itself must be the grid card'
|
||||
assert [classes(n) for n in trigger['children']] == [['label'], ['value']], domain
|
||||
assert all(n['tag'] == 'span' for n in trigger['children'])
|
||||
value_id = {'serial': 'serial-summary', 'network': 'wifi-summary', 'broker': 'broker-clients', 'writer': 'writer-id'}[domain]
|
||||
assert ids[value_id]['parent'] is trigger
|
||||
assert ('wide' in classes(trigger)) == (domain in ('serial', 'network'))
|
||||
assert not any(n['tag'] in ('button', 'a', 'input', 'select') for n in descendants(trigger))
|
||||
options = ids['network-target']['children']
|
||||
assert [n['attrs']['value'] for n in options] == ['ap', '0', '1', '2', '3']
|
||||
for i, option in enumerate(options[1:]):
|
||||
assert ids['network-profile-' + str(i)] is option
|
||||
assert 'hidden' not in option['attrs'] and 'disabled' not in option['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']
|
||||
@@ -87,6 +100,19 @@ def check_layout(html):
|
||||
assert html.index('id="account-delete"') < html.index('id="account-result"') < html.index('id="account-key-add"') < html.index('id="account-submit-password"')
|
||||
css = next(n['text'] for n in descendants(doc.root) if n['tag'] == 'style')
|
||||
for rule in (
|
||||
'.quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}',
|
||||
'.status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}',
|
||||
'.status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}',
|
||||
'.status-item{min-width:0;',
|
||||
'.value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
|
||||
"[data-quick='true'] #quick-header>h2{display:block}",
|
||||
"[data-quick='true'] #settings-values>:nth-child(n+3){display:none}",
|
||||
"[data-quick='true'] #network-edit>*{display:none}",
|
||||
|
||||
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}",
|
||||
"[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}",
|
||||
"[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}",
|
||||
"[data-quick='true'] #network-wifi-load{display:none}",
|
||||
'.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||
'.settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}',
|
||||
'.serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}',
|
||||
@@ -100,8 +126,18 @@ def check_layout(html):
|
||||
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
|
||||
):
|
||||
assert rule in css, rule
|
||||
# These selectors must share a hide rule, not merely occur in explanatory text.
|
||||
import re
|
||||
hide_selectors = {selector.strip() for selectors in re.findall(r'([^{}]+)\{display:none\}', css) for selector in selectors.split(',')}
|
||||
for selector in ('#settings-navigation', '#quick-help', 'h3', '>div>h2', 'p:not([role=status])', '#network-summary', '#broker-values'):
|
||||
scoped = "[data-quick='true']" + ('' if selector.startswith('>') else ' ') + selector
|
||||
assert scoped in hide_selectors, scoped
|
||||
for ident in ('settings-detail', 'serial-operation-detail', 'network-detail', 'network-operation-detail', 'broker-detail', 'broker-operation-detail'):
|
||||
assert ids[ident]['attrs']['role'] == 'status', ident
|
||||
assert [n['tag'] for n in ids['settings-values']['children'][:2]] == ['dt', 'dd']
|
||||
assert ids['settings-values']['children'][0]['text'] == 'Service'
|
||||
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 seven settings views')
|
||||
print('PASS HTML layout: full-card triggers, compact quick CSS, profile slots, status roles and shared settings structure/styles')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
@@ -114,15 +150,45 @@ 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']) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
|
||||
const widths = [320, 600, 900, 1200];
|
||||
const views = ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'];
|
||||
for (const width of widths) {
|
||||
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, errors = [];
|
||||
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
|
||||
const card = d.getElementById(id), value = card.querySelector('.value');
|
||||
card.disabled = false; value.textContent = 'long-unbroken-status-'.repeat(20);
|
||||
}
|
||||
const grid = d.querySelector('.status-grid'), gridRect = grid.getBoundingClientRect();
|
||||
if (grid.scrollWidth > grid.clientWidth + 1 || gridRect.left < 0 || gridRect.right > width + 1) errors.push('status grid overflow');
|
||||
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
|
||||
const card = d.getElementById(id), value = card.querySelector('.value');
|
||||
const r = card.getBoundingClientRect(), v = value.getBoundingClientRect(), style = win.getComputedStyle(value);
|
||||
if (r.width <= 0 || r.left < gridRect.left || r.right > gridRect.right + 1 || card.scrollWidth > card.clientWidth + 1) errors.push('card overflow:' + id);
|
||||
if (v.left < r.left || v.right > r.right || value.scrollWidth <= value.clientWidth) errors.push('missing constrained long value:' + id);
|
||||
if (style.textOverflow !== 'ellipsis' || style.whiteSpace !== 'nowrap' || style.overflowX !== 'hidden') errors.push('missing ellipsis:' + id);
|
||||
// Probe card padding/corners as well as label/value: a nested text-only button must fail.
|
||||
for (const [x, y] of [[r.left + 6, r.top + 6], [r.right - 6, r.bottom - 6], [r.left + r.width / 2, r.top + r.height / 2], [v.left + 1, v.top + 1]]) {
|
||||
const hit = d.elementFromPoint(x, y);
|
||||
if (hit?.closest('button') !== card) errors.push('card hit target:' + id + ' at ' + x + ',' + y + ' hit ' + (hit?.id || hit?.tagName || 'nothing'));
|
||||
}
|
||||
}
|
||||
resolve({width, view:'status-cards', errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
}
|
||||
for (const width of widths) for (const view of views) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings', 'broker-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;
|
||||
d.getElementById('serial-settings').hidden = false;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
|
||||
for (const id of views) d.getElementById(id).hidden = id !== view;
|
||||
const section = d.getElementById(view);
|
||||
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
|
||||
section.querySelectorAll('dl').forEach(dl => {
|
||||
@@ -142,9 +208,27 @@ def check_browser_layout(html, tmp, executable):
|
||||
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']) {
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status', 'network-summary', 'broker-values']) {
|
||||
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
|
||||
}
|
||||
const visible = node => !!node.getBoundingClientRect().width;
|
||||
for (const node of section.querySelectorAll('p:not([role=status]),h2,h3')) if (visible(node)) errors.push('quick explanatory content:' + node.tagName);
|
||||
if (!visible(d.getElementById('quick-title'))) errors.push('missing quick title');
|
||||
const statuses = { 'serial-settings-content':['settings-detail','serial-operation-detail'], 'network-settings':['network-detail','network-operation-detail'], 'broker-settings':['broker-detail','broker-operation-detail'] };
|
||||
for (const id of statuses[view]) if (!visible(d.getElementById(id))) errors.push('hidden result/status:' + id);
|
||||
if (view === 'serial-settings-content') {
|
||||
const rows = [...d.getElementById('settings-values').children];
|
||||
if (!rows.every((node, i) => visible(node) === (i < 2))) errors.push('quick serial must show only service pair');
|
||||
}
|
||||
if (view === 'network-settings') {
|
||||
const allowed = ['network-target','network-policy','network-enabled','network-apply','network-wifi-save','network-result','network-refresh'];
|
||||
const controls = [...section.querySelectorAll('input,select,textarea,button')];
|
||||
for (const node of controls) if (visible(node) !== allowed.includes(node.id)) errors.push('quick control visibility:' + node.id);
|
||||
// CSS-only promotion restores the full editor; application draft/option state is checked in Node.
|
||||
host.dataset.quick = 'false';
|
||||
for (const node of controls) if (!visible(node)) errors.push('full control not restored:' + node.id);
|
||||
host.dataset.quick = 'true';
|
||||
}
|
||||
}
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
@@ -190,6 +274,7 @@ 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) == 18
|
||||
assert all(not case['errors'] for case in results), results
|
||||
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)')
|
||||
assert len(results) == 36
|
||||
failures = [case for case in results if case['errors']]
|
||||
assert not failures, failures
|
||||
print('PASS Chromium layout: full-card hit targets/ellipsis, five full views and Serial/Wi-Fi/Broker popovers at 320/600/900/1200px (quick height360); compact controls/status, promotion restoration and whitespace checks (fixtures, not live app)')
|
||||
@@ -35,16 +35,59 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
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');
|
||||
n(b,'enabled').checked = false; n(b,'enabled').change();
|
||||
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);
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch', generation:7, profile:0, enabled:false});
|
||||
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 Wi-Fi names configured profile slots, hides only empty slots and restores all slots on promotion', async () => {
|
||||
const value = fixture();
|
||||
value.wifi.profiles[2].ssid = '<b>office backup</b>'; // Configured but disabled remains selectable; label is safe text.
|
||||
value.wifi.profiles[2].password_configured = true;
|
||||
const b = await adminBrowser(); b.queues[path].push(json(value)); b.click('quick-network'); await tick();
|
||||
const slots = quick => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const option = n(b, 'profile-' + i), empty = !value.wifi.profiles[i].ssid;
|
||||
assert.ok(option, 'missing profile option ' + i);
|
||||
assert.equal(option.hidden, quick && empty, 'profile visibility ' + i);
|
||||
assert.equal(option.disabled, quick && empty, 'profile selectability ' + i);
|
||||
if (!empty) assert.ok(option.textContent.includes(value.wifi.profiles[i].ssid), 'SSID label ' + i);
|
||||
assert.equal(option.children.length, 0, 'SSID must not become markup');
|
||||
}
|
||||
};
|
||||
slots(true); assert.equal(n(b,'target').value, 'ap');
|
||||
target(b,'2'); n(b,'enabled').checked = true; n(b,'enabled').change();
|
||||
const selectedOption = n(b,'profile-2'), count = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
slots(false); assert.equal(n(b,'profile-2'), selectedOption);
|
||||
assert.equal(n(b,'target').value, '2'); assert.equal(n(b,'enabled').checked, true);
|
||||
assert.equal(n(b,'ssid').value, value.wifi.profiles[2].ssid);
|
||||
assert.equal(b.nodes['terminal-title'].textContent, 'Settings');
|
||||
assert.equal(b.calls.length, count, 'promotion must preserve the draft without fetching');
|
||||
target(b,'3'); assert.equal(n(b,'ssid').value, '', 'full page can configure an empty slot');
|
||||
b.click('select-admin'); b.queues[path].push(json(value)); b.click('quick-network'); await tick(); slots(true);
|
||||
value.wifi.profiles[2].ssid = ''; value.wifi.profiles[2].password_configured = false;
|
||||
value.wifi.profiles[3].ssid = 'replacement'; value.wifi.profiles[3].password_configured = true;
|
||||
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); slots(true);
|
||||
assert.equal(posts(b).length, 0, 'opening, selecting, promotion and refresh never apply'); clean(b);
|
||||
});
|
||||
await test('Quick Wi-Fi AP policy applies only its typed patch; Save persists working values, not the draft', async () => {
|
||||
for (const action of ['wifi-patch', 'wifi-save']) {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(n(b,'target').value, 'ap');
|
||||
input(b,'policy','always','change');
|
||||
assert.equal(posts(b).length, 0);
|
||||
b.queues[operation].push(ack(action)); b.click(action === 'wifi-patch' ? 'network-apply' : 'network-wifi-save'); await tick();
|
||||
assert.equal(posts(b).length, 1);
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), action === 'wifi-patch' ? {action, generation:7, ap_policy:'always'} : {action, generation:7});
|
||||
clean(b);
|
||||
}
|
||||
});
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user