"""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 domain in ('serial', 'network', 'broker', 'writer'): 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 {'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'] 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', 'ssh-values'): assert ids[ident]['tag'] == 'dl' assert 'settings-values' in classes(ids[ident]) for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings', 'lifecycle-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', 'display-refresh', 'broker-refresh', 'ssh-refresh', 'lifecycle-refresh'): assert ids[ident]['text'] == 'Refresh' for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result', 'lifecycle-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 ( '.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}', '.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 # 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: full-card triggers, compact quick CSS, profile slots, status roles and shared settings structure/styles') 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']*>|]*>', '', fixture) probe = r''' const cases = []; 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 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 => { 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 = []; 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', '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(); 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 quick ? [] : 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, height, view, quick, 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('') 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) == 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)')