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'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; 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 = renderAdminIndex(root); class Element { constructor() { this.attributes = new Map(); this.style = {}; this.textContent = ''; this.children = []; this.hidden = false; } getAttribute(key) { return this.attributes.get(key) ?? null; } setAttribute(key, value) { this.attributes.set(key, String(value)); } removeAttribute(key) { this.attributes.delete(key); } append(...children) { this.children.push(...children); } replaceChildren(...children) { this.children = children; } } const ids = new Map([...html.matchAll(/\bid="([^"]+)"/g)].map(([, id]) => [id, new Element()])); const widgets = new Map([...html.matchAll(/data-dashboard-widget="([^"]+)"/g)].map(([, id]) => [id, new Element()])); const listeners = new Map(); const document = { hidden: false, addEventListener: (type, handler) => listeners.set(type, handler), querySelector: selector => widgets.get(selector.match(/^\[data-dashboard-widget="([^"]+)"\]$/)?.[1]) || null, createElement: () => new Element(), }; const samples = { getLimit: () => 60, trim: values => { if (values.length > 60) values.splice(0, values.length - 60); }, onChange: () => {}, }; const window = { addEventListener() {}, rustProxyOverviewSamples: samples, rustProxyRefresh: { current: () => ({ overview_seconds: 3 }) }, }; let now = 1000; const context = vm.createContext({ window, document, performance: { now: () => now }, currentPanel: 'overview', latestStatus: null, $: id => ids.get(id) || null, fmt: (value, n = 0) => Number(value).toFixed(n), fmtBytes: value => `${value} B`, t: value => value, }); for (const file of ['app/rate-sampling.js', 'app/chart-scales.js', 'app/overview-wide-metrics.js', 'app/web-overview.js']) vm.runInContext(code(file), context, { filename: file }); const web = { requests: 200, status_2xx: 190, status_3xx: 2, status_4xx: 5, status_5xx: 3, static_requests: 100, php_requests: 80, other_requests: 20, methods: [160, 20, 10, 5, 5], selected_status: [0, 0, 0, 0, 0, 0, 0, 0], response_headers_us: 300000, response_bytes: 1200000, active_headers: 2, }; const php = { metrics: { requests: 80, response_headers_us: 100000, response_bytes: 320000, failures: Array(9).fill(0) }, capacity_limit: 6, capacity_used: 1, }; const status = { revision: 4, metrics: { requests: 200, http1_requests: 120, http2_requests: 60, http3_requests: 20, compression_gzip: 10, compression_br: 20, compression_zstd: 5, compression_deflate: 8 }, cache: { hits: 50, misses: 30 }, transform_metrics: { applied: 10, cache_hits: 5, skipped: 1, errors: 0 }, web_server_metrics: { demo: { web, php } }, }; function advance(delta) { for (const key of Object.keys(status.metrics)) status.metrics[key] += delta; for (const key of ['hits', 'misses']) status.cache[key] += delta; for (const key of Object.keys(status.transform_metrics)) status.transform_metrics[key] += delta; for (const key of ['requests', 'status_2xx', 'static_requests', 'php_requests', 'response_headers_us', 'response_bytes']) web[key] += delta; for (const key of ['requests', 'response_headers_us', 'response_bytes']) php.metrics[key] += delta; } function render() { window.rustProxyWideOverview.render(status); window.rustProxyWebOverview.render(status); } const line = id => ids.get(id).getAttribute('d') || ''; const compression = 'overview-wide-wide_compression_activity-gzip-line'; const website = 'www_requests-all-line'; const phpLine = 'www_php_requests-line'; const starts = path => (path.match(/M/g) || []).length; render(); // no first-interval rate: establish baseline only now = 4000; advance(12); render(); const beforeWide = line(compression), beforeWeb = line(website); const beforePhp = ids.get(phpLine).getAttribute('points'); assert.equal(starts(beforeWide), 1); assert.equal(starts(beforeWeb), 1); assert.ok(beforePhp); // Background tab -> a new generation, regardless of counter increments. document.hidden = true; listeners.get('visibilitychange')(); document.hidden = false; now = 100000; advance(600); render(); assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '-'); assert.equal(ids.get('www_requests-value').textContent, '-'); assert.equal(line(compression), beforeWide, 'wide rate chart must not receive a null point'); assert.equal(line(website), beforeWeb, 'WWW rate chart must not receive a null point'); assert.equal(ids.get(phpLine).getAttribute('points'), beforePhp, 'small PHP chart must preserve valid history'); // A fresh interval then connects to previous valid samples without a gap. now = 103000; advance(9); render(); assert.equal(starts(line(compression)), 1, 'wide chart must have one connected path'); assert.equal(starts(line(website)), 1, 'WWW chart must have one connected path'); assert.ok(line(compression).includes('L')); assert.ok(line(website).includes('L')); // Real measured zeros must continue to be plotted, not treated as pauses. now = 106000; render(); assert.equal(ids.get('overview-wide-wide_compression_activity-summary').textContent, '0.0/s'); assert.equal(ids.get('www_requests-value').textContent, '0.0/s'); assert.equal(starts(line(compression)), 1); assert.equal(starts(line(website)), 1); assert.match(code('web-server.js'), /if \(record && rate !== null\) \{ previous\.push\(rate\)/, 'Web Server chart must also skip unavailable rates instead of plotting a zero dip'); console.log('PASS: resumed wide/WWW/PHP charts stay connected; first invalid rate is unavailable; measured zero persists');