@@ -6,6 +6,7 @@ import libReport from "istanbul-lib-report";
66import reports from "istanbul-reports" ;
77import { resolve } from "pathe" ;
88import c from "tinyrainbow" ;
9+ import pm from "picomatch" ;
910import { coverageConfigDefaults } from "vitest/config" ;
1011import { 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+
204395export default JsonSchemaCoverageProviderModule ;
0 commit comments