[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

fix(coverage): handle query param based transforms correctly (#8418)

authored by

Ari Perkkiö and committed by
GitHub
(Aug 15, 2025, 1:59 PM +0200) a400a9d2 e68b8474

+162 -56
+2
test/coverage-test/vitest.config.ts
··· 86 86 '**/test-reporter-conflicts.test.ts', 87 87 '**/vue.test.ts', 88 88 '**/in-source.test.ts', 89 + '**/query-param-transforms.test.ts', 89 90 ], 90 91 }, 91 92 }, ··· 113 114 '**/test-reporter-conflicts.test.ts', 114 115 '**/vue.test.ts', 115 116 '**/in-source.test.ts', 117 + '**/query-param-transforms.test.ts', 116 118 ], 117 119 }, 118 120 },
+2 -1
packages/coverage-v8/src/index.ts
··· 3 3 import type { ScriptCoverageWithOffset, V8CoverageProvider } from './provider' 4 4 import inspector from 'node:inspector/promises' 5 5 import { fileURLToPath } from 'node:url' 6 + import { normalize } from 'pathe' 6 7 import { provider } from 'std-env' 7 8 import { loadProvider } from './load-provider' 8 9 ··· 35 36 if (filterResult(entry)) { 36 37 result.push({ 37 38 ...entry, 38 - startOffset: options?.moduleExecutionInfo?.get(fileURLToPath(entry.url))?.startOffset || 0, 39 + startOffset: options?.moduleExecutionInfo?.get(normalize(fileURLToPath(entry.url)))?.startOffset || 0, 39 40 }) 40 41 } 41 42 }
+6 -50
packages/coverage-v8/src/provider.ts
··· 6 6 import { fileURLToPath } from 'node:url' 7 7 // @ts-expect-error -- untyped 8 8 import { mergeProcessCovs } from '@bcoe/v8-coverage' 9 - import { cleanUrl } from '@vitest/utils' 10 9 import astV8ToIstanbul from 'ast-v8-to-istanbul' 11 10 import createDebug from 'debug' 12 11 import libCoverage from 'istanbul-lib-coverage' ··· 16 15 import { parseModule } from 'magicast' 17 16 import { normalize } from 'pathe' 18 17 import { provider } from 'std-env' 19 - 20 18 import c from 'tinyrainbow' 21 19 import { BaseCoverageProvider } from 'vitest/coverage' 22 - import { isCSSRequest, parseAstAsync } from 'vitest/node' 20 + import { parseAstAsync } from 'vitest/node' 23 21 import { version } from '../package.json' with { type: 'json' } 24 22 25 23 export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage { 26 24 startOffset: number 27 25 } 28 26 29 - type TransformResults = Map<string, Vite.TransformResult> 30 27 interface RawCoverage { result: ScriptCoverageWithOffset[] } 31 28 32 29 const FILE_PROTOCOL = 'file://' ··· 145 142 } 146 143 147 144 private async getCoverageMapForUncoveredFiles(testedFiles: string[]): Promise<CoverageMap> { 148 - const transformResults = normalizeTransformResults( 149 - this.ctx.vite.environments, 150 - ) 151 145 const transform = this.createUncoveredFileTransformer(this.ctx) 152 146 153 147 const uncoveredFiles = await this.getUntestedFiles(testedFiles) ··· 176 170 177 171 const sources = await this.getSources( 178 172 url, 179 - transformResults, 180 173 transform, 181 174 ) 182 175 ··· 318 311 319 312 private async getSources( 320 313 url: string, 321 - transformResults: TransformResults, 322 314 onTransform: (filepath: string) => Promise<Vite.TransformResult | undefined | null>, 323 315 functions: Profiler.FunctionCoverage[] = [], 324 316 ): Promise<{ 325 317 code: string 326 318 map?: Vite.Rollup.SourceMap 327 319 }> { 328 - const filePath = normalize(fileURLToPath(url)) 329 - 330 - let transformResult: Vite.TransformResult | null | undefined = transformResults.get(filePath) 331 - 332 - if (!transformResult) { 333 - transformResult = await onTransform(removeStartsWith(url, FILE_PROTOCOL)).catch(() => undefined) 334 - } 320 + const transformResult = await onTransform(removeStartsWith(url, FILE_PROTOCOL)).catch(() => undefined) 335 321 336 322 const map = transformResult?.map as Vite.Rollup.SourceMap | undefined 337 323 const code = transformResult?.code 338 324 339 325 if (code == null) { 326 + const filePath = normalize(fileURLToPath(url)) 327 + 340 328 const original = await fs.readFile(filePath, 'utf-8').catch(() => { 341 329 // If file does not exist construct a dummy source for it. 342 330 // These can be files that were generated dynamically during the test run and were removed after it. ··· 372 360 throw new Error(`Cannot access browser module graph because it was torn down.`) 373 361 } 374 362 375 - const moduleGraph = environment === '__browser__' 376 - ? project.browser!.vite.environments.client.moduleGraph 377 - : project.vite.environments[environment]?.moduleGraph 378 - 379 - if (!moduleGraph) { 380 - throw new Error(`Module graph for environment ${environment} was not defined.`) 381 - } 382 - 383 - const transformResults = normalizeTransformResults({ [environment]: { moduleGraph } }) 384 - 385 363 async function onTransform(filepath: string) { 386 364 if (environment === '__browser__' && project.browser) { 387 365 const result = await project.browser.vite.transformRequest(removeStartsWith(filepath, project.config.root)) ··· 408 386 } 409 387 } 410 388 411 - // Ignore all CSS requests, so we don't override the actual code coverage 412 - // In cases where CSS and JS are in the same file (.vue, .svelte) 413 - // The file has a `.vue` extension, but the URL has `lang.css` query 414 - if (!isCSSRequest(result.url) && this.isIncluded(fileURLToPath(result.url))) { 415 - scriptCoverages.push(result) 389 + if (this.isIncluded(fileURLToPath(result.url))) { 390 + scriptCoverages.push({ ...result, url: decodeURIComponent(result.url) }) 416 391 } 417 392 } 418 393 ··· 437 412 438 413 const sources = await this.getSources( 439 414 url, 440 - transformResults, 441 415 onTransform, 442 416 functions, 443 417 ) ··· 481 455 482 456 return Math.max(previous, maxEndOffset) 483 457 }, 0) 484 - } 485 - 486 - function normalizeTransformResults( 487 - environments: Record<string, { moduleGraph: Vite.EnvironmentModuleGraph }>, 488 - ) { 489 - const normalized: TransformResults = new Map() 490 - 491 - for (const environmentName in environments) { 492 - const moduleGraph = environments[environmentName].moduleGraph 493 - for (const [key, value] of moduleGraph.idToModuleMap) { 494 - const cleanEntry = cleanUrl(key) 495 - if (value.transformResult && !normalized.has(cleanEntry)) { 496 - normalized.set(cleanEntry, value.transformResult) 497 - } 498 - } 499 - } 500 - 501 - return normalized 502 458 } 503 459 504 460 function removeStartsWith(filepath: string, start: string) {
+48
test/coverage-test/test/query-param-transforms.test.ts
··· 1 + import { expect } from 'vitest' 2 + import { readCoverageMap, runVitest, test } from '../utils' 3 + 4 + test('query param based transforms are resolved properly', async () => { 5 + await runVitest({ 6 + config: 'fixtures/configs/vitest.config.query-param-transform.ts', 7 + include: ['fixtures/test/query-param.test.ts'], 8 + coverage: { reporter: 'json' }, 9 + }) 10 + 11 + const coverageMap = await readCoverageMap() 12 + 13 + // Query params should not be present in final report 14 + expect(coverageMap.files()).toMatchInlineSnapshot(` 15 + [ 16 + "<process-cwd>/fixtures/src/query-param-transformed.ts", 17 + ] 18 + `) 19 + 20 + const coverage = coverageMap.fileCoverageFor(coverageMap.files()[0]) 21 + 22 + // Query params change which functions end up in transform result, 23 + // verify that all functions are present 24 + const functionCoverage = Object.keys(coverage.fnMap) 25 + .map(index => ({ name: coverage.fnMap[index].name, hits: coverage.f[index] })) 26 + .sort((a, b) => a.name.localeCompare(b.name)) 27 + 28 + expect(functionCoverage).toMatchInlineSnapshot(` 29 + [ 30 + { 31 + "hits": 1, 32 + "name": "first", 33 + }, 34 + { 35 + "hits": 3, 36 + "name": "initial", 37 + }, 38 + { 39 + "hits": 1, 40 + "name": "second", 41 + }, 42 + { 43 + "hits": 0, 44 + "name": "uncovered", 45 + }, 46 + ] 47 + `) 48 + })
+54
test/coverage-test/fixtures/configs/vitest.config.query-param-transform.ts
··· 1 + import { defineConfig, mergeConfig } from 'vitest/config' 2 + import MagicString from 'magic-string' 3 + import remapping from '@jridgewell/remapping' 4 + import type { Plugin } from 'vite' 5 + 6 + import base from './vitest.config' 7 + 8 + export default mergeConfig( 9 + base, 10 + defineConfig({ 11 + plugins: [QueryParamTransforms()], 12 + test: {} 13 + }) 14 + ) 15 + 16 + /** 17 + * Attempts to do Vue-like query param based transforms 18 + */ 19 + function QueryParamTransforms(): Plugin { 20 + return { 21 + name: 'vitest-custom-query-param-based-transform', 22 + enforce: 'pre', 23 + transform(code, id) { 24 + if (id.includes('src/query-param-transformed')) { 25 + const transformed = new MagicString(code) 26 + const query = id.split("?query=").pop() 27 + 28 + if(query === "first") { 29 + transformed.remove( 30 + code.indexOf("/* QUERY_PARAM FIRST START */"), 31 + code.indexOf("/* QUERY_PARAM FIRST END */") + "/* QUERY_PARAM FIRST END */".length, 32 + ) 33 + } else if(query === "second") { 34 + transformed.remove( 35 + code.indexOf("/* QUERY_PARAM SECOND START */"), 36 + code.indexOf("/* QUERY_PARAM SECOND END */") + "/* QUERY_PARAM SECOND END */".length, 37 + ) 38 + } else { 39 + transformed.remove( 40 + code.indexOf("/* QUERY_PARAM FIRST START */"), 41 + code.length, 42 + ) 43 + } 44 + 45 + const map = remapping( 46 + [transformed.generateMap({ hires: true }), this.getCombinedSourcemap() as any], 47 + () => null, 48 + ) as any 49 + 50 + return { code: transformed.toString(), map } 51 + } 52 + }, 53 + } 54 + }
+19
test/coverage-test/fixtures/src/query-param-transformed.ts
··· 1 + export function initial() { 2 + return "Always present" 3 + } 4 + 5 + /* QUERY_PARAM FIRST START */ 6 + export function first() { 7 + return "Removed when ?query=first" 8 + } 9 + /* QUERY_PARAM FIRST END */ 10 + 11 + /* QUERY_PARAM SECOND START */ 12 + export function second() { 13 + return "Removed when ?query=second" 14 + } 15 + /* QUERY_PARAM SECOND END */ 16 + 17 + export function uncovered() { 18 + return "Always present" 19 + }
+28
test/coverage-test/fixtures/test/query-param.test.ts
··· 1 + import { expect, test } from 'vitest' 2 + 3 + test('run initial', async () => { 4 + const initial = await import('../src/query-param-transformed') 5 + 6 + // Check that custom plugin works 7 + expect(initial.initial()).toBe("Always present") 8 + expect(initial.first).toBeUndefined() 9 + expect(initial.second).toBeUndefined() 10 + }) 11 + 12 + test('run first', async () => { 13 + const initial = await import('../src/query-param-transformed?query=first' as '../src/query-param-transformed') 14 + 15 + // Check that custom plugin works 16 + expect(initial.initial()).toBe("Always present") 17 + expect(initial.first).toBeUndefined() 18 + expect(initial.second()).toBe("Removed when ?query=second") 19 + }) 20 + 21 + test('run second', async () => { 22 + const initial = await import('../src/query-param-transformed?query=second' as '../src/query-param-transformed') 23 + 24 + // Check that custom plugin works 25 + expect(initial.initial()).toBe("Always present") 26 + expect(initial.first()).toBe("Removed when ?query=first") 27 + expect(initial.second).toBeUndefined() 28 + })
+3 -5
packages/vitest/src/runtime/moduleRunner/moduleEvaluator.ts
··· 250 250 )})=>{{` 251 251 const wrappedCode = `${codeDefinition}${code}\n}}` 252 252 const options = { 253 - // we are using a normalized file name by default because this is what 254 - // Vite expects in the source maps handler 255 - filename: module.file || filename, 253 + filename: module.id, 256 254 lineOffset: 0, 257 255 columnOffset: -codeDefinition.length, 258 256 } 259 257 260 - const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(filename, codeDefinition.length) 258 + const finishModuleExecutionInfo = this.debug.startCalculateModuleExecutionInfo(options.filename, codeDefinition.length) 261 259 262 260 try { 263 261 const initModule = this.vm ··· 300 298 finally { 301 299 // moduleExecutionInfo needs to use Node filename instead of the normalized one 302 300 // because we rely on this behaviour in coverage-v8, for example 303 - this.options.moduleExecutionInfo?.set(filename, finishModuleExecutionInfo()) 301 + this.options.moduleExecutionInfo?.set(options.filename, finishModuleExecutionInfo()) 304 302 } 305 303 } 306 304