diff --git a/docs/config/coverage.md b/docs/config/coverage.md index d760620c714e..a9c9cd1eabf7 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -395,3 +395,12 @@ Concurrency limit used when processing the coverage results. - **CLI:** `--coverage.customProviderModule=` 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=` + +Collect coverage only for files changed since a specified commit or branch. When set to `true`, it uses staged and unstaged changes. diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index b1a61ddf1b5d..1cdeec19b044 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -264,6 +264,13 @@ High and low watermarks for branches in the format of `,` High and low watermarks for functions in the format of `,` +### coverage.changed + +- **CLI:** `--coverage.changed ` +- **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 ` diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 6dddd56684f6..e3be70adce06 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -126,6 +126,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider { + await this.updateChangedFiles() const start = debug.enabled ? performance.now() : 0 const coverageMap = this.createCoverageMap() @@ -136,6 +137,10 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider { + 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) diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index 93fbb074ff43..e938d99bdb22 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -41,6 +41,7 @@ export class V8CoverageProvider extends BaseCoverageProvider { + await this.updateChangedFiles() const start = debug.enabled ? performance.now() : 0 const coverageMap = this.createCoverageMap() diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 753ea566f674..e13f3b173e23 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -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: '', + transform(value) { + if (value === 'true' || value === 'yes' || value === true) { + return true + } + if (value === 'false' || value === 'no' || value === false) { + return false + } + return value + }, + }, }, }, mode: { diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index cb98436cf551..da0ff45a706a 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -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( diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index 2fc6e49f969b..ad720e67b873 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -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' @@ -86,6 +87,7 @@ export class BaseCoverageProvider[] = [] coverageFilesDirectory!: string roots: string[] = [] + private changedFiles?: Set _initialize(ctx: Vitest): void { this.ctx = ctx @@ -142,6 +144,33 @@ export class BaseCoverageProvider { + 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` */ @@ -150,32 +179,51 @@ export class BaseCoverageProvider !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[], @@ -189,13 +237,21 @@ export class BaseCoverageProvider 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))) } diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index a148fc0901aa..b73e34089871 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -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 { @@ -266,6 +268,14 @@ 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 {} @@ -273,7 +283,7 @@ export interface CoverageIstanbulOptions extends BaseCoverageOptions {} export interface CoverageV8Options extends BaseCoverageOptions {} export interface CustomProviderOptions - extends Pick { + extends Pick { /** Name of the module or path to a file to load the custom provider from */ customProviderModule: string } diff --git a/test/config/test/public.test.ts b/test/config/test/public.test.ts index 59c972f283c3..b40f3ffaf4a2 100644 --- a/test/config/test/public.test.ts +++ b/test/config/test/public.test.ts @@ -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) +}) diff --git a/test/core/test/cli-test.test.ts b/test/core/test/cli-test.test.ts index 0943d50149b0..ffa7d8f1a76d 100644 --- a/test/core/test/cli-test.test.ts +++ b/test/core/test/cli-test.test.ts @@ -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 @@ -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', diff --git a/test/coverage-test/test/changed.test.ts b/test/coverage-test/test/changed.test.ts old mode 100644 new mode 100755 index 975ede7dc5a6..0e0632846e9b --- a/test/coverage-test/test/changed.test.ts +++ b/test/coverage-test/test/changed.test.ts @@ -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(` + [ + "/fixtures/src/file-to-change.ts", + "/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(` + [ + "/fixtures/src/file-to-change.ts", + "/fixtures/src/new-uncovered-file.ts", + ] + `) +}) diff --git a/test/coverage-test/test/query-param-transforms.test.ts b/test/coverage-test/test/query-param-transforms.test.ts index 65b870d595b2..f7419f9424ad 100644 --- a/test/coverage-test/test/query-param-transforms.test.ts +++ b/test/coverage-test/test/query-param-transforms.test.ts @@ -1,3 +1,5 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' import { expect } from 'vitest' import { readCoverageMap, runVitest, test } from '../utils' @@ -46,3 +48,55 @@ test('query param based transforms are resolved properly', async () => { ] `) }) + +test('query param transforms respect coverage.changed', async () => { + const filePath = resolve('./fixtures/src/query-param-transformed.ts') + const original = readFileSync(filePath, 'utf8') + writeFileSync(filePath, `${original}\nexport const changedMarker = true\n`, 'utf8') + + try { + await runVitest({ + config: 'fixtures/configs/vitest.config.query-param-transform.ts', + include: ['fixtures/test/query-param.test.ts'], + coverage: { reporter: 'json', changed: 'HEAD' }, + }) + + const coverageMap = await readCoverageMap() + + expect(coverageMap.files()).toMatchInlineSnapshot(` + [ + "/fixtures/src/query-param-transformed.ts", + ] + `) + + const coverage = coverageMap.fileCoverageFor(coverageMap.files()[0]) + + const functionCoverage = Object.keys(coverage.fnMap) + .map(index => ({ name: coverage.fnMap[index].name, hits: coverage.f[index] })) + .sort((a, b) => a.name.localeCompare(b.name)) + + expect(functionCoverage).toMatchInlineSnapshot(` + [ + { + "hits": 1, + "name": "first", + }, + { + "hits": 3, + "name": "initial", + }, + { + "hits": 1, + "name": "second", + }, + { + "hits": 0, + "name": "uncovered", + }, + ] + `) + } + finally { + writeFileSync(filePath, original, 'utf8') + } +})