Skip to content
Open
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
7,303 changes: 7,303 additions & 0 deletions docs/public/sponsors/antfu.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
506 changes: 506 additions & 0 deletions docs/public/sponsors/patak-dev.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 136 additions & 0 deletions docs/public/sponsors/sheremet-va.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 29 additions & 15 deletions packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,31 +149,45 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan
const roots = root ? [root] : this.roots

const filename = slash(_filename)
const cacheHit = this.globCache.get(filename)
const cacheKey = root ? `${root}::${filename}` : filename
const cacheHit = this.globCache.get(cacheKey)

if (cacheHit !== undefined) {
return cacheHit
}

// File outside project root with default allowExternal
if (this.options.allowExternal === false && roots.every(root => !filename.startsWith(root))) {
this.globCache.set(filename, false)
const includeGlobs = this.options.include || '**'
const excludes = this.options.exclude

return false
}
for (const projectRoot of roots) {
const relativePath = slash(relative(projectRoot, filename))
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't this actually change how defined globs are interpreted when using --project filter?

  • When it's not used, globs are relative to root
  • When it's used, globs are relative to project's root
├── package.json
├── vitest.config.ts
└── packages
    ├── one
    │   ├── src
    │   │   └── math.ts
    │   └── test
    │       └── math.test.ts
    └── two
        ├── src
        │   └── get-user.ts
        └── test
            └── get-user.test.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    coverage: {
      include: ["./packages/**/*.ts"],
    },

    projects: [
      {
        test: {
          name: "One",
          root: "./packages/one",
        },
      },
      {
        test: {
          name: "Two",
          root: "./packages/two",
        },
      },
    ],
  },
});
> vitest --run --coverage

 RUN  v4.0.16 /x/examples/packages/vitest-example
      Coverage enabled with v8

 ✓  Two  test/get-user.test.ts (1 test) 1ms
 ✓  One  test/math.test.ts (1 test) 1ms

 Test Files  2 passed (2)
      Tests  2 passed (2)
   Start at  10:12:31
   Duration  115ms (transform 32ms, setup 0ms, import 43ms, tests 2ms, environment 0ms)

 % Coverage report from v8
--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------|---------|----------|---------|---------|-------------------
All files     |      40 |      100 |      40 |      40 |                   
 one/src      |      25 |      100 |      25 |      25 |                   
  math.ts     |      25 |      100 |      25 |      25 | 6-14              
 two/src      |     100 |      100 |     100 |     100 |                   
  get-user.ts |     100 |      100 |     100 |     100 |                   
--------------|---------|----------|---------|---------|-------------------

This should show coverage for packages/one, but it's missing:

> vitest --run --coverage --project one

 RUN  v4.0.16 /x/examples/packages/vitest-example
      Coverage enabled with v8

 ✓  One  test/math.test.ts (1 test) 1ms
   ✓ sum 0ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  10:12:50
   Duration  99ms (transform 14ms, setup 0ms, import 19ms, tests 1ms, environment 0ms)

 % Coverage report from v8
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |                   
----------|---------|----------|---------|---------|-------------------

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, I will evaluate these cases.

const isExternal = relativePath.startsWith('..') || path.isAbsolute(relativePath)

// By default `coverage.include` matches all files, except "coverage.exclude"
const glob = this.options.include || '**'
if (this.options.allowExternal === false && isExternal) {
continue
}

const included = pm.isMatch(filename, glob, {
contains: true,
dot: true,
ignore: this.options.exclude,
})
const matchTarget = isExternal ? filename : relativePath

if (pm.isMatch(matchTarget, excludes, { dot: true })) {
continue
}

this.globCache.set(filename, included)
if (!isExternal) {
const absoluteExcludes = excludes.filter(pattern => path.isAbsolute(pattern))
if (absoluteExcludes.length > 0 && pm.isMatch(filename, absoluteExcludes, { dot: true })) {
continue
}
}

