Skip to content

Commit 1d9392c

Browse files
kykim00AriPerkkio
andauthored
feat(coverage): add coverage.changed option to report only changed files (#9521)
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
1 parent 619179f commit 1d9392c

File tree

11 files changed

+247
-8
lines changed

11 files changed

+247
-8
lines changed

docs/config/coverage.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,12 @@ Directory of HTML coverage output to be served in [Vitest UI](/guide/ui) and [HT
405405
This is automatically configured when using builtin coverage reporters that produce HTML output (`html`, `html-spa`, and `lcov`). Use this option to override with a custom coverage reporting location when using custom coverage reporters.
406406

407407
Note that setting this option does not change where coverage HTML report is generated. Configure the `coverage.reporter` option to change the directory instead.
408+
409+
## coverage.changed
410+
411+
- **Type:** `boolean | string`
412+
- **Default:** `false` (inherits from `test.changed`)
413+
- **Available for providers:** `'v8' | 'istanbul'`
414+
- **CLI:** `--coverage.changed`, `--coverage.changed=<commit/branch>`
415+
416+
Collect coverage only for files changed since a specified commit or branch. When set to `true`, it uses staged and unstaged changes.

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,13 @@ High and low watermarks for branches in the format of `<high>,<low>`
278278

279279
High and low watermarks for functions in the format of `<high>,<low>`
280280

281+
### coverage.changed
282+
283+
- **CLI:** `--coverage.changed <commit/branch>`
284+
- **Config:** [coverage.changed](/config/coverage#coverage-changed)
285+
286+
Collect coverage only for files changed since a specified commit or branch (e.g., `origin/main` or `HEAD~1`). Inherits value from `--changed` by default.
287+
281288
### mode
282289

283290
- **CLI:** `--mode <name>`

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,20 @@ export const cliOptionsConfig: VitestCLIOptions = {
306306
},
307307
},
308308
},
309+
changed: {
310+
description:
311+
'Collect coverage only for files changed since a specified commit or branch (e.g., `origin/main` or `HEAD~1`). Inherits value from `--changed` by default.',
312+
argument: '<commit/branch>',
313+
transform(value) {
314+
if (value === 'true' || value === 'yes' || value === true) {
315+
return true
316+
}
317+
if (value === 'false' || value === 'no' || value === false) {
318+
return false
319+
}
320+
return value
321+
},
322+
},
309323
},
310324
},
311325
mode: {

packages/vitest/src/node/config/resolveConfig.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,9 @@ export function resolveConfig(
419419
}
420420

421421
resolved.coverage.reporter = resolveCoverageReporters(resolved.coverage.reporter)
422+
if (resolved.coverage.changed === undefined && resolved.changed !== undefined) {
423+
resolved.coverage.changed = resolved.changed
424+
}
422425

423426
if (resolved.coverage.enabled && resolved.coverage.reportsDirectory) {
424427
const reportsDirectory = resolve(

packages/vitest/src/node/core.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ export class Vitest {
607607
}
608608

609609
await this._testRun.start(specifications).catch(noop)
610+
await this.coverageProvider?.onTestRunStart?.()
610611

611612
for (const file of files) {
612613
await this._reportFileTask(file)
@@ -749,6 +750,7 @@ export class Vitest {
749750
if (!specifications.length) {
750751
await this._traces.$('vitest.test_run', async () => {
751752
await this._testRun.start([])
753+
await this.coverageProvider?.onTestRunStart?.()
752754
const coverage = await this.coverageProvider?.generateCoverage?.({ allTestsRun: true })
753755

754756
await this._testRun.end([], [], coverage)
@@ -880,6 +882,7 @@ export class Vitest {
880882
private async runFiles(specs: TestSpecification[], allTestsRun: boolean): Promise<TestRunResult> {
881883
return this._traces.$('vitest.test_run', async () => {
882884
await this._testRun.start(specs)
885+
await this.coverageProvider?.onTestRunStart?.()
883886

884887
// previous run
885888
await this.cancelPromise

packages/vitest/src/node/coverage.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
99
import module from 'node:module'
1010
import path from 'node:path'
1111
import { fileURLToPath } from 'node:url'
12-
import { slash } from '@vitest/utils/helpers'
12+
import { cleanUrl, slash } from '@vitest/utils/helpers'
1313
import { relative, resolve } from 'pathe'
1414
import pm from 'picomatch'
1515
import { glob } from 'tinyglobby'
@@ -86,6 +86,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
8686
pendingPromises: Promise<void>[] = []
8787
coverageFilesDirectory!: string
8888
roots: string[] = []
89+
changedFiles?: string[]
8990

9091
_initialize(ctx: Vitest): void {
9192
this.ctx = ctx
@@ -148,7 +149,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
148149
isIncluded(_filename: string, root?: string): boolean {
149150
const roots = root ? [root] : this.roots
150151

151-
const filename = slash(_filename)
152+
const filename = slash(cleanUrl(_filename))
152153
const cacheHit = this.globCache.get(filename)
153154

154155
if (cacheHit !== undefined) {
@@ -165,12 +166,16 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
165166
// By default `coverage.include` matches all files, except "coverage.exclude"
166167
const glob = this.options.include || '**'
167168

168-
const included = pm.isMatch(filename, glob, {
169+
let included = pm.isMatch(filename, glob, {
169170
contains: true,
170171
dot: true,
171172
ignore: this.options.exclude,
172173
})
173174

175+
if (included && this.changedFiles) {
176+
included = this.changedFiles.includes(filename)
177+
}
178+
174179
this.globCache.set(filename, included)
175180

176181
return included
@@ -192,8 +197,8 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
192197
// Run again through picomatch as tinyglobby's exclude pattern is different ({ "exclude": ["math"] } should ignore "src/math.ts")
193198
includedFiles = includedFiles.filter(file => this.isIncluded(file, root))
194199

195-
if (this.ctx.config.changed) {
196-
includedFiles = (this.ctx.config.related || []).filter(file => includedFiles.includes(file))
200+
if (this.changedFiles) {
201+
includedFiles = this.changedFiles.filter(file => includedFiles.includes(file))
197202
}
198203

199204
return includedFiles.map(file => slash(path.resolve(root, file)))
@@ -324,6 +329,23 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
324329
}
325330
}
326331

332+
async onTestRunStart(): Promise<void> {
333+
if (this.options.changed) {
334+
const { VitestGit } = await import('./git')
335+
const vitestGit = new VitestGit(this.ctx.config.root)
336+
const changedFiles = await vitestGit.findChangedFiles({ changedSince: this.options.changed })
337+
338+
this.changedFiles = changedFiles ?? undefined
339+
}
340+
else if (this.ctx.config.changed) {
341+
this.changedFiles = this.ctx.config.related
342+
}
343+
344+
if (this.changedFiles) {
345+
this.globCache.clear()
346+
}
347+
}
348+
327349
async onTestFailure(): Promise<void> {
328350
if (!this.options.reportOnFailure) {
329351
await this.cleanAfterRun()

packages/vitest/src/node/types/coverage.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export interface CoverageProvider {
2828
/** Called with coverage results after a single test file has been run */
2929
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void | Promise<void>
3030

31-
/** Callback called when test run fails */
31+
/** Callback called when test run starts */
32+
onTestRunStart?: () => void | Promise<void>
33+
34+
/** Callback called when test run fails due to test failures */
3235
onTestFailure?: () => void | Promise<void>
3336

3437
/** Callback to generate final coverage results */
@@ -274,14 +277,22 @@ export interface BaseCoverageOptions {
274277
* Use this option to override with custom coverage reporting location.
275278
*/
276279
htmlDir?: string
280+
281+
/**
282+
* Collect coverage only for files changed since a specified commit or branch.
283+
* Inherits the default value from `test.changed`.
284+
*
285+
* @default false
286+
*/
287+
changed?: boolean | string
277288
}
278289

279290
export interface CoverageIstanbulOptions extends BaseCoverageOptions {}
280291

281292
export interface CoverageV8Options extends BaseCoverageOptions {}
282293

283294
export interface CustomProviderOptions
284-
extends Pick<BaseCoverageOptions, FieldsWithDefaultValues> {
295+
extends Pick<BaseCoverageOptions, FieldsWithDefaultValues | 'changed'> {
285296
/** Name of the module or path to a file to load the custom provider from */
286297
customProviderModule: string
287298
}

test/config/test/public.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,23 @@ test.for([
117117
expected && resolve(vitestConfig.root, expected),
118118
)
119119
})
120+
121+
test('coverage.changed inherits from test.changed but can be overridden', async () => {
122+
const { vitestConfig: inherited } = await resolveConfig({
123+
changed: 'HEAD',
124+
coverage: {
125+
reporter: 'json',
126+
},
127+
})
128+
129+
expect(inherited.coverage.changed).toBe('HEAD')
130+
131+
const { vitestConfig: overridden } = await resolveConfig({
132+
changed: 'HEAD',
133+
coverage: {
134+
changed: false,
135+
},
136+
})
137+
138+
expect(overridden.coverage.changed).toBe(false)
139+
})

test/core/test/cli-test.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ test('nested coverage options have correct types', async () => {
6060
--coverage.thresholds.100 25
6161
6262
--coverage.provider v8
63+
--coverage.changed HEAD
6364
--coverage.reporter text
6465
--coverage.reportsDirectory .\\dist\\coverage
6566
--coverage.customProviderModule=./folder/coverage.js
@@ -81,6 +82,7 @@ test('nested coverage options have correct types', async () => {
8182
enabled: true,
8283
reporter: ['text'],
8384
provider: 'v8',
85+
changed: 'HEAD',
8486
clean: false,
8587
cleanOnRerun: true,
8688
reportsDirectory: 'dist/coverage',

test/coverage-test/test/changed.test.ts

100644100755
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,99 @@ test('{ changed: "HEAD" }', { skip: SKIP }, async () => {
6868
}
6969
`)
7070
})
71+
72+
test('{ coverage.changed: "HEAD" }', async () => {
73+
await runVitest({
74+
include: [
75+
'fixtures/test/file-to-change.test.ts',
76+
'fixtures/test/math.test.ts',
77+
],
78+
coverage: {
79+
include: [
80+
'fixtures/src/file-to-change.ts',
81+
'fixtures/src/new-uncovered-file.ts',
82+
83+
// Should not show up
84+
'fixtures/src/untested-file.ts',
85+
'fixtures/src/math.ts',
86+
],
87+
reporter: 'json',
88+
changed: 'HEAD',
89+
},
90+
})
91+
92+
const coverageMap = await readCoverageMap()
93+
94+
expect(coverageMap.files()).toMatchInlineSnapshot(`
95+
[
96+
"<process-cwd>/fixtures/src/file-to-change.ts",
97+
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
98+
]
99+
`)
100+
})
101+
102+
test('{ coverage.changed: "HEAD", excludeAfterRemap: true }', async () => {
103+
await runVitest({
104+
include: [
105+
'fixtures/test/file-to-change.test.ts',
106+
'fixtures/test/math.test.ts',
107+
],
108+
coverage: {
109+
include: [
110+
'fixtures/src/file-to-change.ts',
111+
'fixtures/src/new-uncovered-file.ts',
112+
113+
// Should not show up
114+
'fixtures/src/untested-file.ts',
115+
'fixtures/src/math.ts',
116+
],
117+
reporter: 'json',
118+
changed: 'HEAD',
119+
excludeAfterRemap: true,
120+
},
121+
})
122+
123+
const coverageMap = await readCoverageMap()
124+
125+
expect(coverageMap.files()).toMatchInlineSnapshot(`
126+
[
127+
"<process-cwd>/fixtures/src/file-to-change.ts",
128+
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
129+
]
130+
`)
131+
})
132+
133+
test('{ changed: "v0.0.1", coverage.changed: "HEAD" }', async () => {
134+
await runVitest({
135+
include: [
136+
'fixtures/test/file-to-change.test.ts',
137+
'fixtures/test/math.test.ts',
138+
],
139+
140+
// v0.0.1 is an actual git tag in Vitest repository
141+
changed: 'v0.0.1',
142+
143+
coverage: {
144+
include: [
145+
'fixtures/src/file-to-change.ts',
146+
'fixtures/src/new-uncovered-file.ts',
147+
148+
// Should not show up
149+
'fixtures/src/untested-file.ts',
150+
'fixtures/src/math.ts',
151+
],
152+
reporter: 'json',
153+
changed: 'HEAD',
154+
},
155+
})
156+
157+
const coverageMap = await readCoverageMap()
158+
159+
// Should show changes since HEAD, not v0.0.1
160+
expect(coverageMap.files()).toMatchInlineSnapshot(`
161+
[
162+
"<process-cwd>/fixtures/src/file-to-change.ts",
163+
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
164+
]
165+
`)
166+
})

0 commit comments

Comments
 (0)