Skip to content
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
3 changes: 2 additions & 1 deletion packages/coverage-v8/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { CoverageProviderModule } from 'vitest/node'
import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider'
import inspector from 'node:inspector/promises'
import { fileURLToPath } from 'node:url'
import { normalize } from 'pathe'
import { provider } from 'std-env'
import { loadProvider } from './load-provider'

Expand Down Expand Up @@ -35,7 +36,7 @@ const mod: CoverageProviderModule = {
if (filterResult(entry)) {
result.push({
...entry,
startOffset: options?.moduleExecutionInfo?.get(fileURLToPath(entry.url))?.startOffset || 0,
startOffset: options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0,
})
}
}
Expand Down
56 changes: 6 additions & 50 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { promises as fs } from 'node:fs'
import { fileURLToPath } from 'node:url'
// @ts-expect-error -- untyped
import { mergeProcessCovs } from '@bcoe/v8-coverage'
import { cleanUrl } from '@vitest/utils'
import astV8ToIstanbul from 'ast-v8-to-istanbul'
import createDebug from 'debug'
import libCoverage from 'istanbul-lib-coverage'
Expand All @@ -16,17 +15,15 @@ import reports from 'istanbul-reports'
import { parseModule } from 'magicast'
import { normalize } from 'pathe'
import { provider } from 'std-env'

import c from 'tinyrainbow'
import { BaseCoverageProvider } from 'vitest/coverage'
import { isCSSRequest, parseAstAsync } from 'vitest/node'
import { parseAstAsync } from 'vitest/node'
import { version } from '../package.json' with { type: 'json' }

export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage {
startOffset: number
}

type TransformResults = Map<string, Vite.TransformResult>
interface RawCoverage { result: ScriptCoverageWithOffset[] }

const FILE_PROTOCOL = 'file://'
Expand Down Expand Up @@ -145,9 +142,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
}

private async getCoverageMapForUncoveredFiles(testedFiles: string[]): Promise<CoverageMap> {
const transformResults = normalizeTransformResults(
this.ctx.vite.environments,
)
const transform = this.createUncoveredFileTransformer(this.ctx)

const uncoveredFiles = await this.getUntestedFiles(testedFiles)
Expand Down Expand Up @@ -176,7 +170,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt

const sources = await this.getSources(
url,
transformResults,
transform,
)

Expand Down Expand Up @@ -318,25 +311,20 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt

private async getSources(
url: string,
transformResults: TransformResults,
onTransform: (filepath: string) => Promise<Vite.TransformResult | undefined | null>,
functions: Profiler.FunctionCoverage[] = [],
): Promise<{
code: string
map?: Vite.Rollup.SourceMap
}> {
const filePath = normalize(fileURLToPath(url))

let transformResult: Vite.TransformResult | null | undefined = transformResults.get(filePath)

if (!transformResult) {
transformResult = await onTransform(removeStartsWith(url, FILE_PROTOCOL)).catch(() => undefined)
}
const transformResult = await onTransform(removeStartsWith(url, FILE_PROTOCOL)).catch(() => undefined)

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.
Expand Down Expand Up @@ -372,16 +360,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
throw new Error(`Cannot access browser module graph because it was torn down.`)
}

const moduleGraph = environment === '__browser__'
? project.browser!.vite.environments.client.moduleGraph
: project.vite.environments[environment]?.moduleGraph

if (!moduleGraph) {
throw new Error(`Module graph for environment ${environment} was not defined.`)
}

const transformResults = normalizeTransformResults({ [environment]: { moduleGraph } })

async function onTransform(filepath: string) {
if (environment === '__browser__' && project.browser) {
const result = await project.browser.vite.transformRequest(removeStartsWith(filepath, project.config.root))
Expand All @@ -408,11 +386,8 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
}
}

// Ignore all CSS requests, so we don't override the actual code coverage
// In cases where CSS and JS are in the same file (.vue, .svelte)
// The file has a `.vue` extension, but the URL has `lang.css` query
if (!isCSSRequest(result.url) && this.isIncluded(fileURLToPath(result.url))) {
scriptCoverages.push(result)
if (this.isIncluded(fileURLToPath(result.url))) {
scriptCoverages.push({ ...result, url: decodeURIComponent(result.url) })
}
}

Expand All @@ -437,7 +412,6 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt

const sources = await this.getSources(
url,
transformResults,
onTransform,
functions,
)
Expand Down Expand Up @@ -483,24 +457,6 @@ function findLongestFunctionLength(functions: Profiler.FunctionCoverage[]) {
}, 0)
}

