Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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=<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
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
3 changes: 3 additions & 0 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -880,6 +882,7 @@ export class Vitest {
private async runFiles(specs: TestSpecification[], allTestsRun: boolean): Promise<TestRunResult> {
return this._traces.$('vitest.test_run', async () => {
await this._testRun.start(specs)
await this.coverageProvider?.onTestRunStart?.()

// previous run
await this.cancelPromise
Expand Down
32 changes: 27 additions & 5 deletions packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -86,6 +86,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
pendingPromises: Promise<void>[] = []
coverageFilesDirectory!: string
roots: string[] = []
changedFiles?: string[]

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

const filename = slash(_filename)
const filename = slash(cleanUrl(_filename))
const cacheHit = this.globCache.get(filename)

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

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

if (included && this.changedFiles) {
included = this.changedFiles.includes(filename)
}

this.globCache.set(filename, included)

return included
Expand All @@ -192,8 +197,8 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
// 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))
if (this.changedFiles) {
includedFiles = this.changedFiles.filter(file => includedFiles.includes(file))
}

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

async onTestRunStart(): Promise<void> {
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<void> {
if (!this.options.reportOnFailure) {
await this.cleanAfterRun()
Expand Down
15 changes: 13 additions & 2 deletions packages/vitest/src/node/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export interface CoverageProvider {
/** Called with coverage results after a single test file has been run */
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void | Promise<void>

/** Callback called when test run fails */
/** Callback called when test run starts */
onTestRunStart?: () => void | Promise<void>

/** Callback called when test run fails due to test failures */
onTestFailure?: () => void | Promise<void>

/** Callback to generate final coverage results */
Expand Down Expand Up @@ -274,14 +277,22 @@ 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 {}

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 @@ -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)
})
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
96 changes: 96 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,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(`
[
"<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',

// 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(`
[
"<process-cwd>/fixtures/src/file-to-change.ts",
"<process-cwd>/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(`
[
"<process-cwd>/fixtures/src/file-to-change.ts",
"<process-cwd>/fixtures/src/new-uncovered-file.ts",
]
`)
})
54 changes: 53 additions & 1 deletion test/coverage-test/test/query-param-transforms.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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(`
[
"<process-cwd>/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",
},
]
`)
})
Loading