Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
Something went wrong. Try again.
18 kB · 534 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535import 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 -- untypedimport { 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<CoverageMap> { const start = debug.enabled ? performance.now() : 0
const coverageMap = this.createCoverageMap()
const mergedScripts = new Map<ScriptCoverageWithOffset['url'], ScriptCoverageWithOffset>() const autoAttachSubprocess = this.options.autoAttachSubprocess
await this.readCoverageFiles<RawCoverage>({ 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<void> { 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<typeof libReport.create>[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<ProxifiedModule<any>> { const contents = await fs.readFile(configFilePath, 'utf8')
return parseModule(`${contents}${this.autoUpdateMarker}`) }
private async getCoverageMapForUncoveredFiles(testedFiles: string[]): Promise<CoverageMap> { 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<typeof setTimeout> | 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<ReturnType<typeof this.getSources>>, 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<Vite.TransformResult | undefined | null>, 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<CoverageMap> { 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}// <inline-source-map>` } } 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<typeof setTimeout> | 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}