Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix!(coverage): use transformMode and workspace project based source maps #4309

Merged
merged 3 commits into from
Oct 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/browser/src/client/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,14 @@ export function createBrowserRunner(original: any, coverageModule: CoverageHandl
async onAfterRunFiles() {
await super.onAfterRun?.()
const coverage = await coverageModule?.takeCoverage?.()
if (coverage)
await rpc().onAfterSuiteRun({ coverage })

if (coverage) {
await rpc().onAfterSuiteRun({
coverage,
transformMode: 'web',
projectName: this.config.name,
})
}
}

onCollected(files: File[]): unknown {
Expand Down
80 changes: 60 additions & 20 deletions packages/coverage-istanbul/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { BaseCoverageProvider } from 'vitest/coverage'
import c from 'picocolors'
import libReport from 'istanbul-lib-report'
import reports from 'istanbul-reports'
import type { CoverageMap } from 'istanbul-lib-coverage'
import type { CoverageMap, CoverageMapData } from 'istanbul-lib-coverage'
import libCoverage from 'istanbul-lib-coverage'
import libSourceMaps from 'istanbul-lib-source-maps'
import { type Instrumenter, createInstrumenter } from 'istanbul-lib-instrument'
Expand All @@ -16,6 +16,8 @@ import _TestExclude from 'test-exclude'
import { COVERAGE_STORE_KEY } from './constants'

type Options = ResolvedCoverageOptions<'istanbul'>
type CoverageByTransformMode = Record<AfterSuiteRunMeta['transformMode'], CoverageMapData[]>
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT

interface TestExclude {
new(opts: {
Expand All @@ -31,6 +33,8 @@ interface TestExclude {
}
}

const DEFAULT_PROJECT = Symbol.for('default-project')

export class IstanbulCoverageProvider extends BaseCoverageProvider implements CoverageProvider {
name = 'istanbul'

Expand All @@ -45,7 +49,7 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
* If storing in memory causes issues, we can simply write these into fs in `onAfterSuiteRun`
* and read them back when merging coverage objects in `onAfterAllFilesRun`.
*/
coverages: any[] = []
coverages = new Map<ProjectName, CoverageByTransformMode>()

initialize(ctx: Vitest) {
const config: CoverageIstanbulOptions = ctx.config.coverage
Expand Down Expand Up @@ -106,36 +110,52 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
return { code, map }
}

onAfterSuiteRun({ coverage }: AfterSuiteRunMeta) {
this.coverages.push(coverage)
/*
* Coverage and meta information passed from Vitest runners.
* Note that adding new entries here and requiring on those without
* backwards compatibility is a breaking change.
*/
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
if (transformMode !== 'web' && transformMode !== 'ssr')
throw new Error(`Invalid transform mode: ${transformMode}`)

let entry = this.coverages.get(projectName || DEFAULT_PROJECT)

if (!entry) {
entry = { web: [], ssr: [] }
this.coverages.set(projectName || DEFAULT_PROJECT, entry)
}

entry[transformMode].push(coverage as CoverageMapData)
}

async clean(clean = true) {
if (clean && existsSync(this.options.reportsDirectory))
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })

this.coverages = []
this.coverages = new Map()
}

async reportCoverage({ allTestsRun }: ReportContext = {}) {
const mergedCoverage: CoverageMap = this.coverages.reduce((coverage, previousCoverageMap) => {
const map = libCoverage.createCoverageMap(coverage)
map.merge(previousCoverageMap)
return map
}, libCoverage.createCoverageMap({}))

if (this.options.all && allTestsRun)
await this.includeUntestedFiles(mergedCoverage)

includeImplicitElseBranches(mergedCoverage)
const coverageMaps = await Promise.all(
Array.from(this.coverages.values()).map(coverages => [
mergeAndTransformCoverage(coverages.ssr),
mergeAndTransformCoverage(coverages.web),
]).flat(),
)

if (this.options.all && allTestsRun) {
const coveredFiles = coverageMaps.map(map => map.files()).flat()
const uncoveredCoverage = await this.getCoverageMapForUncoveredFiles(coveredFiles)

coverageMaps.push(await mergeAndTransformCoverage([uncoveredCoverage]))
}

const sourceMapStore = libSourceMaps.createSourceMapStore()
const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage)
const coverageMap = mergeCoverageMaps(...coverageMaps)

const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
sourceFinder: sourceMapStore.sourceFinder,
watermarks: this.options.watermarks,
})

Expand Down Expand Up @@ -181,19 +201,21 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
}
}