if (pm.isMatch(matchTarget, includeGlobs, { dot: true, contains: true })) {
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need 2x pm.isMatch calls, instead of doing just one with passing ignore: options.exclude? Any examples that would fail without 2x calls?

Copy link
Contributor Author

@DevJoaoLopes DevJoaoLopes Jan 18, 2026

Choose a reason for hiding this comment

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

@AriPerkkio

You’re right that we call pm.isMatch twice, but they serve different purposes and need distinct options:

First call (excludes, { dot: true }): early-out for any path matching coverage.exclude, regardless of how includes are written. This prevents excluded files from slipping through when an include pattern is broad.
Second call (includeGlobs, { dot: true, contains: true }): only after passing exclusion do we check if the file qualifies for coverage.include. The contains: true is important for patterns like "src/utils" that should match nested paths (e.g., src/utils/math.ts), which a strict match would miss.
Dropping either call breaks behavior: without the exclude check, excluded files (e.g., __mocks__) get counted; without the include check (with contains: true), legitimate files under broad includes are skipped.

this.globCache.set(cacheKey, true)
return true
}
}

return included
this.globCache.set(cacheKey, false)
return false
}

private async getUntestedFilesByRoot(
Expand Down
3 changes: 3 additions & 0 deletions test/coverage-test/fixtures/src/nested/nested-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function nestedLevel(n: number) {
return n * 2
}
3 changes: 3 additions & 0 deletions test/coverage-test/fixtures/src/root-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function rootLevel(n: number) {
return n + 1
}
13 changes: 13 additions & 0 deletions test/coverage-test/fixtures/test/exclude-globstar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, test } from 'vitest'
import { rootLevel } from '../src/root-level'
import { nestedLevel } from '../src/nested/nested-level'

describe('coverage include/exclude patterns', () => {
test('includes files from top-level', () => {
expect(rootLevel(5)).toBe(6)
})

test('includes files from nested directories', () => {
expect(nestedLevel(5)).toBe(10)
})
})
4 changes: 2 additions & 2 deletions test/coverage-test/test/exclude-after-remap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ test('{ excludeAfterRemap: true } should exclude files that come up after remapp
await runVitest({
include: [normalizeURL(import.meta.url)],
coverage: {
exclude: ['fixtures/src/pre-bundle/second.ts', './utils.ts'],
exclude: ['fixtures/src/pre-bundle/second.ts', './utils.ts', '**/test/**'],
excludeAfterRemap: true,
reporter: 'json',
},
Expand All @@ -26,7 +26,7 @@ test('{ excludeAfterRemap: false } should not exclude files that come up after r
await runVitest({
include: [normalizeURL(import.meta.url)],
coverage: {
exclude: ['fixtures/src/pre-bundle/second.ts', './utils.ts'],
exclude: ['fixtures/src/pre-bundle/second.ts', './utils.ts', '**/test/**'],
reporter: 'json',
},
})
Expand Down
22 changes: 20 additions & 2 deletions test/coverage-test/test/include-exclude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ test('exclude can exclude covered files #2', async () => {
reporter: 'json',
include: ['fixtures/src/{math,even}.ts'],

// pattern that's recognized by picomatch but not by tinyglobby
exclude: ['math'],
exclude: ['**/math.ts'],
},
})

Expand Down Expand Up @@ -393,3 +392,22 @@ test('includes covered and uncovered with ] in filenames', async () => {
]
`)
})

test('exclude pattern without globstar excludes only top-level files', async () => {
await runVitest({
include: ['fixtures/test/exclude-globstar.test.ts'],
coverage: {
reporter: 'json',
include: ['fixtures/src/root-level.ts', 'fixtures/src/nested/*.ts'],
exclude: ['fixtures/src/*.ts'],
},
})

const coverageMap = await readCoverageMap()

expect(coverageMap.files()).toMatchInlineSnapshot(`
[
"<process-cwd>/fixtures/src/nested/nested-level.ts",
]
`)
})
Loading