diff --git a/packages/vitest/src/integrations/coverage.ts b/packages/vitest/src/integrations/coverage.ts index a14b4d5dc9f9..1df8d977ac89 100644 --- a/packages/vitest/src/integrations/coverage.ts +++ b/packages/vitest/src/integrations/coverage.ts @@ -1,34 +1,44 @@ import { importModule } from 'local-pkg' import type { CoverageOptions, CoverageProvider, CoverageProviderModule } from '../types' -export const CoverageProviderMap = { +type Loader = (id: string) => Promise<{ default: CoverageProviderModule }> + +export const CoverageProviderMap: Record = { c8: '@vitest/coverage-c8', istanbul: '@vitest/coverage-istanbul', } -export async function resolveCoverageProvider(provider: NonNullable) { - if (typeof provider === 'string') { - const pkg = CoverageProviderMap[provider] - if (!pkg) - throw new Error(`Unknown coverage provider: ${provider}`) - return await importModule(pkg) +async function resolveCoverageProviderModule(provider: NonNullable, loader: Loader) { + const builtInProviderPackage = CoverageProviderMap[provider] + + if (builtInProviderPackage) + return await importModule(builtInProviderPackage) + + let customProviderModule + try { + customProviderModule = await loader(provider) } - else { - return provider + catch (error) { + throw new Error(`Failed to load custom CoverageProviderModule from ${provider}`, { cause: error }) } + + if (customProviderModule.default == null) + throw new Error(`Custom CoverageProviderModule loaded from ${provider} was not the default export`) + + return customProviderModule.default } -export async function getCoverageProvider(options?: CoverageOptions): Promise { - if (options?.enabled && options?.provider) { - const { getProvider } = await resolveCoverageProvider(options.provider) +export async function getCoverageProvider(options: CoverageOptions, loader: Loader): Promise { + if (options.enabled && options.provider) { + const { getProvider } = await resolveCoverageProviderModule(options.provider, loader) return await getProvider() } return null } -export async function takeCoverageInsideWorker(options: CoverageOptions) { +export async function takeCoverageInsideWorker(options: CoverageOptions, loader: Loader) { if (options.enabled && options.provider) { - const { takeCoverage } = await resolveCoverageProvider(options.provider) + const { takeCoverage } = await resolveCoverageProviderModule(options.provider, loader) return await takeCoverage?.() } } diff --git a/packages/vitest/src/node/cli-api.ts b/packages/vitest/src/node/cli-api.ts index 62c245d3b8bc..b891429c638a 100644 --- a/packages/vitest/src/node/cli-api.ts +++ b/packages/vitest/src/node/cli-api.ts @@ -50,9 +50,9 @@ export async function startVitest( if (mode === 'test' && ctx.config.coverage.enabled) { const provider = ctx.config.coverage.provider || 'c8' - if (typeof provider === 'string') { - const requiredPackages = CoverageProviderMap[provider] + const requiredPackages = CoverageProviderMap[provider] + if (requiredPackages) { if (!await ensurePackageInstalled(requiredPackages, root)) { process.exitCode = 1 return ctx diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 520d247a4489..0390c49ede64 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -125,7 +125,7 @@ export class Vitest { async initCoverageProvider() { if (this.coverageProvider !== undefined) return - this.coverageProvider = await getCoverageProvider(this.config.coverage) + this.coverageProvider = await getCoverageProvider(this.config.coverage, id => this.runner.executeId(id)) if (this.coverageProvider) { await this.coverageProvider.initialize(this) this.config.coverage = this.coverageProvider.resolveOptions() diff --git a/packages/vitest/src/runtime/entry.ts b/packages/vitest/src/runtime/entry.ts index e2c8591b9950..0ba941f89483 100644 --- a/packages/vitest/src/runtime/entry.ts +++ b/packages/vitest/src/runtime/entry.ts @@ -56,7 +56,7 @@ async function getTestRunner(config: ResolvedConfig): Promise { const originalOnAfterRun = testRunner.onAfterRun testRunner.onAfterRun = async (files) => { - const coverage = await takeCoverageInsideWorker(config.coverage) + const coverage = await takeCoverageInsideWorker(config.coverage, id => import(id)) rpc().onAfterSuiteRun({ coverage }) await originalOnAfterRun?.call(testRunner, files) } diff --git a/packages/vitest/src/types/coverage.ts b/packages/vitest/src/types/coverage.ts index 209f274ec9e1..befc65472d68 100644 --- a/packages/vitest/src/types/coverage.ts +++ b/packages/vitest/src/types/coverage.ts @@ -53,12 +53,15 @@ export type CoverageReporter = | 'text-summary' | 'text' -type Provider = 'c8' | 'istanbul' | CoverageProviderModule | undefined +type BuiltinProviders = 'c8' | 'istanbul' +type CustomProvider = string & Omit +type Provider = BuiltinProviders | CustomProvider | undefined export type CoverageOptions = - T extends CoverageProviderModule ? ({ provider: T } & BaseCoverageOptions) : - T extends 'istanbul' ? ({ provider: T } & CoverageIstanbulOptions) : - ({ provider?: T } & CoverageC8Options) + T extends 'istanbul' ? ({ provider: T } & CoverageIstanbulOptions) : + T extends 'c8' ? ({ provider: T } & CoverageC8Options) : + T extends CustomProvider ? ({ provider: T } & CustomProviderOptions) : + ({ provider?: T } & CoverageC8Options) /** Fields that have default values. Internally these will always be defined. */ type FieldsWithDefaultValues = @@ -233,3 +236,5 @@ export interface CoverageC8Options extends BaseCoverageOptions { */ 100?: boolean } + +export type CustomProviderOptions = BaseCoverageOptions & Record diff --git a/test/coverage-test/coverage-report-tests/__snapshots__/custom-provider.report.test.ts.snap b/test/coverage-test/coverage-report-tests/__snapshots__/custom-provider.report.test.ts.snap new file mode 100644 index 000000000000..0cbd7fe9a142 --- /dev/null +++ b/test/coverage-test/coverage-report-tests/__snapshots__/custom-provider.report.test.ts.snap @@ -0,0 +1,30 @@ +// Vitest Snapshot v1 + +exports[`custom json report 1`] = ` +{ + "calls": [ + "initialized with context", + "resolveOptions", + "clean with force", + "onBeforeFilesRun", + "onAfterSuiteRun with {\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"}}", + "onAfterSuiteRun with {\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"}}", + "onAfterSuiteRun with {\\"coverage\\":{\\"customCoverage\\":\\"Coverage report passed from workers to main thread\\"}}", + "reportCoverage with {\\"allTestsRun\\":true}", + ], + "transformedFiles": [ + "/src/Counter/Counter.component.ts", + "/src/Counter/Counter.component.ts?vue&type=script&src=true&lang.ts", + "/src/Counter/Counter.vue", + "/src/Counter/index.ts", + "/src/Defined.vue", + "/src/Defined.vue?vue&type=style&index=0&scoped=fdf5cd5f&lang.css", + "/src/Hello.vue", + "/src/another-setup.ts", + "/src/implicitElse.ts", + "/src/importEnv.ts", + "/src/index.mts", + "/src/utils.ts", + ], +} +`; diff --git a/test/coverage-test/coverage-report-tests/custom-provider.report.test.ts b/test/coverage-test/coverage-report-tests/custom-provider.report.test.ts new file mode 100644 index 000000000000..eb06455660de --- /dev/null +++ b/test/coverage-test/coverage-report-tests/custom-provider.report.test.ts @@ -0,0 +1,12 @@ +/* + * Custom coverage provider specific test cases + */ + +import { readFileSync } from 'fs' +import { expect, test } from 'vitest' + +test('custom json report', async () => { + const report = readFileSync('./coverage/custom-coverage-provider-report.json', 'utf-8') + + expect(JSON.parse(report)).toMatchSnapshot() +}) diff --git a/test/coverage-test/coverage-report-tests/utils.ts b/test/coverage-test/coverage-report-tests/utils.ts index 1f52b86061f8..6cc7600a3178 100644 --- a/test/coverage-test/coverage-report-tests/utils.ts +++ b/test/coverage-test/coverage-report-tests/utils.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs' import { normalize } from 'pathe' interface CoverageFinalJson { @@ -17,8 +18,7 @@ interface CoverageFinalJson { * Normalizes paths to keep contents consistent between OS's */ export async function readCoverageJson() { - // @ts-expect-error -- generated file - const { default: jsonReport } = await import('./coverage/coverage-final.json') as CoverageFinalJson + const jsonReport = JSON.parse(readFileSync('./coverage/coverage-final.json', 'utf8')) as CoverageFinalJson const normalizedReport: CoverageFinalJson['default'] = {} @@ -30,6 +30,6 @@ export async function readCoverageJson() { return normalizedReport } -function normalizeFilename(filename: string) { +export function normalizeFilename(filename: string) { return normalize(filename).replace(normalize(process.cwd()), '') } diff --git a/test/coverage-test/custom-provider.ts b/test/coverage-test/custom-provider.ts new file mode 100644 index 000000000000..16f0391fdcf9 --- /dev/null +++ b/test/coverage-test/custom-provider.ts @@ -0,0 +1,75 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import type { AfterSuiteRunMeta, CoverageProvider, CoverageProviderModule, ReportContext, ResolvedCoverageOptions, Vitest } from 'vitest' + +import { normalizeFilename } from './coverage-report-tests/utils' + +const CustomCoverageProviderModule: CoverageProviderModule = { + getProvider(): CoverageProvider { + return new CustomCoverageProvider() + }, + + takeCoverage() { + return { customCoverage: 'Coverage report passed from workers to main thread' } + }, +} + +/** + * Provider that simply keeps track of the functions that were called + */ +class CustomCoverageProvider implements CoverageProvider { + name = 'custom-coverage-provider' + + options!: ResolvedCoverageOptions + calls: string[] = [] + transformedFiles: Set = new Set() + + initialize(ctx: Vitest) { + this.options = ctx.config.coverage + + this.calls.push(`initialized ${ctx ? 'with' : 'without'} context`) + } + + clean(force: boolean) { + this.calls.push(`clean ${force ? 'with' : 'without'} force`) + } + + onBeforeFilesRun() { + this.calls.push('onBeforeFilesRun') + } + + onAfterSuiteRun(meta: AfterSuiteRunMeta) { + this.calls.push(`onAfterSuiteRun with ${JSON.stringify(meta)}`) + } + + reportCoverage(reportContext?: ReportContext) { + this.calls.push(`reportCoverage with ${JSON.stringify(reportContext)}`) + + const jsonReport = JSON.stringify({ + calls: this.calls, + transformedFiles: Array.from(this.transformedFiles.values()).sort(), + }, null, 2) + + if (existsSync('./coverage')) + rmSync('./coverage', { maxRetries: 10, recursive: true }) + + mkdirSync('./coverage') + writeFileSync('./coverage/custom-coverage-provider-report.json', jsonReport, 'utf-8') + } + + onFileTransform(code: string, id: string) { + const filename = normalizeFilename(id) + + if (/\/src\//.test(filename)) + this.transformedFiles.add(filename) + + return { code } + } + + resolveOptions(): ResolvedCoverageOptions { + this.calls.push('resolveOptions') + + return this.options + } +} + +export default CustomCoverageProviderModule diff --git a/test/coverage-test/package.json b/test/coverage-test/package.json index 32142f1da245..f97eb6e10528 100644 --- a/test/coverage-test/package.json +++ b/test/coverage-test/package.json @@ -2,8 +2,9 @@ "name": "@vitest/test-coverage", "private": true, "scripts": { - "test": "pnpm test:c8 && pnpm test:istanbul && pnpm test:types", + "test": "pnpm test:c8 && pnpm test:istanbul && pnpm test:custom && pnpm test:types", "test:c8": "node ./testing.mjs --provider c8", + "test:custom": "node ./testing.mjs --provider custom-provider", "test:istanbul": "node ./testing.mjs --provider istanbul", "test:types": "vitest typecheck --run" }, diff --git a/test/coverage-test/test/configuration-options.test-d.ts b/test/coverage-test/test/configuration-options.test-d.ts index 025ef4155d50..252c6554b713 100644 --- a/test/coverage-test/test/configuration-options.test-d.ts +++ b/test/coverage-test/test/configuration-options.test-d.ts @@ -1,5 +1,4 @@ import { assertType, test } from 'vitest' -import type { ResolvedCoverageOptions, Vitest } from 'vitest' import type { defineConfig } from 'vitest/config' type NarrowToTestConfig = T extends { test?: any } ? NonNullable : never @@ -9,40 +8,11 @@ type Coverage = NonNullable test('providers, built-in', () => { assertType({ provider: 'c8' }) assertType({ provider: 'istanbul' }) - - // @ts-expect-error -- String options must be known built-in's - assertType({ provider: 'unknown-reporter' }) }) test('providers, custom', () => { assertType({ - provider: { - getProvider() { - return { - name: 'custom-provider', - initialize(_: Vitest) {}, - resolveOptions(): ResolvedCoverageOptions { - return { - clean: true, - cleanOnRerun: true, - enabled: true, - exclude: ['string'], - extension: ['string'], - reporter: ['html', 'json'], - reportsDirectory: 'string', - } - }, - clean(_: boolean) {}, - onBeforeFilesRun() {}, - onAfterSuiteRun({ coverage: _coverage }) {}, - reportCoverage() {}, - onFileTransform(_code: string, _id: string, ctx) { - ctx.getCombinedSourcemap() - }, - } - }, - takeCoverage() {}, - }, + provider: 'custom-provider', }) }) diff --git a/test/coverage-test/testing.mjs b/test/coverage-test/testing.mjs index 906b720ba65c..9113b3c7fa60 100644 --- a/test/coverage-test/testing.mjs +++ b/test/coverage-test/testing.mjs @@ -16,9 +16,9 @@ const configs = [ // Run tests for checking coverage report contents. ['coverage-report-tests', { include: [ - './coverage-report-tests/generic.report.test.ts', + ['c8', 'istanbul'].includes(provider) && './coverage-report-tests/generic.report.test.ts', `./coverage-report-tests/${provider}.report.test.ts`, - ], + ].filter(Boolean), coverage: { enabled: false, clean: false }, }], ] diff --git a/test/coverage-test/vitest.config.ts b/test/coverage-test/vitest.config.ts index 218d0d19e5fb..d246c628fd46 100644 --- a/test/coverage-test/vitest.config.ts +++ b/test/coverage-test/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ test: { watch: false, coverage: { - provider: provider as any, + provider, include: ['src/**'], clean: true, all: true,