async includeUntestedFiles(coverageMap: CoverageMap) {
async getCoverageMapForUncoveredFiles(coveredFiles: string[]) {
// Load, instrument and collect empty coverages from all files which
// are not already in the coverage map
const includedFiles = await this.testExclude.glob(this.ctx.config.root)
const uncoveredFiles = includedFiles
.map(file => resolve(this.ctx.config.root, file))
.filter(file => !coverageMap.data[file])
.filter(file => !coveredFiles.includes(file))

const transformResults = await Promise.all(uncoveredFiles.map(async (filename) => {
const transformResult = await this.ctx.vitenode.transformRequest(filename)
return { transformResult, filename }
}))

const coverageMap = libCoverage.createCoverageMap({})

for (const { transformResult, filename } of transformResults) {
const sourceMap = transformResult?.map

Expand All @@ -209,9 +231,27 @@ export class IstanbulCoverageProvider extends BaseCoverageProvider implements Co
coverageMap.addFileCoverage(lastCoverage)
}
}

return coverageMap.data
}
}

async function mergeAndTransformCoverage(coverages: CoverageMapData[]) {
const mergedCoverage = mergeCoverageMaps(...coverages)
includeImplicitElseBranches(mergedCoverage)

const sourceMapStore = libSourceMaps.createSourceMapStore()
return await sourceMapStore.transformCoverage(mergedCoverage)
}

function mergeCoverageMaps(...coverageMaps: (CoverageMap | CoverageMapData)[]) {
return coverageMaps.reduce<CoverageMap>((coverage, previousCoverageMap) => {
const map = libCoverage.createCoverageMap(coverage)
map.merge(previousCoverageMap)
return map
}, libCoverage.createCoverageMap({}))
}

/**
* Remove possible query parameters from filenames
* - From `/src/components/Header.component.ts?vue&type=script&src=true&lang.ts`
Expand Down
121 changes: 79 additions & 42 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import v8ToIstanbul from 'v8-to-istanbul'
import { mergeProcessCovs } from '@bcoe/v8-coverage'
import libReport from 'istanbul-lib-report'
import reports from 'istanbul-reports'
import type { CoverageMap } from 'istanbul-lib-coverage'
import type { CoverageMap, CoverageMapData } from 'istanbul-lib-coverage'
import libCoverage from 'istanbul-lib-coverage'
import libSourceMaps from 'istanbul-lib-source-maps'
import MagicString from 'magic-string'
Expand Down Expand Up @@ -39,20 +39,24 @@ interface TestExclude {

type Options = ResolvedCoverageOptions<'v8'>
type TransformResults = Map<string, FetchResult>
type RawCoverage = Profiler.TakePreciseCoverageReturnType
type CoverageByTransformMode = Record<AfterSuiteRunMeta['transformMode'], RawCoverage[]>
type ProjectName = NonNullable<AfterSuiteRunMeta['projectName']> | typeof DEFAULT_PROJECT

// TODO: vite-node should export this
const WRAPPER_LENGTH = 185

// Note that this needs to match the line ending as well
const VITE_EXPORTS_LINE_PATTERN = /Object\.defineProperty\(__vite_ssr_exports__.*\n/g
const DEFAULT_PROJECT = Symbol.for('default-project')

export class V8CoverageProvider extends BaseCoverageProvider implements CoverageProvider {
name = 'v8'

ctx!: Vitest
options!: Options
testExclude!: InstanceType<TestExclude>
coverages: Profiler.TakePreciseCoverageReturnType[] = []
coverages = new Map<ProjectName, CoverageByTransformMode>()

initialize(ctx: Vitest) {
const config: CoverageV8Options = ctx.config.coverage
Expand Down Expand Up @@ -92,54 +96,52 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
if (clean && existsSync(this.options.reportsDirectory))
await fs.rm(this.options.reportsDirectory, { recursive: true, force: true, maxRetries: 10 })

this.coverages = []
this.coverages = new Map()
}

onAfterSuiteRun({ coverage }: AfterSuiteRunMeta) {
this.coverages.push(coverage as Profiler.TakePreciseCoverageReturnType)
/*
* Coverage and meta information passed from Vitest runners.
* Note that adding new entries here and requiring on those without
* backwards compatibility is a breaking change.
*/
onAfterSuiteRun({ coverage, transformMode, projectName }: AfterSuiteRunMeta) {
if (transformMode !== 'web' && transformMode !== 'ssr')
throw new Error(`Invalid transform mode: ${transformMode}`)

let entry = this.coverages.get(projectName || DEFAULT_PROJECT)

if (!entry) {
entry = { web: [], ssr: [] }
this.coverages.set(projectName || DEFAULT_PROJECT, entry)
}

entry[transformMode].push(coverage as RawCoverage)
}

