Skip to content

Commit 1a3bfae

Browse files
committed
ci: enforce coverage threshold locally
Share the coverage threshold check between local test:coverage and CI so missing coverage summaries or below-threshold runs fail before push.
1 parent 93b27d2 commit 1a3bfae

4 files changed

Lines changed: 91 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,7 @@ jobs:
3131
- name: Unit tests with coverage
3232
env:
3333
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34-
run: |
35-
bun run test:coverage 2>&1 | tee /tmp/coverage.txt
36-
node -e "
37-
const out = require('fs').readFileSync('/tmp/coverage.txt', 'utf8');
38-
const m = out.match(/all files[^\|]*\|\s*([\d.]+)/i);
39-
if (!m) { console.log('No coverage summary found — skipping threshold check.'); process.exit(0); }
40-
const pct = parseFloat(m[1]);
41-
const min = 80;
42-
if (pct < min) { process.stderr.write('Coverage ' + pct.toFixed(1) + '% is below minimum ' + min + '%\n'); process.exit(1); }
43-
console.log('Coverage OK: ' + pct.toFixed(1) + '%');
44-
"
34+
run: bun run test:coverage
4535

4636
- name: Assert Node.js version
4737
run: node -e "const v=+process.version.slice(1).split('.')[0]; if(v<22)process.exit(1)"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"check": "biome check .",
3232
"check:fix": "biome check --write .",
3333
"test": "bun test src/",
34-
"test:coverage": "bun test src/ --coverage",
34+
"test:coverage": "bun src/coverage-threshold.ts",
3535
"prepublishOnly": "bun run build && bun run check && bun run test",
3636
"setup-hooks": "git config core.hooksPath .githooks"
3737
},

src/coverage-threshold.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { assertCoverageThreshold, parseAllFilesLineCoverage } from "./coverage-threshold.js";
4+
5+
describe("parseAllFilesLineCoverage", () => {
6+
test("extracts line coverage from Bun's All files summary row", () => {
7+
const output = `
8+
------------------------------------|---------|---------|-------------------
9+
File | % Funcs | % Lines | Uncovered Line #s
10+
------------------------------------|---------|---------|-------------------
11+
All files | 91.05 | 92.03 |
12+
src/server/json.ts | 100.00 | 96.55 | 16
13+
`;
14+
15+
expect(parseAllFilesLineCoverage(output)).toBe(92.03);
16+
});
17+
});
18+
19+
describe("assertCoverageThreshold", () => {
20+
test("throws when line coverage is below the minimum", () => {
21+
const output = "All files | 91.05 | 79.99 |";
22+
23+
expect(() => assertCoverageThreshold(output, 80)).toThrow(
24+
"Coverage 79.99% is below minimum 80%",
25+
);
26+
});
27+
});

src/coverage-threshold.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { spawn } from "node:child_process";
2+
3+
const DEFAULT_MIN_LINE_COVERAGE = 80;
4+
5+
export function parseAllFilesLineCoverage(output: string): number | undefined {
6+
const match = output.match(/all files[^\n|]*\|\s*[\d.]+\s*\|\s*([\d.]+)/i);
7+
if (!match?.[1]) return undefined;
8+
9+
return Number.parseFloat(match[1]);
10+
}
11+
12+
export function assertCoverageThreshold(
13+
output: string,
14+
minLineCoverage = DEFAULT_MIN_LINE_COVERAGE,
15+
): void {
16+
const coverage = parseAllFilesLineCoverage(output);
17+
if (coverage === undefined) {
18+
throw new Error("No coverage summary found.");
19+
}
20+
21+
if (coverage < minLineCoverage) {
22+
throw new Error(`Coverage ${coverage.toFixed(2)}% is below minimum ${minLineCoverage}%`);
23+
}
24+
25+
console.log(`Coverage OK: ${coverage.toFixed(2)}%`);
26+
}
27+
28+
async function runCoverageCli(): Promise<void> {
29+
const outputChunks: Buffer[] = [];
30+
const proc = spawn("bun", ["test", "src/", "--coverage"], {
31+
stdio: ["ignore", "pipe", "pipe"],
32+
});
33+
34+
proc.stdout.on("data", (chunk: Buffer) => {
35+
outputChunks.push(chunk);
36+
process.stdout.write(chunk);
37+
});
38+
proc.stderr.on("data", (chunk: Buffer) => {
39+
outputChunks.push(chunk);
40+
process.stderr.write(chunk);
41+
});
42+
43+
const exitCode = await new Promise<number>((resolve) => {
44+
proc.on("close", (code) => resolve(code ?? 1));
45+
});
46+
47+
const output = Buffer.concat(outputChunks).toString("utf8");
48+
if (exitCode !== 0) {
49+
process.exit(exitCode);
50+
}
51+
52+
try {
53+
assertCoverageThreshold(output);
54+
} catch (error) {
55+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
56+
process.exit(1);
57+
}
58+
}
59+
60+
if (import.meta.main) {
61+
await runCoverageCli();
62+
}

0 commit comments

Comments
 (0)