v0.33.7
This commit is contained in:
1 parent
162e986d37
commit
6b937aaf85
54 files changed
+3527
-1325
No files matched your search
@@ -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()
|
||||
Executable
+37
@@ -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; }
|
||||
|
||||
Regular → Executable
+2
-1
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+18
@@ -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
@@ -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 '<origin-dns>' 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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user