async reportCoverage({ allTestsRun }: ReportContext = {}) {
if (provider === 'stackblitz')
this.ctx.logger.log(c.blue(' % ') + c.yellow('@vitest/coverage-v8 does not work on Stackblitz. Report will be empty.'))

const transformResults = normalizeTransformResults(this.ctx.projects.map(project => project.vitenode.fetchCache))
const merged = mergeProcessCovs(this.coverages)
const scriptCoverages = merged.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url)))
const coverageMaps = await Promise.all(
Array.from(this.coverages.entries()).map(([projectName, coverages]) => [
this.mergeAndTransformCoverage(coverages.ssr, projectName, 'ssr'),
this.mergeAndTransformCoverage(coverages.web, projectName, 'web'),
]).flat(),
)

if (this.options.all && allTestsRun) {
const coveredFiles = Array.from(scriptCoverages.map(r => r.url))
const untestedFiles = await this.getUntestedFiles(coveredFiles, transformResults)
const coveredFiles = coverageMaps.map(map => map.files()).flat()
const untestedCoverage = await this.getUntestedFiles(coveredFiles)
const untestedCoverageResults = untestedCoverage.map(files => ({ result: [files] }))

scriptCoverages.push(...untestedFiles)
coverageMaps.push(await this.mergeAndTransformCoverage(untestedCoverageResults))
}

const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => {
const sources = await this.getSources(url, transformResults, functions)

// If no source map was found from vite-node we can assume this file was not run in the wrapper
const wrapperLength = sources.sourceMap ? WRAPPER_LENGTH : 0

const converter = v8ToIstanbul(url, wrapperLength, sources)
await converter.load()

converter.applyCoverage(functions)
return converter.toIstanbul()
}))

const mergedCoverage = converted.reduce((coverage, previousCoverageMap) => {
const map = libCoverage.createCoverageMap(coverage)
map.merge(previousCoverageMap)
return map
}, libCoverage.createCoverageMap({}))

const sourceMapStore = libSourceMaps.createSourceMapStore()
const coverageMap: CoverageMap = await sourceMapStore.transformCoverage(mergedCoverage)
const coverageMap = mergeCoverageMaps(...coverageMaps)

const context = libReport.createContext({
dir: this.options.reportsDirectory,
coverageMap,
sourceFinder: sourceMapStore.sourceFinder,
watermarks: this.options.watermarks,
})

Expand Down Expand Up @@ -185,11 +187,13 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
}
}