function normalizeTransformResults(
environments: Record<string, { moduleGraph: Vite.EnvironmentModuleGraph }>,
) {
const normalized: TransformResults = new Map()

for (const environmentName in environments) {
const moduleGraph = environments[environmentName].moduleGraph
for (const [key, value] of moduleGraph.idToModuleMap) {
const cleanEntry = cleanUrl(key)
if (value.transformResult && !normalized.has(cleanEntry)) {
normalized.set(cleanEntry, value.transformResult)
}
}
}

return normalized
}

function removeStartsWith(filepath: string, start: string) {
if (filepath.startsWith(start)) {
return filepath.slice(start.length)
Expand Down
8 changes: 3 additions & 5 deletions packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,12 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
)})=>{{`
const wrappedCode = `${codeDefinition}${code}\n}}`
const options = {
// we are using a normalized file name by default because this is what
// Vite expects in the source maps handler
filename: module.file || filename,
filename: module.id,
Copy link
Member Author

@AriPerkkio AriPerkkio Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module.file || filename did not contain query params. module.id does. We need to have the query present in vm.RunningCodeOptions.filename so that V8 reports those files properly. Otherwise we would see same file URL multiple times, and could not identify which coverage belongs to which file transform.

Before:

{
  scriptId: '305',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts',
  functions: [...]
}
{
  scriptId: '306',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts',
  functions: [...]
}
{
  scriptId: '307',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts',
  functions: [...]
}

After:

{
  scriptId: '305',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts',
  functions: [...]
}
{
  scriptId: '306',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts%3Fquery=first',
  functions: [...]
}
{
  scriptId: '307',
  url: 'file:///Users/ari/Git/vitest/test/coverage-test/fixtures/src/query-param-transformed.ts%3Fquery=second',
  functions: [...]
}

Note that the offset of functions there maps to different positions, based on query params.

lineOffset: 0,
columnOffset: -codeDefinition.length,
}

const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(filename, codeDefinition.length)
const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length)

try {
const initModule = this.vm
Expand Down Expand Up @@ -300,7 +298,7 @@ export class VitestModuleEvaluator implements ModuleEvaluator {
finally {
// moduleExecutionInfo needs to use Node filename instead of the normalized one
// because we rely on this behaviour in coverage-v8, for example
this.options.moduleExecutionInfo?.set(filename, finishModuleExecutionInfo())
this.options.moduleExecutionInfo?.set(options.filename, finishModuleExecutionInfo())
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import MagicString from 'magic-string'
import remapping from '@jridgewell/remapping'
import type { Plugin } from 'vite'

import base from './vitest.config'

export default mergeConfig(
base,
defineConfig({
plugins: [QueryParamTransforms()],
test: {}
})
)

/**
* Attempts to do Vue-like query param based transforms
*/
function QueryParamTransforms(): Plugin {
return {
name: 'vitest-custom-query-param-based-transform',
enforce: 'pre',
transform(code, id) {
if (id.includes('src/query-param-transformed')) {
const transformed = new MagicString(code)
const query = id.split("?query=").pop()

if(query === "first") {
transformed.remove(
code.indexOf("/* QUERY_PARAM FIRST START */"),
code.indexOf("/* QUERY_PARAM FIRST END */") + "/* QUERY_PARAM FIRST END */".length,
)
} else if(query === "second") {
transformed.remove(
code.indexOf("/* QUERY_PARAM SECOND START */"),
code.indexOf("/* QUERY_PARAM SECOND END */") + "/* QUERY_PARAM SECOND END */".length,
)
} else {
transformed.remove(
code.indexOf("/* QUERY_PARAM FIRST START */"),
code.length,
)
}

const map = remapping(
[transformed.generateMap({ hires: true }), this.getCombinedSourcemap() as any],
() => null,
) as any

return { code: transformed.toString(), map }
}
},
}
}
19 changes: 19 additions & 0 deletions test/coverage-test/fixtures/src/query-param-transformed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function initial() {
return "Always present"
}

/* QUERY_PARAM FIRST START */
export function first() {
return "Removed when ?query=first"
}
/* QUERY_PARAM FIRST END */

/* QUERY_PARAM SECOND START */
export function second() {
return "Removed when ?query=second"
}
/* QUERY_PARAM SECOND END */

export function uncovered() {
return "Always present"
}
28 changes: 28 additions & 0 deletions test/coverage-test/fixtures/test/query-param.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from 'vitest'

test('run initial', async () => {
const initial = await import('../src/query-param-transformed')

// Check that custom plugin works
expect(initial.initial()).toBe("Always present")
expect(initial.first).toBeUndefined()
expect(initial.second).toBeUndefined()
})

test('run first', async () => {
const initial = await import('../src/query-param-transformed?query=first' as '../src/query-param-transformed')

// Check that custom plugin works
expect(initial.initial()).toBe("Always present")
expect(initial.first).toBeUndefined()
expect(initial.second()).toBe("Removed when ?query=second")
})

test('run second', async () => {
const initial = await import('../src/query-param-transformed?query=second' as '../src/query-param-transformed')

// Check that custom plugin works
expect(initial.initial()).toBe("Always present")
expect(initial.first()).toBe("Removed when ?query=first")
expect(initial.second).toBeUndefined()
})
48 changes: 48 additions & 0 deletions test/coverage-test/test/query-param-transforms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect } from 'vitest'
import { readCoverageMap, runVitest, test } from '../utils'

test('query param based transforms are resolved properly', async () => {
await runVitest({
config: 'fixtures/configs/vitest.config.query-param-transform.ts',
include: ['fixtures/test/query-param.test.ts'],
coverage: { reporter: 'json' },
})

const coverageMap = await readCoverageMap()

// Query params should not be present in final report
expect(coverageMap.files()).toMatchInlineSnapshot(`
[
"<process-cwd>/fixtures/src/query-param-transformed.ts",
]
`)

const coverage = coverageMap.fileCoverageFor(coverageMap.files()[0])

// Query params change which functions end up in transform result,
// verify that all functions are present
const functionCoverage = Object.keys(coverage.fnMap)
.map(index => ({ name: coverage.fnMap[index].name, hits: coverage.f[index] }))
.sort((a, b) => a.name.localeCompare(b.name))

expect(functionCoverage).toMatchInlineSnapshot(`
[
{
"hits": 1,
"name": "first",
},
{
"hits": 3,
"name": "initial",
},
{
"hits": 1,
"name": "second",
},
{
"hits": 0,
"name": "uncovered",
},
]
`)
})
2 changes: 2 additions & 0 deletions test/coverage-test/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export default defineConfig({
'**/test-reporter-conflicts.test.ts',
'**/vue.test.ts',
'**/in-source.test.ts',
'**/query-param-transforms.test.ts',
],
},
},
Expand Down Expand Up @@ -113,6 +114,7 @@ export default defineConfig({
'**/test-reporter-conflicts.test.ts',
'**/vue.test.ts',
'**/in-source.test.ts',
'**/query-param-transforms.test.ts',
],
},
},
Expand Down
Loading