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 Aug 2, 2022
1 parent a8d1d61 commit 15d1ddc
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 16 deletions.
5 changes: 5 additions & 0 deletions packages/vitest/package.json
Expand Up @@ -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",
Expand Down
Expand Up @@ -10,6 +10,7 @@ export class NullCoverageProvider implements BaseCoverageProvider {
cleanOnRerun: false,
reportsDirectory: 'coverage',
tempDirectory: 'coverage/tmp',
reporter: [],
}
}

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 BaseCoverageProvider {

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 }
}
2 changes: 2 additions & 0 deletions packages/vitest/src/integrations/coverage/index.ts
@@ -1,13 +1,15 @@
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<
NonNullable<CoverageOptions['provider']>,
{ new(): BaseCoverageProvider; getCoverage(): any }
> = {
c8: C8CoverageProvider,
istanbul: IstanbulCoverageProvider,
}

export function getCoverageProvider(options?: CoverageOptions): BaseCoverageProvider {
Expand Down
152 changes: 152 additions & 0 deletions 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<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,
// 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' }
}
15 changes: 12 additions & 3 deletions packages/vitest/src/node/cli-api.ts
Expand Up @@ -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)) {
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
18 changes: 18 additions & 0 deletions 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)
},
}
}
4 changes: 2 additions & 2 deletions packages/vitest/src/node/pool.ts
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/vitest/src/runtime/run.ts
Expand Up @@ -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()

Expand Down
46 changes: 40 additions & 6 deletions packages/vitest/src/types/coverage.ts
Expand Up @@ -18,6 +18,7 @@ export type CoverageReporter =
export type CoverageOptions =
| NullCoverageOptions & { provider?: null }
| C8Options & { provider?: 'c8' }
| IstanbulOptions & { provider?: 'istanbul' }

interface BaseCoverageOptions {
/**
Expand Down Expand Up @@ -45,25 +46,58 @@ interface BaseCoverageOptions {
* Directory to write coverage report to
*/
reportsDirectory?: string

/**
* Reporters
*
* @default 'text'
*/
reporter?: Arrayable<CoverageReporter>
}

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.
*
* @default false
*/
allowExternal?: any
/**
* Reporters
*
* @default 'text'
*/
reporter?: Arrayable<CoverageReporter>
/**
* 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 15d1ddc

Please sign in to comment.