@@ -6,6 +6,7 @@ import libReport from "istanbul-lib-report";
6
6
import reports from "istanbul-reports" ;
7
7
import { resolve } from "pathe" ;
8
8
import c from "tinyrainbow" ;
9
+ import pm from "picomatch" ;
9
10
import { coverageConfigDefaults } from "vitest/config" ;
10
11
import { FileCoverageMapService } from "./file-coverage-map-service.js" ;
11
12
@@ -34,7 +35,7 @@ class JsonSchemaCoverageProvider {
34
35
35
36
ctx = /** @type Vitest */ ( { } ) ;
36
37
37
- options = /** @type ResolvedCoverageOptions<"custom "> */ ( { } ) ;
38
+ options = /** @type ResolvedCoverageOptions<"istanbul "> */ ( { } ) ;
38
39
39
40
/** @type Map<string, boolean> */
40
41
globCache = new Map ( ) ;
@@ -46,24 +47,28 @@ class JsonSchemaCoverageProvider {
46
47
initialize ( ctx ) {
47
48
this . ctx = ctx ;
48
49
49
- const config = /** @type ResolvedCoverageOptions & { include: string[]; } */ ( ctx . config . coverage ) ;
50
+ const config = /** @type ResolvedCoverageOptions<"istanbul"> */ ( ctx . config . coverage ) ;
50
51
51
- /** @type ResolvedCoverageOptions<"custom"> */
52
- this . options = {
52
+ this . options = /** @type ResolvedCoverageOptions<"istanbul"> */ ( {
53
53
...coverageConfigDefaults ,
54
54
55
55
// User's options
56
56
...config ,
57
57
58
58
// Resolved fields
59
- provider : "custom" ,
60
- customProviderModule : this . name ,
61
59
reportsDirectory : resolve (
62
60
ctx . config . root ,
63
61
config . reportsDirectory || coverageConfigDefaults . reportsDirectory
64
62
) ,
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
+ } ) ;
67
72
68
73
const buildScriptPath = path . resolve ( import . meta. dirname , "build-coverage-maps.js" ) ;
69
74
/** @type string[] */ ( ctx . config . globalSetup ) . push ( buildScriptPath ) ;
@@ -110,7 +115,7 @@ class JsonSchemaCoverageProvider {
110
115
111
116
/** @type CoverageProvider["reportCoverage"] */
112
117
async reportCoverage ( coverageMap ) {
113
- this . generateReports ( /** @type CoverageMap */ ( coverageMap ) ?? coverage . createCoverageMap ( ) ) ;
118
+ this . # generateReports( /** @type CoverageMap */ ( coverageMap ) ?? coverage . createCoverageMap ( ) ) ;
114
119
115
120
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
116
121
const keepResults = ! this . options . cleanOnRerun && this . ctx . config . watch ;
@@ -121,13 +126,13 @@ class JsonSchemaCoverageProvider {
121
126
}
122
127
123
128
/** @type (coverageMap: CoverageMap) => void */
124
- generateReports ( coverageMap ) {
129
+ # generateReports( coverageMap ) {
125
130
const context = libReport . createContext ( {
126
131
dir : this . options . reportsDirectory ,
127
132
coverageMap
128
133
} ) ;
129
134
130
- if ( this . hasTerminalReporter ( this . options . reporter ) ) {
135
+ if ( this . # hasTerminalReporter( this . options . reporter ) ) {
131
136
this . ctx . logger . log ( c . blue ( " % " ) + c . dim ( "Coverage report from " ) + c . yellow ( this . name ) ) ;
132
137
}
133
138
@@ -140,10 +145,14 @@ class JsonSchemaCoverageProvider {
140
145
} )
141
146
. execute ( context ) ;
142
147
}
148
+
149
+ if ( this . options . thresholds ) {
150
+ this . reportThresholds ( coverageMap ) ;
151
+ }
143
152
}
144
153
145
154
/** @type (reporters: ResolvedCoverageOptions["reporter"])=> boolean */
146
- hasTerminalReporter ( reporters ) {
155
+ # hasTerminalReporter( reporters ) {
147
156
return reporters . some ( ( [ reporter ] ) => {
148
157
return reporter === "text"
149
158
|| reporter === "text-summary"
@@ -176,6 +185,157 @@ class JsonSchemaCoverageProvider {
176
185
177
186
return coverageMap ;
178
187
}
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
+ }
179
339
}
180
340
181
341
/** @type (configReporters: NonNullable<BaseCoverageOptions["reporter"]>) => [string, Record<string, unknown>][] */
@@ -201,4 +361,35 @@ const resolveCoverageReporters = (configReporters) => {
201
361
return resolvedReporters ;
202
362
} ;
203
363
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
+
204
395
export default JsonSchemaCoverageProviderModule ;
0 commit comments