Skip to content

Commit

Permalink
feat: support istanbul coverage provider
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed Jul 20, 2022
1 parent 0c16d01 commit bc73f74
Show file tree
Hide file tree
Showing 13 changed files with 286 additions and 24 deletions.
4 changes: 4 additions & 0 deletions packages/vitest/package.json
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion 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'

Expand All @@ -10,5 +12,11 @@ export interface BaseCoverageReporter {

onBeforeFilesRun?(): void | Promise<void>
onAfterAllFilesRun(): void | Promise<void>
onAfterSuiteRun(): void | Promise<void>
onAfterSuiteRun(collectedCoverage: any): void | Promise<void>

onFileTransform?(
sourceCode: string,
id: string,
pluginCtx: TransformPluginContext
): void | { code: string; map: ExistingRawSourceMap }
}
143 changes: 143 additions & 0 deletions 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<TestExclude>
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' }
}
12 changes: 9 additions & 3 deletions packages/vitest/src/node/cli-api.ts
Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/vitest/src/node/core.ts
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/node/plugins/index.ts
Expand Up @@ -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<VitePlugin[]> {
let haveStarted = false
Expand Down Expand Up @@ -166,6 +167,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest())
? await BrowserPlugin()
: []),
CSSEnablerPlugin(ctx),
InstrumenterPlugin(ctx),
options.ui
? await UIPlugin()
: null,
Expand Down
25 changes: 25 additions & 0 deletions 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)
},
}
}
4 changes: 2 additions & 2 deletions packages/vitest/src/node/pool.ts
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions packages/vitest/src/runtime/run.ts
Expand Up @@ -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()

Expand Down
71 changes: 58 additions & 13 deletions packages/vitest/src/types/coverage.ts
Expand Up @@ -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 {
/**
Expand All @@ -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<CoverageReporter>
}

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/
*
Expand Down
2 changes: 1 addition & 1 deletion packages/vitest/src/types/worker.ts
Expand Up @@ -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
Expand Down

0 comments on commit bc73f74

Please sign in to comment.