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 20, 2024
1 parent a4ec583 commit dad15a1
Show file tree
Hide file tree
Showing 16 changed files with 191 additions and 37 deletions.
2 changes: 2 additions & 0 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,5 @@ vitest --shard=1/2 --reporter=blob
vitest --shard=2/2 --reporter=blob
vitest --merge-reports --reporter=junit
```

See [`Improving Performance | Sharding`](/guide/improving-performance#sharding) for more information.
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
42 changes: 31 additions & 11 deletions packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
this.pendingPromises = []
}

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

return coverageMap
}

async reportCoverage(coverageMap: unknown, { allTestsRun }: ReportContext) {
await this.generateReports(
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
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 Down Expand Up @@ -248,21 +271,18 @@ 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(coverageMaps: unknown[]) {
const coverageMap = libCoverage.createCoverageMap({})

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })
for (const coverage of coverageMaps)
coverageMap.merge(coverage as CoverageMap)

// 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 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
46 changes: 33 additions & 13 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
this.pendingPromises.push(promise)
}

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

async generateCoverage({ allTestsRun }: ReportContext) {
const coverageMap = libCoverage.createCoverageMap({})
let index = 0
const total = this.pendingPromises.length
Expand Down Expand Up @@ -193,6 +190,32 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
coverageMap.merge(await transformCoverage(converted))
}

return coverageMap
}

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

await this.generateReports(
coverageMap as CoverageMap || libCoverage.createCoverageMap({}),
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) {
const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
Expand Down Expand Up @@ -239,18 +262,15 @@ 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(coverageMaps: unknown[]) {
const coverageMap = libCoverage.createCoverageMap({})

if (!keepResults) {
this.coverageFiles = new Map()
await fs.rm(this.coverageFilesDirectory, { recursive: true })
for (const coverage of coverageMaps)
coverageMap.merge(coverage as CoverageMap)

// 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)
}

private async getUntestedFiles(testedFiles: string[]): Promise<RawCoverage> {
Expand Down
18 changes: 12 additions & 6 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ export class Vitest {
if (this.reporters.some(r => r instanceof BlobReporter))
throw new Error('Cannot merge reports when `--reporter=blob` is used. Remove blob reporter from the config first.')

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

await this.report('onInit', this)
await this.report('onPathsCollected', files.flatMap(f => f.filepath))
Expand Down 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?.(coverages)
}

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

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

this.logger.printNoTestFound(filters)

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

await this.report('onFinished', this.state.getFiles(files), this.state.getUnhandledErrors(), coverage)
await this.reportCoverage(coverage, allTestsRun)

this.runningPromise = undefined
this.isFirstRun = false
Expand Down Expand Up @@ -946,12 +952,12 @@ export class Vitest {
return Array.from(new Set(files))
}

private async reportCoverage(allTestsRun: boolean) {
private async reportCoverage(coverage: unknown, allTestsRun: boolean) {
if (!this.config.coverage.reportOnFailure && this.state.getCountOfFailedTests() > 0)
return

if (this.coverageProvider) {
await this.coverageProvider.reportCoverage({ allTestsRun })
await this.coverageProvider.reportCoverage(coverage, { allTestsRun })
// notify coverage iframe reload
for (const reporter of this.reporters) {
if (reporter instanceof WebSocketReporter)
Expand Down
11 changes: 7 additions & 4 deletions packages/vitest/src/node/reporters/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class BlobReporter implements Reporter {
this.ctx = ctx
}

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

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

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

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

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

return {
files,
errors,
coverages,
}
}

Expand All @@ -120,6 +122,7 @@ type MergeReport = [
files: File[],
errors: unknown[],
moduleKeys: MergeReportModuleKeys[],
coverage: unknown,
]

type MergeReportModuleKeys = [projectName: string, moduleIds: string[]]
17 changes: 16 additions & 1 deletion packages/vitest/src/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,33 @@ import type { Arrayable } from './general'
import type { AfterSuiteRunMeta } from './worker'

type TransformResult = string | Partial<ViteTransformResult> | undefined | null | void
type CoverageResults = unknown

export interface CoverageProvider {
name: string

/** Called when provider is being initialized before tests run */
initialize: (ctx: Vitest) => Promise<void> | void

/** Called when setting coverage options for Vitest context (`ctx.config.coverage`) */
resolveOptions: () => ResolvedCoverageOptions

/** Callback to clean previous reports */
clean: (clean?: boolean) => void | Promise<void>

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

reportCoverage: (reportContext?: ReportContext) => void | Promise<void>
/** Callback to generate final coverage results */
generateCoverage: (reportContext: ReportContext) => CoverageResults | Promise<CoverageResults>

/** Callback to convert coverage results to coverage reports. Called with results returned from `generateCoverage` */
reportCoverage: (coverage: CoverageResults, reportContext: ReportContext) => void | Promise<void>

/** Callback for `--merge-reports` options. Called with multiple coverage results generated by `generateCoverage`. */
mergeReports?: (coverages: CoverageResults[]) => void | Promise<void>

/** Callback called for instrumenting files with coverage counters. */
onFileTransform?: (
sourceCode: string,
id: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/types/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface Reporter {
onPathsCollected?: (paths?: string[]) => Awaitable<void>
onSpecsCollected?: (specs?: SerializableSpec[]) => Awaitable<void>
onCollected?: (files?: File[]) => Awaitable<void>
onFinished?: (files?: File[], errors?: unknown[]) => Awaitable<void>
onFinished?: (files?: File[], errors?: unknown[], coverage?: unknown) => Awaitable<void>
onTaskUpdate?: (packs: TaskResultPack[]) => Awaitable<void>

onTestRemoved?: (trigger?: string) => Awaitable<void>
Expand Down
1 change: 1 addition & 0 deletions test/cli/test/create-vitest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ it(createVitest, async () => {
},
],
[],
undefined,
])
})
27 changes: 27 additions & 0 deletions test/coverage-test/coverage-report-tests/merge-reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from 'vitest'
import libCoverage from 'istanbul-lib-coverage'

import { readCoverageJson } from '../coverage-report-tests/utils'

test('reports are merged', async () => {
const json = await readCoverageJson('./coverage/coverage-final.json')
const coverageMap = libCoverage.createCoverageMap(json as any)
const files = coverageMap.files()

// Two files were covered: 2/3 cases covered utils, 1/3 covered importEnv
expect(files).toMatchInlineSnapshot(`
[
"<process-cwd>/src/importEnv.ts",
"<process-cwd>/src/utils.ts",
]
`)

const fileCoverage = coverageMap.fileCoverageFor('<process-cwd>/src/utils.ts')
const lines = fileCoverage.getLineCoverage()

// add() should be covered by one test file
expect(lines[2]).toBe(1)

// multiply() should be covered by two test files
expect(lines[6]).toBe(2)
})
6 changes: 5 additions & 1 deletion test/coverage-test/custom-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ class CustomCoverageProvider implements CoverageProvider {
}))
}

reportCoverage(reportContext?: ReportContext) {
generateCoverage(_reportContext: ReportContext) {
return {}
}

reportCoverage(coverage: unknown, reportContext?: ReportContext) {
this.calls.add(`reportCoverage with ${JSON.stringify(reportContext)}`)

const jsonReport = JSON.stringify({
Expand Down
6 changes: 6 additions & 0 deletions test/coverage-test/option-tests/merge-fixture-1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test } from 'vitest'
import { add } from '../src/utils'

test('cover add', () => {
add(1, 2)
})
6 changes: 6 additions & 0 deletions test/coverage-test/option-tests/merge-fixture-2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { test } from 'vitest'
import { multiply } from '../src/utils'

test('cover multiply', () => {
multiply(1, 2)
})
11 changes: 11 additions & 0 deletions test/coverage-test/option-tests/merge-fixture-3.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { test } from 'vitest'
import { multiply } from '../src/utils'
import { useImportEnv } from '../src/importEnv'

test('cover multiply again', () => {
multiply(1, 2)
})

test('also cover another file', () => {
useImportEnv()
})
1 change: 1 addition & 0 deletions test/coverage-test/test/configuration-options.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ test('provider module', () => {
return {
name: 'custom-provider',
initialize(_: Vitest) {},
generateCoverage() {},
resolveOptions(): ResolvedCoverageOptions {
return {
clean: true,
Expand Down
Loading

0 comments on commit dad15a1

Please sign in to comment.