From 0b6a52452a2e51920c4a277e30a2d7613f33cb35 Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 27 Jul 2026 22:51:30 -0400 Subject: [PATCH] build: add test report generator (task 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + bun.lock | 17 +++ bun.nix | 32 +++++ package.json | 4 +- scripts/test-report.ts | 273 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 scripts/test-report.ts diff --git a/.gitignore b/.gitignore index bae95d7..31ad133 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ result-* # test coverage output coverage/ +# generated test report and its intermediate artifacts +reports/ + # direnv / nix-direnv cache .direnv/ diff --git a/bun.lock b/bun.lock index 2d485a7..edaebe9 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/bun.nix b/bun.nix index d4674ef..ac06a6a 100644 --- a/bun.nix +++ b/bun.nix @@ -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=="; diff --git a/package.json b/package.json index fcd48b1..aa444ed 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/test-report.ts b/scripts/test-report.ts new file mode 100644 index 0000000..685f4e9 --- /dev/null +++ b/scripts/test-report.ts @@ -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 = { + 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);