v0.32.0
This commit is contained in:
1 parent
d23fa5e154
commit
81ab39e70a
51 files changed
+5352
-1489
No files matched your search
@@ -7,7 +7,7 @@ import tomllib
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
read = lambda path: (root / path).read_text(encoding='utf-8')
|
||||
version = tomllib.loads(read('Cargo.toml'))['package']['version']
|
||||
assert version == '0.31.9', version
|
||||
assert version == '0.32.0', version
|
||||
spec = json.loads(read('src/admin/openapi.json'))
|
||||
assert spec['info']['version'] == version
|
||||
paths = spec['paths']
|
||||
@@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'):
|
||||
for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate',
|
||||
'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'):
|
||||
assert strings.get(key), (language, key)
|
||||
assert '0.31.9' in read('docs/TEST_REPORT.md')
|
||||
assert '0.32.0' in read('docs/TEST_REPORT.md')
|
||||
print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).')
|
||||
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
|
||||
|
||||
preflight(root)
|
||||
manifest = tomllib.loads((root / "Cargo.toml").read_text())
|
||||
assert manifest["package"]["version"] == "0.31.9"
|
||||
assert manifest["package"]["version"] == "0.32.0"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
|
||||
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
|
||||
lock_path = root / "Cargo.lock"
|
||||
if lock_path.exists():
|
||||
lock = lock_path.read_text()
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.9"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.0"' in lock
|
||||
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
|
||||
for release_doc, marker in {
|
||||
"README.md": "# RustProxy 0.31.9",
|
||||
"CHANGELOG.md": "## 0.31.9",
|
||||
"ROADMAP.md": "## 0.31.9",
|
||||
"AGENTS.md": "Current source version: 0.31.9.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.9",
|
||||
"README.md": "# RustProxy 0.32.0",
|
||||
"CHANGELOG.md": "## 0.32.0",
|
||||
"ROADMAP.md": "## 0.32.0",
|
||||
"AGENTS.md": "Current source version: 0.32.0.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.0",
|
||||
}.items():
|
||||
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
|
||||
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
|
||||
@@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
|
||||
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
|
||||
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
|
||||
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
|
||||
for marker in ['"version": "0.31.9"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
for marker in ['"version": "0.32.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
|
||||
assert marker in openapi, marker
|
||||
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
@@ -1396,7 +1396,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
|
||||
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
|
||||
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
|
||||
assert 'location.pathname === "/logging"' in settings_js_0241
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.31.9"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.32.0"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
|
||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||
args = parser.parse_args()
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
assert manifest['package']['version'] == '0.31.9'
|
||||
assert manifest['package']['version'] == '0.32.0'
|
||||
assert manifest['package']['rust-version'] == '1.99'
|
||||
deps = manifest['dependencies']
|
||||
expected_requirements = {
|
||||
|
||||
@@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
|
||||
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
assert readme.startswith("# RustProxy 0.31.9")
|
||||
assert readme.startswith("# RustProxy 0.32.0")
|
||||
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
|
||||
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
|
||||
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||
assert '0.31.9' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '0.32.0' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||
|
||||
@@ -8,9 +8,9 @@ from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.9'
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.9"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.9'
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.0'
|
||||
assert 'name = "rust-proxy"\nversion = "0.32.0"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.0'
|
||||
|
||||
pages = read('src/pages.rs')
|
||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||
|
||||
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert backup.is_dir()
|
||||
assert (backup / "src/obsolete.rs").is_file()
|
||||
assert not (target / "src/obsolete.rs").exists()
|
||||
assert 'version = "0.31.9"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.32.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
|
||||
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
|
||||
assert (target / "langs/site.json").is_file()
|
||||
@@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None:
|
||||
assert (target / "docker-agent/certificate.go").is_file()
|
||||
assert (target / "docker-agent/server.go").is_file()
|
||||
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
|
||||
assert manifest["to_version"] == "0.31.9"
|
||||
assert manifest["to_version"] == "0.32.0"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
|
||||
assert marker in admin, marker
|
||||
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
|
||||
assert '"/api/status"' not in anonymous_block
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.9"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.0"' in openapi_0241
|
||||
assert 'id="panel-logging"' not in html
|
||||
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
|
||||
assert marker in html, marker
|
||||
@@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None:
|
||||
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
|
||||
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
|
||||
assert '"/api/config/history/restore"' in openapi
|
||||
for marker in ['"version": "0.31.9"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.32.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
assert marker in openapi, marker
|
||||
assert '"software_update"' in openapi and '"update_available"' in openapi
|
||||
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
|
||||
|
||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
||||
const en = json('langs/en_US.json').translations;
|
||||
const pl = json('langs/pl_pl.json').translations;
|
||||
|
||||
assert.match(manifest, /version = "0\.31\.9"/);
|
||||
assert.match(manifest, /version = "0\.32\.0"/);
|
||||
assert.match(lib, /pub mod user_agent;/);
|
||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(fileLogs, /aggregate_min_count/);
|
||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||
|
||||
assert.equal(openapi.info.version, '0.31.9');
|
||||
assert.equal(openapi.info.version, '0.32.0');
|
||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Verify the real opt-in Stream Dashboard component against a lightweight DOM.
|
||||
// No server, browser layout engine or Rust executable is exercised here.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const src = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
||||
const preferencesSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
||||
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
||||
assert(!/\b(setInterval|setTimeout|fetch|XMLHttpRequest)\s*\(/.test(src), 'widgets must not create timers/fetches');
|
||||
const cards = new Map(ids.map(id => [id, { hidden: true }]));
|
||||
const elements = new Map();
|
||||
for (const name of ['overview', 'overview-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'traffic', 'traffic-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
||||
elements.set(`stream-dashboard-${name}`, { textContent: '-', attrs: new Map(), setAttribute(k, v) { this.attrs.set(k, v); } });
|
||||
}
|
||||
const preferences = { dashboard_hidden_widgets: [...ids] };
|
||||
const window = { rustProxyPreferences: { current: () => preferences } };
|
||||
let clock = 1000;
|
||||
const scriptContext = { window, document: { querySelector(selector) {
|
||||
const match = selector.match(/^\[data-dashboard-widget="([^\"]+)"\]$/);
|
||||
return match ? cards.get(match[1]) || null : null;
|
||||
} }, $: name => elements.get(name), ADMIN_APPEARANCE_DEFAULTS: { dashboard_hidden_widgets: ids },
|
||||
envelope: { config: { streams: [
|
||||
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
||||
{ id: 'udp', enabled: true, protocol: 'udp' }
|
||||
] } }, latestStatus: null, currentPanel: 'overview',
|
||||
performance: { now: () => clock }, t: key => key, fmt: n => `${n}`, bytes: n => `${n} B`, Math,
|
||||
};
|
||||
vm.runInNewContext(src, scriptContext);
|
||||
const widget = window.rustProxyStreamDashboard;
|
||||
const status = { streams: { active: [
|
||||
{ id: 'tcp', enabled: true, protocol: 'tcp' },
|
||||
{ id: 'udp', enabled: true, protocol: 'udp' }
|
||||
], metrics: {
|
||||
tcp: { active: 3, bytes_in: 500, bytes_out: 1000, backends: [
|
||||
{ id: 'a', enabled: true, drain: false, status: 'healthy' },
|
||||
{ id: 'b', enabled: true, drain: false, status: 'unhealthy' }
|
||||
] },
|
||||
udp: { active: 2, bytes_in: 100, bytes_out: 200, backends: [
|
||||
{ id: 'a', enabled: true, drain: false, status: 'unverified' }
|
||||
] }
|
||||
} } };
|
||||
scriptContext.latestStatus = status;
|
||||
widget.render(status);
|
||||
assert(ids.every(id => cards.get(id).hidden), 'new widgets are hidden by default');
|
||||
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'hidden chart must not sample');
|
||||
preferences.dashboard_hidden_widgets = [];
|
||||
widget.render(status);
|
||||
assert(ids.every(id => !cards.get(id).hidden));
|
||||
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5');
|
||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '2 / 2');
|
||||
assert.equal(elements.get('stream-dashboard-health').textContent, '1 / 1');
|
||||
clock += 1000;
|
||||
status.streams.metrics.tcp.bytes_in += 1024;
|
||||
status.streams.metrics.tcp.bytes_out += 512;
|
||||
widget.render(status);
|
||||
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '1024 B/s');
|
||||
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '512 B/s');
|
||||
assert(elements.get('stream-dashboard-rx').attrs.get('points')?.includes(','));
|
||||
preferences.dashboard_hidden_widgets = ['stream_traffic'];
|
||||
widget.syncVisibility();
|
||||
assert(cards.get('stream_traffic').hidden);
|
||||
const previous = elements.get('stream-dashboard-rx').attrs.get('points');
|
||||
clock += 1000;
|
||||
widget.render(status);
|
||||
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), previous, 'hidden chart must not update');
|
||||
// Explicitly enabled cards remain visible even without active/configured streams.
|
||||
// The user should see a neutral state, not a widget that ignores Appearance.
|
||||
preferences.dashboard_hidden_widgets = [];
|
||||
scriptContext.envelope.config.streams = [];
|
||||
scriptContext.latestStatus = { streams: { active: [], metrics: {} } };
|
||||
widget.render(scriptContext.latestStatus);
|
||||
assert(ids.every(id => !cards.get(id).hidden), 'appearance must control Stream card visibility even with no listeners');
|
||||
for (const name of ['overview', 'sessions', 'health', 'traffic']) {
|
||||
assert.equal(elements.get(`stream-dashboard-${name}`).textContent, '-');
|
||||
if (name !== 'traffic') assert.equal(elements.get(`stream-dashboard-${name}-detail`).textContent, 'streamWidgetUnavailable');
|
||||
}
|
||||
assert.equal(elements.get('stream-dashboard-rx-rate').textContent, '-');
|
||||
assert.equal(elements.get('stream-dashboard-tx-rate').textContent, '-');
|
||||
assert.equal(elements.get('stream-dashboard-rx').attrs.get('points'), '0,55 300,55', 'empty status must clear the graph');
|
||||
// A desired but not yet running Stream must still make the overview useful.
|
||||
scriptContext.envelope.config.streams = [{ id: 'pending', enabled: true, protocol: 'tcp' }];
|
||||
preferences.dashboard_hidden_widgets = [];
|
||||
widget.render({ streams: { active: [], metrics: {} } });
|
||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '0 / 1');
|
||||
assert(!cards.get('stream_overview').hidden, 'restart-pending listener does not suppress the card');
|
||||
preferences.dashboard_hidden_widgets = [...ids];
|
||||
widget.render(scriptContext.latestStatus);
|
||||
assert(ids.every(id => cards.get(id).hidden), 'only the Appearance switch hides opt-in cards');
|
||||
widget.reset();
|
||||
|
||||
// Reuse original Appearance normalization logic to protect 0.31.x saved layouts.
|
||||
const definitions = preferencesSource.slice(preferencesSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), preferencesSource.indexOf('const CHART_STYLE_KEYS'));
|
||||
const normalize = preferencesSource.slice(preferencesSource.indexOf('function normalizeDashboardWidgets'), preferencesSource.indexOf('function browserLanguage'));
|
||||
const sandbox = {};
|
||||
vm.runInNewContext(`${definitions}\n${normalize}\nthis.normalizeDashboardWidgets = normalizeDashboardWidgets; this.normalizeHiddenDashboardWidgets = normalizeHiddenDashboardWidgets`, sandbox);
|
||||
const oldOrder = ['requests', 'errors'];
|
||||
const migratedOrder = [...sandbox.normalizeDashboardWidgets(oldOrder)];
|
||||
const migratedHidden = [...sandbox.normalizeHiddenDashboardWidgets([], oldOrder)];
|
||||
assert.deepEqual(migratedOrder.slice(0, 2), oldOrder);
|
||||
assert(ids.every(id => migratedOrder.includes(id) && migratedHidden.includes(id)), 'legacy order must add cards as hidden');
|
||||
console.log('PASS: opt-in Stream dashboard uses shared data with no polling, handles user visibility/empty-status states, and migrates old Appearance layouts.');
|
||||
@@ -0,0 +1,120 @@
|
||||
// Regression for persisted Appearance preferences and Stream widgets.
|
||||
// Uses the actual JS functions with a minimal DOM, no network or timers.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const prefsSource = fs.readFileSync('src/admin/web/app/preferences.js', 'utf8');
|
||||
const streamSource = fs.readFileSync('src/admin/web/app/stream-dashboard.js', 'utf8');
|
||||
const html = fs.readFileSync('src/admin/web/index.html', 'utf8');
|
||||
const css = fs.readFileSync('src/admin/web/style.css', 'utf8');
|
||||
const ids = ['stream_overview', 'stream_traffic', 'stream_sessions', 'stream_backend_health'];
|
||||
assert(html.includes('id="panel-overview"'));
|
||||
for (const id of ids) assert(html.includes(`data-dashboard-widget="${id}"`));
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \.stat:is\(:hover, :focus-visible, :focus-within\)/,
|
||||
'Overview details must never become hover/focus popovers');
|
||||
assert.match(css, /#panel-overview \.stats-grid \.stat-chart > \.stat-chart-legend/,
|
||||
'in-card chart legends must be visible, including on touch devices');
|
||||
assert.match(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(6, minmax\(0, 1fr\)\); \}/,
|
||||
'desktop Overview must retain the original six cards per row');
|
||||
assert.doesNotMatch(css, /#panel-overview \.stats-grid \{ grid-template-columns: repeat\(4, minmax\(0, 1fr\)\); \}/,
|
||||
'do not replace the original six-column Overview with a four-column layout');
|
||||
assert.match(css, /#panel-overview \.stats-grid > \.stat \{ min-height: 126px;/,
|
||||
'cards should be tall enough for their inline metric legends');
|
||||
assert.match(html, /id="stream-dashboard-traffic-detail"/);
|
||||
assert.match(html, /id="stream-dashboard-rx-rate"/);
|
||||
assert.match(html, /id="stream-dashboard-tx-rate"/);
|
||||
assert.doesNotMatch(html.slice(html.indexOf('id="panel-overview"'), html.indexOf('class="overview-grid"')), /<article[^>]*tabindex="0"/,
|
||||
'metric cards must not introduce redundant keyboard stops');
|
||||
|
||||
const cardIds = [...html.matchAll(/<article class="stat[^"]*" data-dashboard-widget="([^"]+)"/g)].map(m => m[1]);
|
||||
assert.equal(cardIds.length, 16, 'existing Overview cards must be retained');
|
||||
const cards = new Map(cardIds.map(id => [id, { dataset: { dashboardWidget: id }, hidden: id.startsWith('stream_') }]));
|
||||
const grid = { querySelectorAll: () => [...cards.values()], append(node) { assert(cards.has(node.dataset.dashboardWidget)); } };
|
||||
const elements = new Map();
|
||||
for (const name of ['overview', 'overview-detail', 'traffic', 'traffic-detail', 'sessions', 'sessions-detail', 'health', 'health-detail', 'rx', 'tx', 'rx-rate', 'tx-rate']) {
|
||||
elements.set('stream-dashboard-' + name, { textContent: '-', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } });
|
||||
}
|
||||
const status = { streams: {
|
||||
active: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
||||
desired: [{ id: 'alpha', protocol: 'tcp', enabled: true }],
|
||||
metrics: { alpha: { active: 5, bytes_in: 1024, bytes_out: 2048, backends: [{ id: 'a', enabled: true, status: 'healthy' }] } }
|
||||
} };
|
||||
const window = { rustProxyRefresh: { update() {} } };
|
||||
const context = {
|
||||
window, latestStatus: status, envelope: { config: { streams: status.streams.desired } },
|
||||
currentPanel: 'overview', authenticated: true, languagePacks: { size: 0 },
|
||||
$: id => elements.get(id), document: { querySelector(selector) {
|
||||
if (selector === '#panel-overview .stats-grid') return grid;
|
||||
const match = selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/);
|
||||
return match ? cards.get(match[1]) : null;
|
||||
} }, performance: { now: () => 1000 }, t: key => key,
|
||||
fmt: v => String(v), bytes: v => v + ' B', Math,
|
||||
normalizeRefreshPreferences: v => v || {}, renderRefreshPreferences() {},
|
||||
applyAdminTheme: v => v, applyAdminLanguage: v => v, applyAdminUiScale: v => v,
|
||||
applyAdminTimeFormat: v => v, applyAdminChartStyle: v => v,
|
||||
applyAdminButtonStyle: v => v, applyAdminTableDensity: v => v,
|
||||
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
|
||||
};
|
||||
vm.createContext(context);
|
||||
const constants = prefsSource.slice(prefsSource.indexOf('const ADMIN_DASHBOARD_WIDGETS'), prefsSource.indexOf('const CHART_STYLE_KEYS'));
|
||||
const normalize = prefsSource.slice(prefsSource.indexOf('function normalizeDashboardWidgets'), prefsSource.indexOf('function browserLanguage'));
|
||||
const applyLayout = prefsSource.slice(prefsSource.indexOf('function applyDashboardLayout'), prefsSource.indexOf('function dashboardWidgetLabel'));
|
||||
const applyPreferences = prefsSource.slice(prefsSource.indexOf('function applyAdminPreferences'), prefsSource.indexOf('try { applyAdminTheme('));
|
||||
vm.runInContext(`${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
||||
window.rustProxyPreferences = { current: () => context.getPrefs() };
|
||||
vm.runInContext(streamSource, context);
|
||||
|
||||
// First load (or page reload): defaults hide the new widgets.
|
||||
const initial = { dashboard_widgets: cardIds, dashboard_hidden_widgets: ids };
|
||||
context.applyAdminPreferencesTest(initial);
|
||||
assert(ids.every(id => cards.get(id).hidden), 'opt-in cards must start hidden');
|
||||
|
||||
// Server responds to a previously saved opt-in; at this point the old in-memory
|
||||
// prefs are still hidden. The new layout must win before adminPreferences is set.
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||
assert(ids.every(id => !cards.get(id).hidden), 'server-saved opt-in must reveal cards immediately');
|
||||
assert.equal(elements.get('stream-dashboard-sessions').textContent, '5', 'no need to wait for polling');
|
||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '1 / 1');
|
||||
assert.equal(elements.get('stream-dashboard-traffic').textContent, '-', 'cached repaint is not a traffic sample');
|
||||
|
||||
// Hide one widget and restore it, all within the same session. Normal cards stay put.
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ['stream_traffic'] });
|
||||
assert(cards.get('stream_traffic').hidden);
|
||||
assert(!cards.get('requests').hidden);
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||
assert(!cards.get('stream_traffic').hidden);
|
||||
|
||||
// A disabled/absent Stream must not override an explicit Appearance opt-in.
|
||||
context.envelope.config.streams = [];
|
||||
context.latestStatus = { streams: { active: [], desired: [], metrics: {} } };
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: [] });
|
||||
assert(ids.every(id => !cards.get(id).hidden), 'show requested cards even with no configured Stream');
|
||||
assert.equal(elements.get('stream-dashboard-overview').textContent, '-');
|
||||
assert.equal(elements.get('stream-dashboard-overview-detail').textContent, 'streamWidgetUnavailable');
|
||||
context.applyAdminPreferencesTest({ dashboard_widgets: cardIds, dashboard_hidden_widgets: ids });
|
||||
assert(ids.every(id => cards.get(id).hidden), 'hiding cards must still work');
|
||||
assert(!cards.get('requests').hidden, 'legacy widgets must retain their visibility');
|
||||
assert.match(css, /dashboard-widget-setting:is\(:hover, :focus-within\) \.dashboard-widget-description/, 'description is a tooltip');
|
||||
assert.match(css, /\.dashboard-widget-description \{[^}]*position: absolute/s, 'tooltip must not occupy a second row');
|
||||
assert.match(prefsSource, /toggle\.setAttribute\("aria-describedby", description\.id\)/, 'description must be accessible');
|
||||
// Editor-specific regressions: avoid the redundant enable hint and label the
|
||||
// two distinct inheritance layers instead of saying "Inherit" everywhere.
|
||||
const editor = fs.readFileSync('src/admin/web/streams.js', 'utf8');
|
||||
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||
const en = JSON.parse(fs.readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||
assert(editor.includes('enabledLabel?.querySelector(".vh-enable-hint")?.remove()'));
|
||||
const vhostEditor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
|
||||
assert.match(vhostEditor, /window\.rustProxyEnableControl\(enabledControl, "vhEnableHint"\)/);
|
||||
assert.match(vhostEditor, /enabledLabel\?\.querySelector\("\.vh-enable-hint"\)\?\.remove\(\)/, 'vhost editor must not show the redundant active hint');
|
||||
assert.match(vhostEditor, /enabledLabel\?\.classList\.add\("vh-enable-no-hint"\)/);
|
||||
|
||||
assert(editor.includes('["inherit", "streamMetricsDefaultOff"]'));
|
||||
assert(editor.includes('["inherit", "streamMetricsFromStream"]'));
|
||||
for (const locale of [pl, en]) {
|
||||
for (const key of ['streamMetricsDefaultOff', 'streamMetricsFromStream', 'streamMetricsHelp', 'streamBackendDetailMetrics'])
|
||||
assert(locale[key] && locale[key].length > 5, `missing ${key}`);
|
||||
}
|
||||
assert.equal(pl.streamMetricsFromStream, 'Z ustawień Stream');
|
||||
assert.equal(pl.streamMetricsDefaultOff, 'Domyślnie wyłączone');
|
||||
console.log('PASS: Stream visibility, inline Overview legends, preserved Appearance tooltip and Vhost enable label.');
|
||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
||||
const elements = new Map();
|
||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||
const status = {
|
||||
version: '0.31.9', uptime_seconds: 4201,
|
||||
version: '0.32.0', uptime_seconds: 4201,
|
||||
runtime: {
|
||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||
@@ -63,7 +63,7 @@ const context = {
|
||||
vm.runInNewContext(code, context);
|
||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.9');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.32.0');
|
||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||
|
||||
Reference in new issue
Block a user