Unify Settings Layouts and Add Coverage

This commit is contained in:
2026-09-08 21:22:52 +02:00
parent 989821b7c4
commit 4a4d615c59
7 changed files with 295 additions and 43 deletions
+32 -7
View File
@@ -38,12 +38,22 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
const context = vm.createContext({window, document: {getElementById(id) {
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
setAttribute(k, v) { this[k] = v; },
getBoundingClientRect: () => ({width: 100, height: 100}),
addEventListener(k, fn) { this[k] = fn; }};
}}, Terminal, FitAddon: {FitAddon: class {
class Element {
constructor(tagName = 'div') {
this.tagName = tagName.toUpperCase(); this.children = []; this._text = '';
this.value = ''; this.checked = false; this.dataset = {}; this.classList = {toggle() {}};
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(value) { this.children.forEach(child => { child.parentNode = null; }); this.children = []; this._text = String(value); }
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; }
}
const context = vm.createContext({window, document: {
createElement: tag => new Element(tag),
getElementById(id) { return nodes[id] ||= new Element(); }
}, Terminal, FitAddon: {FitAddon: class {
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
@@ -830,7 +840,8 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
});
const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)});
function assertKeySlots(b, indices) {
assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n'));
assert.deepEqual(b.nodes['account-keys-list'].children.map(node => [node.tagName, node.textContent]),
indices.flatMap(index => [['DT', `${index}: `], ['DD', `ssh-ed25519 ${slotKey(index).fingerprint}`]]));
assert.equal(b.nodes['account-key-index'].value, String(indices[0]));
for(let index=0;index<3;++index) {
const option=b.nodes['key-option-'+index], present=indices.includes(index);
@@ -927,6 +938,20 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick();
assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/);
});
await test('Account definition rows and key rows clear as DOM children and fence late lists', async () => {
const b = await keyBrowser(), list = b.nodes['accounts-list'], keys = b.nodes['account-keys-list'];
assert.deepEqual(list.children.map(n => [n.tagName, n.textContent]),
[['DT', 'alice'], ['DD', 'admin (you)'], ['DT', 'carol'], ['DD', 'user']]);
assert.ok(keys.children.length > 0);
assert.ok([...list.children, ...keys.children].every(n => n.children.length === 0));
const old = [...list.children, ...keys.children], d = deferred();
b.queues['/api/settings/accounts'].push(d.promise); b.click('refresh-accounts'); await tick();
b.click('settings-serial'); await tick();
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
assert.ok(old.every(n => n.parentNode === null));
d.resolve(json({users: [{username: 'late', role: 'user', user_id: 9, auth_generation: 1}]})); await tick();
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
+171
View File
@@ -0,0 +1,171 @@
"""Structural checks of production HTML; not a browser layout/visual test."""
from html.parser import HTMLParser
class Document(HTMLParser):
def __init__(self, html):
super().__init__(convert_charrefs=True)
self.root = {'tag': 'root', 'attrs': {}, 'children': [], 'text': ''}
self.stack = [self.root]
self.ids = {}
self.feed(html)
assert self.stack == [self.root]
def handle_starttag(self, tag, attrs):
node = {'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': '', 'parent': self.stack[-1]}
self.stack[-1]['children'].append(node)
if 'id' in node['attrs']:
ident = node['attrs']['id']
assert ident not in self.ids, ident
self.ids[ident] = node
if tag not in {'meta', 'link', 'img', 'input', 'br', 'hr'}:
self.stack.append(node)
def handle_endtag(self, tag):
assert self.stack[-1]['tag'] == tag, (tag, self.stack[-1]['tag'])
self.stack.pop()
def handle_data(self, data):
for node in self.stack:
node['text'] += data
def check_layout(html):
doc = Document(html)
ids = doc.ids
def classes(node):
return node['attrs'].get('class', '').split()
def descendants(node):
for child in node['children']:
yield child
yield from descendants(child)
def ancestor(node, cls):
while 'parent' in node:
node = node['parent']
if cls in classes(node):
return node
raise AssertionError(cls)
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-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')
for n in nodes:
if n['tag'] in ('input', 'select', 'textarea'):
assert n['parent']['tag'] == 'label'
try:
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-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'])
for ident in ('account-public-key', 'account-generated'):
assert 'settings-wide' in classes(ids[ident]['parent'])
assert 'readonly' in ids['account-generated']['attrs']
assert 'hidden' in ids['account-generated-panel']['attrs']
assert ids['network-apply']['parent'] is ids['network-wifi-save']['parent']
assert ids['network-wifi-save']['parent'] is ids['network-wifi-load']['parent']
assert ids['network-start']['parent'] is not ids['network-wifi-save']['parent']
assert ids['network-start']['parent'] is ids['network-next-profile']['parent']
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 (
'.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}',
'.settings-edit .settings-wide{grid-column:1/-1}',
'.settings-edit input[type=checkbox]{width:auto;flex:none}',
'.settings-edit textarea{min-height:96px;resize:vertical}',
'.settings-edit input[readonly]{font-family:monospace}',
'.settings-page h3{font-size:1.17em;margin:1em 0}',
'.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}',
'@media(max-width:360px)',
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
):
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')
def check_browser_layout(html, tmp, executable):
"""Optional real CSS layout check; fixture data, no application/network execution."""
import json
import re
import subprocess
# Keep production HTML/CSS, but do not run its authenticated loader or assets.
fixture = re.sub(r'<script\b[^>]*>.*?</script>', '', html, flags=re.S)
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']) {
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;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
dl.textContent = '';
for (let i = 0; i < 8; i++) {
const dt = d.createElement('dt'), dd = d.createElement('dd');
dt.textContent = 'Password configured'; dd.textContent = 'SHA256:' + 'x'.repeat(96);
dl.append(dt, dd);
}
});
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
const errors = [];
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
const rect = node.getBoundingClientRect();
if (!rect.width) continue;
if (rect.left < 0 || rect.right > width + 1) errors.push('overflow:' + (node.id || node.className));
if (node.matches('dl') && node.scrollWidth > node.clientWidth + 1) errors.push('summary overflow');
if (node.matches('input[type=checkbox]') && rect.width > 30) errors.push('checkbox width');
if (node.matches('dl,.settings-edit,.serial-edit') && rect.width > 601) errors.push('max width');
}
// 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')) {
const original = dd.textContent;
const measureSSID = spaces => {
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
const range = d.createRange();
range.setStart(dd.firstChild, 7); range.setEnd(dd.firstChild, dd.textContent.length - 1);
if (range.getClientRects().length !== 1) errors.push('SSID probe unexpectedly wrapped');
return range.getBoundingClientRect().width;
};
const single = measureSSID(1), double = measureSSID(2);
if (!(single > 0 && double > single + 1)) errors.push('consecutive SSID spaces collapsed');
dd.style.whiteSpace = 'normal';
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});
};
}));
frame.srcdoc = FIXTURE; document.body.append(frame);
}
Promise.all(cases).then(results => {
const out = document.createElement('pre'); out.id = 'layout-results'; out.textContent = JSON.stringify(results); document.body.append(out);
});
'''.replace('FIXTURE', json.dumps(fixture))
page = tmp / 'layout-probe.html'
page.write_text('<!doctype html><html><body><script>' + probe + '</script></body></html>')
result = subprocess.run([executable, '--headless', '--no-sandbox', '--disable-gpu',
'--no-first-run', '--disable-background-networking',
'--user-data-dir=' + str(tmp / 'chromium-profile'),
'--virtual-time-budget=3000', '--dump-dom', page.as_uri()],
capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 9
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)')
+24 -1
View File
@@ -73,6 +73,26 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
}
assert.equal(posts(b).length, 0);
});
await test('Structured network rows retain every field, escape markup and clear/fence navigation', async () => {
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
const b = await open(v), summary = n(b, 'summary');
const rows = summary.children;
assert.equal(rows.length, 58);
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
assert.equal(values['AP password configured'], 'true');
assert.equal(values['STA 3 password configured'], 'false');
assert.equal(values['AP clients'], '1'); assert.equal(values['Wi-Fi last error'], '0');
assert.equal(values['mDNS last error'], '0'); assert.equal(values['Expected announcement'], 'false');
assert.equal(values['DNS verification'], 'Not client-verified DNS.');
const d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick();
b.click('settings-accounts'); await tick(); assert.equal(summary.children.length, 0);
assert.ok(rows.every(node => node.parentNode === null));
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
});
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1,
@@ -272,7 +292,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/);
if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/);
if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/);
assert.match(n(b, 'summary').textContent, /Runtime: error/); assert.match(n(b, 'summary').textContent, /last error 259/);
assert.match(n(b, 'summary').textContent, /Runtime: error/); for (const label of ['Wi-Fi last error', 'mDNS last error']) {
const rows = n(b, 'summary').children, index = rows.findIndex(node => node.tagName === 'DT' && node.textContent === label);
assert.ok(index >= 0); assert.equal(rows[index + 1].tagName, 'DD'); assert.equal(rows[index + 1].textContent, '259');
}
}
const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick();
assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled);
+7
View File
@@ -10,6 +10,10 @@ import re
import shlex
import subprocess
import tempfile
import sys
sys.dont_write_bytecode = True
from layout import check_layout, check_browser_layout
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
@@ -90,6 +94,9 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert 'no private-key upload, export or SSH host management' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
check_layout(rendered['html'])
if os.environ.get('WEB_UI_CHROMIUM'):
check_browser_layout(rendered['html'], tmp, os.environ['WEB_UI_CHROMIUM'])
(tmp / 'rendered.json').write_text(json.dumps(rendered))
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')