Skip to content

Commit 188a95f

Browse files
committed
feat(coverage): v8 to track node:child_process and node:worker_threads contexts
1 parent 6f97b55 commit 188a95f

22 files changed

Lines changed: 379 additions & 18 deletions

packages/coverage-v8/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
"types": "./dist/browser.d.ts",
3333
"default": "./dist/browser.js"
3434
},
35+
"./intercept-new-run-context": {
36+
"types": "./dist/intercept-new-run-context.d.ts",
37+
"default": "./dist/intercept-new-run-context.js"
38+
},
3539
"./*": "./*"
3640
},
3741
"main": "./dist/index.js",
@@ -57,13 +61,15 @@
5761
"@bcoe/v8-coverage": "^1.0.2",
5862
"@vitest/utils": "workspace:*",
5963
"ast-v8-to-istanbul": "^1.0.0",
64+
"get-port-please": "catalog:",
6065
"istanbul-lib-coverage": "catalog:",
6166
"istanbul-lib-report": "catalog:",
6267
"istanbul-reports": "catalog:",
6368
"magicast": "catalog:",
6469
"obug": "catalog:",
6570
"std-env": "catalog:",
66-
"tinyrainbow": "catalog:"
71+
"tinyrainbow": "catalog:",
72+
"ws": "catalog:"
6773
},
6874
"devDependencies": {
6975
"@types/istanbul-lib-coverage": "catalog:",

packages/coverage-v8/rollup.config.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ const require = createRequire(import.meta.url)
1010
const pkg = require('./package.json')
1111

1212
const entries = {
13-
index: 'src/index.ts',
14-
browser: 'src/browser.ts',
15-
provider: 'src/provider.ts',
13+
'index': 'src/index.ts',
14+
'browser': 'src/browser.ts',
15+
'provider': 'src/provider.ts',
16+
'intercept-new-run-context': 'src/intercept-new-run-context.ts',
1617
}
1718

1819
const external = [

packages/coverage-v8/src/index.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,41 @@ import type { CoverageProviderModule } from 'vitest/node'
33
import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider'
44
import inspector from 'node:inspector/promises'
55
import { fileURLToPath } from 'node:url'
6+
import { getPort } from 'get-port-please'
67
import { normalize } from 'pathe'
78
import { provider } from 'std-env'
9+
import { WebSocketServer } from 'ws'
810
import { loadProvider } from './load-provider'
911

10-
const session = new inspector.Session()
12+
let session: inspector.Session | null = null
1113
let enabled = false
1214

13-
const mod: CoverageProviderModule = {
14-
async startCoverage({ isolate }) {
15+
const mod: CoverageProviderModule & { wss: WebSocketServer | undefined; extendedContextCoverage: Profiler.ScriptCoverage[] } = {
16+
wss: undefined,
17+
extendedContextCoverage: [],
18+
19+
async startCoverage({ isolate, trackProcessAndWorker }) {
1520
if (isolate === false && enabled) {
1621
return
1722
}
1823

1924
enabled = true
2025

26+
if (trackProcessAndWorker) {
27+
const port = await getPort()
28+
this.wss = new WebSocketServer({ port })
29+
30+
this.wss.on('connection', socket => socket.on('message', (raw) => {
31+
const result: ScriptCoverageWithOffset[] = JSON.parse(raw.toString())
32+
this.extendedContextCoverage.push(...(result || []))
33+
}))
34+
35+
process.env.NODE_OPTIONS ||= ''
36+
process.env.NODE_OPTIONS += ' --import @vitest/coverage-v8/intercept-new-run-context'
37+
process.env.VITEST_WS_PORT = `${port}`
38+
}
39+
40+
session ||= new inspector.Session()
2141
session.connect()
2242
await session.post('Profiler.enable')
2343
await session.post('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
@@ -28,11 +48,16 @@ const mod: CoverageProviderModule = {
2848
return { result: [] }
2949
}
3050

51+
if (!session) {
52+
throw new Error('V8 provider missing inspector session.')
53+
}
54+
55+
this.wss?.clients.forEach(client => client.send('take-coverage'))
3156
const coverage = await session.post('Profiler.takePreciseCoverage')
3257
const result: ScriptCoverageWithOffset[] = []
3358

3459
// Reduce amount of data sent over rpc by doing some early result filtering
35-
for (const entry of coverage.result) {
60+
for (const entry of [...coverage.result, ...this.extendedContextCoverage.splice(0)]) {
3661
if (filterResult(entry)) {
3762
result.push({
3863
...entry,
@@ -49,9 +74,14 @@ const mod: CoverageProviderModule = {
4974
return
5075
}
5176

77+
if (!session) {
78+
throw new Error('V8 provider missing inspector session.')
79+
}
80+
5281
await session.post('Profiler.stopPreciseCoverage')
5382
await session.post('Profiler.disable')
5483
session.disconnect()
84+
this.wss?.close()
5585
},
5686

5787
async getProvider(): Promise<V8CoverageProvider> {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { ScriptCoverageWithOffset } from './provider'
2+
import { WebSocket } from 'ws'
3+
import provider from './index'
4+
5+
// eslint-disable-next-line antfu/no-top-level-await -- This should be blocking module loading
6+
await initialize().catch((error) => {
7+
console.error('[vitest-coverage] Error initializing process/thread intercepting:', error)
8+
throw error
9+
})
10+
11+
async function initialize() {
12+
let reportedCoverage = false
13+
14+
const ws = new WebSocket(`ws://localhost:${Number(process.env.VITEST_WS_PORT)}`)
15+
16+
// @ts-expect-error -- untyped
17+
ws.on('open', () => ws._socket?.unref?.())
18+
19+
await provider.startCoverage?.({
20+
isolate: true,
21+
22+
// Environment options that were set by parent should inherit, no need to add more ws servers
23+
trackProcessAndWorker: false,
24+
})
25+
26+
onMessage(message => message === 'take-coverage' && takeCoverage())
27+
process.on('beforeExit', takeCoverage)
28+
29+
async function takeCoverage() {
30+
if (reportedCoverage) {
31+
return
32+
}
33+
34+
reportedCoverage = true
35+
36+
const coverage = await provider.takeCoverage?.({
37+
// Start offset should be 0 as these run outside of Vite
38+
moduleExecutionInfo: undefined,
39+
}) as { result: ScriptCoverageWithOffset[] }
40+
41+
ws.send(JSON.stringify(coverage.result.map(entry => ({ ...entry, isExtendedContext: true }))))
42+
43+
await provider.stopCoverage?.({ isolate: true })
44+
45+
ws.close()
46+
}
47+
48+
async function onMessage(callback: (message: unknown) => void) {
49+
ws.on('message', raw => callback(raw.toString()))
50+
}
51+
}

packages/coverage-v8/src/provider.ts

Lines changed: 10 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[] }
@@ -331,8 +334,9 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
331334

332335
private async getSources(
333336
url: string,
334-
onTransform: (filepath: string) => Promise<Vite.TransformResult | undefined | null>,
337+
onTransform: (filepath: string, isExtendedContext?: ScriptCoverageWithOffset['isExtendedContext']) => Promise<Vite.TransformResult | undefined | null>,
335338
functions: Profiler.FunctionCoverage[] = [],
339+
isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false,
336340
): Promise<{
337341
code: string
338342
map?: Vite.Rollup.SourceMap
@@ -342,7 +346,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
342346
? url.slice(8)
343347
: removeStartsWith(url, FILE_PROTOCOL)
344348
// TODO: do we still need to "catch" here? why would it fail?
345-
const transformResult = await onTransform(filepath).catch(() => null)
349+
const transformResult = await onTransform(filepath, isExtendedContext).catch(() => null)
346350

347351
const map = transformResult?.map as Vite.Rollup.SourceMap | undefined
348352
const code = transformResult?.code
@@ -385,8 +389,8 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
385389
throw new Error(`Cannot access browser module graph because it was torn down.`)
386390
}
387391

388-
const onTransform = async (filepath: string) => {
389-
const result = await this.transformFile(filepath, project, environment)
392+
const onTransform = async (filepath: string, isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false) => {
393+
const result = await this.transformFile(filepath, project, environment, !isExtendedContext)
390394
if (result && environment === '__browser__' && project.browser) {
391395
return { ...result, code: `${result.code}// <inline-source-map>` }
392396
}
@@ -423,7 +427,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
423427
}
424428

425429
await Promise.all(
426-
chunk.map(async ({ url, functions, startOffset }) => {
430+
chunk.map(async ({ url, functions, startOffset, isExtendedContext }) => {
427431
let timeout: ReturnType<typeof setTimeout> | undefined
428432
let start: number | undefined
429433

@@ -436,6 +440,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
436440
url,
437441
onTransform,
438442
functions,
443+
isExtendedContext,
439444
)
440445

441446
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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ export async function startCoverageInsideWorker(
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 ?? false,
16+
})
1417
}
1518

1619
return null

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function serializeConfig(project: TestProject): SerializedConfig {
5858
? coverage.customProviderModule
5959
: undefined,
6060
htmlDir: coverage.htmlDir,
61+
trackProcessAndWorker: coverage.trackProcessAndWorker ?? false,
6162
}
6263
})(config.coverage),
6364
fakeTimers: config.fakeTimers,

packages/vitest/src/node/coverage.ts

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

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

0 commit comments

Comments
 (0)