Skip to content

Commit 9baa5fa

Browse files
authored
feat(coverage): v8 to track node:child_process and node:worker_threads contexts (#9976)
1 parent e680ffb commit 9baa5fa

24 files changed

Lines changed: 597 additions & 24 deletions

docs/config/coverage.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,3 +458,14 @@ Note that setting this option does not change where coverage HTML report is gene
458458
- **CLI:** `--coverage.changed`, `--coverage.changed=<commit/branch>`
459459

460460
Collect coverage only for files changed since a specified commit or branch. When set to `true`, it uses staged and unstaged changes.
461+
462+
## coverage.autoAttachSubprocess <Version>5.0.0</Version> {#coverage-autoattachsubprocess}
463+
464+
- **Type:** `boolean`
465+
- **Default:** `false`
466+
- **Available for providers:** `'v8'`
467+
- **CLI:** `--coverage.autoAttachSubprocess`
468+
469+
Track coverage of the `node:child_process` and `node:worker_threads` spawned during test run.
470+
471+
Note that this option has some performance overhead as its using [`NODE_V8_COVERAGE`](https://nodejs.org/api/cli.html#node-v8-coveragedir) internally. This triggers Node to write lots of unnecessary files on file system.

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.autoAttachSubprocess
303+
304+
- **CLI:** `--coverage.autoAttachSubprocess`
305+
- **Config:** [coverage.autoAttachSubprocess](/config/coverage#coverage-autoattachsubprocess)
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: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,41 @@
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 { existsSync } from 'node:fs'
6+
import { readdir, readFile, rm } from 'node:fs/promises'
47
import inspector from 'node:inspector/promises'
8+
import { resolve } from 'node:path'
59
import { fileURLToPath } from 'node:url'
610
import { normalize } from 'pathe'
711
import { provider } from 'std-env'
812
import { loadProvider } from './load-provider'
913

10-
const session = new inspector.Session()
1114
let enabled = false
1215

13-
const mod: CoverageProviderModule = {
14-
async startCoverage({ isolate }) {
16+
const mod: CoverageProviderModule & {
17+
extendedContextCoverageDir?: string
18+
// Use unknown to avoid bundling node:inspector
19+
session?: unknown | null
20+
} = {
21+
extendedContextCoverageDir: undefined,
22+
session: null,
23+
24+
async startCoverage({ isolate, autoAttachSubprocess, reportsDirectory }) {
1525
if (isolate === false && enabled) {
1626
return
1727
}
1828

1929
enabled = true
2030

31+
if (autoAttachSubprocess) {
32+
this.extendedContextCoverageDir = resolve(reportsDirectory, 'tmp', randomUUID())
33+
process.env.NODE_V8_COVERAGE = this.extendedContextCoverageDir
34+
}
35+
36+
this.session ||= new inspector.Session()
37+
const session = this.session as inspector.Session
38+
2139
session.connect()
2240
await session.post('Profiler.enable')
2341
await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
@@ -28,16 +46,50 @@ const mod: CoverageProviderModule = {
2846
return { result: [] }
2947
}
3048

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

3458
// Reduce amount of data sent over rpc by doing some early result filtering
35-
for (const entry of coverage.result) {
59+
for (const entry of coverage.result as ScriptCoverageWithOffset[]) {
3660
if (filterResult(entry)) {
37-
result.push({
38-
...entry,
39-
startOffset: options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0,
40-
})
61+
entry.startOffset = options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0
62+
63+
result.push(entry)
64+
}
65+
}
66+
67+
if (this.extendedContextCoverageDir && existsSync(this.extendedContextCoverageDir)) {
68+
const filenames = await readdir(this.extendedContextCoverageDir)
69+
const contents = await Promise.all(
70+
filenames
71+
.filter(filename => filename.endsWith('.json'))
72+
.map(async (filename) => {
73+
const path = `${this.extendedContextCoverageDir}/${filename}`
74+
75+
const content = await readFile(path, 'utf8')
76+
await rm(path)
77+
78+
return content
79+
}),
80+
)
81+
82+
for (const content of contents) {
83+
const json: { result: ScriptCoverageWithOffset[] } = JSON.parse(content)
84+
85+
for (const entry of json.result) {
86+
if (filterResult(entry)) {
87+
entry.startOffset = 0
88+
entry.isExtendedContext = true
89+
90+
result.push(entry)
91+
}
92+
}
4193
}
4294
}
4395

@@ -49,9 +101,16 @@ const mod: CoverageProviderModule = {
49101
return
50102
}
51103

104+
const session = this.session as inspector.Session
105+
106+
if (!session) {
107+
throw new Error('V8 provider missing inspector session.')
108+
}
109+
52110
await session.post('Profiler.stopPreciseCoverage')
53111
await session.post('Profiler.disable')
54112
session.disconnect()
113+
this.session = null
55114
},
56115

57116
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.autoAttachSubprocess) {
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 autoAttachSubprocess = this.options.autoAttachSubprocess
65+
4966
await this.readCoverageFiles<RawCoverage>({
5067
onFileRead(coverage) {
5168
merged = mergeProcessCovs([merged, coverage])
5269

70+
// mergeProcessCovs sometimes loses autoAttachSubprocess
71+
const fromExtendedContext = autoAttachSubprocess ? 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 (autoAttachSubprocess && !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+
autoAttachSubprocess: 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+
autoAttachSubprocess: options.autoAttachSubprocess,
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+
autoAttachSubprocess: {
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,6 +1,7 @@
11
import type { SerializedConfig } from '../../runtime/config'
22
import type { TestProject } from '../project'
33
import type { ApiConfig } from '../types/config'
4+
import { resolve } from 'node:path'
45
import { configDefaults } from '../../defaults'
56
import { isAgent } from '../../utils/env'
67

@@ -54,13 +55,14 @@ export function serializeConfig(project: TestProject): SerializedConfig {
5455
passWithNoTests: config.passWithNoTests,
5556
coverage: ((coverage) => {
5657
return {
57-
reportsDirectory: coverage.reportsDirectory,
58+
reportsDirectory: resolve(globalConfig.root, coverage.reportsDirectory),
5859
provider: coverage.provider,
5960
enabled: coverage.enabled,
6061
customProviderModule: 'customProviderModule' in coverage
6162
? coverage.customProviderModule
6263
: undefined,
6364
htmlDir: coverage.htmlDir,
65+
autoAttachSubprocess: coverage.autoAttachSubprocess ?? false,
6466
}
6567
})(config.coverage),
6668
fakeTimers: config.fakeTimers,

packages/vitest/src/node/coverage.ts

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

698698
// vite is disabled, should transform manually if possible
699-
if (config.experimental.viteModuleRunner === false) {
699+
if (config.experimental.viteModuleRunner === false || !isTransformedByVite) {
700700
const pathname = url.split('?')[0]
701701
const filename = pathname.startsWith('file://') ? fileURLToPath(pathname) : pathname
702702
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+
| 'autoAttachSubprocess'
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+
autoAttachSubprocess?: boolean
275+
267276
/**
268277
* Set to array of class method names to ignore for coverage
269278
*

0 commit comments

Comments
 (0)