This repository has been archived on 2026-07-29. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kitchen-md/scripts/test-report.ts
alexion 11866a0dd5 feat: scope test report to given paths, default all (task 0003)
Forward any positional arguments to `bun test` as path filters, so
`bun run test:report packages/core` reports just core while a bare
`bun run test:report` still covers the whole repo. The report's header
records the scope.
2026-07-27 22:55:01 -04:00

279 lines
8.3 KiB
TypeScript

// Runs the test suite and renders reports/test-report.md: a summary, a
// describe-grouped inventory, and a coverage table.
// Any positional arguments are forwarded to `bun test` as path filters; with
// none, the whole repo runs.
// 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(paths: string[]): number {
mkdirSync(REPORTS_DIR, { recursive: true });
const proc = Bun.spawnSync(
[
"bun",
"test",
...paths,
"--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<TestStatus, string> = {
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 paths = process.argv.slice(2);
const exitCode = runTests(paths);
mkdirSync(REPORTS_DIR, { recursive: true });
const scope = paths.length > 0 ? paths.join(", ") : "whole repo";
const meta = `Generated ${new Date().toISOString()} · commit ${gitSha()} · ${scope}`;
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);