This commit is contained in:
Mateusz Gruszczyński committed 2026-10-15 13:00:44 +02:00
1 parent 5a36bb0c4b
commit 78ad28ef4d
27 files changed
+1448 -234

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Real-browser regression for Overview chart content when card width changes.
Runs the production HTML/CSS/assembled admin JS against a simulated DOM and
fake network-interface samples. This does not exercise Rust HTTP/SSE.
"""
import json
import re
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
scripts = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
scripts.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
scripts.append('(() => {\n' + '\n'.join(
(ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
scripts.append(json.loads(line[:-1]))
return '\n'.join(scripts)
def inspect(page):
return page.evaluate('''() => {
const selectors = {
'websocket_protocol':'[data-dashboard-widget="websocket_protocol"] svg',
'wide_transform_activity':'[data-dashboard-widget="wide_transform_activity"] svg',
'www_requests':'[data-dashboard-widget="www_requests"] svg',
'traffic_chart':'[data-dashboard-widget="traffic_chart"] svg',
'system_chart':'[data-dashboard-widget="system_chart"] svg',
'cache_diagnostics':'[data-dashboard-widget="cache_diagnostics"] .mini-live-chart svg',
'network_diagnostics':'[data-dashboard-widget="network_diagnostics"] .mini-live-chart svg',
'network_interface':'[data-dashboard-widget="network_diagnostics"] .network-interface-row svg',
};
return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => {
const svg = document.querySelector(selector);
if (!svg) throw new Error('Missing SVG: ' + name);
const box = svg.getBoundingClientRect();
const matrix = svg.getScreenCTM();
const viewBox = svg.viewBox.baseVal;
const contentWidth = Math.hypot(matrix.a, matrix.b) * viewBox.width;
const series = svg.querySelector('.overview-wide-series');
return [name, {width:box.width, contentWidth,
preservation:svg.getAttribute('preserveAspectRatio'),
lineStroke: series ? getComputedStyle(series).vectorEffect : null}];
}));
}''')
with sync_playwright() as browser_api:
browser = browser_api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for screen_width in (320, 375, 800, 1440, 1920):
page = browser.new_page(viewport={'width': screen_width, 'height': 920})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=app_bundle())
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
currentPanel = 'overview';
renderNetworkInterfaceCharts([{name:'eth0',rx_bytes_per_second:12500,tx_bytes_per_second:5200}], true);
}''')
ids = ['websocket_protocol', 'wide_transform_activity', 'www_requests', 'traffic_chart',
'system_chart', 'cache_diagnostics', 'network_diagnostics']
results = []
for span in (3, 12):
page.evaluate('''({ids,span}) => {
const hidden = ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id));
const sizes = Object.fromEntries(ids.map(id => [id, span]));
applyDashboardLayout([...ids, ...hidden], hidden, sizes);
}''', {'ids': ids, 'span': span})
measured = inspect(page)
results.append(measured)
for chart, value in measured.items():
assert value['width'] > 50, (screen_width, span, chart, value)
assert abs(value['width'] - value['contentWidth']) <= 2, (
'chart not filling its own SVG viewport', screen_width, span, chart, value)
assert value['preservation'] == 'none', (screen_width, chart, value)
if value['lineStroke'] is not None:
assert value['lineStroke'] == 'non-scaling-stroke', (chart, value)
if screen_width >= 1440:
for name in results[0]:
assert results[1][name]['width'] > results[0][name]['width'] * 1.4, (
'chart does not grow when widening card', screen_width, name, results)
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, screen_width
assert not errors, (screen_width, errors)
print('PASS SVG chart uses full width at spans 3 and 12:', screen_width)
page.close()
browser.close()
print('PASS resizable Overview chart content regression')
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Regression: Overview visual layout editor fits without horizontal overflow.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences.
This is browser layout testing, not Rust server acceptance.
"""
import json
import re
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {
name: {'id': name, **json.loads((ROOT / 'langs' / (name + '.json')).read_text())}
for name in ('en_US', 'pl_pl')
}
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
BUNDLE = app_bundle()
def load(page):
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyDashboardLayout')
page.evaluate('''packs => {
languagePacks = new Map(Object.entries(packs));
defaultLanguage = 'en_US';
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''', PACKS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.querySelectorAll('#overview-layout-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''')
def geometry(page):
return page.evaluate('''() => {
const d = document.getElementById('overview-layout-dialog');
const grid = document.getElementById('overview-layout-visible');
const rect = node => node.getBoundingClientRect();
const tiles = Array.from(grid.querySelectorAll('.overview-layout-tile'));
const overflows = [];
for (const tile of tiles) {
const r = rect(tile);
const toolbar = tile.querySelector('.overview-layout-tile-toolbar');
if (tile.scrollWidth > tile.clientWidth + 2)
overflows.push([tile.dataset.previewWidget, 'tile-scroll', tile.clientWidth, tile.scrollWidth]);
for (const button of toolbar.querySelectorAll('button')) {
const b = rect(button);
if (b.left < r.left - 2 || b.right > r.right + 2)
overflows.push([tile.dataset.previewWidget, 'button-outside', button.className, b.left, b.right, r.left, r.right]);
}
const buttons = Array.from(toolbar.querySelectorAll('button'));
for (let i = 0; i < buttons.length; i++) {
const a = rect(buttons[i]);
for (let j = i + 1; j < buttons.length; j++) {
const b = rect(buttons[j]);
if (a.right - b.left > 1 && b.right - a.left > 1 && a.bottom - b.top > 1 && b.bottom - a.top > 1)
overflows.push([tile.dataset.previewWidget, 'overlapping-buttons', buttons[i].className, buttons[j].className]);
}
}
}
const hiddenList = document.getElementById('overview-layout-hidden');
for (const row of hiddenList.querySelectorAll('.overview-layout-hidden-item')) {
const r = rect(row), button = row.querySelector('button');
const b = rect(button);
if (b.right > r.right + 2 || b.left < r.left - 2)
overflows.push(['hidden card', 'button-outside', b.left, b.right, r.left, r.right]);
}
const panelPreview = grid.querySelector('[data-preview-widget="network_diagnostics"]');
const dialogRect = rect(d);
return {
cols: getComputedStyle(grid).gridTemplateColumns.split(' ').length,
panelWidth: panelPreview ? rect(panelPreview).width : null,
dialogWidth: d.clientWidth, dialogScrollWidth: d.scrollWidth,
gridWidth: grid.clientWidth, gridScrollWidth: grid.scrollWidth,
dialogLeft:dialogRect.left, dialogRight:dialogRect.right,
visibleCount:tiles.length, overflows,
firstTileWidth:rect(tiles[0]).width,
};
}''')
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 550, 620, 800, 900, 960, 1158, 1440, 1920):
context = browser.new_context(viewport={'width': width, 'height': 823})
page = context.new_page()
errors = []
page.on('pageerror', lambda err: errors.append(str(err)))
load(page)
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
check = geometry(page)
expected = 1 if width <= 620 else 2 if width <= 900 else 3
assert check['cols'] == expected, (width, check)
assert check['visibleCount'] > 20
assert check['panelWidth'] is not None
assert abs(check['firstTileWidth'] - check['panelWidth']) < 3, (width, check)
assert check['dialogLeft'] >= -1 and check['dialogRight'] <= width + 1, (width, check)
assert check['dialogScrollWidth'] <= check['dialogWidth'] + 2, (width, check)
assert check['gridScrollWidth'] <= check['gridWidth'] + 2, (width, check)
assert not check['overflows'], (width, check['overflows'][:20])
tile = '#overview-layout-visible [data-preview-widget="http_protocol"]'
before = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width')
page.locator(tile + ' .overview-layout-widen').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
after = page.locator(tile).evaluate('(el) => el.getBoundingClientRect().width')
assert abs(before - after) < 2, (width, before, after)
assert not geometry(page)['overflows']
page.locator(tile + ' .overview-layout-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '2'
if width == 1158:
page.screenshot(path=str(ROOT.parent / 'modal_preview_1158.png'))
if width == 375:
page.screenshot(path=str(ROOT.parent / 'modal_preview_375.png'))
page.locator(tile + ' .overview-layout-hide').click()
assert page.locator(tile).count() == 0
assert not geometry(page)['overflows']
page.locator('#overview-layout-cancel').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
assert not errors, (width, errors)
context.close()
print('PASS visual modal grid, no overlap/scroll, width/hide/cancel:', width)
browser.close()
print('PASS 0.33.3 modal regression (simulated API)')
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""0.33.1 factory/default + unsaved layout regression against the full bundled JS.
The authenticated app bundle runs against simulated status/preferences; this is
not a Rust server integration test.
"""
import json
import re
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
PRESET = json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())
REGISTRY = json.loads((ROOT / 'src/admin/web/dashboard-widgets.json').read_text())
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
def app_bundle():
match = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert match
body = ROUTER[match.end():]
body = body[:re.search(r'\]\s*\.join\("\\n"\)', body).start()]
result = []
for line in body.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
result.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
result.append('(() => {\n' + '\n'.join((ROOT / 'src/admin/web/vhosts' / (name + '.js')).read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
assert len(PRESET['dashboard_widgets']) == len(REGISTRY)
assert set(PRESET['dashboard_widgets']) == {w['id'] for w in REGISTRY}
assert len(set(PRESET['dashboard_hidden_widgets'])) == len(PRESET['dashboard_hidden_widgets'])
assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_widgets']))
assert PRESET['dashboard_density'] == 'compact' and PRESET['overview_samples'] == 600
assert sum(w['id'].startswith('www_') and w['id'] in PRESET['dashboard_hidden_widgets'] for w in REGISTRY) == 12
HTML = (ROOT / 'src/admin/web/index.html').read_text()
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
def load(page):
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
page.add_script_tag(content=BUNDLE)
page.wait_for_function('window.rustProxyInlineLayout && window.rustProxyPreferences && window.rustProxyBackendDashboard')
def inspect(page, ids):
return page.evaluate('''ids => Object.fromEntries(ids.map(id => {
const node = document.querySelector(`[data-dashboard-widget="${id}"]`);
return [id, {hidden:node.hidden, display:getComputedStyle(node).display}];
}))''', ids)
def assert_visible(page, visible, hidden):
result = inspect(page, visible + hidden)
for widget in visible:
assert result[widget]['hidden'] is False and result[widget]['display'] != 'none', (widget, result)
for widget in hidden:
assert result[widget]['hidden'] is True and result[widget]['display'] == 'none', (widget, result)
# Test actual production CSS/JS in a browser with simulated preferences/status.
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in [320, 375, 800, 1440, 1920]:
context = browser.new_context(viewport={'width': width, 'height': 1000})
page = context.new_page()
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
load(page)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('workspace').hidden = false;
document.getElementById('panel-overview').hidden = false;
document.getElementById('overview-layout-unlock').hidden = false;
document.getElementById('overview-layout-open').hidden = false;
currentPanel = 'overview';
window.__savedDashboard = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.rustProxyPreferences.load = async () => window.__savedDashboard;
window.rustProxyPreferences.current = () => window.__savedDashboard;
window.rustProxyPreferences.update = async patch => {
window.__savedDashboard = {...window.__savedDashboard, ...patch};
applyAdminPreferences(window.__savedDashboard);
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
}''')
# Unchanged previous accounts preserve historic grouped rows.
assert page.evaluate('''() => ({
legacy: document.getElementById('dashboard-overview-grid').dataset.overviewLayoutMode,
widths:window.__savedDashboard.dashboard_widget_sizes,
})''') == {'legacy':'legacy','widths':None}
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
# Widen a number+sparkline card from 2 to 3 and then 4 columns.
selector = '[data-dashboard-widget="http_protocol"]'
old_width = page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width')
page.locator(selector + ' .overview-inline-widen').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '3'
page.locator(selector + ' .overview-inline-widen').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
assert page.locator(selector).evaluate('(node) => node.getBoundingClientRect().width') >= old_width * 1.7
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().sizes.http_protocol') == 4
# Minimize a large multi-series chart to the new 3-column option.
for _ in range(2):
page.locator('[data-dashboard-widget="wide_transform_activity"] .overview-inline-narrow').click()
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
# Real UI controls allow crossing old groups: send mini-chart to the front
# with the upward arrow, then across the original stat/chart boundary.
# A direct drag onto the first card also uses the actual event handlers.
page.evaluate('''() => {
const from = document.querySelector('[data-dashboard-widget="wide_transform_activity"] .overview-inline-grip');
const to = document.querySelector('[data-dashboard-widget="requests"] .overview-inline-grip');
const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true, cancelable:true, dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true, cancelable:true, dataTransfer:transfer, clientX:0}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
assert page.evaluate('window.rustProxyInlineLayout.currentDraft().order[0]') == 'wide_transform_activity'
# Background status and unrelated preference changes cannot reset live sizes.
page.evaluate('''() => {
const status={revision:5,backend_pools:[],streams:{active:[],desired:[],metrics:{}}};
window.rustProxyBackendDashboard.render(status);
window.rustProxyStreamDashboard.render(status);
applyAdminPreferences({...window.__savedDashboard, chart_style:'soft'});
}''')
assert page.locator(selector).get_attribute('data-dashboard-span') == '4'
# Cancel should remove both size and layout changes.
page.locator('#overview-inline-cancel').click()
assert page.locator(selector).get_attribute('data-dashboard-span') == '2'
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '6'
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
# In a fresh draft, the modal editor also provides width controls.
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
tile = '#overview-layout-visible [data-preview-widget="wide_transform_activity"]'
page.locator(tile + ' .overview-layout-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '4'
page.locator(tile + ' .overview-layout-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '3'
# Modal drag-and-drop also works across former small/wide size groups.
page.evaluate('''() => {
const from = document.querySelector('[data-preview-widget="wide_transform_activity"]');
const to = document.querySelector('[data-preview-widget="requests"]');
const transfer = new DataTransfer();
from.dispatchEvent(new DragEvent('dragstart', {bubbles:true,cancelable:true,dataTransfer:transfer}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
# Reload account settings: new card width persists.
page.evaluate('applyAdminPreferences(window.__savedDashboard)')
assert page.locator('[data-dashboard-widget="wide_transform_activity"]').get_attribute('data-dashboard-span') == '3'
assert page.evaluate('window.__savedDashboard.dashboard_density') == 'compact'
# Four charts in one row, set through the same layout function used by UI.
data = page.evaluate('''() => {
const ids=['wide_transform_activity','wide_compression_activity','http_protocol','websocket_protocol'];
const order=[...ids,...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
const hidden=[...ADMIN_DASHBOARD_WIDGETS.filter(id => !ids.includes(id))];
const sizes=Object.fromEntries(ids.map(id => [id,3]));
applyDashboardLayout(order,hidden,sizes);
const grid=document.getElementById('dashboard-overview-grid');
return { gridWidth:grid.getBoundingClientRect().width,
template:getComputedStyle(grid).gridTemplateColumns.split(' ').length,
boxes:ids.map(id=>{const b=document.querySelector(`[data-dashboard-widget="${id}"]`).getBoundingClientRect();return {id,x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),scroll:b.width};})
};
}''')
if data['template'] == 12:
assert len(set(b['y'] for b in data['boxes'])) == 1, (width, data)
assert all(b['w'] > 150 for b in data['boxes']), (width,data)
assert page.evaluate('document.documentElement.scrollWidth - window.innerWidth') <= 2, (width, data)
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-layout-dialog').open")
page.locator('#overview-layout-default').click()
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes') is None
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('document.getElementById("dashboard-overview-grid").dataset.overviewLayoutMode') == 'legacy'
assert not errors, (width, errors)
print('PASS free-order, resize, live refresh, cancel/save, four chart grid', width, data['template'])
context.close()
browser.close()
print('PASS 0.33.3 resizable Overview browser regression')
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Verify sidebar colors use only the UI theme; chart palettes affect charts only.
Uses actual production HTML/CSS in headless Chromium, not a live RustProxy
server or authenticated preference/session roundtrip.
"""
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
THEMES = ('dark', 'ultra_dark', 'light', 'cream')
PALETTES = ('default', 'soft', 'technical', 'high_contrast', 'neon', 'monochrome', 'warm', 'cool')
CORNERS = {'default': 0, 'square': 0, 'subtle': 4, 'rounded': 8, 'extra': 12}
STYLE_INFO = r'''() => {
const color = value => {
const c = document.createElement('canvas'); c.width = c.height = 1;
const ctx = c.getContext('2d'); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1);
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
};
const root = getComputedStyle(document.documentElement);
const active = document.querySelector('.sidebar .main-navigation .nav.active');
const idle = document.querySelector('.sidebar .main-navigation .nav:not(.active)');
const sa = getComputedStyle(active), si = getComputedStyle(idle);
return {
chartAccent:color(root.getPropertyValue('--chart-accent').trim()),
themeSelected:color(root.getPropertyValue('--nav-active-bg').trim()),
themeHovered:color(root.getPropertyValue('--nav-hover-bg').trim()),
foreground:color(sa.color),
background:color(sa.backgroundColor),
borderLeft:color(sa.borderLeftColor),
borderRight:color(sa.borderRightColor),
radius:parseFloat(sa.borderTopLeftRadius),
idleBackground:color(si.backgroundColor),
mobileBackground:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).backgroundColor),
mobileColor:color(getComputedStyle(document.querySelector('.mobile-menu-toggle')).color),
navWidth:document.querySelector('.main-navigation').scrollWidth,
navClientWidth:document.querySelector('.main-navigation').clientWidth,
activeWidth:active.scrollWidth,
activeClientWidth:active.clientWidth,
};
}'''
def contrast(fg, bg):
def luminance(rgb):
channels = [x / 255 for x in rgb]
channels = [x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4 for x in channels]
return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]
lo, hi = sorted((luminance(fg), luminance(bg)))
return (hi + .05) / (lo + .05)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for viewport in (375, 1200):
page = browser.new_page(viewport={'width': viewport, 'height': 820})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(HTML.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', '').replace('/assets/app___RUSTPROXY_ASSET_ID__.js', ''))
page.add_style_tag(content=CSS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading');
document.getElementById('app-startup').remove();
document.querySelector('.sidebar').classList.add('mobile-nav-open');
document.querySelector('.mobile-menu-toggle')?.setAttribute('aria-expanded', 'true');
}''')
theme_colors = []
for theme in THEMES:
baseline = None
chart_accents = set()
for palette in PALETTES:
page.evaluate('''([theme, palette]) => {
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.chartStyle = palette;
}''', [theme, palette])
for corner, expected_radius in CORNERS.items():
page.evaluate('corner => document.documentElement.dataset.cornerStyle = corner', corner)
style = page.evaluate(STYLE_INFO)
assert style['radius'] == expected_radius, (viewport, theme, palette, corner, style['radius'])
assert style['borderLeft'] == style['foreground'], (viewport, theme, palette, corner, style)
assert style['background'] == style['themeSelected'], (viewport, theme, palette, corner, style)
if viewport == 375:
assert style['mobileBackground'] == style['background'], (viewport, theme, palette, corner, style)
assert style['mobileColor'] == style['foreground'], (viewport, theme, palette, corner, style)
assert contrast(style['foreground'], style['background']) >= 4.5, (viewport, theme, palette, corner, style)
assert style['navWidth'] <= style['navClientWidth'] + 2, (viewport, theme, palette, corner, style)
assert style['activeWidth'] <= style['activeClientWidth'] + 2, (viewport, theme, palette, corner, style)
chart_accents.add(tuple(style['chartAccent']))
# The entire selected state is unaffected by chart-style changes.
nav_colors = (tuple(style['foreground']), tuple(style['background']), tuple(style['borderLeft']))
if baseline is None:
baseline = nav_colors
else:
assert baseline == nav_colors, (viewport, theme, palette, baseline, nav_colors)
page.locator('.nav[data-panel="vhosts"]').hover()
hovered = page.evaluate(STYLE_INFO)
assert hovered['idleBackground'] == hovered['themeHovered'], (viewport, theme, palette, hovered)
assert hovered['idleBackground'] != style['idleBackground'], (viewport, theme, palette)
page.mouse.move(0, 0)
if viewport == 1200 and theme == 'ultra_dark' and palette == 'cool':
page.screenshot(path=str(ROOT.parent / 'sidebar_preview_desktop.png'))
if viewport == 375 and theme == 'dark' and palette == 'soft':
page.screenshot(path=str(ROOT.parent / 'sidebar_preview_mobile.png'))
assert len(chart_accents) > 1, (viewport, theme, chart_accents)
theme_colors.append(baseline)
assert len(set(theme_colors)) == len(THEMES), (viewport, theme_colors)
page.evaluate("document.documentElement.dir = 'rtl'")
rtl = page.evaluate(STYLE_INFO)
assert rtl['borderRight'] == rtl['foreground'], (viewport, rtl)
assert not errors, errors
print('PASS themed navigation independence: viewport', viewport, 'themes', len(THEMES), 'palettes', len(PALETTES), 'corners', len(CORNERS))
page.close()
browser.close()
print('PASS: sidebar changes with UI theme and corner setting, never with chart style')