Skip to content

Commit 61eb7dd

Browse files
feat(api): add enableCoverage and disableCoverage methods (#8412)
Co-authored-by: Ari Perkkiö <[email protected]>
1 parent c0b9ad9 commit 61eb7dd

File tree

11 files changed

+183
-27
lines changed

11 files changed

+183
-27
lines changed

docs/advanced/api/vitest.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,24 @@ Creates a coverage provider if `coverage` is enabled in the config. This is done
553553
This method will also clean all previous reports if [`coverage.clean`](/config/#coverage-clean) is not set to `false`.
554554
:::
555555

556+
## enableCoverage <Version>4.0.0</Version> {#enablecoverage}
557+
558+
```ts
559+
function enableCoverage(): Promise<void>
560+
```
561+
562+
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.
563+
564+
It creates a new coverage provider if one doesn't already exist.
565+
566+
## disableCoverage <Version>4.0.0</Version> {#disablecoverage}
567+
568+
```ts
569+
function disableCoverage(): void
570+
```
571+
572+
This method disables coverage collection for tests that run afterwards.
573+
556574
## experimental_parseSpecification <Version>4.0.0</Version> <Badge type="warning">experimental</Badge> {#parsespecification}
557575

558576
```ts

packages/browser/src/node/plugin.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export default (parentServer: ParentBrowserProject, base = '/'): Plugin[] => {
251251
}
252252

253253
if (parentServer.vitest.coverageProvider) {
254-
const coverage = parentServer.vitest.config.coverage
254+
const coverage = parentServer.vitest._coverageOptions
255255
const provider = coverage.provider
256256
if (provider === 'v8') {
257257
const path = tryResolve('@vitest/coverage-v8', [parentServer.config.root])
@@ -630,7 +630,8 @@ function getRequire() {
630630

631631
function resolveCoverageFolder(vitest: Vitest) {
632632
const options = vitest.config
633-
const htmlReporter = options.coverage?.enabled
633+
const coverageOptions = vitest._coverageOptions
634+
const htmlReporter = coverageOptions?.enabled
634635
? toArray(options.coverage.reporter).find((reporter) => {
635636
if (typeof reporter === 'string') {
636637
return reporter === 'html'
@@ -647,7 +648,7 @@ function resolveCoverageFolder(vitest: Vitest) {
647648
// reportsDirectory not resolved yet
648649
const root = resolve(
649650
options.root || process.cwd(),
650-
options.coverage.reportsDirectory || coverageConfigDefaults.reportsDirectory,
651+
coverageOptions.reportsDirectory || coverageConfigDefaults.reportsDirectory,
651652
)
652653

653654
const subdir

packages/coverage-istanbul/src/provider.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CoverageMap } from 'istanbul-lib-coverage'
22
import type { Instrumenter } from 'istanbul-lib-instrument'
33
import type { ProxifiedModule } from 'magicast'
4-
import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest/node'
4+
import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, Vite, Vitest } from 'vitest/node'
55
import { promises as fs } from 'node:fs'
66
// @ts-expect-error missing types
77
import { defaults as istanbulDefaults } from '@istanbuljs/schema'
@@ -26,6 +26,8 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
2626
version: string = version
2727
instrumenter!: Instrumenter
2828

29+
private transformedModuleIds = new Set<string>()
30+
2931
initialize(ctx: Vitest): void {
3032
this._initialize(ctx)
3133

@@ -77,6 +79,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
7779
sourceMap as any,
7880
)
7981
const map = this.instrumenter.lastSourceMap() as any
82+
this.transformedModuleIds.add(id)
8083

8184
return { code, map }
8285
}
@@ -198,6 +201,35 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCover
198201

199202
return coverageMap
200203
}
204+
205+
// the coverage can be enabled after the tests are run
206+
// this means the coverage will not be injected because the modules are cached,
207+
// so we are invalidating all modules that don't have the istanbul coverage injected
208+
onEnabled(): void {
209+
const environments = this.ctx.projects.flatMap(project => [
210+
...Object.values(project.vite.environments),
211+
...Object.values(project.browser?.vite.environments || {}),
212+
])
213+
214+
const seen = new Set<Vite.EnvironmentModuleNode>()
215+
environments.forEach((environment) => {
216+
environment.moduleGraph.idToModuleMap.forEach((node) => {
217+
this.invalidateTree(node, environment.moduleGraph, seen)
218+
})
219+
})
220+
}
221+
222+
private invalidateTree(node: Vite.EnvironmentModuleNode, moduleGraph: Vite.EnvironmentModuleGraph, seen: Set<Vite.EnvironmentModuleNode>) {
223+
if (seen.has(node)) {
224+
return
225+
}
226+
if (node.id && !this.transformedModuleIds.has(node.id)) {
227+
moduleGraph.invalidateModule(node, seen)
228+
}
229+
node.importedModules.forEach((mod) => {
230+
this.invalidateTree(mod, moduleGraph, seen)
231+
})
232+
}
201233
}
202234

203235
async function transformCoverage(coverageMap: CoverageMap) {

packages/vitest/src/node/cli/cli-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ export async function startVitest(
6060
cliFilters,
6161
)
6262

63-
if (mode === 'test' && ctx.config.coverage.enabled) {
64-
const provider = ctx.config.coverage.provider || 'v8'
63+
if (mode === 'test' && ctx._coverageOptions.enabled) {
64+
const provider = ctx._coverageOptions.provider || 'v8'
6565
const requiredPackages = CoverageProviderMap[provider]
6666

6767
if (requiredPackages) {

packages/vitest/src/node/core.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import type { ProcessPool, WorkspaceSpec } from './pool'
1010
import type { TestModule } from './reporters/reported-tasks'
1111
import type { TestSpecification } from './spec'
1212
import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMode } from './types/config'
13-
import type { CoverageProvider } from './types/coverage'
13+
import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage'
1414
import type { Reporter } from './types/reporter'
1515
import type { TestRunResult } from './types/tests'
1616
import os from 'node:os'
1717
import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils'
1818
import { SnapshotManager } from '@vitest/snapshot/manager'
19-
import { noop, toArray } from '@vitest/utils'
19+
import { deepClone, deepMerge, noop, toArray } from '@vitest/utils'
2020
import { normalize, relative } from 'pathe'
2121
import { version } from '../../package.json' with { type: 'json' }
2222
import { WebSocketReporter } from '../api/setup'
@@ -94,7 +94,6 @@ export class Vitest {
9494
public readonly watcher: VitestWatcher
9595

9696
/** @internal */ configOverride: Partial<ResolvedConfig> = {}
97-
/** @internal */ coverageProvider: CoverageProvider | null | undefined
9897
/** @internal */ filenamePattern?: string[]
9998
/** @internal */ runningPromise?: Promise<TestRunResult>
10099
/** @internal */ closingPromise?: Promise<void>
@@ -117,6 +116,7 @@ export class Vitest {
117116
private _state?: StateManager
118117
private _cache?: VitestCache
119118
private _snapshot?: SnapshotManager
119+
private _coverageProvider?: CoverageProvider | null | undefined
120120

121121
constructor(
122122
public readonly mode: VitestRunMode,
@@ -209,10 +209,10 @@ export class Vitest {
209209
this.pool = undefined
210210
this.closingPromise = undefined
211211
this.projects = []
212-
this.coverageProvider = undefined
213212
this.runningPromise = undefined
214213
this.coreWorkspaceProject = undefined
215214
this.specifications.clearCache()
215+
this._coverageProvider = undefined
216216
this._onUserTestsRerun = []
217217

218218
this._vite = server
@@ -312,6 +312,44 @@ export class Vitest {
312312
await Promise.all(this._onSetServer.map(fn => fn()))
313313
}
314314

315+
/** @internal */
316+
get coverageProvider(): CoverageProvider | null | undefined {
317+
if (this.configOverride.coverage?.enabled === false) {
318+
return null
319+
}
320+
return this._coverageProvider
321+
}
322+
323+
public async enableCoverage(): Promise<void> {
324+
this.configOverride.coverage = {} as any
325+
this.configOverride.coverage!.enabled = true
326+
await this.createCoverageProvider()
327+
await this.coverageProvider?.onEnabled?.()
328+
}
329+
330+
public disableCoverage(): void {
331+
this.configOverride.coverage ??= {} as any
332+
this.configOverride.coverage!.enabled = false
333+
}
334+
335+
private _coverageOverrideCache = new WeakMap<ResolvedCoverageOptions, ResolvedCoverageOptions>()
336+
337+
/** @internal */
338+
get _coverageOptions(): ResolvedCoverageOptions {
339+
if (!this.configOverride.coverage) {
340+
return this.config.coverage
341+
}
342+
if (!this._coverageOverrideCache.has(this.configOverride.coverage)) {
343+
const coverage = deepClone(this.config.coverage)
344+
const options = deepMerge(coverage, this.configOverride.coverage)
345+
this._coverageOverrideCache.set(
346+
this.configOverride.coverage,
347+
options,
348+
)
349+
}
350+
return this._coverageOverrideCache.get(this.configOverride.coverage)!
351+
}
352+
315353
/**
316354
* Inject new test projects into the workspace.
317355
* @param config Glob, config path or a custom config options.
@@ -399,12 +437,12 @@ export class Vitest {
399437
* Creates a coverage provider if `coverage` is enabled in the config.
400438
*/
401439
public async createCoverageProvider(): Promise<CoverageProvider | null> {
402-
if (this.coverageProvider) {
403-
return this.coverageProvider
440+
if (this._coverageProvider) {
441+
return this._coverageProvider
404442
}
405443
const coverageProvider = await this.initCoverageProvider()
406444
if (coverageProvider) {
407-
await coverageProvider.clean(this.config.coverage.clean)
445+
await coverageProvider.clean(this._coverageOptions.clean)
408446
}
409447
return coverageProvider || null
410448
}
@@ -444,18 +482,21 @@ export class Vitest {
444482
}
445483

446484
private async initCoverageProvider(): Promise<CoverageProvider | null | undefined> {
447-
if (this.coverageProvider !== undefined) {
485+
if (this._coverageProvider != null) {
448486
return
449487
}
450-
this.coverageProvider = await getCoverageProvider(
451-
this.config.coverage as unknown as SerializedCoverageConfig,
488+
const coverageConfig = (this.configOverride.coverage
489+
? this.getRootProject().serializedConfig.coverage
490+
: this.config.coverage) as unknown as SerializedCoverageConfig
491+
this._coverageProvider = await getCoverageProvider(
492+
coverageConfig,
452493
this.runner,
453494
)
454-
if (this.coverageProvider) {
455-
await this.coverageProvider.initialize(this)
456-
this.config.coverage = this.coverageProvider.resolveOptions()
495+
if (this._coverageProvider) {
496+
await this._coverageProvider.initialize(this)
497+
this.config.coverage = this._coverageProvider.resolveOptions()
457498
}
458-
return this.coverageProvider
499+
return this._coverageProvider
459500
}
460501

461502
/**
@@ -553,7 +594,7 @@ export class Vitest {
553594
async start(filters?: string[]): Promise<TestRunResult> {
554595
try {
555596
await this.initCoverageProvider()
556-
await this.coverageProvider?.clean(this.config.coverage.clean)
597+
await this.coverageProvider?.clean(this._coverageOptions.clean)
557598
}
558599
finally {
559600
await this.report('onInit', this)
@@ -602,7 +643,7 @@ export class Vitest {
602643
async init(): Promise<void> {
603644
try {
604645
await this.initCoverageProvider()
605-
await this.coverageProvider?.clean(this.config.coverage.clean)
646+
await this.coverageProvider?.clean(this._coverageOptions.clean)
606647
}
607648
finally {
608649
await this.report('onInit', this)
@@ -677,7 +718,6 @@ export class Vitest {
677718
* @param allTestsRun Indicates whether all tests were run. This only matters for coverage.
678719
*/
679720
public async rerunTestSpecifications(specifications: TestSpecification[], allTestsRun = false): Promise<TestRunResult> {
680-
this.configOverride.testNamePattern = undefined
681721
const files = specifications.map(spec => spec.moduleId)
682722
await Promise.all([
683723
this.report('onWatcherRerun', files, 'rerun test'),
@@ -709,7 +749,7 @@ export class Vitest {
709749
this.snapshot.clear()
710750
this.state.clearErrors()
711751

712-
if (!this.isFirstRun && this.config.coverage.cleanOnRerun) {
752+
if (!this.isFirstRun && this._coverageOptions.cleanOnRerun) {
713753
await this.coverageProvider?.clean()
714754
}
715755

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

1114-
if (!this.config.coverage.reportOnFailure) {
1154+
if (!this._coverageOptions.reportOnFailure) {
11151155
return
11161156
}
11171157
}

packages/vitest/src/node/coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
9696
)
9797
}
9898

99-
const config = ctx.config.coverage as Options
99+
const config = ctx._coverageOptions as Options
100100

101101
this.options = {
102102
...coverageConfigDefaults,

packages/vitest/src/node/types/coverage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export interface CoverageProvider {
5151
// TODO: when upgrading vite, import Rollup from vite
5252
pluginCtx: any
5353
) => TransformResult | Promise<TransformResult>
54+
55+
/** Callback that's called when the coverage is enabled via a programmatic `enableCoverage` API. */
56+
onEnabled?: () => void | Promise<void>
5457
}
5558

5659
export interface ReportContext {

test/coverage-test/test/mixed-versions-warning.unit.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ test('v8 provider logs warning if versions do not match', async () => {
1515
version: '1.0.0',
1616
logger: { warn },
1717
config: configDefaults,
18+
_coverageOptions: configDefaults.coverage,
1819
} as any)
1920

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

4143
expect(warn).toHaveBeenCalled()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from 'vitest'
2+
import { cleanupCoverageJson, readCoverageMap, runVitest, test } from '../utils'
3+
4+
test('enableCoverage() collects coverage after being called', async () => {
5+
await cleanupCoverageJson()
6+
7+
// Run a minimal suite where coverage starts disabled, then enable it and rerun.
8+
const { ctx } = await runVitest({
9+
include: ['fixtures/test/math.test.ts'],
10+
coverage: {
11+
// start disabled and turn on dynamically
12+
enabled: false,
13+
reporter: 'json',
14+
},
15+
})
16+
17+
await expect(readCoverageMap(), 'coverage map should not be on the disk').rejects.toThrowError(/no such file/)
18+
19+
await ctx!.enableCoverage()
20+
expect(ctx!.coverageProvider).toBeTruthy()
21+
22+
await ctx!.rerunFiles()
23+
24+
const coverageMap = await readCoverageMap()
25+
expect(coverageMap.files()).toContain('<process-cwd>/fixtures/src/math.ts')
26+
})
27+
28+
test('disableCoverage() stops collecting coverage going forward', async () => {
29+
const { ctx } = await runVitest({
30+
include: ['fixtures/test/math.test.ts'],
31+
coverage: {
32+
enabled: true,
33+
reporter: 'json',
34+
},
35+
})
36+
37+
// Initial run collects coverage
38+
const initialMap = await readCoverageMap()
39+
expect(initialMap.files()).toContain('<process-cwd>/fixtures/src/math.ts')
40+
expect(ctx!.coverageProvider).toBeTruthy()
41+
42+
// Disable coverage and rerun
43+
ctx!.disableCoverage()
44+
expect(ctx!.coverageProvider).toBeNull()
45+
46+
await cleanupCoverageJson()
47+
48+
await ctx!.rerunFiles()
49+
50+
await expect(readCoverageMap(), 'coverage map should not be on the disk').rejects.toThrowError(/no such file/)
51+
expect(ctx!.coverageProvider).toBeNull()
52+
})

test/coverage-test/test/threshold-auto-update.unit.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ async function updateThresholds(configurationFile: ReturnType<typeof parseModule
151151
provider._initialize({
152152
config: { coverage: { } },
153153
logger: { log: () => {} },
154+
_coverageOptions: {},
154155
} as any)
155156

156157
provider.updateThresholds({

0 commit comments

Comments
 (0)