v0.32.6
This commit is contained in:
1 parent
e3de084149
commit
e1ac899ec0
14 files changed
+480
-77
No files matched your search
Executable
+148
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user