Skip to content

Commit 21f94bf

Browse files
committed
Add support for coverage thresholds
1 parent e28cbd1 commit 21f94bf

File tree

3 files changed

+207
-14
lines changed

3 files changed

+207
-14
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default defineConfig({
6767
plugins: [jsonSchemaCoveragePlugin()],
6868
test: {
6969
globalSetup: ["./register-my-dialect.ts"], // Optional
70-
include: ["schema-tests/"], // Optional
70+
include: ["schema-tests/**/*.test.ts"], // Optional
7171
coverage: {
7272
include: ["schemas/**/*.json"] // Optional
7373
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
},
2626
"scripts": {
2727
"lint": "eslint src",
28-
"test": "vitest run --config src/vitest/vitest-json-schema.config.js",
28+
"test": "vitest run --config src/vitest/vitest-json-schema.config.js --coverage",
2929
"test:coverage": "vitest run --coverage",
3030
"type-check": "tsc --noEmit",
3131
"docs": "typedoc"
@@ -37,6 +37,7 @@
3737
"@types/istanbul-reports": "^3.0.4",
3838
"@types/moo": "^0.5.10",
3939
"@types/node": "*",
40+
"@types/picomatch": "^4.0.0",
4041
"@types/unist": "^3.0.3",
4142
"@vitest/coverage-v8": "^3.2.4",
4243
"eslint-import-resolver-typescript": "*",
@@ -56,6 +57,7 @@
5657
"istanbul-reports": "^3.1.7",
5758
"moo": "^0.5.2",
5859
"pathe": "^2.0.3",
60+
"picomatch": "^4.0.2",
5961
"tinyglobby": "^0.2.14",
6062
"vfile": "^6.0.3",
6163
"yaml": "^2.8.0",

src/vitest/coverage-provider.js

Lines changed: 203 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import libReport from "istanbul-lib-report";
66
import reports from "istanbul-reports";
77
import { resolve } from "pathe";
88
import c from "tinyrainbow";
9+
import pm from "picomatch";
910
import { coverageConfigDefaults } from "vitest/config";
1011
import { FileCoverageMapService } from "./file-coverage-map-service.js";
1112

@@ -34,7 +35,7 @@ class JsonSchemaCoverageProvider {
3435

3536
ctx = /** @type Vitest */ ({});
3637

37-
options = /** @type ResolvedCoverageOptions<"custom"> */ ({});
38+
options = /** @type ResolvedCoverageOptions<"istanbul"> */ ({});
3839

3940
/** @type Map<string, boolean> */
4041
globCache = new Map();
@@ -46,24 +47,28 @@ class JsonSchemaCoverageProvider {
4647
initialize(ctx) {
4748
this.ctx = ctx;
4849

49-
const config = /** @type ResolvedCoverageOptions & { include: string[]; } */ (ctx.config.coverage);
50+
const config = /** @type ResolvedCoverageOptions<"istanbul"> */ (ctx.config.coverage);
5051

51-
/** @type ResolvedCoverageOptions<"custom"> */
52-
this.options = {
52+
this.options = /** @type ResolvedCoverageOptions<"istanbul"> */ ({
5353
...coverageConfigDefaults,
5454

5555
// User's options
5656
...config,
5757

5858
// Resolved fields
59-
provider: "custom",
60-
customProviderModule: this.name,
6159
reportsDirectory: resolve(
6260
ctx.config.root,
6361
config.reportsDirectory || coverageConfigDefaults.reportsDirectory
6462
),
65-
reporter: resolveCoverageReporters(config.reporter || coverageConfigDefaults.reporter)
66-
};
63+
reporter: resolveCoverageReporters(config.reporter || coverageConfigDefaults.reporter),
64+
thresholds: config.thresholds && {
65+
...config.thresholds,
66+
lines: config.thresholds["100"] ? 100 : config.thresholds.lines,
67+
branches: config.thresholds["100"] ? 100 : config.thresholds.branches,
68+
functions: config.thresholds["100"] ? 100 : config.thresholds.functions,
69+
statements: config.thresholds["100"] ? 100 : config.thresholds.statements
70+
}
71+
});
6772

6873
const buildScriptPath = path.resolve(import.meta.dirname, "build-coverage-maps.js");
6974
/** @type string[] */ (ctx.config.globalSetup).push(buildScriptPath);
@@ -110,7 +115,7 @@ class JsonSchemaCoverageProvider {
110115

111116
/** @type CoverageProvider["reportCoverage"] */
112117
async reportCoverage(coverageMap) {
113-
this.generateReports(/** @type CoverageMap */ (coverageMap) ?? coverage.createCoverageMap());
118+
this.#generateReports(/** @type CoverageMap */ (coverageMap) ?? coverage.createCoverageMap());
114119

115120
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
116121
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch;
@@ -121,13 +126,13 @@ class JsonSchemaCoverageProvider {
121126
}
122127

123128
/** @type (coverageMap: CoverageMap) => void */
124-
generateReports(coverageMap) {
129+
#generateReports(coverageMap) {
125130
const context = libReport.createContext({
126131
dir: this.options.reportsDirectory,
127132
coverageMap
128133
});
129134

130-
if (this.hasTerminalReporter(this.options.reporter)) {
135+
if (this.#hasTerminalReporter(this.options.reporter)) {
131136
this.ctx.logger.log(c.blue(" % ") + c.dim("Coverage report from ") + c.yellow(this.name));
132137
}
133138

@@ -140,10 +145,14 @@ class JsonSchemaCoverageProvider {
140145
})
141146
.execute(context);
142147
}
148+
149+
if (this.options.thresholds) {
150+
this.reportThresholds(coverageMap);
151+
}
143152
}
144153

145154
/** @type (reporters: ResolvedCoverageOptions["reporter"])=> boolean */
146-
hasTerminalReporter(reporters) {
155+
#hasTerminalReporter(reporters) {
147156
return reporters.some(([reporter]) => {
148157
return reporter === "text"
149158
|| reporter === "text-summary"
@@ -176,6 +185,157 @@ class JsonSchemaCoverageProvider {
176185

177186
return coverageMap;
178187
}
188+
189+
/**
190+
* @typedef {"lines" | "functions" | "statements" | "branches"} Threshold
191+
*/
192+
193+
/**
194+
* @typedef {{
195+
* coverageMap: CoverageMap
196+
* name: string
197+
* thresholds: Partial<Record<Threshold, number | undefined>>
198+
* }} ResolvedThreshold
199+
*/
200+
201+
/** @type Set<Threshold> */
202+
#THRESHOLD_KEYS = new Set(["lines", "functions", "statements", "branches"]);
203+
#GLOBAL_THRESHOLDS_KEY = "global";
204+
205+
/** @type (coverageMap: CoverageMap) => void */
206+
reportThresholds(coverageMap) {
207+
const resolvedThresholds = this.#resolveThresholds(coverageMap);
208+
this.#checkThresholds(resolvedThresholds);
209+
}
210+
211+
/** @type (coverageMap: CoverageMap) => ResolvedThreshold[] */
212+
#resolveThresholds(coverageMap) {
213+
/** @type ResolvedThreshold[] */
214+
const resolvedThresholds = [];
215+
const files = coverageMap.files();
216+
const globalCoverageMap = coverage.createCoverageMap();
217+
218+
const thresholds = /** @type NonNullable<typeof this.options.thresholds> */ (this.options.thresholds);
219+
for (const key of /** @type {`${keyof NonNullable<typeof this.options.thresholds>}`[]} */ (Object.keys(thresholds))) {
220+
if (key === "perFile" || key === "autoUpdate" || key === "100" || this.#THRESHOLD_KEYS.has(key)) {
221+
continue;
222+
}
223+
224+
const glob = key;
225+
const globThresholds = resolveGlobThresholds(thresholds[glob]);
226+
const globCoverageMap = coverage.createCoverageMap();
227+
228+
const matcher = pm(glob);
229+
const matchingFiles = files.filter((file) => {
230+
return matcher(path.relative(this.ctx.config.root, file));
231+
});
232+
233+
for (const file of matchingFiles) {
234+
const fileCoverage = coverageMap.fileCoverageFor(file);
235+
globCoverageMap.addFileCoverage(fileCoverage);
236+
}
237+
238+
resolvedThresholds.push({
239+
name: glob,
240+
coverageMap: globCoverageMap,
241+
thresholds: globThresholds
242+
});
243+
}
244+
245+
// Global threshold is for all files, even if they are included by glob patterns
246+
for (const file of files) {
247+
const fileCoverage = coverageMap.fileCoverageFor(file);
248+
globalCoverageMap.addFileCoverage(fileCoverage);
249+
}
250+
251+
resolvedThresholds.unshift({
252+
name: this.#GLOBAL_THRESHOLDS_KEY,
253+
coverageMap: globalCoverageMap,
254+
thresholds: {
255+
branches: this.options.thresholds?.branches,
256+
functions: this.options.thresholds?.functions,
257+
lines: this.options.thresholds?.lines,
258+
statements: this.options.thresholds?.statements
259+
}
260+
});
261+
262+
return resolvedThresholds;
263+
}
264+
265+
/** @type (allThresholds: ResolvedThreshold[]) => void */
266+
#checkThresholds(allThresholds) {
267+
for (const { coverageMap, thresholds, name } of allThresholds) {
268+
if (thresholds.branches === undefined && thresholds.functions === undefined && thresholds.lines === undefined && thresholds.statements === undefined) {
269+
continue;
270+
}
271+
272+
// Construct list of coverage summaries where thresholds are compared against
273+
const summaries = this.options.thresholds?.perFile
274+
? coverageMap.files().map((file) => {
275+
return {
276+
file,
277+
summary: coverageMap.fileCoverageFor(file).toSummary()
278+
};
279+
})
280+
: [{ file: null, summary: coverageMap.getCoverageSummary() }];
281+
282+
// Check thresholds of each summary
283+
for (const { summary, file } of summaries) {
284+
for (const thresholdKey of this.#THRESHOLD_KEYS) {
285+
const threshold = thresholds[thresholdKey];
286+
287+
if (threshold === undefined) {
288+
continue;
289+
}
290+
291+
/**
292+
* Positive thresholds are treated as minimum coverage percentages (X means: X% of lines must be covered),
293+
* while negative thresholds are treated as maximum uncovered counts (-X means: X lines may be uncovered).
294+
*/
295+
if (threshold >= 0) {
296+
const coverage = summary.data[thresholdKey].pct;
297+
298+
if (coverage < threshold) {
299+
process.exitCode = 1;
300+
301+
/**
302+
* Generate error message based on perFile flag:
303+
* - ERROR: Coverage for statements (33.33%) does not meet threshold (85%) for src/math.ts
304+
* - ERROR: Coverage for statements (50%) does not meet global threshold (85%)
305+
*/
306+
let errorMessage = `ERROR: Coverage for ${thresholdKey} (${coverage}%) does not meet ${name === this.#GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`} threshold (${threshold}%)`;
307+
308+
if (this.options.thresholds?.perFile && file) {
309+
errorMessage += ` for ${path.relative("./", file).replace(/\\/g, "/")}`;
310+
}
311+
312+
this.ctx.logger.error(errorMessage);
313+
}
314+
} else {
315+
const uncovered = summary.data[thresholdKey].total - summary.data[thresholdKey].covered;
316+
const absoluteThreshold = threshold * -1;
317+
318+
if (uncovered > absoluteThreshold) {
319+
process.exitCode = 1;
320+
321+
/**
322+
* Generate error message based on perFile flag:
323+
* - ERROR: Uncovered statements (33) exceed threshold (30) for src/math.ts
324+
* - ERROR: Uncovered statements (33) exceed global threshold (30)
325+
*/
326+
let errorMessage = `ERROR: Uncovered ${thresholdKey} (${uncovered}) exceed ${name === this.#GLOBAL_THRESHOLDS_KEY ? name : `"${name}"`} threshold (${absoluteThreshold})`;
327+
328+
if (this.options.thresholds?.perFile && file) {
329+
errorMessage += ` for ${path.relative("./", file).replace(/\\/g, "/")}`;
330+
}
331+
332+
this.ctx.logger.error(errorMessage);
333+
}
334+
}
335+
}
336+
}
337+
}
338+
}
179339
}
180340

181341
/** @type (configReporters: NonNullable<BaseCoverageOptions["reporter"]>) => [string, Record<string, unknown>][] */
@@ -201,4 +361,35 @@ const resolveCoverageReporters = (configReporters) => {
201361
return resolvedReporters;
202362
};
203363

364+
/** @type (thresholds: unknown) => ResolvedThreshold["thresholds"] */
365+
const resolveGlobThresholds = (thresholds) => {
366+
if (!thresholds || typeof thresholds !== "object") {
367+
return {};
368+
}
369+
370+
if ("100" in thresholds && thresholds["100"] === true) {
371+
return {
372+
lines: 100,
373+
branches: 100,
374+
functions: 100,
375+
statements: 100
376+
};
377+
}
378+
379+
return {
380+
lines: "lines" in thresholds && typeof thresholds.lines === "number"
381+
? thresholds.lines
382+
: undefined,
383+
branches: "branches" in thresholds && typeof thresholds.branches === "number"
384+
? thresholds.branches
385+
: undefined,
386+
functions: "functions" in thresholds && typeof thresholds.functions === "number"
387+
? thresholds.functions
388+
: undefined,
389+
statements: "statements" in thresholds && typeof thresholds.statements === "number"
390+
? thresholds.statements
391+
: undefined
392+
};
393+
};
394+
204395
export default JsonSchemaCoverageProviderModule;

0 commit comments

Comments
 (0)