Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -278,6 +278,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 @@ -306,6 +306,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 @@ -419,6 +419,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
10 changes: 9 additions & 1 deletion packages/vitest/src/node/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,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