Skip to content

Commit 1f2e555

Browse files
authoredFeb 13, 2025··
fix(coverage): vite-node to pass correct execution wrapper offset (#7417)
1 parent cb48e64 commit 1f2e555

File tree

21 files changed

+193
-94
lines changed

21 files changed

+193
-94
lines changed
 

‎packages/coverage-v8/src/index.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CoverageProviderModule } from 'vitest/node'
2-
import type { V8CoverageProvider } from './provider'
2+
import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider'
33
import inspector, { type Profiler } from 'node:inspector'
4+
import { fileURLToPath } from 'node:url'
45
import { provider } from 'std-env'
56
import { loadProvider } from './load-provider'
67

@@ -23,15 +24,19 @@ export default {
2324
})
2425
},
2526

26-
takeCoverage(): Promise<{ result: Profiler.ScriptCoverage[] }> {
27+
takeCoverage(options): Promise<{ result: ScriptCoverageWithOffset[] }> {
2728
return new Promise((resolve, reject) => {
2829
session.post('Profiler.takePreciseCoverage', async (error, coverage) => {
2930
if (error) {
3031
return reject(error)
3132
}
3233

33-
// Reduce amount of data sent over rpc by doing some early result filtering
34-
const result = coverage.result.filter(filterResult)
34+
const result = coverage.result
35+
.filter(filterResult)
36+
.map(res => ({
37+
...res,
38+
startOffset: options?.moduleExecutionInfo?.get(fileURLToPath(res.url))?.startOffset || 0,
39+
}))
3540

3641
resolve({ result })
3742
})

‎packages/coverage-v8/src/provider.ts

+15-14
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ import { cleanUrl } from 'vite-node/utils'
2525
import { BaseCoverageProvider } from 'vitest/coverage'
2626
import { version } from '../package.json' with { type: 'json' }
2727

28-
type TransformResults = Map<string, FetchResult>
29-
type RawCoverage = Profiler.TakePreciseCoverageReturnType
28+
export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage {
29+
startOffset: number
30+
}
3031

31-
// TODO: vite-node should export this
32-
const WRAPPER_LENGTH = 185
32+
type TransformResults = Map<string, FetchResult>
33+
interface RawCoverage { result: ScriptCoverageWithOffset[] }
3334

3435
// Note that this needs to match the line ending as well
3536
const VITE_EXPORTS_LINE_PATTERN
@@ -69,6 +70,14 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
6970
await this.readCoverageFiles<RawCoverage>({
7071
onFileRead(coverage) {
7172
merged = mergeProcessCovs([merged, coverage])
73+
74+
// mergeProcessCovs sometimes loses startOffset, e.g. in vue
75+
merged.result.forEach((result) => {
76+
if (!result.startOffset) {
77+
const original = coverage.result.find(r => r.url === result.url)
78+
result.startOffset = original?.startOffset || 0
79+
}
80+
})
7281
},
7382
onFinished: async (project, transformMode) => {
7483
const converted = await this.convertCoverage(
@@ -230,15 +239,12 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
230239
source: string
231240
originalSource: string
232241
sourceMap?: { sourcemap: EncodedSourceMap }
233-
isExecuted: boolean
234242
}> {
235243
const filePath = normalize(fileURLToPath(url))
236244

237-
let isExecuted = true
238245
let transformResult: FetchResult | TransformResult | undefined = transformResults.get(filePath)
239246

240247
if (!transformResult) {
241-
isExecuted = false
242248
transformResult = await onTransform(removeStartsWith(url, FILE_PROTOCOL)).catch(() => undefined)
243249
}
244250

@@ -258,7 +264,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
258264
// These can be uncovered files included by "all: true" or files that are loaded outside vite-node
259265
if (!map) {
260266
return {
261-
isExecuted,
262267
source: code || sourcesContent[0],
263268
originalSource: sourcesContent[0],
264269
}
@@ -273,7 +278,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
273278
}
274279

275280
return {
276-
isExecuted,
277281
originalSource: sourcesContent[0],
278282
source: code || sourcesContent[0],
279283
sourceMap: {
@@ -338,20 +342,17 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
338342
}
339343

340344
await Promise.all(
341-
chunk.map(async ({ url, functions }) => {
345+
chunk.map(async ({ url, functions, startOffset }) => {
342346
const sources = await this.getSources(
343347
url,
344348
transformResults,
345349
onTransform,
346350
functions,
347351
)
348352

349-
// If file was executed by vite-node we'll need to add its wrapper
350-
const wrapperLength = sources.isExecuted ? WRAPPER_LENGTH : 0
351-
352353
const converter = v8ToIstanbul(
353354
url,
354-
wrapperLength,
355+
startOffset,
355356
sources,
356357
undefined,
357358
this.options.ignoreEmptyLines,

‎packages/vite-node/src/client.ts

+4
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ export class ModuleCacheMap extends Map<string, ModuleCache> {
175175
}
176176
}
177177

178+
export type ModuleExecutionInfo = Map<string, { startOffset: number }>
179+
178180
export class ViteNodeRunner {
179181
root: string
180182

@@ -505,6 +507,8 @@ export class ViteNodeRunner {
505507
columnOffset: -codeDefinition.length,
506508
}
507509

510+
this.options.moduleExecutionInfo?.set(options.filename, { startOffset: codeDefinition.length })
511+
508512
const fn = vm.runInThisContext(code, options)
509513
await fn(...Object.values(context))
510514
}

‎packages/vite-node/src/types.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { EncodedSourceMap } from '@jridgewell/trace-mapping'
22
import type { ViteHotContext } from 'vite/types/hot.js'
3-
import type { ModuleCacheMap, ViteNodeRunner } from './client'
3+
import type { ModuleCacheMap, ModuleExecutionInfo, ViteNodeRunner } from './client'
44

55
export type Nullable<T> = T | null | undefined
66
export type Arrayable<T> = T | Array<T>
@@ -87,6 +87,7 @@ export interface ViteNodeRunnerOptions {
8787
createHotContext?: CreateHotContextFunction
8888
base?: string
8989
moduleCache?: ModuleCacheMap
90+
moduleExecutionInfo?: ModuleExecutionInfo
9091
interopDefault?: boolean
9192
requestStubs?: Record<string, any>
9293
debug?: boolean
@@ -140,4 +141,4 @@ export interface DebuggerOptions {
140141
loadDumppedModules?: boolean
141142
}
142143

143-
export type { ModuleCacheMap }
144+
export type { ModuleCacheMap, ModuleExecutionInfo }

‎packages/vitest/src/integrations/coverage.ts

+7-12
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,17 @@
11
import type {
2+
CoverageModuleLoader,
23
CoverageProvider,
3-
CoverageProviderModule,
44
} from '../node/types/coverage'
55
import type { SerializedCoverageConfig } from '../runtime/config'
66

7-
interface Loader {
8-
executeId: (id: string) => Promise<{ default: CoverageProviderModule }>
9-
isBrowser?: boolean
10-
}
11-
127
export const CoverageProviderMap: Record<string, string> = {
138
v8: '@vitest/coverage-v8',
149
istanbul: '@vitest/coverage-istanbul',
1510
}
1611

1712
async function resolveCoverageProviderModule(
1813
options: SerializedCoverageConfig | undefined,
19-
loader: Loader,
14+
loader: CoverageModuleLoader,
2015
) {
2116
if (!options?.enabled || !options.provider) {
2217
return null
@@ -65,7 +60,7 @@ async function resolveCoverageProviderModule(
6560

6661
export async function getCoverageProvider(
6762
options: SerializedCoverageConfig | undefined,
68-
loader: Loader,
63+
loader: CoverageModuleLoader,
6964
): Promise<CoverageProvider | null> {
7065
const coverageModule = await resolveCoverageProviderModule(options, loader)
7166

@@ -78,7 +73,7 @@ export async function getCoverageProvider(
7873

7974
export async function startCoverageInsideWorker(
8075
options: SerializedCoverageConfig | undefined,
81-
loader: Loader,
76+
loader: CoverageModuleLoader,
8277
runtimeOptions: { isolate: boolean },
8378
) {
8479
const coverageModule = await resolveCoverageProviderModule(options, loader)
@@ -92,20 +87,20 @@ export async function startCoverageInsideWorker(
9287

9388
export async function takeCoverageInsideWorker(
9489
options: SerializedCoverageConfig | undefined,
95-
loader: Loader,
90+
loader: CoverageModuleLoader,
9691
) {
9792
const coverageModule = await resolveCoverageProviderModule(options, loader)
9893

9994
if (coverageModule) {
100-
return coverageModule.takeCoverage?.()
95+
return coverageModule.takeCoverage?.({ moduleExecutionInfo: loader.moduleExecutionInfo })
10196
}
10297

10398
return null
10499
}
105100

106101
export async function stopCoverageInsideWorker(
107102
options: SerializedCoverageConfig | undefined,
108-
loader: Loader,
103+
loader: CoverageModuleLoader,
109104
runtimeOptions: { isolate: boolean },
110105
) {
111106
const coverageModule = await resolveCoverageProviderModule(options, loader)

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

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReportOptions } from 'istanbul-reports'
22
import type { TransformResult as ViteTransformResult } from 'vite'
3+
import type { ModuleExecutionInfo } from 'vite-node'
34
import type { AfterSuiteRunMeta, Arrayable } from '../../types/general'
45
import type { Vitest } from '../core'
56

@@ -57,6 +58,12 @@ export interface ReportContext {
5758
allTestsRun?: boolean
5859
}
5960

61+
export interface CoverageModuleLoader {
62+
executeId: (id: string) => Promise<{ default: CoverageProviderModule }>
63+
isBrowser?: boolean
64+
moduleExecutionInfo?: ModuleExecutionInfo
65+
}
66+
6067
export interface CoverageProviderModule {
6168
/**
6269
* Factory for creating a new coverage provider
@@ -71,7 +78,7 @@ export interface CoverageProviderModule {
7178
/**
7279
* Executed on after each run in the worker thread. Possible to return a payload passed to the provider
7380
*/
74-
takeCoverage?: () => unknown | Promise<unknown>
81+
takeCoverage?: (runtimeOptions?: { moduleExecutionInfo?: ModuleExecutionInfo }) => unknown | Promise<unknown>
7582

7683
/**
7784
* Executed after all tests have been run in the worker thread.

‎packages/vitest/src/runtime/execute.ts

+9
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ export async function startVitestExecutor(options: ContextExecutorOptions) {
138138
get moduleCache() {
139139
return state().moduleCache
140140
},
141+
get moduleExecutionInfo() {
142+
return state().moduleExecutionInfo
143+
},
141144
get interopDefault() {
142145
return state().config.deps.interopDefault
143146
},
@@ -255,6 +258,10 @@ export class VitestExecutor extends ViteNodeRunner {
255258
return globalThis.__vitest_worker__ || this.options.state
256259
}
257260

261+
get moduleExecutionInfo() {
262+
return this.options.moduleExecutionInfo
263+
}
264+
258265
shouldResolveId(id: string, _importee?: string | undefined): boolean {
259266
if (isInternalRequest(id) || id.startsWith('data:')) {
260267
return false
@@ -313,6 +320,8 @@ export class VitestExecutor extends ViteNodeRunner {
313320
columnOffset: -codeDefinition.length,
314321
}
315322

323+
this.options.moduleExecutionInfo?.set(options.filename, { startOffset: codeDefinition.length })
324+
316325
const fn = vm.runInContext(code, vmContext, {
317326
...options,
318327
// if we encountered an import, it's not inlined

‎packages/vitest/src/runtime/worker.ts

+1
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ async function execute(method: 'run' | 'collect', ctx: ContextRPC) {
8282
ctx,
8383
// here we create a new one, workers can reassign this if they need to keep it non-isolated
8484
moduleCache: new ModuleCacheMap(),
85+
moduleExecutionInfo: new Map(),
8586
config: ctx.config,
8687
onCancel,
8788
environment,

‎packages/vitest/src/types/worker.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CancelReason, FileSpecification, Task } from '@vitest/runner'
22
import type { BirpcReturn } from 'birpc'
3-
import type { ModuleCacheMap, ViteNodeResolveId } from 'vite-node'
3+
import type { ModuleCacheMap, ModuleExecutionInfo, ViteNodeResolveId } from 'vite-node'
44
import type { SerializedConfig } from '../runtime/config'
55
import type { Environment } from './environment'
66
import type { TransformMode } from './general'
@@ -42,6 +42,7 @@ export interface WorkerGlobalState {
4242
environmentTeardownRun?: boolean
4343
onCancel: Promise<CancelReason>
4444
moduleCache: ModuleCacheMap
45+
moduleExecutionInfo?: ModuleExecutionInfo
4546
providedContext: Record<string, any>
4647
durations: {
4748
environment: number

‎packages/web-worker/src/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function createMessageEvent(
8181

8282
export function getRunnerOptions(): any {
8383
const state = getWorkerState()
84-
const { config, rpc, moduleCache } = state
84+
const { config, rpc, moduleCache, moduleExecutionInfo } = state
8585

8686
return {
8787
async fetchModule(id: string) {
@@ -96,6 +96,7 @@ export function getRunnerOptions(): any {
9696
return rpc.resolveId(id, importer, 'web')
9797
},
9898
moduleCache,
99+
moduleExecutionInfo,
99100
interopDefault: config.deps.interopDefault ?? true,
100101
moduleDirectories: config.deps.moduleDirectories,
101102
root: config.root,

‎pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/coverage-test/fixtures/src/cjs-package/entry.js

-2
This file was deleted.

‎test/coverage-test/fixtures/src/cjs-package/package.json

-5
This file was deleted.

‎test/coverage-test/fixtures/src/cjs-package/target.js

-9
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
export async function sumInBackground(a: number, b: number) {
2+
const worker = new Worker(new URL("./worker?with-some-query=123", import.meta.url), {
3+
type: "module",
4+
});
5+
6+
const promise = new Promise<MessageEvent>(resolve => {
7+
worker.onmessage = resolve;
8+
});
9+
10+
function uncovered() {
11+
return "This is uncovered"
12+
}
13+
14+
worker.postMessage({ a, b });
15+
16+
const result = await promise;
17+
covered();
18+
worker.terminate();
19+
20+
return result.data;
21+
}
22+
23+
function covered() {
24+
return "This is covered"
25+
}
26+
27+
export function uncovered() {
28+
return "This is uncovered"
29+
}
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
self.onmessage = (ev: MessageEvent) => {
2+
const {a, b} = ev.data;
3+
4+
if (a === 5) {
5+
uncovered()
6+
throw new Error("uncovered");
7+
}
8+
9+
if(b === 6) {
10+
uncovered()
11+
throw new Error("uncovered");
12+
13+
}
14+
15+
covered();
16+
postMessage(a + b);
17+
};
18+
19+
export function uncovered() {
20+
return "This is uncovered"
21+
}
22+
23+
function covered() {
24+
return "This is covered"
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { expect, test } from 'vitest';
2+
import { sumInBackground } from '../src/worker-wrapper';
3+
4+
test('run a Worker', async () => {
5+
const result = await sumInBackground(15, 7);
6+
7+
expect(result).toBe(22);
8+
});

‎test/coverage-test/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@vitest/browser": "workspace:*",
1515
"@vitest/coverage-istanbul": "workspace:*",
1616
"@vitest/coverage-v8": "workspace:*",
17+
"@vitest/web-worker": "workspace:*",
1718
"@vue/test-utils": "latest",
1819
"happy-dom": "latest",
1920
"istanbul-lib-coverage": "^3.2.0",

‎test/coverage-test/test/convert-failure.v8.test.ts

-40
This file was deleted.
+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect } from 'vitest'
2+
import { formatSummary, isV8Provider, readCoverageMap, runVitest, test } from '../utils'
3+
4+
test('web worker coverage is correct', async () => {
5+
await runVitest({
6+
setupFiles: ['@vitest/web-worker'],
7+
include: ['fixtures/test/web-worker.test.ts'],
8+
environment: 'jsdom',
9+
coverage: {
10+
include: [
11+
// Runs in web-worker's runner with custom context -> execution wrapper ~430 chars
12+
'fixtures/src/worker.ts',
13+
14+
// Runs in default runner -> execution wrapper ~185 chars
15+
'fixtures/src/worker-wrapper.ts',
16+
],
17+
reporter: 'json',
18+
},
19+
})
20+
21+
const coverageMap = await readCoverageMap()
22+
const worker = coverageMap.fileCoverageFor('<process-cwd>/fixtures/src/worker.ts')
23+
const wrapper = coverageMap.fileCoverageFor('<process-cwd>/fixtures/src/worker-wrapper.ts')
24+
25+
const summary = {
26+
[worker.path]: formatSummary(worker.toSummary()),
27+
[wrapper.path]: formatSummary(wrapper.toSummary()),
28+
}
29+
30+
// Check HTML report if these change unexpectedly
31+
if (isV8Provider()) {
32+
expect(summary).toMatchInlineSnapshot(`
33+
{
34+
"<process-cwd>/fixtures/src/worker-wrapper.ts": {
35+
"branches": "3/3 (100%)",
36+
"functions": "2/4 (50%)",
37+
"lines": "18/22 (81.81%)",
38+
"statements": "18/22 (81.81%)",
39+
},
40+
"<process-cwd>/fixtures/src/worker.ts": {
41+
"branches": "2/4 (50%)",
42+
"functions": "2/3 (66.66%)",
43+
"lines": "11/19 (57.89%)",
44+
"statements": "11/19 (57.89%)",
45+
},
46+
}
47+
`)
48+
}
49+
else {
50+
expect(summary).toMatchInlineSnapshot(`
51+
{
52+
"<process-cwd>/fixtures/src/worker-wrapper.ts": {
53+
"branches": "0/0 (100%)",
54+
"functions": "3/5 (60%)",
55+
"lines": "9/11 (81.81%)",
56+
"statements": "9/11 (81.81%)",
57+
},
58+
"<process-cwd>/fixtures/src/worker.ts": {
59+
"branches": "2/4 (50%)",
60+
"functions": "2/3 (66.66%)",
61+
"lines": "7/12 (58.33%)",
62+
"statements": "7/12 (58.33%)",
63+
},
64+
}
65+
`)
66+
}
67+
})

‎test/coverage-test/vitest.config.ts

-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { defineConfig } from 'vitest/config'
22

33
export default defineConfig({
4-
server: {
5-
watch: null,
6-
},
74
test: {
85
reporters: 'verbose',
96
isolate: false,

0 commit comments

Comments
 (0)
Please sign in to comment.