Skip to content

Commit 81604bc

Browse files
authored
fix(coverage): custom providers to work inside worker threads (#2817)
1 parent 94247f1 commit 81604bc

File tree

15 files changed

+274
-103
lines changed

15 files changed

+274
-103
lines changed

docs/config/index.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ Isolate environment for each test file. Does not work if you disable [`--threads
617617

618618
### coverage
619619

620-
You can use [`c8`](https://github.com/bcoe/c8) or [`istanbul`](https://istanbul.js.org/) for coverage collection.
620+
You can use [`c8`](https://github.com/bcoe/c8), [`istanbul`](https://istanbul.js.org/) or [a custom coverage solution](/guide/coverage#custom-coverage-provider) for coverage collection.
621621

622622
You can provide coverage options to CLI with dot notation:
623623

@@ -631,7 +631,7 @@ If you are using coverage options with dot notation, don't forget to specify `--
631631

632632
#### provider
633633

634-
- **Type:** `'c8' | 'istanbul'`
634+
- **Type:** `'c8' | 'istanbul' | 'custom'`
635635
- **Default:** `'c8'`
636636
- **CLI:** `--coverage.provider=<provider>`
637637

@@ -863,6 +863,14 @@ See [istanbul documentation](https://github.com/istanbuljs/nyc#ignoring-methods)
863863

864864
Watermarks for statements, lines, branches and functions. See [istanbul documentation](https://github.com/istanbuljs/nyc#high-and-low-watermarks) for more information.
865865

866+
#### customProviderModule
867+
868+
- **Type:** `string`
869+
- **Available for providers:** `'custom'`
870+
- **CLI:** `--coverage.customProviderModule=<path or module name>`
871+
872+
Specifies the module name or path for the custom coverage provider module. See [Guide - Custom Coverage Provider](/guide/coverage#custom-coverage-provider) for more information.
873+
866874
### testNamePattern
867875

868876
- **Type** `string | RegExp`

docs/guide/coverage.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,50 @@ export default defineConfig({
7171

7272
## Custom Coverage Provider
7373

74-
It's also possible to provide your custom coverage provider by passing an object to the `test.coverage.provider`:
74+
It's also possible to provide your custom coverage provider by passing `'custom'` in `test.coverage.provider`:
7575

7676
```ts
7777
// vite.config.ts
7878
import { defineConfig } from 'vitest/config'
79-
import CustomCoverageProvider from 'my-custom-coverage-provider'
8079

8180
export default defineConfig({
8281
test: {
8382
coverage: {
84-
provider: CustomCoverageProvider()
83+
provider: 'custom',
84+
customProviderModule: 'my-custom-coverage-provider'
8585
},
8686
},
8787
})
8888
```
8989

90+
The custom providers require a `customProviderModule` option which is a module name or path where to load the `CoverageProviderModule` from. It must export an object that implements `CoverageProviderModule` as default export:
91+
92+
```ts
93+
// my-custom-coverage-provider.ts
94+
import type { CoverageProvider, CoverageProviderModule, ResolvedCoverageOptions, Vitest } from 'vitest'
95+
96+
const CustomCoverageProviderModule: CoverageProviderModule = {
97+
getProvider(): CoverageProvider {
98+
return new CustomCoverageProvider()
99+
},
100+
101+
// Implements rest of the CoverageProviderModule ...
102+
}
103+
104+
class CustomCoverageProvider implements CoverageProvider {
105+
name = 'custom-coverage-provider'
106+
options!: ResolvedCoverageOptions
107+
108+
initialize(ctx: Vitest) {
109+
this.options = ctx.config.coverage
110+
}
111+
112+
// Implements rest of the CoverageProvider ...
113+
}
114+
115+
export default CustomCoverageProviderModule
116+
```
117+
90118
Please refer to the type definition for more details.
91119

92120
## Changing the default coverage folder location
Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,47 @@
11
import { importModule } from 'local-pkg'
22
import type { CoverageOptions, CoverageProvider, CoverageProviderModule } from '../types'
33

4-
export const CoverageProviderMap = {
4+
interface Loader {
5+
executeId: (id: string) => Promise<{ default: CoverageProviderModule }>
6+
}
7+
8+
export const CoverageProviderMap: Record<string, string> = {
59
c8: '@vitest/coverage-c8',
610
istanbul: '@vitest/coverage-istanbul',
711
}
812

9-
export async function resolveCoverageProvider(provider: NonNullable<CoverageOptions['provider']>) {
10-
if (typeof provider === 'string') {
11-
const pkg = CoverageProviderMap[provider]
12-
if (!pkg)
13-
throw new Error(`Unknown coverage provider: ${provider}`)
14-
return await importModule<CoverageProviderModule>(pkg)
13+
async function resolveCoverageProviderModule(options: CoverageOptions & Required<Pick<CoverageOptions, 'provider'>>, loader: Loader) {
14+
const provider = options.provider
15+
16+
if (provider === 'c8' || provider === 'istanbul')
17+
return await importModule<CoverageProviderModule>(CoverageProviderMap[provider])
18+
19+
let customProviderModule
20+
21+
try {
22+
customProviderModule = await loader.executeId(options.customProviderModule)
1523
}
16-
else {
17-
return provider
24+
catch (error) {
25+
throw new Error(`Failed to load custom CoverageProviderModule from ${options.customProviderModule}`, { cause: error })
1826
}
27+
28+
if (customProviderModule.default == null)
29+
throw new Error(`Custom CoverageProviderModule loaded from ${options.customProviderModule} was not the default export`)
30+
31+
return customProviderModule.default
1932
}
2033

21-
export async function getCoverageProvider(options?: CoverageOptions): Promise<CoverageProvider | null> {
22-
if (options?.enabled && options?.provider) {
23-
const { getProvider } = await resolveCoverageProvider(options.provider)
34+
export async function getCoverageProvider(options: CoverageOptions, loader: Loader): Promise<CoverageProvider | null> {
35+
if (options.enabled && options.provider) {
36+
const { getProvider } = await resolveCoverageProviderModule(options, loader)
2437
return await getProvider()
2538
}
2639
return null
2740
}
2841

29-
export async function takeCoverageInsideWorker(options: CoverageOptions) {
42+
export async function takeCoverageInsideWorker(options: CoverageOptions, loader: Loader) {
3043
if (options.enabled && options.provider) {
31-
const { takeCoverage } = await resolveCoverageProvider(options.provider)
44+
const { takeCoverage } = await resolveCoverageProviderModule(options, loader)
3245
return await takeCoverage?.()
3346
}
3447
}

packages/vitest/src/node/cli-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ export async function startVitest(
5050

5151
if (mode === 'test' && ctx.config.coverage.enabled) {
5252
const provider = ctx.config.coverage.provider || 'c8'
53-
if (typeof provider === 'string') {
54-
const requiredPackages = CoverageProviderMap[provider]
53+
const requiredPackages = CoverageProviderMap[provider]
5554

55+
if (requiredPackages) {
5656
if (!await ensurePackageInstalled(requiredPackages, root)) {
5757
process.exitCode = 1
5858
return ctx

packages/vitest/src/node/core.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export class Vitest {
124124
async initCoverageProvider() {
125125
if (this.coverageProvider !== undefined)
126126
return
127-
this.coverageProvider = await getCoverageProvider(this.config.coverage)
127+
this.coverageProvider = await getCoverageProvider(this.config.coverage, this.runner)
128128
if (this.coverageProvider) {
129129
await this.coverageProvider.initialize(this)
130130
this.config.coverage = this.coverageProvider.resolveOptions()

packages/vitest/src/runtime/entry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async function getTestRunner(config: ResolvedConfig, executor: VitestExecutor):
6868

6969
const originalOnAfterRun = testRunner.onAfterRun
7070
testRunner.onAfterRun = async (files) => {
71-
const coverage = await takeCoverageInsideWorker(config.coverage)
71+
const coverage = await takeCoverageInsideWorker(config.coverage, executor)
7272
rpc().onAfterSuiteRun({ coverage })
7373
await originalOnAfterRun?.call(testRunner, files)
7474
}

packages/vitest/src/types/coverage.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,13 @@ export type CoverageReporter =
5353
| 'text-summary'
5454
| 'text'
5555

56-
type Provider = 'c8' | 'istanbul' | CoverageProviderModule | undefined
56+
type Provider = 'c8' | 'istanbul' | 'custom' | undefined
5757

5858
export type CoverageOptions<T extends Provider = Provider> =
59-
T extends CoverageProviderModule ? ({ provider: T } & BaseCoverageOptions) :
60-
T extends 'istanbul' ? ({ provider: T } & CoverageIstanbulOptions) :
61-
({ provider?: T } & CoverageC8Options)
59+
T extends 'istanbul' ? ({ provider: T } & CoverageIstanbulOptions) :
60+
T extends 'c8' ? ({ provider: T } & CoverageC8Options) :
61+
T extends 'custom' ? ({ provider: T } & CustomProviderOptions) :
62+
({ provider?: T } & (CoverageC8Options))
6263

6364
/** Fields that have default values. Internally these will always be defined. */
6465
type FieldsWithDefaultValues =
@@ -233,3 +234,8 @@ export interface CoverageC8Options extends BaseCoverageOptions {
233234
*/
234235
100?: boolean
235236
}
237+
238+
export interface CustomProviderOptions extends Pick<BaseCoverageOptions, FieldsWithDefaultValues> {
239+
/** Name of the module or path to a file to load the custom provider from */
240+
customProviderModule: string
241+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Vitest Snapshot v1
2+
3+
exports[`custom json report 1`] = `
4+
{
5+
"calls": [
6+
"initialized with context",
7+
"resolveOptions",
8+
"clean with force",
9+
"onBeforeFilesRun",
10+
"onAfterSuiteRun with {\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"}}",
11+
"reportCoverage with {\\"allTestsRun\\":true}",
12+
],
13+
"transformedFiles": [
14+
"<process-cwd>/src/Counter/Counter.component.ts",
15+
"<process-cwd>/src/Counter/Counter.vue",
16+
"<process-cwd>/src/Counter/index.ts",
17+
"<process-cwd>/src/Defined.vue",
18+
"<process-cwd>/src/Hello.vue",
19+
"<process-cwd>/src/another-setup.ts",
20+
"<process-cwd>/src/implicitElse.ts",
21+
"<process-cwd>/src/importEnv.ts",
22+
"<process-cwd>/src/index.mts",
23+
"<process-cwd>/src/utils.ts",
24+
],
25+
}
26+
`;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* Custom coverage provider specific test cases
3+
*/
4+
5+
import { readFileSync } from 'fs'
6+
import { expect, test } from 'vitest'
7+
8+
test('custom json report', async () => {
9+
const report = readFileSync('./coverage/custom-coverage-provider-report.json', 'utf-8')
10+
11+
expect(JSON.parse(report)).toMatchSnapshot()
12+
})

test/coverage-test/coverage-report-tests/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from 'fs'
12
import { normalize } from 'pathe'
23

34
interface CoverageFinalJson {
@@ -17,8 +18,7 @@ interface CoverageFinalJson {
1718
* Normalizes paths to keep contents consistent between OS's
1819
*/
1920
export async function readCoverageJson() {
20-
// @ts-expect-error -- generated file
21-
const { default: jsonReport } = await import('./coverage/coverage-final.json') as CoverageFinalJson
21+
const jsonReport = JSON.parse(readFileSync('./coverage/coverage-final.json', 'utf8')) as CoverageFinalJson
2222

2323
const normalizedReport: CoverageFinalJson['default'] = {}
2424

@@ -30,6 +30,6 @@ export async function readCoverageJson() {
3030
return normalizedReport
3131
}
3232

33-
function normalizeFilename(filename: string) {
33+
export function normalizeFilename(filename: string) {
3434
return normalize(filename).replace(normalize(process.cwd()), '<process-cwd>')
3535
}

0 commit comments

Comments
 (0)