From 0e0ff41c7e86d6e2bf581f074dc216805d10d371 Mon Sep 17 00:00:00 2001 From: Bart Waardenburg Date: Mon, 20 Apr 2026 13:44:11 +0200 Subject: [PATCH] feat(coverage): istanbul to support `instrumenter` option (#10119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ari Perkkiƶ --- docs/config/coverage.md | 40 +++++++++++++ packages/coverage-istanbul/src/provider.ts | 52 ++++++++++------- packages/vitest/src/node/types/coverage.ts | 58 +++++++++++++++++++ packages/vitest/src/public/node.ts | 2 + .../test/configuration-options.test-d.ts | 38 +++++++++++- .../test/custom-instrumenter.istanbul.test.ts | 34 +++++++++++ 6 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 test/coverage-test/test/custom-instrumenter.istanbul.test.ts diff --git a/docs/config/coverage.md b/docs/config/coverage.md index 8d3f22696..461466deb 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -390,6 +390,46 @@ Watermarks for statements, lines, branches and functions. See [istanbul document Concurrency limit used when processing the coverage results. +## coverage.instrumenter 4.1.5 {#coverage-instrumenter} + +- **Type:** `(options: InstrumenterOptions) => CoverageInstrumenter` +- **Available for providers:** `'istanbul'` + +Factory for a custom instrumenter to use in place of the default `istanbul-lib-instrument`. Vitest calls the factory once during initialization and reuses the returned instrumenter for every file. The rest of the Istanbul pipeline (collection, merging, reporting) is unchanged. + +The factory receives an `InstrumenterOptions` object with Vitest's runtime coverage settings, and must return an object implementing the `CoverageInstrumenter` interface. Both types are exported from `vitest/node`. + + +```ts +interface InstrumenterOptions { + coverageVariable: string + coverageGlobalScope: string + coverageGlobalScopeFunc: boolean + ignoreClassMethods: string[] +} + +interface CoverageInstrumenter { + instrumentSync: (code: string, filename: string, inputSourceMap?: any) => string + lastSourceMap: () => any + lastFileCoverage: () => any +} +``` + + +```ts +import { defineConfig } from 'vitest/config' +import { createInstrumenter } from '@vitest/some-custom-instrumenter' + +export default defineConfig({ + test: { + coverage: { + provider: 'istanbul', + instrumenter: options => createInstrumenter(options), + } + } +}) +``` + ## coverage.customProviderModule - **Type:** `string` diff --git a/packages/coverage-istanbul/src/provider.ts b/packages/coverage-istanbul/src/provider.ts index 7c20e506a..b05459c4d 100644 --- a/packages/coverage-istanbul/src/provider.ts +++ b/packages/coverage-istanbul/src/provider.ts @@ -31,27 +31,37 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co initialize(ctx: Vitest): void { this._initialize(ctx) - this.instrumenter = createInstrumenter({ - produceSourceMap: true, - autoWrap: false, - esModules: true, - compact: false, - coverageVariable: COVERAGE_STORE_KEY, - coverageGlobalScope: 'globalThis', - coverageGlobalScopeFunc: false, - ignoreClassMethods: this.options.ignoreClassMethods, - parserPlugins: [ - ...istanbulDefaults.instrumenter.parserPlugins, - ['importAttributes', { deprecatedAssertSyntax: true }], - ], - generatorOpts: { - // @ts-expect-error missing type - importAttributesKeyword: 'with', - }, - - // Custom option from the patched istanbul-lib-instrument: https://github.com/istanbuljs/istanbuljs/pull/835 - ignoreLines: true, - }) + if (this.options.instrumenter) { + this.instrumenter = this.options.instrumenter({ + coverageVariable: COVERAGE_STORE_KEY, + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + ignoreClassMethods: this.options.ignoreClassMethods, + }) as Instrumenter + } + else { + this.instrumenter = createInstrumenter({ + produceSourceMap: true, + autoWrap: false, + esModules: true, + compact: false, + coverageVariable: COVERAGE_STORE_KEY, + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + ignoreClassMethods: this.options.ignoreClassMethods, + parserPlugins: [ + ...istanbulDefaults.instrumenter.parserPlugins, + ['importAttributes', { deprecatedAssertSyntax: true }], + ], + generatorOpts: { + // @ts-expect-error missing type + importAttributesKeyword: 'with', + }, + + // Custom option from the patched istanbul-lib-instrument: https://github.com/istanbuljs/istanbuljs/pull/835 + ignoreLines: true, + }) + } } requiresTransform(id: string): boolean { diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index 7c11d86cb..1c252b1d9 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -271,6 +271,34 @@ export interface CoverageOptions { */ ignoreClassMethods?: string[] + /** + * Custom instrumenter factory to use instead of the default `istanbul-lib-instrument`. + * + * The factory receives the same runtime coverage options Vitest passes to its + * built-in Istanbul instrumenter and must return an object implementing the + * `CoverageInstrumenter` interface. + * + * This allows using faster instrumenters (e.g., oxc-coverage-instrument, SWC) while + * keeping the Istanbul coverage pipeline for collection, merging, and reporting. + * + * @example + * ```ts + * import { defineConfig } from 'vitest/config' + * import { createOxcInstrumenter } from 'oxc-coverage-instrument/vitest' + * + * export default defineConfig({ + * test: { + * coverage: { + * provider: 'istanbul', + * instrumenter: options => createOxcInstrumenter(options), + * } + * } + * }) + * + * @experimental + */ + instrumenter?: (options: InstrumenterOptions) => CoverageInstrumenter + /** * Directory of HTML coverage output to be served in UI mode and HTML reporter. * This is automatically configured for builtin reporter with html output (`html`, `html-spa`, and `lcov` reporters). @@ -318,6 +346,36 @@ interface Thresholds { lines?: number } +/** + * Options passed to the custom instrumenter factory. + */ +export interface InstrumenterOptions { + /** Global variable name that Vitest uses to store coverage data at runtime. */ + coverageVariable: string + /** Global scope where the coverage variable is attached at runtime. */ + coverageGlobalScope: string + /** Whether the coverage global scope should be resolved through an evaluated function. */ + coverageGlobalScopeFunc: boolean + /** Class method names to exclude from function coverage. */ + ignoreClassMethods: string[] +} + +/** + * Interface for custom coverage instrumenters. + * + * Matches the subset of istanbul-lib-instrument's `Instrumenter` that Vitest + * actually uses. Implement this to plug in a faster instrumenter while keeping + * the Istanbul coverage pipeline for collection, merging, and reporting. + */ +export interface CoverageInstrumenter { + /** Instrument source code synchronously. Returns the instrumented code string. */ + instrumentSync: (code: string, filename: string, inputSourceMap?: any) => string + /** Get the source map of the last instrumented file. */ + lastSourceMap: () => any + /** Get the Istanbul-compatible file coverage object of the last instrumented file. */ + lastFileCoverage: () => any +} + /** @deprecated Use `CoverageOptions` instead */ export interface CoverageV8Options extends CoverageOptions {} diff --git a/packages/vitest/src/public/node.ts b/packages/vitest/src/public/node.ts index 1e3a67194..6521ddf32 100644 --- a/packages/vitest/src/public/node.ts +++ b/packages/vitest/src/public/node.ts @@ -152,6 +152,7 @@ export type { } from '../node/types/config' export type { BaseCoverageOptions, + CoverageInstrumenter, CoverageIstanbulOptions, CoverageOptions, CoverageProvider, @@ -159,6 +160,7 @@ export type { CoverageReporter, CoverageV8Options, CustomProviderOptions, + InstrumenterOptions, ReportContext, ResolvedCoverageOptions, } from '../node/types/coverage' diff --git a/test/coverage-test/test/configuration-options.test-d.ts b/test/coverage-test/test/configuration-options.test-d.ts index be45d91f1..20acb62b6 100644 --- a/test/coverage-test/test/configuration-options.test-d.ts +++ b/test/coverage-test/test/configuration-options.test-d.ts @@ -1,5 +1,5 @@ import type { defineConfig } from 'vitest/config' -import type { CoverageProviderModule, ResolvedCoverageOptions, Vitest } from 'vitest/node' +import type { CoverageInstrumenter, CoverageProviderModule, InstrumenterOptions, ResolvedCoverageOptions, Vitest } from 'vitest/node' import { assertType, test } from 'vitest' type NarrowToTestConfig = T extends { test?: any } ? NonNullable : never @@ -208,3 +208,39 @@ test('reporters, mixed variations', () => { ], }) }) + +test('custom instrumenter', () => { + // Custom instrumenter factory function + assertType({ + provider: 'istanbul', + instrumenter: _options => ({ + instrumentSync: (code, _filename, _sourceMap?) => code, + lastSourceMap: () => ({}), + lastFileCoverage: () => ({}), + }), + }) + + // Without instrumenter (default behavior) + assertType({ + provider: 'istanbul', + }) + + // Verify CoverageInstrumenter type can be used as return type + const factory: (opts: InstrumenterOptions) => CoverageInstrumenter = _opts => ({ + instrumentSync: code => code, + lastSourceMap: () => null, + lastFileCoverage: () => ({}), + }) + + assertType({ + coverageVariable: '__VITEST_COVERAGE__', + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + ignoreClassMethods: ['test-method'], + }) + + assertType({ + provider: 'istanbul', + instrumenter: factory, + }) +}) diff --git a/test/coverage-test/test/custom-instrumenter.istanbul.test.ts b/test/coverage-test/test/custom-instrumenter.istanbul.test.ts new file mode 100644 index 000000000..c645fd5c5 --- /dev/null +++ b/test/coverage-test/test/custom-instrumenter.istanbul.test.ts @@ -0,0 +1,34 @@ +import { expect, vi } from 'vitest' +import { normalizeURL, runVitest, test } from '../utils' + +test('custom instrumenter receives correct options', async () => { + const instrumenter = vi.fn().mockReturnValue({ + instrumentSync: (code: string) => code, + lastSourceMap: () => ({}), + lastFileCoverage: () => ({ + path: 'test.ts', + statementMap: {}, + fnMap: {}, + branchMap: {}, + s: {}, + f: {}, + b: {}, + }), + }) + + await runVitest({ + include: [normalizeURL(import.meta.url)], + coverage: { + reporter: 'json', + ignoreClassMethods: ['test-method'], + instrumenter, + }, + }, { throwOnError: false }) + + expect(instrumenter).toHaveBeenCalledWith({ + coverageVariable: '__VITEST_COVERAGE__', + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + ignoreClassMethods: ['test-method'], + }) +}) -- 2.51.2