mirror of
https://github.com/vitest-dev/vitest.git
synced 2025-12-08 18:26:03 +00:00
290 lines
9.8 KiB
TypeScript
290 lines
9.8 KiB
TypeScript
import type { CoverageMap } from 'istanbul-lib-coverage'
|
|
import type { Instrumenter } from 'istanbul-lib-instrument'
|
|
import type { ProxifiedModule } from 'magicast'
|
|
import type { CoverageProvider, ReportContext, ResolvedCoverageOptions, Vite, Vitest } from 'vitest/node'
|
|
import { existsSync, promises as fs } from 'node:fs'
|
|
// @ts-expect-error missing types
|
|
import { defaults as istanbulDefaults } from '@istanbuljs/schema'
|
|
import { addMapping, GenMapping, toEncodedMap } from '@jridgewell/gen-mapping'
|
|
import { eachMapping, TraceMap } from '@jridgewell/trace-mapping'
|
|
import libCoverage from 'istanbul-lib-coverage'
|
|
import { createInstrumenter } from 'istanbul-lib-instrument'
|
|
import libReport from 'istanbul-lib-report'
|
|
import libSourceMaps from 'istanbul-lib-source-maps'
|
|
import reports from 'istanbul-reports'
|
|
import { parseModule } from 'magicast'
|
|
import { createDebug } from 'obug'
|
|
import c from 'tinyrainbow'
|
|
import { BaseCoverageProvider } from 'vitest/coverage'
|
|
import { isCSSRequest } from 'vitest/node'
|
|
import { version } from '../package.json' with { type: 'json' }
|
|
import { COVERAGE_STORE_KEY } from './constants'
|
|
|
|
const debug = createDebug('vitest:coverage')
|
|
|
|
export class IstanbulCoverageProvider extends BaseCoverageProvider<ResolvedCoverageOptions<'istanbul'>> implements CoverageProvider {
|
|
name = 'istanbul' as const
|
|
version: string = version
|
|
instrumenter!: Instrumenter
|
|
|
|
private transformedModuleIds = new Set<string>()
|
|
|
|
initialize(ctx: Vitest): void {
|
|
this._initialize(ctx)
|
|
|
|
this.instrumenter = createInstrumenter({
|
|
produceSourceMap: true,
|
|
autoWrap: false,
|
|
esModules: true,
|
|
compact: false,
|
|
coverageVariable: COVERAGE_STORE_KEY,
|
|
coverageGlobalScope: 'globalThis',
|
|
coverageGlobalScopeFunc: false,
|
|
ignoreClassMethods: this.options.ignoreClassMethods,
|
|
parserPlugins: [
|
|
...istanbulDefaults.instrumenter.parserPlugins,
|
|
['importAttributes', { deprecatedAssertSyntax: true }],
|
|
],
|
|
generatorOpts: {
|
|
// @ts-expect-error missing type
|
|
importAttributesKeyword: 'with',
|
|
},
|
|
})
|
|
}
|
|
|
|
requiresTransform(id: string): boolean {
|
|
// Istanbul/babel cannot instrument CSS - e.g. Vue imports end up here.
|
|
// File extension itself is .vue, but it contains CSS.
|
|
// e.g. "Example.vue?vue&type=style&index=0&scoped=f7f04e08&lang.css"
|
|
if (isCSSRequest(id)) {
|
|
return false
|
|
}
|
|
|
|
if (!this.isIncluded(removeQueryParameters(id))) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
onFileTransform(sourceCode: string, id: string, pluginCtx: Vite.Rollup.TransformPluginContext): { code: string; map: any } | undefined {
|
|
if (!this.requiresTransform(id)) {
|
|
return
|
|
}
|
|
|
|
const sourceMap = pluginCtx.getCombinedSourcemap()
|
|
sourceMap.sources = sourceMap.sources.map(removeQueryParameters)
|
|
|
|
sourceCode = sourceCode
|
|
// Exclude SWC's decorators that are left in source maps
|
|
.replaceAll('_ts_decorate', '/* istanbul ignore next */_ts_decorate')
|
|
|
|
// Exclude in-source test's test cases
|
|
.replaceAll(/(if +\(import\.meta\.vitest\))/g, '/* istanbul ignore next */ $1')
|
|
|
|
const code = this.instrumenter.instrumentSync(
|
|
sourceCode,
|
|
id,
|
|
sourceMap as any,
|
|
)
|
|
|
|
if (!id.includes('vitest-uncovered-coverage=true')) {
|
|
const transformMap = new GenMapping(sourceMap)
|
|
|
|
eachMapping(new TraceMap(sourceMap as any), (mapping) => {
|
|
addMapping(transformMap, {
|
|
generated: { line: mapping.generatedLine, column: mapping.generatedColumn },
|
|
original: { line: mapping.generatedLine, column: mapping.generatedColumn },
|
|
content: sourceCode,
|
|
name: mapping.name || '',
|
|
source: mapping.source || '',
|
|
})
|
|
})
|
|
|
|
const encodedMap = toEncodedMap(transformMap)
|
|
delete encodedMap.file
|
|
delete encodedMap.ignoreList
|
|
delete encodedMap.sourceRoot
|
|
|
|
this.instrumenter.instrumentSync(
|
|
sourceCode,
|
|
id,
|
|
encodedMap as any,
|
|
)
|
|
}
|
|
|
|
const map = this.instrumenter.lastSourceMap() as any
|
|
this.transformedModuleIds.add(id)
|
|
|
|
return { code, map }
|
|
}
|
|
|
|
createCoverageMap(): libCoverage.CoverageMap {
|
|
return libCoverage.createCoverageMap({})
|
|
}
|
|
|
|
async generateCoverage({ allTestsRun }: ReportContext): Promise<CoverageMap> {
|
|
const start = debug.enabled ? performance.now() : 0
|
|
|
|
const coverageMap = this.createCoverageMap()
|
|
let coverageMapByEnvironment = this.createCoverageMap()
|
|
|
|
await this.readCoverageFiles<CoverageMap>({
|
|
onFileRead(coverage) {
|
|
coverageMapByEnvironment.merge(coverage)
|
|
},
|
|
onFinished: async () => {
|
|
// 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 transformedCoverage = await transformCoverage(coverageMapByEnvironment)
|
|
coverageMap.merge(transformedCoverage)
|
|
|
|
coverageMapByEnvironment = this.createCoverageMap()
|
|
},
|
|
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 uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles)
|
|
|
|
coverageMap.merge(await transformCoverage(uncoveredCoverage))
|
|
}
|
|
|
|
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 %d ms', (performance.now() - start!).toFixed())
|
|
}
|
|
|
|
return coverageMap
|
|
}
|
|
|
|
async generateReports(coverageMap: CoverageMap, allTestsRun: boolean | undefined): Promise<void> {
|
|
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
|
|
reports
|
|
.create(reporter[0] as Parameters<typeof reports.create>[0], {
|
|
skipFull: this.options.skipFull,
|
|
projectRoot: this.ctx.config.root,
|
|
...reporter[1],
|
|
})
|
|
.execute(context)
|
|
}
|
|
|
|
if (this.options.thresholds) {
|
|
await this.reportThresholds(coverageMap, allTestsRun)
|
|
}
|
|
}
|
|
|
|
async parseConfigModule(configFilePath: string): Promise<ProxifiedModule<any>> {
|
|
return parseModule(
|
|
await fs.readFile(configFilePath, 'utf8'),
|
|
)
|
|
}
|
|
|
|
private async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
|
|
const uncoveredFiles = await this.getUntestedFiles(coveredFiles)
|
|
|
|
const cacheKey = new Date().getTime()
|
|
const coverageMap = this.createCoverageMap()
|
|
|
|
const transform = this.createUncoveredFileTransformer(this.ctx)
|
|
|
|
// Note that these cannot be run parallel as synchronous instrumenter.lastFileCoverage
|
|
// returns the coverage of the last transformed file
|
|
for (const [index, filename] of uncoveredFiles.entries()) {
|
|
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)
|
|
|
|
debug('Uncovered file %d/%d', index, uncoveredFiles.length)
|
|
}
|
|
|
|
// Make sure file is not served from cache so that instrumenter loads up requested file coverage
|
|
await transform(`${filename}?cache=${cacheKey}&vitest-uncovered-coverage=true`)
|
|
const lastCoverage = this.instrumenter.lastFileCoverage()
|
|
coverageMap.addFileCoverage(lastCoverage)
|
|
|
|
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
|
|
}
|
|
|
|
// the coverage can be enabled after the tests are run
|
|
// this means the coverage will not be injected because the modules are cached,
|
|
// so we are invalidating all modules that don't have the istanbul coverage injected
|
|
onEnabled(): void {
|
|
const environments = this.ctx.projects.flatMap(project => [
|
|
...Object.values(project.vite.environments),
|
|
...Object.values(project.browser?.vite.environments || {}),
|
|
])
|
|
|
|
const seen = new Set<Vite.EnvironmentModuleNode>()
|
|
environments.forEach((environment) => {
|
|
environment.moduleGraph.idToModuleMap.forEach((node) => {
|
|
this.invalidateTree(node, environment.moduleGraph, seen)
|
|
})
|
|
})
|
|
}
|
|
|
|
private invalidateTree(node: Vite.EnvironmentModuleNode, moduleGraph: Vite.EnvironmentModuleGraph, seen: Set<Vite.EnvironmentModuleNode>) {
|
|
if (seen.has(node)) {
|
|
return
|
|
}
|
|
if (node.id && !this.transformedModuleIds.has(node.id)) {
|
|
moduleGraph.invalidateModule(node, seen)
|
|
}
|
|
seen.add(node) // to avoid infinite loops in circular dependencies
|
|
node.importedModules.forEach((mod) => {
|
|
this.invalidateTree(mod, moduleGraph, seen)
|
|
})
|
|
}
|
|
}
|
|
|
|
async function transformCoverage(coverageMap: CoverageMap) {
|
|
const sourceMapStore = libSourceMaps.createSourceMapStore()
|
|
return await sourceMapStore.transformCoverage(coverageMap)
|
|
}
|
|
|
|
/**
|
|
* Remove possible query parameters from filenames
|
|
* - From `/src/components/Header.component.ts?vue&type=script&src=true&lang.ts`
|
|
* - To `/src/components/Header.component.ts`
|
|
*/
|
|
function removeQueryParameters(filename: string) {
|
|
return filename.split('?')[0]
|
|
}
|