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
592 changes: 592 additions & 0 deletions docs/test-tree-refactor-plan.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"test:all": "npm run test:unit && npm run test:e2e",
"test:comprehensive": "node tests/run-all-tests.js",
"test:pr": "node tests/run-pr-tests.js",
"test:unified": "node tests/run-unified.mjs --profile full",
"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/",
"test:installation": "./scripts/test-installation-simple.sh",
"test:https": "node tests/ssl-certificate-analysis.js && node tests/mobile-https-compatibility-test.js",
"test:report": "open test-results/reports/index.html",
Expand Down
57 changes: 57 additions & 0 deletions tests/lib/adapters/playwright.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { mkdtempSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runCommand } from '../runner/runSequence.mjs';

/**
* Run a Playwright invocation and normalise its JSON report into a unified
* sequence object. Harvests per-test attachments (videos + screenshots) as
* { srcPath } for the entry to copy into the report's assets/ dir.
*/
export async function runPlaywrightSequence({ id, title, args = [], target, timeoutMs = 900000 }) {
const jsonOut = join(mkdtempSync(join(tmpdir(), 'pw-')), 'pw.json');
const env = {
TEST_URL: target || process.env.TEST_URL || 'http://localhost:3127',
PLAYWRIGHT_JSON_OUTPUT_NAME: jsonOut,
CI: 'true',
};
const res = await runCommand('npx', ['playwright', 'test', ...args, '--reporter=json'], { env, timeoutMs });

let parsed = null;
if (existsSync(jsonOut)) { try { parsed = JSON.parse(readFileSync(jsonOut, 'utf8')); } catch { /* fall through */ } }
if (!parsed) { try { parsed = JSON.parse(res.stdout); } catch { /* none */ } }

const counts = { passed: 0, failed: 0, warned: 0, skipped: 0 };
const cases = [];
const walk = (suites = []) => {
for (const s of suites) {
for (const spec of s.specs || []) {
for (const t of spec.tests || []) {
const last = (t.results || []).slice(-1)[0] || {};
let status = last.status === 'passed' ? 'passed' : last.status === 'skipped' ? 'skipped' : 'failed';
if (spec.ok === false) status = 'failed';
counts[status] = (counts[status] || 0) + 1;
const attachments = [];
for (const r of t.results || []) {
for (const a of r.attachments || []) {
if (!a.path) continue;
if ((a.contentType || '').startsWith('video')) attachments.push({ type: 'video', name: spec.title, srcPath: a.path });
else if ((a.contentType || '').startsWith('image')) attachments.push({ type: 'image', name: a.name || spec.title, srcPath: a.path });
}
}
cases.push({ title: spec.title, status, durationMs: last.duration, error: last.error && last.error.message, attachments });
}
}
if (s.suites) walk(s.suites);
}
};
if (parsed && parsed.suites) walk(parsed.suites);

if (!cases.length) {
const ok = res.code === 0 && !res.timedOut;
counts[ok ? 'passed' : 'failed']++;
cases.push({ title, status: ok ? 'passed' : 'failed', durationMs: res.durationMs, error: ok ? undefined : (res.timedOut ? 'sequence timed out' : (res.stderr || res.stdout || '').slice(-2000)), attachments: [] });
}
const status = counts.failed > 0 ? 'failed' : counts.passed > 0 ? 'passed' : 'skipped';
return { id, title, kind: 'e2e', command: `playwright test ${args.join(' ')}`, status, durationMs: res.durationMs, counts, cases };
}
34 changes: 34 additions & 0 deletions tests/lib/adapters/vitest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { runCommand } from '../runner/runSequence.mjs';

