import type { CoverageMap } from '@vitest/istanbul-lib-coverage' import type { ProxifiedModule } from 'magicast' import type { Profiler } from 'node:inspector' import type { CoverageProvider, ReportContext, TestProject, Vite, Vitest } from 'vitest/node' import { existsSync, promises as fs } from 'node:fs' import { fileURLToPath } from 'node:url' // @ts-expect-error -- untyped import { mergeScriptCovs } from '@bcoe/v8-coverage' import * as libCoverage from '@vitest/istanbul-lib-coverage' import * as libReport from '@vitest/istanbul-lib-report' import astV8ToIstanbul from 'ast-v8-to-istanbul' import { parseModule } from 'magicast' import { createDebug } from 'obug' import { normalize } from 'pathe' import { provider } from 'std-env' import c from 'tinyrainbow' import { BaseCoverageProvider, parseAstAsync } from 'vitest/node' import { version } from '../package.json' with { type: 'json' } import { commands } from './commands' export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage { startOffset: number /** Whether script ran outside Vite, e.g. in sub-processes or worker threads */ isExtendedContext?: boolean } interface RawCoverage { result: ScriptCoverageWithOffset[] } const FILE_PROTOCOL = 'file://' const debug = createDebug('vitest:coverage') export class V8CoverageProvider extends BaseCoverageProvider implements CoverageProvider { name = 'v8' as const version: string = version initialize(ctx: Vitest): void { this._initialize(ctx) for (const project of ctx.projects) { if (project.isBrowserEnabled() && project.browser) { for (const [name, command] of Object.entries(commands)) { project.browser.registerCommand(`__vitest_${name}` as any, command) } } } if (this.options.autoAttachSubprocess) { const isAnyThreadsPools = ctx.projects.some( (p) => p.config.pool === 'threads' || p.config.pool === 'vmThreads', ) if (isAnyThreadsPools) { // Work-around for https://github.com/nodejs/node/issues/46378 // Node never does anything with this directory, it's just required so that // the next Workers read **their** env.NODE_V8_COVERAGE. // Node never creates this .unused directory at all. process.env.NODE_V8_COVERAGE = `${this.coverageFilesDirectory}/.unused` } } } createCoverageMap(): CoverageMap { return libCoverage.createCoverageMap({}) } async generateCoverage({ allTestsRun }: ReportContext): Promise { const start = debug.enabled ? performance.now() : 0 const coverageMap = this.createCoverageMap() const mergedScripts = new Map() const autoAttachSubprocess = this.options.autoAttachSubprocess await this.readCoverageFiles({ onFileRead(coverage) { for (const script of coverage.result) { const previous = mergedScripts.get(script.url) const merged: typeof script = mergeScriptCovs(previous ? [previous, script] : [script]) const startOffset = previous?.startOffset || script.startOffset || 0 const isExtendedContext = previous?.isExtendedContext || script.isExtendedContext merged.startOffset ||= startOffset if (autoAttachSubprocess && isExtendedContext) { merged.isExtendedContext = true } mergedScripts.set(merged.url, merged) } }, onFinished: async (project, environment) => { // Source maps can change based on projectName and transform mode. // Coverage transform re-uses source maps so we need to separate transforms from each other. const converted = await this.convertCoverage( { result: Array.from(mergedScripts.values()) }, project, environment, ) coverageMap.merge(converted) mergedScripts.clear() }, onDebug: debug, }) // Include untested files when all tests were run (not a single file re-run) // or if previous results are preserved by "cleanOnRerun: false" if (this.options.include != null && (allTestsRun || !this.options.cleanOnRerun)) { const coveredFiles = coverageMap.files() const untestedCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles) coverageMap.merge(untestedCoverage) } coverageMap.filter((filename) => { const exists = existsSync(filename) if (this.options.excludeAfterRemap) { return exists && this.isIncluded(filename) } return exists }) if (debug.enabled) { debug(`Generate coverage total time ${(performance.now() - start!).toFixed()} ms`) } return coverageMap } async generateReports(coverageMap: CoverageMap, allTestsRun?: boolean): Promise { if (provider === 'stackblitz') { this.ctx.logger.log( c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'), ) } const context = libReport.createContext({ dir: this.options.reportsDirectory, coverageMap, watermarks: this.options.watermarks, }) if (this.hasTerminalReporter(this.options.reporter)) { this.ctx.logger.log(c.blue(' % ') + c.dim('Coverage report from ') + c.yellow(this.name)) } for (const reporter of this.options.reporter) { // Type assertion required for custom reporters const reportInstance = await libReport.createAsync( reporter[0] as Parameters[0], { skipFull: this.options.skipFull, projectRoot: this.ctx.config.root, ...reporter[1], }, ) reportInstance.execute(context) } if (this.options.thresholds) { await this.reportThresholds(coverageMap, allTestsRun) } } async parseConfigModule(configFilePath: string): Promise> { const contents = await fs.readFile(configFilePath, 'utf8') return parseModule(`${contents}${this.autoUpdateMarker}`) } private async getCoverageMapForUncoveredFiles(testedFiles: string[]): Promise { const transform = this.createUncoveredFileTransformer(this.ctx) const uncoveredFiles = await this.getUntestedFiles(testedFiles) let index = 0 const coverageMap = this.createCoverageMap() for (const chunk of this.toSlices(uncoveredFiles, this.options.processingConcurrency)) { if (debug.enabled) { index += chunk.length debug('Uncovered files %d/%d', index, uncoveredFiles.length) } await Promise.all( chunk.map(async (filename) => { let timeout: ReturnType | undefined let start: number | undefined if (debug.enabled) { start = performance.now() timeout = setTimeout( () => debug(c.bgRed(`File "${filename}" is taking longer than 3s`)), 3_000, ) } // Do not use pathToFileURL to avoid encoding filename parts const url = `file://${filename[0] === '/' ? '' : '/'}${filename}` const sources = await this.getSources(url, transform) coverageMap.merge(await this.remapCoverage(url, 0, sources, [])) if (debug.enabled) { clearTimeout(timeout) const diff = performance.now() - start! const color = diff > 500 ? c.bgRed : c.bgGreen debug(`${color(` ${diff.toFixed()} ms `)} ${filename}`) } }), ) } return coverageMap } private async remapCoverage( filename: string, wrapperLength: number, result: Awaited>, functions: Profiler.FunctionCoverage[], ) { let ast try { ast = await parseAstAsync(result.code) } catch (error) { this.ctx.logger.error(`Failed to parse ${filename}. Excluding it from coverage.\n`, error) return {} } return (await astV8ToIstanbul({ code: result.code, sourceMap: result.map, ast, coverage: { functions, url: filename }, ignoreClassMethods: this.options.ignoreClassMethods, wrapperLength, ignoreNode: (node, type) => { // SSR transformed imports if ( type === 'statement' && node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.id.name.startsWith('__vite_ssr_import_') ) { return true } // SSR transformed import binding if ( type === 'statement' && node.type === 'VariableDeclarator' && node.init && node.init.type === 'MemberExpression' && node.init.object.type === 'MemberExpression' && node.init.object.object.type === 'Identifier' && node.init.object.object.name.startsWith('__vite_ssr_import_') ) { return true } // SSR transformed exports vite@>6.3.5 if ( type === 'statement' && node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && node.expression.left.type === 'MemberExpression' && node.expression.left.object.type === 'Identifier' && node.expression.left.object.name === '__vite_ssr_exports__' ) { return true } // SSR transformed exports vite@^6.3.5 if ( type === 'statement' && node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.id.name === '__vite_ssr_export_default__' ) { return true } // CJS imports as ternaries - e.g. // const React = __vite__cjsImport0_react.__esModule ? __vite__cjsImport0_react.default : __vite__cjsImport0_react; if ( type === 'branch' && node.type === 'ConditionalExpression' && node.test.type === 'MemberExpression' && node.test.object.type === 'Identifier' && node.test.object.name.startsWith('__vite__cjsImport') && node.test.property.type === 'Identifier' && node.test.property.name === '__esModule' ) { return true } // in-source test with "if (import.meta.vitest)" if ( (type === 'branch' || type === 'statement') && node.type === 'IfStatement' && node.test.type === 'MemberExpression' && node.test.property.type === 'Identifier' && node.test.property.name === 'vitest' ) { // SSR if ( node.test.object.type === 'Identifier' && node.test.object.name === '__vite_ssr_import_meta__' ) { return 'ignore-this-and-nested-nodes' } // Web if ( node.test.object.type === 'MetaProperty' && node.test.object.meta.name === 'import' && node.test.object.property.name === 'meta' ) { return 'ignore-this-and-nested-nodes' } } // Browser mode's "import.meta.env =" if ( type === 'statement' && node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && node.expression.left.type === 'MemberExpression' && node.expression.left.object.type === 'MetaProperty' && node.expression.left.object.meta.name === 'import' && node.expression.left.object.property.name === 'meta' && node.expression.left.property.type === 'Identifier' && node.expression.left.property.name === 'env' ) { return true } // SSR mode's "import.meta.env =" if ( type === 'statement' && node.type === 'ExpressionStatement' && node.expression.type === 'AssignmentExpression' && node.expression.left.type === 'MemberExpression' && node.expression.left.object.type === 'Identifier' && node.expression.left.object.name === '__vite_ssr_import_meta__' ) { return true } // SWC's decorators if ( type === 'statement' && node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' && node.expression.callee.type === 'Identifier' && node.expression.callee.name === '_ts_decorate' ) { return 'ignore-this-and-nested-nodes' } }, })) as libCoverage.CoverageMapData } private async getSources( url: string, onTransform: ( filepath: string, isExtendedContext?: ScriptCoverageWithOffset['isExtendedContext'], ) => Promise, functions: Profiler.FunctionCoverage[] = [], isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false, ): Promise<{ code: string map?: Vite.Rollup.SourceMap }> { // TODO: need to standardize file urls before this call somehow, this is messy const filepath = /^file:\/\/\/\w:\//.test(url) ? url.slice(8) : removeStartsWith(url, FILE_PROTOCOL) // TODO: do we still need to "catch" here? why would it fail? const transformResult = await onTransform(filepath, isExtendedContext).catch(() => null) const map = transformResult?.map as Vite.Rollup.SourceMap | undefined const code = transformResult?.code if (code == null) { const filePath = normalize(fileURLToPath(url)) const original = await fs.readFile(filePath, 'utf-8').catch(() => { // If file does not exist construct a dummy source for it. // These can be files that were generated dynamically during the test run and were removed after it. const length = findLongestFunctionLength(functions) return '/'.repeat(length) }) return { code: original } } // Vue needs special handling for "map.sources" if (map) { map.sources ||= [] map.sources = map.sources .filter((source) => source != null) .map((source) => new URL(source, url).href) if (map.sources.length === 0) { map.sources.push(url) } } return { code, map } } private async convertCoverage( coverage: RawCoverage, project: TestProject = this.ctx.getRootProject(), environment: string, ): Promise { const onTransform = async ( filepath: string, isExtendedContext: ScriptCoverageWithOffset['isExtendedContext'] = false, ) => { const result = await this.transformFile(filepath, project, environment, !isExtendedContext) if (result && project.isBrowserEnabled()) { return { ...result, code: `${result.code}// ` } } return result } const scriptCoverages = [] for (const result of coverage.result) { if (environment === 'client' && project.isBrowserEnabled()) { if (result.url.startsWith('/@fs')) { result.url = `${FILE_PROTOCOL}${removeStartsWith(result.url, '/@fs')}` } else if (result.url.startsWith(project.config.root)) { result.url = `${FILE_PROTOCOL}${result.url}` } else { result.url = `${FILE_PROTOCOL}${project.config.root}${result.url}` } } let filePath // Skip results that cannot be converted to file paths, for example virtual files that don't prefix to project root try { filePath = fileURLToPath(result.url) } catch { continue } if (this.isIncluded(filePath)) { scriptCoverages.push({ ...result, url: decodeURIComponent(result.url) }) } } const coverageMap = this.createCoverageMap() let index = 0 for (const chunk of this.toSlices(scriptCoverages, this.options.processingConcurrency)) { if (debug.enabled) { index += chunk.length debug('Converting %d/%d', index, scriptCoverages.length) } await Promise.all( chunk.map(async ({ url, functions, startOffset, isExtendedContext }) => { let timeout: ReturnType | undefined let start: number | undefined if (debug.enabled) { start = performance.now() timeout = setTimeout( () => debug(c.bgRed(`File "${fileURLToPath(url)}" is taking longer than 3s`)), 3_000, ) } const sources = await this.getSources(url, onTransform, functions, isExtendedContext) coverageMap.merge(await this.remapCoverage(url, startOffset, sources, functions)) if (debug.enabled) { clearTimeout(timeout) const diff = performance.now() - start! const color = diff > 500 ? c.bgRed : c.bgGreen debug(`${color(` ${diff.toFixed()} ms `)} ${fileURLToPath(url)}`) } }), ) } return coverageMap } } /** * Find the function with highest `endOffset` to determine the length of the file */ function findLongestFunctionLength(functions: Profiler.FunctionCoverage[]) { return functions.reduce((previous, current) => { const maxEndOffset = current.ranges.reduce( (endOffset, range) => Math.max(endOffset, range.endOffset), 0, ) return Math.max(previous, maxEndOffset) }, 0) } function removeStartsWith(filepath: string, start: string) { if (filepath.startsWith(start)) { return filepath.slice(start.length) } return filepath }