158 lines
5.6 KiB
JavaScript
158 lines
5.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Renders a Vitest coverage-summary.json into either a shields.io badge URL
|
|
// (default) or a sticky PR-comment markdown table (--pr-comment).
|
|
//
|
|
// Usage:
|
|
// node scripts/coverage-summary.mjs # prints shields.io URL
|
|
// node scripts/coverage-summary.mjs --pr-comment # prints markdown for PR
|
|
// node scripts/coverage-summary.mjs --json # prints raw totals
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const MARKER = "<!-- coverage-report -->";
|
|
const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
const targetsPath = path.join(repoRoot, "scripts", "coverage-targets.json");
|
|
|
|
export function resolveSummaryPath({ root = repoRoot, env = process.env, fsApi = fs } = {}) {
|
|
if (env.COVERAGE_SUMMARY_PATH) {
|
|
return path.resolve(root, env.COVERAGE_SUMMARY_PATH);
|
|
}
|
|
|
|
const candidates = [
|
|
path.join(root, "coverage-gate", "coverage-summary.json"),
|
|
path.join(root, "coverage", "coverage-summary.json"),
|
|
];
|
|
|
|
return candidates.find((candidate) => fsApi.existsSync(candidate)) ?? candidates[0];
|
|
}
|
|
|
|
function loadSummary() {
|
|
const summaryPath = resolveSummaryPath();
|
|
if (!fs.existsSync(summaryPath)) return null;
|
|
return JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
|
}
|
|
|
|
function loadTargets() {
|
|
if (!fs.existsSync(targetsPath)) return [];
|
|
return JSON.parse(fs.readFileSync(targetsPath, "utf8"));
|
|
}
|
|
|
|
function aggregateTotals(summary) {
|
|
const total = summary.total ?? null;
|
|
if (!total) return null;
|
|
return {
|
|
statements: total.statements?.pct ?? 0,
|
|
branches: total.branches?.pct ?? 0,
|
|
functions: total.functions?.pct ?? 0,
|
|
lines: total.lines?.pct ?? 0,
|
|
};
|
|
}
|
|
|
|
function pickColor(pct) {
|
|
if (pct >= 90) return "brightgreen";
|
|
if (pct >= 75) return "green";
|
|
if (pct >= 60) return "yellowgreen";
|
|
if (pct >= 50) return "yellow";
|
|
if (pct >= 40) return "orange";
|
|
return "red";
|
|
}
|
|
|
|
function shieldsUrl(totals) {
|
|
if (!totals) return "https://img.shields.io/badge/coverage-unknown-lightgrey";
|
|
const pct = totals.statements.toFixed(1);
|
|
return `https://img.shields.io/badge/coverage-${pct}%25-${pickColor(totals.statements)}`;
|
|
}
|
|
|
|
function evaluatePerPath(summary, targets) {
|
|
const rows = [];
|
|
for (const target of targets) {
|
|
const filtered = Object.entries(summary).filter(([file]) => {
|
|
if (file === "total") return false;
|
|
const rel = path.relative(repoRoot, file);
|
|
return target.pathPrefixes.some((prefix) => rel.startsWith(prefix));
|
|
});
|
|
if (filtered.length === 0) {
|
|
rows.push({ name: target.name, statementsPct: null, branchesPct: null, target, status: "skip" });
|
|
continue;
|
|
}
|
|
const totals = filtered.reduce(
|
|
(acc, [, m]) => ({
|
|
statements: acc.statements + m.statements.covered,
|
|
statementsTotal: acc.statementsTotal + m.statements.total,
|
|
branches: acc.branches + m.branches.covered,
|
|
branchesTotal: acc.branchesTotal + m.branches.total,
|
|
}),
|
|
{ statements: 0, statementsTotal: 0, branches: 0, branchesTotal: 0 }
|
|
);
|
|
const statementsPct = totals.statementsTotal > 0 ? (totals.statements / totals.statementsTotal) * 100 : 0;
|
|
const branchesPct = totals.branchesTotal > 0 ? (totals.branches / totals.branchesTotal) * 100 : 0;
|
|
const passes = statementsPct >= target.statements && branchesPct >= target.branches;
|
|
rows.push({
|
|
name: target.name,
|
|
statementsPct,
|
|
branchesPct,
|
|
target,
|
|
status: passes ? "pass" : "fail",
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function renderPrComment(totals, rows) {
|
|
const lines = [];
|
|
lines.push(MARKER);
|
|
lines.push("## Coverage Report");
|
|
lines.push("");
|
|
if (!totals) {
|
|
lines.push("_coverage-summary.json not found — vitest with --coverage did not run._");
|
|
return lines.join("\n");
|
|
}
|
|
lines.push("| metric | total |");
|
|
lines.push("|---|---|");
|
|
lines.push(`| statements | **${totals.statements.toFixed(1)}%** |`);
|
|
lines.push(`| branches | **${totals.branches.toFixed(1)}%** |`);
|
|
lines.push(`| functions | **${totals.functions.toFixed(1)}%** |`);
|
|
lines.push(`| lines | **${totals.lines.toFixed(1)}%** |`);
|
|
lines.push("");
|
|
lines.push("### Per-path thresholds");
|
|
lines.push("");
|
|
lines.push("| path | statements | branches | gate | status |");
|
|
lines.push("|---|---|---|---|---|");
|
|
for (const row of rows) {
|
|
const tag = row.target.criticalPath ? " (critical)" : "";
|
|
const statements = row.statementsPct === null ? "n/a" : `${row.statementsPct.toFixed(1)}%`;
|
|
const branches = row.branchesPct === null ? "n/a" : `${row.branchesPct.toFixed(1)}%`;
|
|
const gate = `${row.target.statements}% / ${row.target.branches}%`;
|
|
const icon = row.status === "pass" ? "PASS" : row.status === "fail" ? "FAIL" : "SKIP";
|
|
lines.push(`| ${row.name}${tag} | ${statements} | ${branches} | ${gate} | ${icon} |`);
|
|
}
|
|
lines.push("");
|
|
lines.push("_Bump thresholds by editing `scripts/coverage-targets.json`. See `docs/architecture/test-coverage.md`._");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function main() {
|
|
const argv = process.argv.slice(2);
|
|
const summary = loadSummary();
|
|
const targets = loadTargets();
|
|
const totals = summary ? aggregateTotals(summary) : null;
|
|
|
|
if (argv.includes("--pr-comment")) {
|
|
const rows = summary ? evaluatePerPath(summary, targets) : [];
|
|
process.stdout.write(renderPrComment(totals, rows) + "\n");
|
|
return;
|
|
}
|
|
if (argv.includes("--json")) {
|
|
process.stdout.write(JSON.stringify(totals ?? {}, null, 2) + "\n");
|
|
return;
|
|
}
|
|
// Default: shields.io badge URL
|
|
process.stdout.write(shieldsUrl(totals) + "\n");
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
main();
|
|
}
|