/** Run a package's vitest suite and normalise its JSON into a unified sequence. */
export async function runVitestSequence({ id, title, cwd, timeoutMs = 300000 }) {
const res = await runCommand('npx', ['vitest', 'run', '--reporter=json'], { cwd, timeoutMs });
let parsed = null;
try { parsed = JSON.parse(res.stdout); } catch {
const i = res.stdout.indexOf('{');
const j = res.stdout.lastIndexOf('}');
if (i >= 0 && j > i) { try { parsed = JSON.parse(res.stdout.slice(i, j + 1)); } catch { /* none */ } }
}
const counts = { passed: 0, failed: 0, warned: 0, skipped: 0 };
const cases = [];
if (parsed && parsed.testResults) {
for (const f of parsed.testResults) {
for (const a of f.assertionResults || []) {
const st = a.status === 'passed' ? 'passed' : (a.status === 'pending' || a.status === 'skipped' || a.status === 'todo') ? 'skipped' : 'failed';
counts[st]++;
if (st !== 'passed') cases.push({ title: [...(a.ancestorTitles || []), a.title].join(' › '), status: st, error: (a.failureMessages || []).join('\n').slice(-1500) });
}
}
} else if (parsed && parsed.numTotalTests != null) {
counts.passed = parsed.numPassedTests || 0;
counts.failed = parsed.numFailedTests || 0;
counts.skipped = parsed.numPendingTests || 0;
}
if (!parsed) {
const ok = res.code === 0 && !res.timedOut;
counts[ok ? 'passed' : 'failed']++;
if (!ok) cases.push({ title, status: 'failed', error: (res.timedOut ? 'timed out' : (res.stderr || res.stdout || '')).slice(-2000) });
}
const status = counts.failed > 0 ? 'failed' : counts.passed > 0 ? 'passed' : 'skipped';
return { id, title, kind: 'unit', command: `vitest run (${cwd})`, status, durationMs: res.durationMs, counts, cases };
}
58 changes: 58 additions & 0 deletions tests/lib/reporting/aggregate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Pure aggregation for the unified test harness — no I/O, fully unit-testable.
* Every sequence (unit run, e2e spec, live audit, …) reports counts + a status;
* these helpers roll the fleet up into one report object that both the HTML and
* JSON reporters render. Reusable across both repos.
*/

export const STATUS = Object.freeze({ PASSED: 'passed', FAILED: 'failed', WARN: 'warn', SKIPPED: 'skipped' });

const RANK = { failed: 3, warn: 2, passed: 1, skipped: 0 };

/** Worst-of rollup: any failure → failed; else any warn → warn; else any pass → passed; else skipped. */
export function rollupStatus(statuses) {
let worst = 'skipped';
for (const s of statuses) {
if ((RANK[s] ?? 0) > RANK[worst]) worst = s;
}
return worst;
}

/** Derive a sequence's status from its case counts (a failure dominates; warns warn). */
export function statusFromCounts({ passed = 0, failed = 0, warned = 0, skipped = 0 } = {}) {
if (failed > 0) return STATUS.FAILED;
if (warned > 0) return STATUS.WARN;
if (passed > 0) return STATUS.PASSED;
return STATUS.SKIPPED;
}

/** Sum per-sequence counts into a totals object (plus a sequence-status tally). */
export function sumCounts(sequences) {
const totals = { passed: 0, failed: 0, warned: 0, skipped: 0, cases: 0, sequences: sequences.length };
const byStatus = { passed: 0, failed: 0, warned: 0, skipped: 0 };
for (const seq of sequences) {
const c = seq.counts || {};
totals.passed += c.passed || 0;
totals.failed += c.failed || 0;
totals.warned += c.warned || 0;
totals.skipped += c.skipped || 0;
totals.cases += (c.passed || 0) + (c.failed || 0) + (c.warned || 0) + (c.skipped || 0);
byStatus[seq.status] = (byStatus[seq.status] || 0) + 1;
}
return { totals, byStatus };
}

/** Build the full report object the reporters consume. Pure; timestamps passed in. */
export function buildReport({ sequences = [], startedAt, finishedAt, target = '', env = {} } = {}) {
const { totals, byStatus } = sumCounts(sequences);
return {
schema: 'graphdone.unified-report/1',
startedAt,
finishedAt,
durationMs: startedAt != null && finishedAt != null ? finishedAt - startedAt : null,
target,
env,
rollup: { status: rollupStatus(sequences.map((s) => s.status)), totals, byStatus },
sequences,
};
}
56 changes: 56 additions & 0 deletions tests/lib/reporting/aggregate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { rollupStatus, statusFromCounts, sumCounts, buildReport, STATUS } from './aggregate.mjs';

test('rollupStatus: failure dominates', () => {
assert.equal(rollupStatus(['passed', 'failed', 'warn']), 'failed');
});
test('rollupStatus: warn over passed', () => {
assert.equal(rollupStatus(['passed', 'warn', 'passed']), 'warn');
});
test('rollupStatus: all skipped → skipped', () => {
assert.equal(rollupStatus(['skipped', 'skipped']), 'skipped');
});
test('rollupStatus: empty → skipped', () => {
assert.equal(rollupStatus([]), 'skipped');
});

