This commit is contained in:
Mateusz Gruszczyński committed 2026-10-16 13:02:53 +02:00
1 parent 162e986d37
commit 6b937aaf85
54 files changed
+3527 -1325

No files matched your search

+10
View File
@@ -89,6 +89,16 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
for doc_path in sorted(embedded_docs):
if not (root / doc_path).is_file():
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
# An included Rust source fragment cannot begin with an inner doc comment.
# Catch E0753 here so a source-only release does not ship an unbuildable
# admin module when a compiler is unavailable during packaging.
admin_mod = (root / "src/admin/mod.rs").read_text(encoding="utf-8")
if 'include!("dashboard_presets.rs");' in admin_mod:
fragment = root / "src/admin/dashboard_presets.rs"
if not fragment.is_file():
raise ValueError(f"Missing included module: {fragment.relative_to(root)}")
if fragment.read_text(encoding="utf-8").lstrip().startswith(("//!", "/*!")):
raise ValueError("src/admin/dashboard_presets.rs starts with an inner doc comment; use /// instead (Rust E0753)")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.9 entry point; extract the complete package, not individual files.")
@@ -4,12 +4,13 @@
Runs a simulated authenticated DOM rather than a live RustProxy service.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
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')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
ROUTER = (ROOT / 'src/admin/router.rs').read_text(encoding='utf-8')
@@ -76,7 +77,7 @@ with sync_playwright() as p:
noTrafficEyebrow: !traffic.querySelector('.small-label'),
noTrafficLiveBadge: !traffic.querySelector('.pill.live'),
noConnectionLiveBadge: !connection.querySelector('.pill.live'),
connectionTitleIntact: !!connection.querySelector('.card-heading .small-label[data-i18n="connectionActivity"]'),
connectionTitleIntact: !!connection.querySelector('.card-heading h3.overview-chart-heading[data-i18n="connectionActivity"]'),
peakInsideCard: peakRect.left >= cardRect.left - 1 && peakRect.right <= cardRect.right + 1,
peakInsideHeading: peakRect.top >= headingRect.top - 1 && peakRect.bottom <= headingRect.bottom + 1,
browserOverflow: document.documentElement.scrollWidth - innerWidth,
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""All chart titles and series labels in simulated Chromium (no live Rust server).
Covers Overview, diagnostics, Web Server, Traffic, Cache/Transform, Vhosts,
Streams, and DOM-created chart/legend variants. Preserve localized DOM text.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''(settings) => {
const { density, compactBody, theme } = settings;
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.dataset.theme = theme;
document.body.dataset.density = compactBody ? 'compact' : 'comfortable';
const titleSelectors = [
'#panel-overview .overview-wide-chart .card-heading h2',
'#panel-overview .activity-card .card-heading h2',
'#panel-overview :is(.system-card, .connection-card) .overview-chart-heading',
'#panel-overview :is(.stat-chart, .stat-donut) > span:first-child',
'.mini-live-chart > .mini-live-chart-head > span:first-child',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.vhost-chart-head > span:first-child',
];
const monoSelectors = [
'.overview-wide-summary', '.overview-wide-legend',
'.overview-donut-legend', '.www-method-legend', '.stat-chart-legend',
'.mini-chart-legend', '.connection-legend',
'.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend',
'.chart-footer', '.vhost-chart-head > :not(:first-child)',
'.mini-live-chart-head > b', '.mini-live-chart-head > .mini-live-chart-actions',
'.network-interface-rate',
'.function-live-chart-summary > b', '.function-live-chart-heading small',
'.function-live-chart-samples',
'#panel-overview .stat-chart > small',
'#panel-overview .overview-wide-chart > .fine',
'#panel-overview .www-top-row', '.traffic-bar-row',
'#panel-overview .system-card .card-heading h2',
'#panel-overview .system-card .card-heading .meta',
'#panel-overview .system-card .memory-line',
'#panel-overview .connection-card .card-heading h2',
];
const legendSelectors = [
'.overview-wide-legend', '.overview-donut-legend', '.www-ring-legend',
'.www-method-legend', '.stat-chart-legend', '.mini-chart-legend',
'.connection-legend', '.traffic-protocol-legend', '.traffic-chart-legend',
'.function-live-chart-legend', '.stream-chart-legend', '.stream-traffic-legend'
];
const legendNodes = [...document.querySelectorAll(legendSelectors.join(','))];
const requestLegend = document.querySelector('[data-dashboard-widget="traffic_chart"] .traffic-protocol-legend');
const referenceLegendSize = getComputedStyle(requestLegend).fontSize;
const titleNodes = Array.from(document.querySelectorAll(titleSelectors.join(',')));
const monoNodes = Array.from(document.querySelectorAll(monoSelectors.join(',')));
const base = document.createElement('div');
base.style.fontFamily = 'var(--mono)';
document.body.append(base);
const mono = getComputedStyle(base).fontFamily;
base.style.fontFamily = 'var(--sans)';
const sans = getComputedStyle(base).fontFamily;
base.remove();
const titleErrors = titleNodes.flatMap(node => {
const css = getComputedStyle(node);
const inOverview = !!node.closest('#panel-overview');
const isSmallCaption = inOverview && !!node.closest('.mini-live-chart-head');
const wantedFont = isSmallCaption ? mono : sans;
const wantedSize = isSmallCaption ? '10px' : '12px';
const wantedCase = inOverview ? 'none' : 'uppercase';
const problems = [];
if (css.fontFamily !== wantedFont) problems.push(`font=${css.fontFamily}`);
if (css.fontSize !== wantedSize) problems.push(`size=${css.fontSize}`);
if (css.textTransform !== wantedCase) problems.push(`case=${css.textTransform}`);
return problems.map(problem => `${node.outerHTML.slice(0, 100)}: ${problem}`);
});
const monoErrors = monoNodes.flatMap(node => {
const all = [node, ...node.querySelectorAll('*')].filter(el => el.tagName.toLowerCase() !== 'svg');
return all.filter(el => getComputedStyle(el).fontFamily !== mono)
.map(el => `${el.outerHTML.slice(0, 100)}: ${getComputedStyle(el).fontFamily}`);
});
const legendErrors = legendNodes.flatMap(root => {
const children = [root, ...root.querySelectorAll('span, b, strong, small, li, em')];
return children.flatMap(node => {
const style = getComputedStyle(node);
const issues = [];
if (style.fontSize !== referenceLegendSize) issues.push(`size=${style.fontSize}`);
if (style.fontFamily !== mono) issues.push(`font=${style.fontFamily}`);
return issues.map(issue => `${node.outerHTML.slice(0, 95)} ${issue}`);
});
});
// Keep localized Overview titles in sentence case; other sections retain
// their independent uppercase chart-heading convention.
const compression = document.querySelector('[data-dashboard-widget="wide_compression_activity"]');
const compressionHeading = compression.querySelector('.card-heading h2');
const compressionLegend = compression.querySelector('.overview-wide-legend');
const originalTranslation = compressionHeading.textContent.trim() === 'Aktywność kompresji';
const legendFonts = [...compressionLegend.querySelectorAll('span, b')]
.map(el => getComputedStyle(el).fontFamily);
const unrelatedHeading = document.querySelector('#panel-traffic .card-heading h2');
const numericReading = document.querySelector('#system-cpu');
return {
counts: {titles: titleNodes.length, mono: monoNodes.length, legends: legendNodes.length},
titleErrors, monoErrors, legendErrors, referenceLegendSize, mono, sans,
originalTranslation, compressionTitleSize: getComputedStyle(compressionHeading).fontSize,
compressionTitleCase: getComputedStyle(compressionHeading).textTransform,
compressionLegendIsMono: legendFonts.every(font => font === mono),
unrelatedTitleSize: getComputedStyle(unrelatedHeading).fontSize,
numericReadingIsMono: getComputedStyle(numericReading).fontFamily === mono,
};
}'''
with sync_playwright() as api:
browser = api.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 960})
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.evaluate('''() => {
// Representative dynamically rendered Vhost/Web Service/Stream charts.
for (const panel of ['panel-vhosts','panel-web-server','panel-streams']) {
document.getElementById(panel).insertAdjacentHTML('beforeend', `
<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connection history</span>
<span class="fine">42 req/s</span></div>
<svg viewBox="0 0 300 54"><path d=""></path></svg>
<div class="stream-chart-legend"><span>RX 2 MB/s</span><span>TX 1 MB/s</span></div>
</div>`);
}
const comp = document.querySelector('[data-dashboard-widget="wide_compression_activity"] .card-heading h2');
comp.textContent = 'Aktywność kompresji';
// The traffic renderer inserts these series dynamically.
document.querySelectorAll('.traffic-chart-legend').forEach(root => {
root.innerHTML = `<li><span class="traffic-chart-label">GET requests</span><b>12</b></li>`;
});
const sample = document.createElement('div');
sample.innerHTML = `<div class="www-top-row"><span>php-worker</span><b>11/s</b></div>
<div class="traffic-bar-row"><span class="traffic-bar-label">GET</span><b class="traffic-bar-value">22</b></div>
<div class="network-interface-rate">100 Mb/s</div>`;
document.getElementById('panel-overview').append(sample);
}''')
for density in ('normal','compact','ultra'):
for compact_body in (False, True):
for theme in ('dark', 'light'):
result = page.evaluate(CHECK, {'density': density, 'compactBody': compact_body, 'theme': theme})
description = (width, density, compact_body, theme)
assert result['counts']['titles'] >= 43, (description, result['counts'])
assert result['counts']['mono'] >= 90, (description, result['counts'])
assert not result['titleErrors'], (description, result['titleErrors'][:10])
assert not result['monoErrors'], (description, result['monoErrors'][:10])
assert result['counts']['legends'] >= 39, (description, result['counts'])
assert result['referenceLegendSize'] == '9px', description
assert not result['legendErrors'], (description, result['legendErrors'][:10])
assert result['originalTranslation'], description
assert result['compressionTitleSize'] == '12px', description
assert result['compressionTitleCase'] == 'none', description
assert result['compressionLegendIsMono'], description
assert result['numericReadingIsMono'], description
assert result['unrelatedTitleSize'] != '12px', (description, result['unrelatedTitleSize'])
print(f'PASS all chart families at {width}px (3 dashboard densities, 2 interface densities, 2 themes)')
page.close()
browser.close()
@@ -7,10 +7,11 @@ fake network-interface samples. This does not exercise Rust HTTP/SSE.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
@@ -1,16 +1,19 @@
#!/usr/bin/env python3
"""0.33.5 CPU plot autoscaling regression with production HTML/CSS/JS.
"""Legacy CPU plot regression: low and high real values on a fixed 0-100% axis.
Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
Autoscaling was intentionally replaced by 0.33.5 fixed scaling. This test
retains the original history/reset and label checks using the current scale.
Chromium is simulated, not a live RustProxy server.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
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')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
@@ -83,6 +86,7 @@ with sync_playwright() as p:
window.fmtBytes = value => `${value} B`;
window.rustProxyOverviewSamples = {getLimit: () => 60, onChange: () => {}};
}''')
page.add_script_tag(content=SCALE_JS)
page.add_script_tag(content=WIDE_JS)
for density in ('compact', 'normal', 'ultra'):
@@ -97,21 +101,21 @@ with sync_playwright() as p:
assert result['svgWidth'] > 100 and abs(result['svgWidth'] - result['drawnWidth']) < 2, result
assert result['legendInCard'] and result['overflow'] <= 2, (viewport, density, result)
# Measured 0.5-0.8% must be visible, not flattened by a 100% ceiling.
# Measured 0.5-0.8% stay numerically visible without axis auto-expansion.
first = render_cpu(page, 1, 0.5, 0.2)
second = render_cpu(page, 1, 0.8, 0.6)
assert first['system']['label'] == '0.5%' and second['process']['label'] == '0.6%'
assert ys(second['system']['path']) == [77, 41], (viewport, second)
assert ys(second['process']['path']) == [113, 65], (viewport, second)
assert ys(second['system']['path']) == [136, 136], (viewport, second)
assert ys(second['process']['path']) == [137, 136], (viewport, second)
# The peak follows observed history and also works above 100% (multi-core).
# Multi-core values display accurately but clip at the fixed 100% ceiling.
third = render_cpu(page, 1, 3.4, 2.2)
assert ys(third['system']['path'])[-1] == 17, (viewport, third)
assert ys(third['process']['path'])[-1] == 59, (viewport, third)
assert ys(third['system']['path'])[-1] == 133, (viewport, third)
assert ys(third['process']['path'])[-1] == 134, (viewport, third)
high = render_cpu(page, 1, 250, 123)
assert high['system']['label'] == '250.0%'
assert ys(high['system']['path'])[-1] == 17, (viewport, high)
assert 17 <= ys(high['process']['path'])[-1] < 137, (viewport, high)
assert ys(high['process']['path'])[-1] == 17, (viewport, high)
# Revision changes reset the history. Zero stays at the plot baseline.
idle = render_cpu(page, 2, 0, 0)
@@ -119,6 +123,6 @@ with sync_playwright() as p:
missing = render_cpu(page, 3, None, None)
assert missing['system']['path'] == '' and missing['process']['label'] == '-'
assert not errors, (viewport, errors)
print(f'PASS CPU auto Y-scale and standard plot height at {viewport}px (all densities)')
print(f'PASS legacy CPU labels/history on fixed Y-scale at {viewport}px (all densities)')
page.close()
browser.close()
@@ -5,11 +5,12 @@ Uses Chromium with simulated authenticated DOM and status samples, not a live
RustProxy server, SSE stream or real CPU telemetry.
"""
from pathlib import Path
from panel_html import render_admin_index
import re
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')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALE_JS = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
WIDE_JS = (ROOT / 'src/admin/web/app/overview-wide-metrics.js').read_text(encoding='utf-8')
@@ -7,6 +7,7 @@ not a Rust server integration test.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
@@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi
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()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
@@ -94,6 +95,8 @@ with sync_playwright() as p:
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
return {
order: ADMIN_DASHBOARD_WIDGETS,
hidden: ADMIN_DEFAULT_HIDDEN_WIDGETS,
@@ -166,13 +169,12 @@ with sync_playwright() as p:
window.rustProxyStreamDashboard.render(status);
}''')
assert_visible(page, ['backend_health'], ['backend_traffic'])
# Restore defaults in the modal after a custom layout was saved.
# Restore factory layout through the new named-presets modal.
page.locator('#overview-layout-open').evaluate('(node) => node.hidden = false')
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")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert_visible(page, ['backend_traffic'], ['backend_health', 'stream_sessions', 'www_status'])
assert page.evaluate('window.__savedDashboard.dashboard_widgets') == PRESET['dashboard_widgets']
assert page.evaluate('window.__savedDashboard.dashboard_hidden_widgets') == PRESET['dashboard_hidden_widgets']
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Deterministic Overview widget regression; simulated DOM/status, no running API.
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -6,7 +7,7 @@ import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const html = fs.readFileSync(path.join(root, 'src/admin/web/index.html'), 'utf8');
const html = renderAdminIndex(root);
const registry = JSON.parse(fs.readFileSync(path.join(root, 'src/admin/web/dashboard-widgets.json'), 'utf8'));
const lang = JSON.parse(fs.readFileSync(path.join(root, 'langs/en_US.json'), 'utf8')).translations;
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
@@ -1,16 +1,17 @@
#!/usr/bin/env python3
"""Regression: Overview visual layout editor fits without horizontal overflow.
"""Regression: named Overview presets, responsive modal and browser actions.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences.
Runs the real panel DOM/CSS/bundled JavaScript with mocked preferences/API.
This is browser layout testing, not Rust server acceptance.
"""
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {
@@ -52,7 +53,7 @@ def load(page):
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));
document.querySelectorAll('#overview-presets-dialog [data-i18n]').forEach(node => node.textContent = t(node.dataset.i18n));
}''', PACKS)
page.evaluate('''() => {
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
@@ -72,98 +73,104 @@ def load(page):
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));
document.querySelectorAll('#overview-presets-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,
};
}''')
# Regression for the replacement named-presets modal, with a simulated SQLite API.
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})
for width in (320, 375, 800, 1440):
context = browser.new_context(viewport={'width': width, 'height': 880})
page = context.new_page()
errors = []
page.on('pageerror', lambda err: errors.append(str(err)))
page.on('pageerror', lambda error: errors.append(str(error)))
load(page)
page.evaluate('''() => {
const saved = structuredClone(ADMIN_APPEARANCE_DEFAULTS);
window.__savedDashboard = saved;
window.__presets = new Map();
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;
};
api = async (path, options={}) => {
const method = options.method || 'GET';
if (path === '/api/dashboard-presets' && method === 'GET')
return [...window.__presets.entries()].map(([name, data]) => ({ name, ...structuredClone(data) }));
if (path.startsWith('/api/dashboard-presets/')) {
const name = decodeURIComponent(path.split('/').slice(3).join('/'));
if (method === 'PUT') { window.__presets.set(name, JSON.parse(options.body)); return {}; }
if (method === 'DELETE') { window.__presets.delete(name); return {}; }
if (method === 'PATCH') {
const changed = JSON.parse(options.body).name;
if (window.__presets.has(changed)) throw new Error('already exists');
window.__presets.set(changed, window.__presets.get(name));
window.__presets.delete(name);
return {};
}
}
throw new Error('unexpected API: ' + method + ' ' + path);
};
appConfirm = async () => true;
applyAdminPreferences(window.__savedDashboard);
document.getElementById('overview-layout-open').hidden = false;
}''')
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")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 0
widths = page.evaluate('''() => {
const d = document.getElementById('overview-presets-dialog');
const r = d.getBoundingClientRect();
return {left:r.left,right:r.right,overflow:d.scrollWidth - d.clientWidth};
}''')
assert widths['left'] >= -1 and widths['right'] <= width+1 and widths['overflow'] <= 2, (width, widths)
# Persist the factory arrangement as a user preset.
page.locator('#overview-presets-name').fill('Daily')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Daily')")
assert page.locator('#overview-presets-list .overview-presets-row').count() == 1
assert page.evaluate("window.__presets.get('Daily').overview_samples") == 600
# Create another view, with customized widths, density and history.
page.evaluate('''async () => {
await window.rustProxyPreferences.update({
dashboard_hidden_widgets:['requests'], dashboard_widget_sizes:{requests:3},
dashboard_density:'ultra',
});
window.rustProxyOverviewSamples.setLimit(120, true);
}''')
page.locator('#overview-presets-name').fill('Detailed')
page.locator('#overview-presets-save').click()
page.wait_for_function("window.__presets.has('Detailed')")
assert page.evaluate('''() => {
const p=window.__presets.get('Detailed');
return [p.dashboard_widget_sizes.requests,p.dashboard_density,p.overview_samples];
}''') == [3,'ultra',120]
# Named load must restore full layout and history.
page.locator('.overview-presets-row').filter(has_text='Daily').locator('[data-preset-action="dashboardPresetsLoad"]').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate('''() => [window.__savedDashboard.dashboard_density,
window.__savedDashboard.dashboard_widget_sizes,
window.rustProxyOverviewSamples.getLimit()]''') == ['compact',None,600]
# Rename and delete remain administrator-scoped operations.
page.locator('#overview-layout-open').click()
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-name').fill('Nightly')
page.locator('.overview-presets-row').filter(has_text='Detailed').locator('[data-preset-action="dashboardPresetsRename"]').click()
page.wait_for_function("window.__presets.has('Nightly')")
assert not page.evaluate("window.__presets.has('Detailed')")
page.locator('.overview-presets-row').filter(has_text='Nightly').locator('[data-preset-action="dashboardPresetsDelete"]').click()
page.wait_for_function("!window.__presets.has('Nightly')")
assert page.evaluate("window.__presets.has('Daily')")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-dialog').open")
assert page.evaluate("window.__savedDashboard.dashboard_widgets") == json.loads((ROOT / 'src/admin/web/dashboard-defaults.json').read_text())['dashboard_widgets']
assert not errors, (width, errors)
print('PASS named Dashboard presets save/load/rename/delete/factory and responsive modal:', width)
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)')
print('PASS Overview preset modal (simulated SQLite API; no running Rust server)')
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""All Overview widget labels and small values in simulated Chromium.
This is a DOM/CSS regression test, not a live RustProxy integration test.
"""
from pathlib import Path
import json
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
PACKS = {name: json.loads((ROOT / f'langs/{name}.json').read_text(encoding='utf-8'))['translations']
for name in ('en_US', 'pl_pl')}
CHECK = r'''({density, lang, translations}) => {
document.documentElement.dataset.dashboardDensity = density;
document.documentElement.lang = lang === 'pl_pl' ? 'pl-PL' : 'en-US';
const grid = document.getElementById('dashboard-overview-grid');
grid.querySelectorAll('[data-i18n]').forEach(node => {
if (Object.hasOwn(translations, node.dataset.i18n)) node.textContent = translations[node.dataset.i18n];
});
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const font = value => getComputedStyle(value).fontFamily;
const probe = document.createElement('div');
grid.append(probe);
probe.style.fontFamily = 'var(--sans)'; const sans = font(probe);
probe.style.fontFamily = 'var(--mono)'; const mono = font(probe);
probe.remove();
const titleErrors = [];
const titles = [];
const labelErrors = [];
const numericErrors = [];
for (const card of cards) {
let title;
if (card.classList.contains('stat')) title = card.querySelector(':scope > span:first-child');
else if (card.classList.contains('system-card') || card.classList.contains('connection-card'))
title = card.querySelector('.overview-chart-heading');
else if (card.querySelector('.mini-heading > h3')) title = card.querySelector('.mini-heading > h3');
else title = card.querySelector('.card-heading h2');
if (!title) { titleErrors.push(`${card.dataset.dashboardWidget}: missing title`); continue; }
const style = getComputedStyle(title);
titles.push(title);
if (style.fontFamily !== sans || style.fontSize !== '12px' || style.textTransform !== 'none')
titleErrors.push(`${card.dataset.dashboardWidget}: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
if (title.textContent.trim() === title.textContent.trim().toLocaleUpperCase('pl') &&
/[a-ząćęłńóśżź]{4}/i.test(title.textContent) &&
!/^(?:HIT|MISS|HTTP|CPU|RAM|WWW|RX|TX)/.test(title.textContent.trim()))
titleErrors.push(`${card.dataset.dashboardWidget}: all-uppercase title ${title.textContent.trim()}`);
for (const node of card.querySelectorAll(':scope > small, .mini-metrics dt, .mini-metrics dd, .mini-live-chart-head, .memory-line, .chart-footer, .meta, .small-label')) {
if (font(node) !== mono) labelErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const readings = card.classList.contains('stat')
? [...card.querySelectorAll(':scope > strong'), ...card.querySelectorAll(':scope > small')]
: [...card.querySelectorAll('.mini-metrics dd')];
for (const node of readings) if (font(node) !== mono)
numericErrors.push(`${card.dataset.dashboardWidget}: ${node.outerHTML.slice(0, 85)}`);
}
const nested = [...grid.querySelectorAll('.mini-live-chart > .mini-live-chart-head > span:first-child')];
for (const node of nested) {
const style = getComputedStyle(node);
if (style.fontFamily !== mono || style.fontSize !== '10px' || style.textTransform !== 'none')
labelErrors.push(`mini-live-chart: ${style.fontFamily} ${style.fontSize} ${style.textTransform}`);
}
const systemNumeric = getComputedStyle(document.querySelector('#system-cpu'));
return {
cardCount: cards.length, titleCount: titles.length, secondaryCount: nested.length,
titleErrors, labelErrors, numericErrors,
systemNumericMono: systemNumeric.fontFamily === mono,
cpuLabel: document.querySelector('[data-dashboard-widget="system_chart"] .overview-chart-heading').textContent,
connectionsLabel: document.querySelector('[data-dashboard-widget="connection_chart"] .overview-chart-heading').textContent,
memoryLabel: document.querySelector('[data-dashboard-widget="memory"] > span:first-child').textContent,
backendLabel: document.querySelector('[data-dashboard-widget="backend_health"] > span:first-child').textContent,
diagnosticsTitle: document.querySelector('[data-dashboard-widget="process_diagnostics"] .mini-heading h3').textContent,
};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440, 1920):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for density in ('normal', 'compact', 'ultra'):
for lang in ('en_US', 'pl_pl'):
result = page.evaluate(CHECK, {'density': density, 'lang': lang, 'translations': PACKS[lang]})
descriptor = (width, density, lang)
assert result['cardCount'] == result['titleCount'] >= 75, (descriptor, result)
assert result['secondaryCount'] >= 6, descriptor
assert not result['titleErrors'], (descriptor, result['titleErrors'][:10])
assert not result['labelErrors'], (descriptor, result['labelErrors'][:10])
assert not result['numericErrors'], (descriptor, result['numericErrors'][:10])
assert result['systemNumericMono'], descriptor
expected = ('System load', 'Connection activity') if lang == 'en_US' else ('Obciążenie systemu', 'Aktywność połączeń')
assert (result['cpuLabel'], result['connectionsLabel']) == expected, (descriptor, result)
assert result['memoryLabel'] and result['backendLabel'] and result['diagnosticsTitle'], descriptor
print(f'PASS all Overview titles and small readings at {width}px (3 densities, 2 languages)')
page.close()
browser.close()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Offline SQLite regression for additive admin preset migration and 16-slot upsert."""
from pathlib import Path
import sqlite3
ROOT = Path(__file__).resolve().parents[2]
all_scripts = sorted((ROOT / 'migrations').glob('*.sql'))
assert [p.name for p in all_scripts][-1] == '0010_dashboard_presets.sql'
con = sqlite3.connect(':memory:')
con.execute('PRAGMA foreign_keys = ON')
for _ in range(2):
for script in all_scripts:
con.executescript(script.read_text())
con.execute("INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','hash',1,0)")
con.execute("INSERT INTO admin_preferences(administrator_id,document) VALUES(1,'{}')")
upsert = '''
INSERT INTO dashboard_presets(administrator_id, name, document, updated_at)
SELECT a.id, ?, ?, ? FROM administrator a WHERE a.id = 1 AND a.username = ?
AND ((SELECT count(*) FROM dashboard_presets WHERE administrator_id = a.id) < 16
OR EXISTS(SELECT 1 FROM dashboard_presets WHERE administrator_id = a.id AND name = ?))
ON CONFLICT(administrator_id, name) DO UPDATE SET document = excluded.document, updated_at = excluded.updated_at
'''
for i in range(16):
name = f'Preset {i}'
assert con.execute(upsert, (name,'{}',i,'owner',name)).rowcount == 1
assert con.execute(upsert, ('Overflow','{}',16,'owner','Overflow')).rowcount == 0
assert con.execute(upsert, ('Preset 0','{"changed":true}',17,'owner','Preset 0')).rowcount == 1
assert con.execute(upsert, ('Other account','{}',18,'intruder','Other account')).rowcount == 0
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 16
assert con.execute('SELECT document FROM dashboard_presets WHERE name="Preset 0"').fetchone()[0] == '{"changed":true}'
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Preset 1','Preset 0')).rowcount == 0
assert con.execute('UPDATE OR IGNORE dashboard_presets SET name=? WHERE name=?', ('Renamed','Preset 0')).rowcount == 1
con.execute('DELETE FROM administrator WHERE id=1')
assert con.execute('SELECT count(*) FROM dashboard_presets').fetchone()[0] == 0
assert con.execute('SELECT count(*) FROM admin_preferences').fetchone()[0] == 0
print('PASS SQLite migrations repeated, named-preset CRUD, cap, account ownership and cascade')
@@ -7,6 +7,7 @@ not a Rust server integration test.
import json
import re
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
@@ -41,7 +42,7 @@ assert set(PRESET['dashboard_hidden_widgets']).issubset(set(PRESET['dashboard_wi
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()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
BUNDLE = app_bundle()
assert BUNDLE.index('const ADMIN_DASHBOARD_DEFAULTS =') < BUNDLE.index('window.rustProxyOverviewSamples =')
@@ -96,6 +97,8 @@ with sync_playwright() as p:
return window.__savedDashboard;
};
applyAdminPreferences(window.__savedDashboard);
api = async path => { if (path === '/api/dashboard-presets') return []; throw new Error('unexpected API ' + path); };
appConfirm = async () => true;
}''')
# Unchanged previous accounts preserve historic grouped rows.
assert page.evaluate('''() => ({
@@ -142,27 +145,26 @@ with sync_playwright() as p:
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()
# The actual unlocked dashboard remains the only layout editor.
page.locator('#overview-layout-unlock').click()
page.wait_for_function('window.rustProxyInlineLayout.isEditing()')
tile = '[data-dashboard-widget="wide_transform_activity"]'
page.locator(tile + ' .overview-inline-narrow').click()
assert page.locator(tile).get_attribute('data-dashboard-span') == '4'
page.locator(tile + ' .overview-layout-narrow').click()
page.locator(tile + ' .overview-inline-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 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}));
to.dispatchEvent(new DragEvent('drop', {bubbles:true,cancelable:true,dataTransfer:transfer,clientX:0}));
from.dispatchEvent(new DragEvent('dragend', {bubbles:true}));
}''')
page.locator('#overview-layout-save').click()
page.wait_for_function("!document.getElementById('overview-layout-dialog').open")
page.locator('#overview-inline-save').click()
page.wait_for_function('!window.rustProxyInlineLayout.isEditing()')
assert page.evaluate('window.__savedDashboard.dashboard_widget_sizes.wide_transform_activity') == 3
assert page.evaluate('window.__savedDashboard.dashboard_widgets[0]') == 'wide_transform_activity'
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'
@@ -185,10 +187,9 @@ with sync_playwright() as p:
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")
page.wait_for_function("document.getElementById('overview-presets-dialog').open")
page.locator('#overview-presets-factory').click()
page.wait_for_function("!document.getElementById('overview-presets-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'
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated DOM/status regression: no holes or artificial zeros after tab resume.
// This intentionally does not exercise the running Rust server or real browser.
import assert from 'node:assert/strict';
@@ -8,7 +9,7 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const code = file => fs.readFileSync(path.join(root, 'src/admin/web', file), 'utf8');
const html = code('index.html');
const html = renderAdminIndex(root);
class Element {
constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; }
+2 -1
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env python3
"""0.33.6 plot size/scale checks in simulated Chromium, not a running RustProxy."""
from pathlib import Path
from panel_html import render_admin_index
import re
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')
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
SCALES = (ROOT / 'src/admin/web/app/chart-scales.js').read_text(encoding='utf-8')
RATES = (ROOT / 'src/admin/web/app/rate-sampling.js').read_text(encoding='utf-8')
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Simulated Chromium regression for unified subdued metric/chart titles.
Tests computed CSS in the assembled panel DOM; not a live Rust server.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
CHECK = r'''({theme, density}) => {
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.dashboardDensity = density;
const grid = document.getElementById('dashboard-overview-grid');
const probe = document.createElement('span');
probe.style.color = 'var(--muted)';
document.body.append(probe);
const expected = getComputedStyle(probe).color;
probe.style.color = 'var(--text)';
const primary = getComputedStyle(probe).color;
probe.remove();
const color = node => getComputedStyle(node).color;
const cards = [...grid.children].filter(node => node.hasAttribute('data-dashboard-widget'));
const titles = cards.map(card => {
let node;
if (card.classList.contains('stat')) node = card.querySelector(':scope > span:first-child');
else if (card.matches('.system-card,.connection-card')) node = card.querySelector('.overview-chart-heading');
else node = card.querySelector('.mini-heading > h3') || card.querySelector('.card-heading h2');
return [card.dataset.dashboardWidget, node];
});
const otherSelectors = [
'#panel-cache .stats-grid .stat > span:first-child',
'#panel-transform .stats-grid .stat > span:first-child',
'#panel-traffic .stat > span:first-child',
'#panel-cache > .card .card-heading h2',
'#panel-transform > .card .card-heading h2',
'.traffic-chart-card > h3.subheading',
'.function-live-chart-heading > strong',
'.mini-live-chart-head > span:first-child',
'.vhost-chart-head > span:first-child',
'.vhost-live-metric > span',
];
// Include dynamically rendered Vhost/Streams chart and Vhost metric titles.
const container = document.getElementById('panel-vhosts');
const fixture = document.createElement('div');
fixture.innerHTML = `<div class="vhost-chart"><div class="vhost-chart-head">
<span class="small-label">Connections history</span><span class="fine">42/s</span>
</div></div><div class="vhost-live-metric"><span>Requests</span><strong>42</strong></div>`;
container.append(fixture);
const otherTitles = otherSelectors.flatMap(selector => [...document.querySelectorAll(selector)]
.map(node => [selector, node]));
const errors = [...titles, ...otherTitles].flatMap(([name, node]) => {
if (!node) return [`${name} missing title`];
if (color(node) !== expected) return [`${name}: ${color(node)} != ${expected} (${node.textContent.trim().slice(0, 35)})`];
return [];
});
const numeric = [
document.querySelector('#system-cpu'),
document.querySelector('#panel-overview .system-card .card-heading h2'),
document.querySelector('#panel-overview .connection-card .card-heading h2'),
document.querySelector('[data-dashboard-widget="requests"] > strong'),
fixture.querySelector('.vhost-live-metric strong'),
].map(node => ({node: node && node.outerHTML.slice(0, 80), color: node && color(node)}));
fixture.remove();
return {expected, primary, count: titles.length, otherCount: otherTitles.length,
errors, numeric};
}'''
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 768, 1440):
page = browser.new_page(viewport={'width': width, 'height': 900})
page.set_content(HTML.replace('/assets/app___RUSTPROXY_ASSET_ID__.js', '')
.replace('/assets/style___RUSTPROXY_ASSET_ID__.css', ''))
page.add_style_tag(content=CSS)
for theme in ('dark', 'light', 'ultra_dark'):
for density in ('normal', 'compact', 'ultra'):
result = page.evaluate(CHECK, {'theme': theme, 'density': density})
descriptor = width, theme, density
assert result['count'] == 78, (descriptor, result['count'])
assert result['otherCount'] >= 35, (descriptor, result['otherCount'])
assert result['primary'] != result['expected'], (descriptor, result)
assert not result['errors'], (descriptor, result['errors'][:25])
assert all(item['color'] == result['primary'] for item in result['numeric']), (descriptor, result['numeric'])
print(f'PASS muted titles, primary numeric readings at {width}px (3 themes, 3 densities)')
page.close()
browser.close()
print('PASS subdued metric/chart title CSS (simulated Chromium, no Rust runtime)')
@@ -1,3 +1,4 @@
import { renderAdminIndex } from './panel_html.mjs';
// Simulated browser/status contract: this does not exercise the Rust server.
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -7,7 +8,7 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const html = read('src/admin/web/index.html');
const html = renderAdminIndex(root);
const controllerSource = read('src/admin/web/app/overview-samples.js');
const store = new Map();
+22
View File
@@ -0,0 +1,22 @@
import fs from 'node:fs';
import path from 'node:path';
// Same four compile-time substitutions as src/admin/public.rs.
export function renderAdminIndex(root) {
const web = path.join(root, 'src/admin/web');
let html = fs.readFileSync(path.join(web, 'index.html'), 'utf8');
const metrics = fs.readFileSync(path.join(web, 'partials/overview-metrics.html'), 'utf8')
.split('<!-- RP_METRICS_SPLIT -->');
const charts = fs.readFileSync(path.join(web, 'partials/overview-charts.html'), 'utf8')
.split('<!-- RP_CHARTS_SPLIT -->');
if (metrics.length !== 2 || charts.length !== 2) throw new Error('Invalid Overview partials');
for (const [marker, text] of [
['METRICS_MAIN', metrics[0]], ['CHARTS_MAIN', charts[0]],
['METRICS_WEB', metrics[1]], ['CHARTS_WEB', charts[1]],
]) {
const token = `<!-- RP_${marker} -->`;
if (!html.includes(token)) throw new Error(`Missing ${token}`);
html = html.replace(token, text);
}
return html;
}
+18
View File
@@ -0,0 +1,18 @@
"""Return the actual assembled embedded Overview HTML to simulated-browser tests."""
from pathlib import Path
def render_admin_index(root: Path) -> str:
web = root / 'src/admin/web'
html = (web / 'index.html').read_text(encoding='utf-8')
metrics = (web / 'partials/overview-metrics.html').read_text(encoding='utf-8').split('<!-- RP_METRICS_SPLIT -->')
charts = (web / 'partials/overview-charts.html').read_text(encoding='utf-8').split('<!-- RP_CHARTS_SPLIT -->')
assert len(metrics) == len(charts) == 2
for key, fragment in (
('METRICS_MAIN', metrics[0]), ('CHARTS_MAIN', charts[0]),
('METRICS_WEB', metrics[1]), ('CHARTS_WEB', charts[1]),
):
marker = '<!-- RP_' + key + ' -->'
assert html.count(marker) == 1
html = html.replace(marker, fragment)
return html
@@ -7,6 +7,7 @@ from __future__ import annotations
from io import BytesIO
from pathlib import Path
from panel_html import render_admin_index
import re
from PIL import Image
@@ -14,7 +15,7 @@ from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
CSS = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
APP_HTML = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8')
APP_HTML = render_admin_index(ROOT)
assert re.search(r'<div class="overview-meter system-memory-meter" id="memory-meter"', APP_HTML)
assert 'id="memory-meter-fill"' in APP_HTML
CORNERS = {'default': '3px', 'square': '0px', 'subtle': '2px', 'rounded': '4px', 'extra': '999px'}
@@ -5,10 +5,11 @@ Uses actual production HTML/CSS in headless Chromium, not a live RustProxy
server or authenticated preference/session roundtrip.
"""
from pathlib import Path
from panel_html import render_admin_index
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')
HTML = render_admin_index(ROOT)
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')
@@ -4,6 +4,7 @@
This is a simulated authenticated DOM/status test, not a Rust runtime integration test.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
@@ -29,7 +30,7 @@ def bundle_for(section):
result.append(json.loads(line[:-1]))
return '\n'.join(result)
html = (ROOT / 'src/admin/web/index.html').read_text()
html = render_admin_index(ROOT)
css = (ROOT / 'src/admin/web/style.css').read_text()
js = bundle_for('admin_app_javascript')
with sync_playwright() as playwright:
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""Simulated Chromium Vhost backend quick-view, without a Rust server.
Verify both backend triggers, the shared click-to-open upstream H2 protocol
popover without native title flicker, and the two Cache card margins.
Exercise live updates, safe text, keyboard/outside dismissal and mobile sizing.
"""
from pathlib import Path
from panel_html import render_admin_index
from playwright.sync_api import sync_playwright
import json
import re
import time
ROOT = Path(__file__).resolve().parents[2]
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()
PACKS = {lang: {'id': lang, **json.loads((ROOT / 'langs' / f'{lang}.json').read_text())}
for lang in ('en_US', 'pl_pl')}
def app_bundle():
found = re.search(r'fn admin_app_javascript\([^)]*\)[^{]*\{', ROUTER)
assert found
code = ROUTER[found.end():]
code = code[:re.search(r'\]\s*\.join\("\\n"\)', code).start()]
fragments = []
for line in code.splitlines():
line = line.strip()
include = re.search(r'include_str!\("([^"]+)"\)', line)
if include:
fragments.append((ROOT / 'src/admin' / include.group(1)).read_text())
elif line == 'admin_vhosts_javascript(),':
fragments.append('(() => {\n' + '\n'.join(
(ROOT / 'src/admin/web/vhosts' / f'{name}.js').read_text()
for name in ('core', 'protocol', 'health', 'rates', 'editor', 'render')) + '\n})();')
elif line.startswith('"') and line.endswith('",'):
fragments.append(json.loads(line[:-1]))
return '\n'.join(fragments)
CONFIG = {
'vhosts': [{
'id': 'api-one', 'enabled': True, 'source': {'type': 'reverse_proxy'},
'frontend': {'hosts': ['api.example.test'], 'listener_ids': []},
'backends': [
{'id': 'origin-http', 'url': 'http://10.12.0.5:8080/api', 'protocol': 'http2', 'enabled': True},
{'id': '<origin-dns>', 'url': 'https://hiddenuser:secret@backend.example.test:8443/path', 'protocol': 'auto', 'enabled': True},
]
}, {'id': 'static-only', 'enabled': True,
'source': {'type': 'web_server', 'web_service_id': 'static-test'},
'frontend': {'hosts': ['static.example.test'], 'listener_ids': []}, 'backends': []}],
'listeners': {'endpoints': []}, 'web_services': [], 'acl_profiles': [],
}
def snapshot(first=200):
return {'backend_pools': [{
'id': 'api-one', 'health_mode': 'on',
'members': [
{'id': 'origin-http', 'status': 'up', 'last_status': first,
'requests': 12, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
{'id': '<origin-dns>', 'status': 'down', 'last_status': 0,
'requests': 0, 'active': 0, 'errors': 0, 'bytes_in': 0, 'bytes_out': 0},
]
}], 'vhost_metrics': {}}
def protocol_snapshot(h2_count):
status = snapshot()
status['backend_pools'][0]['members'][0].update({
'upstream_protocol_observed': 'h2',
'upstream_protocol_last_seen_ms': int(time.time() * 1000),
'upstream_protocol_http1_total': 3,
'upstream_protocol_http2_total': h2_count,
})
return status
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True, executable_path='/usr/bin/chromium', args=['--no-sandbox'])
for width in (320, 375, 768, 1440):
page = browser.new_page(viewport={'width': width, 'height': 820})
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('''([config, status, packs]) => {
languagePacks = new Map(Object.entries(packs));
defaultLanguage = 'en_US'; language = 'pl_pl';
document.documentElement.lang = 'pl-PL';
document.documentElement.classList.remove('app-loading', 'app-startup-failed');
document.getElementById('login').hidden = true;
document.getElementById('workspace').hidden = false;
document.getElementById('panel-vhosts').hidden = false;
currentPanel = 'vhosts';
envelope = {revision: 12, config};
latestStatus = status;
window.rustProxyVhosts.render();
}''', [CONFIG, snapshot(), PACKS])
vhost = page.locator('[data-vhost-card="api-one"]')
backend = vhost.locator('.vhost-detail.vhost-backend-trigger')
health = vhost.locator('.vhost-live-metric.vhost-backend-trigger')
assert backend.count() == health.count() == 1, width
assert backend.get_attribute('aria-expanded') == 'false'
assert backend.get_attribute('aria-haspopup') == 'dialog'
# Clickable backend/health fields must match their non-button peers,
# including for user-selected interface corner styles.
shapes = page.evaluate('''() => {
const root = document.querySelector('[data-vhost-card="api-one"]');
const peers = [
[root.querySelector('.vhost-detail.vhost-backend-trigger'), root.querySelector('.vhost-detail:not(.vhost-backend-trigger)')],
[root.querySelector('.vhost-live-metric.vhost-backend-trigger'), root.querySelector('.vhost-live-metric:not(.vhost-backend-trigger)')]
];
const checks = ['square', 'subtle', 'rounded', 'extra'].map(corner => {
document.documentElement.dataset.cornerStyle = corner;
return {corner, tiles: peers.map(([trigger, peer]) => {
const left = getComputedStyle(trigger), right = getComputedStyle(peer);
return {radius: left.borderRadius, peerRadius: right.borderRadius,
padding: left.padding, peerPadding: right.padding,
font: left.fontFamily, peerFont: right.fontFamily};
})};
});
document.documentElement.removeAttribute('data-corner-style');
return checks;
}''')
for shape in shapes:
for tile in shape['tiles']:
assert tile['radius'] == tile['peerRadius'] == '0px', (width, shape)
assert tile['padding'] == tile['peerPadding'], (width, shape)
assert tile['font'] == tile['peerFont'], (width, shape)
backend.click()
popup = page.locator('#vhost-backend-popup')
assert popup.count() == 1, width
assert backend.get_attribute('aria-expanded') == 'true'
assert popup.get_attribute('role') == 'dialog'
# The anchored popup is a surface, not a permanently rounded tooltip.
# It must follow live interface corner preference changes without
# dismissing/recreating the open backend (or shared H2) popup.
corners = page.evaluate('''() => {
const node = document.getElementById('vhost-backend-popup');
const saved = document.documentElement.getAttribute('data-corner-style');
const out = {};
for (const style of ['default', 'square', 'subtle', 'rounded', 'extra']) {
document.documentElement.dataset.cornerStyle = style;
out[style] = {
popup: getComputedStyle(node).borderRadius,
row: getComputedStyle(node.querySelector('.vhost-backend-popup-row')).borderRadius,
close: getComputedStyle(node.querySelector('.vhost-backend-popup-close')).borderRadius,
reference: getComputedStyle(document.querySelector('[data-vhost-card="api-one"]')).borderRadius,
sameNode: node === document.getElementById('vhost-backend-popup'),
};
}
if (saved === null) document.documentElement.removeAttribute('data-corner-style');
else document.documentElement.dataset.cornerStyle = saved;
return out;
}''')
expected_radii = {'default': ('0px', '0px'), 'square': ('0px', '0px'),
'subtle': ('5px', '4px'), 'rounded': ('12px', '8px'),
'extra': ('18px', '12px')}
for style, (surface, control) in expected_radii.items():
actual = corners[style]
assert actual == {'popup': surface, 'row': surface, 'close': control,
'reference': surface, 'sameNode': True}, (width, style, actual)
values = popup.evaluate('''node => ({
cards: node.querySelectorAll('.vhost-backend-popup-row').length,
text: node.textContent, html: node.innerHTML,
box: (() => { const r = node.getBoundingClientRect();
return {left: r.left, right: r.right, top:r.top, bottom:r.bottom}; })(),
})''')
assert values['cards'] == 2, (width, values)
assert '10.12.0.5:8080' in values['text'] and 'HTTP 200' in values['text'], (width, values)
assert 'backend.example.test:8443' in values['text'], width
assert 'secret' not in values['text'], width
assert '<origin-dns>' in values['text'] and '&lt;origin-dns&gt;' in values['html'], width
assert 'Brak odpowiedzi HTTP' in values['text'], width
assert 'HTTPS · AUTO' in values['text'], width
assert values['box']['left'] >= -1 and values['box']['right'] <= width + 1, (width, values['box'])
assert values['box']['top'] >= -1 and values['box']['bottom'] <= 821, (width, values['box'])
assert page.evaluate('document.documentElement.scrollWidth - innerWidth') <= 2, width
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', snapshot(503))
assert 'HTTP 503' in popup.inner_text(), width
assert popup.locator('.has-error').count() == 1, width
health.click()
assert popup.count() == 1 and health.get_attribute('aria-expanded') == 'true', width
assert backend.get_attribute('aria-expanded') == 'false', width
page.keyboard.press('Escape')
assert popup.count() == 0 and health.get_attribute('aria-expanded') == 'false', width
assert health.evaluate('(node) => document.activeElement === node'), width
backend.click()
page.locator('#vhost-search').click(force=True)
assert popup.count() == 0, width
# A static-only vhost presents a translated empty state, not stale pool values.
page.locator('[data-vhost-card="static-only"] .vhost-detail.vhost-backend-trigger').click()
assert 'Brak skonfigurowanych backendów.' in popup.inner_text(), width
popup.locator('.vhost-backend-popup-close').click()
assert popup.count() == 0, width
backend.click()
assert popup.count() == 1, width
# The existing backend popover is also used by the H2 policy badge.
protocol = vhost.locator('[data-upstream-protocol-backend="origin-http"]')
assert protocol.count() == 1 and protocol.evaluate('n => n.tagName') == 'BUTTON', width
assert protocol.get_attribute('title') is None, width
protocol.click()
assert backend.get_attribute('aria-expanded') == 'false', width
assert protocol.get_attribute('aria-expanded') == 'true', width
assert 'Brak odpowiedzi backendu' in popup.inner_text(), width
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(8))
assert 'origin-http · H2 compatible → H2' in protocol.inner_text(), width
assert 'Ostatnia wersja HTTP' in popup.inner_text(), width
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '8'], width
page.evaluate('''() => {
window.__popupReference = document.getElementById('vhost-backend-popup');
window.__headingReference = window.__popupReference.querySelector('h4');
window.__popupRemoved = 0;
window.__popupObserver = new MutationObserver(records => {
for (const record of records) for (const item of record.removedNodes) {
if (item === window.__popupReference) window.__popupRemoved++;
}
});
window.__popupObserver.observe(document.body, {childList: true});
}''')
for count in range(9, 19):
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', protocol_snapshot(count))
unchanged = page.evaluate('''() => ({
samePopup: document.getElementById('vhost-backend-popup') === window.__popupReference,
sameHeading: window.__popupReference.querySelector('h4') === window.__headingReference,
removed: window.__popupRemoved,
expanded: document.querySelector('[data-upstream-protocol-backend="origin-http"]').getAttribute('aria-expanded'),
})''')
assert unchanged == {'samePopup': True, 'sameHeading': True, 'removed': 0, 'expanded': 'true'}, (width, unchanged)
assert popup.locator('.vhost-backend-popup-field > span:last-child').all_inner_texts() == ['3', '18'], width
page.evaluate('window.__popupObserver.disconnect()')
assert protocol.get_attribute('title') is None, width
# A stale observation should change the displayed information in
# place, not dismiss/recreate the click popover.
stale = protocol_snapshot(18)
stale['backend_pools'][0]['members'][0]['upstream_protocol_last_seen_ms'] -= 600000
page.evaluate('''s => window.rustProxyVhosts.renderStatus(s)''', stale)
assert 'H2 compatible → ?' in protocol.inner_text(), width
assert popup.locator('.vhost-backend-popup-list').is_hidden(), width
assert page.evaluate('document.getElementById("vhost-backend-popup") === window.__popupReference'), width
page.keyboard.press('Escape')
assert popup.count() == 0 and protocol.get_attribute('aria-expanded') == 'false', width
assert protocol.evaluate('node => document.activeElement === node'), width
# Cache Domains and Stored objects are the only directly adjacent
# table cards and need a visible, small gap at any viewport width.
cache_gap = page.evaluate('''() => {
const panel = document.getElementById('panel-cache');
panel.hidden = false;
const [domains, stored] = panel.querySelectorAll(':scope > article.card');
const gap = stored.getBoundingClientRect().top - domains.getBoundingClientRect().bottom;
panel.hidden = true;
return gap;
}''')
assert 9.5 <= cache_gap <= 10.5, (width, cache_gap)
page.evaluate('window.rustProxyVhosts.reset()')
assert popup.count() == 0, width
assert not errors, (width, errors)
print(f'PASS Vhosts shared backend/H2 popover and Cache gap at {width}px')
page.close()
browser.close()
print('PASS Vhost quick-view with simulated runtime status (not live Rust)')
@@ -1,4 +1,5 @@
from pathlib import Path
from panel_html import render_admin_index
import re, json
from playwright.sync_api import sync_playwright
root=Path(__file__).resolve().parents[2]
@@ -26,7 +27,7 @@ bundle=concatenate('admin_app_javascript')
assert 'window.rustProxyOverviewSamples =' in bundle
assert bundle.index('window.rustProxyOverviewSamples =') < bundle.index('window.rustProxyOverviewSamples.onChange(')
css=(root/'src/admin/web/style.css').read_text()
html=(root/'src/admin/web/index.html').read_text()
html=render_admin_index(root)
with sync_playwright() as p:
browser=p.chromium.launch(headless=True, executable_path='/usr/bin/chromium',args=['--no-sandbox','--allow-file-access-from-files'])
for width in [320,375,800,1440]:
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
"""Static contracts for 0.33.0 WWW observability (not Rust compilation)."""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
import tomllib
@@ -18,7 +19,7 @@ widgets = [w for w in registry if w['id'].startswith('www_')]
assert len(widgets) == 12
assert all(w['default_hidden'] is True for w in widgets)
assert len({w['id'] for w in registry}) == len(registry)
html = read('src/admin/web/index.html')
html = render_admin_index(ROOT)
english = json.loads(read('langs/en_US.json'))['translations']
polish = json.loads(read('langs/pl_pl.json'))['translations']
for widget in widgets:
+2 -1
View File
@@ -7,6 +7,7 @@ Run: python3 scripts/tests/web_server_0330_ui_test.py
from __future__ import annotations
import json
from pathlib import Path
from panel_html import render_admin_index
import re
from playwright.sync_api import sync_playwright
@@ -58,7 +59,7 @@ with sync_playwright() as p:
page = browser.new_page(viewport={'width': width, 'height': 900})
errors = []
page.on('pageerror', lambda err: errors.append(str(err)))
page.set_content((ROOT / 'src/admin/web/index.html').read_text())
page.set_content(render_admin_index(ROOT))
page.add_style_tag(content=(ROOT / 'src/admin/web/style.css').read_text())
page.add_script_tag(content=BUNDLE)
page.evaluate("""() => {
@@ -5,12 +5,13 @@ Chromium exercises the real assembled admin JS/CSS and responsive layouts.
It does not establish Rust, SSE or PHP-FPM runtime acceptance.
"""
from pathlib import Path
from panel_html import render_admin_index
import json
import re
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[2]
HTML = (ROOT / 'src/admin/web/index.html').read_text()
HTML = render_admin_index(ROOT)
CSS = (ROOT / 'src/admin/web/style.css').read_text()
ROUTER = (ROOT / 'src/admin/router.rs').read_text()