Skip to content
9 changes: 9 additions & 0 deletions docs/config/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,12 @@ Concurrency limit used when processing the coverage results.
- **CLI:** `--coverage.customProviderModule=<path or module name>`

Specifies the module name or path for the custom coverage provider module. See [Guide - Custom Coverage Provider](/guide/coverage#custom-coverage-provider) for more information.

## coverage.changed

- **Type:** `boolean | string`
- **Default:** `false` (inherits from `test.changed`)
- **Available for providers:** `'v8' | 'istanbul'`
- **CLI:** `--coverage.changed`, `--coverage.changed=<commit/branch>`

Collect coverage only for files changed since a specified commit or branch. When set to `true`, it uses staged and unstaged changes.
7 changes: 7 additions & 0 deletions docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ High and low watermarks for branches in the format of `<high>,<low>`

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

### coverage.changed

- **CLI:** `--coverage.changed <commit/branch>`
- **Config:** [coverage.changed](/config/coverage#coverage-changed)

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.

### mode

- **CLI:** `--mode <name>`
Expand Down
5 changes: 5 additions & 0 deletions packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
}

async generateCoverage({ allTestsRun }: ReportContext): Promise<CoverageMap> {
await this.updateChangedFiles()
const start = debug.enabled ? performance.now() : 0

const coverageMap = this.createCoverageMap()
Expand All @@ -136,6 +137,10 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
coverageMapByEnvironment.merge(coverage)
},
onFinished: async () => {
if (!this.options.excludeAfterRemap) {
this.filterChangedFiles(coverageMapByEnvironment)
}

// Source maps can change based on projectName and transform mode.
// Coverage transform re-uses source maps so we need to separate transforms from each other.
const transformedCoverage = await transformCoverage(coverageMapByEnvironment)
Expand Down
1 change: 1 addition & 0 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
}

async generateCoverage({ allTestsRun }: ReportContext): Promise<CoverageMap> {
await this.updateChangedFiles()
const start = debug.enabled ? performance.now() : 0

const coverageMap = this.createCoverageMap()
Expand Down
14 changes: 14 additions & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,20 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
},
},
changed: {
description:
'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.',
argument: '<commit/branch>',
transform(value) {
if (value === 'true' || value === 'yes' || value === true) {
return true
}
if (value === 'false' || value === 'no' || value === false) {
return false
}
return value
},
},
},
},
mode: {
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/node/config/resolveConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,9 @@ export function resolveConfig(
}

resolved.coverage.reporter = resolveCoverageReporters(resolved.coverage.reporter)
if (resolved.coverage.changed === undefined && resolved.changed !== undefined) {
resolved.coverage.changed = resolved.changed
}

if (resolved.coverage.enabled && resolved.coverage.reportsDirectory) {
const reportsDirectory = resolve(
Expand Down
96 changes: 76 additions & 20 deletions packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import c from 'tinyrainbow'
import { coverageConfigDefaults } from '../defaults'
import { resolveCoverageReporters } from '../node/config/resolveConfig'
import { resolveCoverageProviderModule } from '../utils/coverage'
import { GitNotFoundError } from './errors'

type Threshold = 'lines' | 'functions' | 'statements' | 'branches'

Expand Down Expand Up @@ -86,6 +87,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
pendingPromises: Promise<void>[] = []
coverageFilesDirectory!: string
roots: string[] = []
private changedFiles?: Set<string>

_initialize(ctx: Vitest): void {
this.ctx = ctx
Expand Down Expand Up @@ -142,6 +144,33 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
: [ctx.config.root]
}

protected async updateChangedFiles(): Promise<void> {
const coverageChanged = this.options.changed
if (!coverageChanged) {
this.changedFiles = undefined
return
}
const { VitestGit } = await import('./git')
const vitestGit = new VitestGit(this.ctx.config.root)
const changedFiles = await vitestGit.findChangedFiles({
changedSince: coverageChanged,
})
if (!changedFiles) {
process.exitCode = 1
throw new GitNotFoundError()
}
this.changedFiles = new Set(changedFiles.map(file => slash(file)))
}

protected filterChangedFiles(coverageMap: CoverageMap): void {
if (!this.changedFiles) {
return
}
coverageMap.filter((filename) => {
return this.changedFiles!.has(this.normalizeChangedFilename(filename))
})
}

/**
* Check if file matches `coverage.include` but not `coverage.exclude`
*/
Expand All @@ -150,32 +179,51 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan

const filename = slash(_filename)
const cacheHit = this.globCache.get(filename)
let included = cacheHit
if (included === undefined) {
// File outside project root with default allowExternal
if (this.options.allowExternal === false && roots.every(root => !filename.startsWith(root))) {
this.globCache.set(filename, false)
return false
}

if (cacheHit !== undefined) {
return cacheHit
}
// By default `coverage.include` matches all files, except "coverage.exclude"
const glob = this.options.include || '**'

// File outside project root with default allowExternal
if (this.options.allowExternal === false && roots.every(root => !filename.startsWith(root))) {
this.globCache.set(filename, false)
included = pm.isMatch(filename, glob, {
contains: true,
dot: true,
ignore: this.options.exclude,
})

return false
this.globCache.set(filename, included)
}

// By default `coverage.include` matches all files, except "coverage.exclude"
const glob = this.options.include || '**'

const included = pm.isMatch(filename, glob, {
contains: true,
dot: true,
ignore: this.options.exclude,
})
if (!included) {
return false
}

this.globCache.set(filename, included)
if (this.changedFiles && !this.changedFiles.has(this.normalizeChangedFilename(filename))) {
return false
}

return included
}

private normalizeChangedFilename(filename: string): string {
let normalized = filename.split('?')[0]
if (normalized.startsWith('file://')) {
normalized = fileURLToPath(normalized)
}
if (normalized.startsWith('/@fs/')) {
normalized = normalized.slice(4)
}
if (normalized.startsWith('/') && /^[a-z]:/i.test(normalized.slice(1))) {
normalized = normalized.slice(1)
}
return slash(normalized)
}

private async getUntestedFilesByRoot(
testedFiles: string[],
include: string[],
Expand All @@ -189,13 +237,21 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
onlyFiles: true,
})

if (this.changedFiles) {
includedFiles = includedFiles.filter(file => this.changedFiles!.has(slash(file)))
}
else if (this.ctx.config.changed) {
const related = this.ctx.config.related || []
if (!related.length) {
return []
}
const relatedSet = new Set(related.map(file => slash(file)))
includedFiles = includedFiles.filter(file => relatedSet.has(slash(file)))
}

// Run again through picomatch as tinyglobby's exclude pattern is different ({ "exclude": ["math"] } should ignore "src/math.ts")
includedFiles = includedFiles.filter(file => this.isIncluded(file, root))

if (this.ctx.config.changed) {
includedFiles = (this.ctx.config.related || []).filter(file => includedFiles.includes(file))
}

return includedFiles.map(file => slash(path.resolve(root, file)))
}

Expand Down
12 changes: 11 additions & 1 deletion packages/vitest/src/node/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface CoverageProvider {
export interface ReportContext {
/** Indicates whether all tests were run. False when only specific tests were run. */
allTestsRun?: boolean
/** Absolute paths for files changed since a given commit/branch. */
changedFiles?: string[]
}

export interface CoverageModuleLoader extends RuntimeCoverageModuleLoader {
Expand Down Expand Up @@ -266,14 +268,22 @@ export interface BaseCoverageOptions {
* @default []
*/
ignoreClassMethods?: string[]

/**
* Collect coverage only for files changed since a specified commit or branch.
* Inherits the default value from `test.changed`.
*
* @default false
*/
changed?: boolean | string
}

export interface CoverageIstanbulOptions extends BaseCoverageOptions {}

export interface CoverageV8Options extends BaseCoverageOptions {}

export interface CustomProviderOptions
extends Pick<BaseCoverageOptions, FieldsWithDefaultValues> {
extends Pick<BaseCoverageOptions, FieldsWithDefaultValues | 'changed'> {
/** Name of the module or path to a file to load the custom provider from */
customProviderModule: string
}
Expand Down
20 changes: 20 additions & 0 deletions test/config/test/public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,23 @@ test('default value changes of coverage.exclude do not reflect to test.exclude',
expect(vitestConfig.coverage.exclude).toContain('**/custom-exclude/**')
expect(vitestConfig.coverage.exclude).toContain('**/example.test.ts')
})

test('coverage.changed inherits from test.changed but can be overridden', async () => {
const { vitestConfig: inherited } = await resolveConfig({
changed: 'HEAD',
coverage: {
reporter: 'json',
},
})

expect(inherited.coverage.changed).toBe('HEAD')

const { vitestConfig: overridden } = await resolveConfig({
changed: 'HEAD',
coverage: {
changed: false,
},
})

expect(overridden.coverage.changed).toBe(false)
})
2 changes: 2 additions & 0 deletions test/core/test/cli-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ test('nested coverage options have correct types', async () => {
--coverage.thresholds.100 25

--coverage.provider v8
--coverage.changed HEAD
--coverage.reporter text
--coverage.reportsDirectory .\\dist\\coverage
--coverage.customProviderModule=./folder/coverage.js
Expand All @@ -81,6 +82,7 @@ test('nested coverage options have correct types', async () => {
enabled: true,
reporter: ['text'],
provider: 'v8',
changed: 'HEAD',
clean: false,
cleanOnRerun: true,
reportsDirectory: 'dist/coverage',
Expand Down
55 changes: 55 additions & 0 deletions test/coverage-test/test/changed.test.ts
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,58 @@ test('{ changed: "HEAD" }', { skip: SKIP }, async () => {
}
`)
})

test('{ coverage.changed: "HEAD" }', async () => {
await runVitest({
include: [
'fixtures/test/file-to-change.test.ts',
'fixtures/test/math.test.ts',
],
coverage: {
include: [
'fixtures/src/file-to-change.ts',
'fixtures/src/new-uncovered-file.ts',
'fixtures/src/math.ts',
],
reporter: 'json',
changed: 'HEAD',
},
})

const coverageMap = await readCoverageMap()

expect(coverageMap.files()).toMatchInlineSnapshot(`
[
"<process-cwd>/fixtures/src/file-to-change.ts",
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
]
`)
})

test('{ coverage.changed: "HEAD", excludeAfterRemap: true }', async () => {
await runVitest({
include: [
'fixtures/test/file-to-change.test.ts',
'fixtures/test/math.test.ts',
],
coverage: {
include: [
'fixtures/src/file-to-change.ts',
'fixtures/src/new-uncovered-file.ts',
'fixtures/src/math.ts',
],
reporter: 'json',
changed: 'HEAD',
excludeAfterRemap: true,
},
})

const coverageMap = await readCoverageMap()

expect(coverageMap.files()).toMatchInlineSnapshot(`
[
"<process-cwd>/fixtures/src/file-to-change.ts",
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
]
`)
})
Loading
Loading