Files
RustProxy/scripts/tests/sidebar_nav_0333_browser_test.py
T
2026-10-15 13:00:44 +02:00

116 lines
6.8 KiB
Python
Executable File

#!/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')