Skip to content

Commit 73fb841

Browse files
committed
feat!: --merge-reports to support coverage
1 parent a4ec583 commit 73fb841

File tree

16 files changed

+172
-37
lines changed

16 files changed

+172
-37
lines changed

docs/guide/features.md

+2
Original file line numberDiff line numberDiff line change
@@ -239,3 +239,5 @@ vitest --shard=1/2 --reporter=blob
239239
vitest --shard=2/2 --reporter=blob
240240
vitest --merge-reports --reporter=junit
241241
```
242+
243+
See [`Improving Performance | Sharding`](/guide/improving-performance#sharding) for more information.

docs/guide/improving-performance.md

+4
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,7 @@ export default defineConfig({
7272
})
7373
```
7474
:::
75+
76+
## Sharding
77+
78+
TODO

packages/coverage-istanbul/src/provider.ts

+31-11
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
162162
this.pendingPromises = []
163163
}
164164

165-
async reportCoverage({ allTestsRun }: ReportContext = {}) {
165+
async generateCoverage({ allTestsRun }: ReportContext) {
166166
const coverageMap = libCoverage.createCoverageMap({})
167167
let index = 0
168168
const total = this.pendingPromises.length
@@ -202,6 +202,29 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
202202
coverageMap.merge(await transformCoverage(uncoveredCoverage))
203203
}
204204

205+
return coverageMap
206+
}
207+
208+
async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext) {
209+
await this.generateReports(
210+
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
211+
allTestsRun,
212+
)
213+
214+
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
215+
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
216+
217+
if (!keepResults) {
218+
this.coverageFiles = new Map()
219+
await fs.rm(this.coverageFilesDirectory, { recursive: true })
220+
221+
// Remove empty reports directory, e.g. when only text-reporter is used
222+
if (readdirSync(this.options.reportsDirectory).length === 0)
223+
await fs.rm(this.options.reportsDirectory, { recursive: true })
224+
}
225+
}
226+
227+
async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
205228
const context = libReport.createContext({
206229
dir: this.options.reportsDirectory,
207230
coverageMap,
@@ -248,21 +271,18 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
248271
})
249272
}
250273
}
274+
}
251275

252-
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
253-
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
276+
async mergeReports(coverageMaps: unknown[]) {
277+
const coverageMap = libCoverage.createCoverageMap({})
254278

255-
if (!keepResults) {
256-
this.coverageFiles = new Map()
257-
await fs.rm(this.coverageFilesDirectory, { recursive: true })
279+
for (const coverage of coverageMaps)
280+
coverageMap.merge(coverage as CoverageMap)
258281

259-
// Remove empty reports directory, e.g. when only text-reporter is used
260-
if (readdirSync(this.options.reportsDirectory).length === 0)
261-
await fs.rm(this.options.reportsDirectory, { recursive: true })
262-
}
282+
await this.generateReports(coverageMap, true)
263283
}
264284

265-
async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
285+
private async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
266286
const allFiles = await this.testExclude.glob(this.ctx.config.root)
267287
let includedFiles = allFiles.map(file => resolve(this.ctx.config.root, file))
268288

packages/coverage-v8/src/provider.ts

+33-13
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
148148
this.pendingPromises.push(promise)
149149
}
150150

151-
async reportCoverage({ allTestsRun }: ReportContext = {}) {
152-
if (provider === 'stackblitz')
153-
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'))
154-
151+
async generateCoverage({ allTestsRun }: ReportContext) {
155152
const coverageMap = libCoverage.createCoverageMap({})
156153
let index = 0
157154
const total = this.pendingPromises.length
@@ -193,6 +190,32 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
193190
coverageMap.merge(await transformCoverage(converted))
194191
}
195192

193+
return coverageMap
194+
}
195+
196+
async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext) {
197+
if (provider === 'stackblitz')
198+
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'))
199+
200+
await this.generateReports(
201+
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
202+
allTestsRun,
203+
)
204+
205+
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
206+
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
207+
208+
if (!keepResults) {
209+
this.coverageFiles = new Map()
210+
await fs.rm(this.coverageFilesDirectory, { recursive: true })
211+
212+
// Remove empty reports directory, e.g. when only text-reporter is used
213+
if (readdirSync(this.options.reportsDirectory).length === 0)
214+
await fs.rm(this.options.reportsDirectory, { recursive: true })
215+
}
216+
}
217+
218+
async generateReports(coverageMap: CoverageMap, allTestsRun?: boolean) {
196219
const context = libReport.createContext({
197220
dir: this.options.reportsDirectory,
198221
coverageMap,
@@ -239,18 +262,15 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
239262
})
240263
}
241264
}
265+
}
242266