private async getUntestedFiles(testedFiles: string[], transformResults: TransformResults): Promise<Profiler.ScriptCoverage[]> {
private async getUntestedFiles(testedFiles: string[]): Promise<Profiler.ScriptCoverage[]> {
const transformResults = normalizeTransformResults(this.ctx.vitenode.fetchCache)

const includedFiles = await this.testExclude.glob(this.ctx.config.root)
const uncoveredFiles = includedFiles
.map(file => pathToFileURL(resolve(this.ctx.config.root, file)))
.filter(file => !testedFiles.includes(file.href))
.filter(file => !testedFiles.includes(file.pathname))

return await Promise.all(uncoveredFiles.map(async (uncoveredFile) => {
const { source } = await this.getSources(uncoveredFile.href, transformResults)
Expand Down Expand Up @@ -247,6 +251,41 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage
},
}
}

private async mergeAndTransformCoverage(coverages: RawCoverage[], projectName?: ProjectName, transformMode?: 'web' | 'ssr') {
const viteNode = this.ctx.projects.find(project => project.getName() === projectName)?.vitenode || this.ctx.vitenode
const fetchCache = transformMode ? viteNode.fetchCaches[transformMode] : viteNode.fetchCache
const transformResults = normalizeTransformResults(fetchCache)

const merged = mergeProcessCovs(coverages)
const scriptCoverages = merged.result.filter(result => this.testExclude.shouldInstrument(fileURLToPath(result.url)))

const converted = await Promise.all(scriptCoverages.map(async ({ url, functions }) => {
const sources = await this.getSources(url, transformResults, functions)

// If no source map was found from vite-node we can assume this file was not run in the wrapper
const wrapperLength = sources.sourceMap ? WRAPPER_LENGTH : 0

const converter = v8ToIstanbul(url, wrapperLength, sources)
await converter.load()

converter.applyCoverage(functions)
return converter.toIstanbul()
}))

const mergedCoverage = mergeCoverageMaps(...converted)

const sourceMapStore = libSourceMaps.createSourceMapStore()
return sourceMapStore.transformCoverage(mergedCoverage)
}
}

function mergeCoverageMaps(...coverageMaps: (CoverageMap | CoverageMapData)[]) {
return coverageMaps.reduce<CoverageMap>((coverage, previousCoverageMap) => {
const map = libCoverage.createCoverageMap(coverage)
map.merge(previousCoverageMap)
return map
}, libCoverage.createCoverageMap({}))
}

/**
Expand Down Expand Up @@ -284,16 +323,14 @@ function findLongestFunctionLength(functions: Profiler.FunctionCoverage[]) {
}, 0)
}

function normalizeTransformResults(fetchCaches: Map<string, { result: FetchResult }>[]) {
function normalizeTransformResults(fetchCache: Map<string, { result: FetchResult }>) {
const normalized: TransformResults = new Map()

for (const fetchCache of fetchCaches) {
for (const [key, value] of fetchCache.entries()) {
const cleanEntry = cleanUrl(key)
for (const [key, value] of fetchCache.entries()) {
const cleanEntry = cleanUrl(key)

if (!normalized.has(cleanEntry))
normalized.set(cleanEntry, value.result)
}
if (!normalized.has(cleanEntry))
normalized.set(cleanEntry, value.result)
}

return normalized
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/pools/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function createChildProcessPool(ctx: Vitest, { execArgv, env, forksPath }
invalidates,
environment,
workerId,
projectName: project.getName(),
}
try {
await pool.run(data, { name, channel })
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/pools/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function createThreadsPool(ctx: Vitest, { execArgv, env, workerPath }: Po
invalidates,
environment,
workerId,
projectName: project.getName(),
}
try {
await pool.run(data, { transferList: [workerPort], name })
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/pools/vm-threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function createVmThreadsPool(ctx: Vitest, { execArgv, env, vmPath }: Pool
invalidates,
environment,
workerId,
projectName: project.getName(),
}
try {
await pool.run(data, { transferList: [workerPort], name })
Expand Down
Loading