// 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"')), /]*tabindex="0"/, 'metric cards must not introduce redundant keyboard stops'); const cardIds = [...html.matchAll(/
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.');