Skip to content

Commit 90a9848

Browse files
committed
feat: --merge-reports to support coverage
1 parent a4ec583 commit 90a9848

File tree

9 files changed

+149
-29
lines changed

9 files changed

+149
-29
lines changed

docs/guide/cli.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ If `--reporter=blob` is used without an output file, the default path will inclu
121121

122122
- **Type:** `boolean | string`
123123

124-
Merges every blob report located in the specified folder (`.vitest-reports` by default). You can use any reporters with this command (except [`blob`](/guide/reporters#blob-reporter)):
124+
Merges every test and coverage blob report located in the specified folder (`.vitest-reports` by default). You can use any reporters with this command (except [`blob`](/guide/reporters#blob-reporter)):
125125

126126
```sh
127127
vitest --merge-reports --reporter=junit

docs/guide/coverage.md

+23
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,29 @@ export default defineConfig({
183183
})
184184
```
185185

186+
## Blob Reporter
187+
188+
Stores coverage results on the machine so they can be later merged using [`--merge-reports`](/guide/cli#merge-reports) command.
189+
By default, stores all results in `.vitest-reports/coverage` folder, but can be overriden with `--coverage.reporter.blob.dir` and `--coverage.reporter.blob.file` flags.
190+
191+
```bash
192+
npx vitest --coverage.reporter=blob
193+
npx vitest --coverage.reporter=blob --coverage.reporter.blob.dir=reports
194+
npx vitest --coverage.reporter=blob --coverage.reporter.blob.file=coverage-blob-1.json
195+
npx vitest --coverage.reporter=blob --coverage.reporter.blob.dir=reports --coverage.reporter.blob.file=coverage-blob-1.json
196+
```
197+
198+
::: tip
199+
Coverage blob reporter uses `--coverage.reporter.blob.dir` instead of `--coverage.reportsDirectory` for storing the blob reports
200+
:::
201+
202+
We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag.
203+
All coverage blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline:
204+
205+
```bash
206+
npx vitest --merge-reports=reports --coverage.reporter=text
207+
```
208+
186209
## Ignoring Code
187210

188211
Both coverage providers have their own ways how to ignore code from coverage reports:

docs/guide/features.md

+4-4
Original file line numberDiff line numberDiff line change
@@ -231,11 +231,11 @@ test('my types work properly', () => {
231231

232232
## Sharding
233233

234-
Run tests on different machines using [`--shard`](/guide/cli#shard) and [`--reporter=blob`](/guide/reporters#blob-reporter) flags.
234+
Run tests on different machines using [`--shard`](/guide/cli#shard), [`--reporter=blob`](/guide/reporters#blob-reporter) and [`--coverage.reporter=blob`](/guide/coverage#blob-reporter) flags.
235235
All test results can be merged at the end of your CI pipeline using `--merge-reports` command:
236236

237237
```bash
238-
vitest --shard=1/2 --reporter=blob
239-
vitest --shard=2/2 --reporter=blob
240-
vitest --merge-reports --reporter=junit
238+
vitest --shard=1/2 --reporter=blob --coverage.reporter=blob
239+
vitest --shard=2/2 --reporter=blob --coverage.reporter=blob
240+
vitest --merge-reports --reporter=junit --coverage.reporter=text
241241
```

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

+50-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
2+
import { basename, dirname } from 'node:path'
23
import { resolve } from 'pathe'
34
import type { AfterSuiteRunMeta, CoverageIstanbulOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest'
45
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
@@ -202,6 +203,22 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
202203
coverageMap.merge(await transformCoverage(uncoveredCoverage))
203204
}
204205

206+
await this.generateReports(coverageMap, allTestsRun)
207+
208+
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
209+
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
210+
211+
if (!keepResults) {
212+
this.coverageFiles = new Map()
213+
await fs.rm(this.coverageFilesDirectory, { recursive: true })
214+
215+
// Remove empty reports directory, e.g. when only text-reporter is used
216+
if (readdirSync(this.options.reportsDirectory).length === 0)
217+
await fs.rm(this.options.reportsDirectory, { recursive: true })
218+
}
219+
}
220+
221+
async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
205222
const context = libReport.createContext({
206223
dir: this.options.reportsDirectory,
207224
coverageMap,
@@ -212,6 +229,11 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
212229
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
213230

214231
for (const reporter of this.options.reporter) {
232+
if (reporter[0] === 'blob') {
233+
await this.createBlobReport(coverageMap, reporter[1])
234+
continue
235+
}
236+
215237
// Type assertion required for custom reporters
216238
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
217239
skipFull: this.options.skipFull,
@@ -248,21 +270,40 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
248270
})
249271
}
250272
}
273+
}
251274

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
275+
async mergeReports(path: string) {
276+
const directory = resolve(path, 'coverage')
277+
const files = await fs.readdir(directory)
278+
const coverageMap = libCoverage.createCoverageMap({})
254279

255-
if (!keepResults) {
256-
this.coverageFiles = new Map()
257-
await fs.rm(this.coverageFilesDirectory, { recursive: true })
280+
for (const file of files) {
281+
const report = await fs.readFile(resolve(directory, file), 'utf8')
282+
coverageMap.merge(JSON.parse(report))
283+
}
258284

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 })
285+
await this.generateReports(coverageMap, true)
286+
}
287+
288+
async createBlobReport(coverageMap: CoverageMap, options: Options['reporter'][number][1]) {
289+
const outputFile = 'outputFile' in options && options.outputFile as string
290+
const dir = outputFile ? dirname(outputFile) : '.vitest-reports/coverage'
291+
let file = outputFile && basename(outputFile)
292+
293+
if (!file) {
294+
const shard = this.ctx.config.shard
295+
file = shard
296+
? `.coverage-blob-${shard.index}-${shard.count}.json`
297+
: '.coverage-blob.json'
262298
}
299+
300+
const context = libReport.createContext({ dir, coverageMap })
301+
reports.create('json', { file }).execute(context)
302+
303+
this.ctx.logger.log('coverage blob report written to', resolve(dir, file))
263304
}
264305

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

packages/coverage-v8/src/provider.ts

+49-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
22
import type { Profiler } from 'node:inspector'
33
import { fileURLToPath, pathToFileURL } from 'node:url'
4+
import { basename, dirname } from 'node:path'
45
import v8ToIstanbul from 'v8-to-istanbul'
56
import { mergeProcessCovs } from '@bcoe/v8-coverage'
67
import libReport from 'istanbul-lib-report'
@@ -193,6 +194,22 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
193194
coverageMap.merge(await transformCoverage(converted))
194195
}
195196

197+
await this.generateReports(coverageMap, allTestsRun)
198+
199+
// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
200+
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
201+
202+
if (!keepResults) {
203+
this.coverageFiles = new Map()
204+
await fs.rm(this.coverageFilesDirectory, { recursive: true })
205+
206+
// Remove empty reports directory, e.g. when only text-reporter is used
207+
if (readdirSync(this.options.reportsDirectory).length === 0)
208+
await fs.rm(this.options.reportsDirectory, { recursive: true })
209+
}
210+
}
211+
212+
async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
196213
const context = libReport.createContext({
197214
dir: this.options.reportsDirectory,
198215
coverageMap,
@@ -203,6 +220,11 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
203220
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
204221

205222
for (const reporter of this.options.reporter) {
223+
if (reporter[0] === 'blob') {
224+
await this.createBlobReport(coverageMap, reporter[1])
225+
continue
226+
}
227+
206228
// Type assertion required for custom reporters
207229
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
208230
skipFull: this.options.skipFull,
@@ -239,18 +261,37 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
239261
})
240262
}
241263
}
264+
}
242265

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
266+
async mergeReports(path: string) {
267+
const directory = resolve(path, 'coverage')
268+
const files = await fs.readdir(directory)
269+
const coverageMap = libCoverage.createCoverageMap({})
245270

246-
if (!keepResults) {
247-
this.coverageFiles = new Map()
248-
await fs.rm(this.coverageFilesDirectory, { recursive: true })
271+
for (const file of files) {
272+
const report = await fs.readFile(resolve(directory, file), 'utf8')
273+
coverageMap.merge(JSON.parse(report))
274+
}
249275

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 })
276+
await this.generateReports(coverageMap, true)
277+
}
278+
279+
async createBlobReport(coverageMap: CoverageMap, options: Options['reporter'][number][1]) {
280+
const outputFile = 'outputFile' in options && options.outputFile as string
281+
const dir = outputFile ? dirname(outputFile) : '.vitest-reports/coverage'
282+
let file = outputFile && basename(outputFile)
283+
284+
if (!file) {
285+
const shard = this.ctx.config.shard
286+
file = shard
287+
? `.coverage-blob-${shard.index}-${shard.count}.json`
288+
: '.coverage-blob.json'
253289
}
290+
291+
const context = libReport.createContext({ dir, coverageMap })
292+
reports.create('json', { file }).execute(context)
293+
294+
this.ctx.logger.log('coverage blob report written to', resolve(dir, file))
254295
}
255296

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

