This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 10:50:21 +02:00
1 parent e3de084149
commit e1ac899ec0
14 files changed
+480 -77

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Regression for live Network diagnostic DOM stability beside Process.
Runs the actual renderer and the shipped dashboard CSS with mocked sample data
in Chromium touch emulation. This does NOT certify Safari/iOS 27 behavior.
"""
from pathlib import Path
import shutil
from bs4 import BeautifulSoup
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
soup = BeautifulSoup((WEB / 'index.html').read_text(), 'html.parser')
process = soup.select_one('[data-dashboard-widget="process_diagnostics"]')
network = soup.select_one('[data-dashboard-widget="network_diagnostics"]')
assert process and network
source = (WEB / 'app/runtime.js').read_text()
start = source.index('function renderNetworkInterfaceCharts(interfaces, appendSample) {')
start = source.rfind('let latestNetworkInterfaceSnapshot', 0, start)
assert start >= 0
renderer = source[start:source.index('function renderDiagnostics(', start)]
helper = source[source.index('function setChartText('):start]
html = ('<!doctype html><html data-theme="ultra_dark" data-dashboard-density="compact">'
'<head><meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>' + (WEB / 'style.css').read_text() + '</style></head>'
'<body style="margin:0"><div style="height:1020px">Overview metrics</div>'
'<section id="panel-overview"><div class="stats-grid dashboard-overview-grid is-aligned" '
'id="dashboard-overview-grid">' + str(process) + str(network) +
'</div></section><div style="height:160px"></div></body></html>')
stubs = '''
const networkInterfaceSamples = new Map();
const hiddenNetworkInterfaces = new Set();
const $ = id => document.getElementById(id);
const t = s => ({networkInterfacesVisibleCount:'{visible} / {total}',
networkInterfaceRate:'Rate', networkInterfacesHistory:'Traffic',
hideMetric:'Hide', showMetric:'Show',hiddenInterfaces:'Hidden',
networkNoInterfaces:'No interfaces',networkNoVisibleInterfaces:'None visible'})[s] || s;
const fmtBytes = n => `${n} B`;
const el = (tag, text, cls) => {const node=document.createElement(tag);
if(text !== undefined) node.textContent=text;if(cls) node.className=cls;return node;};
const action = (label, fn) => {const node=el('button',label);node.addEventListener('click',fn);return node;};
const svgNode = (tag, attrs={}) => {const node=document.createElementNS('http://www.w3.org/2000/svg',tag);
Object.entries(attrs).forEach(([key,value])=>node.setAttribute(key,value));return node;};
const sparklinePoints = (values,width=300,height=60,padding=5,peakOverride=null) => {
const peak=Math.max(1,peakOverride??Math.max(0,...values));
return values.map((value,index)=>`${index*width/Math.max(1,values.length-1)},${height-padding-Math.max(0,value)/peak*(height-padding*2)}`).join(' ');
};
const saveHiddenNetworkInterfaces = () => {};
'''
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
context = browser.new_context(viewport={'width':393,'height':844},
device_scale_factor=3, is_mobile=True, has_touch=True)
page = context.new_page()
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + helper + renderer)
baseline = page.evaluate('''() => {
const items = [{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:20},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}];
renderNetworkInterfaceCharts(items,true);
const panel = document.querySelector('[data-dashboard-widget="process_diagnostics"]');
const network = document.querySelector('[data-dashboard-widget="network_diagnostics"]');
const target = document.getElementById('network-interface-charts');
window.testNodes = {first:target.firstElementChild, svg:target.querySelector('svg'),
path:target.querySelector('.network-rx-line')};
window.testInterfaces = items;
window.scrollTo(0,Math.round(network.getBoundingClientRect().top+window.scrollY+180));
return {processHeight:panel.getBoundingClientRect().height, y:scrollY,
network:network.getBoundingClientRect().top, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight};
}''')
assert baseline['y'] > 0 and baseline['count'] == 2, baseline
count = page.evaluate('''() => {
const target = document.getElementById('network-interface-charts');
let mutations=0;
const obs = new MutationObserver(list=>mutations+=list.filter(item=>item.type==='childList').length);
obs.observe(target,{childList:true,subtree:true});
const beforeY=scrollY;
const beforeHeight=document.documentElement.scrollHeight;
const beforeProcess=document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height;
for(let i=0;i<45;i++){
renderNetworkInterfaceCharts([{name:'ens18',rx_bytes_per_second:i*109,tx_bytes_per_second:12},
{name:'lo',rx_bytes_per_second:i*2,tx_bytes_per_second:3}],true);
if(target.firstElementChild!==window.testNodes.first ||
target.querySelector('svg')!==window.testNodes.svg ||
target.querySelector('.network-rx-line')!==window.testNodes.path) throw Error('Network DOM replaced');
}
obs.takeRecords().forEach(r=> {if(r.type==='childList')mutations++});obs.disconnect();
return {mutations, y:scrollY, beforeY, count:target.children.length,
scrollHeight:document.documentElement.scrollHeight, beforeHeight,
processHeight:document.querySelector('[data-dashboard-widget="process_diagnostics"]').getBoundingClientRect().height, beforeProcess,
changed:target.querySelector('.network-interface-rate').textContent.includes('4796')};
}''')
assert count['mutations'] == 0, count
assert abs(count['y'] - count['beforeY']) < 2, count
assert count['count'] == 2 and count['changed'], count
assert abs(count['scrollHeight'] - count['beforeHeight']) < 2, count
assert abs(count['processHeight'] - count['beforeProcess']) < 2, count
print('PASS: 45 updates preserve SVG nodes, Process height, document height and scroll')
# Interface charts advance at every Overview live tick like the aggregate
# network chart, repeating the last observed rate until system-info updates.
# A system-info-only refresh or Hide/Show redraw never adds history points.
history = page.evaluate('''() => {
networkInterfaceSamples.clear();
const one=[{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200}];
renderNetworkInterfaceCharts(one,true);
const before=networkInterfaceSamples.get('ens18').rx.length;
for(let i=0;i<45;i++) renderNetworkInterfaceCharts(one,true);
const live=networkInterfaceSamples.get('ens18').rx.length;
const measured=[{name:'ens18',rx_bytes_per_second:500,tx_bytes_per_second:250}];
renderNetworkInterfaceCharts(measured,false); // only new system-info data
const afterSystem=networkInterfaceSamples.get('ens18').rx.length;
const label=document.querySelector('.network-interface-rate').textContent;
renderNetworkInterfaceCharts(measured,true); // next live tick
const afterLive=networkInterfaceSamples.get('ens18').rx.length;
const newest=networkInterfaceSamples.get('ens18').rx.at(-1);
return {before,live,afterSystem,afterLive,label,newest};
}''')
assert history['before'] == 1 and history['live'] == 46, history
assert history['afterSystem'] == 46 and history['afterLive'] == 47, history
assert history['newest'] == 500 and '500 B' in history['label'], history
assert 'renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);' in source
refresh = (WEB / 'app/refresh.js').read_text()
assert 'interval: () => options.system_info_seconds' in refresh
assert 'renderDiagnostics(latestStatus, false, true);' in refresh
print('PASS: frequent interface chart history, configured system-info cadence, no extra samples on system-only redraw')
page.evaluate('''() => renderNetworkInterfaceCharts([
{name:'ens18',rx_bytes_per_second:100,tx_bytes_per_second:200},
{name:'lo',rx_bytes_per_second:3,tx_bytes_per_second:3}],false)''')
# The Hide / Show controls retain the newest sample on click, not the array
# captured when a chart node was created.
page.locator('.network-interface-hide').first.click()
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-hidden button').count() == 1
page.locator('.network-interface-hidden button').click()
assert page.locator('.network-interface-row').count() == 2
assert page.locator('.network-interface-hidden').count() == 0
page.evaluate('''() => renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:9,tx_bytes_per_second:11}],true)''')
assert page.locator('.network-interface-row').count() == 1
assert page.locator('.network-interface-row').first.get_attribute('data-interface-name') == 'eth0'
page.evaluate('''() => renderNetworkInterfaceCharts([],true)''')
assert page.locator('.network-interface-empty').count() == 1
print('PASS: Hide/Show actions, hotplug/removal and empty state still work')
browser.close()
+10
View File
@@ -23,6 +23,16 @@ assert 'if (ordered.some((node, i) => node !== previous[i])) grid.append(...orde
assert html.count('data-dashboard-widget=') >= 62
assert 'function renderUpstreamProtocolBadges(status)' in render
assert 'const VHOST_PROTOCOL_FRESH_MS = 5 * 60 * 1000' in render
# Protocol badges live in the existing Vhost policy-pill group, not above the
# live traffic metrics. The class and observed-color state remain unchanged.
policy_start=render.index('const policy = el("div", undefined, "vhost-policy-pills");')
policy_end=render.index('if (policy.children.length) box.append(policy);', policy_start)
policy_block=render[policy_start:policy_end]
assert 'policy.append(badge);' in policy_block
assert 'vhost-backend-protocol-badge' in policy_block
assert 'box.append(badges)' not in render
assert '.vhost-backend-protocol-badge[data-observed="true"]' in css
assert 'color: var(--green)' in css[css.index('.vhost-backend-protocol-badge[data-observed="true"]'):]
assert 'self.inner.observe_protocol(value.version())' in client
assert '"upstream_protocol_observed"' in pool
print('PASS: mobile grid, shape, stable reorder and observed backend protocol source contracts')
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Render real Vhost cards with mock config/status to verify badge placement/color.
Headless Chromium fixture only: does not verify authenticated Rust runtime/ALPN.
"""
from pathlib import Path
import shutil
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / 'src/admin/web'
source = (WEB / 'vhosts/render.js').read_text()
start = source.index('const VHOST_PROTOCOL_FRESH_MS = ')
end = source.index('function backendTrafficRate(', start)
renderer = source[start:end]
css = (WEB / 'style.css').read_text()
html = (f'<!doctype html><html data-theme="ultra_dark" data-corner-style="rounded">'
f'<head><meta name="viewport" content="width=device-width,initial-scale=1">'
f'<style>{css}</style></head><body><section id="panel-vhosts">'
f'<div id="backend-diagnostics-card"></div><input id="vhost-search">'
f'<div id="vhost-list"></div><span id="vhost-editing"></span>'
f'</section></body></html>')
stubs = r'''
const $ = id => document.getElementById(id);
const t = key => key;
const locale = () => 'en-US';
const el = (tag, value, cls) => {
const node=document.createElement(tag);
if (value !== undefined) node.textContent=value;
if (cls) node.className=cls;
return node;
};
const text = (tag, key, cls) => el(tag, t(key), cls);
const action = (name, fn) => {
const btn=el('button', name, 'quiet');
btn.addEventListener('click', fn);
return btn;
};
const button = (name, fn, cls) => {
const btn=action(name, fn);
if (cls) btn.className=cls;
return btn;
};
const initialized=true;
let editing=null, latestStatus=null;
const vhostListPrefs={sort:'id',descending:false};
const envelope={config:{listeners:{endpoints:[]},vhosts:[
{id:'proxy-test',enabled:true,frontend:{hosts:['proxy.example'],listener_ids:[]},
source:{type:'reverse_proxy'},force_ssl:true,websockets:true,
backends:[{id:'origin1',protocol:'auto'}, {id:'origin2',protocol:'http1'}]},
{id:'static-test',enabled:true,frontend:{hosts:['static.example'],listener_ids:[]},
source:{type:'web_server',web_service_id:'public'},
backends:[{id:'unused',protocol:'auto'}]},
]}};
'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path=shutil.which('chromium'), args=['--no-sandbox'])
for width in (393, 1024):
page = browser.new_page(viewport={'width': width, 'height': 844})
page.set_content(html, wait_until='domcontentloaded')
page.add_script_tag(content=stubs + renderer + '\nrender();')
initial = page.evaluate('''() => {
const card=document.querySelector('[data-vhost-card="proxy-test"]');
const badge=card.querySelector('.vhost-backend-protocol-badge');
const group=card.querySelector('.vhost-policy-pills');
const staticCard=document.querySelector('[data-vhost-card="static-test"]');
return {badges:group.querySelectorAll('.vhost-backend-protocol-badge').length,
otherPills:group.querySelectorAll('.pill').length,
parent:badge.parentElement===group,
lastChild:card.lastElementChild===group,
separateRows:card.querySelectorAll('.vhost-backend-protocols').length,
staticBadges:staticCard.querySelectorAll('.vhost-backend-protocol-badge').length,
initialText:badge.textContent,
scroll:document.documentElement.scrollWidth,screen:innerWidth};
}''')
assert initial['badges'] == 2 and initial['otherPills'] == 2, initial
assert initial['parent'] and initial['lastChild'], initial
assert initial['separateRows'] == 0 and initial['staticBadges'] == 0, initial
assert initial['initialText'] == 'origin1 · AUTO → ?', initial
assert initial['scroll'] <= initial['screen'] + 2, initial
appearance = page.evaluate('''() => {
const badge=document.querySelector('[data-upstream-protocol-backend="origin1"]');
const expected=token=>{const n=document.createElement('span');n.style.color=`var(${token})`;
document.body.append(n);const c=getComputedStyle(n).color;n.remove();return c;};
const unknown=getComputedStyle(badge).color;
renderUpstreamProtocolBadges({backend_pools:[{id:'proxy-test',members:[
{id:'origin1',upstream_protocol_observed:'h2',upstream_protocol_last_seen_ms:Date.now()-1000},
{id:'origin2',upstream_protocol_observed:'h1',upstream_protocol_last_seen_ms:Date.now()-1000}
]}]});
const h2={text:badge.textContent,observed:badge.dataset.observed,
color:getComputedStyle(badge).color,expected:expected('--green')};
return {unknown, expectedUnknown:expected('--muted'), h2};
}''')
assert appearance['unknown'] == appearance['expectedUnknown'], (width, appearance)
assert appearance['h2']['color'] == appearance['h2']['expected'], (width, appearance)
assert appearance['h2']['observed'] == 'true' and appearance['h2']['text'] == 'origin1 · AUTO → H2', appearance
print(f'PASS: {width}px Vhost protocol badges share policy group and preserve green/neutral states')
page.close()
browser.close()