diff --git a/docs/config/coverage.md b/docs/config/coverage.md index 6406a0496f46..097a45369843 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -405,3 +405,12 @@ Directory of HTML coverage output to be served in [Vitest UI](/guide/ui) and [HT 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. Note that setting this option does not change where coverage HTML report is generated. Configure the `coverage.reporter` option to change the directory instead. + +## 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 118f687a756e..289348a98aac 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -278,6 +278,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/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 4e8da7dc8723..8ead70196400 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -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: '', + 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 3aa64fc399c3..eb886f865d8e 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -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( diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 100a6d1aee79..25d54c31016f 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -607,6 +607,7 @@ export class Vitest { } await this._testRun.start(specifications).catch(noop) + await this.coverageProvider?.onTestRunStart?.() for (const file of files) { await this._reportFileTask(file) @@ -749,6 +750,7 @@ export class Vitest { if (!specifications.length) { await this._traces.$('vitest.test_run', async () => { await this._testRun.start([]) + await this.coverageProvider?.onTestRunStart?.() const coverage = await this.coverageProvider?.generateCoverage?.({ allTestsRun: true }) await this._testRun.end([], [], coverage) @@ -880,6 +882,7 @@ export class Vitest { private async runFiles(specs: TestSpecification[], allTestsRun: boolean): Promise { return this._traces.$('vitest.test_run', async () => { await this._testRun.start(specs) + await this.coverageProvider?.onTestRunStart?.() // previous run await this.cancelPromise diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index 2fc6e49f969b..8a6cc1d79f73 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -9,7 +9,7 @@ import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs' import module from 'node:module' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { slash } from '@vitest/utils/helpers' +import { cleanUrl, slash } from '@vitest/utils/helpers' import { relative, resolve } from 'pathe' import pm from 'picomatch' import { glob } from 'tinyglobby' @@ -86,6 +86,7 @@ export class BaseCoverageProvider[] = [] coverageFilesDirectory!: string roots: string[] = [] + changedFiles?: string[] _initialize(ctx: Vitest): void { this.ctx = ctx @@ -148,7 +149,7 @@ export class BaseCoverageProvider this.isIncluded(file, root)) - if (this.ctx.config.changed) { - includedFiles = (this.ctx.config.related || []).filter(file => includedFiles.includes(file)) + if (this.changedFiles) { + includedFiles = this.changedFiles.filter(file => includedFiles.includes(file)) } return includedFiles.map(file => slash(path.resolve(root, file))) @@ -324,6 +329,23 @@ export class BaseCoverageProvider { + if (this.options.changed) { + const { VitestGit } = await import('./git') + const vitestGit = new VitestGit(this.ctx.config.root) + const changedFiles = await vitestGit.findChangedFiles({ changedSince: this.options.changed }) + + this.changedFiles = changedFiles ?? undefined + } + else if (this.ctx.config.changed) { + this.changedFiles = this.ctx.config.related + } + + if (this.changedFiles) { + this.globCache.clear() + } + } + async onTestFailure(): Promise { if (!this.options.reportOnFailure) { await this.cleanAfterRun() diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 13fe8a661c26..c6f249b718b6 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -28,7 +28,10 @@ export interface CoverageProvider { /** Called with coverage results after a single test file has been run */ onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void | Promise - /** Callback called when test run fails */ + /** Callback called when test run starts */ + onTestRunStart?: () => void | Promise + + /** Callback called when test run fails due to test failures */ onTestFailure?: () => void | Promise /** Callback to generate final coverage results */ @@ -274,6 +277,14 @@ export interface BaseCoverageOptions { * Use this option to override with custom coverage reporting location. */ htmlDir?: 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 {} @@ -281,7 +292,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 56ac3b8a1c7b..f0d18b00d125 100644 --- a/test/config/test/public.test.ts +++ b/test/config/test/public.test.ts @@ -117,3 +117,23 @@ test.for([ expected && resolve(vitestConfig.root, expected), ) }) + +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 efed18d12cde..1a7f1db23d31 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..8e9313257d40 --- a/test/coverage-test/test/changed.test.ts +++ b/test/coverage-test/test/changed.test.ts @@ -68,3 +68,99 @@ 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', + + // Should not show up + 'fixtures/src/untested-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', + + // Should not show up + 'fixtures/src/untested-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", + ] + `) +}) + +test('{ changed: "v0.0.1", coverage.changed: "HEAD" }', async () => { + await runVitest({ + include: [ + 'fixtures/test/file-to-change.test.ts', + 'fixtures/test/math.test.ts', + ], + + // v0.0.1 is an actual git tag in Vitest repository + changed: 'v0.0.1', + + coverage: { + include: [ + 'fixtures/src/file-to-change.ts', + 'fixtures/src/new-uncovered-file.ts', + + // Should not show up + 'fixtures/src/untested-file.ts', + 'fixtures/src/math.ts', + ], + reporter: 'json', + changed: 'HEAD', + }, + }) + + const coverageMap = await readCoverageMap() + + // Should show changes since HEAD, not v0.0.1 + 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..7d3fb3c8fbf8 100644 --- a/test/coverage-test/test/query-param-transforms.test.ts +++ b/test/coverage-test/test/query-param-transforms.test.ts @@ -1,4 +1,6 @@ -import { expect } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { expect, onTestFinished } from 'vitest' import { readCoverageMap, runVitest, test } from '../utils' test('query param based transforms are resolved properly', async () => { @@ -46,3 +48,53 @@ test('query param based transforms are resolved properly', async () => { ] `) }) + +test.each([{ changed: 'HEAD' }, { coverage: { changed: 'HEAD' } }])('query param transforms respect %s', async (options) => { + const filePath = resolve('./fixtures/src/query-param-transformed.ts') + const original = readFileSync(filePath, 'utf8') + + onTestFinished(() => writeFileSync(filePath, original, 'utf8')) + writeFileSync(filePath, `${original}\nexport const changedMarker = true\n`, 'utf8') + + await runVitest({ + config: 'fixtures/configs/vitest.config.query-param-transform.ts', + include: ['fixtures/test/query-param.test.ts'], + ...options, + coverage: { reporter: 'json', ...options.coverage }, + }) + + 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", + }, + ] + `) +})