Skip to content

Commit 28109cc

Browse files
authored
fix!(coverage): use transformMode and workspace project based source maps (#4309)
1 parent fde1843 commit 28109cc

File tree

28 files changed

+1568
-72
lines changed

28 files changed

+1568
-72
lines changed

packages/browser/src/client/runner.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,14 @@ export function createBrowserRunner(original: any, coverageModule: CoverageHandl
4242
async onAfterRunFiles() {
4343
await super.onAfterRun?.()
4444
const coverage = await coverageModule?.takeCoverage?.()
45-
if (coverage)
46-
await rpc().onAfterSuiteRun({ coverage })
45+
46+
if (coverage) {
47+
await rpc().onAfterSuiteRun({
48+
coverage,
49+
transformMode: 'web',
50+
projectName: this.config.name,
51+
})
52+
}
4753
}
4854

4955
onCollected(files: File[]): unknown {

packages/coverage-istanbul/src/provider.ts

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BaseCoverageProvider } from 'vitest/coverage'
66
import c from 'picocolors'
77
import libReport from 'istanbul-lib-report'
88
import reports from 'istanbul-reports'
9-
import type { CoverageMap } from 'istanbul-lib-coverage'
9+
import type { CoverageMap, CoverageMapData } from 'istanbul-lib-coverage'
1010
import libCoverage from 'istanbul-lib-coverage'
1111
import libSourceMaps from 'istanbul-lib-source-maps'
1212
import { type Instrumenter, createInstrumenter } from 'istanbul-lib-instrument'
@@ -16,6 +16,8 @@ import _TestExclude from 'test-exclude'
1616
import { COVERAGE_STORE_KEY } from './constants'
1717

1818
type Options = ResolvedCoverageOptions<'istanbul'>
19+
type CoverageByTransformMode = Record<AfterSuiteRunMeta['transformMode'], CoverageMapData[]>
20+
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT
1921

2022
interface TestExclude {
2123
new(opts: {
@@ -31,6 +33,8 @@ interface TestExclude {
3133
}
3234
}
3335

36+
const DEFAULT_PROJECT = Symbol.for('default-project')
37+
3438
export class IstanbulCoverageProvider extends BaseCoverageProvider implements CoverageProvider {
3539
name = 'istanbul'
3640

@@ -45,7 +49,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
4549
* If storing in memory causes issues, we can simply write these into fs in `onAfterSuiteRun`
4650
* and read them back when merging coverage objects in `onAfterAllFilesRun`.
4751
*/
48-
coverages: any[] = []
52+
coverages = new Map<ProjectName, CoverageByTransformMode>()
4953

5054
initialize(ctx: Vitest) {
5155
const config: CoverageIstanbulOptions = ctx.config.coverage
@@ -106,36 +110,52 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
106110
return { code, map }
107111
}
108112

109-
onAfterSuiteRun({ coverage }: AfterSuiteRunMeta) {
110-
this.coverages.push(coverage)
113+
/*
114+
* Coverage and meta information passed from Vitest runners.
115+
* Note that adding new entries here and requiring on those without
116+
* backwards compatibility is a breaking change.
117+
*/
118+
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
119+
if (transformMode !== 'web' && transformMode !== 'ssr')
120+
throw new Error(`Invalid transform mode: ${transformMode}`)
121+
122+
let entry = this.coverages.get(projectName || DEFAULT_PROJECT)
123+
124+
if (!entry) {
125+
entry = { web: [], ssr: [] }
126+
this.coverages.set(projectName || DEFAULT_PROJECT, entry)
127+
}
128+
129+
entry[transformMode].push(coverage as CoverageMapData)
111130
}
112131

113132
async clean(clean = true) {
114133
if (clean && existsSync(this.options.reportsDirectory))
115134
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })
116135

117-
this.coverages = []
136+
this.coverages = new Map()
118137
}
119138

120139
async reportCoverage({ allTestsRun }: ReportContext = {}) {
121-
const mergedCoverage: CoverageMap = this.coverages.reduce((coverage, previousCoverageMap) => {
122-
const map = libCoverage.createCoverageMap(coverage)
123-
map.merge(previousCoverageMap)
124-
return map
125-
}, libCoverage.createCoverageMap({}))
126-
127-
if (this.options.all && allTestsRun)
128-
await this.includeUntestedFiles(mergedCoverage)
129-
130-
includeImplicitElseBranches(mergedCoverage)
140+
const coverageMaps = await Promise.all(
141+
Array.from(this.coverages.values()).map(coverages => [
142+
mergeAndTransformCoverage(coverages.ssr),
143+
mergeAndTransformCoverage(coverages.web),
144+
]).flat(),
145+
)
146+
147+
if (this.options.all && allTestsRun) {
148+
const coveredFiles = coverageMaps.map(map => map.files()).flat()
149+
const uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles)
150+
151+
coverageMaps.push(await mergeAndTransformCoverage([uncoveredCoverage]))
152+
}
131153

132-
const sourceMapStore = libSourceMaps.createSourceMapStore()
133-
const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage)
154+
const coverageMap = mergeCoverageMaps(...coverageMaps)
134155

135156
const context = libReport.createContext({
136157
dir: this.options.reportsDirectory,
137158
coverageMap,
138-
sourceFinder: sourceMapStore.sourceFinder,
139159
watermarks: this.options.watermarks,
140160
})
141161

@@ -181,19 +201,21 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
181201
}
182202
}
183203