243-
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
244-
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
267+
async mergeReports(coverageMaps: unknown[]) {
268+
const coverageMap = libCoverage.createCoverageMap({})
245269

246-
if (!keepResults) {
247-
this.coverageFiles = new Map()
248-
await fs.rm(this.coverageFilesDirectory, { recursive: true })
270+
for (const coverage of coverageMaps)
271+
coverageMap.merge(coverage as CoverageMap)
249272

250-
// Remove empty reports directory, e.g. when only text-reporter is used
251-
if (readdirSync(this.options.reportsDirectory).length === 0)
252-
await fs.rm(this.options.reportsDirectory, { recursive: true })
253-
}
273+
await this.generateReports(coverageMap, true)
254274
}
255275

256276
private async getUntestedFiles(testedFiles: string[]): Promise<RawCoverage> {

packages/vitest/src/node/core.ts

+12-6
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ export class Vitest {
393393
if (this.reporters.some(r => r instanceof BlobReporter))
394394
throw new Error('Cannot merge reports when `--reporter=blob` is used. Remove blob reporter from the config first.')
395395

396-
const { files, errors } = await readBlobs(this.config.mergeReports, this.projects)
396+
const { files, errors, coverages } = await readBlobs(this.config.mergeReports, this.projects)
397397

398398
await this.report('onInit', this)
399399
await this.report('onPathsCollected', files.flatMap(f => f.filepath))
@@ -439,6 +439,8 @@ export class Vitest {
439439
process.exitCode = 1
440440

441441
await this.report('onFinished', files, errors)
442+
await this.initCoverageProvider()
443+
await this.coverageProvider?.mergeReports?.(coverages)
442444
}
443445

444446
async start(filters?: string[]) {
@@ -459,7 +461,9 @@ export class Vitest {
459461

460462
// if run with --changed, don't exit if no tests are found
461463
if (!files.length) {
462-
await this.reportCoverage(true)
464+
// Report coverage for uncovered files
465+
const coverage = await this.coverageProvider?.generateCoverage?.({ allTestsRun: true })
466+
await this.reportCoverage(coverage, true)
463467

464468
this.logger.printNoTestFound(filters)
465469

@@ -645,8 +649,10 @@ export class Vitest {
645649
.finally(async () => {
646650
// can be duplicate files if different projects are using the same file
647651
const files = Array.from(new Set(specs.map(([, p]) => p)))
648-
await this.report('onFinished', this.state.getFiles(files), this.state.getUnhandledErrors())
649-
await this.reportCoverage(allTestsRun)
652+
const coverage = await this.coverageProvider?.generateCoverage({ allTestsRun })
653+
654+
await this.report('onFinished', this.state.getFiles(files), this.state.getUnhandledErrors(), coverage)
655+
await this.reportCoverage(coverage, allTestsRun)
650656

651657
this.runningPromise = undefined
652658
this.isFirstRun = false
@@ -946,12 +952,12 @@ export class Vitest {
946952
return Array.from(new Set(files))
947953
}
948954

949-
private async reportCoverage(allTestsRun: boolean) {
955+
private async reportCoverage(coverage: unknown, allTestsRun: boolean) {
950956
if (!this.config.coverage.reportOnFailure && this.state.getCountOfFailedTests() > 0)
951957
return
952958

953959
if (this.coverageProvider) {
954-
await this.coverageProvider.reportCoverage({ allTestsRun })
960+
await this.coverageProvider.reportCoverage(coverage, { allTestsRun })
955961
// notify coverage iframe reload
956962
for (const reporter of this.reporters) {
957963
if (reporter instanceof WebSocketReporter)

packages/vitest/src/node/reporters/blob.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class BlobReporter implements Reporter {
2626
this.ctx = ctx
2727
}
2828

29-
async onFinished(files: File[] = [], errors: unknown[] = []) {
29+
async onFinished(files: File[] = [], errors: unknown[] = [], coverage: unknown) {
3030
let outputFile = this.options.outputFile ?? getOutputFile(this.ctx.config, 'blob')
3131
if (!outputFile) {
3232
const shard = this.ctx.config.shard
@@ -39,7 +39,7 @@ export class BlobReporter implements Reporter {
3939
return [project.getName(), [...project.server.moduleGraph.idToModuleMap.keys()]]
4040
})
4141

42-
const report = stringify([this.ctx.version, files, errors, moduleKeys] satisfies MergeReport)
42+
const report = stringify([this.ctx.version, files, errors, moduleKeys, coverage] satisfies MergeReport)
4343

4444
const reportFile = resolve(this.ctx.config.root, outputFile)
4545

@@ -62,8 +62,8 @@ export async function readBlobs(blobsDirectory: string, projectsArray: Workspace
6262
const blobsFiles = await readdir(resolvedDir)
6363
const promises = blobsFiles.map(async (file) => {
6464
const content = await readFile(resolve(resolvedDir, file), 'utf-8')
65-
const [version, files, errors, moduleKeys] = parse(content) as MergeReport
66-
return { version, files, errors, moduleKeys }
65+
const [version, files, errors, moduleKeys, coverage] = parse(content) as MergeReport
66+
return { version, files, errors, moduleKeys, coverage }
6767
})
6868
const blobs = await Promise.all(promises)
6969

@@ -108,10 +108,12 @@ export async function readBlobs(blobsDirectory: string, projectsArray: Workspace
108108
return time1 - time2
109109
})
110110
const errors = blobs.flatMap(blob => blob.errors)
111+
const coverages = blobs.map(blob => blob.coverage)
111112

112113
return {
113114
files,
114115
errors,
116+
coverages,
115117
}
116118
}
117119

@@ -120,6 +122,7 @@ type MergeReport = [
120122
files: File[],
121123
errors: unknown[],
122124
moduleKeys: MergeReportModuleKeys[],
125+
coverage: unknown,
123126
]
124127

125128
type MergeReportModuleKeys = [projectName: string, moduleIds: string[]]

packages/vitest/src/types/coverage.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,33 @@ import type { Arrayable } from './general'
55
import type { AfterSuiteRunMeta } from './worker'
66

77
type TransformResult = string | Partial<ViteTransformResult> | undefined | null | void
8+
type CoverageResults = unknown
89

910
export interface CoverageProvider {
1011
name: string
12+
13+
/** Called when provider is being initialized before tests run */
1114
initialize: (ctx: Vitest) => Promise<void> | void
1215

16+
/** Called when setting coverage options for Vitest context (`ctx.config.coverage`) */
1317
resolveOptions: () => ResolvedCoverageOptions
18+
19+
/** Callback to clean previous reports */
1420
clean: (clean?: boolean) => void | Promise<void>
1521

22+
/** Called with coverage results after a single test file has been run */
1623
onAfterSuiteRun: (meta: AfterSuiteRunMeta) => void | Promise<void>
1724

18-
reportCoverage: (reportContext?: ReportContext) => void | Promise<void>
25+
/** Callback to generate final coverage results */
26+
generateCoverage: (reportContext: ReportContext) => CoverageResults | Promise<CoverageResults>
27+
28+
/** Callback to convert coverage results to coverage reports. Called with results returned from `generateCoverage` */
29+
reportCoverage: (coverage: CoverageResults, reportContext: ReportContext) => void | Promise<void>
30+
31+
/** Callback for `--merge-reports` options. Called with multiple coverage results generated by `generateCoverage`. */
32+
mergeReports?: (coverages: CoverageResults[]) => void | Promise<void>
1933

34+
/** Callback called for instrumenting files with coverage counters. */
2035
onFileTransform?: (
2136
sourceCode: string,
2237
id: string,

packages/vitest/src/types/reporter.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface Reporter {
77
onPathsCollected?: (paths?: string[]) => Awaitable<void>
88
onSpecsCollected?: (specs?: SerializableSpec[]) => Awaitable<void>
99
onCollected?: (files?: File[]) => Awaitable<void>
10-
onFinished?: (files?: File[], errors?: unknown[]) => Awaitable<void>
10+
onFinished?: (files?: File[], errors?: unknown[], coverage?: unknown) => Awaitable<void>
1111
onTaskUpdate?: (packs: TaskResultPack[]) => Awaitable<void>
1212

1313
onTestRemoved?: (trigger?: string) => Awaitable<void>

test/cli/test/create-vitest.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ it(createVitest, async () => {
2424
},
2525
],
2626
[],
27+
undefined,
2728
])
2829
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { test } from 'vitest'
2+
import { readCoverageJson } from '../coverage-report-tests/utils'
3+
4+
test('reports are merged', async () => {
5+
const json = await readCoverageJson('./coverage/coverage-final.json')
6+
7+
// TODO: Verify json
8+
console.log(json)
9+
})

test/coverage-test/custom-provider.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ class CustomCoverageProvider implements CoverageProvider {
6969
}))
7070
}
7171

72-
reportCoverage(reportContext?: ReportContext) {
72+
generateCoverage(_reportContext: ReportContext) {
73+
return {}
74+
}
75+
76+
reportCoverage(coverage: unknown, reportContext?: ReportContext) {
7377
this.calls.add(`reportCoverage with ${JSON.stringify(reportContext)}`)
7478

7579
const jsonReport = JSON.stringify({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { test } from 'vitest'
2+
import { add } from '../src/utils'
3+
4+
test('cover add', () => {
5+
add(1, 2)
6+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { test } from 'vitest'
2+
import { multiply } from '../src/utils'
3+
4+
test('cover multiply', () => {
5+
multiply(1, 2)
6+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { test } from 'vitest'
2+
import { multiply } from '../src/utils'
3+
import { useImportEnv } from '../src/importEnv'
4+
5+
test('cover multiply again', () => {
6+
multiply(1, 2)
7+
})
8+
9+
test('also cover another file', () => {
10+
useImportEnv()
11+
})

test/coverage-test/test/configuration-options.test-d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ test('provider module', () => {
106106
return {
107107
name: 'custom-provider',
108108
initialize(_: Vitest) {},
109+
generateCoverage() {},
109110
resolveOptions(): ResolvedCoverageOptions {
110111
return {
111112
clean: true,

0 commit comments

Comments
 (0)