Skip to content
18 changes: 18 additions & 0 deletions docs/advanced/api/vitest.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,24 @@ Creates a coverage provider if `coverage` is enabled in the config. This is done
This method will also clean all previous reports if [`coverage.clean`](/config/#coverage-clean) is not set to `false`.
:::

## enableCoverage <Version>4.0.0</Version> {#enablecoverage}

```ts
function enableCoverage(): Promise<void>
```

This method enables coverage for tests that run after this call. `enableCoverage` doesn't run any tests; it only sets up Vitest to collect coverage.

It creates a new coverage provider if one doesn't already exist.

## disableCoverage <Version>4.0.0</Version> {#disablecoverage}

```ts
function disableCoverage(): void
```

This method disables coverage collection for tests that run afterwards.

## experimental_parseSpecification <Version>4.0.0</Version> <Badge type="warning">experimental</Badge> {#parsespecification}

```ts
Expand Down
7 changes: 4 additions & 3 deletions packages/browser/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
}

if (parentServer.vitest.coverageProvider) {
const coverage = parentServer.vitest.config.coverage
const coverage = parentServer.vitest._coverageOptions
const provider = coverage.provider
if (provider === 'v8') {
const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root])
Expand Down Expand Up @@ -630,7 +630,8 @@ function getRequire() {

function resolveCoverageFolder(vitest: Vitest) {
const options = vitest.config
const htmlReporter = options.coverage?.enabled
const coverageOptions = vitest._coverageOptions
const htmlReporter = coverageOptions?.enabled
? toArray(options.coverage.reporter).find((reporter) => {
if (typeof reporter === 'string') {
return reporter === 'html'
Expand All @@ -647,7 +648,7 @@ function resolveCoverageFolder(vitest: Vitest) {
// reportsDirectory not resolved yet
const root = resolve(
options.root || process.cwd(),
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
coverageOptions.reportsDirectory || coverageConfigDefaults.reportsDirectory,
)

const subdir
Expand Down
34 changes: 33 additions & 1 deletion packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { CoverageMap } from 'istanbul-lib-coverage'
import type { Instrumenter } from 'istanbul-lib-instrument'
import type { ProxifiedModule } from 'magicast'
import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest/node'
import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, Vite, Vitest } from 'vitest/node'
import { promises as fs } from 'node:fs'
// @ts-expect-error missing types
import { defaults as istanbulDefaults } from '@istanbuljs/schema'
Expand All @@ -26,6 +26,8 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
version: string = version
instrumenter!: Instrumenter

private transformedModuleIds = new Set<string>()

initialize(ctx: Vitest): void {
this._initialize(ctx)

Expand Down Expand Up @@ -77,6 +79,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
sourceMap as any,
)
const map = this.instrumenter.lastSourceMap() as any
this.transformedModuleIds.add(id)

return { code, map }
}
Expand Down Expand Up @@ -198,6 +201,35 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover

return coverageMap
}

// the coverage can be enabled after the tests are run
// this means the coverage will not be injected because the modules are cached,
// so we are invalidating all modules that don't have the istanbul coverage injected
onEnabled(): void {
const environments = this.ctx.projects.flatMap(project => [
...Object.values(project.vite.environments),
...Object.values(project.browser?.vite.environments || {}),
])

const seen = new Set<Vite.EnvironmentModuleNode>()
environments.forEach((environment) => {
environment.moduleGraph.idToModuleMap.forEach((node) => {
this.invalidateTree(node, environment.moduleGraph, seen)
})
})
}

private invalidateTree(node: Vite.EnvironmentModuleNode, moduleGraph: Vite.EnvironmentModuleGraph, seen = new Set<Vite.EnvironmentModuleNode>()) {
if (seen.has(node)) {
return
}
if (node.id && !this.transformedModuleIds.has(node.id)) {
moduleGraph.invalidateModule(node, seen)
}
node.importedModules.forEach((mod) => {
this.invalidateTree(mod, moduleGraph, seen)
})
}
}

async function transformCoverage(coverageMap: CoverageMap) {
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/node/cli/cli-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ export async function startVitest(
cliFilters,
)

if (mode === 'test' && ctx.config.coverage.enabled) {
const provider = ctx.config.coverage.provider || 'v8'
if (mode === 'test' && ctx._coverageOptions.enabled) {
const provider = ctx._coverageOptions.provider || 'v8'
const requiredPackages = CoverageProviderMap[provider]

if (requiredPackages) {
Expand Down
78 changes: 59 additions & 19 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import type { ProcessPool, WorkspaceSpec } from './pool'
import type { TestModule } from './reporters/reported-tasks'
import type { TestSpecification } from './spec'
import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMode } from './types/config'
import type { CoverageProvider } from './types/coverage'
import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage'
import type { Reporter } from './types/reporter'
import type { TestRunResult } from './types/tests'
import os from 'node:os'
import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils'
import { SnapshotManager } from '@vitest/snapshot/manager'
import { noop, toArray } from '@vitest/utils'
import { deepClone, deepMerge, noop, toArray } from '@vitest/utils'
import { normalize, relative } from 'pathe'
import { version } from '../../package.json' with { type: 'json' }
import { WebSocketReporter } from '../api/setup'
Expand Down Expand Up @@ -94,7 +94,6 @@ export class Vitest {
public readonly watcher: VitestWatcher

/** @internal */ configOverride: Partial<ResolvedConfig> = {}
/** @internal */ coverageProvider: CoverageProvider | null | undefined
/** @internal */ filenamePattern?: string[]
/** @internal */ runningPromise?: Promise<TestRunResult>
/** @internal */ closingPromise?: Promise<void>
Expand All @@ -117,6 +116,7 @@ export class Vitest {
private _state?: StateManager
private _cache?: VitestCache
private _snapshot?: SnapshotManager
private _coverageProvider?: CoverageProvider | null | undefined

constructor(
public readonly mode: VitestRunMode,
Expand Down Expand Up @@ -209,10 +209,10 @@ export class Vitest {
this.pool = undefined
this.closingPromise = undefined
this.projects = []
this.coverageProvider = undefined
this.runningPromise = undefined
this.coreWorkspaceProject = undefined
this.specifications.clearCache()
this._coverageProvider = undefined
this._onUserTestsRerun = []

this._vite = server
Expand Down Expand Up @@ -312,6 +312,44 @@ export class Vitest {
await Promise.all(this._onSetServer.map(fn => fn()))
}

/** @internal */
get coverageProvider(): CoverageProvider | null | undefined {
if (this.configOverride.coverage?.enabled === false) {
return null
}
return this._coverageProvider
}

public async enableCoverage(): Promise<void> {
this.configOverride.coverage = {} as any
this.configOverride.coverage!.enabled = true
await this.createCoverageProvider()
await this.coverageProvider?.onEnabled?.()
}

public disableCoverage(): void {
this.configOverride.coverage ??= {} as any
this.configOverride.coverage!.enabled = false
}

private _coverageOverrideCache = new WeakMap<ResolvedCoverageOptions, ResolvedCoverageOptions>()

/** @internal */
get _coverageOptions(): ResolvedCoverageOptions {
if (!this.configOverride.coverage) {
return this.config.coverage
}
if (!this._coverageOverrideCache.has(this.configOverride.coverage)) {
const coverage = deepClone(this.config.coverage)
const options = deepMerge(coverage, this.configOverride.coverage)
this._coverageOverrideCache.set(
this.configOverride.coverage,
options,
)
}
return this._coverageOverrideCache.get(this.configOverride.coverage)!
}

/**
* Inject new test projects into the workspace.
* @param config Glob, config path or a custom config options.
Expand Down Expand Up @@ -399,12 +437,12 @@ export class Vitest {
* Creates a coverage provider if `coverage` is enabled in the config.
*/
public async createCoverageProvider(): Promise<CoverageProvider | null> {
if (this.coverageProvider) {
return this.coverageProvider
if (this._coverageProvider) {
return this._coverageProvider
}
const coverageProvider = await this.initCoverageProvider()
if (coverageProvider) {
await coverageProvider.clean(this.config.coverage.clean)
await coverageProvider.clean(this._coverageOptions.clean)
}
return coverageProvider || null
}
Expand Down Expand Up @@ -444,18 +482,21 @@ export class Vitest {
}

private async initCoverageProvider(): Promise<CoverageProvider | null | undefined> {
if (this.coverageProvider !== undefined) {
if (this._coverageProvider != null) {
return
}
this.coverageProvider = await getCoverageProvider(
this.config.coverage as unknown as SerializedCoverageConfig,
const coverageConfig = (this.configOverride.coverage
? this.getRootProject().serializedConfig.coverage
: this.config.coverage) as unknown as SerializedCoverageConfig
this._coverageProvider = await getCoverageProvider(
coverageConfig,
this.runner,
)
if (this.coverageProvider) {
await this.coverageProvider.initialize(this)
this.config.coverage = this.coverageProvider.resolveOptions()
if (this._coverageProvider) {
await this._coverageProvider.initialize(this)
this.config.coverage = this._coverageProvider.resolveOptions()
}
return this.coverageProvider
return this._coverageProvider
}

/**
Expand Down Expand Up @@ -553,7 +594,7 @@ export class Vitest {
async start(filters?: string[]): Promise<TestRunResult> {
try {
await this.initCoverageProvider()
await this.coverageProvider?.clean(this.config.coverage.clean)
await this.coverageProvider?.clean(this._coverageOptions.clean)
}
finally {
await this.report('onInit', this)
Expand Down Expand Up @@ -602,7 +643,7 @@ export class Vitest {
async init(): Promise<void> {
try {
await this.initCoverageProvider()
await this.coverageProvider?.clean(this.config.coverage.clean)
await this.coverageProvider?.clean(this._coverageOptions.clean)
}
finally {
await this.report('onInit', this)
Expand Down Expand Up @@ -677,7 +718,6 @@ export class Vitest {
* @param allTestsRun Indicates whether all tests were run. This only matters for coverage.
*/
public async rerunTestSpecifications(specifications: TestSpecification[], allTestsRun = false): Promise<TestRunResult> {
this.configOverride.testNamePattern = undefined
const files = specifications.map(spec => spec.moduleId)
await Promise.all([
this.report('onWatcherRerun', files, 'rerun test'),
Expand Down Expand Up @@ -709,7 +749,7 @@ export class Vitest {
this.snapshot.clear()
this.state.clearErrors()

if (!this.isFirstRun && this.config.coverage.cleanOnRerun) {
if (!this.isFirstRun && this._coverageOptions.cleanOnRerun) {
await this.coverageProvider?.clean()
}

Expand Down Expand Up @@ -1111,7 +1151,7 @@ export class Vitest {
if (this.state.getCountOfFailedTests() > 0) {
await this.coverageProvider?.onTestFailure?.()

if (!this.config.coverage.reportOnFailure) {
if (!this._coverageOptions.reportOnFailure) {
return
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
)
}

const config = ctx.config.coverage as Options
const config = ctx._coverageOptions as Options

this.options = {
...coverageConfigDefaults,
Expand Down
3 changes: 3 additions & 0 deletions packages/vitest/src/node/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export interface CoverageProvider {
// TODO: when upgrading vite, import Rollup from vite
pluginCtx: any
) => TransformResult | Promise<TransformResult>

/** Callback that's called when the coverage is enabled via a programmatic `enableCoverage` API. */
onEnabled?: () => void | Promise<void>
}

export interface ReportContext {
Expand Down
2 changes: 2 additions & 0 deletions test/coverage-test/test/mixed-versions-warning.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test('v8 provider logs warning if versions do not match', async () => {
version: '1.0.0',
logger: { warn },
config: configDefaults,
_coverageOptions: configDefaults.coverage,
} as any)

expect(warn).toHaveBeenCalled()
Expand All @@ -36,6 +37,7 @@ test('istanbul provider logs warning if versions do not match', async () => {
version: '1.0.0',
logger: { warn },
config: configDefaults,
_coverageOptions: configDefaults.coverage,
} as any)

expect(warn).toHaveBeenCalled()
Expand Down
52 changes: 52 additions & 0 deletions test/coverage-test/test/run-dynamic-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect } from 'vitest'
import { cleanupCoverageJson, readCoverageMap, runVitest, test } from '../utils'

test('enableCoverage() collects coverage after being called', async () => {
await cleanupCoverageJson()

// Run a minimal suite where coverage starts disabled, then enable it and rerun.
const { ctx } = await runVitest({
include: ['fixtures/test/math.test.ts'],
coverage: {
// start disabled and turn on dynamically
enabled: false,
reporter: 'json',
},
})

await expect(readCoverageMap(), 'coverage map should not be on the disk').rejects.toThrowError(/no such file/)

await ctx!.enableCoverage()
expect(ctx!.coverageProvider).toBeTruthy()

await ctx!.rerunFiles()

const coverageMap = await readCoverageMap()
expect(coverageMap.files()).toContain('<process-cwd>/fixtures/src/math.ts')
})

test('disableCoverage() stops collecting coverage going forward', async () => {
const { ctx } = await runVitest({
include: ['fixtures/test/math.test.ts'],
coverage: {
enabled: true,
reporter: 'json',
},
})

// Initial run collects coverage
const initialMap = await readCoverageMap()
expect(initialMap.files()).toContain('<process-cwd>/fixtures/src/math.ts')
expect(ctx!.coverageProvider).toBeTruthy()

// Disable coverage and rerun
ctx!.disableCoverage()
expect(ctx!.coverageProvider).toBeNull()

await cleanupCoverageJson()

await ctx!.rerunFiles()

await expect(readCoverageMap(), 'coverage map should not be on the disk').rejects.toThrowError(/no such file/)
expect(ctx!.coverageProvider).toBeNull()
})
1 change: 1 addition & 0 deletions test/coverage-test/test/threshold-auto-update.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ async function updateThresholds(configurationFile: ReturnType<typeof parseModule
provider._initialize({
config: { coverage: { } },
logger: { log: () => {} },
_coverageOptions: {},
} as any)

provider.updateThresholds({
Expand Down
Loading
Loading