// Runs the whole test suite once and renders reports/test-report.md: a summary, // a describe-grouped inventory, and a coverage table. // The report is written even when tests fail, and the process exits with the // test run's own status so CI can gate on it. // Split into run -> parse -> render so a future CI job can reuse parse+render // on artifacts it already produced. import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { XMLParser } from "fast-xml-parser"; const REPORTS_DIR = "reports"; const JUNIT_PATH = `${REPORTS_DIR}/junit.xml`; const LCOV_PATH = "coverage/lcov.info"; const REPORT_PATH = `${REPORTS_DIR}/test-report.md`; type TestStatus = "passed" | "failed" | "todo"; interface TestCase { name: string; status: TestStatus; time: number; message?: string; } interface Suite { name: string; suites: Suite[]; tests: TestCase[]; } interface Junit { totals: { tests: number; failures: number; skipped: number; time: number }; files: Suite[]; } interface FileCoverage { file: string; linesFound: number; linesHit: number; funcsFound: number; funcsHit: number; uncovered: number[]; } // --- run --- function runTests(): number { mkdirSync(REPORTS_DIR, { recursive: true }); const proc = Bun.spawnSync( [ "bun", "test", "--coverage", "--coverage-reporter=lcov", "--reporter=junit", `--reporter-outfile=${JUNIT_PATH}`, ], { stdout: "inherit", stderr: "inherit" }, ); return proc.exitCode ?? 1; } // --- parse --- const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", isArray: (name) => name === "testsuite" || name === "testcase", }); function parseJunit(path: string): Junit { const root = xml.parse(readFileSync(path, "utf8")).testsuites; return { totals: { tests: Number(root["@_tests"] ?? 0), failures: Number(root["@_failures"] ?? 0), skipped: Number(root["@_skipped"] ?? 0), time: Number(root["@_time"] ?? 0), }, files: (root.testsuite ?? []).map(parseSuite), }; } // biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped function parseSuite(node: any): Suite { return { name: node["@_name"] ?? "(unnamed)", suites: (node.testsuite ?? []).map(parseSuite), tests: (node.testcase ?? []).map(parseCase), }; } // biome-ignore lint/suspicious/noExplicitAny: raw fast-xml-parser nodes are untyped function parseCase(node: any): TestCase { const name = node["@_name"] ?? "(unnamed)"; const time = Number(node["@_time"] ?? 0); if (node.failure !== undefined) { const message = node.failure["@_message"] ?? node.failure["#text"] ?? ""; return { name, status: "failed", time, message: String(message) }; } if (node.skipped !== undefined) { return { name, status: "todo", time }; } return { name, status: "passed", time }; } function parseLcov(path: string): FileCoverage[] { if (!existsSync(path)) { return []; } const records: FileCoverage[] = []; let current: FileCoverage | null = null; for (const line of readFileSync(path, "utf8").split("\n")) { if (line.startsWith("SF:")) { current = { file: line.slice(3).trim(), linesFound: 0, linesHit: 0, funcsFound: 0, funcsHit: 0, uncovered: [], }; } else if (!current) { // Skip anything before the first source record. } else if (line.startsWith("FNF:")) { current.funcsFound = Number(line.slice(4)); } else if (line.startsWith("FNH:")) { current.funcsHit = Number(line.slice(4)); } else if (line.startsWith("DA:")) { const [lineNo, hits] = line.slice(3).split(","); current.linesFound++; if (Number(hits) > 0) { current.linesHit++; } else { current.uncovered.push(Number(lineNo)); } } else if (line.startsWith("end_of_record")) { records.push(current); current = null; } } return records; } // --- render --- const ICON: Record = { passed: "✅", failed: "❌", todo: "⏭️", }; function pct(hit: number, found: number): string { if (found === 0) { return "—"; } return `${((hit / found) * 100).toFixed(1)}% (${hit}/${found})`; } // Collapse consecutive line numbers into ranges, e.g. 33,34,...,40 -> "33-40". function ranges(lines: number[]): string { if (lines.length === 0) { return "—"; } const sorted = [...lines].sort((a, b) => a - b); const out: string[] = []; let start = sorted[0]; let prev = sorted[0]; for (const n of sorted.slice(1)) { if (n === prev + 1) { prev = n; continue; } out.push(start === prev ? `${start}` : `${start}-${prev}`); start = n; prev = n; } out.push(start === prev ? `${start}` : `${start}-${prev}`); return out.join(", "); } function renderSuite(suite: Suite, depth: number, lines: string[]): void { const indent = " ".repeat(depth); for (const child of suite.suites) { lines.push(`${indent}- **${child.name}**`); renderSuite(child, depth + 1, lines); } for (const test of suite.tests) { const timing = test.status === "passed" && test.time > 0 ? ` _(${(test.time * 1000).toFixed(0)}ms)_` : ""; lines.push(`${indent}- ${ICON[test.status]} ${test.name}${timing}`); if (test.status === "failed" && test.message) { lines.push(`${indent} - \`${test.message.replace(/`/g, "'").replace(/\n/g, " ")}\``); } } } function renderReport(junit: Junit, coverage: FileCoverage[], meta: string): string { const { tests, failures, skipped, time } = junit.totals; const passed = tests - failures - skipped; const result = failures === 0 ? "✅ All passing" : `❌ ${failures} failing`; const lines: string[] = []; lines.push("# Test Report", "", `_${meta}_`, ""); lines.push("## Summary", ""); lines.push("| Metric | Value |", "| --- | --- |"); lines.push(`| Result | ${result} |`); lines.push(`| Passed | ${passed} |`); lines.push(`| Failed | ${failures} |`); lines.push(`| Todo | ${skipped} |`); lines.push(`| Total | ${tests} |`); lines.push(`| Duration | ${time.toFixed(2)}s |`, ""); lines.push("## Test inventory", ""); for (const file of junit.files) { lines.push(`### \`${file.name}\``, ""); renderSuite(file, 0, lines); lines.push(""); } lines.push("## Coverage", ""); if (coverage.length === 0) { lines.push("_No coverage data found._", ""); } else { lines.push("| File | Lines | Functions | Uncovered |", "| --- | --- | --- | --- |"); const total = { linesFound: 0, linesHit: 0, funcsFound: 0, funcsHit: 0 }; for (const file of coverage) { total.linesFound += file.linesFound; total.linesHit += file.linesHit; total.funcsFound += file.funcsFound; total.funcsHit += file.funcsHit; lines.push( `| \`${file.file}\` | ${pct(file.linesHit, file.linesFound)} | ${pct(file.funcsHit, file.funcsFound)} | ${ranges(file.uncovered)} |`, ); } lines.push( `| **Total** | **${pct(total.linesHit, total.linesFound)}** | **${pct(total.funcsHit, total.funcsFound)}** | |`, "", ); lines.push( "> Coverage instruments in-process code only; code exercised solely through a subprocess (such as the CLI entry point) is not counted.", "", ); } return lines.join("\n"); } // --- main --- function gitSha(): string { const proc = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"]); return proc.exitCode === 0 ? proc.stdout.toString().trim() : "unknown"; } const exitCode = runTests(); mkdirSync(REPORTS_DIR, { recursive: true }); const meta = `Generated ${new Date().toISOString()} · commit ${gitSha()} · whole repo`; if (!existsSync(JUNIT_PATH)) { writeFileSync( REPORT_PATH, `# Test Report\n\n_${meta}_\n\nThe test run produced no JUnit output; it likely failed to start.\n`, ); console.error(`Test run produced no JUnit output. Wrote a stub to ${REPORT_PATH}.`); process.exit(exitCode || 1); } writeFileSync(REPORT_PATH, renderReport(parseJunit(JUNIT_PATH), parseLcov(LCOV_PATH), meta)); console.log(`Wrote ${REPORT_PATH}`); process.exit(exitCode);