184-
async includeUntestedFiles(coverageMap: CoverageMap) {
204+
async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
185205
// Load, instrument and collect empty coverages from all files which
186206
// are not already in the coverage map
187207
const includedFiles = await this.testExclude.glob(this.ctx.config.root)
188208
const uncoveredFiles = includedFiles
189209
.map(file => resolve(this.ctx.config.root, file))
190-
.filter(file => !coverageMap.data[file])
210+
.filter(file => !coveredFiles.includes(file))
191211

192212
const transformResults = await Promise.all(uncoveredFiles.map(async (filename) => {
193213
const transformResult = await this.ctx.vitenode.transformRequest(filename)
194214
return { transformResult, filename }
195215
}))
196216

217+
const coverageMap = libCoverage.createCoverageMap({})
218+
197219
for (const { transformResult, filename } of transformResults) {
198220
const sourceMap = transformResult?.map
199221

@@ -209,9 +231,27 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
209231
coverageMap.addFileCoverage(lastCoverage)
210232
}
211233
}
234+
235+
return coverageMap.data
212236
}
213237
}
214238

239+
async function mergeAndTransformCoverage(coverages: CoverageMapData[]) {
240+
const mergedCoverage = mergeCoverageMaps(...coverages)
241+
includeImplicitElseBranches(mergedCoverage)
242+
243+
const sourceMapStore = libSourceMaps.createSourceMapStore()
244+
return await sourceMapStore.transformCoverage(mergedCoverage)
245+
}
246+
247+
function mergeCoverageMaps(...coverageMaps: (CoverageMap | CoverageMapData)[]) {
248+
return coverageMaps.reduce<CoverageMap>((coverage, previousCoverageMap) => {
249+
const map = libCoverage.createCoverageMap(coverage)
250+
map.merge(previousCoverageMap)
251+
return map
252+
}, libCoverage.createCoverageMap({}))
253+
}
254+
215255
/**
216256
* Remove possible query parameters from filenames
217257
* - From `/src/components/Header.component.ts?vue&type=script&src=true&lang.ts`

packages/coverage-v8/src/provider.ts

Lines changed: 79 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import v8ToIstanbul from 'v8-to-istanbul'
55
import { mergeProcessCovs } from '@bcoe/v8-coverage'
66
import libReport from 'istanbul-lib-report'
77
import reports from 'istanbul-reports'
8-
import type { CoverageMap } from 'istanbul-lib-coverage'
8+
import type { CoverageMap, CoverageMapData } from 'istanbul-lib-coverage'
99
import libCoverage from 'istanbul-lib-coverage'
1010
import libSourceMaps from 'istanbul-lib-source-maps'
1111
import MagicString from 'magic-string'
@@ -39,20 +39,24 @@ interface TestExclude {
3939

4040
type Options = ResolvedCoverageOptions<'v8'>
4141
type TransformResults = Map<string, FetchResult>
42+
type RawCoverage = Profiler.TakePreciseCoverageReturnType
43+
type CoverageByTransformMode = Record<AfterSuiteRunMeta['transformMode'], RawCoverage[]>
44+
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT
4245

4346
// TODO: vite-node should export this
4447
const WRAPPER_LENGTH = 185
4548

4649
// Note that this needs to match the line ending as well
4750
const VITE_EXPORTS_LINE_PATTERN = /Object\.defineProperty\(__vite_ssr_exports__.*\n/g
51+
const DEFAULT_PROJECT = Symbol.for('default-project')
4852

4953
export class V8CoverageProvider extends BaseCoverageProvider implements CoverageProvider {
5054
name = 'v8'
5155

5256
ctx!: Vitest
5357
options!: Options
5458
testExclude!: InstanceType<TestExclude>
55-
coverages: Profiler.TakePreciseCoverageReturnType[] = []
59+
coverages = new Map<ProjectName, CoverageByTransformMode>()
5660

5761
initialize(ctx: Vitest) {
5862
const config: CoverageV8Options = ctx.config.coverage
@@ -92,54 +96,52 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
9296
if (clean && existsSync(this.options.reportsDirectory))
9397
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })
9498

95-
this.coverages = []
99+
this.coverages = new Map()
96100
}
97101

98-
onAfterSuiteRun({ coverage }: AfterSuiteRunMeta) {
99-
this.coverages.push(coverage as Profiler.TakePreciseCoverageReturnType)
102+
/*
103+
* Coverage and meta information passed from Vitest runners.
104+
* Note that adding new entries here and requiring on those without
105+
* backwards compatibility is a breaking change.
106+
*/
107+
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
108+
if (transformMode !== 'web' && transformMode !== 'ssr')
109+
throw new Error(`Invalid transform mode: ${transformMode}`)
110+
111+
let entry = this.coverages.get(projectName || DEFAULT_PROJECT)
112+
113+
if (!entry) {
114+
entry = { web: [], ssr: [] }
115+
this.coverages.set(projectName || DEFAULT_PROJECT, entry)
116+
}
117+
118+
entry[transformMode].push(coverage as RawCoverage)
100119
}
101120

