Skip to content

Commit 4c913db

Browse files
committed
feat: --merge-reports to support coverage
1 parent f178059 commit 4c913db

File tree

9 files changed

+130
-12
lines changed

9 files changed

+130
-12
lines changed

docs/guide/cli.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,18 @@ 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` and `.vitest-reports/coverage` 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
128+
vitest --merge-reports=custom/reports --reporter=junit
129+
```
130+
131+
Use `--merge-reports.coverage=custom/directory` if coverage results are not stored in default location.
132+
133+
```sh
134+
vitest --merge-reports.coverage=custom/directory
135+
vitest --merge-reports=custom/reports --merge-reports.coverage=custom/coverage
128136
```
129137

130138
[cac's dot notation]: https://github.com/cacjs/cac#dot-nested-options

docs/guide/coverage.md

+21
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,27 @@ 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.outputFile` flag.
190+
191+
```bash
192+
npx vitest --coverage.reporter=blob
193+
npx vitest --coverage.reporter=blob --coverage.reporter.blob.outputFile=reports/coverage-blob-1.json
194+
```
195+
196+
::: tip
197+
Coverage blob reporter uses `--coverage.reporter.blob.outputFile` instead of `--coverage.reportsDirectory` for storing the blob reports
198+
:::
199+
200+
We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag.
201+
All coverage blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline:
202+
203+
```bash
204+
npx vitest --merge-reports.coverage=reports --coverage.reporter=text
205+
```
206+
186207
## Ignoring Code
187208

188209
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

+37
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'
@@ -228,6 +229,11 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
228229
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
229230

230231
for (const reporter of this.options.reporter) {
232+
if (reporter[0] === 'blob') {
233+
await this.createBlobReport(coverageMap, reporter[1])
234+
continue
235+
}
236+
231237
// Type assertion required for custom reporters
232238
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
233239
skipFull: this.options.skipFull,
@@ -266,6 +272,37 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
266272
}
267273
}
268274

275+
async mergeReports(path?: string) {
276+
const directory = resolve(path || '.vitest-reports/coverage')
277+
const files = await fs.readdir(directory)
278+
const coverageMap = libCoverage.createCoverageMap({})
279+
280+
for (const file of files) {
281+
const report = await fs.readFile(resolve(directory, file), 'utf8')
282+
coverageMap.merge(JSON.parse(report))
283+
}
284+
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'
298+
}
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))
304+
}
305+
269306
private async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
270307
const allFiles = await this.testExclude.glob(this.ctx.config.root)
271308
let includedFiles = allFiles.map(file => resolve(this.ctx.config.root, file))

packages/coverage-v8/src/provider.ts

+37
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'
@@ -219,6 +220,11 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
219220
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))
220221

221222
for (const reporter of this.options.reporter) {
223+
if (reporter[0] === 'blob') {
224+
await this.createBlobReport(coverageMap, reporter[1])
225+
continue
226+
}
227+
222228
// Type assertion required for custom reporters
223229
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
224230
skipFull: this.options.skipFull,
@@ -257,6 +263,37 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
257263
}
258264
}
259265

266+
async mergeReports(path?: string) {
267+
const directory = resolve(path || '.vitest-reports/coverage')
268+
const files = await fs.readdir(directory)
269+
const coverageMap = libCoverage.createCoverageMap({})
270+
271+
for (const file of files) {
272+
const report = await fs.readFile(resolve(directory, file), 'utf8')
273+
coverageMap.merge(JSON.parse(report))
274+
}
275+
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'
289+
}
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))
295+
}
296+
260297
private async getUntestedFiles(testedFiles: string[]): Promise<RawCoverage> {
261298
const transformResults = normalizeTransformResults(this.ctx.vitenode.fetchCache)
262299

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.coverage
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 }
59+
}
60+
5561
export type CoverageReporter = keyof ReportOptions | (string & {})
5662

5763
type CoverageReporterWithOptions<ReporterName extends CoverageReporter = CoverageReporter> =

0 commit comments

Comments
 (0)