From d626ed214d57b7c1b26920c8b7d0495d51f5171f Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Fri, 19 Jun 2026 00:37:43 -0700 Subject: [PATCH] feat(test): live, self-updating test dashboard (the continuous guiding light) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A local read-only web dashboard (npm run dashboard → http://localhost:3199) that watches every graphdone.unified-report/1 run across BOTH repos (Core test-artifacts/unified* + Cloud live-full-report/), accumulates trend history that survives Core's per-run overwrite, and updates itself LIVE over SSE when a new run lands — no zip, no manual refresh. - Performance & health trend charts over time (suite pass-rate/failures/duration; graph idle/drag/interaction FPS, load, tick cost, query p95, drift; physics settle; VLM score) — hand-rolled inline SVG, zero deps. - Runs timeline + per-check drill-down (citable §-refs) with embedded screenshots and video clips; a media gallery. - Append-only JSONL history + retention-capped media snapshots of clobber-prone runs (under the already-gitignored test-artifacts/dashboard/). Zero runtime deps (node:http + SSE + native-ESM modules shared by Node tests and the browser). Read-only, bound to 127.0.0.1; media served only from within a run's report dir (realpath-checked, traversal/symlink-escape rejected, media-ext allowlist) with HTTP Range support; CSP on the shell; /static is a 3-file allowlist. 31 unit tests; hardened against a 4-lens adversarial review (Range suffix-bytes, runId/snapshot/history-staleness, canonical metric keys). Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 + CLAUDE.md | 13 ++ Makefile | 7 +- package.json | 2 + tests/lib/dashboard/README.md | 78 ++++++++ tests/lib/dashboard/app.mjs | 235 ++++++++++++++++++++++++ tests/lib/dashboard/charts.mjs | 111 ++++++++++++ tests/lib/dashboard/dashboard.test.mjs | 230 ++++++++++++++++++++++++ tests/lib/dashboard/format.mjs | 70 ++++++++ tests/lib/dashboard/history.mjs | 101 +++++++++++ tests/lib/dashboard/ingest.mjs | 129 +++++++++++++ tests/lib/dashboard/metrics.mjs | 125 +++++++++++++ tests/lib/dashboard/page.mjs | 84 +++++++++ tests/lib/dashboard/server.mjs | 239 +++++++++++++++++++++++++ 14 files changed, 1426 insertions(+), 1 deletion(-) create mode 100644 tests/lib/dashboard/README.md create mode 100644 tests/lib/dashboard/app.mjs create mode 100644 tests/lib/dashboard/charts.mjs create mode 100644 tests/lib/dashboard/dashboard.test.mjs create mode 100644 tests/lib/dashboard/format.mjs create mode 100644 tests/lib/dashboard/history.mjs create mode 100644 tests/lib/dashboard/ingest.mjs create mode 100644 tests/lib/dashboard/metrics.mjs create mode 100644 tests/lib/dashboard/page.mjs create mode 100644 tests/lib/dashboard/server.mjs diff --git a/.env.example b/.env.example index 6ca28ff9..48c9b82e 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ PORT=4127 NODE_ENV=development +# Live test dashboard (optional) — `npm run dashboard` serves it on this port +DASHBOARD_PORT=3199 + # SSL/TLS Configuration SSL_ENABLED=false # Set to true to enable HTTPS/TLS encryption diff --git a/CLAUDE.md b/CLAUDE.md index d094aee3..01c8fc76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,6 +179,19 @@ make test-report # open the latest unified HTML report open test-artifacts/unified/report.html ``` +**📊 Live test dashboard (the continuous "guiding light"):** +```bash +npm run dashboard # serve at http://localhost:3199 (read-only, localhost) +npm run dashboard:open # …and open the browser +make dashboard +``` +Leave it running while you work. It watches every `graphdone.unified-report/1` run +in BOTH repos (Core `test-artifacts/unified*` + Cloud `live-full-report/`), +keeps append-only trend history that survives Core's per-run overwrite, and +**updates live over SSE** when a new run lands — performance trend charts, a runs +timeline, per-check drill-down with embedded screenshots + video clips. Zero deps +(`node:http` + SSE). See `tests/lib/dashboard/README.md`. + The unified harness (`tests/run-unified.mjs` + `tests/sequences/unified.config.mjs`) is the single entry; profiles are `smoke|pr|full|report`. The test tree is organised as `tests/e2e//`, `tests/diagnostics//`, `tests/integration/`, with diff --git a/Makefile b/Makefile index 71dc0d98..af835609 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # GraphDone Makefile for Test Automation # Run all tests and generate HTML report with: make test-all -.PHONY: help test test-all test-https test-e2e test-unit test-report deploy clean +.PHONY: help test test-all test-https test-e2e test-unit test-report dashboard deploy clean # Default target - show help help: @@ -55,6 +55,11 @@ test-report: echo "❌ No test report found. Run 'make test-all' (or npm run test:unified) first."; \ fi +# Live test dashboard (auto-opens browser; watches test runs + updates live) +dashboard: + @echo "📊 Starting live test dashboard at http://localhost:3199 ..." + @npm run dashboard:open + # Start production deployment deploy: @echo "🚀 Starting production deployment..." diff --git a/package.json b/package.json index bf34e42f..0b0531ca 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "test:unified:smoke": "node tests/run-unified.mjs --profile smoke", "test:unified:open": "node tests/run-unified.mjs --profile full --open", "test:unified:lib": "node --test tests/lib/", + "dashboard": "node tests/lib/dashboard/server.mjs", + "dashboard:open": "node tests/lib/dashboard/server.mjs --open", "test:installation": "./scripts/test-installation-simple.sh", "test:https": "node tests/integration/ssl-certificate-analysis.js && node tests/integration/mobile-https-compatibility-test.js", "test:report": "open test-artifacts/unified/report.html", diff --git a/tests/lib/dashboard/README.md b/tests/lib/dashboard/README.md new file mode 100644 index 00000000..09e77421 --- /dev/null +++ b/tests/lib/dashboard/README.md @@ -0,0 +1,78 @@ +# Live Test Dashboard + +A local, read-only web dashboard that is the **single guiding-light surface** for +test health and performance as you work. It watches every `graphdone.unified-report/1` +run produced across both repos, accumulates trend history, and **updates itself +live** (Server-Sent Events) the instant a new run lands — no zip, no manual refresh. + +```bash +npm run dashboard # serve at http://localhost:3199 +npm run dashboard:open # …and open the browser +make dashboard # same, via make +``` + +Then, in another terminal, run tests as usual — each completed run appears live: + +```bash +npm run test:unified # Core unified run → appears live +node ../GraphDone-Cloud/scripts/full-live-test.mjs # Cloudflare live run → appears live +npm run test:perf:scale # perf trends update on the Overview tab +``` + +## What it shows + +- **Overview** — performance & health **trend charts over time** (suite pass-rate, + failures, duration; graph idle/drag/interaction FPS, load time, tick cost, query + p95, layout drift; physics settle time; VLM visual score) plus the most recent runs. +- **Runs** — every indexed run (newest first) with a status bar; click any run to + drill into its sequences and **every citable check** (`§3.2.4`), with embedded + **screenshots and video clips** and failure detail. +- **Media** — a gallery of all screenshots + videos for any media-bearing run. + +## How it stays "continuous" + +Core overwrites `test-artifacts/unified/` on every run, so the dashboard keeps its +own append-only history under `test-artifacts/dashboard/`: + +- `runs.jsonl` / `metrics.jsonl` — tiny, unbounded trend history that survives the + overwrite. +- `runs//` — media **snapshots** of clobber-prone runs (retention-capped, default + 12) so old screenshots/videos remain browsable. Cloud `live-full-report//` + runs are already one-dir-per-run and are served in place. + +This whole store lives under the already-gitignored `test-artifacts/`. + +## Watched run roots + +| Root | Mode | Source | +|------|------|--------| +| `test-artifacts/unified` | slot (overwritten) | `npm run test:unified` | +| `test-artifacts/unified-cloudcheck` | slot | cloud-check profile | +| `test-artifacts/unified-perfcheck` | slot | perf-budgets profile | +| `test-artifacts/unified-showcase` | slot | showcase profile | +| `../GraphDone-Cloud/live-full-report/` | stamped (kept) | `full-live-test.mjs` | + +Override paths with `--core ` / `--cloud `; port with `--port` / +`DASHBOARD_PORT`; snapshot retention with `--keep `. + +## Architecture (zero runtime deps) + +`node:http` + SSE + `fs.watch`/poll. The browser app imports the **same** pure +modules the Node unit tests do — served as native ES modules, no bundler. + +- `format.mjs`, `charts.mjs` — pure + isomorphic (Node tests **and** `/static/`). +- `ingest.mjs` — discover + parse reports; classify slot vs stamped roots. +- `metrics.mjs` — normalize perf artifacts → one flat metric-point series. +- `history.mjs` — append-only JSONL store, media snapshot + retention. +- `page.mjs` — server-rendered HTML shell. +- `app.mjs` — browser entry (fetch APIs, render, live-update over SSE). +- `server.mjs` — orchestrator: index → serve → push. + +Unit tests: `node --test tests/lib/dashboard/`. + +## Security + +Read-only and **bound to `127.0.0.1`** (no LAN exposure, no auth surface). No +command-execution endpoints. Media is served only from within a run's own report +directory (real-path checked, traversal/symlink-escape rejected) and only for an +allowlist of media extensions; `/static/` serves a fixed three-file allowlist. diff --git a/tests/lib/dashboard/app.mjs b/tests/lib/dashboard/app.mjs new file mode 100644 index 00000000..a6656319 --- /dev/null +++ b/tests/lib/dashboard/app.mjs @@ -0,0 +1,235 @@ +/** + * Browser entry (native ES module, served at /static/app.mjs). Renders the live + * dashboard from the server JSON APIs and re-renders on SSE change events while + * preserving the open tab / run / expanded sections. Imports the SAME pure + * charts.mjs + format.mjs the server unit-tests — no bundler, one source. + */ +import { lineChart, statusBar } from './charts.mjs'; +import { STATUS_COLOR, STATUS_ICON, esc, fmtDuration, fmtAgo, fmtClock, pct, decodeMungedName } from './format.mjs'; + +const $ = (id) => document.getElementById(id); +const ui = { tab: 'overview', runId: null, mediaRunId: null, open: new Set() }; +let state = null; +const detailCache = new Map(); + +async function fetchJSON(url) { + const r = await fetch(url); + if (!r.ok) throw new Error(`${r.status} ${url}`); + return r.json(); +} + +const badge = (status) => `${STATUS_ICON[status] || ''} ${esc(status)}`; +const mediaUrl = (runId, href) => `/media/${encodeURIComponent(runId)}?href=${encodeURIComponent(href)}`; + +function renderHeader() { + const latest = state.runs[0]; + const roll = $('rollup'); + if (latest) { + roll.textContent = `${STATUS_ICON[latest.status] || ''} ${latest.status}`; + roll.style.background = STATUS_COLOR[latest.status] || '#475569'; + } + const t = latest ? latest.totals : { cases: 0, passed: 0, failed: 0, warned: 0, skipped: 0 }; + $('cards').innerHTML = [ + ['Runs', state.runs.length, '#e2e8f0'], + ['Latest cases', t.cases, '#e2e8f0'], + ['Passed', t.passed, STATUS_COLOR.passed], + ['Failed', t.failed, STATUS_COLOR.failed], + ['Warn', t.warned, STATUS_COLOR.warn], + ['Skipped', t.skipped, STATUS_COLOR.skipped], + ].map(([l, n, c]) => `
${n}
${l}
`).join(''); + const targets = [...new Set(state.runs.map((r) => r.target).filter(Boolean))]; + $('meta').innerHTML = latest + ? `latest: ${esc(latest.label)} · ${esc(latest.target || '')} · ${esc(fmtClock(latest.finishedAt))} (${esc(fmtAgo(latest.finishedAt))}) · ${state.metrics.length} metric points · targets: ${targets.map(esc).join(', ') || '—'}` + : 'no runs indexed yet — run a test suite (e.g. npm run test:unified) and it will appear here live'; +} + +function seriesFor(metricName) { + const pts = state.metrics.filter((m) => m.metric === metricName && isFinite(m.ts) && m.ts > 0); + const groups = new Map(); + for (const m of pts) { + const ctx = m.context || {}; + const lab = [ctx.quality, ctx.graphSize != null ? `${ctx.graphSize}n` : null, ctx.persona, ctx.profile && !ctx.quality ? ctx.profile : null] + .filter(Boolean).join(' ') || (ctx.target ? new URL(ctx.target).host : 'series'); + if (!groups.has(lab)) groups.set(lab, []); + groups.get(lab).push({ x: m.ts, y: m.value, title: `${fmtClock(m.ts)} — ${Math.round(m.value * 100) / 100}${m.unit || ''}` }); + } + return [...groups.entries()].map(([label, points]) => ({ label, points })); +} + +const CHART_DEFS = [ + { metric: 'suite.passRate', title: 'Suite pass rate over time', unit: '%' }, + { metric: 'suite.cases', title: 'Total checks over time', unit: '' }, + { metric: 'suite.failed', title: 'Failures over time', unit: '' }, + { metric: 'suite.durationMs', title: 'Suite duration', unit: 'ms' }, + { metric: 'graph.idleFps', title: 'Idle FPS', unit: 'fps' }, + { metric: 'graph.dragFps', title: 'Drag FPS', unit: 'fps' }, + { metric: 'graph.interactionFps', title: 'Interaction FPS (scale sweep)', unit: 'fps' }, + { metric: 'graph.loadMs', title: 'Graph load time', unit: 'ms', budget: null }, + { metric: 'graph.avgTickMs', title: 'Sim tick cost', unit: 'ms', budget: 8 }, + { metric: 'graph.queryP95Ms', title: 'Query p95 latency', unit: 'ms', budget: 800 }, + { metric: 'graph.driftPx', title: 'Layout drift', unit: 'px', budget: 25 }, + { metric: 'physics.settleSeconds', title: 'Physics settle time', unit: 's' }, + { metric: 'vlm.score', title: 'VLM visual score', unit: 'score' }, +]; + +function renderOverview() { + const charts = CHART_DEFS.map((d) => { + const series = seriesFor(d.metric); + if (!series.length) return ''; + return lineChart({ title: d.title, series, unit: d.unit, budget: d.budget ?? null, xIsTime: true }); + }).filter(Boolean).join(''); + $('view').innerHTML = `
Performance & health trends
` + + (charts ? `
${charts}
` : `

No metric points yet. Run perf suites (scale-sweep, large-graph, physics, vlm) or any unified run and trends accumulate here.

`) + + `
Recent runs
${state.runs.slice(0, 6).map(runRow).join('') || '

none

'}
`; + wireRuns(); +} + +function runRow(r) { + return `
+ ${badge(r.status)} + ${esc(r.source)} + ${esc(r.label)} + ${esc(r.target || '')} + ${r.sources && r.sources.length ? `${r.sources.map(esc).join(' · ')}` : ''} + + ${statusBar(r.totals)} + ${r.totals.cases} checks${r.mediaCount ? ` · ${r.mediaCount} media` : ''} + ${esc(fmtAgo(r.finishedAt))} +
`; +} + +function renderRuns() { + $('view').innerHTML = `
${state.runs.map(runRow).join('') || '

No runs indexed yet.

'}
`; + wireRuns(); +} + +function wireRuns() { + document.querySelectorAll('.run[data-run]').forEach((el) => el.onclick = () => openRun(el.getAttribute('data-run'))); +} + +function wireBack() { + const b = document.querySelector('#view .back'); + if (b) b.onclick = () => { ui.runId = null; setTab('runs'); }; +} + +async function openRun(runId) { + ui.runId = runId; + setTab('detail', false); + let detail = detailCache.get(runId); + let loadingTimer; + if (!detail) { + loadingTimer = setTimeout(() => { $('view').innerHTML = `← back

loading ${esc(runId)}…

`; wireBack(); }, 250); + try { detail = await fetchJSON(`/api/runs/${encodeURIComponent(runId)}`); detailCache.set(runId, detail); } + catch (e) { + clearTimeout(loadingTimer); + $('view').innerHTML = `← back

Could not load run: ${esc(String(e))}

`; + wireBack(); + return; + } + clearTimeout(loadingTimer); + } + renderDetail(detail); +} + +function caseHtml(runId, c) { + const atts = (c.attachments || []).map((a) => { + const url = mediaUrl(runId, a.href); + if (a.type === 'video') return `
${esc(decodeMungedName(a.name || a.href))}
`; + return `
${esc(a.name || '')}
${esc(decodeMungedName(a.name || a.href))}
`; + }).join(''); + return `
+ + ${c.ref ? `${esc(c.ref)}` : ''} + ${esc(c.title || '')} + ${c.error ? `
${esc(c.error)}
` : ''} + ${atts ? `
${atts}
` : ''} +
`; +} + +function seqHtml(runId, seq) { + const c = seq.counts || {}; + const key = `${runId}#${seq.ref || seq.id}`; + const open = ui.open.has(key) || seq.status === 'failed' || seq.status === 'warn'; + return `
+ ${badge(seq.status)}${seq.ref ? `§${esc(seq.ref)}` : ''}${esc(seq.title || seq.id)}${c.passed || 0}✓ ${c.failed || 0}✗ ${c.warned || 0}⚠ ${c.skipped || 0}⏭ + ${(seq.cases || []).map((cs) => caseHtml(runId, cs)).join('') || '

no per-case detail

'} +
`; +} + +function renderDetail(detail) { + const r = detail.report; + const s = detail.summary; + $('view').innerHTML = `← back to runs +
${badge(s.status)}  ${esc(s.label)} ${esc(s.source)}
+
${esc(s.target || '')} · ${esc(fmtClock(s.finishedAt))} · ${esc(fmtDuration(s.durationMs))} · ${s.totals.cases} checks · ${pct(s.totals.passed, s.totals.cases)}% pass${s.sources && s.sources.length ? ` · sources: ${s.sources.map(esc).join(', ')}` : ''}${s.snapshotTruncated ? ' · media too large to snapshot' : ''}
+ ${(r.sequences || []).map((seq) => seqHtml(s.runId, seq)).join('')}`; + wireBack(); + document.querySelectorAll('details[data-seq]').forEach((d) => d.addEventListener('toggle', () => { + const k = d.getAttribute('data-seq'); + if (d.open) ui.open.add(k); else ui.open.delete(k); + })); +} + +async function renderMedia() { + const mediaRuns = state.runs.filter((r) => r.mediaCount > 0); + if (!mediaRuns.length) { $('view').innerHTML = '

No screenshots or videos captured yet. Run the showcase/live-tour suites and clips appear here.

'; return; } + if (!ui.mediaRunId || !mediaRuns.find((r) => r.runId === ui.mediaRunId)) ui.mediaRunId = mediaRuns[0].runId; + const picker = `
${mediaRuns.map((r) => `
${esc(r.label)} · ${r.mediaCount}
`).join('')}
`; + $('view').innerHTML = picker + '

loading media…

'; + document.querySelectorAll('[data-mrun]').forEach((el) => el.onclick = () => { ui.mediaRunId = el.getAttribute('data-mrun'); renderMedia(); }); + let detail = detailCache.get(ui.mediaRunId); + try { if (!detail) { detail = await fetchJSON(`/api/runs/${encodeURIComponent(ui.mediaRunId)}`); detailCache.set(ui.mediaRunId, detail); } } catch { return; } + const cases = []; + for (const seq of detail.report.sequences || []) for (const c of seq.cases || []) if ((c.attachments || []).length) cases.push(c); + $('view').innerHTML = picker + `
${cases.flatMap((c) => (c.attachments || []).map((a) => { + const url = mediaUrl(ui.mediaRunId, a.href); + const cap = `${c.ref ? '§' + c.ref + ' ' : ''}${decodeMungedName(a.name || a.href)}`; + return a.type === 'video' + ? `
${esc(cap)}
` + : `
${esc(cap)}
`; + })).join('') || '

no media in this run

'}
`; + document.querySelectorAll('[data-mrun]').forEach((el) => el.onclick = () => { ui.mediaRunId = el.getAttribute('data-mrun'); renderMedia(); }); +} + +function setTab(tab, render = true) { + ui.tab = tab; + document.querySelectorAll('.tab[data-tab]').forEach((el) => el.classList.toggle('active', el.getAttribute('data-tab') === tab)); + if (!render) return; + if (tab === 'overview') renderOverview(); + else if (tab === 'runs') renderRuns(); + else if (tab === 'media') renderMedia(); +} + +function rerender() { + renderHeader(); + if (ui.tab === 'detail' && ui.runId) openRun(ui.runId); + else setTab(ui.tab); +} + +async function refresh() { + state = await fetchJSON('/api/state'); + rerender(); +} + +function connectSSE() { + const live = $('live'); + const label = $('liveLabel'); + const es = new EventSource('/api/events'); + es.onopen = () => { live.classList.add('on'); label.textContent = 'live'; }; + es.onerror = () => { live.classList.remove('on'); label.textContent = 'reconnecting…'; }; + es.addEventListener('changed', async () => { + live.classList.add('pulse'); setTimeout(() => live.classList.remove('pulse'), 700); + const y = window.scrollY; + detailCache.clear(); + try { await refresh(); window.scrollTo(0, y); } catch { /* */ } + }); +} + +document.querySelectorAll('.tab[data-tab]').forEach((el) => el.onclick = () => { ui.runId = null; setTab(el.getAttribute('data-tab')); }); +document.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const el = e.target.closest && e.target.closest('[data-run],[data-tab],[data-mrun],.back'); + if (el) { e.preventDefault(); el.click(); } +}); + +refresh().then(connectSSE).catch((e) => { $('meta').innerHTML = `${esc(String(e))}`; }); diff --git a/tests/lib/dashboard/charts.mjs b/tests/lib/dashboard/charts.mjs new file mode 100644 index 00000000..9ee76f67 --- /dev/null +++ b/tests/lib/dashboard/charts.mjs @@ -0,0 +1,111 @@ +/** + * Pure, isomorphic inline-SVG chart builders — no deps, no DOM. Returns SVG + * markup strings usable server-side (unit tests) and in the browser (innerHTML). + * Visual idiom mirrors tests/lib/reporting/generate-perf-report.mjs. + */ +import { esc } from './format.mjs'; + +export const SERIES_COLORS = ['#34d399', '#60a5fa', '#f472b6', '#fbbf24', '#a78bfa', '#22d3ee', '#fb923c', '#4ade80']; + +export function niceMax(v) { + if (!(v > 0)) return 1; + const exp = Math.floor(Math.log10(v)); + const base = Math.pow(10, exp); + const f = v / base; + const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; + return nice * base; +} + +export function bounds(series) { + let minX = Infinity, maxX = -Infinity, maxY = 0, minY = Infinity; + for (const s of series) for (const p of s.points) { + if (p.x < minX) minX = p.x; + if (p.x > maxX) maxX = p.x; + if (p.y > maxY) maxY = p.y; + if (p.y < minY) minY = p.y; + } + if (!isFinite(minX)) { minX = 0; maxX = 1; minY = 0; maxY = 1; } + return { minX, maxX, minY, maxY }; +} + +const fmtTick = (v) => { + const a = Math.abs(v); + if (a >= 1000) return `${Math.round(v / 100) / 10}k`; + if (a < 1 && a > 0) return v.toFixed(2); + return String(Math.round(v * 10) / 10); +}; + +const fmtDate = (ms) => { + const d = new Date(ms); + const p = (n) => String(n).padStart(2, '0'); + return `${p(d.getMonth() + 1)}/${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +}; + +/** + * Time-series (or any numeric-x) multi-line chart. + * series: [{ label, color?, points: [{ x, y, title? }] }] + */ +export function lineChart({ title = '', series = [], unit = '', budget = null, xIsTime = true, width = 560, height = 240 } = {}) { + const W = width, H = height, PADL = 52, PADB = 34, PADT = 10, PADR = 14; + const live = (series || []).map((s) => ({ ...s, points: (s.points || []).filter((p) => p && isFinite(p.x) && isFinite(p.y)).sort((a, b) => a.x - b.x) })).filter((s) => s.points.length); + if (!live.length) return `

${esc(title)}

no data yet

`; + const { minX, maxX, maxY } = bounds(live); + const top = niceMax(Math.max(maxY, budget != null ? budget : 0) * 1.08) || 1; + const sx = (x) => PADL + ((x - minX) / (maxX - minX || 1)) * (W - PADL - PADR); + const sy = (y) => H - PADB - (y / top) * (H - PADT - PADB); + + const grid = [0, 0.25, 0.5, 0.75, 1].map((f) => { + const y = sy(top * f); + return `${fmtTick(top * f)}${esc(unit)}`; + }).join(''); + + const distinctX = new Set(live.flatMap((s) => s.points.map((p) => p.x))).size; + const nTicks = Math.min(5, Math.max(1, distinctX)); + const tickX = (i) => (nTicks === 1 ? minX : minX + ((maxX - minX) * i) / (nTicks - 1)); + const xticks = Array.from({ length: nTicks }, (_, i) => { + const x = tickX(i); + const lab = xIsTime ? fmtDate(x) : fmtTick(x); + return `${esc(lab)}`; + }).join(''); + + const budgetLine = budget != null ? `budget ${esc(budget)}${esc(unit)}` : ''; + + const paths = live.map((s, i) => { + const c = s.color || SERIES_COLORS[i % SERIES_COLORS.length]; + const d = s.points.map((p, j) => `${j === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); + const dots = s.points.map((p) => `${esc(s.label)} — ${esc(p.title || `${fmtTick(p.y)}${unit}`)}`).join(''); + return `${dots}`; + }).join(''); + + const legend = live.length > 1 ? `
${live.map((s, i) => `● ${esc(s.label)}`).join(' ')}
` : ''; + return `

${esc(title)}

${legend}${grid}${xticks}${budgetLine}${paths}
`; +} + +/** + * Stacked horizontal bar of passed/failed/warned/skipped counts. + */ +export function statusBar(counts = {}, { width = 260, height = 14 } = {}) { + const order = [['passed', '#34d399'], ['warned', '#fbbf24'], ['failed', '#f87171'], ['skipped', '#94a3b8']]; + const total = order.reduce((a, [k]) => a + (counts[k] || 0), 0) || 1; + let x = 0; + const segs = order.map(([k, c]) => { + const w = ((counts[k] || 0) / total) * width; + const seg = w > 0 ? `${k}: ${counts[k] || 0}` : ''; + x += w; + return seg; + }).join(''); + return `${segs}`; +} + +/** + * Tiny sparkline of a single value series (latest-trend glance). + */ +export function sparkline(values = [], { color = '#60a5fa', width = 120, height = 28 } = {}) { + const v = values.filter((n) => isFinite(n)); + if (v.length < 2) return ``; + const max = Math.max(...v), min = Math.min(...v), span = max - min || 1; + const sx = (i) => (i / (v.length - 1)) * (width - 2) + 1; + const sy = (y) => height - 2 - ((y - min) / span) * (height - 4); + const d = v.map((y, i) => `${i === 0 ? 'M' : 'L'}${sx(i).toFixed(1)},${sy(y).toFixed(1)}`).join(' '); + return ``; +} diff --git a/tests/lib/dashboard/dashboard.test.mjs b/tests/lib/dashboard/dashboard.test.mjs new file mode 100644 index 00000000..6b47c671 --- /dev/null +++ b/tests/lib/dashboard/dashboard.test.mjs @@ -0,0 +1,230 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { rollupStatus, fmtDuration, fmtBytes, pct, decodeMungedName, esc, STATUS_COLOR } from './format.mjs'; +import { niceMax, bounds, lineChart, statusBar, sparkline } from './charts.mjs'; +import { isUnifiedReport, runIdFor, summarize, mediaCount, safeId } from './ingest.mjs'; +import { reportMetrics, scaleSweepMetrics, largeGraphMetrics, physicsMetrics, vlmMetrics, pointKey } from './metrics.mjs'; +import { mergeRuns, mergeMetrics, readJsonl, appendJsonl, snapshotRun, pruneSnapshots } from './history.mjs'; + +// ── format ──────────────────────────────────────────────────────────────── +test('rollupStatus: worst-of precedence', () => { + assert.equal(rollupStatus(['passed', 'warn', 'failed']), 'failed'); + assert.equal(rollupStatus(['passed', 'warn']), 'warn'); + assert.equal(rollupStatus(['passed', 'skipped']), 'passed'); + assert.equal(rollupStatus([]), 'skipped'); +}); +test('fmtDuration buckets', () => { + assert.equal(fmtDuration(450), '450ms'); + assert.equal(fmtDuration(5893), '5.9s'); + assert.equal(fmtDuration(98156), '1m 38s'); + assert.equal(fmtDuration(null), '—'); +}); +test('fmtBytes', () => { + assert.equal(fmtBytes(900), '900 B'); + assert.equal(fmtBytes(2048), '2 KB'); + assert.equal(fmtBytes(3 * 1024 * 1024), '3.0 MB'); +}); +test('pct', () => { + assert.equal(pct(212, 212), 100); + assert.equal(pct(1, 3), 33.3); + assert.equal(pct(0, 0), 0); +}); +test('decodeMungedName humanizes', () => { + assert.equal(decodeMungedName('tour-graph-overview.mp4'), 'Graph Overview'); + assert.equal(decodeMungedName('audit-route_'), 'Route'); + assert.equal(decodeMungedName(''), '—'); +}); +test('esc escapes html', () => { + assert.equal(esc('&'), '<a href="x">&'); +}); + +// ── charts ────────────────────────────────────────────────────────────────── +test('niceMax rounds up to 1/2/5×10^n', () => { + assert.equal(niceMax(0), 1); + assert.equal(niceMax(7), 10); + assert.equal(niceMax(42), 50); + assert.equal(niceMax(130), 200); +}); +test('bounds over multi-series', () => { + const b = bounds([{ points: [{ x: 1, y: 5 }, { x: 3, y: 9 }] }, { points: [{ x: 2, y: 2 }] }]); + assert.equal(b.minX, 1); assert.equal(b.maxX, 3); assert.equal(b.maxY, 9); +}); +test('lineChart returns svg with data, empty fallback without', () => { + const svg = lineChart({ title: 'FPS', series: [{ label: 'HIGH', points: [{ x: 1000, y: 58 }, { x: 2000, y: 60 }] }], unit: 'fps' }); + assert.match(svg, / { + const svg = lineChart({ title: 'x', series: [{ label: 'a', points: [{ x: 1, y: NaN }, { x: 2, y: 5 }, { x: 3, y: 7 }] }] }); + assert.match(svg, / { + const one = lineChart({ title: 'one', series: [{ label: 'a', points: [{ x: 1700000000000, y: 58 }] }], unit: 'fps' }); + assert.match(one, / { + const svg = statusBar({ passed: 8, failed: 2, warned: 0, skipped: 0 }); + assert.equal((svg.match(/ { + assert.doesNotMatch(sparkline([5]), / { + assert.ok(isUnifiedReport(sampleReport)); + assert.ok(!isUnifiedReport({ schema: 'other', sequences: [] })); + assert.ok(!isUnifiedReport({ schema: 'graphdone.unified-report/1' })); + assert.ok(!isUnifiedReport(null)); +}); +test('runIdFor: stamped uses dir, slot uses finishedAt, falls back to mtime', () => { + assert.equal(runIdFor('live-full-report', { mode: 'stamped', dirName: '2026-06-19T05-07Z', report: sampleReport }), 'live-full-report/2026-06-19T05-07Z'); + assert.equal(runIdFor('unified', { mode: 'slot', report: sampleReport }), 'unified/4000'); + assert.equal(runIdFor('unified', { mode: 'slot', report: {}, mtimeMs: 555.7 }), 'unified/556'); +}); +test('mediaCount counts attachments across cases', () => { + assert.equal(mediaCount(sampleReport), 2); +}); +test('summarize extracts totals + status + media', () => { + const s = summarize(sampleReport, { runId: 'unified/4000', source: 'unified', label: 'L', mode: 'slot' }); + assert.equal(s.status, 'passed'); + assert.equal(s.totals.cases, 5); + assert.equal(s.mediaCount, 2); + assert.equal(s.finishedAt, 4000); + assert.equal(s.durationMs, 3000); +}); +test('summarize handles a real Core report (no refs) and a Cloud report (hierarchical refs + sources)', () => { + const coreLike = { schema: 'graphdone.unified-report/1', startedAt: 1, finishedAt: 2, target: 'http://localhost:3127', env: { node: 'v20', profile: 'smoke' }, rollup: { status: 'passed', totals: { passed: 205, cases: 205, sequences: 1 } }, sequences: [{ id: 'unit-web', title: 'Web unit tests', kind: 'unit', status: 'passed', counts: { passed: 205 }, cases: [] }] }; + const cs = summarize(coreLike, { runId: 'unified/2', source: 'unified', label: 'U', mode: 'slot' }); + assert.equal(cs.totals.cases, 205); + assert.equal(cs.mediaCount, 0); + assert.equal(cs.sources, null); + const cloudLike = { schema: 'graphdone.unified-report/1', startedAt: 10, finishedAt: 20, target: 'https://graphdone-cloud.pages.dev', env: { node: 'v20', kind: 'live-full', sources: ['Live Audit', 'Red Team'] }, rollup: { status: 'warn', totals: { passed: 1, warned: 1, cases: 2, sequences: 1 } }, sequences: [{ id: 'audit-auth', ref: '1.1', title: 'Live Audit · AUTH', kind: 'audit', status: 'warn', counts: { passed: 1, warned: 1 }, cases: [{ ref: '1.1.1', title: 'x', status: 'passed', attachments: [{ type: 'image', name: 'route_', href: 'assets/audit-route_.jpg' }] }] }] }; + const cl = summarize(cloudLike, { runId: 'live-full-report/s', source: 'live-full-report', label: 'C', mode: 'stamped' }); + assert.deepEqual(cl.sources, ['Live Audit', 'Red Team']); + assert.equal(cl.profile, 'live-full'); + assert.equal(cl.mediaCount, 1); + assert.ok(!('sequenceTitles' in cl)); +}); +test('safeId strips unsafe chars', () => { + assert.equal(safeId('unified/4000'), 'unified_4000'); + assert.equal(safeId('a/../b'), 'a_.._b'); +}); + +// ── metrics ───────────────────────────────────────────────────────────────── +test('reportMetrics derives pass rate + duration', () => { + const m = reportMetrics(sampleReport); + const rate = m.find((x) => x.metric === 'suite.passRate'); + assert.equal(rate.value, 100); + assert.equal(rate.ts, 4000); + assert.ok(m.find((x) => x.metric === 'suite.durationMs').value === 3000); +}); +test('scaleSweepMetrics maps fps/load/etc with context', () => { + const m = scaleSweepMetrics({ size: 1000, quality: 'HIGH', interactionFps: 45, loadMs: 1200, settleMs: 3000, avgTickMs: 6, queryP95Ms: 300, rmsFromSavedPx: 4, fps: 58, timestampISO: '2026-06-19T00:00:00Z' }); + const fps = m.find((x) => x.metric === 'graph.interactionFps'); + assert.equal(fps.value, 45); + assert.equal(fps.context.graphSize, 1000); + assert.equal(fps.context.quality, 'HIGH'); + assert.equal(fps.better, 'higher'); +}); +test('largeGraphMetrics + physicsMetrics + vlmMetrics', () => { + assert.equal(largeGraphMetrics({ quality: 'HIGH', graph: 'g', idleFps: 60, panFps: 55, dragFps: 50, zoomFps: 48, zoomedInDragFps: 40 }, 123).length, 5); + const p = physicsMetrics({ graphId: 'g', summary: { settleSeconds: 4, overlapAfterOrganize: 0, labelOverlapAfter: 1 } }, 9); + assert.equal(p.find((x) => x.metric === 'physics.settleSeconds').value, 4); + const v = vlmMetrics({ generatedAt: '2026-06-19T00:00:00Z', results: [{ persona: 'new-user', context: 'home', verdict: { score: 0.8, latencyMs: 14000 } }] }); + assert.equal(v.length, 2); +}); +test('metric points carry a dedupe key', () => { + const m = reportMetrics(sampleReport); + assert.ok(m.every((x) => typeof x.key === 'string' && x.key.includes('|'))); +}); +test('pointKey is canonical: context key order does not change the key', () => { + assert.equal(pointKey('graph.idleFps', { quality: 'HIGH', graphSize: 1000 }, 5), pointKey('graph.idleFps', { graphSize: 1000, quality: 'HIGH' }, 5)); + assert.notEqual(pointKey('graph.idleFps', { quality: 'HIGH' }, 5), pointKey('graph.idleFps', { quality: 'LOW' }, 5)); + assert.notEqual(pointKey('graph.idleFps', { quality: 'HIGH' }, 5), pointKey('graph.idleFps', { quality: 'HIGH' }, 6)); +}); + +// ── history merge ───────────────────────────────────────────────────────── +test('mergeRuns dedupes by runId, reports added, keeps newest', () => { + const a = [{ runId: 'r1', finishedAt: 100 }]; + const { merged, added } = mergeRuns(a, [{ runId: 'r1', finishedAt: 200, status: 'passed' }, { runId: 'r2', finishedAt: 150 }]); + assert.equal(merged.length, 2); + assert.deepEqual(added.map((r) => r.runId).sort(), ['r1', 'r2']); + assert.equal(merged.find((r) => r.runId === 'r1').finishedAt, 200); + assert.equal(merged[0].runId, 'r1'); +}); +test('mergeRuns appends a CHANGED summary (same runId, different status), skips unchanged', () => { + const existing = [{ runId: 'unified/1000', finishedAt: 1000, status: 'passed', totals: { cases: 5 } }]; + const r1 = mergeRuns(existing, [{ runId: 'unified/1000', finishedAt: 1000, status: 'passed', totals: { cases: 5 } }]); + assert.equal(r1.added.length, 0); + const r2 = mergeRuns(existing, [{ runId: 'unified/1000', finishedAt: 1000, status: 'failed', totals: { cases: 5 } }]); + assert.equal(r2.added.length, 1); + assert.equal(r2.merged.find((r) => r.runId === 'unified/1000').status, 'failed'); +}); +test('mergeRuns tie-breaks equal finishedAt deterministically by runId', () => { + const { merged } = mergeRuns([], [{ runId: 'b', finishedAt: 5 }, { runId: 'a', finishedAt: 5 }]); + assert.deepEqual(merged.map((r) => r.runId), ['a', 'b']); +}); +test('mergeMetrics dedupes by key', () => { + const { merged, added } = mergeMetrics([{ key: 'a' }], [{ key: 'a' }, { key: 'b' }, { key: 'b' }]); + assert.equal(merged.length, 2); + assert.deepEqual(added.map((m) => m.key), ['b']); +}); + +// ── history fs round-trip ─────────────────────────────────────────────────── +test('jsonl append/read round-trip tolerates blank lines', () => { + const dir = mkdtempSync(join(tmpdir(), 'gd-dash-')); + const p = join(dir, 'runs.jsonl'); + appendJsonl(p, [{ runId: 'r1' }, { runId: 'r2' }]); + appendJsonl(p, [{ runId: 'r3' }]); + const rows = readJsonl(p); + assert.deepEqual(rows.map((r) => r.runId), ['r1', 'r2', 'r3']); + assert.deepEqual(readJsonl(join(dir, 'missing.jsonl')), []); +}); +test('snapshotRun copies report+assets, pruneSnapshots caps count', () => { + const dir = mkdtempSync(join(tmpdir(), 'gd-snap-')); + const store = join(dir, 'store'); + const src = join(dir, 'run'); + mkdirSync(join(src, 'assets'), { recursive: true }); + writeFileSync(join(src, 'report.json'), JSON.stringify(sampleReport)); + writeFileSync(join(src, 'assets', '0.png'), 'PNGDATA'); + const res = snapshotRun({ runId: 'unified/4000', dir: src, finishedAt: 4000 }, store); + assert.ok(existsSync(join(res.dest, 'report.json'))); + assert.ok(existsSync(join(res.dest, 'assets', '0.png'))); + assert.equal(res.truncated, false); + for (let i = 0; i < 15; i++) snapshotRun({ runId: `slot/${i}`, dir: src, finishedAt: i }, store); + const removed = pruneSnapshots(store, 12); + assert.ok(removed.length >= 1); +}); +test('snapshotRun flags truncation above maxBytes', () => { + const dir = mkdtempSync(join(tmpdir(), 'gd-trunc-')); + const src = join(dir, 'run'); + mkdirSync(join(src, 'assets'), { recursive: true }); + writeFileSync(join(src, 'report.json'), JSON.stringify(sampleReport)); + writeFileSync(join(src, 'assets', 'big.bin'), Buffer.alloc(4096)); + const res = snapshotRun({ runId: 'unified/9', dir: src, finishedAt: 9 }, join(dir, 'store'), { maxBytes: 1024 }); + assert.equal(res.truncated, true); + assert.ok(existsSync(join(res.dest, 'report.json'))); + assert.ok(!existsSync(join(res.dest, 'assets'))); +}); diff --git a/tests/lib/dashboard/format.mjs b/tests/lib/dashboard/format.mjs new file mode 100644 index 00000000..f8e8e5bd --- /dev/null +++ b/tests/lib/dashboard/format.mjs @@ -0,0 +1,70 @@ +/** + * Pure, isomorphic formatting helpers shared by the dashboard server (Node) and + * the browser app (served as a native ES module). No I/O, no Node builtins — so + * it loads unchanged in both. Status vocab + colors mirror reporting/html.mjs. + */ +export const STATUS_COLOR = { passed: '#34d399', failed: '#f87171', warn: '#fbbf24', skipped: '#94a3b8' }; +export const STATUS_ICON = { passed: '✅', failed: '❌', warn: '⚠️', skipped: '⏭️' }; +export const STATUS_RANK = { failed: 3, warn: 2, passed: 1, skipped: 0 }; + +export function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +export function rollupStatus(statuses) { + let best = 'skipped'; + for (const s of statuses) if ((STATUS_RANK[s] ?? 0) > (STATUS_RANK[best] ?? 0)) best = s; + return best; +} + +export function fmtDuration(ms) { + if (ms == null || !isFinite(ms)) return '—'; + if (ms < 1000) return `${Math.round(ms)}ms`; + const s = ms / 1000; + if (s < 60) return `${s.toFixed(1)}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s - m * 60); + return `${m}m ${rem}s`; +} + +export function fmtBytes(n) { + if (n == null || !isFinite(n)) return '—'; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export function pct(part, total) { + if (!total) return 0; + return Math.round((part / total) * 1000) / 10; +} + +export function fmtClock(ms) { + if (!ms) return '—'; + const d = new Date(ms); + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +export function fmtAgo(ms, now) { + const ref = now ?? Date.now(); + const diff = ref - ms; + if (!isFinite(diff) || diff < 0) return fmtClock(ms); + const s = Math.floor(diff / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + return `${d}d ago`; +} + +export function decodeMungedName(name) { + return String(name ?? '') + .replace(/\.[a-z0-9]+$/i, '') + .replace(/^(audit|tour|redteam|flow)[-_]/i, '') + .replace(/[-_]+/g, ' ') + .trim() + .replace(/\b\w/g, (c) => c.toUpperCase()) || '—'; +} diff --git a/tests/lib/dashboard/history.mjs b/tests/lib/dashboard/history.mjs new file mode 100644 index 00000000..c5fa89bf --- /dev/null +++ b/tests/lib/dashboard/history.mjs @@ -0,0 +1,101 @@ +/** + * Append-only history store under test-artifacts/dashboard/. runs.jsonl + + * metrics.jsonl give unbounded trend history (tiny) that survives Core's + * overwrite of test-artifacts/unified/ each run. For clobber-prone "slot" runs + * we snapshot report.json + assets into runs// so media drill-down survives + * the next run; snapshots are retention-capped. Pure merge/dedupe helpers are + * exported separately for unit tests. + */ +import { existsSync, mkdirSync, readFileSync, appendFileSync, readdirSync, statSync, cpSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { safeId } from './ingest.mjs'; + +const sortKey = (r) => JSON.stringify({ ...r, available: undefined }); + +export function mergeRuns(existing, incoming) { + const byId = new Map(existing.map((r) => [r.runId, r])); + const added = []; + for (const r of incoming) { + const prev = byId.get(r.runId); + const changed = !prev || sortKey(r) !== sortKey(prev); + if (changed) { added.push(r); byId.set(r.runId, r); } + } + const merged = [...byId.values()].sort((a, b) => (b.finishedAt || 0) - (a.finishedAt || 0) || String(a.runId).localeCompare(String(b.runId))); + return { merged, added }; +} + +export function mergeMetrics(existing, incoming) { + const seen = new Set(existing.map((m) => m.key)); + const added = []; + for (const m of incoming) if (m.key && !seen.has(m.key)) { seen.add(m.key); added.push(m); } + return { merged: existing.concat(added), added }; +} + +export function readJsonl(path) { + if (!existsSync(path)) return []; + const out = []; + for (const line of readFileSync(path, 'utf8').split('\n')) { + const t = line.trim(); + if (!t) continue; + try { out.push(JSON.parse(t)); } catch { /* skip a torn trailing line */ } + } + return out; +} + +export function appendJsonl(path, records) { + if (!records.length) return; + ensureDir(join(path, '..')); + appendFileSync(path, records.map((r) => JSON.stringify(r)).join('\n') + '\n'); +} + +function ensureDir(d) { if (!existsSync(d)) mkdirSync(d, { recursive: true }); } + +function dirSize(dir) { + let total = 0; + const walk = (d) => { + for (const name of readdirSync(d)) { + const p = join(d, name); + const st = statSync(p); + if (st.isDirectory()) walk(p); else total += st.size; + } + }; + try { walk(dir); } catch { /* */ } + return total; +} + +/** + * Snapshot a slot run's report.json + assets/ into /runs//. + * Skips the assets copy (but still copies report.json) when assets exceed + * maxBytes, returning truncated:true so the caller can log it (no silent drop). + */ +export function snapshotRun(run, storeDir, { maxBytes = 200 * 1024 * 1024 } = {}) { + const dest = join(storeDir, 'runs', safeId(run.runId)); + ensureDir(dest); + const destAssets = join(dest, 'assets'); + if (existsSync(destAssets)) rmSync(destAssets, { recursive: true, force: true }); + const srcReport = join(run.dir, 'report.json'); + if (existsSync(srcReport)) cpSync(srcReport, join(dest, 'report.json')); + const srcAssets = join(run.dir, 'assets'); + let truncated = false, bytes = 0; + if (existsSync(srcAssets)) { + bytes = dirSize(srcAssets); + if (bytes <= maxBytes) cpSync(srcAssets, destAssets, { recursive: true }); + else truncated = true; + } + writeFileSync(join(dest, '.meta.json'), JSON.stringify({ runId: run.runId, finishedAt: run.finishedAt, truncated }, null, 2)); + return { dest, bytes, truncated }; +} + +export function pruneSnapshots(storeDir, keep = 12) { + const runsDir = join(storeDir, 'runs'); + if (!existsSync(runsDir)) return []; + const dirs = readdirSync(runsDir) + .map((d) => join(runsDir, d)) + .filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + const removed = []; + for (const old of dirs.slice(keep)) { + try { rmSync(old, { recursive: true, force: true }); removed.push(old); } catch { /* */ } + } + return removed; +} diff --git a/tests/lib/dashboard/ingest.mjs b/tests/lib/dashboard/ingest.mjs new file mode 100644 index 00000000..7891fba4 --- /dev/null +++ b/tests/lib/dashboard/ingest.mjs @@ -0,0 +1,129 @@ +/** + * Discovers + parses graphdone.unified-report/1 reports across configured run + * roots. Pure transforms (runIdFor/summarize/isUnifiedReport) are separated from + * the fs scan so they unit-test without disk. A "slot" root is overwritten in + * place each run (Core test-artifacts/unified*) → its runId keys on finishedAt so + * each distinct run is a distinct history entry; a "stamped" root keeps one dir + * per run (Cloud live-full-report/) → its runId keys on the dir name. + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { rollupStatus } from './format.mjs'; + +export function isUnifiedReport(obj) { + return !!obj && typeof obj === 'object' && typeof obj.schema === 'string' && obj.schema.startsWith('graphdone.unified-report/') && Array.isArray(obj.sequences); +} + +export function safeId(s) { + return String(s).replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +export function runIdFor(sourceName, { mode, dirName, report, mtimeMs }) { + if (mode === 'stamped' && dirName) return `${sourceName}/${dirName}`; + const stamp = report?.finishedAt || report?.startedAt || Math.round(mtimeMs || 0) || 0; + return `${sourceName}/${stamp}`; +} + +export function mediaCount(report) { + let n = 0; + for (const seq of report.sequences || []) for (const c of seq.cases || []) n += (c.attachments || []).length; + return n; +} + +export function summarize(report, extra = {}) { + const totals = (report.rollup && report.rollup.totals) || {}; + const seqStatuses = (report.sequences || []).map((s) => s.status); + return { + runId: extra.runId, + source: extra.source, + label: extra.label, + mode: extra.mode, + target: report.target || null, + profile: (report.env && (report.env.profile || report.env.kind)) || null, + node: (report.env && report.env.node) || null, + sources: (report.env && report.env.sources) || null, + status: (report.rollup && report.rollup.status) || rollupStatus(seqStatuses), + totals: { + passed: totals.passed || 0, + failed: totals.failed || 0, + warned: totals.warned || 0, + skipped: totals.skipped || 0, + cases: totals.cases || 0, + sequences: totals.sequences || (report.sequences || []).length, + }, + startedAt: report.startedAt || null, + finishedAt: report.finishedAt || report.startedAt || null, + durationMs: report.durationMs ?? ((report.finishedAt && report.startedAt) ? report.finishedAt - report.startedAt : null), + mediaCount: mediaCount(report), + }; +} + +export function readReport(reportPath) { + try { + const obj = JSON.parse(readFileSync(reportPath, 'utf8')); + return isUnifiedReport(obj) ? obj : null; + } catch { + return null; + } +} + +function listStampedDirs(rootPath) { + if (!existsSync(rootPath)) return []; + return readdirSync(rootPath) + .map((d) => join(rootPath, d)) + .filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }); +} + +/** + * roots: [{ name, label, path, mode: 'slot'|'stamped' }] + * returns discovered runs: [{ runId, source, label, mode, reportPath, dir, mtimeMs }] + */ +export function discover(roots) { + const found = []; + for (const root of roots) { + if (!existsSync(root.path)) continue; + if (root.mode === 'slot') { + const reportPath = join(root.path, 'report.json'); + if (!existsSync(reportPath)) continue; + const report = readReport(reportPath); + if (!report) continue; + const mt = safeMtime(reportPath); + found.push({ runId: runIdFor(root.name, { mode: 'slot', report, mtimeMs: mt }), source: root.name, label: root.label, mode: 'slot', reportPath, dir: root.path, mtimeMs: mt, report }); + } else { + for (const dir of listStampedDirs(root.path)) { + const reportPath = join(dir, 'report.json'); + if (!existsSync(reportPath)) continue; + const report = readReport(reportPath); + if (!report) continue; + found.push({ runId: runIdFor(root.name, { mode: 'stamped', dirName: basename(dir), report }), source: root.name, label: root.label, mode: 'stamped', reportPath, dir, mtimeMs: safeMtime(reportPath), report }); + } + } + } + found.sort((a, b) => (b.report.finishedAt || b.mtimeMs || 0) - (a.report.finishedAt || a.mtimeMs || 0)); + return found; +} + +function safeMtime(p) { + try { return statSync(p).mtimeMs; } catch { return 0; } +} +function safeSize(p) { + try { return statSync(p).size; } catch { return 0; } +} + +/** Signature of all discoverable reports (mtime+size), for cheap change-detection. */ +export function signature(roots) { + const parts = []; + for (const root of roots) { + if (!existsSync(root.path)) continue; + if (root.mode === 'slot') { + const p = join(root.path, 'report.json'); + if (existsSync(p)) parts.push(`${root.name}:${safeMtime(p)}:${safeSize(p)}`); + } else { + for (const dir of listStampedDirs(root.path)) { + const p = join(dir, 'report.json'); + if (existsSync(p)) parts.push(`${basename(dir)}:${safeMtime(p)}:${safeSize(p)}`); + } + } + } + return parts.sort().join('|'); +} diff --git a/tests/lib/dashboard/metrics.mjs b/tests/lib/dashboard/metrics.mjs new file mode 100644 index 00000000..fa8a7868 --- /dev/null +++ b/tests/lib/dashboard/metrics.mjs @@ -0,0 +1,125 @@ +/** + * Normalizes heterogeneous perf artifacts into one flat metric-point series the + * dashboard charts as trends: + * { metric, value, unit, better: 'higher'|'lower', context:{...}, ts, key } + * Pure transforms (one per artifact shape) are split from the fs scan so they + * unit-test against fixtures. `key` dedupes points across re-scans. + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +export function pointKey(metric, context, ts) { + const c = context || {}; + const canon = JSON.stringify(Object.keys(c).sort().reduce((a, k) => { a[k] = c[k] === undefined ? null : c[k]; return a; }, {})); + return `${metric}|${canon}|${ts || 0}`; +} + +const pt = (metric, value, unit, better, context, ts) => ( + isFinite(value) ? { metric, value: Number(value), unit, better, context: context || {}, ts: ts || 0, key: pointKey(metric, context, ts) } : null +); + +export function reportMetrics(report) { + const t = (report.rollup && report.rollup.totals) || {}; + const ts = report.finishedAt || report.startedAt || 0; + const total = t.cases || 0; + const passRate = total ? (t.passed / total) * 100 : 0; + const ctx = { target: report.target || null, profile: (report.env && (report.env.profile || report.env.kind)) || null }; + return [ + pt('suite.passRate', passRate, '%', 'higher', ctx, ts), + pt('suite.cases', total, 'count', 'higher', ctx, ts), + pt('suite.failed', t.failed || 0, 'count', 'lower', ctx, ts), + pt('suite.warned', t.warned || 0, 'count', 'lower', ctx, ts), + pt('suite.durationMs', report.durationMs ?? 0, 'ms', 'lower', ctx, ts), + ].filter(Boolean); +} + +export function scaleSweepMetrics(json) { + const ts = Date.parse(json.timestampISO || '') || 0; + const ctx = { graphSize: json.size, quality: json.quality }; + return [ + pt('graph.interactionFps', json.interactionFps, 'fps', 'higher', ctx, ts), + pt('graph.loadMs', json.loadMs, 'ms', 'lower', ctx, ts), + pt('graph.settleMs', json.settleMs, 'ms', 'lower', ctx, ts), + pt('graph.avgTickMs', json.avgTickMs, 'ms', 'lower', ctx, ts), + pt('graph.queryP95Ms', json.queryP95Ms, 'ms', 'lower', ctx, ts), + pt('graph.driftPx', json.rmsFromSavedPx, 'px', 'lower', ctx, ts), + pt('graph.idleFps', json.fps, 'fps', 'higher', ctx, ts), + ].filter(Boolean); +} + +export function largeGraphMetrics(json, ts) { + const ctx = { quality: json.quality, graph: json.graph }; + return [ + pt('graph.idleFps', json.idleFps, 'fps', 'higher', ctx, ts), + pt('graph.panFps', json.panFps, 'fps', 'higher', ctx, ts), + pt('graph.dragFps', json.dragFps, 'fps', 'higher', ctx, ts), + pt('graph.zoomFps', json.zoomFps, 'fps', 'higher', ctx, ts), + pt('graph.zoomedInDragFps', json.zoomedInDragFps, 'fps', 'higher', ctx, ts), + ].filter(Boolean); +} + +export function physicsMetrics(json, ts) { + const s = json.summary || {}; + const ctx = { graphId: json.graphId }; + return [ + pt('physics.settleSeconds', s.settleSeconds, 's', 'lower', ctx, ts), + pt('physics.overlapAfterOrganize', s.overlapAfterOrganize, 'count', 'lower', ctx, ts), + pt('physics.labelOverlapAfter', s.labelOverlapAfter, 'count', 'lower', ctx, ts), + ].filter(Boolean); +} + +export function vlmMetrics(json) { + const ts = Date.parse(json.generatedAt || '') || 0; + const out = []; + for (const r of json.results || []) { + const v = r.verdict || {}; + const ctx = { persona: r.persona, context: r.context }; + if (v.score != null) out.push(pt('vlm.score', v.score, 'score', 'higher', ctx, ts)); + if (v.latencyMs != null) out.push(pt('vlm.latencyMs', v.latencyMs, 'ms', 'lower', ctx, ts)); + } + return out.filter(Boolean); +} + +function readJson(p) { + try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } +} +function mtime(p) { + try { return statSync(p).mtimeMs; } catch { return 0; } +} + +/** Scan the Core test-artifacts dir for perf artifacts → metric points. */ +export function scanPerfArtifacts(artifactsDir) { + const out = []; + if (!existsSync(artifactsDir)) return out; + + const sweepDir = join(artifactsDir, 'scale-sweep'); + if (existsSync(sweepDir)) { + for (const f of readdirSync(sweepDir).filter((f) => f.endsWith('.json') && f !== 'index.json')) { + const j = readJson(join(sweepDir, f)); + if (j && j.size != null) out.push(...scaleSweepMetrics(j)); + } + } + + const largeDir = join(artifactsDir, 'large-graph'); + if (existsSync(largeDir)) { + for (const f of readdirSync(largeDir).filter((f) => f.endsWith('.json'))) { + const p = join(largeDir, f); + const j = readJson(p); + if (j && j.idleFps != null) out.push(...largeGraphMetrics(j, mtime(p))); + } + } + + const physicsPath = join(artifactsDir, 'physics', 'report.json'); + if (existsSync(physicsPath)) { + const j = readJson(physicsPath); + if (j && j.summary) out.push(...physicsMetrics(j, mtime(physicsPath))); + } + + const vlmPath = join(artifactsDir, 'vlm', 'results.json'); + if (existsSync(vlmPath)) { + const j = readJson(vlmPath); + if (j && Array.isArray(j.results)) out.push(...vlmMetrics(j)); + } + + return out; +} diff --git a/tests/lib/dashboard/page.mjs b/tests/lib/dashboard/page.mjs new file mode 100644 index 00000000..3d7345f1 --- /dev/null +++ b/tests/lib/dashboard/page.mjs @@ -0,0 +1,84 @@ +/** + * Server-rendered HTML shell for the dashboard. All live content is filled by the + * browser module /static/app.mjs (which imports the same pure charts.mjs + + * format.mjs over /static/). Theme mirrors reporting/html.mjs. + */ +export function renderShell() { + return ` + +GraphDone — Live Test Dashboard +
+
+

GraphDone — Live Test Dashboard

+ connecting… +
+
loading…
+
+
+ + + +
+
+
graphdone.unified-report/1 · live dashboard · read-only · localhost
+
+ +`; +} diff --git a/tests/lib/dashboard/server.mjs b/tests/lib/dashboard/server.mjs new file mode 100644 index 00000000..c6255222 --- /dev/null +++ b/tests/lib/dashboard/server.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Local, read-only live test dashboard. Watches the unified-report/1 run roots in + * GraphDone-Core (test-artifacts/unified*, overwritten each run) and GraphDone- + * Cloud (live-full-report/, one dir per run), accumulates trend history + * that survives the Core overwrite, snapshots clobber-prone runs' media, and + * serves a live SPA that pushes updates over SSE. + * + * node tests/lib/dashboard/server.mjs [--port 3199] [--core ] [--cloud ] [--open] + * + * Bound to 127.0.0.1 only (no auth, no LAN exposure). Serves no command surface. + */ +import { createServer } from 'node:http'; +import { createReadStream, existsSync, statSync, readFileSync, realpathSync, watch } from 'node:fs'; +import { resolve, join, sep, extname } from 'node:path'; +import { spawn } from 'node:child_process'; +import { discover, signature, safeId, summarize } from './ingest.mjs'; +import { reportMetrics, scanPerfArtifacts } from './metrics.mjs'; +import { mergeRuns, mergeMetrics, readJsonl, appendJsonl, snapshotRun, pruneSnapshots } from './history.mjs'; +import { renderShell } from './page.mjs'; + +const args = process.argv.slice(2); +const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : def; }; +const PORT = Number(flag('--port', process.env.DASHBOARD_PORT || 3199)); +const CORE = resolve(flag('--core', process.cwd())); +const CLOUD = resolve(flag('--cloud', join(CORE, '..', 'GraphDone-Cloud'))); +const OPEN = args.includes('--open'); +const KEEP = Number(flag('--keep', 12)); +const POLL_MS = 2500; + +const ARTIFACTS = join(CORE, 'test-artifacts'); +const STORE = join(ARTIFACTS, 'dashboard'); +const RUNS_JSONL = join(STORE, 'runs.jsonl'); +const METRICS_JSONL = join(STORE, 'metrics.jsonl'); + +const ROOTS = [ + { name: 'unified', label: 'Unified (latest)', path: join(ARTIFACTS, 'unified'), mode: 'slot' }, + { name: 'unified-cloudcheck', label: 'Cloud audit check', path: join(ARTIFACTS, 'unified-cloudcheck'), mode: 'slot' }, + { name: 'unified-perfcheck', label: 'Perf budgets', path: join(ARTIFACTS, 'unified-perfcheck'), mode: 'slot' }, + { name: 'unified-showcase', label: 'Showcase', path: join(ARTIFACTS, 'unified-showcase'), mode: 'slot' }, + { name: 'live-full-report', label: 'Cloudflare live', path: join(CLOUD, 'live-full-report'), mode: 'stamped' }, +]; + +const STATIC = new Set(['app.mjs', 'charts.mjs', 'format.mjs']); +const HERE = resolve(new URL('.', import.meta.url).pathname); + +const MIME = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.webp': 'image/webp', '.svg': 'image/svg+xml', '.webm': 'video/webm', '.mp4': 'video/mp4', +}; +const MEDIA_EXT = new Set(Object.keys(MIME)); + +let state = { generatedAt: 0, latest: null, runs: [], metrics: [], roots: [] }; +const runIndex = new Map(); +const sseClients = new Set(); + +function snapshotPaths(runId) { + const dir = join(STORE, 'runs', safeId(runId)); + return { dir, report: join(dir, 'report.json') }; +} + +function reindex() { + const discovered = discover(ROOTS); + const existingRuns = readJsonl(RUNS_JSONL); + + const summaries = []; + for (const d of discovered) { + const snap = snapshotPaths(d.runId); + const srcMtime = safeMtime(join(d.dir, 'report.json')); + const snapMtime = existsSync(snap.report) ? safeMtime(snap.report) : 0; + if (d.mode === 'slot' && srcMtime > snapMtime) { + try { + const res = snapshotRun(d, STORE, { maxBytes: 200 * 1024 * 1024 }); + if (res.truncated) console.warn(`⚠️ ${d.runId}: assets ${(res.bytes / 1e6).toFixed(0)}MB exceed snapshot cap — media not snapshotted (trend kept)`); + } catch (e) { console.warn(`⚠️ snapshot failed for ${d.runId}: ${e.message}`); } + } + const truncated = readTruncatedFlag(snap.dir); + summaries.push({ ...summarize(d.report, { runId: d.runId, source: d.source, label: d.label, mode: d.mode }), snapshotTruncated: truncated }); + } + + const { merged, added } = mergeRuns(existingRuns, summaries); + if (added.length) appendJsonl(RUNS_JSONL, added); + + const existingMetrics = readJsonl(METRICS_JSONL); + const freshMetrics = []; + for (const d of discovered) freshMetrics.push(...reportMetrics(d.report)); + freshMetrics.push(...scanPerfArtifacts(ARTIFACTS)); + const { added: metricsAdded } = mergeMetrics(existingMetrics, freshMetrics); + if (metricsAdded.length) appendJsonl(METRICS_JSONL, metricsAdded); + + runIndex.clear(); + for (const r of merged) { + const snap = snapshotPaths(r.runId); + const live = discovered.find((d) => d.runId === r.runId); + let reportPath = null, mediaBase = null; + if (existsSync(snap.report)) { reportPath = snap.report; mediaBase = snap.dir; } + else if (live) { reportPath = join(live.dir, 'report.json'); mediaBase = live.dir; } + runIndex.set(r.runId, { reportPath, mediaBase }); + r.available = !!reportPath; + } + + if (existsSync(join(STORE, 'runs'))) pruneSnapshots(STORE, KEEP); + + const allMetrics = readJsonl(METRICS_JSONL); + state = { + generatedAt: Date.now(), + latest: merged[0] ? merged[0].runId : null, + runs: merged, + metrics: allMetrics.slice(-6000), + roots: ROOTS.map((r) => ({ name: r.name, label: r.label, mode: r.mode, exists: existsSync(r.path), count: discovered.filter((d) => d.source === r.name).length })), + }; + return added.length + metricsAdded.length; +} + +function safeMtime(p) { + try { return statSync(p).mtimeMs; } catch { return 0; } +} + +function readTruncatedFlag(snapDir) { + const p = join(snapDir, '.meta.json'); + if (!existsSync(p)) return false; + try { return JSON.parse(readFileSync(p, 'utf8')).truncated === true; } + catch (e) { console.warn(`⚠️ unreadable ${p}: ${e.message}`); return false; } +} + +function broadcast() { + const payload = `event: changed\ndata: ${JSON.stringify({ latest: state.latest, generatedAt: state.generatedAt })}\n\n`; + for (const res of sseClients) { try { res.write(payload); } catch { /* */ } } +} + +function send(res, code, type, body, extra = {}) { + res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-cache', ...extra }); + res.end(body); +} + +function serveMedia(req, res, runId, href) { + const entry = runIndex.get(runId); + if (!entry || !entry.mediaBase) return send(res, 404, 'text/plain', 'unknown run'); + let base, target; + try { + base = realpathSync(entry.mediaBase); + target = realpathSync(resolve(base, href)); + } catch { return send(res, 404, 'text/plain', 'not found'); } + if (target !== base && !target.startsWith(base + sep)) return send(res, 403, 'text/plain', 'forbidden'); + const ext = extname(target).toLowerCase(); + if (!MEDIA_EXT.has(ext)) return send(res, 415, 'text/plain', 'unsupported'); + let st; + try { st = statSync(target); } catch { return send(res, 404, 'text/plain', 'not found'); } + if (!st.isFile()) return send(res, 404, 'text/plain', 'not found'); + const type = MIME[ext] || 'application/octet-stream'; + const range = req.headers.range; + if (range) { + const m = /bytes=(\d*)-(\d*)/.exec(range); + if (m && (m[1] || m[2])) { + let start, end; + if (m[1] === '') { start = Math.max(0, st.size - parseInt(m[2], 10)); end = st.size - 1; } + else { start = parseInt(m[1], 10); end = m[2] ? parseInt(m[2], 10) : st.size - 1; } + if (isNaN(start) || start < 0) start = 0; + if (isNaN(end) || end >= st.size) end = st.size - 1; + if (start > end) return send(res, 416, 'text/plain', 'range not satisfiable', { 'Content-Range': `bytes */${st.size}` }); + res.writeHead(206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'public, max-age=31536000, immutable' }); + return createReadStream(target, { start, end }).pipe(res); + } + } + res.writeHead(200, { 'Content-Type': type, 'Content-Length': st.size, 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=31536000, immutable' }); + createReadStream(target).pipe(res); +} + +const server = createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + const path = url.pathname; + if (req.method !== 'GET') return send(res, 405, 'text/plain', 'method not allowed'); + + if (path === '/') return send(res, 200, 'text/html; charset=utf-8', renderShell(), { 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'" }); + + if (path.startsWith('/static/')) { + const name = path.slice('/static/'.length); + if (!STATIC.has(name)) return send(res, 404, 'text/plain', 'not found'); + try { return send(res, 200, 'text/javascript; charset=utf-8', readFileSync(join(HERE, name)), { 'Cache-Control': 'no-cache' }); } + catch { return send(res, 404, 'text/plain', 'not found'); } + } + + if (path === '/api/state') return send(res, 200, 'application/json', JSON.stringify(state)); + + if (path.startsWith('/api/runs/')) { + const id = decodeURIComponent(path.slice('/api/runs/'.length)); + const entry = runIndex.get(id); + const summary = state.runs.find((r) => r.runId === id); + if (!entry || !entry.reportPath || !summary) return send(res, 404, 'application/json', JSON.stringify({ error: 'unknown or unavailable run', id })); + try { return send(res, 200, 'application/json', JSON.stringify({ runId: id, summary, report: JSON.parse(readFileSync(entry.reportPath, 'utf8')) })); } + catch { return send(res, 410, 'application/json', JSON.stringify({ error: 'artifacts no longer on disk', id })); } + } + + if (path.startsWith('/media/')) { + const id = decodeURIComponent(path.slice('/media/'.length)); + const href = url.searchParams.get('href'); + if (!href) return send(res, 400, 'text/plain', 'missing href'); + return serveMedia(req, res, id, href); + } + + if (path === '/api/events') { + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); + res.write(`event: changed\ndata: ${JSON.stringify({ latest: state.latest })}\n\n`); + sseClients.add(res); + const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* */ } }, 25000); + req.on('close', () => { clearInterval(hb); sseClients.delete(res); }); + return; + } + + return send(res, 404, 'text/plain', 'not found'); +}); + +let lastSig = ''; +let debounce = null; +function check(force) { + const sig = signature(ROOTS); + if (!force && sig === lastSig) return; + lastSig = sig; + try { reindex(); broadcast(); } catch (e) { console.warn(`reindex error: ${e.message}`); } +} +function scheduleCheck() { clearTimeout(debounce); debounce = setTimeout(() => check(false), 400); } + +server.listen(PORT, '127.0.0.1', () => { + lastSig = signature(ROOTS); + try { reindex(); } catch (e) { console.warn(`initial index error: ${e.message}`); } + const url = `http://localhost:${PORT}`; + console.log(`\n\x1b[1;36m📊 GraphDone live test dashboard\x1b[0m ${url}`); + console.log(` core : ${CORE}`); + console.log(` cloud: ${CLOUD}`); + console.log(` runs : ${state.runs.length} indexed · ${state.metrics.length} metric points\n`); + for (const root of ROOTS) { + try { if (existsSync(root.path)) watch(root.path, { recursive: true }, scheduleCheck); } catch { /* recursive watch unsupported → poll covers it */ } + } + setInterval(() => check(false), POLL_MS); + if (OPEN) { const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; try { spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* */ } } +}); + +process.on('SIGINT', () => { server.close(); process.exit(0); }); +process.on('SIGTERM', () => { server.close(); process.exit(0); });