Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<stamp>`),
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/<domain>/`, `tests/diagnostics/<concern>/`, `tests/integration/`, with
Expand Down
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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..."
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions tests/lib/dashboard/README.md
Original file line number Diff line number Diff line change
@@ -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/<id>/` — media **snapshots** of clobber-prone runs (retention-capped, default
12) so old screenshots/videos remain browsable. Cloud `live-full-report/<stamp>/`
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/<stamp>` | stamped (kept) | `full-live-test.mjs` |

Override paths with `--core <dir>` / `--cloud <dir>`; port with `--port` /
`DASHBOARD_PORT`; snapshot retention with `--keep <n>`.

## 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.
235 changes: 235 additions & 0 deletions tests/lib/dashboard/app.mjs
Original file line number Diff line number Diff line change
@@ -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) => `<span class="badge" style="background:${STATUS_COLOR[status] || '#888'}">${STATUS_ICON[status] || ''} ${esc(status)}</span>`;
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]) => `<div class="card"><div class="n" style="color:${c}">${n}</div><div class="l">${l}</div></div>`).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. <code>npm run test:unified</code>) 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 = `<div class="section-h">Performance &amp; health trends</div>`
+ (charts ? `<div class="charts">${charts}</div>` : `<p class="muted">No metric points yet. Run perf suites (scale-sweep, large-graph, physics, vlm) or any unified run and trends accumulate here.</p>`)
+ `<div class="section-h">Recent runs</div><div class="runlist">${state.runs.slice(0, 6).map(runRow).join('') || '<p class="muted">none</p>'}</div>`;
wireRuns();
}

function runRow(r) {
return `<div class="run" data-run="${esc(r.runId)}" tabindex="0" role="button" aria-label="${esc(r.label)} — ${esc(r.status)}, ${r.totals.cases} checks">
${badge(r.status)}
<span class="src">${esc(r.source)}</span>
<span class="rtitle">${esc(r.label)}</span>
<span class="rmeta">${esc(r.target || '')}</span>
${r.sources && r.sources.length ? `<span class="rmeta">${r.sources.map(esc).join(' · ')}</span>` : ''}
<span class="rspacer"></span>
${statusBar(r.totals)}
<span class="rmeta">${r.totals.cases} checks${r.mediaCount ? ` · ${r.mediaCount} media` : ''}</span>
<span class="rmeta">${esc(fmtAgo(r.finishedAt))}</span>
</div>`;
}

function renderRuns() {
$('view').innerHTML = `<div class="runlist">${state.runs.map(runRow).join('') || '<p class="empty-state">No runs indexed yet.</p>'}</div>`;
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 = `<span class="back" tabindex="0" role="button">← back</span><p class="muted">loading ${esc(runId)}…</p>`; wireBack(); }, 250);
try { detail = await fetchJSON(`/api/runs/${encodeURIComponent(runId)}`); detailCache.set(runId, detail); }
catch (e) {
clearTimeout(loadingTimer);
$('view').innerHTML = `<span class="back" tabindex="0" role="button">← back</span><p class="err">Could not load run: ${esc(String(e))}</p>`;
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 `<figure class="att"><video src="${esc(url)}" controls preload="metadata" playsinline></video><figcaption>${esc(decodeMungedName(a.name || a.href))}</figcaption></figure>`;
return `<figure class="att"><a href="${esc(url)}" target="_blank"><img src="${esc(url)}" loading="lazy" alt="${esc(a.name || '')}"></a><figcaption>${esc(decodeMungedName(a.name || a.href))}</figcaption></figure>`;
}).join('');
return `<div class="case">
<span class="dot" style="background:${STATUS_COLOR[c.status] || '#888'}"></span>
${c.ref ? `<code class="ref">${esc(c.ref)}</code>` : ''}
<span>${esc(c.title || '')}</span>
${c.error ? `<pre class="err">${esc(c.error)}</pre>` : ''}
${atts ? `<div class="atts">${atts}</div>` : ''}
</div>`;
}

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 `<details class="seq" data-seq="${esc(key)}" ${open ? 'open' : ''}>
<summary>${badge(seq.status)}${seq.ref ? `<code class="sref">§${esc(seq.ref)}</code>` : ''}<span class="sname">${esc(seq.title || seq.id)}</span><span class="counts">${c.passed || 0}✓ ${c.failed || 0}✗ ${c.warned || 0}⚠ ${c.skipped || 0}⏭</span></summary>
${(seq.cases || []).map((cs) => caseHtml(runId, cs)).join('') || '<p class="muted">no per-case detail</p>'}
</details>`;
}

function renderDetail(detail) {
const r = detail.report;
const s = detail.summary;
$('view').innerHTML = `<span class="back" tabindex="0" role="button">← back to runs</span>
<div class="section-h">${badge(s.status)} &nbsp;${esc(s.label)} <span class="src">${esc(s.source)}</span></div>
<div class="meta">${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 ? ' · <span style="color:#fbbf24">media too large to snapshot</span>' : ''}</div>
${(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 = '<p class="empty-state">No screenshots or videos captured yet. Run the showcase/live-tour suites and clips appear here.</p>'; return; }
if (!ui.mediaRunId || !mediaRuns.find((r) => r.runId === ui.mediaRunId)) ui.mediaRunId = mediaRuns[0].runId;
const picker = `<div class="tabs">${mediaRuns.map((r) => `<div class="tab ${r.runId === ui.mediaRunId ? 'active' : ''}" data-mrun="${esc(r.runId)}">${esc(r.label)} · ${r.mediaCount}</div>`).join('')}</div>`;
$('view').innerHTML = picker + '<p class="muted">loading media…</p>';
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 + `<div class="atts" style="margin-left:0">${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'
? `<figure class="att"><video src="${esc(url)}" controls preload="metadata" playsinline></video><figcaption>${esc(cap)}</figcaption></figure>`
: `<figure class="att"><a href="${esc(url)}" target="_blank"><img src="${esc(url)}" loading="lazy"></a><figcaption>${esc(cap)}</figcaption></figure>`;
})).join('') || '<p class="muted">no media in this run</p>'}</div>`;
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 = `<span class="err">${esc(String(e))}</span>`; });
Loading
Loading