Skip to content

Commit 3bef604

Browse files
committed
feat(coverage): v8 experimental AST-aware remapping
1 parent fcd3a1b commit 3bef604

File tree

13 files changed

+140
-17
lines changed

13 files changed

+140
-17
lines changed

docs/config/index.md

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

1641-
Ignore empty lines, comments and other non-runtime code, e.g. Typescript types.
1641+
Ignore empty lines, comments and other non-runtime code, e.g. Typescript types. Requires `experimentalAstAwareRemapping: false`.
16421642

16431643
This option works only if the used compiler removes comments and other non-runtime code from the transpiled code.
16441644
By default Vite uses ESBuild which removes comments and Typescript types from `.ts`, `.tsx` and `.jsx` files.
@@ -1662,6 +1662,14 @@ export default defineConfig({
16621662
},
16631663
})
16641664
```
1665+
#### coverage.experimentalAstAwareRemapping
1666+
1667+
- **Type:** `boolean`
1668+
- **Default:** `false`
1669+
- **Available for providers:** `'v8'`
1670+
- **CLI:** `--coverage.experimentalAstAwareRemapping=<boolean>`
1671+
1672+
Remap coverage with experimental AST based analysis. Provides more accurate results compared to default mode.
16651673

16661674
#### coverage.ignoreClassMethods
16671675

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.1.0",
5960
"debug": "catalog:",
6061
"istanbul-lib-coverage": "catalog:",
6162
"istanbul-lib-report": "catalog:",

packages/coverage-v8/src/provider.ts

Lines changed: 48 additions & 1 deletion
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 {
@@ -219,6 +221,51 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
219221
}
220222

221223
private async v8ToIstanbul(filename: string, wrapperLength: number, sources: Awaited<ReturnType<typeof this.getSources>>, functions: Profiler.FunctionCoverage[]) {
224+
if (this.options.experimentalAstAwareRemapping) {
225+
let ast
226+
try {
227+
ast = parseAstAsync(sources.source)
228+
}
229+
catch (error) {
230+
this.ctx.logger.error(`Failed to parse ${filename}. Excluding it from coverage.\n`, error)
231+
return {}
232+
}
233+
234+
return await astV8ToIstanbul({
235+
code: sources.source,
236+
sourceMap: sources.sourceMap?.sourcemap,
237+
ast,
238+
coverage: { functions, url: filename },
239+
ignoreClassMethods: this.options.ignoreClassMethods,
240+
wrapperLength,
241+
ignoreNode: (node, type) => {
242+
// SSR transformed imports
243+
if (
244+
type === 'statement'
245+
&& node.type === 'AwaitExpression'
246+
&& node.argument.type === 'CallExpression'
247+
&& node.argument.callee.type === 'Identifier'
248+
&& node.argument.callee.name === '__vite_ssr_import__'
249+
) {
250+
return true
251+
}
252+
253+
// SSR transformed exports
254+
if (
255+
type === 'statement'
256+
&& node.type === 'ExpressionStatement'
257+
&& node.expression.type === 'AssignmentExpression'
258+
&& node.expression.left.type === 'MemberExpression'
259+
&& node.expression.left.object.type === 'Identifier'
260+
&& node.expression.left.object.name === '__vite_ssr_exports__'
261+
) {
262+
return true
263+
}
264+
},
265+
},
266+
)
267+
}
268+
222269
const converter = v8ToIstanbul(
223270
filename,
224271
wrapperLength,
@@ -265,7 +312,7 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
265312
// If file does not exist construct a dummy source for it.
266313
// These can be files that were generated dynamically during the test run and were removed after it.
267314
const length = findLongestFunctionLength(functions)
268-
return '.'.repeat(length)
315+
return '/'.repeat(length)
269316
})
270317
}
271318

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: 13 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()

0 commit comments

Comments
 (0)