diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 97a7072f6098..48ab6ee92708 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -300,3 +300,20 @@ You can also pass multiple `--tags-filter` flags. They are combined with AND log # Run tests that match (unit OR e2e) AND are NOT slow vitest --tags-filter="unit || e2e" --tags-filter="!slow" ``` + +### Checking Tags Filter at Runtime + +You can use `TestRunner.matchesTagsFilter` (since Vitest 4.1.1) to check whether the current tags filter matches a set of tags. This is useful for conditionally running expensive setup logic only when relevant tests are included: + +```ts +import { beforeAll, TestRunner } from 'vitest' + +beforeAll(async () => { + // Seed database when "vitest --tags-filter db" is used + if (TestRunner.matchesTagsFilter(['db'])) { + await seedDatabase() + } +}) +``` + +The method accepts an array of tags and returns `true` if the current `--tags-filter` would include a test with those tags. If no tags filter is active, it always returns `true`. diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 7c03af2ed2f9..b422a3079400 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -36,6 +36,8 @@ export async function collectTests( 'collect_spec', { 'code.file.path': filepath }, async () => { + runner._currentSpecification = typeof spec === 'string' ? { filepath: spec } : spec + const testLocations = typeof spec === 'string' ? undefined : spec.testLocations const testNamePattern = typeof spec === 'string' ? undefined : spec.testNamePattern const testIds = typeof spec === 'string' ? undefined : spec.testIds diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index edecb67bfbba..d05ab8056004 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -55,10 +55,10 @@ export interface FileSpecification { // file can be marked via a jsdoc comment to have tags, // these are _not_ tags to filter tests by fileTags?: string[] - testLocations: number[] | undefined - testNamePattern: RegExp | undefined - testTagsFilter: string[] | undefined - testIds: string[] | undefined + testLocations?: number[] | undefined + testNamePattern?: RegExp | undefined + testTagsFilter?: string[] | undefined + testIds?: string[] | undefined } export interface TestTagDefinition extends Omit { @@ -231,8 +231,10 @@ export interface VitestRunner { // eslint-disable-next-line ts/method-signature-style trace?(name: string, attributes: Record, cb: () => T): T - /** @private */ + /** @internal */ + _currentSpecification?: FileSpecification | undefined + /** @internal */ _currentTaskStartTime?: number - /** @private */ + /** @internal */ _currentTaskTimeout?: number } diff --git a/packages/runner/src/utils/index.ts b/packages/runner/src/utils/index.ts index fc3f347941cb..e25432281320 100644 --- a/packages/runner/src/utils/index.ts +++ b/packages/runner/src/utils/index.ts @@ -10,7 +10,7 @@ export { } from './collect' export { limitConcurrency } from './limit-concurrency' export { partitionSuiteChildren } from './suite' -export { createTagsFilter, validateTags } from './tags' +export { createTagsFilter, matchesTagsFilter, validateTags } from './tags' export { createTaskName, getFullName, diff --git a/packages/runner/src/utils/tags.ts b/packages/runner/src/utils/tags.ts index d7c5b640c499..36744e0bbb86 100644 --- a/packages/runner/src/utils/tags.ts +++ b/packages/runner/src/utils/tags.ts @@ -1,4 +1,24 @@ import type { TestTagDefinition, VitestRunnerConfig } from '../types/runner' +import { getRunner } from '../suite' + +const filterMap = new WeakMap boolean>() + +/** + * @experimental + */ +export function matchesTagsFilter(testTags: string[]): boolean { + const runner = getRunner() + const tagsFilter = runner._currentSpecification?.testTagsFilter ?? runner.config.tagsFilter + if (!tagsFilter) { + return true + } + let tagsFilterPredicate = filterMap.get(tagsFilter) + if (!tagsFilterPredicate) { + tagsFilterPredicate = createTagsFilter(tagsFilter, runner.config.tags) + filterMap.set(tagsFilter, tagsFilterPredicate) + } + return tagsFilterPredicate(testTags) +} export function validateTags(config: VitestRunnerConfig, tags: string[]): void { if (!config.strictTags) { diff --git a/packages/vitest/src/runtime/runners/test.ts b/packages/vitest/src/runtime/runners/test.ts index a5155c88112f..b6af05d312e2 100644 --- a/packages/vitest/src/runtime/runners/test.ts +++ b/packages/vitest/src/runtime/runners/test.ts @@ -22,7 +22,7 @@ import { getFn, getHooks, } from '@vitest/runner' -import { createChainable, getNames, getTestName, getTests } from '@vitest/runner/utils' +import { createChainable, getNames, getTestName, getTests, matchesTagsFilter } from '@vitest/runner/utils' import { processError } from '@vitest/utils/error' import { normalize } from 'pathe' import { createExpect } from '../../integrations/chai/index' @@ -278,6 +278,7 @@ export class TestRunner implements VitestTestRunner { static getTestFn: typeof getFn = getFn static setSuiteHooks: typeof getHooks = getHooks static setTestFn: typeof getFn = getFn + static matchesTagsFilter: typeof matchesTagsFilter = matchesTagsFilter /** * @deprecated diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 004efbe69c99..be9ca6de21ed 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -1361,6 +1361,371 @@ test('test meta overrides tag meta', async () => { `) }) +test('matchesTagsFilter returns true when no filter is configured', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, describe, expect, test } from 'vitest' + + let matchResult + + beforeAll(() => { + matchResult = TestRunner.matchesTagsFilter(['unit']) + }) + + test('test 1', { tags: ['unit'] }, () => { + expect(matchResult).toBe(true) + }) + `, + 'vitest.config.js': { + test: { + tags: [{ name: 'unit' }], + }, + }, + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": "passed", + }, + } + `) +}) + +test('matchesTagsFilter returns true when tags match the filter', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchUnit, matchE2e + + beforeAll(() => { + matchUnit = TestRunner.matchesTagsFilter(['unit']) + matchE2e = TestRunner.matchesTagsFilter(['e2e']) + }) + + test('unit matches', { tags: ['unit'] }, () => { + expect(matchUnit).toBe(true) + expect(matchE2e).toBe(false) + }) + `, + 'vitest.config.js': { + test: { + tags: [{ name: 'unit' }, { name: 'e2e' }], + }, + }, + }, { + tagsFilter: ['unit'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "unit matches": "passed", + }, + } + `) +}) + +test('matchesTagsFilter supports NOT expressions', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchUnit, matchSlow + + beforeAll(() => { + matchUnit = TestRunner.matchesTagsFilter(['unit']) + matchSlow = TestRunner.matchesTagsFilter(['slow']) + }) + + test('unit passes NOT slow', { tags: ['unit'] }, () => { + expect(matchUnit).toBe(true) + expect(matchSlow).toBe(false) + }) + `, + 'vitest.config.js': { + test: { + tags: [{ name: 'unit' }, { name: 'slow' }], + }, + }, + }, { + tagsFilter: ['!slow'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "unit passes NOT slow": "passed", + }, + } + `) +}) + +test('matchesTagsFilter supports AND/OR expressions', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchUnitFast, matchUnitSlow, matchE2eFast, matchEmpty + + beforeAll(() => { + matchUnitFast = TestRunner.matchesTagsFilter(['unit', 'fast']) + matchUnitSlow = TestRunner.matchesTagsFilter(['unit', 'slow']) + matchE2eFast = TestRunner.matchesTagsFilter(['e2e', 'fast']) + matchEmpty = TestRunner.matchesTagsFilter([]) + }) + + test('matches complex expression', { tags: ['unit', 'fast'] }, () => { + expect(matchUnitFast).toBe(true) + expect(matchUnitSlow).toBe(false) + expect(matchE2eFast).toBe(true) + expect(matchEmpty).toBe(false) + }) + `, + 'vitest.config.js': { + test: { + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + { name: 'fast' }, + { name: 'slow' }, + ], + }, + }, + }, { + tagsFilter: ['(unit || e2e) && fast'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "matches complex expression": "passed", + }, + } + `) +}) + +test('matchesTagsFilter supports wildcard patterns', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchBrowserChrome, matchNode + + beforeAll(() => { + matchBrowserChrome = TestRunner.matchesTagsFilter(['browser-chrome']) + matchNode = TestRunner.matchesTagsFilter(['node']) + }) + + test('wildcard matches', { tags: ['browser-chrome'] }, () => { + expect(matchBrowserChrome).toBe(true) + expect(matchNode).toBe(false) + }) + `, + 'vitest.config.js': { + test: { + tags: [ + { name: 'browser-chrome' }, + { name: 'browser-firefox' }, + { name: 'node' }, + ], + }, + }, + }, { + tagsFilter: ['browser-*'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "wildcard matches": "passed", + }, + } + `) +}) + +test('matchesTagsFilter with empty tags array and no filter returns true', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchEmpty + + beforeAll(() => { + matchEmpty = TestRunner.matchesTagsFilter([]) + }) + + test('empty tags no filter', () => { + expect(matchEmpty).toBe(true) + }) + `, + 'vitest.config.js': { + test: { + tags: [{ name: 'unit' }], + }, + }, + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "empty tags no filter": "passed", + }, + } + `) +}) + +test('per-specification testTagsFilter overrides global tagsFilter', async () => { + const { fs, ctx, errorTree } = await runInlineTests({ + 'basic.test.js': ` + test('unit-test', { tags: ['unit'] }, () => {}) + test('e2e-test', { tags: ['e2e'] }, () => {}) + test('integration-test', { tags: ['integration'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + { name: 'integration' }, + ], + }, + }, + }, { standalone: true, watch: true, tagsFilter: ['unit'] }) + const vitest = ctx! + + const specification = vitest.getRootProject().createSpecification( + fs.resolveFile('./basic.test.js'), + { testTagsFilter: ['e2e'] }, + ) + + await vitest.runTestSpecifications([specification]) + + expect(errorTree()).toEqual({ + 'basic.test.js': { + 'unit-test': 'skipped', + 'e2e-test': 'passed', + 'integration-test': 'skipped', + }, + }) +}) + +test('per-specification testTagsFilter with complex expression', async () => { + const { fs, ctx, errorTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['unit', 'fast'] }, () => {}) + test('test 2', { tags: ['unit', 'slow'] }, () => {}) + test('test 3', { tags: ['e2e', 'fast'] }, () => {}) + test('test 4', { tags: ['e2e', 'slow'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + { name: 'fast' }, + { name: 'slow' }, + ], + }, + }, + }, { standalone: true, watch: true }) + const vitest = ctx! + + const specification = vitest.getRootProject().createSpecification( + fs.resolveFile('./basic.test.js'), + { testTagsFilter: ['unit && fast'] }, + ) + + await vitest.runTestSpecifications([specification]) + + expect(errorTree()).toEqual({ + 'basic.test.js': { + 'test 1': 'passed', + 'test 2': 'skipped', + 'test 3': 'skipped', + 'test 4': 'skipped', + }, + }) +}) + +test('matchesTagsFilter uses per-specification filter instead of global filter', async () => { + const { fs, ctx, errorTree } = await runInlineTests({ + 'basic.test.js': ` + import { TestRunner, beforeAll, expect, test } from 'vitest' + + let matchUnit, matchE2e + + beforeAll(() => { + matchUnit = TestRunner.matchesTagsFilter(['unit']) + matchE2e = TestRunner.matchesTagsFilter(['e2e']) + }) + + test('check filter', { tags: ['e2e'] }, () => { + expect(matchUnit).toBe(false) + expect(matchE2e).toBe(true) + }) + `, + 'vitest.config.js': { + test: { + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + ], + }, + }, + }, { standalone: true, watch: true, tagsFilter: ['unit'] }) + const vitest = ctx! + + const specification = vitest.getRootProject().createSpecification( + fs.resolveFile('./basic.test.js'), + { testTagsFilter: ['e2e'] }, + ) + + await vitest.runTestSpecifications([specification]) + + expect(errorTree()).toEqual({ + 'basic.test.js': { + 'check filter': 'passed', + }, + }) +}) + +test('per-specification testTagsFilter with no global filter', async () => { + const { fs, ctx, errorTree } = await runInlineTests({ + 'basic.test.js': ` + test('unit-test', { tags: ['unit'] }, () => {}) + test('e2e-test', { tags: ['e2e'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + ], + }, + }, + }, { standalone: true, watch: true }) + const vitest = ctx! + + const specification = vitest.getRootProject().createSpecification( + fs.resolveFile('./basic.test.js'), + { testTagsFilter: ['unit'] }, + ) + + await vitest.runTestSpecifications([specification]) + + expect(errorTree()).toEqual({ + 'basic.test.js': { + 'unit-test': 'passed', + 'e2e-test': 'skipped', + }, + }) +}) + test('multiple tags with meta are merged with priority order', async () => { const { stderr, ctx } = await runInlineTests({ 'basic.test.js': `