This commit is contained in:
Mateusz Gruszczyński committed 2026-11-07 13:56:11 +01:00
1 parent 672c84bc13
commit 6f52ac3d00
38 files changed
+1183 -221

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Browser smoke test for the optional semantic SVG interface icons.
Uses a locally installed Chromium when available and otherwise exits successfully
with a skip message. It exercises the real index.html plus the embedded icon
module without requiring a running RustProxy backend.
"""
from __future__ import annotations
from pathlib import Path
import html
import re
import shutil
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
WEB = ROOT / "src/admin/web"
chromium = next((shutil.which(name) for name in ("chromium", "chromium-browser", "google-chrome") if shutil.which(name)), None)
if not chromium:
print("Panel SVG icon browser smoke: SKIP (Chromium not installed)")
raise SystemExit(0)
page = (WEB / "index.html").read_text(encoding="utf-8")
page = re.sub(r'<script\s+defer=""\s+src="/assets/app___RUSTPROXY_ASSET_ID__\.js"></script>', "", page, count=1)
icons = (WEB / "app/icons.js").read_text(encoding="utf-8")
script = r'''
const $ = id => document.getElementById(id);
const translations = { edit: "Edit", remove: "Remove", copyValue: "Copy value", previous: "Previous", next: "Next" };
function t(key) { return translations[key] || key; }
''' + icons + r'''
(() => {
const fail = message => { document.getElementById("browser-icon-test").textContent = `FAIL: ${message}`; throw new Error(message); };
const result = document.getElementById("browser-icon-test");
const eligible = [...document.querySelectorAll("button, a.primary, a.outline, a.quiet")].filter(node => !node.matches(".chart-style-choice"));
if (document.querySelectorAll("svg.ui-icon").length !== 0) fail("icons must be absent before enabling the preference");
window.rustProxyIcons.setEnabled(true);
const decorated = eligible.filter(node => node.querySelector(":scope > svg.ui-icon"));
if (decorated.length !== eligible.length) fail(`expected ${eligible.length} decorated controls, got ${decorated.length}`);
if ([...document.querySelectorAll("svg.ui-icon")].some(icon => icon.getAttribute("aria-hidden") !== "true")) fail("SVG icons must be aria-hidden");
const expectedPanels = { overview: "grid", vhosts: "network", "web-server": "globe", "docker-vhosts": "box", redirects: "route", notfound: "fileX", streams: "network", "acl-profiles": "shield", tls: "certificate", traffic: "activity", cache: "database", transform: "wand", settings: "settings" };
for (const [panel, icon] of Object.entries(expectedPanels)) {
const button = document.querySelector(`[data-panel="${panel}"]`);
if (button?.querySelector(":scope > svg.ui-icon")?.dataset.uiIcon !== icon) fail(`panel ${panel} must use ${icon}`);
}
const dynamic = document.createElement("button"); dynamic.type = "button"; dynamic.className = "quiet"; dynamic.textContent = "Edit"; document.body.append(dynamic);
window.rustProxyIcons.refresh(dynamic);
if (dynamic.querySelector("svg.ui-icon")?.dataset.uiIcon !== "edit") fail("translated dynamic Edit action must use edit icon");
dynamic.textContent = "Remove item"; dynamic.setAttribute("aria-label", "Remove item"); window.rustProxyIcons.refresh(dynamic);
if (dynamic.querySelector("svg.ui-icon")?.dataset.uiIcon !== "trash") fail("dynamic Remove action must use trash icon");
const visibility = document.createElement("button"); visibility.dataset.dashboardWidgetAction = "toggle"; visibility.className = "dashboard-widget-toggle is-hidden"; document.body.append(visibility);
window.rustProxyIcons.refresh(visibility);
if (visibility.querySelector("svg.ui-icon")?.dataset.uiIcon !== "eyeOff") fail("hidden widget action must use eyeOff");
visibility.classList.remove("is-hidden"); window.rustProxyIcons.refresh(visibility);
if (visibility.querySelector("svg.ui-icon")?.dataset.uiIcon !== "eye") fail("visible widget action must use eye");
window.rustProxyIcons.setEnabled(false);
if (document.querySelectorAll("svg.ui-icon").length !== 0) fail("disabling preference must remove all injected icons");
result.textContent = `OK ${eligible.length} controls`;
})();
'''
page = page.replace("</body>", '<pre id="browser-icon-test">PENDING</pre><script>' + script + "</script></body>", 1)
with tempfile.TemporaryDirectory(prefix="rustproxy-icons-") as tmp:
target = Path(tmp) / "icons-test.html"
target.write_text(page, encoding="utf-8")
try:
proc = subprocess.run(
[chromium, "--headless", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--virtual-time-budget=1000", "--dump-dom", target.as_uri()],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=8,
check=False,
)
except subprocess.TimeoutExpired:
print("Panel SVG icon browser smoke: SKIP (Chromium cannot start in this environment)")
raise SystemExit(0)
match = re.search(r'<pre id="browser-icon-test">([^<]+)</pre>', proc.stdout)
status = html.unescape(match.group(1)) if match else "missing result marker"
if proc.returncode != 0 or not status.startswith("OK "):
raise SystemExit(f"Panel SVG icon browser smoke failed: {status}\n{proc.stderr[-4000:]}")
print(f"Panel SVG icon browser smoke: {status}")
+45
View File
@@ -0,0 +1,45 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.join(__dirname, "..");
const read = relative => fs.readFileSync(path.join(root, relative), "utf8");
const preferences = read("src/admin/web/app/preferences.js");
const icons = read("src/admin/web/app/icons.js");
const core = read("src/admin/web/app/core.js");
const html = read("src/admin/web/index.html");
const css = read("src/admin/web/css/10-base.css");
const overviewCss = read("src/admin/web/css/30-overview.css");
const integrations = read("src/admin/web/integrations.js");
const metricsPartial = read("src/admin/web/partials/overview-metrics.html");
const pl = JSON.parse(read("langs/pl_pl.json"));
const openapi = JSON.parse(read("src/admin/openapi.json"));
assert.match(html, /id="ui-interface-icons"[^>]*type="checkbox"/);
assert.match(preferences, /interface_icons:\s*false/);
assert.match(preferences, /applyAdminInterfaceIcons\(value\.interface_icons\)/);
assert.match(preferences, /saveAppearancePreference\(\{ interface_icons: next \}/);
assert.match(preferences, /interface:\s*\{[^}]*interface_icons:\s*false/);
assert.match(core, /rustProxyIcons\?\.refresh\?\.\(\)/);
assert.match(icons, /dataset\.interfaceIcons !== "true"/);
assert.match(icons, /aria-hidden", "true"/);
assert.match(icons, /data\.panel|dataset\.panel/);
assert.match(icons, /MutationObserver/);
assert.match(icons, /if \(target\.dataset\.httpSettingsTab\) return "network"/);
assert.match(icons, /#web-service-preset-apply.*return "wand"/);
assert.match(icons, /#tunnels-open.*return null/);
assert.match(icons, /#logs-menu > summary/);
assert.match(integrations, /listener-h2-settings-link.*uiIconName = "settings"/);
assert.match(integrations, /listener-h3-settings-link.*uiIconName = "settings"/);
assert.match(metricsPartial, /stat-corner-badge[^>]+data-ui-icon-name="none"[^>]+id="tunnels-open"/);
assert.match(overviewCss, /\.stat-corner-badge/);
assert.equal(pl.translations.interfaceIconsHelp, "Dodaje semantyczne ikony SVG do nawigacji, akcji, zakładek i okien dialogowych.");
assert.match(css, /html\[data-interface-icons="true"\]/);
assert.equal(openapi.components.schemas.AdminPreferences.properties.interface_icons.default, false);
assert.equal(openapi.components.schemas.AdminPreferences.properties.interface_icons.type, "boolean");
for (const icon of ["plus", "save", "check", "trash", "edit", "refresh", "settings", "search", "key", "certificate", "shield", "network", "database", "wand", "grid", "activity", "book"]) {
assert.ok(new RegExp(`\\b${icon}:`).test(icons), `missing semantic SVG icon definition: ${icon}`);
}
console.log("Panel SVG icon preference contract: OK");
+7
View File
@@ -76,6 +76,13 @@ for path in sources:
missing_refs = {dom_id: sorted(paths) for dom_id, paths in literal_refs.items() if dom_id not in id_set}
assert not missing_refs, f"embedded panel JS references missing DOM ids: {missing_refs}"
assert "ui-interface-icons" in id_set, "Appearance must expose the SVG icon toggle"
assert any(path.name == "icons.js" for path in sources), "semantic icon module must be embedded in app.js"
assert 'data-i18n="overviewExtraRecentSamples"' not in html, "Overview metric tiles must not show the redundant Recent samples footer"
overview_extra = (WEB / "app/overview-extra-metrics.js").read_text(encoding="utf-8")
assert 't("overviewExtraRecentSamples")' not in overview_extra, "Overview metric details must not fall back to the generic Recent samples label"
# Every visible primary navigation target must have a panel section. filelogs is
# intentionally opened from the Logs menu rather than the main navigation.
panel_targets = attribute_values(html, "data-panel")
+20 -4
View File
@@ -11,6 +11,9 @@ const core = read("src/admin/web/app/core.js");
const cache = read("src/admin/web/cache-panel.js");
const transform = read("src/admin/web/transform-panel.js");
const traffic = read("src/admin/web/traffic-panel.js");
const refresh = read("src/admin/web/app/refresh.js");
const management = read("src/admin/web/management.js");
const loggingApi = read("src/admin/logging_api.rs");
const openapi = JSON.parse(read("src/admin/openapi.json"));
assert.match(settings, /authenticated && name === "certificate-api"[^\n]+rustProxyCertificateApi\?\.load/);
@@ -18,9 +21,18 @@ assert.match(settings, /authenticated && active === "certificate-api"[^\n]+rustP
assert.ok(!/renderCertificateApi\(\);\s*loadCertificateApi\(\);\s*\}\)\(\);\s*$/.test(certificateApi),
"Certificate API must not fetch credentials when its module is initialized");
assert.match(core, /async function refresh\(force = false, includeLogs = false\)/);
assert.match(core, /if \(includeLogs\) tasks\.push\(rustProxyRefresh\.manual\("logs-fallback"\)\)/);
assert.match(core, /refresh\(true, true\)\.catch/);
assert.match(core, /async function refresh\(force = false\)/);
assert.doesNotMatch(core, /includeLogs/);
assert.match(core, /new EventSource\(`\/api\/events\?interval=\$\{seconds\}&logs=false`\)/);
assert.match(loggingApi, /logs: Option<bool>/);
assert.match(loggingApi, /let include_logs = query\.logs\.unwrap_or\(true\)/);
assert.match(loggingApi, /if include_logs[\s\S]+logs_payload\(&state\)[\s\S]+else[\s\S]+live_status_payload/);
assert.match(core, /if \(payload\.logs\) renderLogs\(payload\.logs\)/);
assert.match(refresh, /active: \(\) => !!window\.rustProxyLogs\?\.active\?\.\(\)/);
assert.doesNotMatch(refresh, /manual\("logs-fallback"\).*manual\("system-info"\)/);
assert.match(management, /function active\(\) \{ return dialog\.open && !paused && !expanded; \}/);
assert.match(management, /syncLivePolling\(true\)/);
for (const [name, source, endpoint] of [
["cache", cache, "/api/cache"],
@@ -35,5 +47,9 @@ for (const [name, source, endpoint] of [
assert.deepEqual(view?.schema?.enum, ["live"], `${endpoint} must document view=live`);
}
assert.equal(openapi.info.version, "0.36.0");
const events = openapi.paths["/api/events"].get;
const logsFlag = (events.parameters || []).find(parameter => parameter.name === "logs");
assert.equal(logsFlag?.schema?.type, "boolean");
assert.equal(logsFlag?.schema?.default, true);
assert.equal(openapi.info.version, "0.36.1");
console.log("Panel refresh/API contract checks: OK");