diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index fb2569c9d..081f2545b 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -14,6 +14,7 @@ import { pathToFileURL } from 'node:url' import { slash, toArray } from '@vitest/utils/helpers' import { resolveModule } from 'local-pkg' import { join, normalize, relative, resolve } from 'pathe' +import { isDynamicPattern } from 'tinyglobby' import c from 'tinyrainbow' import { mergeConfig } from 'vite' import { @@ -513,6 +514,17 @@ export function resolveConfig( resolvePath(file, resolved.root), ) + if (resolved.coverage.include) { + resolved.coverage.include = resolved.coverage.include.map((pattern) => { + if (isDynamicPattern(pattern)) { + return pattern + } + + // Convert patterns like ["src", "packages/server"] to ["src/**", "packages/server/**"] + return pattern.endsWith('/') ? `${pattern}**` : `${pattern}/**` + }) + } + // Add hard-coded default coverage exclusions. These cannot be overridden by user config. // Override original exclude array for cases where user re-uses same object in test.exclude. resolved.coverage.exclude = [ diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts index 131cf70b7..e05ffca58 100644 --- a/packages/vitest/src/node/coverage.ts +++ b/packages/vitest/src/node/coverage.ts @@ -163,11 +163,18 @@ export class BaseCoverageProvider { return false } + const matchingRoot = roots.find(root => filename.startsWith(`${slash(root)}/`) || filename === slash(root)) + const relativeFilename = matchingRoot ? relative(matchingRoot, filename) : filename + + if (pm.isMatch(relativeFilename, this.options.exclude, { dot: true })) { + this.globCache.set(filename, false) + return false + } + // By default `coverage.include` matches all files, except "coverage.exclude" const glob = this.options.include || '**' - let included = pm.isMatch(filename, glob, { - contains: true, + let included = pm.isMatch(relativeFilename, glob, { dot: true, ignore: this.options.exclude, }) diff --git a/test/config/test/failures.test.ts b/test/config/test/failures.test.ts index fb7be8484..cecaf6124 100644 --- a/test/config/test/failures.test.ts +++ b/test/config/test/failures.test.ts @@ -349,7 +349,7 @@ test('coverage.autoUpdate cannot update thresholds when configuration file doesn }) test('boolean flag 100 should not crash CLI', async () => { - let { stderr } = await runVitestCli('--coverage.enabled', '--coverage.thresholds.100', '--coverage.include=fixtures/coverage-test', '--passWithNoTests') + let { stderr } = await runVitestCli('--coverage.enabled', '--coverage.thresholds.100', '--coverage.include=fixtures/coverage-test/*', '--passWithNoTests') // non-zero coverage shows up, which is non-deterministic, so strip it. stderr = stderr.replace(/\([0-9.]+%\) does/g, '(0%) does') diff --git a/test/coverage-test/test/include-exclude.test.ts b/test/coverage-test/test/include-exclude.test.ts index da9e7e453..b11cf24c7 100644 --- a/test/coverage-test/test/include-exclude.test.ts +++ b/test/coverage-test/test/include-exclude.test.ts @@ -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'], }, }) @@ -109,6 +108,25 @@ test('exclude can exclude covered files #2', async () => { `) }) +test('exclude globs don\'t filter out cwd', async () => { + await runVitest({ + include: ['fixtures/test/math.test.ts', 'fixtures/test/even.test.ts'], + coverage: { + reporter: 'json', + include: ['fixtures/src/{math,even}.ts'], + exclude: ['**/coverage-test/**'], + }, + }) + + const coverageMap = await readCoverageMap() + expect(coverageMap.files()).toMatchInlineSnapshot(` + [ + "/fixtures/src/even.ts", + "/fixtures/src/math.ts", + ] + `) +}) + test('uncovered files are included after watch-mode re-run', async () => { // to avoid printing coverage report to stdout const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) diff --git a/test/coverage-test/test/include-exclude.unit.test.ts b/test/coverage-test/test/include-exclude.unit.test.ts new file mode 100644 index 000000000..0287b7d00 --- /dev/null +++ b/test/coverage-test/test/include-exclude.unit.test.ts @@ -0,0 +1,151 @@ +import type { BaseCoverageProvider, CoverageOptions } from 'vitest/node' +import { join, resolve, sep } from 'node:path' +import { Writable } from 'node:stream' +import { expect, onTestFinished, test } from 'vitest' +import { createVitest } from 'vitest/node' + +test('include nested, exclude top level', async () => { + const isIncluded = await init({ + include: ['**/*.ts'], + exclude: ['*.ts'], + }) + + expect.soft(isIncluded('top-level.ts')).toBe(false) + expect.soft(isIncluded('src/nested.ts')).toBe(true) +}) + +test('exclude directory that is also in cwd', async () => { + const cwd = process.cwd() + expect(cwd).toContain(join(sep, 'coverage-test')) + + const isIncluded = await init({ + include: ['src/**/*.ts'], + exclude: ['**/coverage-test/**', join('**', sep, 'coverage-test', sep, '**')], + }) + + expect(isIncluded('src/example.ts')).toBe(true) +}) + +test('include with exact match', async () => { + const isIncluded = await init({ + include: ['src/**/*.ts', 'utils/setupFile.ts'], + }) + + expect(isIncluded('utils/setupFile.ts')).toBe(true) +}) + +test('include with partial extension match', async () => { + const isIncluded = await init({ + include: ['**/*.js'], + }) + + expect.soft(isIncluded('setupFile.js')).toBe(true) + expect.soft(isIncluded('component.jsx')).toBe(false) + expect.soft(isIncluded('package.json')).toBe(false) +}) + +test('include without actual glob', async () => { + const isIncluded = await init({ + include: ['src', 'another/nested'], + }) + + expect.soft(isIncluded('src/component.js')).toBe(true) + expect.soft(isIncluded('src/nested/component.ts')).toBe(true) + + expect.soft(isIncluded('another/nested/component.ts')).toBe(true) + expect.soft(isIncluded('another/nested/deeply/component.ts')).toBe(true) + expect.soft(isIncluded('another/nested-ignored.js')).toBe(false) + + expect.soft(isIncluded('src-file-in-root.js')).toBe(false) +}) + +test('no include defaults to match all files', async () => { + const isIncluded = await init({ + exclude: [], + }) + + expect.soft(isIncluded('src/anything.ts')).toBe(true) + expect.soft(isIncluded('deeply/nested/dir/file.js')).toBe(true) +}) + +test('exclude takes priority over include', async () => { + const isIncluded = await init({ + include: ['src/**/*.ts'], + exclude: ['src/**/*.ts'], + }) + + expect(isIncluded('src/example.ts')).toBe(false) +}) + +test('multiple include patterns', async () => { + const isIncluded = await init({ + include: ['src/**/*.ts', 'lib/**/*.ts'], + exclude: [], + }) + + expect.soft(isIncluded('src/foo.ts')).toBe(true) + expect.soft(isIncluded('lib/bar.ts')).toBe(true) + expect.soft(isIncluded('other/baz.ts')).toBe(false) +}) + +test('multiple exclude patterns', async () => { + const isIncluded = await init({ + include: ['**/*.ts'], + exclude: ['**/*.test.ts', '**/*.spec.ts'], + }) + + expect.soft(isIncluded('src/foo.ts')).toBe(true) + expect.soft(isIncluded('src/foo.test.ts')).toBe(false) + expect.soft(isIncluded('src/foo.spec.ts')).toBe(false) +}) + +test('dot files are matched when using dot patterns', async () => { + const isIncluded = await init({ + include: ['**/*.ts'], + exclude: [], + }) + + expect.soft(isIncluded('.hidden/file.ts')).toBe(true) + expect.soft(isIncluded('src/.hidden.ts')).toBe(true) +}) + +test('files outside project when allowExternal: false', async () => { + const isIncluded = await init({ + include: ['**/*.ts'], + exclude: ['**/package-b/**'], + allowExternal: false, + }) + + expect(isIncluded(resolve(process.cwd(), '../../package-a/src/one.ts'))).toBe(false) + expect(isIncluded(resolve(process.cwd(), '../../package-b/src/two.ts'))).toBe(false) +}) + +test('files outside project when allowExternal: true', async () => { + const isIncluded = await init({ + include: ['**/*.ts'], + exclude: ['**/package-b/**'], + allowExternal: true, + }) + + expect(isIncluded(resolve(process.cwd(), '../../package-a/src/one.ts'))).toBe(true) + expect(isIncluded(resolve(process.cwd(), '../../package-b/src/two.ts'))).toBe(false) +}) + +async function init(options: Partial) { + const vitest = await createVitest('test', { + config: false, + include: ['dont-match-anything'], + coverage: { + ...options, + enabled: true, + provider: 'v8', + }, + }, {}, { stdout: new Writable() }) + + onTestFinished(() => vitest.close()) + await vitest.init() + + const provider = vitest.coverageProvider as unknown as BaseCoverageProvider + + return (path: string) => provider.isIncluded(resolve(process.cwd(), path)) +} diff --git a/test/coverage-test/utils.ts b/test/coverage-test/utils.ts index 5e8becd9c..3aebf83d1 100644 --- a/test/coverage-test/utils.ts +++ b/test/coverage-test/utils.ts @@ -4,7 +4,7 @@ import type { SuiteAPI, TestAPI } from 'vitest' import type { TestUserConfig } from 'vitest/node' import { existsSync, readFileSync } from 'node:fs' import { unlink } from 'node:fs/promises' -import { resolve } from 'node:path' +import { relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' import { playwright } from '@vitest/browser-playwright' @@ -146,7 +146,7 @@ export function isNativeRunner() { } export function normalizeURL(importMetaURL: string) { - return normalize(fileURLToPath(importMetaURL)) + return normalize(relative(process.cwd(), fileURLToPath(importMetaURL))) } export function captureStdout() {