Skip to content

Commit 81604bc

Browse files
authoredFeb 13, 2023
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

+10-2
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

+31-3
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
+27-14
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

+2-2
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

+1-1
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

+1-1
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

+10-4
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+
}
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+
`;
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

+3-3
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
}

‎test/coverage-test/custom-provider.ts

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
2+
import type { AfterSuiteRunMeta, CoverageProvider, CoverageProviderModule, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest'
3+
4+
import { normalizeFilename } from './coverage-report-tests/utils'
5+
6+
const CustomCoverageProviderModule: CoverageProviderModule = {
7+
getProvider(): CoverageProvider {
8+
return new CustomCoverageProvider()
9+
},
10+
11+
takeCoverage() {
12+
return { customCoverage: 'Coverage report passed from workers to main thread' }
13+
},
14+
}
15+
16+
/**
17+
* Provider that simply keeps track of the functions that were called
18+
*/
19+
class CustomCoverageProvider implements CoverageProvider {
20+
name = 'custom-coverage-provider'
21+
22+
options!: ResolvedCoverageOptions
23+
calls: Set<string> = new Set()
24+
transformedFiles: Set<string> = new Set()
25+
26+
initialize(ctx: Vitest) {
27+
this.options = ctx.config.coverage
28+
29+
this.calls.add(`initialized ${ctx ? 'with' : 'without'} context`)
30+
}
31+
32+
clean(force: boolean) {
33+
this.calls.add(`clean ${force ? 'with' : 'without'} force`)
34+
}
35+
36+
onBeforeFilesRun() {
37+
this.calls.add('onBeforeFilesRun')
38+
}
39+
40+
onAfterSuiteRun(meta: AfterSuiteRunMeta) {
41+
this.calls.add(`onAfterSuiteRun with ${JSON.stringify(meta)}`)
42+
}
43+
44+
reportCoverage(reportContext?: ReportContext) {
45+
this.calls.add(`reportCoverage with ${JSON.stringify(reportContext)}`)
46+
47+
const jsonReport = JSON.stringify({
48+
calls: Array.from(this.calls.values()),
49+
transformedFiles: Array.from(this.transformedFiles.values()).sort(),
50+
}, null, 2)
51+
52+
if (existsSync('./coverage'))
53+
rmSync('./coverage', { maxRetries: 10, recursive: true })
54+
55+
mkdirSync('./coverage')
56+
writeFileSync('./coverage/custom-coverage-provider-report.json', jsonReport, 'utf-8')
57+
}
58+
59+
onFileTransform(code: string, id: string) {
60+
const filename = normalizeFilename(id).split('?')[0]
61+
62+
if (/\/src\//.test(filename))
63+
this.transformedFiles.add(filename)
64+
65+
return { code }
66+
}
67+
68+
resolveOptions(): ResolvedCoverageOptions {
69+
this.calls.add('resolveOptions')
70+
71+
return this.options
72+
}
73+
}
74+
75+
export default CustomCoverageProviderModule

‎test/coverage-test/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
"name": "@vitest/test-coverage",
33
"private": true,
44
"scripts": {
5-
"test": "pnpm run test:c8 && pnpm run test:istanbul && pnpm run test:types",
5+
"test": "pnpm test:c8 && pnpm test:istanbul && pnpm test:custom && pnpm test:types",
66
"test:c8": "node ./testing.mjs --provider c8",
7+
"test:custom": "node ./testing.mjs --provider custom",
78
"test:istanbul": "node ./testing.mjs --provider istanbul",
8-
"test:types": "vitest typecheck --run"
9+
"test:types": "vitest typecheck --run --reporter verbose"
910
},
1011
"devDependencies": {
1112
"@vitejs/plugin-vue": "latest",

‎test/coverage-test/test/configuration-options.test-d.ts

+54-30
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { assertType, test } from 'vitest'
2-
import type { ResolvedCoverageOptions, Vitest } from 'vitest'
2+
import type { CoverageProviderModule, ResolvedCoverageOptions, Vitest } from 'vitest'
33
import type { defineConfig } from 'vitest/config'
44

55
type NarrowToTestConfig<T> = T extends { test?: any } ? NonNullable<T['test']> : never
@@ -10,39 +10,14 @@ test('providers, built-in', () => {
1010
assertType<Coverage>({ provider: 'c8' })
1111
assertType<Coverage>({ provider: 'istanbul' })
1212

13-
// @ts-expect-error -- String options must be known built-in's
14-
assertType<Coverage>({ provider: 'unknown-reporter' })
13+
// @ts-expect-error -- String options must be known ones only
14+
assertType<Coverage>({ provider: 'unknown-provider' })
1515
})
1616

1717
test('providers, custom', () => {
1818
assertType<Coverage>({
19-
provider: {
20-
getProvider() {
21-
return {
22-
name: 'custom-provider',
23-
initialize(_: Vitest) {},
24-
resolveOptions(): ResolvedCoverageOptions {
25-
return {
26-
clean: true,
27-
cleanOnRerun: true,
28-
enabled: true,
29-
exclude: ['string'],
30-
extension: ['string'],
31-
reporter: ['html', 'json'],
32-
reportsDirectory: 'string',
33-
}
34-
},
35-
clean(_: boolean) {},
36-
onBeforeFilesRun() {},
37-
onAfterSuiteRun({ coverage: _coverage }) {},
38-
reportCoverage() {},
39-
onFileTransform(_code: string, _id: string, ctx) {
40-
ctx.getCombinedSourcemap()
41-
},
42-
}
43-
},
44-
takeCoverage() {},
45-
},
19+
provider: 'custom',
20+
customProviderModule: 'custom-provider-module.ts',
4621
})
4722
})
4823

@@ -95,6 +70,55 @@ test('provider specific options, istanbul', () => {
9570
})
9671
})
9772

73+
test('provider specific options, custom', () => {
74+
assertType<Coverage>({
75+
provider: 'custom',
76+
customProviderModule: 'custom-provider-module.ts',
77+
enabled: true,
78+
})
79+
80+
// @ts-expect-error -- customProviderModule is required
81+
assertType<Coverage>({ provider: 'custom' })
82+
83+
assertType<Coverage>({
84+
provider: 'custom',
85+
customProviderModule: 'some-module',
86+
87+
// @ts-expect-error -- typings of BaseCoverageOptions still apply
88+
enabled: 'not boolean',
89+
})
90+
})
91+
92+
test('provider module', () => {
93+
assertType<CoverageProviderModule>({
94+
getProvider() {
95+
return {
96+
name: 'custom-provider',
97+
initialize(_: Vitest) {},
98+
resolveOptions(): ResolvedCoverageOptions {
99+
return {
100+
clean: true,
101+
cleanOnRerun: true,
102+
enabled: true,
103+
exclude: ['string'],
104+
extension: ['string'],
105+
reporter: ['html', 'json'],
106+
reportsDirectory: 'string',
107+
}
108+
},
109+
clean(_: boolean) {},
110+
onBeforeFilesRun() {},
111+
onAfterSuiteRun({ coverage: _coverage }) {},
112+
reportCoverage() {},
113+
onFileTransform(_code: string, _id: string, ctx) {
114+
ctx.getCombinedSourcemap()
115+
},
116+
}
117+
},
118+
takeCoverage() {},
119+
})
120+
})
121+
98122
test('reporters, single', () => {
99123
assertType<Coverage>({ reporter: 'clover' })
100124
assertType<Coverage>({ reporter: 'cobertura' })

‎test/coverage-test/testing.mjs

+13-39
Original file line numberDiff line numberDiff line change
@@ -3,61 +3,35 @@ import { startVitest } from 'vitest/node'
33
// Set this to true when intentionally updating the snapshots
44
const UPDATE_SNAPSHOTS = false
55

6-
const provider = getArgument('--provider')
6+
const provider = process.argv[1 + process.argv.indexOf('--provider')]
77

88
const configs = [
99
// Run test cases. Generates coverage report.
1010
['test/', {
1111
include: ['test/*.test.*'],
1212
exclude: ['coverage-report-tests/**/*'],
13+
coverage: { enabled: true },
1314
}],
1415

1516
// Run tests for checking coverage report contents.
1617
['coverage-report-tests', {
1718
include: [
18-
'./coverage-report-tests/generic.report.test.ts',
19+
['c8', 'istanbul'].includes(provider) && './coverage-report-tests/generic.report.test.ts',
1920
`./coverage-report-tests/${provider}.report.test.ts`,
20-
],
21+
].filter(Boolean),
2122
coverage: { enabled: false, clean: false },
2223
}],
2324
]
2425

25-
runTests()
26+
for (const threads of [true, false]) {
27+
for (const [directory, config] of configs) {
28+
await startVitest('test', [directory], {
29+
...config,
30+
update: UPDATE_SNAPSHOTS,
31+
threads,
32+
})
2633

27-
async function runTests() {
28-
for (const threads of [true, false]) {
29-
for (const [directory, config] of configs) {
30-
await startVitest('test', [directory], {
31-
run: true,
32-
update: UPDATE_SNAPSHOTS,
33-
...config,
34-
threads,
35-
coverage: {
36-
include: ['src/**'],
37-
provider,
38-
...config.coverage,
39-
},
40-
})
41-
42-
if (process.exitCode)
43-
process.exit()
44-
}
34+
if (process.exitCode)
35+
process.exit()
4536
}
46-
47-
process.exit(0)
48-
}
49-
50-
function getArgument(name) {
51-
const args = process.argv
52-
const index = args.indexOf(name)
53-
54-
if (index === -1)
55-
throw new Error(`Missing argument ${name}, received ${args}`)
56-
57-
const value = args[index + 1]
58-
59-
if (!value)
60-
throw new Error(`Missing value of ${name}, received ${args}`)
61-
62-
return value
6337
}

‎test/coverage-test/vitest.config.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { resolve } from 'pathe'
22
import { defineConfig } from 'vitest/config'
33
import vue from '@vitejs/plugin-vue'
44

5+
const provider = process.argv[1 + process.argv.indexOf('--provider')]
6+
57
export default defineConfig({
68
plugins: [
79
vue(),
@@ -10,9 +12,11 @@ export default defineConfig({
1012
MY_CONSTANT: '"my constant"',
1113
},
1214
test: {
13-
reporters: 'verbose',
15+
watch: false,
1416
coverage: {
15-
enabled: true,
17+
provider: provider as any,
18+
customProviderModule: provider === 'custom' ? 'custom-provider' : undefined,
19+
include: ['src/**'],
1620
clean: true,
1721
all: true,
1822
reporter: ['html', 'text', 'lcov', 'json'],

0 commit comments

Comments
 (0)
Please sign in to comment.