ci: print exact uncovered locations when the coverage gate fails
The per-file 100% thresholds name only the failing file. A custom istanbul reporter now prints one clickable path:line:col record per uncovered statement, branch path, and function, right above the threshold errors, in both the CI coverage lane and local test:coverage runs (they share this config). CJS because istanbul-reports loads custom reporters with a bare require() outside the tsx/ESM pipeline.
This commit is contained in:
@@ -130,6 +130,7 @@ External packages **directly declared** only by repository tooling, test infrast
|
|||||||
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
|
||||||
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
|
||||||
| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
|
| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
|
||||||
|
| [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause |
|
||||||
| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
|
| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
|
||||||
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
|
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
|
||||||
| [`knip`](https://github.com/webpro-nl/knip) | ISC |
|
| [`knip`](https://github.com/webpro-nl/knip) | ISC |
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
".": {
|
".": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"scripts/**/*.mjs"
|
"scripts/**/*.mjs",
|
||||||
|
"scripts/**/*.cjs"
|
||||||
],
|
],
|
||||||
"project": [
|
"project": [
|
||||||
"scripts/**/*.ts",
|
"scripts/**/*.ts",
|
||||||
"scripts/**/*.mjs"
|
"scripts/**/*.mjs",
|
||||||
|
"scripts/**/*.cjs"
|
||||||
],
|
],
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
"playwright"
|
"playwright"
|
||||||
|
|||||||
@@ -134,6 +134,7 @@
|
|||||||
"eslint-plugin-sonarjs": "^4.1.0",
|
"eslint-plugin-sonarjs": "^4.1.0",
|
||||||
"execa": "^10.0.0",
|
"execa": "^10.0.0",
|
||||||
"fast-check": "^4.8.0",
|
"fast-check": "^4.8.0",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
"js-yaml": "^4.2.0",
|
"js-yaml": "^4.2.0",
|
||||||
"jscpd": "^5.0.12",
|
"jscpd": "^5.0.12",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
|
|||||||
Generated
+3
@@ -62,6 +62,9 @@ importers:
|
|||||||
fast-check:
|
fast-check:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.0
|
version: 4.8.0
|
||||||
|
istanbul-lib-report:
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Istanbul coverage reporter printing one clickable `path:line:col` record per
|
||||||
|
* uncovered statement, branch path, and function. Vitest's per-file threshold
|
||||||
|
* failures name only the file; this reporter supplies the exact locations,
|
||||||
|
* printed just above those ERROR lines (reports run before threshold checks).
|
||||||
|
* Files at 100% print nothing, so a green run stays silent.
|
||||||
|
*
|
||||||
|
* CommonJS by requirement: istanbul-reports loads custom reporters with a bare
|
||||||
|
* require() outside the tsx/ESM pipeline (istanbul-reports index.js create()),
|
||||||
|
* so this file can be neither TypeScript nor ESM. Wired into vitest.config.ts
|
||||||
|
* by absolute path — require() would resolve a relative specifier against
|
||||||
|
* istanbul-reports' own directory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('node:path');
|
||||||
|
const { ReportBase } = require('istanbul-lib-report');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor-convention `line:column` of an istanbul location start (istanbul
|
||||||
|
* columns are 0-based; editors and terminal link handlers expect 1-based).
|
||||||
|
*/
|
||||||
|
function pos(loc) {
|
||||||
|
return `${loc.start.line}:${loc.start.column + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a location carries a usable 1-based start line. */
|
||||||
|
function usable(loc) {
|
||||||
|
return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ` (to line:col)` suffix when the range end adds information beyond the
|
||||||
|
* start. v8-remapped whole-line statements carry end.column = Infinity; those
|
||||||
|
* degrade to a line-only suffix, or to nothing on a single line.
|
||||||
|
*/
|
||||||
|
function endSuffix(loc) {
|
||||||
|
const end = loc.end;
|
||||||
|
if (!end || !Number.isFinite(end.line) || end.line < 1) return '';
|
||||||
|
if (!Number.isFinite(end.column)) {
|
||||||
|
return end.line === loc.start.line ? '' : ` (to ${end.line})`;
|
||||||
|
}
|
||||||
|
if (end.line === loc.start.line && end.column === loc.start.column) return '';
|
||||||
|
return ` (to ${end.line}:${end.column + 1})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UncoveredLocationsReport extends ReportBase {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
super(opts);
|
||||||
|
// Vitest passes the resolved config root alongside reporter options.
|
||||||
|
this.projectRoot = opts.projectRoot || process.cwd();
|
||||||
|
this.records = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onStart() {
|
||||||
|
this.records = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onDetail(node) {
|
||||||
|
const fc = node.getFileCoverage();
|
||||||
|
const rel = path.relative(this.projectRoot, fc.path).split(path.sep).join('/');
|
||||||
|
const items = [];
|
||||||
|
const add = (loc, text) => items.push({ line: loc.start.line, column: loc.start.column, text });
|
||||||
|
|
||||||
|
for (const id of Object.keys(fc.statementMap)) {
|
||||||
|
if (fc.s[id] !== 0) continue;
|
||||||
|
const loc = fc.statementMap[id];
|
||||||
|
if (!usable(loc)) continue;
|
||||||
|
add(loc, `${rel}:${pos(loc)} uncovered statement${endSuffix(loc)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(fc.fnMap)) {
|
||||||
|
if (fc.f[id] !== 0) continue;
|
||||||
|
const fn = fc.fnMap[id];
|
||||||
|
const loc = usable(fn.decl) ? fn.decl : fn.loc;
|
||||||
|
if (!usable(loc)) continue;
|
||||||
|
const name = fn.name ? ` ${fn.name}` : '';
|
||||||
|
add(loc, `${rel}:${pos(loc)} uncovered function${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(fc.branchMap)) {
|
||||||
|
const counts = fc.b[id];
|
||||||
|
const branch = fc.branchMap[id];
|
||||||
|
for (let i = 0; i < counts.length; i += 1) {
|
||||||
|
if (counts[i] !== 0) continue;
|
||||||
|
// Implicit arms (e.g. a missing else) may carry an empty location;
|
||||||
|
// fall back to the branch's own span so the record stays clickable.
|
||||||
|
const loc = usable(branch.locations && branch.locations[i]) ? branch.locations[i] : branch.loc;
|
||||||
|
if (!usable(loc)) continue;
|
||||||
|
add(loc, `${rel}:${pos(loc)} uncovered branch (${branch.type}, path ${i + 1}/${counts.length})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) return;
|
||||||
|
items.sort((a, b) => a.line - b.line || a.column - b.column);
|
||||||
|
for (const item of items) this.records.push(item.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnd() {
|
||||||
|
if (this.records.length === 0) return;
|
||||||
|
console.log(`\nUncovered locations (per-file 100% gate): ${this.records.length}`);
|
||||||
|
for (const record of this.records) console.log(record);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UncoveredLocationsReport;
|
||||||
+10
-1
@@ -1,10 +1,17 @@
|
|||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
|
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import { vitestExecArgv } from './vitest.shared.ts'
|
import { vitestExecArgv } from './vitest.shared.ts'
|
||||||
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
|
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
|
||||||
|
|
||||||
|
// Prints exact `path:line:col` records for every uncovered statement, branch
|
||||||
|
// path, and function when a file misses the per-file 100% gate — the built-in
|
||||||
|
// threshold ERRORs name only the file. Absolute path because istanbul-reports
|
||||||
|
// require()s custom reporters (which is also why the reporter is CJS).
|
||||||
|
const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-uncovered-locations.cjs', import.meta.url))
|
||||||
|
|
||||||
// Resolution facade shared by every plugin instance below: tsconfig.base.json
|
// Resolution facade shared by every plugin instance below: tsconfig.base.json
|
||||||
// has no include, which vite-tsconfig-paths treats as match-all, so its paths
|
// has no include, which vite-tsconfig-paths treats as match-all, so its paths
|
||||||
// map applies to every test file. paths must win over package exports so built
|
// map applies to every test file. paths must win over package exports so built
|
||||||
@@ -227,7 +234,9 @@ export default defineConfig({
|
|||||||
functions: 100,
|
functions: 100,
|
||||||
lines: 100,
|
lines: 100,
|
||||||
},
|
},
|
||||||
reporter: process.env.CI ? ['text'] : ['text', 'html'],
|
reporter: process.env.CI
|
||||||
|
? ['text', uncoveredLocationsReporter]
|
||||||
|
: ['text', 'html', uncoveredLocationsReporter],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user