Skip to content

Commit e165216

Browse files
authored
feat(coverage): automatic threshold updating (#2886)
Closes #1241
1 parent 615e150 commit e165216

File tree

19 files changed

+203
-95
lines changed

19 files changed

+203
-95
lines changed

docs/config/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,16 @@ Do not show files with 100% statement, branch, and function coverage.
789789
Check thresholds per file.
790790
See `lines`, `functions`, `branches` and `statements` for the actual thresholds.
791791

792+
#### thresholdAutoUpdate
793+
794+
- **Type:** `boolean`
795+
- **Default:** `false`
796+
- **Available for providers:** `'c8' | 'istanbul'`
797+
- **CLI:** `--coverage.thresholdAutoUpdate=<boolean>`
798+
799+
Update threshold values `lines`, `functions`, `branches` and `statements` to configuration file when current coverage is above the configured thresholds.
800+
This option helps to maintain thresholds when coverage is improved.
801+
792802
#### lines
793803

794804
- **Type:** `number`

packages/coverage-c8/rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const external = [
2121
'vitest',
2222
'vitest/node',
2323
'vitest/config',
24+
'vitest/coverage',
2425
]
2526

2627
const plugins = [

packages/coverage-c8/src/provider.ts

Lines changed: 34 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import c from 'picocolors'
66
import { provider } from 'std-env'
77
import type { EncodedSourceMap } from 'vite-node'
88
import { coverageConfigDefaults } from 'vitest/config'
9+
import { BaseCoverageProvider } from 'vitest/coverage'
910
// eslint-disable-next-line no-restricted-imports
1011
import type { AfterSuiteRunMeta, CoverageC8Options, CoverageProvider, ReportContext, ResolvedCoverageOptions } from 'vitest'
1112
import type { Vitest } from 'vitest/node'
@@ -17,16 +18,36 @@ import { checkCoverages } from 'c8/lib/commands/check-coverage.js'
1718

1819
type Options = ResolvedCoverageOptions<'c8'>
1920

20-
export class C8CoverageProvider implements CoverageProvider {
21+
export class C8CoverageProvider extends BaseCoverageProvider implements CoverageProvider {
2122
name = 'c8'
2223

2324
ctx!: Vitest
2425
options!: Options
2526
coverages: Profiler.TakePreciseCoverageReturnType[] = []
2627

2728
initialize(ctx: Vitest) {
29+
const config: CoverageC8Options = ctx.config.coverage
30+
2831
this.ctx = ctx
29-
this.options = resolveC8Options(ctx.config.coverage, ctx.config.root)
32+
this.options = {
33+
...coverageConfigDefaults,
34+
35+
// Provider specific defaults
36+
excludeNodeModules: true,
37+
allowExternal: false,
38+
39+
// User's options
40+
...config,
41+
42+
// Resolved fields
43+
provider: 'c8',
44+
reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter),
45+
reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory),
46+
lines: config['100'] ? 100 : config.lines,
47+
functions: config['100'] ? 100 : config.functions,
48+
branches: config['100'] ? 100 : config.branches,
49+
statements: config['100'] ? 100 : config.statements,
50+
}
3051
}
3152

3253
resolveOptions() {
@@ -156,55 +177,18 @@ export class C8CoverageProvider implements CoverageProvider {
156177

157178
await report.run()
158179
await checkCoverages(options, report)
159-
}
160-
}
161-
162-
function resolveC8Options(options: CoverageC8Options, root: string): Options {
163-
const reportsDirectory = resolve(root, options.reportsDirectory || coverageConfigDefaults.reportsDirectory)
164-
165-
const resolved: Options = {
166-
...coverageConfigDefaults,
167-
168-
// Provider specific defaults
169-
excludeNodeModules: true,
170-
allowExternal: false,
171-
172-
// User's options
173-
...options,
174-
175-
// Resolved fields
176-
provider: 'c8',
177-
reporter: resolveReporters(options.reporter || coverageConfigDefaults.reporter),
178-
reportsDirectory,
179-
}
180180

181-
if (options['100']) {
182-
resolved.lines = 100
183-
resolved.functions = 100
184-
resolved.branches = 100
185-
resolved.statements = 100
186-
}
187-
188-
return resolved
189-
}
190-
191-
function resolveReporters(configReporters: NonNullable<CoverageC8Options['reporter']>): Options['reporter'] {
192-
// E.g. { reporter: "html" }
193-
if (!Array.isArray(configReporters))
194-
return [[configReporters, {}]]
195-
196-
const resolvedReporters: Options['reporter'] = []
197-
198-
for (const reporter of configReporters) {
199-
if (Array.isArray(reporter)) {
200-
// E.g. { reporter: [ ["html", { skipEmpty: true }], ["lcov"], ["json", { file: "map.json" }] ]}
201-
resolvedReporters.push([reporter[0], reporter[1] || {}])
202-
}
203-
else {
204-
// E.g. { reporter: ["html", "json"]}
205-
resolvedReporters.push([reporter, {}])
181+
if (this.options.thresholdAutoUpdate && allTestsRun) {
182+
this.updateThresholds({
183+
coverageMap: await report.getCoverageMapFromAllCoverageFiles(),
184+
thresholds: {
185+
branches: this.options.branches,
186+
functions: this.options.functions,
187+
lines: this.options.lines,
188+
statements: this.options.statements,
189+
},
190+
configurationFile: this.ctx.server.config.configFile,
191+
})
206192
}
207193
}
208-
209-
return resolvedReporters
210194
}

packages/coverage-istanbul/rollup.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const external = [
1919
'vitest',
2020
'vitest/node',
2121
'vitest/config',
22+
'vitest/coverage',
2223
]
2324

2425
const plugins = [

packages/coverage-istanbul/src/provider.ts

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { relative, resolve } from 'pathe'
44
import type { TransformPluginContext } from 'rollup'
55
import type { AfterSuiteRunMeta, CoverageIstanbulOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest'
66
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
7+
import { BaseCoverageProvider } from 'vitest/coverage'
78
import libReport from 'istanbul-lib-report'
89
import reports from 'istanbul-reports'
910
import type { CoverageMap } from 'istanbul-lib-coverage'
@@ -31,7 +32,7 @@ interface TestExclude {
3132
}
3233
}
3334

34-
export class IstanbulCoverageProvider implements CoverageProvider {
35+
export class IstanbulCoverageProvider extends BaseCoverageProvider implements CoverageProvider {
3536
name = 'istanbul'
3637

3738
ctx!: Vitest
@@ -48,8 +49,20 @@ export class IstanbulCoverageProvider implements CoverageProvider {
4849
coverages: any[] = []
4950

5051
initialize(ctx: Vitest) {
52+
const config: CoverageIstanbulOptions = ctx.config.coverage
53+
5154
this.ctx = ctx
52-
this.options = resolveIstanbulOptions(ctx.config.coverage, ctx.config.root)
55+
this.options = {
56+
...coverageConfigDefaults,
57+
58+
// User's options
59+
...config,
60+
61+
// Resolved fields
62+
provider: 'istanbul',
63+
reportsDirectory: resolve(ctx.config.root, config.reportsDirectory || coverageConfigDefaults.reportsDirectory),
64+
reporter: this.resolveReporters(config.reporter || coverageConfigDefaults.reporter),
65+
}
5366

5467
this.instrumenter = createInstrumenter({
5568
produceSourceMap: true,
@@ -141,6 +154,19 @@ export class IstanbulCoverageProvider implements CoverageProvider {
141154
statements: this.options.statements,
142155
})
143156
}
157+
158+
if (this.options.thresholdAutoUpdate && allTestsRun) {
159+
this.updateThresholds({
160+
coverageMap,
161+
thresholds: {
162+
branches: this.options.branches,
163+
functions: this.options.functions,
164+
lines: this.options.lines,
165+
statements: this.options.statements,
166+
},
167+
configurationFile: this.ctx.server.config.configFile,
168+
})
169+
}
144170
}
145171

146172
checkThresholds(coverageMap: CoverageMap, thresholds: Record<Threshold, number | undefined>) {
@@ -220,24 +246,6 @@ export class IstanbulCoverageProvider implements CoverageProvider {
220246
}
221247
}
222248

223-
function resolveIstanbulOptions(options: CoverageIstanbulOptions, root: string): Options {
224-
const reportsDirectory = resolve(root, options.reportsDirectory || coverageConfigDefaults.reportsDirectory)
225-
226-
const resolved: Options = {
227-
...coverageConfigDefaults,
228-
229-
// User's options
230-
...options,
231-
232-
// Resolved fields
233-
provider: 'istanbul',
234-
reportsDirectory,
235-
reporter: resolveReporters(options.reporter || coverageConfigDefaults.reporter),
236-
}
237-
238-
return resolved
239-
}
240-
241249
/**
242250
* Remove possible query parameters from filenames
243251
* - From `/src/components/Header.component.ts?vue&type=script&src=true&lang.ts`
@@ -287,24 +295,3 @@ function isEmptyCoverageRange(range: libCoverage.Range) {
287295
|| range.end.column === undefined
288296
)
289297
}
290-
291-
function resolveReporters(configReporters: NonNullable<CoverageIstanbulOptions['reporter']>): Options['reporter'] {
292-
// E.g. { reporter: "html" }
293-
if (!Array.isArray(configReporters))
294-
return [[configReporters, {}]]
295-
296-
const resolvedReporters: Options['reporter'] = []
297-
298-
for (const reporter of configReporters) {
299-
if (Array.isArray(reporter)) {
300-
// E.g. { reporter: [ ["html", { skipEmpty: true }], ["lcov"], ["json", { file: "map.json" }] ]}
301-
resolvedReporters.push([reporter[0], reporter[1] || {}])
302-
}
303-
else {
304-
// E.g. { reporter: ["html", "json"]}
305-
resolvedReporters.push([reporter, {}])
306-
}
307-
}
308-
309-
return resolvedReporters
310-
}

packages/vitest/coverage.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './dist/coverage.js'

packages/vitest/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@
6565
"types": "./config.d.ts",
6666
"require": "./dist/config.cjs",
6767
"import": "./dist/config.js"
68+
},
69+
"./coverage": {
70+
"types": "./coverage.d.ts",
71+
"import": "./dist/coverage.js"
6872
}
6973
},
7074
"main": "./dist/index.js",
@@ -144,6 +148,7 @@
144148
"@edge-runtime/vm": "2.0.2",
145149
"@sinonjs/fake-timers": "^10.0.2",
146150
"@types/diff": "^5.0.2",
151+
"@types/istanbul-lib-coverage": "^2.0.4",
147152
"@types/istanbul-reports": "^3.0.1",
148153
"@types/jsdom": "^21.1.0",
149154
"@types/micromatch": "^4.0.2",

packages/vitest/rollup.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const entries = [
2626
'src/runtime/loader.ts',
2727
'src/runtime/entry.ts',
2828
'src/integrations/spy.ts',
29+
'src/coverage.ts',
2930
]
3031

3132
const dtsEntries = [
@@ -36,6 +37,7 @@ const dtsEntries = [
3637
'src/runners.ts',
3738
'src/suite.ts',
3839
'src/config.ts',
40+
'src/coverage.ts',
3941
]
4042

4143
const external = [

packages/vitest/src/coverage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { BaseCoverageProvider } from './utils/coverage'

packages/vitest/src/types/coverage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,13 @@ export interface BaseCoverageOptions {
186186
* @default undefined
187187
*/
188188
statements?: number
189+
190+
/**
191+
* Update threshold values automatically when current coverage is higher than earlier thresholds
192+
*
193+
* @default false
194+
*/
195+
thresholdAutoUpdate?: boolean
189196
}
190197

191198
export interface CoverageIstanbulOptions extends BaseCoverageOptions {

packages/vitest/src/utils/coverage.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { readFileSync, writeFileSync } from 'node:fs'
2+
import type { CoverageMap } from 'istanbul-lib-coverage'
3+
import type { BaseCoverageOptions, ResolvedCoverageOptions } from '../types'
4+
5+
type Threshold = 'lines' | 'functions' | 'statements' | 'branches'
6+
7+
const THRESHOLD_KEYS: Readonly<Threshold[]> = ['lines', 'functions', 'statements', 'branches']
8+
9+
export class BaseCoverageProvider {
10+
/**
11+
* Check if current coverage is above configured thresholds and bump the thresholds if needed
12+
*/
13+
updateThresholds({ configurationFile, coverageMap, thresholds }: {
14+
coverageMap: CoverageMap
15+
thresholds: Record<Threshold, number | undefined>
16+
configurationFile?: string
17+
}) {
18+
// Thresholds cannot be updated if there is no configuration file and
19+
// feature was enabled by CLI, e.g. --coverage.thresholdAutoUpdate
20+
if (!configurationFile)
21+
throw new Error('Missing configurationFile. The "coverage.thresholdAutoUpdate" can only be enabled when configuration file is used.')
22+
23+
const summary = coverageMap.getCoverageSummary()
24+
const thresholdsToUpdate: Threshold[] = []
25+
26+
for (const key of THRESHOLD_KEYS) {
27+
const threshold = thresholds[key] || 100
28+
const actual = summary[key].pct
29+
30+
if (actual > threshold)
31+
thresholdsToUpdate.push(key)
32+
}
33+
34+
if (thresholdsToUpdate.length === 0)
35+
return
36+
37+
const originalConfig = readFileSync(configurationFile, 'utf8')
38+
let updatedConfig = originalConfig
39+
40+
for (const threshold of thresholdsToUpdate) {
41+
// Find the exact match from the configuration file and replace the value
42+
const previousThreshold = (thresholds[threshold] || 100).toString()
43+
const pattern = new RegExp(`(${threshold}\\s*:\\s*)${previousThreshold.replace('.', '\\.')}`)
44+
const matches = originalConfig.match(pattern)
45+
46+
if (matches)
47+
updatedConfig = updatedConfig.replace(matches[0], matches[1] + summary[threshold].pct)
48+
else
49+
console.error(`Unable to update coverage threshold ${threshold}. No threshold found using pattern ${pattern}`)
50+
}
51+
52+
if (updatedConfig !== originalConfig) {
53+
// eslint-disable-next-line no-console
54+
console.log('Updating thresholds to configuration file. You may want to push with updated coverage thresholds.')
55+
writeFileSync(configurationFile, updatedConfig, 'utf-8')
56+
}
57+
}
58+
59+
/**
60+
* Resolve reporters from various configuration options
61+
*/
62+
resolveReporters(configReporters: NonNullable<BaseCoverageOptions['reporter']>): ResolvedCoverageOptions['reporter'] {
63+
// E.g. { reporter: "html" }
64+
if (!Array.isArray(configReporters))
65+
return [[configReporters, {}]]
66+
67+
const resolvedReporters: ResolvedCoverageOptions['reporter'] = []
68+
69+
for (const reporter of configReporters) {
70+
if (Array.isArray(reporter)) {
71+
// E.g. { reporter: [ ["html", { skipEmpty: true }], ["lcov"], ["json", { file: "map.json" }] ]}
72+
resolvedReporters.push([reporter[0], reporter[1] || {}])
73+
}
74+
else {
75+
// E.g. { reporter: ["html", "json"]}
76+
resolvedReporters.push([reporter, {}])
77+
}
78+
}
79+
80+
return resolvedReporters
81+
}
82+
}

0 commit comments

Comments
 (0)