packages/vitest/src/node/core.ts

+2
Original file line numberDiff line numberDiff line change
@@ -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?.(this.config.mergeReports)
442444
}
443445

444446
async start(filters?: string[]) {

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

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
1+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
22
import { existsSync } from 'node:fs'
33
import { parse, stringify } from 'flatted'
44
import { dirname, resolve } from 'pathe'
@@ -61,14 +61,17 @@ export async function readBlobs(blobsDirectory: string, projectsArray: Workspace
6161
const resolvedDir = resolve(process.cwd(), blobsDirectory)
6262
const blobsFiles = await readdir(resolvedDir)
6363
const promises = blobsFiles.map(async (file) => {
64-
const content = await readFile(resolve(resolvedDir, file), 'utf-8')
64+
const filename = resolve(resolvedDir, file)
65+
const stats = await stat(filename)
66+
if (!stats.isFile())
67+
return null
68+
69+
const content = await readFile(filename, 'utf-8')
6570
const [version, files, errors, moduleKeys] = parse(content) as MergeReport
6671
return { version, files, errors, moduleKeys }
6772
})
68-
const blobs = await Promise.all(promises)
69-
70-
if (!blobs.length)
71-
throw new Error(`vitest.mergeReports() requires at least one blob file paths in the config`)
73+
const results = await Promise.all(promises)
74+
const blobs = results.filter((result): result is NonNullable<typeof result> => result != null)
7275

7376
// fake module graph - it is used to check if module is imported, but we don't use values inside
7477
const projects = Object.fromEntries(projectsArray.map(p => [p.getName(), p]))

packages/vitest/src/types/coverage.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { TransformResult as ViteTransformResult } from 'vite'
2-
import type { ReportOptions } from 'istanbul-reports'
2+
import type { ReportOptions as IstanbulReportOptions } from 'istanbul-reports'
33
import type { Vitest } from '../node'
44
import type { Arrayable } from './general'
55
import type { AfterSuiteRunMeta } from './worker'
@@ -17,6 +17,8 @@ export interface CoverageProvider {
1717

1818
reportCoverage: (reportContext?: ReportContext) => void | Promise<void>
1919

20+
mergeReports?: (path: string) => void | Promise<void>
21+
2022
onFileTransform?: (
2123
sourceCode: string,
2224
id: string,
@@ -52,6 +54,10 @@ export interface CoverageProviderModule {
5254
stopCoverage?: () => unknown | Promise<unknown>
5355
}
5456

57+
interface ReportOptions extends IstanbulReportOptions {
58+
blob: { outputFile?: string } // TODO: dir, file
59+
}
60+
5561
export type CoverageReporter = keyof ReportOptions | (string & {})
5662

5763
type CoverageReporterWithOptions<ReporterName extends CoverageReporter = CoverageReporter> =

0 commit comments

Comments
 (0)