diff --git a/docs/config/coverage.md b/docs/config/coverage.md index a4d01ad3e..698355dc0 100644 --- a/docs/config/coverage.md +++ b/docs/config/coverage.md @@ -407,6 +407,35 @@ Sets thresholds to 100 for files matching the glob pattern. Set to array of class method names to ignore for coverage. See [istanbul documentation](https://github.com/istanbuljs/nyc#ignoring-methods) for more information. +## coverage.ignoreNode 5.1.0 {#coverage-ignorenode} + +- **Type:** `(node: Node, type: 'function' | 'statement' | 'branch') => boolean | 'ignore-this-and-nested-nodes' | void` +- **Available for providers:** `'v8'` + +Filter to ignore code from coverage based on its AST node. Vitest calls it for each node of the transformed code, together with the kind of coverage the node counts for. Return `true` to ignore the node, or `'ignore-this-and-nested-nodes'` to also ignore all nodes inside it. + +This is useful for code that doesn't exist in the source file and can't carry an [ignore hint](/guide/coverage#ignoring-code), like code that compilers or other plugins generate. Since the nodes come from the code after Vite's transforms, the filter sees exactly what the plugins generated. + +Vitest's built-in filters run first, so nodes that Vitest already ignores, like its own transform artifacts, are never passed to this function. + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + ignoreNode: (node, type) => + type === 'statement' + && node.type === 'ExpressionStatement' + && node.expression.type === 'CallExpression' + && node.expression.callee.type === 'Identifier' + && node.expression.callee.name === '__generatedHelper', + }, + }, +}) +``` + ## coverage.watermarks - **Type:** diff --git a/packages/coverage-v8/src/provider.ts b/packages/coverage-v8/src/provider.ts index 6da8868f8..d892c150c 100644 --- a/packages/coverage-v8/src/provider.ts +++ b/packages/coverage-v8/src/provider.ts @@ -242,7 +242,7 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage return {} } - return (await astV8ToIstanbul({ + return (await astV8ToIstanbul({ code: result.code, sourceMap: result.map, ast, @@ -372,6 +372,8 @@ export class V8CoverageProvider extends BaseCoverageProvider implements Coverage ) { return 'ignore-this-and-nested-nodes' } + + return this.options.ignoreNode?.(node, type) }, })) as libCoverage.CoverageMapData } diff --git a/packages/vitest/src/node/types/coverage.ts b/packages/vitest/src/node/types/coverage.ts index f25d3ba74..cf014c26b 100644 --- a/packages/vitest/src/node/types/coverage.ts +++ b/packages/vitest/src/node/types/coverage.ts @@ -1,6 +1,6 @@ import type { ReportOptions } from '@vitest/istanbul-lib-report' import type { Arrayable } from '@vitest/utils' -import type { TransformResult as ViteTransformResult } from 'vite' +import type { ESTree, TransformResult as ViteTransformResult } from 'vite' import type { AfterSuiteRunMeta } from '../../types/general' import type { RuntimeCoverageModuleLoader, @@ -269,6 +269,22 @@ export interface CoverageOptions { */ ignoreClassMethods?: string[] + /** + * Filter to ignore code from coverage based on its AST node. + * Called only for nodes that Vitest's built-in filters don't already ignore. + * Return `'ignore-this-and-nested-nodes'` to also ignore all nodes inside the given node. + * Supported only by `v8` provider. + * + * @example + * ```ts + * ignoreNode: (node, type) => type === 'statement' && node.type === 'DebuggerStatement' + * ``` + */ + ignoreNode?: ( + node: ESTree.Node, + type: 'function' | 'statement' | 'branch', + ) => boolean | 'ignore-this-and-nested-nodes' | void + /** * Custom instrumenter factory to use instead of the default `@vitest/istanbul-lib-instrument`. * diff --git a/test/coverage-test/fixtures/src/ignore-node.ts b/test/coverage-test/fixtures/src/ignore-node.ts new file mode 100644 index 000000000..708d42b08 --- /dev/null +++ b/test/coverage-test/fixtures/src/ignore-node.ts @@ -0,0 +1,14 @@ +import { sum } from './math' + +export function calculate(a: number, b: number) { + __generatedHelper() + + if (a > b) { + const total = sum(a, b) + return total + } + + return sum(b, a) +} + +function __generatedHelper() {} diff --git a/test/coverage-test/fixtures/test/ignore-node-fixture.test.ts b/test/coverage-test/fixtures/test/ignore-node-fixture.test.ts new file mode 100644 index 000000000..9e4e06cdb --- /dev/null +++ b/test/coverage-test/fixtures/test/ignore-node-fixture.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { calculate } from '../src/ignore-node' + +test('cover some lines', () => { + expect(calculate(2, 1)).toBe(3) +}) diff --git a/test/coverage-test/test/configuration-options.test-d.ts b/test/coverage-test/test/configuration-options.test-d.ts index a31a59ee1..35472141b 100644 --- a/test/coverage-test/test/configuration-options.test-d.ts +++ b/test/coverage-test/test/configuration-options.test-d.ts @@ -328,3 +328,40 @@ test('custom instrumenter', () => { instrumenter: factory, }) }) + +test('ignoreNode', () => { + assertType({ + provider: 'v8', + ignoreNode: (node, type) => type === 'branch' && node.type === 'IfStatement', + }) + + assertType({ + provider: 'v8', + ignoreNode: () => 'ignore-this-and-nested-nodes', + }) + + assertType({ + provider: 'v8', + ignoreNode: () => {}, + }) + + assertType({ + provider: 'v8', + // @ts-expect-error -- must return a boolean, 'ignore-this-and-nested-nodes' or nothing + ignoreNode: () => 'ignore-everything', + }) + + assertType({ + provider: 'v8', + ignoreNode: (node) => { + if (node.type === 'IfStatement' && node.alternate) { + return true + } + + // @ts-expect-error -- CatchClause has no alternate. Should fail if node types work correctly + if (node.type === 'CatchClause' && node.alternate) { + return true + } + }, + }) +}) diff --git a/test/coverage-test/test/ignore-node.v8.test.ts b/test/coverage-test/test/ignore-node.v8.test.ts new file mode 100644 index 000000000..ddbca1544 --- /dev/null +++ b/test/coverage-test/test/ignore-node.v8.test.ts @@ -0,0 +1,60 @@ +import { expect } from 'vitest' +import { isNativeRunner, readCoverageMap, runVitest, test } from '../utils' + +/* See {@link file://./../fixtures/src/ignore-node.ts} */ +const filename = '/fixtures/src/ignore-node.ts' + +test('{ ignoreNode } excludes the matching nodes', async () => { + await runVitest({ + include: ['fixtures/test/ignore-node-fixture.test.ts'], + coverage: { + reporter: 'json', + ignoreNode: (node, type) => { + if ( + type === 'statement' && + node.type === 'ExpressionStatement' && + node.expression.type === 'CallExpression' && + node.expression.callee.type === 'Identifier' && + node.expression.callee.name === '__generatedHelper' + ) { + return true + } + + return type === 'branch' && node.type === 'IfStatement' + }, + }, + }) + + const coverageMap = await readCoverageMap() + const fileCoverage = coverageMap.fileCoverageFor(filename) + const lines = fileCoverage.getLineCoverage() + + expect(lines[4]).toBeUndefined() + + expect(fileCoverage.getBranchCoverageByLine()[6]).toBeUndefined() + + expect(lines[7]).toBeGreaterThanOrEqual(1) + expect(lines[8]).toBeGreaterThanOrEqual(1) + + expect(lines[11]).toBe(0) +}) + +test('{ ignoreNode } is not called for nodes that Vitest already ignores', async ({ skip }) => { + skip(isNativeRunner(), 'Native runner does not transform imports to __vite_ssr_import_') + + const declarators: string[] = [] + + await runVitest({ + include: ['fixtures/test/ignore-node-fixture.test.ts'], + coverage: { + reporter: 'json', + ignoreNode: (node) => { + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') { + declarators.push(node.id.name) + } + }, + }, + }) + + expect(declarators).toEqual(['total']) +})