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