From 66c1cbb53a1bf2a59888df2fa3c002958d21394c Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 18 Aug 2026 11:56:42 +0200 Subject: [PATCH] fix: combine multiple --project filters correctly (#10988) --- docs/guide/cli-generated.md | 2 +- docs/guide/projects.md | 9 +++ packages/vitest/src/node/cli/cli-config.ts | 2 +- .../vitest/src/node/config/resolveConfig.ts | 9 ++- .../src/node/projects/resolveProjects.ts | 23 ++++--- packages/vitest/src/utils/base.ts | 14 +---- test/e2e/test/config/browser-configs.test.ts | 61 +++++++++++++++++++ test/e2e/test/projects.test.ts | 4 ++ 8 files changed, 96 insertions(+), 28 deletions(-) diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index b5836f695..a6b09bec9 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -796,7 +796,7 @@ Minimum time in milliseconds it takes to spawn the typechecker - **CLI:** `-p, --project ` -The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: `--project=1 --project=2`. You can also filter projects using wildcards like `--project=packages*`, and exclude projects with `--project=!pattern`. +The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: `--project=1 --project=2`. You can also filter projects using wildcards like `--project=packages*`, and exclude projects with `--project=!pattern`. A project runs if it matches no negated pattern and, when regular patterns are also given, matches at least one of them. ### slowTestThreshold diff --git a/docs/guide/projects.md b/docs/guide/projects.md index b3b96b2ed..f48b25a66 100644 --- a/docs/guide/projects.md +++ b/docs/guide/projects.md @@ -235,6 +235,15 @@ bun run test --project e2e --project unit ``` ::: +The filter supports `*` wildcards and `!` exclusions. A project runs if it matches no negated pattern and, when regular patterns are also given, matches at least one of them: + +```bash +# run every project except "e2e" +vitest --project '!e2e' +# run every project starting with "unit", except "unit (browser)" +vitest --project 'unit*' --project '!unit (browser)' +``` + ## Configuration Projects defined with an inline configuration inherit all options from the root-level configuration. This is controlled by the `extends` option, which is enabled by default since Vitest 5.0: diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 02f7218fc..9ebfbe079 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -745,7 +745,7 @@ export const cliOptionsConfig: VitestCLIOptions = { project: { shorthand: 'p', description: - 'The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: `--project=1 --project=2`. You can also filter projects using wildcards like `--project=packages*`, and exclude projects with `--project=!pattern`.', + 'The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: `--project=1 --project=2`. You can also filter projects using wildcards like `--project=packages*`, and exclude projects with `--project=!pattern`. A project runs if it matches no negated pattern and, when regular patterns are also given, matches at least one of them.', argument: '', array: true, }, diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index fe21cd4c1..8e826edd8 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -1251,9 +1251,12 @@ export function matchesProjectFilter(projects: string[], name: string): boolean if (!projects.length) { return true } - return projects.some((project) => { - const regexp = wildcardPatternToRegExp(project) - return regexp.test(name) + if (isExcludedByProjectFilter(projects, name)) { + return false + } + const positives = projects.filter(project => !project.startsWith('!')) + return !positives.length || positives.some((project) => { + return wildcardPatternToRegExp(project).test(name) }) } diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index e14e5ae64..ed93141f8 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -814,10 +814,10 @@ function expandBrowserInstancesInEntries( continue } - const keepAllInstances = matchesEntryFilter(globalConfig.project, parentName, entry.ancestors) - const filteredInstances = keepAllInstances - ? instances - : instances.filter(instance => matchesProjectFilter(globalConfig.project, instance.name!)) + const parentMatches = matchesEntryFilter(globalConfig.project, parentName, entry.ancestors) + const filteredInstances = instances.filter(instance => parentMatches + ? !isExcludedByProjectFilter(globalConfig.project, instance.name!) + : matchesProjectFilter(globalConfig.project, instance.name!)) if (!filteredInstances.length) { debug?.(`browser project ${projectLabel(parentName)} is dropped: no instances match the --project filter`) continue @@ -1083,14 +1083,17 @@ function matchesEntryFilter( if (!filter.length) { return true } + if (isEntryExcludedByFilter(filter, name, ancestors)) { + return false + } + const positives = filter.filter(project => !project.startsWith('!')) + if (!positives.length) { + return true + } const names = [name, ...(ancestors || [])] - return filter.some((project) => { + return positives.some((project) => { const regexp = wildcardPatternToRegExp(project) - // a negated pattern compiles into a negative lookahead: the entry is kept - // only when neither its name nor any of its containers match the exclusion - return project.startsWith('!') - ? names.every(candidate => regexp.test(candidate)) - : names.some(candidate => regexp.test(candidate)) + return names.some(candidate => regexp.test(candidate)) }) } diff --git a/packages/vitest/src/utils/base.ts b/packages/vitest/src/utils/base.ts index f0a769ca6..e4140aca1 100644 --- a/packages/vitest/src/utils/base.ts +++ b/packages/vitest/src/utils/base.ts @@ -24,19 +24,7 @@ export function escapeRegExp(s: string): string { } export function wildcardPatternToRegExp(pattern: string): RegExp { - const negated = pattern[0] === '!' - - if (negated) { - pattern = pattern.slice(1) - } - - let regexp = `${pattern.split('*').map(escapeRegExp).join('.*')}$` - - if (negated) { - regexp = `(?!${regexp})` - } - - return new RegExp(`^${regexp}`, 'i') + return new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`, 'i') } export function createIndexLocationsMap(source: string): Map { diff --git a/test/e2e/test/config/browser-configs.test.ts b/test/e2e/test/config/browser-configs.test.ts index d17ba2c2e..b0aaf50e9 100644 --- a/test/e2e/test/config/browser-configs.test.ts +++ b/test/e2e/test/config/browser-configs.test.ts @@ -526,6 +526,67 @@ test('negation wildcard filter excludes all matching browser instances', async ( ]) }) +test('negation filter excludes a single browser instance', async () => { + const projects = await config({ + project: '!myproject (chromium)', + projects: [ + { + test: { + name: 'myproject', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + ], + }, + }, + }, + { + test: { + name: 'other', + }, + }, + ], + }) + expect(projects.map(p => p.projectConfig.name)).toEqual([ + 'other', + 'myproject (firefox)', + ]) +}) + +test('negation filter excludes a browser instance of a matching project', async () => { + const projects = await config({ + project: ['myproject', '!myproject (chromium)'], + projects: [ + { + test: { + name: 'myproject', + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { browser: 'chromium' }, + { browser: 'firefox' }, + ], + }, + }, + }, + { + test: { + name: 'other', + }, + }, + ], + }) + expect(projects.map(p => p.projectConfig.name)).toEqual([ + 'myproject (firefox)', + ]) +}) + test('filter for the global browser project includes all browser instances', async () => { const projects = await config({ project: 'myproject', diff --git a/test/e2e/test/projects.test.ts b/test/e2e/test/projects.test.ts index a70a00c2f..054857056 100644 --- a/test/e2e/test/projects.test.ts +++ b/test/e2e/test/projects.test.ts @@ -1039,6 +1039,10 @@ describe('project filtering', () => { { pattern: '!project_1', expected: ['project_2', 'space_1'] }, { pattern: '!project*', expected: ['space_1'] }, { pattern: '!project', expected: allProjects }, + { pattern: ['!project_1', '!project_2'], expected: ['space_1'] }, + { pattern: ['!project_1', '!space_1'], expected: ['project_2'] }, + { pattern: ['project*', '!project_1'], expected: ['project_2'] }, + { pattern: ['*', '!space*'], expected: ['project_1', 'project_2'] }, ])('should match projects correctly: $pattern', async ({ pattern, expected }) => { const { ctx, stderr, stdout } = await runVitest({ root: 'fixtures/project', -- 2.51.2