155 lines
12 KiB
JavaScript
155 lines
12 KiB
JavaScript
// 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\s*>\s*\.stat-chart-legend/,
|
|
'in-card chart legends must be visible, including on touch devices');
|
|
assert.match(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);\s*\}/,
|
|
'desktop Overview must retain the original six cards per row');
|
|
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*\{\s*grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\);\s*\}/,
|
|
'do not replace the original six-column Overview with a four-column layout');
|
|
assert.doesNotMatch(css, /#panel-overview \.stats-grid\s*>\s*\.stat\s*\{[^}]*\bmin-height\s*:/s,
|
|
'0.32.0 fixed height produced empty tiles; let inline metric legends size the row');
|
|
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('id="panel-vhosts"')), /<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.filter(id => !id.startsWith('backend_') && !id.startsWith('extra_')).length, 16, 'original Overview cards must be retained');
|
|
assert.equal(cardIds.length, 48, '30 existing metric/Stream/backend tiles and 18 optional small charts must be registered');
|
|
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, applyAdminCornerStyle: v => v, applyAdminTableDensity: v => v,
|
|
applyAdminDashboardDensity: v => v, refreshAppearanceUi() {},
|
|
};
|
|
vm.createContext(context);
|
|
const registryPrelude = `const ADMIN_DASHBOARD_WIDGET_REGISTRY = Object.freeze(${fs.readFileSync("src/admin/web/dashboard-widgets.json", "utf8")});`;
|
|
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(`${registryPrelude}\n${constants}\n${normalize}\nlet adminPreferences = null;\n${applyLayout}\n${applyPreferences}\nthis.getPrefs = () => adminPreferences;\nthis.applyAdminPreferencesTest = applyAdminPreferences;`, context);
|
|
window.rustProxyPreferences = { current: () => context.getPrefs() };
|
|
vm.runInContext(fs.readFileSync('src/admin/web/app/rate-sampling.js', 'utf8'), context);
|
|
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');
|
|
// Native title tooltips do not participate in layout or create horizontal scroll.
|
|
assert.doesNotMatch(css, /dashboard-widget-(?:description|setting-label-block)/, 'obsolete custom tooltip styles must be removed');
|
|
assert.match(css, /grid-template-columns: repeat\(auto-fit, minmax\(min\(260px, 100%\), 1fr\)\)/,
|
|
'effective Appearance card columns must preserve a readable name and right-aligned arrows');
|
|
assert.doesNotMatch(prefsSource, /role", "tooltip"|aria-describedby|dashboard-widget-description/,
|
|
'old popover DOM and associations must be removed');
|
|
const widgetList = { children: [], replaceChildren(...children) { this.children = children; } };
|
|
const makeElement = (tag, text, className) => ({
|
|
tag, textContent: text || '', className, dataset: {}, children: [], attributes: {},
|
|
classList: { toggle() {} }, setAttribute(name, value) { this.attributes[name] = value; },
|
|
append(...children) { this.children.push(...children); }, addEventListener() {},
|
|
});
|
|
for (const card of cards.values()) card.querySelector = () => ({ textContent: card.dataset.dashboardWidget });
|
|
elements.set('dashboard-widget-list', widgetList);
|
|
context.el = makeElement;
|
|
context.CSS = { escape: value => value };
|
|
vm.runInContext(prefsSource.slice(prefsSource.indexOf('function dashboardWidgetLabel'),
|
|
prefsSource.indexOf('function refreshChartStylePalette')), context);
|
|
context.renderDashboardWidgetSettings();
|
|
assert.equal(widgetList.children.length, 62, 'all cards and full-width panels retain settings controls');
|
|
for (const id of ids) {
|
|
const row = widgetList.children.find(item => item.dataset.dashboardWidgetSetting === id);
|
|
const button = row.children[0];
|
|
const key = ({ stream_overview: 'streamWidgetOverviewDescription',
|
|
stream_traffic: 'streamWidgetTrafficDescription', stream_sessions: 'streamWidgetSessionsDescription',
|
|
stream_backend_health: 'streamWidgetHealthDescription' })[id];
|
|
assert.equal(row.title, key, `${id} must have a localized browser tooltip`);
|
|
assert.equal(button.attributes['aria-description'], key, 'the tooltip must remain accessible');
|
|
assert.equal(button.children.length, 2, 'Appearance shows only the visibility indicator and label');
|
|
assert.equal(row.children.length, 2, 'Appearance keeps badge metadata in the visual editor, not in each setting');
|
|
assert.equal(row.children[1].className, 'dashboard-widget-setting-actions', 'move actions remain on the right');
|
|
}
|
|
assert.equal(widgetList.children.filter(row => row.title).length, 50,
|
|
'Stream, backend, performance and full-panel entries have translated descriptions');
|
|
// 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, scroll-safe native Appearance tooltips and Vhost enable label.');
|