test('statusFromCounts: any failure → failed', () => {
assert.equal(statusFromCounts({ passed: 5, failed: 1 }), STATUS.FAILED);
});
test('statusFromCounts: warns but no fails → warn', () => {
assert.equal(statusFromCounts({ passed: 5, warned: 2 }), STATUS.WARN);
});
test('statusFromCounts: only passes → passed', () => {
assert.equal(statusFromCounts({ passed: 3 }), STATUS.PASSED);
});
test('statusFromCounts: nothing → skipped', () => {
assert.equal(statusFromCounts({}), STATUS.SKIPPED);
});

test('sumCounts totals + status tally', () => {
const seqs = [
{ status: 'passed', counts: { passed: 3, failed: 0, warned: 0, skipped: 1 } },
{ status: 'failed', counts: { passed: 1, failed: 2, warned: 0, skipped: 0 } },
];
const { totals, byStatus } = sumCounts(seqs);
assert.equal(totals.passed, 4);
assert.equal(totals.failed, 2);
assert.equal(totals.skipped, 1);
assert.equal(totals.cases, 7);
assert.equal(totals.sequences, 2);
assert.equal(byStatus.passed, 1);
assert.equal(byStatus.failed, 1);
});

test('buildReport rolls up status + duration', () => {
const r = buildReport({
sequences: [{ status: 'passed', counts: { passed: 2 } }, { status: 'warn', counts: { warned: 1 } }],
startedAt: 1000, finishedAt: 4000, target: 'http://x', env: { node: 'v20' },
});
assert.equal(r.schema, 'graphdone.unified-report/1');
assert.equal(r.durationMs, 3000);
assert.equal(r.rollup.status, 'warn');
assert.equal(r.rollup.totals.sequences, 2);
assert.equal(r.target, 'http://x');
});
105 changes: 105 additions & 0 deletions tests/lib/reporting/html.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Pure HTML renderer for the unified report. Takes the aggregated report object
* (sequences may carry attachments {type:'image'|'video', name, href} whose href
* is already relative to the report dir) and returns a self-contained HTML string
* embedding <img> screenshots and <video> .webm clips. No I/O.
*/
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const COLOR = { passed: '#34d399', failed: '#f87171', warn: '#fbbf24', skipped: '#94a3b8' };
const ICON = { passed: '✅', failed: '❌', warn: '⚠️', skipped: '⏭️' };

function attachmentHtml(a) {
if (!a || !a.href) return '';
if (a.type === 'video') {
return `<figure class="att"><video src="${esc(a.href)}" controls preload="metadata" playsinline></video><figcaption>${esc(a.name || '')}</figcaption></figure>`;
}
return `<figure class="att"><a href="${esc(a.href)}" target="_blank"><img src="${esc(a.href)}" loading="lazy" alt="${esc(a.name || '')}"></a><figcaption>${esc(a.name || '')}</figcaption></figure>`;
}

function caseHtml(c) {
const atts = (c.attachments || []).map(attachmentHtml).join('');
const err = c.error ? `<pre class="err">${esc(c.error)}</pre>` : '';
return `<div class="case ${esc(c.status)}">
<span class="dot" style="background:${COLOR[c.status] || '#888'}"></span>
<span class="ctitle">${esc(c.title)}</span>
${c.durationMs != null ? `<span class="cdur">${(c.durationMs / 1000).toFixed(1)}s</span>` : ''}
${err}${atts ? `<div class="atts">${atts}</div>` : ''}
</div>`;
}

function seqHtml(seq, i) {
const c = seq.counts || {};
const cases = (seq.cases || []);
// Show failed/warn cases expanded; collapse a long all-green list to just its attachments.
const notable = cases.filter((x) => x.status === 'failed' || x.status === 'warn');
const withAtts = cases.filter((x) => (x.attachments || []).length);
const shown = notable.length ? notable : withAtts.slice(0, 24);
return `<details class="seq ${esc(seq.status)}" ${seq.status === 'failed' ? 'open' : ''}>
<summary>
<span class="badge" style="background:${COLOR[seq.status] || '#888'}">${ICON[seq.status] || ''} ${esc(seq.status)}</span>
<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>
${seq.durationMs != null ? `<span class="sdur">${(seq.durationMs / 1000).toFixed(1)}s</span>` : ''}
</summary>
${seq.command ? `<code class="cmd">${esc(seq.command)}</code>` : ''}
${seq.notes ? `<p class="notes">${esc(seq.notes)}</p>` : ''}
${shown.map(caseHtml).join('') || '<p class="empty">no per-case detail</p>'}
</details>`;
}

