Skip to content

Commit 78a3d27

Browse files
authored
feat(coverage): v8 experimental AST-aware remapping (#7736)
1 parent e761f27 commit 78a3d27

File tree

12 files changed

+155
-47
lines changed

12 files changed

+155
-47
lines changed

docs/config/index.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1642,7 +1642,7 @@ Sets thresholds to 100 for files matching the glob pattern.
16421642
- **Available for providers:** `'v8'`
16431643
- **CLI:** `--coverage.ignoreEmptyLines=<boolean>`
16441644

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`.
16461646

16471647
This option works only if the used compiler removes comments and other non-runtime code from the transpiled code.
16481648
By default Vite uses ESBuild which removes comments and Typescript types from `.ts`, `.tsx` and `.jsx` files.
@@ -1666,6 +1666,14 @@ export default defineConfig({
16661666
},
16671667
})
16681668
```
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.
16691677

16701678
#### coverage.ignoreClassMethods
16711679

docs/guide/coverage.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,24 +190,21 @@ Both coverage providers have their own ways how to ignore code from coverage rep
190190

191191
- [`v8`](https://github.com/istanbuljs/v8-to-istanbul#ignoring-uncovered-lines)
192192
- [`ì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)
193194

194195
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)).
195196
Comments which are considered as [legal comments](https://esbuild.github.io/api/#legal-comments) are preserved.
196197

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.
198199
Beware that these ignore hints may now be included in final production build as well.
199200

200201
```diff
201202
-/* istanbul ignore if */
202203
+/* istanbul ignore if -- @preserve */
203204
if (condition) {
204-
```
205-
206-
For `v8` this does not cause any issues. You can use `v8 ignore` comments with Typescript as usual:
207205

208-
<!-- eslint-skip -->
209-
```ts
210-
/* v8 ignore next 3 */
206+
-/* v8 ignore if */
207+
+/* v8 ignore if -- @preserve */
211208
if (condition) {
212209
```
213210

packages/coverage-v8/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"dependencies": {
5757
"@ampproject/remapping": "catalog:",
5858
"@bcoe/v8-coverage": "^1.0.2",
59+
"ast-v8-to-istanbul": "^0.3.1",
5960
"debug": "catalog:",
6061
"istanbul-lib-coverage": "catalog:",
6162
"istanbul-lib-report": "catalog:",

packages/coverage-v8/src/provider.ts

Lines changed: 75 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
99
import remapping from '@ampproject/remapping'
1010
// @ts-expect-error -- untyped
1111
import { mergeProcessCovs } from '@bcoe/v8-coverage'
12+
import astV8ToIstanbul from 'ast-v8-to-istanbul'
1213
import createDebug from 'debug'
1314
import libCoverage from 'istanbul-lib-coverage'
1415
import libReport from 'istanbul-lib-report'
@@ -24,6 +25,7 @@ import v8ToIstanbul from 'v8-to-istanbul'
2425
import { cleanUrl } from 'vite-node/utils'
2526

2627
import { BaseCoverageProvider } from 'vitest/coverage'
28+
import { parseAstAsync } from 'vitest/node'
2729
import { version } from '../package.json' with { type: 'json' }
2830

2931
export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage {
@@ -209,19 +211,11 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
209211
transform,
210212
)
211213

212-
const converter = v8ToIstanbul(
214+
coverageMap.merge(await this.v8ToIstanbul(
213215
filename.href,
214216
0,
215217
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+
[{
225219
ranges: [
226220
{
227221
startOffset: 0,
@@ -232,13 +226,8 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
232226
isBlockCoverage: true,
233227
// 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
234228
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+
))
242231

243232
if (debug.enabled) {
244233
clearTimeout(timeout)
@@ -253,6 +242,71 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
253242
return coverageMap
254243
}
255244

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()
308+
}
309+
256310
private async getSources<TransformResult extends (FetchResult | Awaited<ReturnType<typeof this.ctx.vitenode.transformRequest>>)>(
257311
url: string,
258312
transformResults: TransformResults,
@@ -280,7 +334,7 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
280334
// If file does not exist construct a dummy source for it.
281335
// These can be files that were generated dynamically during the test run and were removed after it.
282336
const length = findLongestFunctionLength(functions)
283-
return '.'.repeat(length)
337+
return '/'.repeat(length)
284338
})
285339
}
286340

@@ -381,23 +435,12 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
381435
functions,
382436
)
383437

384-
const converter = v8ToIstanbul(
438+
coverageMap.merge(await this.v8ToIstanbul(
385439
url,
386440
startOffset,
387441
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+
))
401444

402445
if (debug.enabled) {
403446
clearTimeout(timeout)

packages/vitest/src/node/types/coverage.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,23 @@ export interface CoverageIstanbulOptions extends BaseCoverageOptions {
275275
export interface CoverageV8Options extends BaseCoverageOptions {
276276
/**
277277
* Ignore empty lines, comments and other non-runtime code, e.g. Typescript types
278+
* - Requires `experimentalAstAwareRemapping: false`
278279
*/
279280
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[]
280295
}
281296

282297
export interface CustomProviderOptions

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/coverage-test/fixtures/src/ignore-hints.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function second() {
1111
// Covered line
1212
second()
1313

14-
/* v8 ignore next -- Uncovered line v8 */
14+
/* v8 ignore next -- @preserve, Uncovered line v8 */
1515
second()
1616

1717
/* istanbul ignore next -- @preserve, Uncovered line istanbul */

test/coverage-test/test/configuration-options.test-d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,17 @@ test('provider options, generic', () => {
7575
test('provider specific options, v8', () => {
7676
assertType<Coverage>({
7777
provider: 'v8',
78-
// @ts-expect-error -- Istanbul-only option is not allowed
79-
ignoreClassMethods: ['string'],
78+
experimentalAstAwareRemapping: true,
8079
})
8180
})
8281

8382
test('provider specific options, istanbul', () => {
8483
assertType<Coverage>({
8584
provider: 'istanbul',
8685
ignoreClassMethods: ['string'],
86+
87+
// @ts-expect-error -- v8 specific error
88+
experimentalAstAwareRemapping: true,
8789
})
8890
})
8991

test/coverage-test/test/file-outside-vite.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createRequire } from 'node:module'
22
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'
44

55
test('does not crash when file outside Vite is loaded (#5639)', async () => {
66
await runVitest({
@@ -11,7 +11,7 @@ test('does not crash when file outside Vite is loaded (#5639)', async () => {
1111
const coverageMap = await readCoverageMap()
1212
const fileCoverage = coverageMap.fileCoverageFor('<process-cwd>/fixtures/src/load-outside-vite.cjs')
1313

14-
if (isV8Provider()) {
14+
if (isV8Provider() || isExperimentalV8Provider()) {
1515
expect(fileCoverage).toMatchInlineSnapshot(`
1616
{
1717
"branches": "0/0 (100%)",
@@ -22,6 +22,8 @@ test('does not crash when file outside Vite is loaded (#5639)', async () => {
2222
`)
2323
}
2424
else {
25+
// On istanbul the instrumentation happens on Vite plugin, so files
26+
// loaded outsite Vite should have 0% coverage
2527
expect(fileCoverage).toMatchInlineSnapshot(`
2628
{
2729
"branches": "0/0 (100%)",

test/coverage-test/test/ignore-hints.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { expect } from 'vitest'
7-
import { isV8Provider, readCoverageMap, runVitest, test } from '../utils'
7+
import { isExperimentalV8Provider, isV8Provider, readCoverageMap, runVitest, test } from '../utils'
88

99
test('ignore hints work', async () => {
1010
await runVitest({
@@ -23,6 +23,10 @@ test('ignore hints work', async () => {
2323
expect(lines[15]).toBeUndefined()
2424
expect(lines[18]).toBeGreaterThanOrEqual(1)
2525
}
26+
else if (isExperimentalV8Provider()) {
27+
expect(lines[15]).toBeUndefined()
28+
expect(lines[18]).toBeUndefined()
29+
}
2630
else {
2731
expect(lines[15]).toBeGreaterThanOrEqual(1)
2832
expect(lines[18]).toBeUndefined()

test/coverage-test/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export async function runVitest(config: UserConfig, options = { throwOnError: tr
4343
enabled: true,
4444
reporter: [],
4545
...config.coverage,
46-
provider,
46+
provider: provider === 'v8-ast-aware' ? 'v8' : provider,
47+
experimentalAstAwareRemapping: provider === 'v8-ast-aware',
4748
customProviderModule: provider === 'custom' ? 'fixtures/custom-provider' : undefined,
4849
},
4950
browser: {
@@ -106,6 +107,10 @@ export function isV8Provider() {
106107
return process.env.COVERAGE_PROVIDER === 'v8'
107108
}
108109

110+
export function isExperimentalV8Provider() {
111+
return process.env.COVERAGE_PROVIDER === 'v8-ast-aware'
112+
}
113+
109114
export function isBrowser() {
110115
return process.env.COVERAGE_BROWSER === 'true'
111116
}

0 commit comments

Comments
 (0)