Skip to content

Commit fa35cd5

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

File tree

17 files changed

+226
-10
lines changed

17 files changed

+226
-10
lines changed

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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
// TODO: Now where do we get source maps
42+
ws.send(JSON.stringify(coverage.result))
43+
44+
await provider.stopCoverage?.({ isolate: true })
45+
46+
ws.close()
47+
}
48+
49+
async function onMessage(callback: (message: unknown) => void) {
50+
ws.on('message', raw => callback(raw.toString()))
51+
}
52+
}

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/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/types/coverage.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,14 @@ export interface CoverageOptions {
264264
*/
265265
processingConcurrency?: number
266266

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

packages/vitest/src/runtime/config.ts

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

160161
export type RuntimeConfig = Pick<

packages/vitest/src/utils/coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface RuntimeCoverageProviderModule {
1515
/**
1616
* Executed before tests are run in the worker thread.
1717
*/
18-
startCoverage?: (runtimeOptions: { isolate: boolean }) => unknown | Promise<unknown>
18+
startCoverage?: (runtimeOptions: { isolate: boolean; trackProcessAndWorker?: boolean }) => unknown | Promise<unknown>
1919

2020
/**
2121
* Executed on after each run in the worker thread. Possible to return a payload passed to the provider

0 commit comments

Comments
 (0)