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
10 changes: 9 additions & 1 deletion docs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1642,7 +1642,7 @@ Sets thresholds to 100 for files matching the glob pattern.
- **Available for providers:** `'v8'`
- **CLI:** `--coverage.ignoreEmptyLines=<boolean>`

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

This option works only if the used compiler removes comments and other non-runtime code from the transpiled code.
By default Vite uses ESBuild which removes comments and Typescript types from `.ts`, `.tsx` and `.jsx` files.
Expand All @@ -1666,6 +1666,14 @@ export default defineConfig({
},
})
```
#### coverage.experimentalAstAwareRemapping

- **Type:** `boolean`
- **Default:** `false`
- **Available for providers:** `'v8'`
- **CLI:** `--coverage.experimentalAstAwareRemapping=<boolean>`

Remap coverage with experimental AST based analysis. Provides more accurate results compared to default mode.

#### coverage.ignoreClassMethods

Expand Down
11 changes: 4 additions & 7 deletions docs/guide/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,24 +190,21 @@ Both coverage providers have their own ways how to ignore code from coverage rep

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

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

For `istanbul` provider you can include a `@preserve` keyword in the ignore hint.
You can include a `@preserve` keyword in the ignore hint.
Beware that these ignore hints may now be included in final production build as well.

```diff
-/* istanbul ignore if */
+/* istanbul ignore if -- @preserve */
if (condition) {
```

For `v8` this does not cause any issues. You can use `v8 ignore` comments with Typescript as usual:

<!-- eslint-skip -->
```ts
/* v8 ignore next 3 */
-/* v8 ignore if */
+/* v8 ignore if -- @preserve */
if (condition) {
```

Expand Down
1 change: 1 addition & 0 deletions packages/coverage-v8/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dependencies": {
"@ampproject/remapping": "catalog:",
"@bcoe/v8-coverage": "^1.0.2",
"ast-v8-to-istanbul": "^0.3.1",
"debug": "catalog:",
"istanbul-lib-coverage": "catalog:",
"istanbul-lib-report": "catalog:",
Expand Down
107 changes: 75 additions & 32 deletions packages/coverage-v8/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import remapping from '@ampproject/remapping'
// @ts-expect-error -- untyped
import { mergeProcessCovs } from '@bcoe/v8-coverage'
import astV8ToIstanbul from 'ast-v8-to-istanbul'
import createDebug from 'debug'
import libCoverage from 'istanbul-lib-coverage'
import libReport from 'istanbul-lib-report'
Expand All @@ -24,6 +25,7 @@ import v8ToIstanbul from 'v8-to-istanbul'
import { cleanUrl } from 'vite-node/utils'

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

export interface ScriptCoverageWithOffset extends Profiler.ScriptCoverage {
Expand Down Expand Up @@ -209,19 +211,11 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
transform,
)

const converter = v8ToIstanbul(
coverageMap.merge(await this.v8ToIstanbul(
filename.href,
0,
sources,
undefined,
this.options.ignoreEmptyLines,
)

await converter.load()

try {
// Create a made up function to mark whole file as uncovered. Note that this does not exist in source maps.
converter.applyCoverage([{
[{
ranges: [
{
startOffset: 0,
Expand All @@ -232,13 +226,8 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
isBlockCoverage: true,
// 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
functionName: '(empty-report)',
}])
}
catch (error) {
this.ctx.logger.error(`Failed to convert coverage for uncovered ${filename.href}.\n`, error)
}

coverageMap.merge(converter.toIstanbul())
}],
))

if (debug.enabled) {
clearTimeout(timeout)
Expand All @@ -253,6 +242,71 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
return coverageMap
}

private async v8ToIstanbul(filename: string, wrapperLength: number, sources: Awaited<ReturnType<typeof this.getSources>>, functions: Profiler.FunctionCoverage[]) {
if (this.options.experimentalAstAwareRemapping) {
let ast
try {
ast = await parseAstAsync(sources.source)
}
catch (error) {
this.ctx.logger.error(`Failed to parse ${filename}. Excluding it from coverage.\n`, error)
return {}
}

return await astV8ToIstanbul({
code: sources.source,
sourceMap: sources.sourceMap?.sourcemap,
ast,
coverage: { functions, url: filename },
ignoreClassMethods: this.options.ignoreClassMethods,
wrapperLength,
ignoreNode: (node, type) => {
// SSR transformed imports
if (
type === 'statement'
&& node.type === 'AwaitExpression'
&& node.argument.type === 'CallExpression'
&& node.argument.callee.type === 'Identifier'
&& node.argument.callee.name === '__vite_ssr_import__'
) {
return true
}

// SSR transformed exports
if (
type === 'statement'
&& node.type === 'ExpressionStatement'
&& node.expression.type === 'AssignmentExpression'
&& node.expression.left.type === 'MemberExpression'
&& node.expression.left.object.type === 'Identifier'
&& node.expression.left.object.name === '__vite_ssr_exports__'
) {
return true
}
},
Comment on lines +263 to +286
Copy link
Member Author

@AriPerkkio AriPerkkio Apr 1, 2025

Choose a reason for hiding this comment

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

@hi-ogawa this approach should provide us an easier way to control how Vite's generated code is excluded from coverage reports. We no longer need to modify source maps - instead we ignore the nodes while remapping coverage.

https://github.com/AriPerkkio/ast-v8-to-istanbul?tab=readme-ov-file#ignoring-generated-code

},
)
}

const converter = v8ToIstanbul(
filename,
wrapperLength,
sources,
undefined,
this.options.ignoreEmptyLines,
)
await converter.load()

try {
converter.applyCoverage(functions)
}
catch (error) {
this.ctx.logger.error(`Failed to convert coverage for ${filename}.\n`, error)
}

return converter.toIstanbul()
}

private async getSources<TransformResult extends (FetchResult | Awaited<ReturnType<typeof this.ctx.vitenode.transformRequest>>)>(
url: string,
transformResults: TransformResults,
Expand Down Expand Up @@ -280,7 +334,7 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
// 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.
const length = findLongestFunctionLength(functions)
return '.'.repeat(length)
return '/'.repeat(length)
})
}

Expand Down Expand Up @@ -381,23 +435,12 @@ export class V8CoverageProvider extends BaseCoverageProvider<ResolvedCoverageOpt
functions,
)

const converter = v8ToIstanbul(
coverageMap.merge(await this.v8ToIstanbul(
url,
startOffset,
sources,
undefined,
this.options.ignoreEmptyLines,
)
await converter.load()

try {
converter.applyCoverage(functions)
}
catch (error) {
this.ctx.logger.error(`Failed to convert coverage for ${url}.\n`, error)
}

coverageMap.merge(converter.toIstanbul())
functions,
))

if (debug.enabled) {
clearTimeout(timeout)
Expand Down
15 changes: 15 additions & 0 deletions packages/vitest/src/node/types/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,23 @@ export interface CoverageIstanbulOptions extends BaseCoverageOptions {
export interface CoverageV8Options extends BaseCoverageOptions {
/**
* Ignore empty lines, comments and other non-runtime code, e.g. Typescript types
* - Requires `experimentalAstAwareRemapping: false`
*/
ignoreEmptyLines?: boolean

/**
* Remap coverage with experimental AST based analysis
* - Provides more accurate results compared to default mode
*/
experimentalAstAwareRemapping?: boolean

/**
* Set to array of class method names to ignore for coverage.
* - Requires `experimentalAstAwareRemapping: true`
*
* @default []
*/
ignoreClassMethods?: string[]
}

export interface CustomProviderOptions
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion test/coverage-test/fixtures/src/ignore-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function second() {
// Covered line
second()

/* v8 ignore next -- Uncovered line v8 */
/* v8 ignore next -- @preserve, Uncovered line v8 */
second()

/* istanbul ignore next -- @preserve, Uncovered line istanbul */
Expand Down
6 changes: 4 additions & 2 deletions test/coverage-test/test/configuration-options.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,17 @@ test('provider options, generic', () => {
test('provider specific options, v8', () => {
assertType<Coverage>({
provider: 'v8',
// @ts-expect-error -- Istanbul-only option is not allowed
ignoreClassMethods: ['string'],
experimentalAstAwareRemapping: true,
})
})

test('provider specific options, istanbul', () => {
assertType<Coverage>({
provider: 'istanbul',
ignoreClassMethods: ['string'],

// @ts-expect-error -- v8 specific error
experimentalAstAwareRemapping: true,
})
})

Expand Down
6 changes: 4 additions & 2 deletions test/coverage-test/test/file-outside-vite.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createRequire } from 'node:module'
import { expect } from 'vitest'
import { coverageTest, isV8Provider, normalizeURL, readCoverageMap, runVitest, test } from '../utils'
import { coverageTest, isExperimentalV8Provider, isV8Provider, normalizeURL, readCoverageMap, runVitest, test } from '../utils'

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

if (isV8Provider()) {
if (isV8Provider() || isExperimentalV8Provider()) {
expect(fileCoverage).toMatchInlineSnapshot(`
{
"branches": "0/0 (100%)",
Expand All @@ -22,6 +22,8 @@ test('does not crash when file outside Vite is loaded (#5639)', async () => {
`)
}
else {
// On istanbul the instrumentation happens on Vite plugin, so files
// loaded outsite Vite should have 0% coverage
expect(fileCoverage).toMatchInlineSnapshot(`
{
"branches": "0/0 (100%)",
Expand Down
6 changes: 5 additions & 1 deletion test/coverage-test/test/ignore-hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { expect } from 'vitest'
import { isV8Provider, readCoverageMap, runVitest, test } from '../utils'
import { isExperimentalV8Provider, isV8Provider, readCoverageMap, runVitest, test } from '../utils'

test('ignore hints work', async () => {
await runVitest({
Expand All @@ -23,6 +23,10 @@ test('ignore hints work', async () => {
expect(lines[15]).toBeUndefined()
expect(lines[18]).toBeGreaterThanOrEqual(1)
}
else if (isExperimentalV8Provider()) {
expect(lines[15]).toBeUndefined()
expect(lines[18]).toBeUndefined()
}
else {
expect(lines[15]).toBeGreaterThanOrEqual(1)
expect(lines[18]).toBeUndefined()
Expand Down
7 changes: 6 additions & 1 deletion test/coverage-test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export async function runVitest(config: UserConfig, options = { throwOnError: tr
enabled: true,
reporter: [],
...config.coverage,
provider,
provider: provider === 'v8-ast-aware' ? 'v8' : provider,
experimentalAstAwareRemapping: provider === 'v8-ast-aware',
customProviderModule: provider === 'custom' ? 'fixtures/custom-provider' : undefined,
},
browser: {
Expand Down Expand Up @@ -106,6 +107,10 @@ export function isV8Provider() {
return process.env.COVERAGE_PROVIDER === 'v8'
}

export function isExperimentalV8Provider() {
return process.env.COVERAGE_PROVIDER === 'v8-ast-aware'
}

export function isBrowser() {
return process.env.COVERAGE_BROWSER === 'true'
}
Expand Down
Loading
Loading