102121
async reportCoverage({ allTestsRun }: ReportContext = {}) {
103122
if (provider === 'stackblitz')
104123
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'))
105124

106-
const transformResults = normalizeTransformResults(this.ctx.projects.map(project => project.vitenode.fetchCache))
107-
const merged = mergeProcessCovs(this.coverages)
108-
const scriptCoverages = merged.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url)))
125+
const coverageMaps = await Promise.all(
126+
Array.from(this.coverages.entries()).map(([projectName, coverages]) => [
127+
this.mergeAndTransformCoverage(coverages.ssr, projectName, 'ssr'),
128+
this.mergeAndTransformCoverage(coverages.web, projectName, 'web'),
129+
]).flat(),
130+
)
109131

110132
if (this.options.all && allTestsRun) {
111-
const coveredFiles = Array.from(scriptCoverages.map(r => r.url))
112-
const untestedFiles = await this.getUntestedFiles(coveredFiles, transformResults)
133+
const coveredFiles = coverageMaps.map(map => map.files()).flat()
134+
const untestedCoverage = await this.getUntestedFiles(coveredFiles)
135+
const untestedCoverageResults = untestedCoverage.map(files => ({ result: [files] }))
113136

114-
scriptCoverages.push(...untestedFiles)
137+
coverageMaps.push(await this.mergeAndTransformCoverage(untestedCoverageResults))
115138
}
116139

117-
const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => {
118-
const sources = await this.getSources(url, transformResults, functions)
119-
120-
// If no source map was found from vite-node we can assume this file was not run in the wrapper
121-
const wrapperLength = sources.sourceMap ? WRAPPER_LENGTH : 0
122-
123-
const converter = v8ToIstanbul(url, wrapperLength, sources)
124-
await converter.load()
125-
126-
converter.applyCoverage(functions)
127-
return converter.toIstanbul()
128-
}))
129-
130-
const mergedCoverage = converted.reduce((coverage, previousCoverageMap) => {
131-
const map = libCoverage.createCoverageMap(coverage)
132-
map.merge(previousCoverageMap)
133-
return map
134-
}, libCoverage.createCoverageMap({}))
135-
136-
const sourceMapStore = libSourceMaps.createSourceMapStore()
137-
const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage)
140+
const coverageMap = mergeCoverageMaps(...coverageMaps)
138141

139142
const context = libReport.createContext({
140143
dir: this.options.reportsDirectory,
141144
coverageMap,
142-
sourceFinder: sourceMapStore.sourceFinder,
143145
watermarks: this.options.watermarks,
144146
})
145147

@@ -185,11 +187,13 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
185187
}
186188
}
187189

