diff --git a/docs/advanced/api/vitest.md b/docs/advanced/api/vitest.md
index 72d11d50d64d..cb625e6143c1 100644
--- a/docs/advanced/api/vitest.md
+++ b/docs/advanced/api/vitest.md
@@ -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 4.0.0 {#enablecoverage}
+
+```ts
+function enableCoverage(): Promise
+```
+
+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 4.0.0 {#disablecoverage}
+
+```ts
+function disableCoverage(): void
+```
+
+This method disables coverage collection for tests that run afterwards.
+
## experimental_parseSpecification 4.0.0 experimental {#parsespecification}
```ts
diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts
index 7ab05bda22f9..e20424a03129 100644
--- a/packages/browser/src/node/plugin.ts
+++ b/packages/browser/src/node/plugin.ts
@@ -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])
@@ -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'
@@ -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
diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts
index ea7c468c76d3..dc731d784cad 100644
--- a/packages/coverage-istanbul/src/provider.ts
+++ b/packages/coverage-istanbul/src/provider.ts
@@ -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'
@@ -26,6 +26,8 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider()
+
initialize(ctx: Vitest): void {
this._initialize(ctx)
@@ -77,6 +79,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider [
+ ...Object.values(project.vite.environments),
+ ...Object.values(project.browser?.vite.environments || {}),
+ ])
+
+ const seen = new Set()
+ environments.forEach((environment) => {
+ environment.moduleGraph.idToModuleMap.forEach((node) => {
+ this.invalidateTree(node, environment.moduleGraph, seen)
+ })
+ })
+ }
+
+ private invalidateTree(node: Vite.EnvironmentModuleNode, moduleGraph: Vite.EnvironmentModuleGraph, seen: Set) {
+ 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) {
diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts
index e0c70659566c..56170f0b8abd 100644
--- a/packages/vitest/src/node/cli/cli-api.ts
+++ b/packages/vitest/src/node/cli/cli-api.ts
@@ -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) {
diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts
index d2a023d8dce3..4af942ee1d83 100644
--- a/packages/vitest/src/node/core.ts
+++ b/packages/vitest/src/node/core.ts
@@ -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'
@@ -94,7 +94,6 @@ export class Vitest {
public readonly watcher: VitestWatcher
/** @internal */ configOverride: Partial = {}
- /** @internal */ coverageProvider: CoverageProvider | null | undefined
/** @internal */ filenamePattern?: string[]
/** @internal */ runningPromise?: Promise
/** @internal */ closingPromise?: Promise
@@ -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,
@@ -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
@@ -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 {
+ 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()
+
+ /** @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.
@@ -399,12 +437,12 @@ export class Vitest {
* Creates a coverage provider if `coverage` is enabled in the config.
*/
public async createCoverageProvider(): Promise {
- 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
}
@@ -444,18 +482,21 @@ export class Vitest {
}
private async initCoverageProvider(): Promise {
- 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
}
/**
@@ -553,7 +594,7 @@ export class Vitest {
async start(filters?: string[]): Promise {
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)
@@ -602,7 +643,7 @@ export class Vitest {
async init(): Promise {
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)
@@ -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 {
- this.configOverride.testNamePattern = undefined
const files = specifications.map(spec => spec.moduleId)
await Promise.all([
this.report('onWatcherRerun', files, 'rerun test'),
@@ -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()
}
@@ -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
}
}
diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts
index e6de85171937..78c117d19482 100644
--- a/packages/vitest/src/node/coverage.ts
+++ b/packages/vitest/src/node/coverage.ts
@@ -96,7 +96,7 @@ export class BaseCoverageProvider TransformResult | Promise
+
+ /** Callback that's called when the coverage is enabled via a programmatic `enableCoverage` API. */
+ onEnabled?: () => void | Promise
}
export interface ReportContext {
diff --git a/test/coverage-test/test/mixed-versions-warning.unit.test.ts b/test/coverage-test/test/mixed-versions-warning.unit.test.ts
index 8161a1be4760..fde3f3a91b4c 100644
--- a/test/coverage-test/test/mixed-versions-warning.unit.test.ts
+++ b/test/coverage-test/test/mixed-versions-warning.unit.test.ts
@@ -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()
@@ -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()
diff --git a/test/coverage-test/test/run-dynamic-coverage.test.ts b/test/coverage-test/test/run-dynamic-coverage.test.ts
new file mode 100644
index 000000000000..aa30366f1ef6
--- /dev/null
+++ b/test/coverage-test/test/run-dynamic-coverage.test.ts
@@ -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('/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('/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()
+})
diff --git a/test/coverage-test/test/threshold-auto-update.unit.test.ts b/test/coverage-test/test/threshold-auto-update.unit.test.ts
index fa6c02de63bd..7b92fe6c1dec 100644
--- a/test/coverage-test/test/threshold-auto-update.unit.test.ts
+++ b/test/coverage-test/test/threshold-auto-update.unit.test.ts
@@ -151,6 +151,7 @@ async function updateThresholds(configurationFile: ReturnType {} },
+ _coverageOptions: {},
} as any)
provider.updateThresholds({
diff --git a/test/coverage-test/utils.ts b/test/coverage-test/utils.ts
index a957ddc11b86..78cae2fffe74 100644
--- a/test/coverage-test/utils.ts
+++ b/test/coverage-test/utils.ts
@@ -1,7 +1,8 @@
import type { CoverageSummary, FileCoverageData } from 'istanbul-lib-coverage'
import type { TestFunction } from 'vitest'
import type { TestUserConfig } from 'vitest/node'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
+import { unlink } from 'node:fs/promises'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { stripVTControlCharacters } from 'node:util'
@@ -67,6 +68,12 @@ export async function runVitest(config: TestUserConfig, options = { throwOnError
return result
}
+export async function cleanupCoverageJson(name = './coverage/coverage-final.json') {
+ if (existsSync(name)) {
+ await unlink(name)
+ }
+}
+
/**
* Read JSON coverage report from file system.
* Normalizes paths to keep contents consistent between OS's