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.
195 lines
12 KiB
Python
195 lines
12 KiB
Python
"""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'):
|
|
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])
|
|
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-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'):
|
|
assert ids[ident]['text'] == 'Refresh'
|
|
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-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 five 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', '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;
|
|
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;
|
|
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']) {
|
|
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;
|
|
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('<!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) == 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)') |