188-
private async getUntestedFiles(testedFiles: string[], transformResults: TransformResults): Promise<Profiler.ScriptCoverage[]> {
190+
private async getUntestedFiles(testedFiles: string[]): Promise<Profiler.ScriptCoverage[]> {
191+
const transformResults = normalizeTransformResults(this.ctx.vitenode.fetchCache)
192+
189193
const includedFiles = await this.testExclude.glob(this.ctx.config.root)
190194
const uncoveredFiles = includedFiles
191195
.map(file => pathToFileURL(resolve(this.ctx.config.root, file)))
192-
.filter(file => !testedFiles.includes(file.href))
196+
.filter(file => !testedFiles.includes(file.pathname))
193197

194198
return await Promise.all(uncoveredFiles.map(async (uncoveredFile) => {
195199
const { source } = await this.getSources(uncoveredFile.href, transformResults)
@@ -247,6 +251,41 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
247251
},
248252
}
249253
}
254+
255+
private async mergeAndTransformCoverage(coverages: RawCoverage[], projectName?: ProjectName, transformMode?: 'web' | 'ssr') {
256+
const viteNode = this.ctx.projects.find(project => project.getName() === projectName)?.vitenode || this.ctx.vitenode
257+
const fetchCache = transformMode ? viteNode.fetchCaches[transformMode] : viteNode.fetchCache
258+
const transformResults = normalizeTransformResults(fetchCache)
259+
260+
const merged = mergeProcessCovs(coverages)
261+
const scriptCoverages = merged.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url)))
262+
263+
const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => {
264+
const sources = await this.getSources(url, transformResults, functions)
265+
266+
// If no source map was found from vite-node we can assume this file was not run in the wrapper
267+
const wrapperLength = sources.sourceMap ? WRAPPER_LENGTH : 0
268+
269+
const converter = v8ToIstanbul(url, wrapperLength, sources)
270+
await converter.load()
271+
272+
converter.applyCoverage(functions)
273+
return converter.toIstanbul()
274+
}))
275+
276+
const mergedCoverage = mergeCoverageMaps(...converted)
277+
278+
const sourceMapStore = libSourceMaps.createSourceMapStore()
279+
return sourceMapStore.transformCoverage(mergedCoverage)
280+
}
281+
}
282+
283+
function mergeCoverageMaps(...coverageMaps: (CoverageMap | CoverageMapData)[]) {
284+
return coverageMaps.reduce<CoverageMap>((coverage, previousCoverageMap) => {
285+
const map = libCoverage.createCoverageMap(coverage)
286+
map.merge(previousCoverageMap)
287+
return map
288+
}, libCoverage.createCoverageMap({}))
250289
}
251290

252291
/**
@@ -284,16 +323,14 @@ function findLongestFunctionLength(functions: Profiler.FunctionCoverage[]) {
284323
}, 0)
285324
}
286325

287-
function normalizeTransformResults(fetchCaches: Map<string, { result: FetchResult }>[]) {
326+
function normalizeTransformResults(fetchCache: Map<string, { result: FetchResult }>) {
288327
const normalized: TransformResults = new Map()
289328

290-
for (const fetchCache of fetchCaches) {
291-
for (const [key, value] of fetchCache.entries()) {
292-
const cleanEntry = cleanUrl(key)
329+
for (const [key, value] of fetchCache.entries()) {
330+
const cleanEntry = cleanUrl(key)
293331

294-
if (!normalized.has(cleanEntry))
295-
normalized.set(cleanEntry, value.result)
296-
}
332+
if (!normalized.has(cleanEntry))
333+
normalized.set(cleanEntry, value.result)
297334
}
298335

299336
return normalized

packages/vitest/src/node/pools/child.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath }
100100
invalidates,
101101
environment,
102102
workerId,
103+
projectName: project.getName(),
103104
}
104105
try {
105106
await pool.run(data, { name, channel })

packages/vitest/src/node/pools/threads.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po
8888
invalidates,
8989
environment,
9090
workerId,
91+
projectName: project.getName(),
9192
}
9293
try {
9394
await pool.run(data, { transferList: [workerPort], name })

packages/vitest/src/node/pools/vm-threads.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool
9595
invalidates,
9696
environment,
9797
workerId,
98+
projectName: project.getName(),
9899
}
99100
try {
100101
await pool.run(data, { transferList: [workerPort], name })

0 commit comments

Comments
 (0)