+
GraphDone — Unified Test Report
+ ${ICON[r.status] || ''} ${esc(r.status || 'n/a')}
+
+
${esc(when)} · target ${esc(report.target || 'n/a')} · ${esc((report.env && report.env.node) || '')} · ${report.durationMs != null ? (report.durationMs / 1000).toFixed(1) + 's' : ''}
+
+
${t.sequences || 0}
Sequences
+
+
+
+
+
+
+${(report.sequences || []).map(seqHtml).join('')}
+
+
`;
+}
diff --git a/tests/lib/reporting/json.mjs b/tests/lib/reporting/json.mjs
new file mode 100644
index 00000000..b6c6ad19
--- /dev/null
+++ b/tests/lib/reporting/json.mjs
@@ -0,0 +1,20 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+/**
+ * Machine-parsable output: one report.json (schema graphdone.unified-report/1)
+ * plus per-sequence raw JSON under sequences/ for CI artifact upload / debugging.
+ * Returns the paths written.
+ */
+export function writeJsonReport(report, outDir) {
+ mkdirSync(join(outDir, 'sequences'), { recursive: true });
+ const mainPath = join(outDir, 'report.json');
+ writeFileSync(mainPath, JSON.stringify(report, null, 2));
+ const seqPaths = [];
+ for (const seq of report.sequences || []) {
+ const p = join(outDir, 'sequences', `${seq.id || 'sequence'}.json`);
+ writeFileSync(p, JSON.stringify(seq, null, 2));
+ seqPaths.push(p);
+ }
+ return { mainPath, seqPaths };
+}
diff --git a/tests/lib/runner/runSequence.mjs b/tests/lib/runner/runSequence.mjs
new file mode 100644
index 00000000..822e4fba
--- /dev/null
+++ b/tests/lib/runner/runSequence.mjs
@@ -0,0 +1,27 @@
+import { spawn } from 'node:child_process';
+
+/**
+ * Spawn a child process, capture stdout/stderr/exit/duration. No shell (argv
+ * array) so paths/args with spaces are safe. Resolves (never rejects) so the
+ * harness can record a failed sequence and continue.
+ */
+export function runCommand(cmd, args, { cwd, env, timeoutMs } = {}) {
+ return new Promise((resolve) => {
+ const start = Date.now();
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env } });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (d) => { stdout += d; });
+ child.stderr.on('data', (d) => { stderr += d; });
+ let timedOut = false;
+ const timer = timeoutMs ? setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs) : null;
+ child.on('close', (code) => {
+ if (timer) clearTimeout(timer);
+ resolve({ code, stdout, stderr, durationMs: Date.now() - start, timedOut });
+ });
+ child.on('error', (err) => {
+ if (timer) clearTimeout(timer);
+ resolve({ code: -1, stdout, stderr: stderr + String(err), durationMs: Date.now() - start, error: String(err) });
+ });
+ });
+}
diff --git a/tests/run-unified.mjs b/tests/run-unified.mjs
new file mode 100644
index 00000000..06938653
--- /dev/null
+++ b/tests/run-unified.mjs
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+/**
+ * GraphDone unified test harness — ONE reproducible entry that runs a profile of
+ * sequences (unit + e2e + report captures) from the reusable tests/lib modules,
+ * then emits DUAL output: a human-readable report.html (embedded screenshots +
+ * .webm video clips) AND a machine-parsable report.json. Exit code = rollup
+ * status, so it doubles as a CI gate.
+ *
+ * node tests/run-unified.mjs [--profile smoke|pr|full|report] [--sequence