205 lines
7.9 KiB
JavaScript
205 lines
7.9 KiB
JavaScript
|
|
#!/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`);
|