Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/verify-test-coverage.mjs
T
2026-07-08 16:15:37 +02:00

185 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
// Optional coverage gate. Runs vitest with --coverage against apps/api and
// apps/worker source roots, parses the JSON summary, and asserts each target
// stays at or above its configured threshold.
//
// Requires @vitest/coverage-v8 (workspace-level dev dep) to be installed.
// When the package is missing, the gate degrades to a SKIP with a clear
// install hint so CI does not block on tooling that has not been adopted yet.
//
// Targets are declared in scripts/coverage-targets.json so future work can
// adjust thresholds without touching this runner.
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
const targetsPath = path.join(repoRoot, "scripts", "coverage-targets.json");
const coverageGatePath = path.join(repoRoot, "coverage-gate");
const summaryPath = path.join(coverageGatePath, "coverage-summary.json");
function readTargets() {
const raw = readFileSync(targetsPath, "utf8");
return JSON.parse(raw);
}
function coverageDepInstalled() {
try {
const pkg = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const devDeps = pkg.devDependencies ?? {};
return Boolean(devDeps["@vitest/coverage-v8"] ?? devDeps["@vitest/coverage-c8"]);
} catch {
return false;
}
}
export function buildVitestCoverageArgs(includeGlobs) {
return ["exec", "vitest", "run", "--coverage", "--pool=threads", ...includeGlobs];
}
export function buildVitestCoverageEnv(env = process.env) {
return { ...env, HIVEGRID_COVERAGE_GATE: "1" };
}
export function isIgnorableCoverageTempFailure({ status, output, summaryExists }) {
if (status === 0 || !summaryExists) return false;
if (!/ENOENT: no such file or directory/.test(output)) return false;
if (!/[\\/]coverage(?:-gate)?[\\/]\.tmp(?:[\\/]|')/.test(output)) return false;
const testFilesLine = output
.split(/\r?\n/)
.reverse()
.find((line) => line.includes("Test Files"));
const testsLine = output
.split(/\r?\n/)
.reverse()
.find((line) => /^\s*Tests\s+/.test(line));
return Boolean(testFilesLine && testsLine && !/\bfailed\b/i.test(testFilesLine) && !/\bfailed\b/i.test(testsLine));
}
function runVitestCoverage(includeGlobs) {
if (existsSync(coverageGatePath)) {
rmSync(coverageGatePath, { recursive: true, force: true });
}
// vitest 0.29 CLI does not support dotted --coverage.reporter flags; the
// reporters (text-summary, json-summary) are configured in vitest.config.ts.
//
// Keep this gate on the threads pool: the broad multi-package V8 coverage
// run can race fork teardown against coverage temp-file cleanup, producing a
// coverage-only ENOENT after all tests have passed and the JSON summary has
// been written.
const args = buildVitestCoverageArgs(includeGlobs);
const result = spawnSync("pnpm", args, { cwd: repoRoot, encoding: "utf8", maxBuffer: 50 * 1024 * 1024, env: buildVitestCoverageEnv() });
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
if (isIgnorableCoverageTempFailure({ status: result.status, output, summaryExists: existsSync(summaryPath) })) {
console.warn("WARN ignored Vitest V8 coverage temp cleanup ENOENT after successful test run and summary generation");
return true;
}
return result.status === 0;
}
function readSummary() {
if (!existsSync(summaryPath)) return null;
return JSON.parse(readFileSync(summaryPath, "utf8"));
}
function canSkipEmptyTarget(target) {
if (target.criticalPath === true) return false;
return target.allowEmpty === true || (target.statements === 0 && target.branches === 0 && /\btype-only\b/i.test(target.name));
}
export function evaluateTargets(targets, summary) {
const failures = [];
const passes = [];
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) {
const tag = target.criticalPath ? " [CRITICAL]" : "";
if (canSkipEmptyTarget(target)) {
passes.push(`SKIP ${target.name}${tag} no covered files matched`);
} else {
failures.push(`FAIL ${target.name}${tag} no covered files matched`);
}
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 sprintTarget = target.sprintTarget;
const sprintGap = sprintTarget
? ` sprintTarget=${sprintTarget.statements}%/${sprintTarget.branches}% (gap statements=${(sprintTarget.statements - statementsPct).toFixed(1)} branches=${(sprintTarget.branches - branchesPct).toFixed(1)})`
: "";
const tag = target.criticalPath ? " [CRITICAL]" : "";
const line = `${target.name}${tag} statements=${statementsPct.toFixed(1)}% (target ${target.statements}%) branches=${branchesPct.toFixed(1)}% (target ${target.branches}%)${sprintGap}`;
if (statementsPct >= target.statements && branchesPct >= target.branches) {
passes.push(`PASS ${line}`);
} else {
failures.push(`FAIL ${line}`);
}
}
return { passes, failures };
}
function main() {
if (!coverageDepInstalled()) {
console.log("SKIP @vitest/coverage-v8 not installed");
console.log("");
console.log("To enable real coverage thresholds:");
console.log(" pnpm add -D -w @vitest/coverage-v8@~0.29");
console.log("");
console.log("Thresholds in scripts/coverage-targets.json will then be enforced by this gate.");
console.log("Until adoption, scripts/verify-test-density.mjs covers structural test-per-source ratios.");
process.exit(0);
}
const targets = readTargets();
const includeGlobs = [...new Set(targets.flatMap((t) => t.includeGlobs))];
const success = runVitestCoverage(includeGlobs);
if (!success) {
console.error("FAIL vitest run with --coverage exited non-zero");
process.exit(1);
}
const summary = readSummary();
if (!summary) {
console.error("FAIL coverage-gate/coverage-summary.json missing — verify @vitest/coverage-v8 reporter config");
process.exit(1);
}
const { passes, failures } = evaluateTargets(targets, summary);
for (const line of passes) console.log(line);
for (const line of failures) console.error(line);
console.log(`Summary: ${passes.length} passed, 0 observed, ${failures.length} failed`);
process.exit(failures.length > 0 ? 1 : 0);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}