diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 8257f53..fc7c81f 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **Settings presentation unified (2026-09-08), user-requested visual refinement:** Accounts/Network now use Serial-style 600px label/value definition lists, shared form styling, compact muted help, consistent Refresh/result labels and action grouping. Public-key textarea/generated-password fields styled; checkboxes intrinsic-width. Safe DOM text replaces preformatted summaries; readable ASCII SSID/hex fallback retained, pre-wrap preserves significant spaces (review finding fixed with rendered-width regression). IDs/events/auth/mutations/secret cleanup/terminal ownership unchanged; no backend/assets changes. Parent UI100 behavior groups plus HTML/renderer/CSP and headless Chromium geometry/whitespace checks at320/600/1200px PASS; pio run PASS23.69s, 99,548 B RAM / 1,744,325 B flash (+0/+1,744 vs preceding ASCII-summary build). Diff check PASS. Fixtures/browser layout checks are not target visual sign-off or live Wi-Fi validation. No upload/erase/commit. + - **8D.12/8D.13 implemented together by user authorization (2026-09-08); host-tested/build-verified, target pending:** Backend and admin Network UI deliver 8D.12 nonsecret STA/AP/profile/mDNS edits and explicit persistence, then 8D.13 secret replacement/disabled-STA clear and connection controls. Exact API, byte SSID/UTF-8+hex UI, owner/generation/persistence/uncertainty contracts: `docs/phase8d12_13_implementation.md`. One 768-byte request, 2,048-byte snapshot, 128-byte result; one login-bound slot and one-second ESP timer, 30-second non-executing expiry plus scheduling latency, not hard cancellation. Existing dispatcher IDs/manager owner; accepted != online. Wi-Fi Load stored-only, no reset/default-secret/export; mDNS separate generation/Set/Save/Load/Defaults/reannouncement. 27 handlers/six sockets, no stack/task/queue/schema growth; staged optional route failures preserve unrelated routes. Reported backend/cookie Network PASS; backend P3 queue-drop-counter fix complete; UI agent97+renderer/CSP/review PASS; lifecycle agent21 PASS. Parent final reruns PASS Network five production-path groups, cookie Network five+shared/accounts/serial-settings/admin, console boundary/canonical accounts, lifecycle21, UI97+CSP, idle18+guards, transport25/tickets12, store--serial, diagnostics12+guard and diff check. Independent reviews no remaining actionable findings. Parent pio run PASS24.99s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs accepted legacy cleanup). Earlier pre-final-UI integration build emitted nonfatal FATFS_PRINT_FLOAT config warning; no unrelated config edits. Host owner paths use radio/scheduler/storage doubles, not real network validation. Timer heap, memory floors, HTTPD/dispatcher margins, live Wi-Fi/mDNS/DNS/trust and target checklist remain pending. Profile editor is not explicit-index connection selection: only canonical Next profile. UART0/USB recovery, danger confirmations, no same-response delivery guarantee and unchanged browser-shell restrictions documented. No 8D.14, full M3, target acceptance or reserve approval. Documentation agent touched only authorized docs, no source/tests/assets/build/device/commands; also corrected stale SSH-to-HTTPS startup dependency against `main.c` from accepted legacy cleanup. Older wait-for-8D.12 statements below are superseded, not earlier scoped sign-offs. - **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action. diff --git a/docs/phase8d12_13_implementation.md b/docs/phase8d12_13_implementation.md index aff25ca..e7aed76 100644 --- a/docs/phase8d12_13_implementation.md +++ b/docs/phase8d12_13_implementation.md @@ -6,6 +6,12 @@ The user authorized both phases together. Backend and admin-only Settings/Networ Source authority: `src/web_network_settings.{c,h}`, `wifi_manager.{c,h}`, `wifi_config.{c,h}`, `mdns_service.{c,h}`, `mdns_config.{c,h}`, `admin_ssh_console.{c,h}`, integration in `web_server.c`/`src/CMakeLists.txt`, and authored `web_ui.c`. Contract/test details: `tests/web_network_settings/README.md`, `tests/web_ui_session/network.cjs`, cookie Network tests and server lifecycle tests. This documentation handoff changes no source, tests, generated assets or commands. Browser-shell Wi-Fi/mDNS restrictions are unchanged; typed routes do not grant general command execution. +## Presentation refinement — 2026-09-08 + +On user request, Accounts and Network adopt Serial's compact label/value grids, form styles, help typography and action spacing. Account/key/Network summaries are semantic definition lists populated with DOM text; all information remains available. Printable ASCII SSIDs remain quoted, other bytes use hex, and value cells preserve significant spaces while wrapping. Key textarea, generated-password field and checkboxes share form styling. IDs, request/operation behavior, warnings, secret clearing and terminal/lease ownership are unchanged. + +Parent validation PASS: UI **100 behavior groups**, HTML structure/renderer/header/CSP, and headless Chromium geometry/whitespace regressions at **320/600/1200px**. Review's SSID space-collapse issue was corrected and regression-tested. Final `pio run` **23.69s**, **99,548 B RAM / 1,744,325 B flash**, **+0/+1,744** versus the preceding readable-ASCII summary build (1,742,581 B flash); diff check PASS. These are fixture-based browser checks, not on-device visual acceptance. No backend/generated-asset/upload/erase/commit action. + ## Routes, authorization and isolation | Method | Path | Purpose | diff --git a/src/web_ui.c b/src/web_ui.c index 04f56f8..90dddea 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -82,10 +82,15 @@ static const char s_index_html[] = ".terminal-host .xterm-viewport{border-radius:7px}\n" ".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}" ".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}" - ".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n" - ".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}" - ".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}" - ".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#network-summary{white-space:pre-wrap;overflow-wrap:anywhere}\n" + ".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}\n" + ".serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}" + ".serial-edit label,.settings-edit label{display:grid;gap:4px;color:var(--muted);min-width:0}.serial-edit input,.serial-edit select,.settings-edit input,.settings-edit select,.settings-edit textarea{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}" + ".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0;max-width:600px}\n" + ".settings-page h3{font-size:1.17em;margin:1em 0}.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}" + ".settings-edit .settings-wide{grid-column:1/-1}.settings-edit textarea{min-height:96px;resize:vertical}" + ".settings-edit label.settings-check{display:flex;align-items:center;gap:8px}.settings-edit input[type=checkbox]{width:auto;flex:none}" + ".settings-edit input[readonly]{font-family:monospace}.settings-values dt{overflow-wrap:anywhere}" + "@media(max-width:360px){.settings-values{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}.settings-edit{grid-template-columns:minmax(0,1fr)}}\n" ".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n" "@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}" ".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" @@ -182,22 +187,22 @@ static const char s_index_html[] = "" "" "
Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. " + "
Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. " "Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. " "Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. " "Next selects the next enabled profile in priority order, wrapping.
" - "Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. " + "
Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. " "Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.
" - "" - "" - "SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. " + "
" + "SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. " "Switching modes is lossless or refused. Password replacement requires 8–63 printable ASCII characters; blank never clears. " "AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. " "NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.
" - "" - "STA-only responder. Expected announcement is not client-verified DNS. " + "
STA-only responder. Expected announcement is not client-verified DNS. " "Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. " "Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.
" - "" - "1–55 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.
1–55 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.
After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.
After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.
Working UART1 configuration below is not a saved NVS snapshot. " "Navigation leaves both terminals connected and preserves the serial writer lease.
" "" @@ -264,9 +269,9 @@ static const char s_index_html[] = "Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. " "Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.
" - "" + "" "Select Refresh to read accounts.
" - "Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.
" + "Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.
" "" - "" + "12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.
" "" - "One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.
" - "One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.
" + "Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.
" "" "" @@ -307,6 +312,13 @@ static const char s_app_js[] = "(() => {\n" "'use strict';\n" "const element = (id) => document.getElementById(id);\n" + "function settingsRows(target, rows) {\n" + " target.textContent = '';\n" + " for (const [label, value] of rows) {\n" + " const dt = document.createElement('dt'), dd = document.createElement('dd');\n" + " dt.textContent = label; dd.textContent = value; target.appendChild(dt); target.appendChild(dd);\n" + " }\n" + "}\n" "const connectionStatus = element('connection-status');\n" "const roleStatus = element('role-status');\n" "const clientIdField = element('client-id');\n" @@ -491,7 +503,7 @@ static const char s_app_js[] = " if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n" " if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n" " accountKeys = p.keys; keysIdentity = identity;\n" - " element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n" + " settingsRows(element('account-keys-list'), accountKeys.map(k => [k.index + ': ', k.type + ' ' + k.fingerprint]));\n" " for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n" " element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n" " } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n" @@ -568,7 +580,7 @@ static const char s_app_js[] = " !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n" " new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n" " clearAccountSecret(); accounts = payload.users;\n" - " element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n" + " settingsRows(element('accounts-list'), accounts.map(u => [u.username, u.role + (u.username === sessionIdentity.username ? ' (you)' : '')]));\n" " for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n" " const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n" " element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n" @@ -779,8 +791,14 @@ static const char s_app_js[] = " if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n" " networkSnapshot = payload; networkFresh = true;\n" " const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n" - " net('summary').textContent = 'Wi-Fi generation ' + w.generation + '; boot enabled: ' + w.enabled_at_boot + '\\nAP ' + w.ap.policy + ', channel ' + w.ap.channel + ', ' + networkSSIDSummary(w.ap.ssid) + ', password configured: ' + w.ap.password_configured + '\\n' +\n" - " w.profiles.map(p => 'STA ' + p.index + ': enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security + ', ' + networkSSIDSummary(p.ssid) + ', password configured: ' + p.password_configured).join('\\n') + '\\nRuntime: ' + r.state + ', started ' + r.started + ', active profile ' + r.active_profile + ', IP ' + (r.ip || 'none') + ', AP running ' + r.ap_running + ', AP clients ' + r.ap_clients + ', last error ' + r.last_error + '\\nmDNS generation ' + m.generation + ': ' + m.hostname + '.local; expected announcement ' + m.announced + ', last error ' + m.last_error + '. Not client-verified DNS.';\n" + " settingsRows(net('summary'), [\n" + " ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n" + " ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n" + " ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n" + " ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n" + " ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n" + " ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n" + " ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n" " if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n" " renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n" " net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n" diff --git a/tests/web_ui_session/browser.cjs b/tests/web_ui_session/browser.cjs index c255dfe..742186b 100644 --- a/tests/web_ui_session/browser.cjs +++ b/tests/web_ui_session/browser.cjs @@ -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')); diff --git a/tests/web_ui_session/layout.py b/tests/web_ui_session/layout.py new file mode 100644 index 0000000..565dd8f --- /dev/null +++ b/tests/web_ui_session/layout.py @@ -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'', '', html, flags=re.S) + fixture = re.sub(r']*>|