v0.33.3
This commit is contained in:
1 parent
5a36bb0c4b
commit
78ad28ef4d
27 files changed
+1448
-234
No files matched your search
+109
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
Reference in new issue
Block a user