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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,11 @@ 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`.
(`node:http` + SSE). It also has a **🎧 Narrated tab**: `npm run dashboard:narrate`
(`make dashboard-narrate`) renders a piper-tts walkthrough of progress (female
voice en_US-lessac-high) β€” test health, shipped PRs, perf trends, feature tour β€”
each segment's audio synced to its chart/video/screenshot. 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
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 dashboard deploy clean
.PHONY: help test test-all test-https test-e2e test-unit test-report dashboard dashboard-narrate deploy clean

# Default target - show help
help:
Expand Down Expand Up @@ -60,6 +60,11 @@ dashboard:
@echo "πŸ“Š Starting live test dashboard at http://localhost:3199 ..."
@npm run dashboard:open

# Generate the piper-tts narrated progress report (Narrated tab in the dashboard)
dashboard-narrate:
@echo "🎧 Generating narrated progress report (piper-tts)..."
@npm run dashboard:narrate

# Start production deployment
deploy:
@echo "πŸš€ Starting production deployment..."
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:unified:lib": "node --test tests/lib/",
"dashboard": "node tests/lib/dashboard/server.mjs",
"dashboard:open": "node tests/lib/dashboard/server.mjs --open",
"dashboard:narrate": "node scripts/narrate-report.mjs",
"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
149 changes: 149 additions & 0 deletions scripts/narrate-report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env node
/**
* Generates the narrated progress report for the live dashboard: gathers real
* data (newest unified runs across both repos, recently-shipped commits, live
* perf metrics, the feature-tour clips), builds a spoken script with narrate.mjs,
* renders each segment to audio with piper-tts (high-quality female voice) +
* ffmpeg, and writes a manifest the dashboard plays back, synced to charts/media.
*
* node scripts/narrate-report.mjs
*
* Voice: test-artifacts/dashboard/voices/en_US-lessac-high.onnx (override PIPER_VOICE).
* Output (git-ignored, under test-artifacts/): test-artifacts/dashboard/narration/.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { discover } from '../tests/lib/dashboard/ingest.mjs';
import { readJsonl } from '../tests/lib/dashboard/history.mjs';
import { buildNarration } from '../tests/lib/dashboard/narrate.mjs';

const CORE = resolve(process.cwd());
const CLOUD = resolve(CORE, '..', 'GraphDone-Cloud');
const ARTIFACTS = join(CORE, 'test-artifacts');
const STORE = join(ARTIFACTS, 'dashboard');
const OUT = join(STORE, 'narration');
const VOICE = process.env.PIPER_VOICE || join(STORE, 'voices', 'en_US-lessac-high.onnx');
const VOICE_NAME = VOICE.split('/').pop().replace(/\.onnx$/, '');

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 PERF_LABELS = {
'graph.idleFps': 'idle frame rate', 'graph.dragFps': 'drag frame rate', 'graph.interactionFps': 'interaction frame rate',
'graph.avgTickMs': 'average simulation tick', 'graph.driftPx': 'layout drift', 'graph.queryP95Ms': 'query latency at the 95th percentile',
'suite.passRate': 'suite pass rate', 'physics.settleSeconds': 'physics settle time',
};
const PERF_ORDER = ['graph.idleFps', 'graph.dragFps', 'graph.interactionFps', 'graph.avgTickMs', 'graph.driftPx', 'physics.settleSeconds'];

if (!existsSync(VOICE)) {
console.error(`❌ Voice model not found: ${VOICE}\n Download it, e.g.:\n curl -L -o "${VOICE}" https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/high/en_US-lessac-high.onnx\n (and the matching .onnx.json)`);
process.exit(1);
}
for (const bin of ['piper', 'ffmpeg', 'ffprobe']) {
if (spawnSync(bin, ['--help'], { stdio: 'ignore' }).error) { console.error(`❌ ${bin} not on PATH`); process.exit(1); }
}

// ── gather data ───────────────────────────────────────────────────────────────
const runs = discover(ROOTS);
const healthRun = runs[0] || null;
const mediaRun = runs.find((r) => {
for (const s of r.report.sequences || []) for (const c of s.cases || []) if ((c.attachments || []).some((a) => a.type === 'video')) return true;
return false;
}) || healthRun;

const health = healthRun ? (() => {
const t = (healthRun.report.rollup && healthRun.report.rollup.totals) || {};
const cases = t.cases || 0;
return { cases, sequences: t.sequences || (healthRun.report.sequences || []).length, passed: t.passed || 0, failed: t.failed || 0, warned: t.warned || 0, passRate: cases ? Math.round((t.passed / cases) * 1000) / 10 : 0, target: healthRun.report.target };
})() : {};

const shipped = (() => {
const r = spawnSync('git', ['log', '--no-merges', '--format=%s', '-n', '60'], { cwd: CORE, encoding: 'utf8' });
const subjects = (r.stdout || '').split('\n').map((s) => s.trim()).filter(Boolean);
const wanted = subjects.filter((s) => /^(feat|fix|perf|refactor|chore)(\([^)]*\))?:/i.test(s));
return wanted.slice(0, 7).map((title) => ({ title }));
})();

const metricsRows = readJsonl(join(STORE, 'metrics.jsonl'));
const latestByMetric = new Map();
for (const m of metricsRows) {
const prev = latestByMetric.get(m.metric);
if (!prev || (m.ts || 0) >= (prev.ts || 0)) latestByMetric.set(m.metric, m);
}
const perf = PERF_ORDER.filter((k) => latestByMetric.has(k)).map((k) => {
const m = latestByMetric.get(k);
return { metric: k, label: PERF_LABELS[k] || k, value: m.value, unit: m.unit, better: m.better };
});

const tour = [];
if (mediaRun) {
for (const s of mediaRun.report.sequences || []) {
for (const c of s.cases || []) {
for (const a of c.attachments || []) {
if (a.type === 'video') tour.push({ name: a.name || c.ref || `clip-${tour.length + 1}`, note: c.title || '', kind: 'video', runId: mediaRun.runId, href: a.href });
}
}
}
}

const dateISO = new Date().toISOString();
const narration = buildNarration({ dateISO, voiceName: VOICE_NAME, health, shipped, perf, tour });
console.log(`πŸ“ ${narration.segments.length} segments (${shipped.length} shipped, ${perf.length} perf, ${tour.length} clips${narration.meta.tourTruncated ? ' β€” truncated to 8' : ''})`);

// ── render audio ──────────────────────────────────────────────────────────────
if (existsSync(OUT)) rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });

const durOf = (f) => {
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nk=1:nw=1', f], { encoding: 'utf8' });
return Math.round((parseFloat((r.stdout || '0').trim()) || 0) * 1000);
};

const outSegments = [];
let totalMs = 0;
for (const seg of narration.segments) {
const wav = join(OUT, `${seg.id}.wav`);
const mp3 = `${seg.id}.mp3`;
const piperRes = spawnSync('piper', ['-m', VOICE, '-f', wav], { input: seg.text, encoding: 'utf8' });
if (piperRes.status !== 0 || !existsSync(wav)) { console.warn(` ⚠️ piper failed for ${seg.id}`); continue; }
const ff = spawnSync('ffmpeg', ['-y', '-i', wav, '-ac', '1', '-b:a', '64k', join(OUT, mp3)], { stdio: 'ignore' });
rmSync(wav, { force: true });
if (ff.status !== 0) { console.warn(` ⚠️ ffmpeg failed for ${seg.id}`); continue; }
const durationMs = durOf(join(OUT, mp3));
totalMs += durationMs;
outSegments.push({ ...seg, audioHref: mp3, durationMs });
console.log(` πŸŽ™οΈ ${seg.id} ${(durationMs / 1000).toFixed(1)}s`);
}

// combined single track
let full = null;
if (outSegments.length) {
const listFile = join(OUT, 'concat.txt');
writeFileSync(listFile, outSegments.map((s) => `file '${s.audioHref}'`).join('\n') + '\n');
const ff = spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listFile, '-c', 'copy', join(OUT, 'narration-full.mp3')], { stdio: 'ignore', cwd: OUT });
rmSync(listFile, { force: true });
if (ff.status === 0 && existsSync(join(OUT, 'narration-full.mp3'))) full = { audioHref: 'narration-full.mp3', durationMs: durOf(join(OUT, 'narration-full.mp3')) };
}

const manifest = {
schema: 'graphdone.narration/1',
generatedAt: Date.now(),
voice: VOICE_NAME,
target: health.target || null,
totalDurationMs: totalMs,
full,
segments: outSegments,
};
writeFileSync(join(OUT, 'narration.json'), JSON.stringify(manifest, null, 2));

const sizeKb = readdirSync(OUT).reduce((s, f) => { try { return s + statSync(join(OUT, f)).size; } catch { return s; } }, 0) / 1024;
console.log(`\n🎧 Narration ready: ${outSegments.length} segments · ${(totalMs / 1000).toFixed(0)}s total · ${Math.round(sizeKb)}KB`);
console.log(` voice : ${VOICE_NAME}`);
console.log(` out : ${join(OUT, 'narration.json')}`);
console.log(` dashboard β†’ Narrated tab (npm run dashboard)`);
1 change: 1 addition & 0 deletions tests/lib/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ npm run test:perf:scale # perf trends update on the Overvie
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.
- **🎧 Narrated** β€” a piper-tts narrated walkthrough of progress (high-quality female voice): test health, recently-shipped PRs, performance trends, and a feature tour, each segment playing its audio while the synced media (trend chart / video clip / screenshot) shows above and the transcript below. Auto-advances. Generate it with `npm run dashboard:narrate` (data-driven from the newest runs + git history + metrics).

## How it stays "continuous"

Expand Down
58 changes: 57 additions & 1 deletion tests/lib/dashboard/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,15 +194,71 @@ async function 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 (tab !== 'narrated') { try { narrAudio.pause(); narrIdx = -1; } catch { /* */ } }
if (!render) return;
if (tab === 'overview') renderOverview();
else if (tab === 'runs') renderRuns();
else if (tab === 'media') renderMedia();
else if (tab === 'narrated') renderNarrated();
}

const narrAudio = new Audio();
let narration = null;
let narrIdx = -1;

function chartDef(ref) { return CHART_DEFS.find((d) => d.metric === ref) || { metric: ref, title: ref, unit: '' }; }

function narrMediaHtml(seg) {
const m = seg.media || { kind: 'none' };
if (m.kind === 'chart') {
const d = chartDef(m.ref);
return lineChart({ title: d.title, series: seriesFor(d.metric), unit: d.unit, budget: d.budget ?? null, xIsTime: true });
}
if (m.kind === 'video' && m.runId && m.href) return `<video src="${esc(mediaUrl(m.runId, m.href))}" autoplay muted loop playsinline></video>`;
if (m.kind === 'image' && m.runId && m.href) return `<img src="${esc(mediaUrl(m.runId, m.href))}" alt="${esc(seg.title)}">`;
return '<div class="muted" style="padding:40px;text-align:center">no media for this segment</div>';
}

function playNarrSegment(i) {
if (!narration || i < 0 || i >= narration.segments.length) { narrIdx = -1; return; }
narrIdx = i;
const seg = narration.segments[i];
const stage = $('narr-stage');
if (stage) stage.innerHTML = narrMediaHtml(seg);
document.querySelectorAll('.narr-seg').forEach((el) => el.classList.toggle('active', Number(el.getAttribute('data-i')) === i));
const row = document.querySelector(`.narr-seg[data-i="${i}"]`);
if (row) row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
narrAudio.src = `/narration/${encodeURIComponent(seg.audioHref)}`;
narrAudio.play().catch(() => {});
}

narrAudio.onended = () => { if (ui.tab === 'narrated' && narrIdx >= 0) playNarrSegment(narrIdx + 1); };

async function renderNarrated() {
let data;
try { data = await fetchJSON('/api/narration'); }
catch {
narrAudio.pause();
$('view').innerHTML = `<div class="empty-state">No narrated report yet.<br><br>Generate it with <code>npm run dashboard:narrate</code> (renders a piper-tts walkthrough of the latest results), then reopen this tab.</div>`;
return;
}
narration = data;
const mins = Math.round((data.totalDurationMs || 0) / 600) / 100;
$('view').innerHTML = `
<div class="section-h">🎧 Narrated progress report</div>
<div class="meta">voice ${esc(data.voice || '')} Β· ${data.segments.length} segments Β· ~${mins} min Β· narrated by piper-tts${data.full ? ` Β· <a href="/narration/${esc(data.full.audioHref)}" download>download full track</a>` : ''}</div>
<div class="narr-controls"><button id="narr-play" class="narr-btn">β–Ά Play narrated report</button><button id="narr-stop" class="narr-btn">β–  Stop</button></div>
<div id="narr-stage" class="narr-stage"><div class="muted" style="padding:40px;text-align:center">press play</div></div>
<div class="narr-list">${data.segments.map((s, i) => `<div class="narr-seg" data-i="${i}" tabindex="0" role="button"><div class="narr-seg-h"><span class="narr-seg-t">${esc(s.title)}</span><span class="narr-seg-d">${((s.durationMs || 0) / 1000).toFixed(0)}s</span></div><div class="narr-seg-text">${esc(s.text)}</div></div>`).join('')}</div>`;
$('narr-play').onclick = () => playNarrSegment(0);
$('narr-stop').onclick = () => { narrAudio.pause(); narrIdx = -1; document.querySelectorAll('.narr-seg').forEach((el) => el.classList.remove('active')); };
document.querySelectorAll('.narr-seg').forEach((el) => el.onclick = () => playNarrSegment(Number(el.getAttribute('data-i'))));
}

function rerender() {
renderHeader();
if (ui.tab === 'detail' && ui.runId) openRun(ui.runId);
else if (ui.tab === 'narrated') { /* leave the narration player undisturbed by live run updates */ }
else setTab(ui.tab);
}

Expand All @@ -228,7 +284,7 @@ function connectSSE() {
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');
const el = e.target.closest && e.target.closest('[data-run],[data-tab],[data-mrun],.narr-seg,.back');
if (el) { e.preventDefault(); el.click(); }
});

Expand Down
15 changes: 15 additions & 0 deletions tests/lib/dashboard/page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ h1{font-size:20px;margin:0;display:flex;align-items:center;gap:10px}
.att img,.att video{width:300px;border-radius:8px;border:1px solid #1e293b;background:#000;display:block}
.att figcaption{color:#64748b;font-size:11px;margin-top:4px;word-break:break-all}
.empty-state{color:#64748b;padding:40px;text-align:center}
.narr-controls{display:flex;gap:10px;margin:8px 0 14px}
.narr-btn{padding:8px 16px;border-radius:8px;background:#1e293b;border:1px solid #334155;color:#e2e8f0;cursor:pointer;font-size:14px;font-weight:600}
.narr-btn:hover{background:#27364f}
.narr-stage{background:#0f1623;border:1px solid #243044;border-radius:12px;padding:12px;min-height:200px;display:flex;align-items:center;justify-content:center;margin-bottom:16px}
.narr-stage video,.narr-stage img{max-width:100%;max-height:420px;border-radius:8px;border:1px solid #1e293b;background:#000}
.narr-stage .chart{width:100%;max-width:640px}
.narr-list{display:flex;flex-direction:column;gap:8px}
.narr-seg{background:#0f1729;border:1px solid #1e293b;border-radius:10px;padding:10px 14px;cursor:pointer}
.narr-seg:hover{border-color:#334155}
.narr-seg.active{border-color:#34d399;background:#10241f;box-shadow:0 0 0 1px #34d39955}
.narr-seg-h{display:flex;align-items:center;gap:10px}
.narr-seg-t{font-weight:600;flex:1;text-transform:capitalize}
.narr-seg-d{color:#64748b;font-size:12px;font-variant-numeric:tabular-nums}
.narr-seg-text{color:#94a3b8;font-size:12px;margin-top:4px;line-height:1.5}
footer{color:#475569;font-size:12px;margin-top:40px;text-align:center}
</style></head><body><div class="wrap">
<header class="top">
Expand All @@ -75,6 +89,7 @@ footer{color:#475569;font-size:12px;margin-top:40px;text-align:center}
<div class="tab active" data-tab="overview" role="tab" tabindex="0">Overview</div>
<div class="tab" data-tab="runs" role="tab" tabindex="0">Runs</div>
<div class="tab" data-tab="media" role="tab" tabindex="0">Media</div>
<div class="tab" data-tab="narrated" role="tab" tabindex="0">🎧 Narrated</div>
</div>
<div id="view"></div>
<footer>graphdone.unified-report/1 Β· live dashboard Β· read-only Β· localhost</footer>
Expand Down
Loading
Loading