Skip to content

Commit 0e0ff41

Browse files
feat(coverage): istanbul to support instrumenter option (#10119)
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
1 parent 86807ad commit 0e0ff41

6 files changed

Lines changed: 202 additions & 22 deletions

File tree

docs/config/coverage.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,46 @@ Watermarks for statements, lines, branches and functions. See [istanbul document
390390

391391
Concurrency limit used when processing the coverage results.
392392

393+
## coverage.instrumenter <Version type="experimental">4.1.5</Version> {#coverage-instrumenter}
394+
395+
- **Type:** `(options: InstrumenterOptions) => CoverageInstrumenter`
396+
- **Available for providers:** `'istanbul'`
397+
398+
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.
399+
400+
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`.
401+
402+
<!-- eslint-skip -->
403+
```ts
404+
interface InstrumenterOptions {
405+
coverageVariable: string
406+
coverageGlobalScope: string
407+
coverageGlobalScopeFunc: boolean
408+
ignoreClassMethods: string[]
409+
}
410+
411+
interface CoverageInstrumenter {
412+
instrumentSync: (code: string, filename: string, inputSourceMap?: any) => string
413+
lastSourceMap: () => any
414+
lastFileCoverage: () => any
415+
}
416+
```
417+
418+
<!-- eslint-skip -->
419+
```ts
420+
import { defineConfig } from 'vitest/config'
421+
import { createInstrumenter } from '@vitest/some-custom-instrumenter'
422+
423+
export default defineConfig({
424+
test: {
425+
coverage: {
426+
provider: 'istanbul',
427+
instrumenter: options => createInstrumenter(options),
428+
}
429+
}
430+
})
431+
```
432+
393433
## coverage.customProviderModule
394434

395435
- **Type:** `string`

packages/coverage-istanbul/src/provider.ts

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,37 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
3131
initialize(ctx: Vitest): void {
3232
this._initialize(ctx)
3333

34-
this.instrumenter = createInstrumenter({
35-
produceSourceMap: true,
36-
autoWrap: false,
37-
esModules: true,
38-
compact: false,
39-
coverageVariable: COVERAGE_STORE_KEY,
40-
coverageGlobalScope: 'globalThis',
41-
coverageGlobalScopeFunc: false,
42-
ignoreClassMethods: this.options.ignoreClassMethods,
43-
parserPlugins: [
44-
...istanbulDefaults.instrumenter.parserPlugins,
45-
['importAttributes', { deprecatedAssertSyntax: true }],
46-
],
47-
generatorOpts: {
48-
// @ts-expect-error missing type
49-
importAttributesKeyword: 'with',
50-
},
51-
52-
// Custom option from the patched istanbul-lib-instrument: https://github.com/istanbuljs/istanbuljs/pull/835
53-
ignoreLines: true,
54-
})
34+
if (this.options.instrumenter) {
35+
this.instrumenter = this.options.instrumenter({
36+
coverageVariable: COVERAGE_STORE_KEY,
37+
coverageGlobalScope: 'globalThis',
38+
coverageGlobalScopeFunc: false,
39+
ignoreClassMethods: this.options.ignoreClassMethods,
40+
}) as Instrumenter
41+
}
42+
else {
43+
this.instrumenter = createInstrumenter({
44+
produceSourceMap: true,
45+
autoWrap: false,
46+
esModules: true,
47+
compact: false,
48+
coverageVariable: COVERAGE_STORE_KEY,
49+
coverageGlobalScope: 'globalThis',
50+
coverageGlobalScopeFunc: false,
51+
ignoreClassMethods: this.options.ignoreClassMethods,
52+
parserPlugins: [
53+
...istanbulDefaults.instrumenter.parserPlugins,
54+
['importAttributes', { deprecatedAssertSyntax: true }],
55+
],
56+
generatorOpts: {
57+
// @ts-expect-error missing type
58+
importAttributesKeyword: 'with',
59+
},
60+
61+
// Custom option from the patched istanbul-lib-instrument: https://github.com/istanbuljs/istanbuljs/pull/835
62+
ignoreLines: true,
63+
})
64+
}
5565
}
5666

5767
requiresTransform(id: string): boolean {

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,34 @@ export interface CoverageOptions {
271271
*/
272272
ignoreClassMethods?: string[]
273273

274+
/**
275+
* Custom instrumenter factory to use instead of the default `istanbul-lib-instrument`.
276+
*
277+
* The factory receives the same runtime coverage options Vitest passes to its
278+
* built-in Istanbul instrumenter and must return an object implementing the
279+
* `CoverageInstrumenter` interface.
280+
*
281+
* This allows using faster instrumenters (e.g., oxc-coverage-instrument, SWC) while
282+
* keeping the Istanbul coverage pipeline for collection, merging, and reporting.
283+
*
284+
* @example
285+
* ```ts
286+
* import { defineConfig } from 'vitest/config'
287+
* import { createOxcInstrumenter } from 'oxc-coverage-instrument/vitest'
288+
*
289+
* export default defineConfig({
290+
* test: {
291+
* coverage: {
292+
* provider: 'istanbul',
293+
* instrumenter: options => createOxcInstrumenter(options),
294+
* }
295+
* }
296+
* })
297+
*
298+
* @experimental
299+
*/
300+
instrumenter?: (options: InstrumenterOptions) => CoverageInstrumenter
301+
274302
/**
275303
* Directory of HTML coverage output to be served in UI mode and HTML reporter.
276304
* This is automatically configured for builtin reporter with html output (`html`, `html-spa`, and `lcov` reporters).
@@ -318,6 +346,36 @@ interface Thresholds {
318346
lines?: number
319347
}
320348

349+
/**
350+
* Options passed to the custom instrumenter factory.
351+
*/
352+
export interface InstrumenterOptions {
353+
/** Global variable name that Vitest uses to store coverage data at runtime. */
354+
coverageVariable: string
355+
/** Global scope where the coverage variable is attached at runtime. */
356+
coverageGlobalScope: string
357+
/** Whether the coverage global scope should be resolved through an evaluated function. */
358+
coverageGlobalScopeFunc: boolean
359+
/** Class method names to exclude from function coverage. */
360+
ignoreClassMethods: string[]
361+
}
362+
363+
/**
364+
* Interface for custom coverage instrumenters.
365+
*
366+
* Matches the subset of istanbul-lib-instrument's `Instrumenter` that Vitest
367+
* actually uses. Implement this to plug in a faster instrumenter while keeping
368+
* the Istanbul coverage pipeline for collection, merging, and reporting.
369+
*/
370+
export interface CoverageInstrumenter {
371+
/** Instrument source code synchronously. Returns the instrumented code string. */
372+
instrumentSync: (code: string, filename: string, inputSourceMap?: any) => string
373+
/** Get the source map of the last instrumented file. */
374+
lastSourceMap: () => any
375+
/** Get the Istanbul-compatible file coverage object of the last instrumented file. */
376+
lastFileCoverage: () => any
377+
}
378+
321379
/** @deprecated Use `CoverageOptions` instead */
322380
export interface CoverageV8Options extends CoverageOptions {}
323381

packages/vitest/src/public/node.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,15 @@ export type {
152152
} from '../node/types/config'
153153
export type {
154154
BaseCoverageOptions,
155+
CoverageInstrumenter,
155156
CoverageIstanbulOptions,
156157
CoverageOptions,
157158
CoverageProvider,
158159
CoverageProviderModule,
159160
CoverageReporter,
160161
CoverageV8Options,
161162
CustomProviderOptions,
163+
InstrumenterOptions,
162164
ReportContext,
163165
ResolvedCoverageOptions,
164166
} from '../node/types/coverage'

test/coverage-test/test/configuration-options.test-d.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { defineConfig } from 'vitest/config'
2-
import type { CoverageProviderModule, ResolvedCoverageOptions, Vitest } from 'vitest/node'
2+
import type { CoverageInstrumenter, CoverageProviderModule, InstrumenterOptions, ResolvedCoverageOptions, Vitest } from 'vitest/node'
33
import { assertType, test } from 'vitest'
44

55
type NarrowToTestConfig<T> = T extends { test?: any } ? NonNullable<T['test']> : never
@@ -208,3 +208,39 @@ test('reporters, mixed variations', () => {
208208
],
209209
})
210210
})
211+
212+
test('custom instrumenter', () => {
213+
// Custom instrumenter factory function
214+
assertType<Coverage>({
215+
provider: 'istanbul',
216+
instrumenter: _options => ({
217+
instrumentSync: (code, _filename, _sourceMap?) => code,
218+
lastSourceMap: () => ({}),
219+
lastFileCoverage: () => ({}),
220+
}),
221+
})
222+
223+
// Without instrumenter (default behavior)
224+
assertType<Coverage>({
225+
provider: 'istanbul',
226+
})
227+
228+
// Verify CoverageInstrumenter type can be used as return type
229+
const factory: (opts: InstrumenterOptions) => CoverageInstrumenter = _opts => ({
230+
instrumentSync: code => code,
231+
lastSourceMap: () => null,
232+
lastFileCoverage: () => ({}),
233+
})
234+
235+
assertType<InstrumenterOptions>({
236+
coverageVariable: '__VITEST_COVERAGE__',
237+
coverageGlobalScope: 'globalThis',
238+
coverageGlobalScopeFunc: false,
239+
ignoreClassMethods: ['test-method'],
240+
})
241+
242+
assertType<Coverage>({
243+
provider: 'istanbul',
244+
instrumenter: factory,
245+
})
246+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { expect, vi } from 'vitest'
2+
import { normalizeURL, runVitest, test } from '../utils'
3+
4+
test('custom instrumenter receives correct options', async () => {
5+
const instrumenter = vi.fn().mockReturnValue({
6+
instrumentSync: (code: string) => code,
7+
lastSourceMap: () => ({}),
8+
lastFileCoverage: () => ({
9+
path: 'test.ts',
10+
statementMap: {},
11+
fnMap: {},
12+
branchMap: {},
13+
s: {},
14+
f: {},
15+
b: {},
16+
}),
17+
})
18+
19+
await runVitest({
20+
include: [normalizeURL(import.meta.url)],
21+
coverage: {
22+
reporter: 'json',
23+
ignoreClassMethods: ['test-method'],
24+
instrumenter,
25+
},
26+
}, { throwOnError: false })
27+
28+
expect(instrumenter).toHaveBeenCalledWith({
29+
coverageVariable: '__VITEST_COVERAGE__',
30+
coverageGlobalScope: 'globalThis',
31+
coverageGlobalScopeFunc: false,
32+
ignoreClassMethods: ['test-method'],
33+
})
34+
})

0 commit comments

Comments
 (0)