export function renderHtml(report) {
const r = report.rollup || {};
const t = r.totals || {};
const when = report.finishedAt ? new Date(report.finishedAt).toISOString() : '';
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GraphDone — Unified Test Report</title>
<style>
:root{color-scheme:dark}
*{box-sizing:border-box}
body{margin:0;background:#0b1220;color:#e2e8f0;font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
.wrap{max-width:1100px;margin:0 auto;padding:28px 20px 80px}
h1{font-size:22px;margin:0 0 4px;display:flex;align-items:center;gap:10px}
.meta{color:#94a3b8;font-size:12px;margin-bottom:20px}
.rollup{display:inline-block;padding:4px 12px;border-radius:999px;font-weight:700;color:#0b1220}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:12px;margin:18px 0 26px}
.card{background:#111a2e;border:1px solid #1e293b;border-radius:12px;padding:14px}
.card .n{font-size:26px;font-weight:800}
.card .l{color:#94a3b8;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
.seq{background:#0f1729;border:1px solid #1e293b;border-radius:12px;margin:10px 0;padding:6px 14px}
.seq summary{display:flex;align-items:center;gap:12px;cursor:pointer;list-style:none;padding:8px 0}
.seq summary::-webkit-details-marker{display:none}
.badge{padding:2px 10px;border-radius:999px;font-size:11px;font-weight:700;color:#0b1220;text-transform:uppercase}
.sname{font-weight:600;flex:1}
.counts{font-variant-numeric:tabular-nums;color:#cbd5e1;font-size:12px}
.sdur,.cdur{color:#64748b;font-size:12px}
.cmd{display:block;background:#0b1220;border:1px solid #1e293b;border-radius:6px;padding:6px 8px;color:#7dd3fc;font-size:12px;margin:4px 0;overflow-x:auto}
.notes{color:#94a3b8;margin:4px 0}
.case{padding:8px 0;border-top:1px solid #16223a}
.case .dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:middle}
.ctitle{vertical-align:middle}
.cdur{margin-left:8px}
.err{background:#1a1014;border:1px solid #5b2330;color:#fca5a5;border-radius:6px;padding:8px;white-space:pre-wrap;font-size:12px;margin:6px 0 0 17px}
.atts{display:flex;flex-wrap:wrap;gap:12px;margin:10px 0 4px 17px}
.att{margin:0;width:280px}
.att img,.att video{width:280px;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{color:#64748b;font-size:12px;padding:6px 0 10px}
footer{color:#475569;font-size:12px;margin-top:30px;text-align:center}
</style></head><body><div class="wrap">
<h1>GraphDone — Unified Test Report
<span class="rollup" style="background:${COLOR[r.status] || '#888'}">${ICON[r.status] || ''} ${esc(r.status || 'n/a')}</span>
</h1>
<div class="meta">${esc(when)} · target ${esc(report.target || 'n/a')} · ${esc((report.env && report.env.node) || '')} · ${report.durationMs != null ? (report.durationMs / 1000).toFixed(1) + 's' : ''}</div>
<div class="cards">
<div class="card"><div class="n">${t.sequences || 0}</div><div class="l">Sequences</div></div>
<div class="card"><div class="n">${t.cases || 0}</div><div class="l">Cases</div></div>
<div class="card"><div class="n" style="color:${COLOR.passed}">${t.passed || 0}</div><div class="l">Passed</div></div>
<div class="card"><div class="n" style="color:${COLOR.failed}">${t.failed || 0}</div><div class="l">Failed</div></div>
<div class="card"><div class="n" style="color:${COLOR.warn}">${t.warned || 0}</div><div class="l">Warn</div></div>
<div class="card"><div class="n" style="color:${COLOR.skipped}">${t.skipped || 0}</div><div class="l">Skipped</div></div>
</div>
${(report.sequences || []).map(seqHtml).join('')}
<footer>graphdone.unified-report/1 · machine-readable companion: report.json</footer>
</div></body></html>`;
}
20 changes: 20 additions & 0 deletions tests/lib/reporting/json.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
27 changes: 27 additions & 0 deletions tests/lib/runner/runSequence.mjs
Original file line number Diff line number Diff line change
@@ -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) });
});
});
}
Loading
Loading