[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.

feat(coverage): v8 experimental AST-aware remapping (#7736)

authored by

Ari Perkkiö and committed by
GitHub
(May 5, 2025, 4:17 PM +0200) 78a3d278 e761f27d

+155 -47
+12
pnpm-lock.yaml
··· 590 590 '@bcoe/v8-coverage': 591 591 specifier: ^1.0.2 592 592 version: 1.0.2 593 + ast-v8-to-istanbul: 594 + specifier: ^0.3.1 595 + version: 0.3.1 593 596 debug: 594 597 specifier: 'catalog:' 595 598 version: 4.4.0 ··· 4843 4846 ast-types@0.13.4: 4844 4847 resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} 4845 4848 engines: {node: '>=4'} 4849 + 4850 + ast-v8-to-istanbul@0.3.1: 4851 + resolution: {integrity: sha512-JTXdVVvDN2GYU99F33hyGP1etlltAqV3bk6LRepl5twqAxGAL02VDAEKuckemYBxlND+Gic3Gf9sT3f8UxTPRw==} 4846 4852 4847 4853 async@3.2.6: 4848 4854 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} ··· 12941 12947 ast-types@0.13.4: 12942 12948 dependencies: 12943 12949 tslib: 2.8.1 12950 + 12951 + ast-v8-to-istanbul@0.3.1: 12952 + dependencies: 12953 + '@jridgewell/trace-mapping': 0.3.25 12954 + estree-walker: 3.0.3 12955 + js-tokens: 9.0.1 12944 12956 12945 12957 async@3.2.6: {} 12946 12958
+9 -1
docs/config/index.md
··· 1642 1642 - **Available for providers:** `'v8'` 1643 1643 - **CLI:** `--coverage.ignoreEmptyLines=<boolean>` 1644 1644 1645 - Ignore empty lines, comments and other non-runtime code, e.g. Typescript types. 1645 + Ignore empty lines, comments and other non-runtime code, e.g. Typescript types. Requires `experimentalAstAwareRemapping: false`. 1646 1646 1647 1647 This option works only if the used compiler removes comments and other non-runtime code from the transpiled code. 1648 1648 By default Vite uses ESBuild which removes comments and Typescript types from `.ts`, `.tsx` and `.jsx` files. ··· 1666 1666 }, 1667 1667 }) 1668 1668 ``` 1669 + #### coverage.experimentalAstAwareRemapping 1670 + 1671 + - **Type:** `boolean` 1672 + - **Default:** `false` 1673 + - **Available for providers:** `'v8'` 1674 + - **CLI:** `--coverage.experimentalAstAwareRemapping=<boolean>` 1675 + 1676 + Remap coverage with experimental AST based analysis. Provides more accurate results compared to default mode. 1669 1677 1670 1678 #### coverage.ignoreClassMethods 1671 1679
+4 -7
docs/guide/coverage.md
··· 190 190 191 191 - [`v8`](https://github.com/istanbuljs/v8-to-istanbul#ignoring-uncovered-lines) 192 192 - [`ìstanbul`](https://github.com/istanbuljs/nyc#parsing-hints-ignoring-lines) 193 + - `v8` with [`experimentalAstAwareRemapping: true`](https://vitest.dev/config/#coverage-experimentalAstAwareRemapping) see [ast-v8-to-istanbul | Ignoring code](https://github.com/AriPerkkio/ast-v8-to-istanbul?tab=readme-ov-file#ignoring-code) 193 194 194 195 When using TypeScript the source codes are transpiled using `esbuild`, which strips all comments from the source codes ([esbuild#516](https://github.com/evanw/esbuild/issues/516)). 195 196 Comments which are considered as [legal comments](https://esbuild.github.io/api/#legal-comments) are preserved. 196 197 197 - For `istanbul` provider you can include a `@preserve` keyword in the ignore hint. 198 + You can include a `@preserve` keyword in the ignore hint. 198 199 Beware that these ignore hints may now be included in final production build as well. 199 200 200 201 ```diff 201 202 -/* istanbul ignore if */ 202 203 +/* istanbul ignore if -- @preserve */ 203 204 if (condition) { 204 - ``` 205 205 206 - For `v8` this does not cause any issues. You can use `v8 ignore` comments with Typescript as usual: 207 - 208 - <!-- eslint-skip --> 209 - ```ts 210 - /* v8 ignore next 3 */ 206 + -/* v8 ignore if */ 207 + +/* v8 ignore if -- @preserve */ 211 208 if (condition) { 212 209 ``` 213 210
+1
packages/coverage-v8/package.json
··· 56 56 "dependencies": { 57 57 "@ampproject/remapping": "catalog:", 58 58 "@bcoe/v8-coverage": "^1.0.2", 59 + "ast-v8-to-istanbul": "^0.3.1", 59 60 "debug": "catalog:", 60 61 "istanbul-lib-coverage": "catalog:", 61 62 "istanbul-lib-report": "catalog:",
+6 -1
test/coverage-test/utils.ts
··· 43 43 enabled: true, 44 44 reporter: [], 45 45 ...config.coverage, 46 - provider, 46 + provider: provider === 'v8-ast-aware' ? 'v8' : provider, 47 + experimentalAstAwareRemapping: provider === 'v8-ast-aware', 47 48 customProviderModule: provider === 'custom' ? 'fixtures/custom-provider' : undefined, 48 49 }, 49 50 browser: { ··· 104 105 105 106 export function isV8Provider() { 106 107 return process.env.COVERAGE_PROVIDER === 'v8' 108 + } 109 + 110 + export function isExperimentalV8Provider() { 111 + return process.env.COVERAGE_PROVIDER === 'v8-ast-aware' 107 112 } 108 113 109 114 export function isBrowser() {
+19
test/coverage-test/vitest.workspace.custom.ts
··· 31 31 }, 32 32 }, 33 33 34 + // Test cases for experimental AST aware v8-provider 35 + { 36 + test: { 37 + ...config.test, 38 + name: 'v8-ast-aware', 39 + env: { COVERAGE_PROVIDER: 'v8-ast-aware' }, 40 + 41 + // Intentionally run Istanbul tests too 42 + include: [GENERIC_TESTS, ISTANBUL_TESTS, V8_TESTS], 43 + exclude: [ 44 + UNIT_TESTS, 45 + CUSTOM_TESTS, 46 + BROWSER_TESTS, 47 + // Not using original v8-to-istanbul that has patch applied: github.com/istanbuljs/v8-to-istanbul/pull/244 48 + 'test/empty-lines.v8.test.ts', 49 + ], 50 + }, 51 + }, 52 + 34 53 // Test cases for istanbul-provider 35 54 { 36 55 test: {
+75 -32
packages/coverage-v8/src/provider.ts
··· 9 9 import remapping from '@ampproject/remapping' 10 10 // @ts-expect-error -- untyped 11 11 import { mergeProcessCovs } from '@bcoe/v8-coverage' 12 + import astV8ToIstanbul from 'ast-v8-to-istanbul' 12 13 import createDebug from 'debug' 13 14 import libCoverage from 'istanbul-lib-coverage' 14 15 import libReport from 'istanbul-lib-report' ··· 24 25 import { cleanUrl } from 'vite-node/utils' 25 26 26 27 import { BaseCoverageProvider } from 'vitest/coverage' 28 + import { parseAstAsync } from 'vitest/node' 27 29 import { version } from '../package.json' with { type: 'json' } 28 30 29 31 export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage { ··· 209 211 transform, 210 212 ) 211 213 212 - const converter = v8ToIstanbul( 214 + coverageMap.merge(await this.v8ToIstanbul( 213 215 filename.href, 214 216 0, 215 217 sources, 216 - undefined, 217 - this.options.ignoreEmptyLines, 218 - ) 219 - 220 - await converter.load() 221 - 222 - try { 223 - // Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps. 224 - converter.applyCoverage([{ 218 + [{ 225 219 ranges: [ 226 220 { 227 221 startOffset: 0, ··· 232 226 isBlockCoverage: true, 233 227 // This is magical value that indicates an empty report: https://github.com/istanbuljs/v8-to-istanbul/blob/fca5e6a9e6ef38a9cdc3a178d5a6cf9ef82e6cab/lib/v8-to-istanbul.js#LL131C40-L131C40 234 228 functionName: '(empty-report)', 235 - }]) 236 - } 237 - catch (error) { 238 - this.ctx.logger.error(`Failed to convert coverage for uncovered ${filename.href}.\n`, error) 239 - } 240 - 241 - coverageMap.merge(converter.toIstanbul()) 229 + }], 230 + )) 242 231 243 232 if (debug.enabled) { 244 233 clearTimeout(timeout) ··· 251 240 } 252 241 253 242 return coverageMap 243 + } 244 + 245 + private async v8ToIstanbul(filename: string, wrapperLength: number, sources: Awaited<ReturnType<typeof this.getSources>>, functions: Profiler.FunctionCoverage[]) { 246 + if (this.options.experimentalAstAwareRemapping) { 247 + let ast 248 + try { 249 + ast = await parseAstAsync(sources.source) 250 + } 251 + catch (error) { 252 + this.ctx.logger.error(`Failed to parse ${filename}. Excluding it from coverage.\n`, error) 253 + return {} 254 + } 255 + 256 + return await astV8ToIstanbul({ 257 + code: sources.source, 258 + sourceMap: sources.sourceMap?.sourcemap, 259 + ast, 260 + coverage: { functions, url: filename }, 261 + ignoreClassMethods: this.options.ignoreClassMethods, 262 + wrapperLength, 263 + ignoreNode: (node, type) => { 264 + // SSR transformed imports 265 + if ( 266 + type === 'statement' 267 + && node.type === 'AwaitExpression' 268 + && node.argument.type === 'CallExpression' 269 + && node.argument.callee.type === 'Identifier' 270 + && node.argument.callee.name === '__vite_ssr_import__' 271 + ) { 272 + return true 273 + } 274 + 275 + // SSR transformed exports 276 + if ( 277 + type === 'statement' 278 + && node.type === 'ExpressionStatement' 279 + && node.expression.type === 'AssignmentExpression' 280 + && node.expression.left.type === 'MemberExpression' 281 + && node.expression.left.object.type === 'Identifier' 282 + && node.expression.left.object.name === '__vite_ssr_exports__' 283 + ) { 284 + return true 285 + } 286 + }, 287 + }, 288 + ) 289 + } 290 + 291 + const converter = v8ToIstanbul( 292 + filename, 293 + wrapperLength, 294 + sources, 295 + undefined, 296 + this.options.ignoreEmptyLines, 297 + ) 298 + await converter.load() 299 + 300 + try { 301 + converter.applyCoverage(functions) 302 + } 303 + catch (error) { 304 + this.ctx.logger.error(`Failed to convert coverage for ${filename}.\n`, error) 305 + } 306 + 307 + return converter.toIstanbul() 254 308 } 255 309 256 310 private async getSources<TransformResult extends (FetchResult | Awaited<ReturnType<typeof this.ctx.vitenode.transformRequest>>)>( ··· 280 334 // If file does not exist construct a dummy source for it. 281 335 // These can be files that were generated dynamically during the test run and were removed after it. 282 336 const length = findLongestFunctionLength(functions) 283 - return '.'.repeat(length) 337 + return '/'.repeat(length) 284 338 }) 285 339 } 286 340 ··· 381 435 functions, 382 436 ) 383 437 384 - const converter = v8ToIstanbul( 438 + coverageMap.merge(await this.v8ToIstanbul( 385 439 url, 386 440 startOffset, 387 441 sources, 388 - undefined, 389 - this.options.ignoreEmptyLines, 390 - ) 391 - await converter.load() 392 - 393 - try { 394 - converter.applyCoverage(functions) 395 - } 396 - catch (error) { 397 - this.ctx.logger.error(`Failed to convert coverage for ${url}.\n`, error) 398 - } 399 - 400 - coverageMap.merge(converter.toIstanbul()) 442 + functions, 443 + )) 401 444 402 445 if (debug.enabled) { 403 446 clearTimeout(timeout)
+4 -2
test/coverage-test/test/configuration-options.test-d.ts
··· 75 75 test('provider specific options, v8', () => { 76 76 assertType<Coverage>({ 77 77 provider: 'v8', 78 - // @ts-expect-error -- Istanbul-only option is not allowed 79 - ignoreClassMethods: ['string'], 78 + experimentalAstAwareRemapping: true, 80 79 }) 81 80 }) 82 81 ··· 84 83 assertType<Coverage>({ 85 84 provider: 'istanbul', 86 85 ignoreClassMethods: ['string'], 86 + 87 + // @ts-expect-error -- v8 specific error 88 + experimentalAstAwareRemapping: true, 87 89 }) 88 90 }) 89 91
+4 -2
test/coverage-test/test/file-outside-vite.test.ts
··· 1 1 import { createRequire } from 'node:module' 2 2 import { expect } from 'vitest' 3 - import { coverageTest, isV8Provider, normalizeURL, readCoverageMap, runVitest, test } from '../utils' 3 + import { coverageTest, isExperimentalV8Provider, isV8Provider, normalizeURL, readCoverageMap, runVitest, test } from '../utils' 4 4 5 5 test('does not crash when file outside Vite is loaded (#5639)', async () => { 6 6 await runVitest({ ··· 11 11 const coverageMap = await readCoverageMap() 12 12 const fileCoverage = coverageMap.fileCoverageFor('<process-cwd>/fixtures/src/load-outside-vite.cjs') 13 13 14 - if (isV8Provider()) { 14 + if (isV8Provider() || isExperimentalV8Provider()) { 15 15 expect(fileCoverage).toMatchInlineSnapshot(` 16 16 { 17 17 "branches": "0/0 (100%)", ··· 22 22 `) 23 23 } 24 24 else { 25 + // On istanbul the instrumentation happens on Vite plugin, so files 26 + // loaded outsite Vite should have 0% coverage 25 27 expect(fileCoverage).toMatchInlineSnapshot(` 26 28 { 27 29 "branches": "0/0 (100%)",
+5 -1
test/coverage-test/test/ignore-hints.test.ts
··· 4 4 */ 5 5 6 6 import { expect } from 'vitest' 7 - import { isV8Provider, readCoverageMap, runVitest, test } from '../utils' 7 + import { isExperimentalV8Provider, isV8Provider, readCoverageMap, runVitest, test } from '../utils' 8 8 9 9 test('ignore hints work', async () => { 10 10 await runVitest({ ··· 22 22 if (isV8Provider()) { 23 23 expect(lines[15]).toBeUndefined() 24 24 expect(lines[18]).toBeGreaterThanOrEqual(1) 25 + } 26 + else if (isExperimentalV8Provider()) { 27 + expect(lines[15]).toBeUndefined() 28 + expect(lines[18]).toBeUndefined() 25 29 } 26 30 else { 27 31 expect(lines[15]).toBeGreaterThanOrEqual(1)
+1 -1
test/coverage-test/fixtures/src/ignore-hints.ts
··· 11 11 // Covered line 12 12 second() 13 13 14 - /* v8 ignore next -- Uncovered line v8 */ 14 + /* v8 ignore next -- @preserve, Uncovered line v8 */ 15 15 second() 16 16 17 17 /* istanbul ignore next -- @preserve, Uncovered line istanbul */
+15
packages/vitest/src/node/types/coverage.ts
··· 275 275 export interface CoverageV8Options extends BaseCoverageOptions { 276 276 /** 277 277 * Ignore empty lines, comments and other non-runtime code, e.g. Typescript types 278 + * - Requires `experimentalAstAwareRemapping: false` 278 279 */ 279 280 ignoreEmptyLines?: boolean 281 + 282 + /** 283 + * Remap coverage with experimental AST based analysis 284 + * - Provides more accurate results compared to default mode 285 + */ 286 + experimentalAstAwareRemapping?: boolean 287 + 288 + /** 289 + * Set to array of class method names to ignore for coverage. 290 + * - Requires `experimentalAstAwareRemapping: true` 291 + * 292 + * @default [] 293 + */ 294 + ignoreClassMethods?: string[] 280 295 } 281 296 282 297 export interface CustomProviderOptions