diff --git a/packages/vitest/package.json b/packages/vitest/package.json index c1497e680cab..93b3babcbafe 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -126,6 +126,10 @@ "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-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/base.ts b/packages/vitest/src/integrations/coverage/base.ts index a380b741c64b..67e2940cf104 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 BaseCoverageReporter { 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/istanbul.ts b/packages/vitest/src/integrations/coverage/istanbul.ts new file mode 100644 index 000000000000..c9d171ad7f3c --- /dev/null +++ b/packages/vitest/src/integrations/coverage/istanbul.ts @@ -0,0 +1,143 @@ +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 { BaseCoverageReporter } 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 IstanbulReporter implements BaseCoverageReporter { + 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, + 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 + + // TODO: Sourcemaps are not accurate. + // The c8 is using some "magic number" of 224 as offset. Maybe that one is required here. + + // 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 = [] + } + + onAfterAllFilesRun() { + const libReport = require('istanbul-lib-report') + const reports = require('istanbul-reports') + const libCoverage = require('istanbul-lib-coverage') + + const coverageMap = this.coverages.reduce((coverage, previousCoverageMap) => { + const map = libCoverage.createCoverageMap(coverage) + map.merge(previousCoverageMap) + + return map + }, {}) + + const context = libReport.createContext({ + dir: this.options.reportsDirectory, + coverageMap, + }) + + for (const reporter of this.options.reporter) + reports.create(reporter).execute(context) + } +} + +function resolveIstanbulOptions(options: IstanbulOptions, root: string) { + const reportsDirectory = resolve(root, options.reportsDirectory || configDefaults.coverage.reportsDirectory!) + + const resolved = { + ...configDefaults.coverage, + + // Custom + provider: 'istanbul', + coverageVariable, + coverageGlobalScope: 'globalThis', + coverageGlobalScopeFunc: false, + esModules: true, + + // Defaults from nyc, https://github.com/istanbuljs/nyc/blob/master/lib/instrumenters/istanbul.js#L7 + preserveComments: true, + produceSourceMap: true, + autoWrap: true, + + // Overrides + ...options, + + 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..b298d2bb3e5d 100644 --- a/packages/vitest/src/node/cli-api.ts +++ b/packages/vitest/src/node/cli-api.ts @@ -37,9 +37,15 @@ 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', + ] + : ['c8'] for (const pkg of requiredPackages) { if (!await ensurePackageInstalled(pkg, root)) { diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 438ac8656466..19aa7e867334 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -11,6 +11,7 @@ import { SnapshotManager } from '../integrations/snapshot/manager' import { clearTimeout, deepMerge, hasFailed, noop, setTimeout, slash } from '../utils' import type { BaseCoverageReporter } from '../integrations/coverage/base' import { C8Reporter } from '../integrations/coverage/c8' +import { IstanbulReporter } from '../integrations/coverage/istanbul' import { createPool } from './pool' import type { WorkerPool } from './pool' import { createReporters } from './reporters/utils' @@ -85,7 +86,7 @@ export class Vitest { this.reporters = await createReporters(resolved.reporters, this.runner) - this.coverageReporter = new C8Reporter() + this.coverageReporter = options.coverage?.provider === 'istanbul' ? new IstanbulReporter() : new C8Reporter() this.coverageReporter.initialize(this) this.config.coverage = this.coverageReporter.resolveOptions() diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index aab48c57f40d..054886f813c8 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,9 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest()) ? await BrowserPlugin() : []), CSSEnablerPlugin(ctx), + options.coverage?.enabled + ? InstrumenterPlugin(ctx) + : null, 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..cd128e2bb94c --- /dev/null +++ b/packages/vitest/src/node/plugins/instrumenter.ts @@ -0,0 +1,25 @@ +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.coverageReporter.onFileTransform !== 'function') + // return null + + return { + name: 'vitest:instrumenter', + + config(config) { + if (config.test?.coverage && !config?.build?.sourcemap) { + config.build = config.build || {} + config.build.sourcemap = true + } + }, + + transform(srcCode, id) { + return ctx.coverageReporter?.onFileTransform?.(srcCode, id, this) + }, + } +} diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index d14ea76b9cb9..8d98a41b4452 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -156,8 +156,8 @@ function createChannel(ctx: Vitest) { ctx.state.collectFiles(files) ctx.report('onCollected', files) }, - onFilesRun() { - ctx.coverageReporter.onAfterSuiteRun() + onFilesRun(coverage: any) { + ctx.coverageReporter.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 e610fd032dfe..910f6a994c55 100644 --- a/packages/vitest/src/runtime/run.ts +++ b/packages/vitest/src/runtime/run.ts @@ -315,9 +315,17 @@ async function startTestsNode(paths: string[], config: ResolvedConfig) { await runFiles(files, config) - // TODO: Not sure if this will work. Previously v8.takeCoverage() was called + // TODO: How could this __VITEST_COVERAGE__ specific logic be passed from pool.ts to here + // so that it would only be declared inside coverage/istanbul.ts? + // Passing globalThis to onFilesRun does not work + + // @ts-expect-error -- untyped global + const coverage = globalThis.__VITEST_COVERAGE__ + + // TODO: Not sure if this will work for c8. Previously v8.takeCoverage() was called // here inside the worker. Now, this will simply inform the main process to call it. - rpc().onFilesRun() + + rpc().onFilesRun(coverage) await getSnapshotClient().saveCurrent() diff --git a/packages/vitest/src/types/coverage.ts b/packages/vitest/src/types/coverage.ts index 342cf39c3ea9..0cecad676f4a 100644 --- a/packages/vitest/src/types/coverage.ts +++ b/packages/vitest/src/types/coverage.ts @@ -15,9 +15,11 @@ export type CoverageReporter = | 'text-summary' | 'text' -export type CoverageProviders = 'c8' +export type CoverageProviders = 'c8' | 'istanbul' -export type CoverageOptions = C8Options & { provider?: 'c8' } +export type CoverageOptions = + | ({ provider?: 'c8' } & C8Options) + | ({ provider?: 'istanbul' } & IstanbulOptions) interface BaseCoverageOptions { /** @@ -34,31 +36,74 @@ interface BaseCoverageOptions { */ cleanOnRerun?: boolean - /** - * Directory to write coverage report to - */ - reportsDirectory?: string -} - -export interface C8Options extends BaseCoverageOptions { /** * Clean coverage before running tests * * @default true */ clean?: boolean + /** - * Allow files from outside of your cwd. - * - * @default false + * Directory to write coverage report to */ - allowExternal?: any + reportsDirectory?: string + /** * Reporters * * @default 'text' */ reporter?: Arrayable +} + +export interface IstanbulOptions extends BaseCoverageOptions { + /* Name of global coverage variable. (optional, default __coverage__) */ + coverageVariable?: string + + /* 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[] + + /* The global coverage variable scope. (optional, default this) */ + coverageGlobalScope?: string + + /* Use an evaluated function to find coverageGlobalScope. (optional, default true) */ + coverageGlobalScopeFunc?: boolean +} + +export interface C8Options extends BaseCoverageOptions { + /** + * Allow files from outside of your cwd. + * + * @default false + */ + allowExternal?: any /** * 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..49055d0b4534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -713,6 +713,10 @@ 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-reports: ^3.1.5 jsdom: ^19.0.0 local-pkg: ^0.4.2 log-update: ^5.0.1 @@ -767,6 +771,10 @@ 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-reports: 3.1.5 jsdom: 19.0.0 log-update: 5.0.1 magic-string: 0.26.2 @@ -8799,7 +8807,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 +13918,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',