Skip to content

Commit

Permalink
feat: --merge-reports to support coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed May 18, 2024
1 parent a4ec583 commit 90a9848
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 29 deletions.
2 changes: 1 addition & 1 deletion docs/guide/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ If `--reporter=blob` is used without an output file, the default path will inclu

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

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)):
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)):

```sh
vitest --merge-reports --reporter=junit
Expand Down
23 changes: 23 additions & 0 deletions docs/guide/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,29 @@ export default defineConfig({
})
```

## Blob Reporter

Stores coverage results on the machine so they can be later merged using [`--merge-reports`](/guide/cli#merge-reports) command.
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.

```bash
npx vitest --coverage.reporter=blob
npx vitest --coverage.reporter=blob --coverage.reporter.blob.dir=reports
npx vitest --coverage.reporter=blob --coverage.reporter.blob.file=coverage-blob-1.json
npx vitest --coverage.reporter=blob --coverage.reporter.blob.dir=reports --coverage.reporter.blob.file=coverage-blob-1.json
```

::: tip
Coverage blob reporter uses `--coverage.reporter.blob.dir` instead of `--coverage.reportsDirectory` for storing the blob reports
:::

We recommend using this reporter if you are running Vitest on different machines with the [`--shard`](/guide/cli#shard) flag.
All coverage blob reports can be merged into any report by using `--merge-reports` command at the end of your CI pipeline:

```bash
npx vitest --merge-reports=reports --coverage.reporter=text
```

## Ignoring Code

Both coverage providers have their own ways how to ignore code from coverage reports:
Expand Down
8 changes: 4 additions & 4 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,11 @@ test('my types work properly', () => {

## Sharding

Run tests on different machines using [`--shard`](/guide/cli#shard) and [`--reporter=blob`](/guide/reporters#blob-reporter) flags.
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.
All test results can be merged at the end of your CI pipeline using `--merge-reports` command:

```bash
vitest --shard=1/2 --reporter=blob
vitest --shard=2/2 --reporter=blob
vitest --merge-reports --reporter=junit
vitest --shard=1/2 --reporter=blob --coverage.reporter=blob
vitest --shard=2/2 --reporter=blob --coverage.reporter=blob
vitest --merge-reports --reporter=junit --coverage.reporter=text
```
4 changes: 4 additions & 0 deletions docs/guide/improving-performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ export default defineConfig({
})
```
:::

## Sharding

TODO
59 changes: 50 additions & 9 deletions packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
import { basename, dirname } from 'node:path'
import { resolve } from 'pathe'
import type { AfterSuiteRunMeta, CoverageIstanbulOptions, CoverageProvider, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest'
import { coverageConfigDefaults, defaultExclude, defaultInclude } from 'vitest/config'
Expand Down Expand Up @@ -202,6 +203,22 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
coverageMap.merge(await transformCoverage(uncoveredCoverage))
}

await this.generateReports(coverageMap, allTestsRun)

// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })

// Remove empty reports directory, e.g. when only text-reporter is used
if (readdirSync(this.options.reportsDirectory).length === 0)
await fs.rm(this.options.reportsDirectory, { recursive: true })
}
}

async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
Expand All @@ -212,6 +229,11 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))

for (const reporter of this.options.reporter) {
if (reporter[0] === 'blob') {
await this.createBlobReport(coverageMap, reporter[1])
continue
}

// Type assertion required for custom reporters
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
skipFull: this.options.skipFull,
Expand Down Expand Up @@ -248,21 +270,40 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
})
}
}
}

// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
async mergeReports(path: string) {
const directory = resolve(path, 'coverage')
const files = await fs.readdir(directory)
const coverageMap = libCoverage.createCoverageMap({})

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })
for (const file of files) {
const report = await fs.readFile(resolve(directory, file), 'utf8')
coverageMap.merge(JSON.parse(report))
}

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

async createBlobReport(coverageMap: CoverageMap, options: Options['reporter'][number][1]) {
const outputFile = 'outputFile' in options && options.outputFile as string
const dir = outputFile ? dirname(outputFile) : '.vitest-reports/coverage'
let file = outputFile && basename(outputFile)

if (!file) {
const shard = this.ctx.config.shard
file = shard
? `.coverage-blob-${shard.index}-${shard.count}.json`
: '.coverage-blob.json'
}

const context = libReport.createContext({ dir, coverageMap })
reports.create('json', { file }).execute(context)

this.ctx.logger.log('coverage blob report written to', resolve(dir, file))
}

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

Expand Down
57 changes: 49 additions & 8 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, promises as fs, readdirSync, writeFileSync } from 'node:fs'
import type { Profiler } from 'node:inspector'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { basename, dirname } from 'node:path'
import v8ToIstanbul from 'v8-to-istanbul'
import { mergeProcessCovs } from '@bcoe/v8-coverage'
import libReport from 'istanbul-lib-report'
Expand Down Expand Up @@ -193,6 +194,22 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
coverageMap.merge(await transformCoverage(converted))
}

await this.generateReports(coverageMap, allTestsRun)

// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })

// Remove empty reports directory, e.g. when only text-reporter is used
if (readdirSync(this.options.reportsDirectory).length === 0)
await fs.rm(this.options.reportsDirectory, { recursive: true })
}
}

async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined) {
const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
Expand All @@ -203,6 +220,11 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name))

for (const reporter of this.options.reporter) {
if (reporter[0] === 'blob') {
await this.createBlobReport(coverageMap, reporter[1])
continue
}

// Type assertion required for custom reporters
reports.create(reporter[0] as Parameters<typeof reports.create>[0], {
skipFull: this.options.skipFull,
Expand Down Expand Up @@ -239,18 +261,37 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
})
}
}
}

// In watch mode we need to preserve the previous results if cleanOnRerun is disabled
const keepResults = !this.options.cleanOnRerun && this.ctx.config.watch
async mergeReports(path: string) {
const directory = resolve(path, 'coverage')
const files = await fs.readdir(directory)
const coverageMap = libCoverage.createCoverageMap({})

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })
for (const file of files) {
const report = await fs.readFile(resolve(directory, file), 'utf8')
coverageMap.merge(JSON.parse(report))
}

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

async createBlobReport(coverageMap: CoverageMap, options: Options['reporter'][number][1]) {
const outputFile = 'outputFile' in options && options.outputFile as string
const dir = outputFile ? dirname(outputFile) : '.vitest-reports/coverage'
let file = outputFile && basename(outputFile)

if (!file) {
const shard = this.ctx.config.shard
file = shard
? `.coverage-blob-${shard.index}-${shard.count}.json`
: '.coverage-blob.json'
}

const context = libReport.createContext({ dir, coverageMap })
reports.create('json', { file }).execute(context)

this.ctx.logger.log('coverage blob report written to', resolve(dir, file))
}

private async getUntestedFiles(testedFiles: string[]): Promise<RawCoverage> {
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,8 @@ export class Vitest {
process.exitCode = 1

await this.report('onFinished', files, errors)
await this.initCoverageProvider()
await this.coverageProvider?.mergeReports?.(this.config.mergeReports)
}

async start(filters?: string[]) {
Expand Down
15 changes: 9 additions & 6 deletions packages/vitest/src/node/reporters/blob.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { parse, stringify } from 'flatted'
import { dirname, resolve } from 'pathe'
Expand Down Expand Up @@ -61,14 +61,17 @@ export async function readBlobs(blobsDirectory: string, projectsArray: Workspace
const resolvedDir = resolve(process.cwd(), blobsDirectory)
const blobsFiles = await readdir(resolvedDir)
const promises = blobsFiles.map(async (file) => {
const content = await readFile(resolve(resolvedDir, file), 'utf-8')
const filename = resolve(resolvedDir, file)
const stats = await stat(filename)
if (!stats.isFile())
return null

const content = await readFile(filename, 'utf-8')
const [version, files, errors, moduleKeys] = parse(content) as MergeReport
return { version, files, errors, moduleKeys }
})
const blobs = await Promise.all(promises)

if (!blobs.length)
throw new Error(`vitest.mergeReports() requires at least one blob file paths in the config`)
const results = await Promise.all(promises)
const blobs = results.filter((result): result is NonNullable<typeof result> => result != null)

// fake module graph - it is used to check if module is imported, but we don't use values inside
const projects = Object.fromEntries(projectsArray.map(p => [p.getName(), p]))
Expand Down
8 changes: 7 additions & 1 deletion packages/vitest/src/types/coverage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TransformResult as ViteTransformResult } from 'vite'
import type { ReportOptions } from 'istanbul-reports'
import type { ReportOptions as IstanbulReportOptions } from 'istanbul-reports'
import type { Vitest } from '../node'
import type { Arrayable } from './general'
import type { AfterSuiteRunMeta } from './worker'
Expand All @@ -17,6 +17,8 @@ export interface CoverageProvider {

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

mergeReports?: (path: string) => void | Promise<void>

onFileTransform?: (
sourceCode: string,
id: string,
Expand Down Expand Up @@ -52,6 +54,10 @@ export interface CoverageProviderModule {
stopCoverage?: () => unknown | Promise<unknown>
}

interface ReportOptions extends IstanbulReportOptions {
blob: { outputFile?: string } // TODO: dir, file
}

export type CoverageReporter = keyof ReportOptions | (string & {})

type CoverageReporterWithOptions<ReporterName extends CoverageReporter = CoverageReporter> =
Expand Down

0 comments on commit 90a9848

Please sign in to comment.