From 1bfd7e09601cdb899aa30c125db76f751bc55cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Tue, 19 Jul 2022 09:25:46 +0000 Subject: [PATCH] feat: support `istanbul` coverage provider --- packages/vitest/package.json | 5 + .../coverage/NullCoverageProvider.ts | 1 + .../vitest/src/integrations/coverage/base.ts | 10 +- .../vitest/src/integrations/coverage/index.ts | 5 + .../src/integrations/coverage/istanbul.ts | 152 ++++++++++++++++++ packages/vitest/src/node/cli-api.ts | 15 +- packages/vitest/src/node/plugins/index.ts | 2 + .../vitest/src/node/plugins/instrumenter.ts | 18 +++ packages/vitest/src/node/pool.ts | 4 +- packages/vitest/src/runtime/run.ts | 4 +- packages/vitest/src/types/coverage.ts | 46 +++++- packages/vitest/src/types/worker.ts | 2 +- pnpm-lock.yaml | 20 ++- test/coverage-test/vitest.config.ts | 4 + 14 files changed, 272 insertions(+), 16 deletions(-) create mode 100644 packages/vitest/src/integrations/coverage/istanbul.ts create mode 100644 packages/vitest/src/node/plugins/instrumenter.ts diff --git a/packages/vitest/package.json b/packages/vitest/package.json index c1497e680cab..24ad51e2b0f9 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -126,6 +126,11 @@ "find-up": "^6.3.0", "flatted": "^3.2.6", "happy-dom": "^6.0.3", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.2.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.1.5", "jsdom": "^19.0.0", "log-update": "^5.0.1", "magic-string": "^0.26.2", diff --git a/packages/vitest/src/integrations/coverage/NullCoverageProvider.ts b/packages/vitest/src/integrations/coverage/NullCoverageProvider.ts index c85055bd8e41..042cc1516be7 100644 --- a/packages/vitest/src/integrations/coverage/NullCoverageProvider.ts +++ b/packages/vitest/src/integrations/coverage/NullCoverageProvider.ts @@ -10,6 +10,7 @@ export class NullCoverageProvider implements BaseCoverageProvider { cleanOnRerun: false, reportsDirectory: 'coverage', tempDirectory: 'coverage/tmp', + reporter: [], } } diff --git a/packages/vitest/src/integrations/coverage/base.ts b/packages/vitest/src/integrations/coverage/base.ts index bca5c6015127..53baff024629 100644 --- a/packages/vitest/src/integrations/coverage/base.ts +++ b/packages/vitest/src/integrations/coverage/base.ts @@ -1,3 +1,5 @@ +import type { ExistingRawSourceMap, TransformPluginContext } from 'rollup' + import type { Vitest } from '../../node' import type { ResolvedCoverageOptions } from '../../types' @@ -10,5 +12,11 @@ export interface BaseCoverageProvider { onBeforeFilesRun?(): void | Promise onAfterAllFilesRun(): void | Promise - onAfterSuiteRun(): void | Promise + onAfterSuiteRun(collectedCoverage: any): void | Promise + + onFileTransform?( + sourceCode: string, + id: string, + pluginCtx: TransformPluginContext + ): void | { code: string; map: ExistingRawSourceMap } } diff --git a/packages/vitest/src/integrations/coverage/index.ts b/packages/vitest/src/integrations/coverage/index.ts index 3efe3ccff372..45e963554fc1 100644 --- a/packages/vitest/src/integrations/coverage/index.ts +++ b/packages/vitest/src/integrations/coverage/index.ts @@ -1,6 +1,7 @@ import type { CoverageOptions } from '../../types' import type { BaseCoverageProvider } from './base' import { C8CoverageProvider } from './c8' +import { IstanbulCoverageProvider } from './istanbul' import { NullCoverageProvider } from './NullCoverageProvider' const CoverageProviderMap: Record< @@ -8,6 +9,7 @@ const CoverageProviderMap: Record< { new(): BaseCoverageProvider; getCoverage(): any } > = { c8: C8CoverageProvider, + istanbul: IstanbulCoverageProvider, } export function getCoverageProvider(options?: CoverageOptions): BaseCoverageProvider { @@ -17,6 +19,9 @@ export function getCoverageProvider(options?: CoverageOptions): BaseCoverageProv return new CoverageProvider() } + if (options?.enabled && options?.provider === 'istanbul') + return new IstanbulCoverageProvider() + return new NullCoverageProvider() } diff --git a/packages/vitest/src/integrations/coverage/istanbul.ts b/packages/vitest/src/integrations/coverage/istanbul.ts new file mode 100644 index 000000000000..99daec3bdd74 --- /dev/null +++ b/packages/vitest/src/integrations/coverage/istanbul.ts @@ -0,0 +1,152 @@ +import { existsSync, promises as fs } from 'fs' +import { createRequire } from 'module' +import { resolve } from 'pathe' +import type { ExistingRawSourceMap, TransformPluginContext } from 'rollup' + +import { configDefaults, defaultExclude, defaultInclude } from '../../defaults' +import type { Vitest } from '../../node' +import type { IstanbulOptions, ResolvedCoverageOptions } from '../../types' +import type { BaseCoverageProvider } from './base' + +const require = createRequire(import.meta.url) +const coverageVariable = '__VITEST_COVERAGE__' + +interface Instrumenter { + /* Instrument the supplied code and track coverage against the supplied filename. It throws if invalid code is passed to it. ES5 and ES6 syntax is supported. To instrument ES6 modules, make sure that you set the esModules property to true when creating the instrumenter. */ + instrumentSync( + /* The code to instrument */ + code: string, + /* The filename against which to track coverage. */ + filename: string, + /* The source map that maps the not instrumented code back to it's original form. Is assigned to the coverage object and therefore, is available in the json output and can be used to remap the coverage to the untranspiled source.): string; */ + inputSourceMap: object + ): string + + /* Returns the file coverage object for the last file instrumented. */ + lastSourceMap(): ExistingRawSourceMap +} + +interface TestExclude { + new(opts: { + cwd?: string | string[] + include?: string | string[] + exclude?: string | string[] + extension?: string | string[] + excludeNodeModules?: boolean + }): { shouldInstrument(filePath: string): boolean } +} + +export class IstanbulCoverageProvider implements BaseCoverageProvider { + ctx!: Vitest + options!: ResolvedCoverageOptions & { provider: 'istanbul' } + instrumenter!: Instrumenter + testExclude!: InstanceType + coverages: any[] = [] + + initialize(ctx: Vitest) { + this.ctx = ctx + this.options = resolveIstanbulOptions(ctx.config.coverage, ctx.config.root) + + const { createInstrumenter } = require('istanbul-lib-instrument') + this.instrumenter = createInstrumenter(this.options) + + const TestExclude = require('test-exclude') + + this.testExclude = new TestExclude({ + cwd: ctx.config.root, + // TODO: Should we add a custom `coverage.exclude` to IstanbulOptions? It could be passed here. + exclude: [...defaultExclude, ...defaultInclude], + extension: ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.vue', '.svelte'], + excludeNodeModules: true, + }) + } + + resolveOptions(): ResolvedCoverageOptions { + return this.options + } + + onFileTransform(sourceCode: string, id: string, pluginCtx: TransformPluginContext) { + if (!this.testExclude.shouldInstrument(id)) + return + + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- ignoreRestSiblings should be enabled + const { sourcesContent, ...sourceMap } = pluginCtx.getCombinedSourcemap() + const code = this.instrumenter.instrumentSync(sourceCode, id, sourceMap) + const map = this.instrumenter.lastSourceMap() + + return { code, map } + } + + onAfterSuiteRun(coverage: any) { + // TODO: Some implementations write these into file system instead of storing in memory. + // Then when merging the results, JSONs are read & deleted from fs and convert into coverageMap + this.coverages.push(coverage) + } + + async clean(clean = true) { + if (clean && existsSync(this.options.reportsDirectory)) + await fs.rm(this.options.reportsDirectory, { recursive: true, force: true }) + + this.coverages = [] + } + + async onAfterAllFilesRun() { + const libReport = require('istanbul-lib-report') + const reports = require('istanbul-reports') + const libCoverage = require('istanbul-lib-coverage') + const libSourceMaps = require('istanbul-lib-source-maps') + + const mergedCoverage = this.coverages.reduce((coverage, previousCoverageMap) => { + const map = libCoverage.createCoverageMap(coverage) + map.merge(previousCoverageMap) + + return map + }, {}) + + const sourceMapStore = libSourceMaps.createSourceMapStore() + const coverageMap = await sourceMapStore.transformCoverage(mergedCoverage) + + const context = libReport.createContext({ + dir: this.options.reportsDirectory, + coverageMap, + sourceFinder: sourceMapStore.sourceFinder, + }) + + for (const reporter of this.options.reporter) + reports.create(reporter).execute(context) + } + + static getCoverage() { + // @ts-expect-error -- untyped global + return globalThis[coverageVariable] + } +} + +function resolveIstanbulOptions(options: IstanbulOptions, root: string) { + const reportsDirectory = resolve(root, options.reportsDirectory || configDefaults.coverage.reportsDirectory!) + + const resolved = { + ...configDefaults.coverage, + + provider: 'istanbul', + + // Defaults from nyc, https://github.com/istanbuljs/nyc/blob/master/lib/instrumenters/istanbul.js#L7 + preserveComments: true, + produceSourceMap: true, + autoWrap: true, + esModules: true, + + // Overrides + ...options, + + // Options of nyc which should not be overriden + coverageVariable, + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + + reportsDirectory, + tempDirectory: resolve(reportsDirectory, 'tmp'), + } + + return resolved as ResolvedCoverageOptions & { provider: 'istanbul' } +} diff --git a/packages/vitest/src/node/cli-api.ts b/packages/vitest/src/node/cli-api.ts index 5527c99767c1..1aee929cc763 100644 --- a/packages/vitest/src/node/cli-api.ts +++ b/packages/vitest/src/node/cli-api.ts @@ -37,9 +37,18 @@ export async function startVitest(cliFilters: string[], options: CliOptions, vit const ctx = await createVitest(options, viteOverrides) if (ctx.config.coverage.enabled) { - const requiredPackages = ctx.config.coverage.provider === 'c8' - ? ['c8'] - : [] + // TODO: Requring all these packages to be installed by users may be too much + const requiredPackages = ctx.config.coverage.provider === 'istanbul' + ? [ + 'istanbul-lib-coverage', + 'istanbul-lib-instrument', + 'istanbul-lib-report', + 'istanbul-reports', + 'istanbul-lib-source-maps', + ] + : ctx.config.coverage.provider === 'c8' + ? ['c8'] + : [] for (const pkg of requiredPackages) { if (!await ensurePackageInstalled(pkg, root)) { diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index aab48c57f40d..0b050a7889c6 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -8,6 +8,7 @@ import { EnvReplacerPlugin } from './envRelacer' import { GlobalSetupPlugin } from './globalSetup' import { MocksPlugin } from './mock' import { CSSEnablerPlugin } from './cssEnabler' +import { InstrumenterPlugin } from './instrumenter' export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()): Promise { let haveStarted = false @@ -166,6 +167,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()) ? await BrowserPlugin() : []), CSSEnablerPlugin(ctx), + InstrumenterPlugin(ctx), options.ui ? await UIPlugin() : null, diff --git a/packages/vitest/src/node/plugins/instrumenter.ts b/packages/vitest/src/node/plugins/instrumenter.ts new file mode 100644 index 000000000000..dbc01f6a20af --- /dev/null +++ b/packages/vitest/src/node/plugins/instrumenter.ts @@ -0,0 +1,18 @@ +import type { Plugin as VitePlugin } from 'vite' + +import type { Vitest } from '../core' + +export function InstrumenterPlugin(ctx: Vitest): VitePlugin | null { + // // Skip coverage reporters which do not need code transforms, e.g. native v8 + // TODO: This would be great but ctx has not yet been initialized + // if (typeof ctx.coverageProvider.onFileTransform !== 'function') + // return null + + return { + name: 'vitest:instrumenter', + + transform(srcCode, id) { + return ctx.coverageProvider?.onFileTransform?.(srcCode, id, this) + }, + } +} diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index a617ae1d20c7..0fdb0299fb23 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -155,8 +155,8 @@ function createChannel(ctx: Vitest) { ctx.state.collectFiles(files) ctx.report('onCollected', files) }, - onFilesRun() { - ctx.coverageProvider.onAfterSuiteRun() + onFilesRun(coverage: any) { + ctx.coverageProvider.onAfterSuiteRun(coverage) }, onTaskUpdate(packs) { ctx.state.updateTasks(packs) diff --git a/packages/vitest/src/runtime/run.ts b/packages/vitest/src/runtime/run.ts index 7654b55d0eff..b4cdb4267eca 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -316,8 +316,8 @@ async function startTestsNode(paths: string[], config: ResolvedConfig) { await runFiles(files, config) - getCoverageInsideWorker(config.coverage) - rpc().onFilesRun() + const coverage = getCoverageInsideWorker(config.coverage) + rpc().onFilesRun(coverage) await getSnapshotClient().saveCurrent() diff --git a/packages/vitest/src/types/coverage.ts b/packages/vitest/src/types/coverage.ts index 71be19e02454..e3e2d17e18f1 100644 --- a/packages/vitest/src/types/coverage.ts +++ b/packages/vitest/src/types/coverage.ts @@ -18,6 +18,7 @@ export type CoverageReporter = export type CoverageOptions = | NullCoverageOptions & { provider?: null } | C8Options & { provider?: 'c8' } + | IstanbulOptions & { provider?: 'istanbul' } interface BaseCoverageOptions { /** @@ -45,12 +46,51 @@ interface BaseCoverageOptions { * Directory to write coverage report to */ reportsDirectory?: string + + /** + * Reporters + * + * @default 'text' + */ + reporter?: Arrayable } export interface NullCoverageOptions extends BaseCoverageOptions { enabled: false } +export interface IstanbulOptions extends BaseCoverageOptions { + /* Report boolean value of logical expressions. (optional, default false) */ + reportLogic?: boolean + + /* Preserve comments in output. (optional, default false) */ + preserveComments?: boolean + + /* Generate compact code. (optional, default true) */ + compact?: boolean + + /* Set to true to instrument ES6 modules. (optional, default false) */ + esModules?: boolean + + /* Set to true to allow return statements outside of functions. (optional, default false) */ + autoWrap?: boolean + + /* Set to true to produce a source map for the instrumented code. (optional, default false) */ + produceSourceMap?: boolean + + /* Set to array of class method names to ignore for coverage. (optional, default []) */ + ignoreClassMethods?: string[] + + /* A callback function that is called when a source map URL. is found in the original code. This function is called with the source file name and the source map URL. (optional, default null) */ + sourceMapUrlCallback?: Function + + /* Turn debugging on. (optional, default false) */ + debug?: boolean + + /* Set babel parser plugins, see @istanbuljs/schema for defaults. */ + parserPlugins?: string[] +} + export interface C8Options extends BaseCoverageOptions { /** * Allow files from outside of your cwd. @@ -58,12 +98,6 @@ export interface C8Options extends BaseCoverageOptions { * @default false */ allowExternal?: any - /** - * Reporters - * - * @default 'text' - */ - reporter?: Arrayable /** * Exclude coverage under /node_modules/ * diff --git a/packages/vitest/src/types/worker.ts b/packages/vitest/src/types/worker.ts index 68eff9c88bfb..26d4daf48157 100644 --- a/packages/vitest/src/types/worker.ts +++ b/packages/vitest/src/types/worker.ts @@ -28,7 +28,7 @@ export interface WorkerRPC { onUserConsoleLog: (log: UserConsoleLog) => void onUnhandledRejection: (err: unknown) => void onCollected: (files: File[]) => void - onFilesRun: () => void + onFilesRun: (coverage: any) => void onTaskUpdate: (pack: TaskResultPack[]) => void snapshotSaved: (snapshot: SnapshotResult) => void diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b5b15488247..109134c8a84b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -713,6 +713,11 @@ importers: find-up: ^6.3.0 flatted: ^3.2.6 happy-dom: ^6.0.3 + istanbul-lib-coverage: ^3.2.0 + istanbul-lib-instrument: ^5.2.0 + istanbul-lib-report: ^3.0.0 + istanbul-lib-source-maps: ^4.0.1 + istanbul-reports: ^3.1.5 jsdom: ^19.0.0 local-pkg: ^0.4.2 log-update: ^5.0.1 @@ -767,6 +772,11 @@ importers: find-up: 6.3.0 flatted: 3.2.6 happy-dom: 6.0.3 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-instrument: 5.2.0 + istanbul-lib-report: 3.0.0 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 jsdom: 19.0.0 log-update: 5.0.1 magic-string: 0.26.2 @@ -8799,7 +8809,7 @@ packages: foreground-child: 2.0.0 istanbul-lib-coverage: 3.2.0 istanbul-lib-report: 3.0.0 - istanbul-reports: 3.1.4 + istanbul-reports: 3.1.5 rimraf: 3.0.2 test-exclude: 6.0.0 v8-to-istanbul: 9.0.0 @@ -13910,6 +13920,14 @@ packages: istanbul-lib-report: 3.0.0 dev: true + /istanbul-reports/3.1.5: + resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.0 + dev: true + /iterate-iterator/1.0.2: resolution: {integrity: sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw==} dev: true diff --git a/test/coverage-test/vitest.config.ts b/test/coverage-test/vitest.config.ts index 3944cd39f032..fdd88001e8ca 100644 --- a/test/coverage-test/vitest.config.ts +++ b/test/coverage-test/vitest.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ MY_CONSTANT: '"my constant"', }, test: { + // coverage: { + // enabled: true, + // provider: 'istanbul', + // }, threads: !!process.env.THREAD, include: [ 'test/*.test.ts',