Skip to content

Commit 63acca7

Browse files
committed
feat(coverage): v8 to track node:child_process and node:worker_threads contexts
1 parent b865b4d commit 63acca7

23 files changed

Lines changed: 532 additions & 24 deletions

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,13 @@ Apply exclusions again after coverage has been remapped to original sources. (de
299299

300300
Directory of HTML coverage output to be served in UI mode and HTML reporter.
301301

302+
### coverage.trackProcessAndWorker
303+
304+
- **CLI:** `--coverage.trackProcessAndWorker`
305+
- **Config:** [coverage.trackProcessAndWorker](/config/coverage#coverage-trackprocessandworker)
306+
307+
Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. Supported only by `v8` provider. (default: false)
308+
302309
### mode
303310

304311
- **CLI:** `--mode <name>`

packages/coverage-v8/src/index.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,40 @@
11
import type { Profiler } from 'node:inspector'
22
import type { CoverageProviderModule } from 'vitest/node'
33
import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider'
4+
import { randomUUID } from 'node:crypto'
5+
import { readdir, readFile } from 'node:fs/promises'
46
import inspector from 'node:inspector/promises'
7+
import { resolve } from 'node:path'
58
import { fileURLToPath } from 'node:url'
69
import { normalize } from 'pathe'
710
import { provider } from 'std-env'
811
import { loadProvider } from './load-provider'
912

10-
const session = new inspector.Session()
1113
let enabled = false
1214

13-
const mod: CoverageProviderModule = {
14-
async startCoverage({ isolate }) {
15+
const mod: CoverageProviderModule & {
16+
extendedContextCoverageDir?: string
17+
// Use unknown to avoid bundling node:inspector
18+
session?: unknown | null
19+
} = {
20+
extendedContextCoverageDir: undefined,
21+
session: null,
22+
23+
async startCoverage({ isolate, trackProcessAndWorker, reportsDirectory }) {
1524
if (isolate === false && enabled) {
1625
return
1726
}
1827

1928
enabled = true
2029

30+
if (trackProcessAndWorker) {
31+
this.extendedContextCoverageDir = resolve(reportsDirectory, 'tmp', randomUUID())
32+
process.env.NODE_V8_COVERAGE = this.extendedContextCoverageDir
33+
}
34+
35+
this.session ||= new inspector.Session()
36+
const session = this.session as inspector.Session
37+
2138
session.connect()
2239
await session.post('Profiler.enable')
2340
await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
@@ -28,16 +45,43 @@ const mod: CoverageProviderModule = {
2845
return { result: [] }
2946
}
3047

48+
const session = this.session as inspector.Session
49+
50+
if (!session) {
51+
throw new Error('V8 provider missing inspector session.')
52+
}
53+
3154
const coverage = await session.post('Profiler.takePreciseCoverage')
3255
const result: ScriptCoverageWithOffset[] = []
3356

3457
// Reduce amount of data sent over rpc by doing some early result filtering
35-
for (const entry of coverage.result) {
58+
for (const entry of coverage.result as ScriptCoverageWithOffset[]) {
3659
if (filterResult(entry)) {
37-
result.push({
38-
...entry,
39-
startOffset: options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0,
40-
})
60+
entry.startOffset = options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0
61+
62+
result.push(entry)
63+
}
64+
}
65+
66+
if (this.extendedContextCoverageDir) {
67+
const filenames = await readdir(this.extendedContextCoverageDir)
68+
const contents = await Promise.all(
69+
filenames
70+
.filter(filename => filename.endsWith('.json'))
71+
.map(filename => readFile(`${this.extendedContextCoverageDir}/${filename}`, 'utf8')),
72+
)
73+
74+
for (const content of contents) {
75+
const json: { result: ScriptCoverageWithOffset[] } = JSON.parse(content)
76+
77+
for (const entry of json.result) {
78+
if (filterResult(entry)) {
79+
entry.startOffset = 0
80+
entry.isExtendedContext = true
81+
82+
result.push(entry)
83+
}
84+
}
4185
}
4286
}
4387

@@ -49,9 +93,16 @@ const mod: CoverageProviderModule = {
4993
return
5094
}
5195

96+
const session = this.session as inspector.Session
97+
98+
if (!session) {
99+
throw new Error('V8 provider missing inspector session.')
100+
}
101+
52102
await session.post('Profiler.stopPreciseCoverage')
53103
await session.post('Profiler.disable')
54104
session.disconnect()
105+
this.session = null
55106
},
56107

57108
async getProvider(): Promise<V8CoverageProvider> {

packages/coverage-v8/src/provider.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { version } from '../package.json' with { type: 'json' }
2020

2121
export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage {
2222
startOffset: number
23+
24+
/** Whether script ran outside Vite, e.g. in sub-processes or worker threads */
25+
isExtendedContext?: boolean
2326
}
2427

2528
interface RawCoverage { result: ScriptCoverageWithOffset[] }
@@ -34,6 +37,18 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
3437

3538
initialize(ctx: Vitest): void {
3639
this._initialize(ctx)
40+
41+
if (this.options.trackProcessAndWorker) {
42+
const isAnyThreadsPools = ctx.projects.some(p => p.config.pool === 'threads' || p.config.pool === 'vmThreads')
43+
44+
if (isAnyThreadsPools) {
45+
// Work-around for https://github.com/nodejs/node/issues/46378
46+
// Node never does anything with this directory, it's just required so that
47+
// the next Workers read **their** env.NODE_V8_COVERAGE.
48+
// Node never creates this .unused directory at all.
49+
process.env.NODE_V8_COVERAGE = `${this.coverageFilesDirectory}/.unused`
50+
}
51+
}
3752
}
3853

3954
createCoverageMap(): CoverageMap {
@@ -46,16 +61,26 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
4661
const coverageMap = this.createCoverageMap()
4762
let merged: RawCoverage = { result: [] }
4863

64+
const trackProcessAndWorker = this.options.trackProcessAndWorker
65+
4966
await this.readCoverageFiles<RawCoverage>({
5067
onFileRead(coverage) {
5168
merged = mergeProcessCovs([merged, coverage])
5269

70+
// mergeProcessCovs sometimes loses trackProcessAndWorker
71+
const fromExtendedContext = trackProcessAndWorker ? coverage.result.filter(r => r.isExtendedContext) : []
72+
5373
// mergeProcessCovs sometimes loses startOffset, e.g. in vue
5474
merged.result.forEach((result) => {
5575
if (!result.startOffset) {
5676
const original = coverage.result.find(r => r.url === result.url)
5777
result.startOffset = original?.startOffset || 0
5878
}
79+
80+
if (trackProcessAndWorker && !result.isExtendedContext) {
81+
const actual = fromExtendedContext.find(r => r.url === result.url)
82+
result.isExtendedContext = actual?.isExtendedContext
83+
}
5984
})
6085
},
6186
onFinished: async (project, environment) => {
@@ -331,8 +356,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
331356

332357
private async getSources(
333358
url: string,
334-
onTransform: (filepath: string) => Promise<Vite.TransformResult | undefined | null>,
359+
onTransform: (filepath: string, isExtendedContext?: ScriptCoverageWithOffset['isExtendedContext']) => Promise<Vite.TransformResult | undefined | null>,
335360
functions: Profiler.FunctionCoverage[] = [],
361+
isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false,
336362
): Promise<{
337363
code: string
338364
map?: Vite.Rollup.SourceMap
@@ -342,7 +368,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
342368
? url.slice(8)
343369
: removeStartsWith(url, FILE_PROTOCOL)
344370
// TODO: do we still need to "catch" here? why would it fail?
345-
const transformResult = await onTransform(filepath).catch(() => null)
371+
const transformResult = await onTransform(filepath, isExtendedContext).catch(() => null)
346372

347373
const map = transformResult?.map as Vite.Rollup.SourceMap | undefined
348374
const code = transformResult?.code
@@ -385,8 +411,8 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
385411
throw new Error(`Cannot access browser module graph because it was torn down.`)
386412
}
387413

388-
const onTransform = async (filepath: string) => {
389-
const result = await this.transformFile(filepath, project, environment)
414+
const onTransform = async (filepath: string, isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false) => {
415+
const result = await this.transformFile(filepath, project, environment, !isExtendedContext)
390416
if (result && environment === '__browser__' && project.browser) {
391417
return { ...result, code: `${result.code}// <inline-source-map>` }
392418
}
@@ -423,7 +449,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
423449
}
424450

425451
await Promise.all(
426-
chunk.map(async ({ url, functions, startOffset }) => {
452+
chunk.map(async ({ url, functions, startOffset, isExtendedContext }) => {
427453
let timeout: ReturnType<typeof setTimeout> | undefined
428454
let start: number | undefined
429455

@@ -436,6 +462,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
436462
url,
437463
onTransform,
438464
functions,
465+
isExtendedContext,
439466
)
440467

441468
coverageMap.merge(await this.remapCoverage(

packages/vitest/src/defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const coverageConfigDefaults: Required<Pick<CoverageOptions, FieldsWithDe
5353
branches: [50, 80],
5454
lines: [50, 80],
5555
},
56+
trackProcessAndWorker: false,
5657
}
5758

5859
export const fakeTimersDefaults: NonNullable<UserConfig['fakeTimers']> = {

packages/vitest/src/integrations/coverage.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ import type { RuntimeCoverageModuleLoader } from '../utils/coverage'
33
import { resolveCoverageProviderModule } from '../utils/coverage'
44

55
export async function startCoverageInsideWorker(
6-
options: SerializedCoverageConfig | undefined,
6+
options: SerializedCoverageConfig,
77
loader: RuntimeCoverageModuleLoader,
88
runtimeOptions: { isolate: boolean },
99
): Promise<unknown> {
1010
const coverageModule = await resolveCoverageProviderModule(options, loader)
1111

1212
if (coverageModule) {
13-
return coverageModule.startCoverage?.(runtimeOptions)
13+
return coverageModule.startCoverage?.({
14+
...runtimeOptions,
15+
trackProcessAndWorker: options.trackProcessAndWorker,
16+
reportsDirectory: options.reportsDirectory,
17+
})
1418
}
1519

1620
return null
1721
}
1822

1923
export async function takeCoverageInsideWorker(
20-
options: SerializedCoverageConfig | undefined,
24+
options: SerializedCoverageConfig,
2125
loader: RuntimeCoverageModuleLoader,
2226
): Promise<unknown> {
2327
const coverageModule = await resolveCoverageProviderModule(options, loader)
@@ -30,7 +34,7 @@ export async function takeCoverageInsideWorker(
3034
}
3135

3236
export async function stopCoverageInsideWorker(
33-
options: SerializedCoverageConfig | undefined,
37+
options: SerializedCoverageConfig,
3438
loader: RuntimeCoverageModuleLoader,
3539
runtimeOptions: { isolate: boolean },
3640
): Promise<unknown> {

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
326326
description: 'Directory of HTML coverage output to be served in UI mode and HTML reporter.',
327327
argument: '<path>',
328328
},
329+
trackProcessAndWorker: {
330+
description: 'Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run. Supported only by `v8` provider. (default: false)',
331+
},
329332
},
330333
},
331334
mode: {

packages/vitest/src/node/config/serializeConfig.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { TestProject } from '../project'
22
import type { ApiConfig, SerializedConfig } from '../types/config'
3+
import { resolve } from 'node:path'
34
import { configDefaults } from '../../defaults'
45
import { isAgent } from '../../utils/env'
56

@@ -51,13 +52,14 @@ export function serializeConfig(project: TestProject): SerializedConfig {
5152
passWithNoTests: config.passWithNoTests,
5253
coverage: ((coverage) => {
5354
return {
54-
reportsDirectory: coverage.reportsDirectory,
55+
reportsDirectory: resolve(globalConfig.root, coverage.reportsDirectory),
5556
provider: coverage.provider,
5657
enabled: coverage.enabled,
5758
customProviderModule: 'customProviderModule' in coverage
5859
? coverage.customProviderModule
5960
: undefined,
6061
htmlDir: coverage.htmlDir,
62+
trackProcessAndWorker: coverage.trackProcessAndWorker ?? false,
6163
}
6264
})(config.coverage),
6365
fakeTimers: config.fakeTimers,

packages/vitest/src/node/coverage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,11 +685,11 @@ export class BaseCoverageProvider {
685685
// TODO: should this be abstracted in `project`/`vitest` instead?
686686
// if we decide to keep `viteModuleRunner: false`, we will need to abstract transformation in both main thread and tests
687687
// custom --import=module.registerHooks need to be transformed as well somehow
688-
async transformFile(url: string, project: TestProject, viteEnvironment: string): Promise<TransformResult | null | undefined> {
688+
async transformFile(url: string, project: TestProject, viteEnvironment: string, isTransformedByVite = true): Promise<TransformResult | null | undefined> {
689689
const config = project.config
690690

691691
// vite is disabled, should transform manually if possible
692-
if (config.experimental.viteModuleRunner === false) {
692+
if (config.experimental.viteModuleRunner === false || !isTransformedByVite) {
693693
const pathname = url.split('?')[0]
694694
const filename = pathname.startsWith('file://') ? fileURLToPath(pathname) : pathname
695695
const extension = path.extname(filename)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export type FieldsWithDefaultValues
111111
| 'ignoreClassMethods'
112112
| 'skipFull'
113113
| 'watermarks'
114+
| 'trackProcessAndWorker'
114115

115116
export type ResolvedCoverageOptions
116117
= CoverageOptions
@@ -264,6 +265,14 @@ export interface CoverageOptions {
264265
*/
265266
processingConcurrency?: number
266267

268+
/**
269+
* Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run.
270+
* Supported only by `v8` provider.
271+
*
272+
* @default false
273+
*/
274+
trackProcessAndWorker?: boolean
275+
267276
/**
268277
* Set to array of class method names to ignore for coverage
269278
*

packages/vitest/src/runtime/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export interface SerializedCoverageConfig {
156156
htmlDir: string | undefined
157157
enabled: boolean
158158
customProviderModule: string | undefined
159+
trackProcessAndWorker: boolean
159160
}
160161

161162
export type RuntimeConfig = Pick<

0 commit comments

Comments
 (0)