build: add test report generator (task 0003)

Add scripts/test-report.ts (bun run test:report): it runs the full suite
with JUnit + lcov output, then renders a gitignored reports/test-report.md
— a summary, a describe-grouped inventory (pass/todo/fail), and a
coverage table. The report is written even when tests fail and the script
exits with the test run's own status, so CI can gate on it later. Adds
fast-xml-parser (dev) for robust JUnit parsing; lcov is parsed by hand.
This commit is contained in:
2026-07-27 22:51:30 -04:00
parent 3d81d1c76e
commit 0b6a52452a
5 changed files with 328 additions and 1 deletions

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ result-*
# test coverage output
coverage/
# generated test report and its intermediate artifacts
reports/
# direnv / nix-direnv cache
.direnv/

View File

@@ -8,6 +8,7 @@
"@biomejs/biome": "^2.5.3",
"bun-types": "latest",
"bun2nix": "^2.1.2",
"fast-xml-parser": "^5.10.1",
},
},
"packages/bin": {
@@ -57,6 +58,8 @@
"@kitchen-md/core": ["@kitchen-md/core@workspace:packages/core"],
"@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
@@ -67,6 +70,8 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
@@ -91,12 +96,18 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="],
"fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
@@ -157,12 +168,16 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
@@ -181,6 +196,8 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],

32
bun.nix
View File

@@ -51,6 +51,10 @@
};
"@kitchen-md/bin" = copyPathToStore ./packages/bin;
"@kitchen-md/core" = copyPathToStore ./packages/core;
"@nodable/entities@3.0.0" = fetchurl {
url = "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz";
hash = "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==";
};
"@types/debug@4.1.13" = fetchurl {
url = "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz";
hash = "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==";
@@ -71,6 +75,10 @@
url = "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz";
hash = "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==";
};
"anynum@1.0.1" = fetchurl {
url = "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz";
hash = "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==";
};
"bail@2.0.2" = fetchurl {
url = "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz";
hash = "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==";
@@ -119,6 +127,14 @@
url = "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz";
hash = "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==";
};
"fast-xml-builder@1.3.0" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz";
hash = "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==";
};
"fast-xml-parser@5.10.1" = fetchurl {
url = "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz";
hash = "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==";
};
"fault@2.0.1" = fetchurl {
url = "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz";
hash = "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==";
@@ -131,6 +147,10 @@
url = "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz";
hash = "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==";
};
"is-unsafe@2.0.0" = fetchurl {
url = "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz";
hash = "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==";
};
"longest-streak@3.1.0" = fetchurl {
url = "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz";
hash = "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==";
@@ -251,6 +271,10 @@
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
};
"path-expression-matcher@1.6.2" = fetchurl {
url = "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz";
hash = "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==";
};
"remark-frontmatter@5.0.0" = fetchurl {
url = "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz";
hash = "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==";
@@ -263,6 +287,10 @@
url = "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz";
hash = "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==";
};
"strnum@2.4.1" = fetchurl {
url = "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz";
hash = "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==";
};
"trough@2.2.0" = fetchurl {
url = "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz";
hash = "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==";
@@ -299,6 +327,10 @@
url = "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz";
hash = "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==";
};
"xml-naming@0.3.0" = fetchurl {
url = "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz";
hash = "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==";
};
"yaml@2.9.0" = fetchurl {
url = "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz";
hash = "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==";

View File

@@ -9,6 +9,7 @@
"scripts": {
"test": "bun test",
"test:coverage": "bun test --coverage",
"test:report": "bun run scripts/test-report.ts",
"lint": "biome check .",
"format": "biome check --write .",
"postinstall": "bun2nix -o bun.nix"
@@ -16,6 +17,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.3",
"bun-types": "latest",
"bun2nix": "^2.1.2"
"bun2nix": "^2.1.2",
"fast-xml-parser": "^5.10.1"
}
}

273
scripts/test-report.ts Normal file
View File

@@ -0,0 +1,273 @@
// 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<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 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);