Files

205 lines
7.9 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Coverage reporter for the SPA suites.
*
* node coverage-report.cjs # e2e only (.nyc_output)
* node coverage-report.cjs unit # unit only (.nyc_output_unit)
* node coverage-report.cjs combined # e2e + unit, merged BY SOURCE LINE
*
* Each mode writes html + json-summary + text-summary under its own report dir
* and prints a per-file table + totals. Paths in the coverage data are absolute
* (.../frontend/src/...); we report them relative to the repo root.
*
* ── Why combined isn't a plain istanbul merge ────────────────────────────
* The e2e build (vite dev server) and the unit build (vitest + the
* @testing-library/svelte client compile) both instrument with
* vite-plugin-istanbul, but they compile each `.svelte` file differently, so
* the resulting statement/fn/branch MAPS have different shapes and ids for the
* same source. istanbul's `CoverageMap.merge` keys by statement INDEX, so
* merging two mismatched maps UNIONS them — inflating the denominator and
* dragging the percentage toward a blend of the two passes instead of the true
* union of covered code (e.g. admin: e2e 2498 + unit 2717 lines → 4042 merged).
*
* Source LINE NUMBERS, however, are identical across the two instrumenters
* (same source file). So for `combined` we merge by line: we take the e2e
* FileCoverage as the canonical structure (it instruments the whole app, the
* widest denominator) and fold the unit pass in by marking any e2e
* statement/fn/branch whose source line the unit pass covered. Files only the
* unit pass loaded pass through unchanged. The denominator stays stable; the
* covered set becomes the genuine e2e ∪ unit union.
*/
const fs = require('fs');
const path = require('path');
const libCoverage = require('istanbul-lib-coverage');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const REPO_ROOT = path.join(__dirname, '..', '..');
const E2E_DIR = path.join(__dirname, '.nyc_output');
const UNIT_DIR = path.join(__dirname, '.nyc_output_unit');
const MODES = {
e2e: { dirs: [E2E_DIR], report: path.join(__dirname, 'coverage-report'), label: 'E2E' },
unit: { dirs: [UNIT_DIR], report: path.join(__dirname, 'coverage-report-unit'), label: 'UNIT' },
combined: {
report: path.join(__dirname, 'coverage-report-combined'),
label: 'COMBINED (e2e ∪ unit, by source line)',
},
};
const mode = process.argv[2] || 'e2e';
const cfg = MODES[mode];
if (!cfg) {
console.error(`Unknown mode "${mode}". Use: e2e | unit | combined`);
process.exit(1);
}
/** Merge every coverage-*.json under `dirs` into one istanbul CoverageMap. */
function loadMap(dirs) {
const m = libCoverage.createCoverageMap({});
let n = 0;
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
m.merge(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
n++;
}
}
return { map: m, count: n };
}
/** `{ sourceLine: maxHits }` for every function, keyed by its declaration line. */
function fnHitsByLine(fc) {
const out = {};
const d = fc.data;
for (const id of Object.keys(d.fnMap)) {
const loc = d.fnMap[id].decl || d.fnMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null) out[line] = Math.max(out[line] || 0, d.f[id] || 0);
}
return out;
}
/** `{ sourceLine: maxArmHits }` across all branch arms, keyed by arm line. */
function branchHitsByLine(fc) {
const out = {};
const d = fc.data;
for (const id of Object.keys(d.branchMap)) {
const arms = d.b[id] || [];
const locs = d.branchMap[id].locations || [];
for (let k = 0; k < arms.length; k++) {
const loc = locs[k] || d.branchMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null) out[line] = Math.max(out[line] || 0, arms[k] || 0);
}
}
return out;
}
/**
* Build the combined map: e2e structure + unit coverage folded in by line.
* Anything the unit pass covered on a given source line marks the
* corresponding (uncovered) e2e statement/fn/branch arm on that line.
*/
function buildLineUnion(mapE2E, mapUnit) {
const out = libCoverage.createCoverageMap({});
const e2eFiles = new Set(mapE2E.files());
const unitFiles = new Set(mapUnit.files());
const all = new Set([...e2eFiles, ...unitFiles]);
for (const file of all) {
if (!e2eFiles.has(file)) {
out.addFileCoverage(mapUnit.fileCoverageFor(file).data);
continue;
}
if (!unitFiles.has(file)) {
out.addFileCoverage(mapE2E.fileCoverageFor(file).data);
continue;
}
// Present in both → base on e2e, overlay unit hits by source line.
const raw = JSON.parse(JSON.stringify(mapE2E.fileCoverageFor(file).data));
const unitFc = mapUnit.fileCoverageFor(file);
const unitLines = unitFc.getLineCoverage(); // { line: hits }
const unitFnLine = fnHitsByLine(unitFc);
const unitBranchLine = branchHitsByLine(unitFc);
for (const id of Object.keys(raw.statementMap)) {
if ((raw.s[id] || 0) === 0) {
const line = raw.statementMap[id].start.line;
if (unitLines[line] > 0) raw.s[id] = unitLines[line];
}
}
for (const id of Object.keys(raw.fnMap)) {
if ((raw.f[id] || 0) === 0) {
const loc = raw.fnMap[id].decl || raw.fnMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null && unitFnLine[line] > 0) raw.f[id] = unitFnLine[line];
}
}
for (const id of Object.keys(raw.branchMap)) {
const arms = raw.b[id] || [];
const locs = raw.branchMap[id].locations || [];
for (let k = 0; k < arms.length; k++) {
if ((arms[k] || 0) === 0) {
const loc = locs[k] || raw.branchMap[id].loc;
const line = loc && loc.start ? loc.start.line : undefined;
if (line != null && unitBranchLine[line] > 0) raw.b[id][k] = unitBranchLine[line];
}
}
}
out.addFileCoverage(raw);
}
return out;
}
let map;
let merged;
if (mode === 'combined') {
const e2e = loadMap([E2E_DIR]);
const unit = loadMap([UNIT_DIR]);
merged = e2e.count + unit.count;
if (merged === 0) {
console.error(`No coverage files found for combined in: ${E2E_DIR}, ${UNIT_DIR}`);
process.exit(1);
}
map = buildLineUnion(e2e.map, unit.map);
} else {
const loaded = loadMap(cfg.dirs);
map = loaded.map;
merged = loaded.count;
if (merged === 0) {
console.error(`No coverage files found for mode "${mode}" in: ${cfg.dirs.join(', ')}`);
process.exit(1);
}
}
const context = libReport.createContext({ dir: cfg.report, coverageMap: map });
reports.create('html').execute(context);
reports.create('json-summary').execute(context);
reports.create('text-summary').execute(context);
const rows = map.files().map((file) => {
const s = map.fileCoverageFor(file).toSummary();
return {
file: path.relative(REPO_ROOT, file),
pct: s.lines.pct,
covered: s.lines.covered,
total: s.lines.total,
};
});
rows.sort((a, b) => a.pct - b.pct);
console.log(`\n[${cfg.label}] merged ${merged} coverage file(s); ${rows.length} source files.\n`);
console.log('Least-covered files (line %):');
for (const r of rows.slice(0, 30)) {
console.log(` ${String(r.pct).padStart(6)}% ${r.covered}/${r.total} ${r.file}`);
}
const total = map.getCoverageSummary();
console.log(`\n=== ${cfg.label} TOTAL ===`);
console.log(`Statements : ${total.statements.pct}% (${total.statements.covered}/${total.statements.total})`);
console.log(`Branches : ${total.branches.pct}% (${total.branches.covered}/${total.branches.total})`);
console.log(`Functions : ${total.functions.pct}% (${total.functions.covered}/${total.functions.total})`);
console.log(`Lines : ${total.lines.pct}% (${total.lines.covered}/${total.lines.total})`);
console.log(`HTML report: ${path.relative(process.cwd(), cfg.report)}/index.html`);