From e6e834bab189277cebab38aea26509c4d302a90a Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 19:00:38 +0100 Subject: [PATCH 01/48] feat: support tags --- docs/config/tags.md | 47 +++++++++++++++++++ packages/runner/src/collect.ts | 2 + packages/runner/src/suite.ts | 24 ++++++++++ packages/runner/src/types.ts | 1 + packages/runner/src/types/runner.ts | 11 +++++ packages/runner/src/types/tasks.ts | 8 ++++ packages/runner/src/utils/collect.ts | 5 ++ packages/vitest/src/node/cli/cli-config.ts | 6 +++ .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/core.ts | 2 + packages/vitest/src/node/pool.ts | 2 + .../vitest/src/node/test-specification.ts | 6 +++ packages/vitest/src/node/types/config.ts | 15 +++++- packages/vitest/src/public/config.ts | 1 + packages/vitest/src/runtime/config.ts | 3 +- packages/vitest/src/utils/test-helpers.ts | 2 + 16 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 docs/config/tags.md diff --git a/docs/config/tags.md b/docs/config/tags.md new file mode 100644 index 000000000000..57cc69b5aed5 --- /dev/null +++ b/docs/config/tags.md @@ -0,0 +1,47 @@ +--- +title: tags | Config +outline: deep +--- + +# tags 4.1.0 {#tags} + +- **Type:** `TestTagDefinition[]` +- **Default:** `[]` + +Defines all [available tags](/guide/test-tags) in your test project. If test defines a name not listed here, Vitest will throw an error. + +If you are using [`projects`](/config/projects), they will inherit all global tags automatically. + +To filter tags, you can pass them down as [`--tag`](/guide/cli#tag): + +```shell +vitest --tag=frontend --tag=!backend +vitest --tag="unit/*" +``` + +::: tip FILTERING +You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. +::: + + + +## Example + +```ts [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + tags: [ + { + name: 'frontend', + description: 'Tests written for frontend.', + }, + { + name: 'backend', + description: 'Tests written for backend.', + }, + ], + }, +}) +``` diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 2f6803e85cfe..0d2b06ee1bb2 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -37,6 +37,7 @@ export async function collectTests( const testLocations = typeof spec === 'string' ? undefined : spec.testLocations const testNamePattern = typeof spec === 'string' ? undefined : spec.testNamePattern const testIds = typeof spec === 'string' ? undefined : spec.testIds + const testTags = typeof spec === 'string' ? undefined : spec.testTags const file = createFileTask(filepath, config.root, config.name, runner.pool, runner.viteEnvironment) setFileContext(file, Object.create(null)) @@ -113,6 +114,7 @@ export async function collectTests( testNamePattern ?? config.testNamePattern, testLocations, testIds, + testTags, hasOnlyTasks, false, config.allowOnly, diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 574194fe2803..c6d612d1ac59 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -306,6 +306,11 @@ function createSuiteCollector( const task = function (name = '', options: TaskCustomOptions = {}) { const timeout = options?.timeout ?? runner.config.testTimeout const currentSuite = collectorContext.currentSuite?.suite + const parentTask = currentSuite ?? collectorContext.currentSuite?.file + const parentTags = parentTask?.tags || [] + if (options?.tags) { + validateTags(runner, options.tags) + } const task: Test = { id: '', name, @@ -333,6 +338,7 @@ function createSuiteCollector( meta: options.meta ?? Object.create(null), annotations: [], artifacts: [], + tags: [...parentTags, ...(options.tags || [])], } const handler = options.handler if (task.mode === 'run' && !handler) { @@ -454,6 +460,10 @@ function createSuiteCollector( } const currentSuite = collectorContext.currentSuite?.suite + const parentTask = currentSuite ?? collectorContext.currentSuite?.file + if (suiteOptions?.tags) { + validateTags(runner, suiteOptions.tags) + } suite = { id: '', @@ -472,6 +482,7 @@ function createSuiteCollector( tasks: [], meta: Object.create(null), concurrent: suiteOptions?.concurrent, + tags: [...parentTask?.tags || [], ...(suiteOptions?.tags || [])], } if (runner && includeLocation && runner.config.includeTaskLocation) { @@ -957,3 +968,16 @@ function formatTemplateString(cases: any[], args: any[]): any[] { } return res } + +function validateTags(runner: VitestRunner, tags: string[]) { + const availableTags = new Set(runner.config.tags.map(tag => tag.name)) + for (const tag of tags) { + if (!availableTags.has(tag)) { + throw new Error( + `Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags + .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) + .join('\n')}.`, + ) + } + } +} diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index c15b3b2af6df..c1f2bf4199e5 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -1,6 +1,7 @@ export type { CancelReason, FileSpecification, + TestTagDefinition, VitestRunner, VitestRunnerConfig, VitestRunnerConstructor, diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 499ad471a11f..8da43dbccedd 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -40,6 +40,7 @@ export interface VitestRunnerConfig { retry: SerializableRetry includeTaskLocation?: boolean diffOptions?: DiffOptions + tags: TestTagDefinition[] } /** @@ -47,11 +48,21 @@ export interface VitestRunnerConfig { */ export interface FileSpecification { filepath: string + // 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 + testTags: string[] | undefined testIds: string[] | undefined } +export interface TestTagDefinition { + name: string + description?: string + // TODO: repeats/retry/timeout/... options? +} + export type VitestRunnerImportSource = 'collect' | 'setup' export interface VitestRunnerConstructor { diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 779ce7a25137..687a043f8715 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -114,6 +114,10 @@ export interface TaskBase { * @experimental */ dynamic?: boolean + /** + * Custom tags of the task. Useful for filtering tasks. + */ + tags?: string[] } export interface TaskPopulated extends TaskBase { @@ -564,6 +568,10 @@ export interface TestOptions { * Whether the test is expected to fail. If it does, the test will pass, otherwise it will fail. */ fails?: boolean + /** + * Custom tags of the test. Useful for filtering tests. + */ + tags?: string[] } interface ExtendedAPI { diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 80b5065eb050..dd9719607571 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -12,6 +12,7 @@ export function interpretTaskModes( namePattern?: string | RegExp, testLocations?: number[] | undefined, testIds?: string[] | undefined, + testTags?: string[] | undefined, onlyMode?: boolean, parentIsOnly?: boolean, allowOnly?: boolean, @@ -74,6 +75,10 @@ export function interpretTaskModes( if (testIds && !testIds.includes(t.id)) { t.mode = 'skip' } + // match at least one tag + if (testTags && !testTags.some(tag => t.tags?.includes(tag))) { + t.mode = 'skip' + } } else if (t.type === 'suite') { if (t.mode === 'skip') { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 0b5db5a7fad9..0ad6a9fb16fc 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -788,6 +788,11 @@ export const cliOptionsConfig: VitestCLIOptions = { clearCache: { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', }, + tag: { + description: 'Run only tests with the specified tags. Multiple tags can be specified by repeating the option. To exclude tags, prefix the tag with an exclamation mark (!). Use "*" in the name as a wildcard to match any sequence of characters, e.g., "unit/*".', + argument: '', + array: true, + }, experimental: { description: 'Experimental features.', @@ -838,6 +843,7 @@ export const cliOptionsConfig: VitestCLIOptions = { filesOnly: null, projects: null, watchTriggerPatterns: null, + tags: null, } export const benchCliOptionsConfig: Pick< diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 44943686bffc..b2c3ee37d247 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -135,5 +135,6 @@ export function serializeConfig(project: TestProject): SerializedConfig { printImportBreakdown: config.experimental.printImportBreakdown, openTelemetry: config.experimental.openTelemetry, }, + tags: config.tags || [], } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index a19d6abb1fd3..7f208f314e89 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -328,6 +328,8 @@ export class Vitest { ...this._onSetServer.map(fn => fn()), this._traces.waitInit(), ]) + + // validate tags } /** @internal */ diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index b164b0c90a68..90d8d8e847de 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -149,9 +149,11 @@ export function createPool(ctx: Vitest): ProcessPool { context: { files: specs.map(spec => ({ filepath: spec.moduleId, + fileTags: [], // TODO: read from @tag testLocations: spec.testLines, testNamePattern: spec.testNamePattern, testIds: spec.testIds, + testTags: spec.testTags, })), invalidates, providedContext: project.getProvidedContext(), diff --git a/packages/vitest/src/node/test-specification.ts b/packages/vitest/src/node/test-specification.ts index ad560f4faa0a..835b51984dfa 100644 --- a/packages/vitest/src/node/test-specification.ts +++ b/packages/vitest/src/node/test-specification.ts @@ -9,6 +9,7 @@ export interface TestSpecificationOptions { testNamePattern?: RegExp testIds?: string[] testLines?: number[] + testTags?: string[] } export class TestSpecification { @@ -41,6 +42,10 @@ export class TestSpecification { * The ids of tasks inside of this specification to run. */ public readonly testIds: string[] | undefined + /** + * The tags of tests to run. + */ + public readonly testTags: string[] | undefined /** * This class represents a test suite for a test module within a single project. @@ -73,6 +78,7 @@ export class TestSpecification { this.testLines = testLinesOrOptions.testLines this.testNamePattern = testLinesOrOptions.testNamePattern this.testIds = testLinesOrOptions.testIds + this.testTags = testLinesOrOptions.testTags } } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index cb792a5e7ea1..a6a5a077456d 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1,6 +1,6 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import type { PrettyFormatOptions } from '@vitest/pretty-format' -import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' +import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner' import type { SnapshotStateOptions } from '@vitest/snapshot' import type { Arrayable } from '@vitest/utils' import type { SerializedDiffOptions } from '@vitest/utils/diff' @@ -862,6 +862,13 @@ export interface InlineConfig { */ printImportBreakdown?: boolean } + + /** + * Define tags available in your test files. + * + * If test defines a tag that is not listed here, an error will be thrown. + */ + tags?: TestTagDefinition[] } export interface TypecheckConfig { @@ -997,6 +1004,11 @@ export interface UserConfig extends InlineConfig { * @experimental */ clearCache?: boolean + + /** + * Tags to filter tests with. + */ + tag?: string[] } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void @@ -1128,6 +1140,7 @@ type NonProjectOptions | 'inspectBrk' | 'coverage' | 'watchTriggerPatterns' + | 'tag' // CLI option only export interface ServerDepsOptions { /** diff --git a/packages/vitest/src/public/config.ts b/packages/vitest/src/public/config.ts index 48e24a7f0c19..7ef411ea2be6 100644 --- a/packages/vitest/src/public/config.ts +++ b/packages/vitest/src/public/config.ts @@ -19,6 +19,7 @@ export { defaultInclude, } from '../defaults' export type { WatcherTriggerPattern } from '../node/watcher' +export type { TestTagDefinition } from '@vitest/runner' export { mergeConfig } from 'vite' export type { Plugin } from 'vite' diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 10ff10ce6bd4..26242d186343 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -1,6 +1,6 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import type { PrettyFormatOptions } from '@vitest/pretty-format' -import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' +import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TagDefinition } from '@vitest/runner' import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot' import type { SerializedDiffOptions } from '@vitest/utils/diff' @@ -126,6 +126,7 @@ export interface SerializedConfig { browserSdkPath?: string } | undefined } + tags: TagDefinition[] } export interface SerializedCoverageConfig { diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index 9b218c3f3768..af1972e8fccf 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -18,6 +18,8 @@ export async function getSpecificationsEnvironments( cache.set(filepath, code) } + // TODO: parse @tag to inject test tags into test files, and validate against config.tags + // 1. Check for control comments in the file let env = code.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] // 2. Fallback to global env From c85fbbad47fb4bc03ab89ab5ba12bda5d0528b81 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 20:14:06 +0100 Subject: [PATCH 02/48] fix: code to filter tags --- packages/runner/src/collect.ts | 2 +- packages/runner/src/types/runner.ts | 1 + packages/runner/src/utils/collect.ts | 23 ++++++++++++++++++- .../vitest/src/node/config/resolveConfig.ts | 21 +++++++++++++++++ .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/runtime/config.ts | 5 ++-- 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 0d2b06ee1bb2..567e8eebbcfa 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -114,7 +114,7 @@ export async function collectTests( testNamePattern ?? config.testNamePattern, testLocations, testIds, - testTags, + testTags ?? config.tagsFilter, hasOnlyTasks, false, config.allowOnly, diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 8da43dbccedd..a71369a01591 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -41,6 +41,7 @@ export interface VitestRunnerConfig { includeTaskLocation?: boolean diffOptions?: DiffOptions tags: TestTagDefinition[] + tagsFilter?: string[] } /** diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index dd9719607571..73dcd97a16e5 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -76,7 +76,7 @@ export function interpretTaskModes( t.mode = 'skip' } // match at least one tag - if (testTags && !testTags.some(tag => t.tags?.includes(tag))) { + if (testTags && !matchesTags(testTags, t.tags || [])) { t.mode = 'skip' } } @@ -243,3 +243,24 @@ export function findTestFileStackTrace(testFilePath: string, error: string): Par } } } + +function matchesTags(filterTags: string[], testTags: string[]) { + if (testTags.length === 0) { + // test has no tags, cannot match any filter + return false + } + + let hasPositiveTag = false + for (const tag of filterTags) { + if (tag.startsWith('!')) { + const ignoreTag = tag.slice(1) + if (testTags.includes(ignoreTag)) { + return false + } + } + else if (testTags.includes(tag)) { + hasPositiveTag = true + } + } + return hasPositiveTag +} diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index fed4e7b913e2..4eacaf08c5ad 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -22,6 +22,7 @@ import { defaultPort, } from '../../constants' import { benchmarkConfigDefaults, configDefaults } from '../../defaults' +import { wildcardPatternToRegExp } from '../../utils/base' import { isCI, stdProvider } from '../../utils/env' import { getWorkersCountByPercentage } from '../../utils/workers' import { BaseSequencer } from '../sequencers/BaseSequencer' @@ -168,6 +169,26 @@ export function resolveConfig( resolved.project = toArray(resolved.project) resolved.provide ??= {} + if (resolved.tag?.length) { + const filterTags = resolved.tag.map((tag) => { + const negated = tag[0] === '!' + const actualTag = negated ? tag.slice(1) : tag + return { pattern: wildcardPatternToRegExp(actualTag), negated } + }) + + const availableTags: string[] = [] + resolved.tags.forEach((tag) => { + const match = filterTags.find(({ pattern }) => pattern.test(tag.name)) + if (match) { + availableTags.push(match.negated ? `!${tag.name}` : tag.name) + } + }) + resolved.tag = availableTags + } + else { + resolved.tag = [] + } + resolved.name = typeof options.name === 'string' ? options.name : (options.name?.label || '') diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index b2c3ee37d247..72a7b7a89b83 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -136,5 +136,6 @@ export function serializeConfig(project: TestProject): SerializedConfig { openTelemetry: config.experimental.openTelemetry, }, tags: config.tags || [], + tagsFilter: config.tag, } } diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 26242d186343..4649e85c416a 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -1,6 +1,6 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import type { PrettyFormatOptions } from '@vitest/pretty-format' -import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TagDefinition } from '@vitest/runner' +import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner' import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot' import type { SerializedDiffOptions } from '@vitest/utils/diff' @@ -126,7 +126,8 @@ export interface SerializedConfig { browserSdkPath?: string } | undefined } - tags: TagDefinition[] + tags: TestTagDefinition[] + tagsFilter: string[] | undefined } export interface SerializedCoverageConfig { From d77b401c3be1f9c88ae2ac30214c4e0bbba2afb9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 20:49:59 +0100 Subject: [PATCH 03/48] chore: fix filtering --- docs/api/advanced/test-case.md | 1 + docs/api/advanced/test-suite.md | 1 + docs/guide/test-tags.md | 97 ++++++++ packages/runner/src/suite.ts | 20 +- packages/runner/src/utils/collect.ts | 13 +- packages/utils/src/helpers.ts | 4 + .../vitest/src/node/config/resolveConfig.ts | 8 +- .../src/node/reporters/reported-tasks.ts | 2 + packages/vitest/src/node/types/config.ts | 2 + test/cli/fixtures/test-tags/basic.test.ts | 11 + test/cli/test/test-tags.test.ts | 218 ++++++++++++++++++ 11 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 docs/guide/test-tags.md create mode 100644 test/cli/fixtures/test-tags/basic.test.ts create mode 100644 test/cli/test/test-tags.test.ts diff --git a/docs/api/advanced/test-case.md b/docs/api/advanced/test-case.md index 1574a70534ad..e9bc3b818031 100644 --- a/docs/api/advanced/test-case.md +++ b/docs/api/advanced/test-case.md @@ -105,6 +105,7 @@ interface TaskOptions { readonly shuffle: boolean | undefined readonly retry: number | undefined readonly repeats: number | undefined + readonly tags: string[] | undefined readonly mode: 'run' | 'only' | 'skip' | 'todo' } ``` diff --git a/docs/api/advanced/test-suite.md b/docs/api/advanced/test-suite.md index 96d39455fd22..56abb67ec125 100644 --- a/docs/api/advanced/test-suite.md +++ b/docs/api/advanced/test-suite.md @@ -106,6 +106,7 @@ interface TaskOptions { readonly shuffle: boolean | undefined readonly retry: number | undefined readonly repeats: number | undefined + readonly tags: string[] | undefined readonly mode: 'run' | 'only' | 'skip' | 'todo' } ``` diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md new file mode 100644 index 000000000000..f61bc262b1a7 --- /dev/null +++ b/docs/guide/test-tags.md @@ -0,0 +1,97 @@ +# Test Tags 4.1.0 + +[`Tags`](/config/tags) allow you to mark tests and change their options based on the tag's definition. + +## Defining Tags + +Tags must be defined in your configuration file. Vitest will throw an error if a test uses a tag that is not defined in the config. + +```ts [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + tags: [ + { + name: 'frontend', + description: 'Tests written for frontend.', + }, + { + name: 'backend', + description: 'Tests written for backend.', + }, + ], + }, +}) +``` + +## Using Tags in Tests + +You can apply tags to individual tests or entire suites using the `tags` option: + +```ts +import { describe, test } from 'vitest' + +test('renders homepage', { tags: ['frontend'] }, () => { + // ... +}) + +describe('API endpoints', { tags: ['backend'] }, () => { + test('returns user data', () => { + // This test inherits the "backend" tag from the parent suite + }) + + test('validates input', { tags: ['validation'] }, () => { + // This test has both "backend" (inherited) and "validation" tags + }) +}) +``` + +Tags are inherited from parent suites, so all tests inside a tagged `describe` block will automatically have that tag. + +It's also possible to define `tags` for every test in the file by using JSDoc's `@tag`: + +```ts +/** + * Auth tests + * @tag admin/pages/dashboard + * @tag acceptance + */ + +test('dashboard renders items', () => { + // ... +}) +``` + +## Filtering Tests by Tag + +To run only tests with specific tags, use the [`--tag`](/guide/cli#tag) CLI option: + +```shell +vitest --tag=frontend +vitest --tag=frontend --tag=backend +``` + +### Wildcards + +You can use a wildcard (`*`) to match any number of characters: + +```shell +vitest --tag="unit/*" +``` + +This will match tags like `unit/components`, `unit/utils`, etc. + +### Excluding Tags + +To exclude tests with a specific tag, add an exclamation mark (`!`) at the start: + +```shell +vitest --tag=frontend --tag=!slow +``` + +This runs all tests tagged with `frontend` except those also tagged with `slow`. Note that wildcard syntax is also supported for excluded tags: + +```shell +vitest --tag="!unit/*" +``` diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index c6d612d1ac59..33720bad8ca2 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -23,6 +23,7 @@ import { isObject, objectAttr, toArray, + unique, } from '@vitest/utils/helpers' import { abortIfTimeout, @@ -338,7 +339,7 @@ function createSuiteCollector( meta: options.meta ?? Object.create(null), annotations: [], artifacts: [], - tags: [...parentTags, ...(options.tags || [])], + tags: unique([...parentTags, ...(options.tags || [])]), } const handler = options.handler if (task.mode === 'run' && !handler) { @@ -482,7 +483,7 @@ function createSuiteCollector( tasks: [], meta: Object.create(null), concurrent: suiteOptions?.concurrent, - tags: [...parentTask?.tags || [], ...(suiteOptions?.tags || [])], + tags: unique([...parentTask?.tags || [], ...(suiteOptions?.tags || [])]), } if (runner && includeLocation && runner.config.includeTaskLocation) { @@ -973,11 +974,18 @@ function validateTags(runner: VitestRunner, tags: string[]) { const availableTags = new Set(runner.config.tags.map(tag => tag.name)) for (const tag of tags) { if (!availableTags.has(tag)) { - throw new Error( - `Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags + let message: string + + if (!runner.config.tags.length) { + message = `The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags` + } + else { + message = `Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) - .join('\n')}.`, - ) + .join('\n')}` + } + + throw new Error(message) } } } diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 73dcd97a16e5..309136e60822 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -251,6 +251,7 @@ function matchesTags(filterTags: string[], testTags: string[]) { } let hasPositiveTag = false + let allNegative = true for (const tag of filterTags) { if (tag.startsWith('!')) { const ignoreTag = tag.slice(1) @@ -258,9 +259,17 @@ function matchesTags(filterTags: string[], testTags: string[]) { return false } } - else if (testTags.includes(tag)) { - hasPositiveTag = true + else { + allNegative = false + + if (testTags.includes(tag)) { + hasPositiveTag = true + } } } + // if all tags are negative, and none matched, the test passes + if (hasPositiveTag || allNegative) { + return true + } return hasPositiveTag } diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index 3b5d59d49282..aa0a43dde765 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -368,3 +368,7 @@ export function deepMerge( return deepMerge(target, ...sources) } + +export function unique(array: T[]): T[] { + return Array.from(new Set(array)) +} diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 4eacaf08c5ad..39b1b42c617c 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -177,17 +177,17 @@ export function resolveConfig( }) const availableTags: string[] = [] - resolved.tags.forEach((tag) => { + resolved.tags?.forEach((tag) => { const match = filterTags.find(({ pattern }) => pattern.test(tag.name)) if (match) { availableTags.push(match.negated ? `!${tag.name}` : tag.name) } }) + if (!availableTags.length) { + throw new Error(`Cannot find any tags to filter based on the ${resolved.tag.map(t => `--tag ${t}`).join(' ')} option. Did you define them in "test.tags" in your config?`) + } resolved.tag = availableTags } - else { - resolved.tag = [] - } resolved.name = typeof options.name === 'string' ? options.name diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index dbc3d85a4f1d..bdd2dd481299 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -565,6 +565,7 @@ export interface TaskOptions { readonly shuffle: boolean | undefined readonly retry: SerializableRetry | undefined readonly repeats: number | undefined + readonly tags: string[] | undefined readonly mode: 'run' | 'only' | 'skip' | 'todo' } @@ -578,6 +579,7 @@ function buildOptions( shuffle: task.shuffle, retry: task.retry as SerializableRetry | undefined, repeats: task.repeats, + tags: task.tags, // runner types are too broad, but the public API should be more strict // the queued state exists only on Files and this method is called // only for tests and suites diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index a6a5a077456d..b62f050cb772 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1041,6 +1041,7 @@ export interface ResolvedConfig | 'name' | 'vmMemoryLimit' | 'fileParallelism' + | 'tag' > { mode: VitestRunMode @@ -1111,6 +1112,7 @@ export interface ResolvedConfig vmMemoryLimit?: UserConfig['vmMemoryLimit'] dumpDir?: string + tag?: string[] } type NonProjectOptions diff --git a/test/cli/fixtures/test-tags/basic.test.ts b/test/cli/fixtures/test-tags/basic.test.ts new file mode 100644 index 000000000000..40e163fa4201 --- /dev/null +++ b/test/cli/fixtures/test-tags/basic.test.ts @@ -0,0 +1,11 @@ +import { describe, test } from 'vitest' + +describe('suite 1', { tags: ['suite'] }, () => { + test('test 1', () => {}) + test('test 2', { tags: ['test'] }, () => {}) + + describe('suite 2', { tags: ['suite 2', 'suite'] }, () => { + test('test 3', () => {}) + test('test 4', { tags: ['test 2'] }, () => {}) + }) +}) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts new file mode 100644 index 000000000000..219db975c61a --- /dev/null +++ b/test/cli/test/test-tags.test.ts @@ -0,0 +1,218 @@ +import type { TestModule, TestSuite } from 'vitest/node' +import { runInlineTests, runVitest } from '#test-utils' +import { expect, test } from 'vitest' + +test('vitest records tags', async () => { + const { stderr, ctx } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { name: 'suite' }, + { name: 'test' }, + { name: 'suite 2' }, + { name: 'test 2' }, + ], + }) + + expect(stderr).toBe('') + expect(getTestTree(ctx!.state.getTestModules()[0])).toMatchInlineSnapshot(` + { + "suite 1": { + "suite 2": { + "test 3": [ + "suite", + "suite 2", + ], + "test 4": [ + "suite", + "suite 2", + "test 2", + ], + }, + "test 1": [ + "suite", + ], + "test 2": [ + "suite", + "test", + ], + }, + } + `) +}) + +test('filters tests based on --tag=!ignore', async () => { + const { stderr, testTree } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { name: 'suite' }, + { name: 'test' }, + { name: 'suite 2' }, + { name: 'test 2' }, + ], + tag: ['!suite 2'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "suite 1": { + "suite 2": { + "test 3": "skipped", + "test 4": "skipped", + }, + "test 1": "passed", + "test 2": "passed", + }, + }, + } + `) +}) + +test('filters tests based on --tag=!ignore and --tag=include', async () => { + const { stderr, testTree } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { name: 'suite' }, + { name: 'test' }, + { name: 'suite 2' }, + { name: 'test 2' }, + ], + tag: ['!suite 2', 'test'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "suite 1": { + "suite 2": { + "test 3": "skipped", + "test 4": "skipped", + }, + "test 1": "skipped", + "test 2": "passed", + }, + }, + } + `) +}) + +test('filters tests based on --tag=include', async () => { + const { stderr, testTree } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { name: 'suite' }, + { name: 'test' }, + { name: 'suite 2' }, + { name: 'test 2' }, + ], + tag: ['test*'], + }) + + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "suite 1": { + "suite 2": { + "test 3": "skipped", + "test 4": "passed", + }, + "test 1": "skipped", + "test 2": "passed", + }, + }, + } + `) +}) + +test('throws an error if no tags are defined in the config, but in the test', async () => { + const { stderr } = await runInlineTests( + { + 'basic.test.js': ` + test('test 1', { tags: ['unknown'] }, () => {}) + `, + }, + { globals: true }, + ) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.js [ basic.test.js ] + Error: The Vitest config does't define any "tags", cannot apply "unknown" tag for this test. See: https://vitest.dev/guide/test-tags + ❯ basic.test.js:2:9 + 1| + 2| test('test 1', { tags: ['unknown'] }, () => {}) + | ^ + 3| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) +}) + +test('throws an error if tag is not defined in the config, but in the test', async () => { + const { stderr } = await runInlineTests( + { + 'basic.test.js': ` + test('test 1', { tags: ['unknown'] }, () => {}) + `, + }, + { + globals: true, + tags: [{ name: 'known' }], + }, + ) + + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL basic.test.js [ basic.test.js ] + Error: Tag "unknown" is not defined in the configuration. Available tags are: + - known + ❯ basic.test.js:2:9 + 1| + 2| test('test 1', { tags: ['unknown'] }, () => {}) + | ^ + 3| + + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) +}) + +test('throws an error if tag is not defined in the config, but in --tag filter', async () => { + const { stderr } = await runInlineTests( + { + 'basic.test.js': '', + }, + { + tag: ['unknown'], + }, + { fails: true }, + ) + expect(stderr).toContain('Cannot find any tags to filter based on the --tag unknown option. Did you define them in "test.tags" in your config?') +}) + +function getTestTree(testModule: TestModule | TestSuite, tree: Record = {}) { + for (const child of testModule.children) { + if (child.type === 'suite') { + tree[child.name] = {} + getTestTree(child, tree[child.name]) + } + else { + tree[child.name] = child.options.tags + } + } + return tree +} From 07c13eb7aa3b8f9c0b22f79017e59435962381bd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 21:06:12 +0100 Subject: [PATCH 04/48] docs: improve tags docs --- docs/.vitepress/config.ts | 4 ++++ docs/.vitepress/scripts/cli-generator.ts | 1 + docs/api/advanced/test-specification.md | 5 +++++ docs/api/index.md | 4 ++++ docs/config/tags.md | 2 -- docs/guide/filtering.md | 18 ++++++++++++++++ docs/guide/test-tags.md | 27 +++++++++++++++++++++++- 7 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a553a40e4167..2c3fe8d42a18 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -776,6 +776,10 @@ export default ({ mode }: { mode: string }) => { text: 'Test Filtering', link: '/guide/filtering', }, + { + text: 'Test Tags', + link: '/guide/test-tags', + }, { text: 'Test Context', link: '/guide/test-context', diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index 1e82a8bd5d2e..d8d40fb2ff61 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -42,6 +42,7 @@ const skipConfig = new Set([ 'browser.name', 'browser.fileParallelism', 'clearCache', + 'tag', ]) function resolveOptions(options: CLIOptions, parentName?: string) { diff --git a/docs/api/advanced/test-specification.md b/docs/api/advanced/test-specification.md index 5ed2e721dfbe..9be523ec8198 100644 --- a/docs/api/advanced/test-specification.md +++ b/docs/api/advanced/test-specification.md @@ -11,6 +11,7 @@ const specification = project.createSpecification( testLines: [20, 40], testNamePattern: /hello world/, testIds: ['1223128da3_0_0_0', '1223128da3_0_0'], + testTags: ['frontend', 'backend'], } // optional test filters ) ``` @@ -82,6 +83,10 @@ A regexp that matches the name of the test in this module. This value will overr The ids of tasks inside of this specification to run. +## testTags 4.1.0 {#testids} + +The [tags](/guide/test-tags) that a test must have in order to be included in the run. + ## toJSON ```ts diff --git a/docs/api/index.md b/docs/api/index.md index 298a6b3bbd98..4876cb5d4815 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -29,6 +29,10 @@ interface TestOptions { * @default 0 */ repeats?: number + /** + * Custom tags of the test. Useful for filtering tests. + */ + tags?: string[] } ``` diff --git a/docs/config/tags.md b/docs/config/tags.md index 57cc69b5aed5..140ad36b0b0a 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -23,8 +23,6 @@ vitest --tag="unit/*" You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. ::: - - ## Example ```ts [vitest.config.js] diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 8eefdc18f8b1..c6d701ee419b 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -89,6 +89,24 @@ describe('suite', () => { }) ``` +## Filtering Tags + +If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tag` option: + +```ts +test('renders a form', { tags: ['frontend'] }, () => { + // ... +}) + +test('calls an external API', { tags: ['backend'] }, () => { + // ... +}) +``` + +```shell +vitest --tag=frontend +``` + ## Selecting Suites and Tests to Run Use `.only` to only run certain suites or tests diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index f61bc262b1a7..d12638fbd21e 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -4,7 +4,7 @@ ## Defining Tags -Tags must be defined in your configuration file. Vitest will throw an error if a test uses a tag that is not defined in the config. +Tags must be defined in your configuration file. Vitest does not provide any built-in tags. The test runner will throw an error if a test uses a tag not defined in the config. ```ts [vitest.config.js] import { defineConfig } from 'vitest/config' @@ -72,6 +72,31 @@ vitest --tag=frontend vitest --tag=frontend --tag=backend ``` +If you are using a programmatic API, you can pass down a `tag` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): + +```ts +import { startVitest } from 'vitest/node' + +await startVitest('test', [], { + tag: ['frontend', 'backend'], +}) +``` + +Or you can create a [test specification](/api/advanced/test-specification) with tags of your choice: + +```ts +const specification = vitest.getRootProject().createSpecification( + '/path-to-file.js', + { + testTags: ['frontend', 'backend'], + }, +) +``` + +::: warning +Note that `createSpecification` does not support wildcards and will not validate if the tags are defined in the config. +::: + ### Wildcards You can use a wildcard (`*`) to match any number of characters: From 072e1150d79a7df8a953791bf31720cecb2c027b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 21:10:16 +0100 Subject: [PATCH 05/48] docs: remove repeated example --- docs/config/tags.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/config/tags.md b/docs/config/tags.md index 140ad36b0b0a..75dc41684479 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -22,24 +22,3 @@ vitest --tag="unit/*" ::: tip FILTERING You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. ::: - -## Example - -```ts [vitest.config.js] -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - tags: [ - { - name: 'frontend', - description: 'Tests written for frontend.', - }, - { - name: 'backend', - description: 'Tests written for backend.', - }, - ], - }, -}) -``` From 972574dc48a64cf7fa0d577ba4a1725d45033090 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 16 Jan 2026 21:13:22 +0100 Subject: [PATCH 06/48] docs: add tags to config --- docs/.vitepress/config.ts | 4 ++++ docs/api/advanced/test-specification.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2c3fe8d42a18..6139eb87b45d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -473,6 +473,10 @@ export default ({ mode }: { mode: string }) => { text: 'sequence', link: '/config/sequence', }, + { + text: 'tags', + link: '/config/tags', + }, { text: 'typecheck', link: '/config/typecheck', diff --git a/docs/api/advanced/test-specification.md b/docs/api/advanced/test-specification.md index 9be523ec8198..7c94270ac040 100644 --- a/docs/api/advanced/test-specification.md +++ b/docs/api/advanced/test-specification.md @@ -83,7 +83,7 @@ A regexp that matches the name of the test in this module. This value will overr The ids of tasks inside of this specification to run. -## testTags 4.1.0 {#testids} +## testTags 4.1.0 {#testtags} The [tags](/guide/test-tags) that a test must have in order to be included in the run. From 5adfef355cd92feb0424a08a6fdc8a714457f7bf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 14:09:07 +0100 Subject: [PATCH 07/48] feat: support options inheritence --- docs/config/tags.md | 5 + docs/guide/test-tags.md | 34 ++++- packages/runner/src/suite.ts | 121 +++++++++------ packages/runner/src/types/runner.ts | 16 +- packages/runner/src/types/tasks.ts | 17 ++- packages/vitest/src/node/ast-collect.ts | 1 + .../vitest/src/node/config/resolveConfig.ts | 30 ++-- packages/vitest/src/node/core.ts | 2 +- .../src/node/reporters/reported-tasks.ts | 5 + test/cli/fixtures/test-tags/basic.test.ts | 2 +- test/cli/test/test-tags.test.ts | 142 +++++++++++++++++- test/test-utils/index.ts | 17 ++- 12 files changed, 313 insertions(+), 79 deletions(-) diff --git a/docs/config/tags.md b/docs/config/tags.md index 75dc41684479..3c45228da21b 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -22,3 +22,8 @@ vitest --tag="unit/*" ::: tip FILTERING You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. ::: + +## name +## description +## priority +## Test Options diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index d12638fbd21e..380521d95fdd 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -4,7 +4,9 @@ ## Defining Tags -Tags must be defined in your configuration file. Vitest does not provide any built-in tags. The test runner will throw an error if a test uses a tag not defined in the config. +Tags must be defined in your configuration file. Vitest does not provide any built-in tags. The test runner will throw an error if a test uses a tag not defined in the config in order to avoid silently doing something surprising due to mistyped names. + +You must define a `name` of the tag, and you may define additional options that will be applied to every test marked with the tag, e.g., a `timeout`, or `retry`. For the full list of available options, see [`tags`](/config/tags). ```ts [vitest.config.js] import { defineConfig } from 'vitest/config' @@ -20,11 +22,41 @@ export default defineConfig({ name: 'backend', description: 'Tests written for backend.', }, + { + name: 'db', + description: 'Tests for database queries.', + timeout: 60_000, + }, + { + name: 'flaky', + description: 'Flaky CI tests.', + retry: process.env.CI ? 3 : 0, + timeout: 30_000, + priority: 1, + }, ], }, }) ``` +::: warning +If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `properity` first (the lower the number, the higher the priority is): + +```ts +tet('flaky database test', { tags: ['flaky', 'db'] }) +// { timeout: 30_000, retry: 3 } +``` + +Note that the `timeout` is 30 seconds (and not 60) because `flaky` tag has a priority of `1` while `db` (that defines 60 second timeout) has no priority. + +If test defines its own options, they will have the highest priority: + +```ts +tet('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) +// { timeout: 120_000, retry: 3 } +``` +::: + ## Using Tags in Tests You can apply tags to individual tests or entire suites using the `tags` option: diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 33720bad8ca2..141778bd3e84 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -9,6 +9,7 @@ import type { SuiteCollector, SuiteFactory, SuiteHooks, + SuiteOptions, Task, TaskCustomOptions, TaskPopulated, @@ -214,7 +215,11 @@ export function getRunner(): VitestRunner { function createDefaultSuite(runner: VitestRunner) { const config = runner.config.sequence - const collector = suite('', { concurrent: config.concurrent }, () => {}) + const options: SuiteOptions = {} + if (config.concurrent != null) { + options.concurrent = config.concurrent + } + const collector = suite('', options, () => {}) // no parent suite for top-level tests delete collector.suite return collector @@ -295,7 +300,7 @@ function createSuiteCollector( factory: SuiteFactory = () => {}, mode: RunMode, each?: boolean, - suiteOptions?: TestOptions, + suiteOptions?: SuiteOptions, parentCollectorFixtures?: FixtureItem[], ) { const tasks: (Test | Suite | SuiteCollector)[] = [] @@ -305,13 +310,31 @@ function createSuiteCollector( initSuite(true) const task = function (name = '', options: TaskCustomOptions = {}) { - const timeout = options?.timeout ?? runner.config.testTimeout const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file const parentTags = parentTask?.tags || [] - if (options?.tags) { - validateTags(runner, options.tags) + const testTags = toArray(unique([...parentTags, ...options.tags || []])) + const tagsOptions = testTags + .map((tag) => { + const tagDefinition = runner.config.tags?.find(t => t.name === tag) + if (!tagDefinition) { + throw createNoTagsError(runner, tag) + } + return tagDefinition + }) + // higher priority should be last, run 1, 2, 3, ... etc + .sort((tag1, tag2) => (tag2.priority ?? Number.POSITIVE_INFINITY) - (tag1.priority ?? Number.POSITIVE_INFINITY)) + .reduce((acc, tag) => { + const { name, description, priority, ...options } = tag + Object.assign(acc, options) + return acc + }, {} as TestOptions) + + options = { + ...tagsOptions, + ...options, } + const timeout = options.timeout ?? runner.config.testTimeout const task: Test = { id: '', name, @@ -339,7 +362,7 @@ function createSuiteCollector( meta: options.meta ?? Object.create(null), annotations: [], artifacts: [], - tags: unique([...parentTags, ...(options.tags || [])]), + tags: testTags, } const handler = options.handler if (task.mode === 'run' && !handler) { @@ -352,7 +375,6 @@ function createSuiteCollector( task.concurrent = true } task.shuffle = suiteOptions?.shuffle - const context = createTestContext(task, runner) // create test context Object.defineProperty(task, 'context', { @@ -408,10 +430,15 @@ function createSuiteCollector( } // inherit concurrent / sequential from suite - options.concurrent - = this.concurrent || (!this.sequential && options?.concurrent) - options.sequential - = this.sequential || (!this.concurrent && options?.sequential) + const concurrent = this.concurrent ?? (!this.sequential && options?.concurrent) + if (options.concurrent != null && concurrent != null) { + options.concurrent = concurrent + } + + const sequential = this.sequential ?? (!this.concurrent && options?.sequential) + if (options.sequential != null && sequential != null) { + options.sequential = sequential + } const test = task(formatName(name), { ...this, @@ -462,8 +489,9 @@ function createSuiteCollector( const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file - if (suiteOptions?.tags) { - validateTags(runner, suiteOptions.tags) + const suiteTags = toArray(suiteOptions?.tags) + if (suiteTags.length) { + validateTags(runner, suiteTags) } suite = { @@ -483,7 +511,7 @@ function createSuiteCollector( tasks: [], meta: Object.create(null), concurrent: suiteOptions?.concurrent, - tags: unique([...parentTask?.tags || [], ...(suiteOptions?.tags || [])]), + tags: unique([...parentTask?.tags || [], ...suiteTags]), } if (runner && includeLocation && runner.config.includeTaskLocation) { @@ -553,7 +581,7 @@ function createSuite() { function suiteFn( this: Record, name: string | Function, - factoryOrOptions?: SuiteFactory | TestOptions, + factoryOrOptions?: SuiteFactory | SuiteOptions, optionsOrFactory?: number | SuiteFactory, ) { if (getCurrentTest()) { @@ -562,23 +590,12 @@ function createSuite() { ) } - let mode: RunMode = this.only - ? 'only' - : this.skip - ? 'skip' - : this.todo - ? 'todo' - : 'run' const currentSuite: SuiteCollector | undefined = collectorContext.currentSuite || defaultSuite let { options, handler: factory } = parseArguments( factoryOrOptions, optionsOrFactory, - ) - - if (mode === 'run' && !factory) { - mode = 'todo' - } + ) as { options: SuiteOptions; handler: SuiteFactory | undefined } const isConcurrentSpecified = options.concurrent || this.concurrent || options.sequential === false const isSequentialSpecified = options.sequential || this.sequential || options.concurrent === false @@ -587,14 +604,36 @@ function createSuite() { options = { ...currentSuite?.options, ...options, - shuffle: this.shuffle ?? options.shuffle ?? currentSuite?.options?.shuffle ?? runner?.config.sequence.shuffle, + } + + const shuffle = this.shuffle ?? options.shuffle ?? currentSuite?.options?.shuffle ?? runner?.config.sequence.shuffle + if (shuffle != null) { + options.shuffle = shuffle + } + + let mode: RunMode = (this.only ?? options.only) + ? 'only' + : (this.skip ?? options.skip) + ? 'skip' + : (this.todo ?? options.todo) + ? 'todo' + : 'run' + + // passed not factory, but also didn't tag it as todo or skip + // assume it's todo + if (mode === 'run' && !factory) { + mode = 'todo' } // inherit concurrent / sequential from suite const isConcurrent = isConcurrentSpecified || (options.concurrent && !isSequentialSpecified) const isSequential = isSequentialSpecified || (options.sequential && !isConcurrentSpecified) - options.concurrent = isConcurrent && !isSequential - options.sequential = isSequential && !isConcurrent + if (isConcurrent != null) { + options.concurrent = isConcurrent && !isSequential + } + if (isSequential != null) { + options.sequential = isSequential && !isConcurrent + } return createSuiteCollector( formatName(name), @@ -974,18 +1013,16 @@ function validateTags(runner: VitestRunner, tags: string[]) { const availableTags = new Set(runner.config.tags.map(tag => tag.name)) for (const tag of tags) { if (!availableTags.has(tag)) { - let message: string - - if (!runner.config.tags.length) { - message = `The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags` - } - else { - message = `Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags - .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) - .join('\n')}` - } - - throw new Error(message) + throw createNoTagsError(runner, tag) } } } + +function createNoTagsError(runner: VitestRunner, tag: string) { + if (!runner.config.tags.length) { + throw new Error(`The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags`) + } + throw new Error(`Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags + .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) + .join('\n')}`) +} diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index a71369a01591..38393025e6d1 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -12,6 +12,7 @@ import type { TestAnnotation, TestArtifact, TestContext, + TestOptions, } from './tasks' /** @@ -58,10 +59,21 @@ export interface FileSpecification { testIds: string[] | undefined } -export interface TestTagDefinition { +export interface TestTagDefinition extends Omit { + /** + * The name of the tag. This is what you use in the `tags` array in tests. + */ name: string + /** + * A description for the tag. This will be shown in the CLI help and UI. + */ description?: string - // TODO: repeats/retry/timeout/... options? + /** + * Priority for merging options when multiple tags with the same options are applied to a test. + * + * Lower number means higher priority. E.g., priority 1 takes precedence over priority 3. + */ + priority?: number } export type VitestRunnerImportSource = 'collect' | 'setup' diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 687a043f8715..ba31b415cfb0 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -548,10 +548,6 @@ export interface TestOptions { * Tests inherit `sequential` from `describe()` and nested `describe()` will inherit from parent's `sequential`. */ sequential?: boolean - /** - * Whether the tasks of the suite run in a random order. - */ - shuffle?: boolean /** * Whether the test should be skipped. */ @@ -571,7 +567,14 @@ export interface TestOptions { /** * Custom tags of the test. Useful for filtering tests. */ - tags?: string[] + tags?: string[] | string +} + +export interface SuiteOptions extends TestOptions { + /** + * Whether the tasks of the suite run in a random order. + */ + shuffle?: boolean } interface ExtendedAPI { @@ -655,7 +658,7 @@ interface SuiteCollectorCallable { ): SuiteCollector ( name: string | Function, - options: TestOptions, + options: SuiteOptions, fn?: SuiteFactory ): SuiteCollector } @@ -727,7 +730,7 @@ export interface TaskCustomOptions extends TestOptions { export interface SuiteCollector { readonly name: string readonly mode: RunMode - options?: TestOptions + options?: SuiteOptions type: 'collector' test: TestAPI tasks: ( diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index 4914aa3c5d1e..bcdc0e4f47dc 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -383,6 +383,7 @@ function createFileTask( options.testNamePattern, undefined, undefined, + undefined, hasOnly, false, options.allowOnly, diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 39b1b42c617c..c4b722cbff37 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -22,7 +22,6 @@ import { defaultPort, } from '../../constants' import { benchmarkConfigDefaults, configDefaults } from '../../defaults' -import { wildcardPatternToRegExp } from '../../utils/base' import { isCI, stdProvider } from '../../utils/env' import { getWorkersCountByPercentage } from '../../utils/workers' import { BaseSequencer } from '../sequencers/BaseSequencer' @@ -169,25 +168,18 @@ export function resolveConfig( resolved.project = toArray(resolved.project) resolved.provide ??= {} - if (resolved.tag?.length) { - const filterTags = resolved.tag.map((tag) => { - const negated = tag[0] === '!' - const actualTag = negated ? tag.slice(1) : tag - return { pattern: wildcardPatternToRegExp(actualTag), negated } - }) - - const availableTags: string[] = [] - resolved.tags?.forEach((tag) => { - const match = filterTags.find(({ pattern }) => pattern.test(tag.name)) - if (match) { - availableTags.push(match.negated ? `!${tag.name}` : tag.name) - } - }) - if (!availableTags.length) { - throw new Error(`Cannot find any tags to filter based on the ${resolved.tag.map(t => `--tag ${t}`).join(' ')} option. Did you define them in "test.tags" in your config?`) + resolved.tags ??= [] + resolved.tags.forEach((tag) => { + if (!tag.name || typeof tag.name !== 'string') { + throw new Error(`Each tag defined in "test.tags" must have a "name" property, received: ${JSON.stringify(tag)}`) } - resolved.tag = availableTags - } + if (tag.name.startsWith('!')) { + throw new Error(`Tag name "${tag.name}" cannot start with "!".`) + } + if (typeof tag.retry === 'object' && typeof tag.retry.condition === 'function') { + throw new TypeError(`Tag "${tag.name}": retry.condition function cannot be used inside a config file. Use a RegExp pattern instead, or define the function in your test file.`) + } + }) resolved.name = typeof options.name === 'string' ? options.name diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 7f208f314e89..1f7960b521ad 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -111,6 +111,7 @@ export class Vitest { /** @internal */ reporters: Reporter[] = [] /** @internal */ runner!: ModuleRunner /** @internal */ _testRun: TestRun = undefined! + /** @internal */ _config?: ResolvedConfig /** @internal */ _resolver!: VitestResolver /** @internal */ _fetcher!: VitestFetchFunction /** @internal */ _fsCache!: FileSystemModuleCache @@ -122,7 +123,6 @@ export class Vitest { private readonly specifications: VitestSpecifications private pool: ProcessPool | undefined - private _config?: ResolvedConfig private _vite?: ViteDevServer private _state?: StateManager private _cache?: VitestCache diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index bdd2dd481299..d8d92c0e2f0d 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -566,6 +566,10 @@ export interface TaskOptions { readonly retry: SerializableRetry | undefined readonly repeats: number | undefined readonly tags: string[] | undefined + /** + * Only tests have a `timeout` option. + */ + readonly timeout: number | undefined readonly mode: 'run' | 'only' | 'skip' | 'todo' } @@ -580,6 +584,7 @@ function buildOptions( retry: task.retry as SerializableRetry | undefined, repeats: task.repeats, tags: task.tags, + timeout: task.type === 'test' ? task.timeout : undefined, // runner types are too broad, but the public API should be more strict // the queued state exists only on Files and this method is called // only for tests and suites diff --git a/test/cli/fixtures/test-tags/basic.test.ts b/test/cli/fixtures/test-tags/basic.test.ts index 40e163fa4201..e19c61778cd7 100644 --- a/test/cli/fixtures/test-tags/basic.test.ts +++ b/test/cli/fixtures/test-tags/basic.test.ts @@ -1,6 +1,6 @@ import { describe, test } from 'vitest' -describe('suite 1', { tags: ['suite'] }, () => { +describe('suite 1', { tags: ['suite', 'alone'] }, () => { test('test 1', () => {}) test('test 2', { tags: ['test'] }, () => {}) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 219db975c61a..ecbc4696872e 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -1,4 +1,4 @@ -import type { TestModule, TestSuite } from 'vitest/node' +import type { TestCase, TestModule, TestSuite } from 'vitest/node' import { runInlineTests, runVitest } from '#test-utils' import { expect, test } from 'vitest' @@ -204,6 +204,136 @@ test('throws an error if tag is not defined in the config, but in --tag filter', expect(stderr).toContain('Cannot find any tags to filter based on the --tag unknown option. Did you define them in "test.tags" in your config?') }) +test('defining a tag available only in one project', async () => { + await runVitest({ + tag: ['project-2-tag'], + projects: [ + { + test: { + tags: [{ name: 'project-1-tag' }], + }, + }, + { + test: { + tags: [{ name: 'project-2-tag' }], + }, + }, + ], + }) +}) + +test.only('can specify custom options for tags', async () => { + const { stderr, buildTree } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { name: 'alone' }, + { name: 'suite', timeout: 1000 }, + { name: 'test', retry: 2, skip: true }, + { name: 'suite 2', repeats: 3 }, + { name: 'test 2', timeout: 500, retry: 1 }, + ], + }) + expect(stderr).toBe('') + expect(buildTree(testCase => removeUndefined(testCase.options))).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "suite 1": { + "suite 2": { + "test 3": { + "mode": "run", + "repeats": 3, + "tags": [ + "suite", + "alone", + "suite 2", + ], + "timeout": 1000, + }, + "test 4": { + "mode": "run", + "repeats": 3, + "retry": 1, + "tags": [ + "suite", + "alone", + "suite 2", + "test 2", + ], + "timeout": 500, + }, + }, + "test 1": { + "mode": "run", + "tags": [ + "suite", + "alone", + ], + "timeout": 1000, + }, + "test 2": { + "mode": "skip", + "retry": 2, + "tags": [ + "suite", + "alone", + "test", + ], + "timeout": 1000, + }, + }, + }, + } + `) +}) + +// TODO: test that custom options override tags options + +test('can specify custom options with priorities for tags', async () => { + const { stderr, ctx } = await runVitest({ + root: './fixtures/test-tags', + config: false, + tags: [ + { + name: 'test', + timeout: 500, + skip: false, + concurrent: true, + fails: false, + priority: 1, + }, + { + name: 'suite', + timeout: 1000, + skip: true, + concurrent: false, + fails: true, + priority: 2, + }, + { name: 'suite 2' }, + { name: 'test 2' }, + ], + }) + + expect(stderr).toBe('') + const testModule = ctx!.state.getTestModules()[0] + const testSuite = testModule.children.at(0) as TestSuite + const testCase = testSuite.children.at(1) as TestCase + + expect(testCase.name).toBe('test 2') + expect(testCase.options.tags).toEqual(['suite', 'test']) + // from 'test' tag (priority 1 is higher) + expect(testCase.options.timeout).toBe(500) + // concurrent is not set anywhere manually, so + // test always gets it from the highest priority tag + expect(testCase.options.concurrent).toBe(true) + expect(testCase.options.fails).toBe(false) + expect(testCase.result().state).toBe('passed') +}) + +test('@tag docs inject test tags', async () => {}) +test('invalid @tag throws and error', async () => {}) + function getTestTree(testModule: TestModule | TestSuite, tree: Record = {}) { for (const child of testModule.children) { if (child.type === 'suite') { @@ -216,3 +346,13 @@ function getTestTree(testModule: TestModule | TestSuite, tree: Record>(obj: T): Partial { + const result: Partial = {} + for (const key in obj) { + if (obj[key] !== undefined) { + result[key] = obj[key] + } + } + return result +} diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index 61b1f7e62faf..8b21eb8391f0 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -3,9 +3,9 @@ import type { UserConfig as ViteUserConfig } from 'vite' import type { SerializedConfig, WorkerGlobalState } from 'vitest' import type { TestProjectConfiguration } from 'vitest/config' import type { + TestCase, TestCollection, TestModule, - TestResult, TestSpecification, TestUserConfig, Vitest, @@ -226,6 +226,9 @@ export async function runVitest( testTree() { return buildTestTree(ctx?.state.getTestModules() || []) }, + buildTree(onResult: (testResult: TestCase) => any) { + return buildTestTree(ctx?.state.getTestModules() || [], onResult) + }, waitForClose: async () => { await new Promise(resolve => ctx!.onClose(resolve)) return ctx?.closingPromise @@ -465,6 +468,9 @@ export async function runInlineTests( testTree() { return buildTestTree(vitest.ctx?.state.getTestModules() || []) }, + buildTree(onResult: (testResult: TestCase) => any) { + return buildTestTree(vitest.ctx?.state.getTestModules() || [], onResult) + }, } } @@ -481,7 +487,8 @@ export class StableTestFileOrderSorter { } export function buildErrorTree(testModules: TestModule[]) { - return buildTestTree(testModules, (result) => { + return buildTestTree(testModules, (testCase) => { + const result = testCase.result() if (result.state === 'failed') { return result.errors.map(e => e.message) } @@ -489,7 +496,7 @@ export function buildErrorTree(testModules: TestModule[]) { }) } -export function buildTestTree(testModules: TestModule[], onResult?: (result: TestResult) => unknown) { +export function buildTestTree(testModules: TestModule[], onTestCase?: (result: TestCase) => unknown) { type TestTree = Record function walkCollection(collection: TestCollection): TestTree { @@ -503,8 +510,8 @@ export function buildTestTree(testModules: TestModule[], onResult?: (result: Tes } else if (child.type === 'test') { const result = child.result() - if (onResult) { - node[child.name] = onResult(result) + if (onTestCase) { + node[child.name] = onTestCase(child) } else { node[child.name] = result.state From 09e07e12ebb78ea9332640b57243212babbf931e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 14:22:22 +0100 Subject: [PATCH 08/48] test: cleanup --- packages/runner/src/suite.ts | 4 +- test/cli/test/test-tags.test.ts | 127 ++++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 40 deletions(-) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 141778bd3e84..7a775e142206 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -256,6 +256,8 @@ export function createSuiteHooks(): SuiteHooks { } } +const POSITIVE_INFINITY = Number.POSITIVE_INFINITY + function parseArguments any>( optionsOrFn: T | object | undefined, timeoutOrTest: T | number | undefined, @@ -323,7 +325,7 @@ function createSuiteCollector( return tagDefinition }) // higher priority should be last, run 1, 2, 3, ... etc - .sort((tag1, tag2) => (tag2.priority ?? Number.POSITIVE_INFINITY) - (tag1.priority ?? Number.POSITIVE_INFINITY)) + .sort((tag1, tag2) => (tag2.priority ?? POSITIVE_INFINITY) - (tag1.priority ?? POSITIVE_INFINITY)) .reduce((acc, tag) => { const { name, description, priority, ...options } = tag Object.assign(acc, options) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index ecbc4696872e..d5f011abf5b2 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -1,12 +1,13 @@ -import type { TestCase, TestModule, TestSuite } from 'vitest/node' +import type { TestCase, TestSuite } from 'vitest/node' import { runInlineTests, runVitest } from '#test-utils' import { expect, test } from 'vitest' test('vitest records tags', async () => { - const { stderr, ctx } = await runVitest({ + const { stderr, buildTree } = await runVitest({ root: './fixtures/test-tags', config: false, tags: [ + { name: 'alone' }, { name: 'suite' }, { name: 'test' }, { name: 'suite 2' }, @@ -15,27 +16,33 @@ test('vitest records tags', async () => { }) expect(stderr).toBe('') - expect(getTestTree(ctx!.state.getTestModules()[0])).toMatchInlineSnapshot(` + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` { - "suite 1": { - "suite 2": { - "test 3": [ + "basic.test.ts": { + "suite 1": { + "suite 2": { + "test 3": [ + "suite", + "alone", + "suite 2", + ], + "test 4": [ + "suite", + "alone", + "suite 2", + "test 2", + ], + }, + "test 1": [ "suite", - "suite 2", + "alone", ], - "test 4": [ + "test 2": [ "suite", - "suite 2", - "test 2", + "alone", + "test", ], }, - "test 1": [ - "suite", - ], - "test 2": [ - "suite", - "test", - ], }, } `) @@ -46,6 +53,7 @@ test('filters tests based on --tag=!ignore', async () => { root: './fixtures/test-tags', config: false, tags: [ + { name: 'alone' }, { name: 'suite' }, { name: 'test' }, { name: 'suite 2' }, @@ -76,6 +84,7 @@ test('filters tests based on --tag=!ignore and --tag=include', async () => { root: './fixtures/test-tags', config: false, tags: [ + { name: 'alone' }, { name: 'suite' }, { name: 'test' }, { name: 'suite 2' }, @@ -106,6 +115,7 @@ test('filters tests based on --tag=include', async () => { root: './fixtures/test-tags', config: false, tags: [ + { name: 'alone' }, { name: 'suite' }, { name: 'test' }, { name: 'suite 2' }, @@ -121,17 +131,17 @@ test('filters tests based on --tag=include', async () => { "suite 1": { "suite 2": { "test 3": "skipped", - "test 4": "passed", + "test 4": "skipped", }, "test 1": "skipped", - "test 2": "passed", + "test 2": "skipped", }, }, } `) }) -test('throws an error if no tags are defined in the config, but in the test', async () => { +test.skip('throws an error if no tags are defined in the config, but in the test', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': ` @@ -159,7 +169,7 @@ test('throws an error if no tags are defined in the config, but in the test', as `) }) -test('throws an error if tag is not defined in the config, but in the test', async () => { +test.skip('throws an error if tag is not defined in the config, but in the test', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': ` @@ -191,7 +201,7 @@ test('throws an error if tag is not defined in the config, but in the test', asy `) }) -test('throws an error if tag is not defined in the config, but in --tag filter', async () => { +test.skip('throws an error if tag is not defined in the config, but in --tag filter', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': '', @@ -204,8 +214,9 @@ test('throws an error if tag is not defined in the config, but in --tag filter', expect(stderr).toContain('Cannot find any tags to filter based on the --tag unknown option. Did you define them in "test.tags" in your config?') }) -test('defining a tag available only in one project', async () => { +test.todo('defining a tag available only in one project', async () => { await runVitest({ + config: false, tag: ['project-2-tag'], projects: [ { @@ -222,7 +233,7 @@ test('defining a tag available only in one project', async () => { }) }) -test.only('can specify custom options for tags', async () => { +test('can specify custom options for tags', async () => { const { stderr, buildTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -235,7 +246,7 @@ test.only('can specify custom options for tags', async () => { ], }) expect(stderr).toBe('') - expect(buildTree(testCase => removeUndefined(testCase.options))).toMatchInlineSnapshot(` + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` { "basic.test.ts": { "suite 1": { @@ -287,13 +298,12 @@ test.only('can specify custom options for tags', async () => { `) }) -// TODO: test that custom options override tags options - test('can specify custom options with priorities for tags', async () => { const { stderr, ctx } = await runVitest({ root: './fixtures/test-tags', config: false, tags: [ + { name: 'alone' }, { name: 'test', timeout: 500, @@ -321,7 +331,7 @@ test('can specify custom options with priorities for tags', async () => { const testCase = testSuite.children.at(1) as TestCase expect(testCase.name).toBe('test 2') - expect(testCase.options.tags).toEqual(['suite', 'test']) + expect(testCase.options.tags).toEqual(['suite', 'alone', 'test']) // from 'test' tag (priority 1 is higher) expect(testCase.options.timeout).toBe(500) // concurrent is not set anywhere manually, so @@ -331,20 +341,59 @@ test('can specify custom options with priorities for tags', async () => { expect(testCase.result().state).toBe('passed') }) +test('custom options override tag options', async () => { + const { stderr, buildTree } = await runInlineTests({ + 'basic.test.js': ` + test.fails('test 1', { tags: ['test'], timeout: 2000, skip: false, repeats: 0 }, () => { + throw new Error('fail') + }) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { + name: 'test', + timeout: 500, + skip: true, + concurrent: true, + fails: false, + retry: 2, + repeats: 2, + }, + ], + }, + }, + }) + expect(stderr).toBe('') + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": { + "concurrent": true, + "fails": true, + "mode": "run", + "repeats": 0, + "retry": 2, + "tags": [ + "test", + ], + "timeout": 2000, + }, + }, + } + `) +}) + test('@tag docs inject test tags', async () => {}) test('invalid @tag throws and error', async () => {}) -function getTestTree(testModule: TestModule | TestSuite, tree: Record = {}) { - for (const child of testModule.children) { - if (child.type === 'suite') { - tree[child.name] = {} - getTestTree(child, tree[child.name]) - } - else { - tree[child.name] = child.options.tags - } - } - return tree +function getTestTree(builder: (fn: (test: TestCase) => any) => any) { + return builder(test => test.options.tags) +} + +function buildOptionsTree(builder: (fn: (test: TestCase) => any) => any) { + return builder(test => removeUndefined(test.options)) } function removeUndefined>(obj: T): Partial { From bff0b23d6787df181cc0972756977a1300ccfd9c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 14:42:37 +0100 Subject: [PATCH 09/48] feat: add --strict-tags option --- docs/.vitepress/config.ts | 4 +++ docs/api/index.md | 2 ++ docs/config/stricttags.md | 30 +++++++++++++++++++ docs/config/tags.md | 2 +- docs/guide/test-tags.md | 2 +- packages/runner/src/suite.ts | 5 ++-- packages/runner/src/types/runner.ts | 1 + packages/vitest/src/node/cli/cli-config.ts | 3 ++ .../vitest/src/node/config/serializeConfig.ts | 1 + packages/vitest/src/node/types/config.ts | 6 ++++ packages/vitest/src/runtime/config.ts | 1 + test/cli/test/test-tags.test.ts | 19 ++++++++++++ 12 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 docs/config/stricttags.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6139eb87b45d..1f6a637b6ae1 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -477,6 +477,10 @@ export default ({ mode }: { mode: string }) => { text: 'tags', link: '/config/tags', }, + { + text: 'strictTags', + link: '/config/stricttags', + }, { text: 'typecheck', link: '/config/typecheck', diff --git a/docs/api/index.md b/docs/api/index.md index 4876cb5d4815..45d2a7fce7e5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -36,6 +36,8 @@ interface TestOptions { } ``` + + When a test function returns a promise, the runner will wait until it is resolved to collect async expectations. If the promise is rejected, the test will fail. ::: tip diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md new file mode 100644 index 000000000000..d5e759096dbe --- /dev/null +++ b/docs/config/stricttags.md @@ -0,0 +1,30 @@ +# strictTags 4.1.0 {#stricttags} + +- **Type:** `boolean` +- **Default:** `true` +- **CLI:** `--strict-tags`, `--no-strict-tags` + +Should Vitest throw an error if test has a [`tag`](/config/tags) that is not defined in the config to avoid silently doing something surprising due to mistyped names (applying the wrong configuration or skipping the test due to a `--tag` flag). + +Note that Vitest will always throw an error if `--tag` flag defines a tag not present in the config. + +For examle, this test will throw an error because the tag `fortend` has a typo (it should be `frontend`): + +::: code-group +```js [form.test.js] +test('renders a form', { tags: ['fortend'] }, () => { + // ... +}) +``` +```js [vitest.config.js] +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + tags: [ + { name: 'frontend' }, + ], + }, +}) +``` +::: diff --git a/docs/config/tags.md b/docs/config/tags.md index 3c45228da21b..191d7af68993 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -8,7 +8,7 @@ outline: deep - **Type:** `TestTagDefinition[]` - **Default:** `[]` -Defines all [available tags](/guide/test-tags) in your test project. If test defines a name not listed here, Vitest will throw an error. +Defines all [available tags](/guide/test-tags) in your test project. By default, if test defines a name not listed here, Vitest will throw an error, but this can be configured via a [`strictTags`](/config/stricttags) option. If you are using [`projects`](/config/projects), they will inherit all global tags automatically. diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 380521d95fdd..b7ebfe07dabd 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -4,7 +4,7 @@ ## Defining Tags -Tags must be defined in your configuration file. Vitest does not provide any built-in tags. The test runner will throw an error if a test uses a tag not defined in the config in order to avoid silently doing something surprising due to mistyped names. +Tags must be defined in your configuration file. Vitest does not provide any built-in tags. The test runner will throw an error if a test uses a tag not defined in the config in order to avoid silently doing something surprising due to mistyped names, but you can disable this behaviour via a [`strictTags`](/config/stricttags) option. You must define a `name` of the tag, and you may define additional options that will be applied to every test marked with the tag, e.g., a `timeout`, or `retry`. For the full list of available options, see [`tags`](/config/tags). diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 7a775e142206..16765b069c14 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -319,11 +319,12 @@ function createSuiteCollector( const tagsOptions = testTags .map((tag) => { const tagDefinition = runner.config.tags?.find(t => t.name === tag) - if (!tagDefinition) { + if (!tagDefinition && runner.config.strictTags) { throw createNoTagsError(runner, tag) } return tagDefinition }) + .filter(r => r != null) // higher priority should be last, run 1, 2, 3, ... etc .sort((tag1, tag2) => (tag2.priority ?? POSITIVE_INFINITY) - (tag1.priority ?? POSITIVE_INFINITY)) .reduce((acc, tag) => { @@ -492,7 +493,7 @@ function createSuiteCollector( const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file const suiteTags = toArray(suiteOptions?.tags) - if (suiteTags.length) { + if (suiteTags.length && runner.config.strictTags) { validateTags(runner, suiteTags) } diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 38393025e6d1..7d7afeda6b08 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -43,6 +43,7 @@ export interface VitestRunnerConfig { diffOptions?: DiffOptions tags: TestTagDefinition[] tagsFilter?: string[] + strictTags: boolean } /** diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 0ad6a9fb16fc..f2e382804c21 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -793,6 +793,9 @@ export const cliOptionsConfig: VitestCLIOptions = { argument: '', array: true, }, + strictTags: { + description: 'Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`)', + }, experimental: { description: 'Experimental features.', diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 72a7b7a89b83..fa556bdc05a9 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -137,5 +137,6 @@ export function serializeConfig(project: TestProject): SerializedConfig { }, tags: config.tags || [], tagsFilter: config.tag, + strictTags: config.strictTags ?? true, } } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index b62f050cb772..ef8f4bbc8f32 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -869,6 +869,12 @@ export interface InlineConfig { * If test defines a tag that is not listed here, an error will be thrown. */ tags?: TestTagDefinition[] + + /** + * Should Vitest throw an error if test has a tag that is not defined in the config. + * @default true + */ + strictTags?: boolean } export interface TypecheckConfig { diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 4649e85c416a..4dd7998951ad 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -128,6 +128,7 @@ export interface SerializedConfig { } tags: TestTagDefinition[] tagsFilter: string[] | undefined + strictTags: boolean } export interface SerializedCoverageConfig { diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index d5f011abf5b2..1c94ab9c8c2b 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -385,6 +385,25 @@ test('custom options override tag options', async () => { `) }) +test('strictFlag: false does not throw an error if test has an undefined tag', async () => { + const { stderr } = await runInlineTests( + { + 'basic.test.js': ` + test('test 1', { tags: ['unknown'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + strictTags: false, + tags: [{ name: 'known' }], + }, + }, + }, + ) + + expect(stderr).toBe('') +}) + test('@tag docs inject test tags', async () => {}) test('invalid @tag throws and error', async () => {}) From dfd225b4862222aacd93d7c77babca57126d16c7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 15:18:06 +0100 Subject: [PATCH 10/48] feat: support @tag jsdoc --- docs/config/stricttags.md | 5 ++ docs/guide/test-tags.md | 42 +++++++++++++- packages/runner/src/collect.ts | 10 +++- packages/runner/src/suite.ts | 24 +------- packages/runner/src/utils/tasks.ts | 23 ++++++++ packages/vitest/src/node/pool.ts | 8 +-- packages/vitest/src/utils/test-helpers.ts | 49 ++++++++++------ .../file-tags/error-file-tags.test.ts | 10 ++++ .../file-tags/valid-file-tags.test.ts | 11 ++++ test/cli/test/test-tags.test.ts | 58 ++++++++++++++++++- 10 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 test/cli/fixtures/file-tags/error-file-tags.test.ts create mode 100644 test/cli/fixtures/file-tags/valid-file-tags.test.ts diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md index d5e759096dbe..c3f2139ae3d3 100644 --- a/docs/config/stricttags.md +++ b/docs/config/stricttags.md @@ -1,3 +1,8 @@ +--- +title: strictTags | Config +outline: deep +--- + # strictTags 4.1.0 {#stricttags} - **Type:** `boolean` diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index b7ebfe07dabd..2ab5d6935c71 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -1,3 +1,7 @@ +--- +title: Test Tags | Guide +--- + # Test Tags 4.1.0 [`Tags`](/config/tags) allow you to mark tests and change their options based on the tag's definition. @@ -81,7 +85,7 @@ describe('API endpoints', { tags: ['backend'] }, () => { Tags are inherited from parent suites, so all tests inside a tagged `describe` block will automatically have that tag. -It's also possible to define `tags` for every test in the file by using JSDoc's `@tag`: +It's also possible to define `tags` for every test in the file by using JSDoc's `@tag` at the top of the file: ```ts /** @@ -95,6 +99,42 @@ test('dashboard renders items', () => { }) ``` +::: danger +Any JSDoc comment with a `@tag` will add that tag to all tests in that file. Putting it before the test does not mark that test with a tag: + +```js{3,10} +describe('forms', () => { + /** + * @tag frontend + */ + test('renders a form', () => { + // ... + }) + + /** + * @tag db + */ + test('db returns users', () => { + // ... + }) +}) +``` + +This test file will mark all tests with a `frontend` and a `db` tag, you should pass an object instead: + +```js{2,6} +describe('forms', () => { + test('renders a form', { tags: 'frontend' }, () => { + // ... + }) + + test('db returns users', { tags: 'db' }, () => { + // ... + }) +}) +``` +::: + ## Filtering Tests by Tag To run only tests with specific tags, use the [`--tag`](/guide/cli#tag) CLI option: diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 567e8eebbcfa..cb8659cdf2db 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -16,6 +16,7 @@ import { interpretTaskModes, someTasksAreOnly, } from './utils/collect' +import { validateTags } from './utils/tasks' const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now @@ -39,15 +40,20 @@ export async function collectTests( const testIds = typeof spec === 'string' ? undefined : spec.testIds const testTags = typeof spec === 'string' ? undefined : spec.testTags + const fileTags: string[] = typeof spec === 'string' ? [] : (spec.fileTags || []) + const file = createFileTask(filepath, config.root, config.name, runner.pool, runner.viteEnvironment) setFileContext(file, Object.create(null)) + file.tags = fileTags file.shuffle = config.sequence.shuffle - runner.onCollectStart?.(file) - clearCollectorContext(file, runner) try { + validateTags(runner, fileTags) + + runner.onCollectStart?.(file) + const setupFiles = toArray(config.setupFiles) if (setupFiles.length) { const setupStart = now() diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index 16765b069c14..f8994e00ab58 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -40,7 +40,7 @@ import { getHooks, setFn, setHooks, setTestFixture } from './map' import { getCurrentTest } from './test-state' import { findTestFileStackTrace } from './utils' import { createChainable } from './utils/chain' -import { createTaskName } from './utils/tasks' +import { createNoTagsError, createTaskName, validateTags } from './utils/tasks' /** * Creates a suite of tests, allowing for grouping and hierarchical organization of tests. @@ -493,9 +493,7 @@ function createSuiteCollector( const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file const suiteTags = toArray(suiteOptions?.tags) - if (suiteTags.length && runner.config.strictTags) { - validateTags(runner, suiteTags) - } + validateTags(runner, suiteTags) suite = { id: '', @@ -1011,21 +1009,3 @@ function formatTemplateString(cases: any[], args: any[]): any[] { } return res } - -function validateTags(runner: VitestRunner, tags: string[]) { - const availableTags = new Set(runner.config.tags.map(tag => tag.name)) - for (const tag of tags) { - if (!availableTags.has(tag)) { - throw createNoTagsError(runner, tag) - } - } -} - -function createNoTagsError(runner: VitestRunner, tag: string) { - if (!runner.config.tags.length) { - throw new Error(`The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags`) - } - throw new Error(`Tag "${tag}" is not defined in the configuration. Available tags are: \n${runner.config.tags - .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) - .join('\n')}`) -} diff --git a/packages/runner/src/utils/tasks.ts b/packages/runner/src/utils/tasks.ts index e7a3b55529ea..c3173610b3db 100644 --- a/packages/runner/src/utils/tasks.ts +++ b/packages/runner/src/utils/tasks.ts @@ -1,4 +1,5 @@ import type { Arrayable } from '@vitest/utils' +import type { VitestRunner } from '../types/runner' import type { Suite, Task, Test } from '../types/tasks' import { toArray } from '@vitest/utils/helpers' @@ -84,3 +85,25 @@ export function getTestName(task: Task, separator = ' > '): string { export function createTaskName(names: readonly (string | undefined)[], separator = ' > '): string { return names.filter(name => name !== undefined).join(separator) } + +export function validateTags(runner: VitestRunner, tags: string[]): void { + if (!runner.config.strictTags) { + return + } + + const availableTags = new Set(runner.config.tags.map(tag => tag.name)) + for (const tag of tags) { + if (!availableTags.has(tag)) { + throw createNoTagsError(runner, tag) + } + } +} + +export function createNoTagsError(runner: VitestRunner, tag: string): never { + if (!runner.config.tags.length) { + throw new Error(`The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags`) + } + throw new Error(`Tag "${tag}" is not defined in the configuration. Available tags are:\n${runner.config.tags + .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) + .join('\n')}`) +} diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 90d8d8e847de..ec698eb44fd2 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -12,7 +12,7 @@ import { version as viteVersion } from 'vite' import { rootDir } from '../paths' import { isWindows } from '../utils/env' import { getWorkerMemoryLimit, stringToBytes } from '../utils/memory-limit' -import { getSpecificationsEnvironments } from '../utils/test-helpers' +import { getSpecificationsOptions } from '../utils/test-helpers' import { createBrowserPool } from './pools/browser' import { Pool } from './pools/pool' @@ -87,7 +87,7 @@ export function createPool(ctx: Vitest): ProcessPool { let workerId = 0 const sorted = await sequencer.sort(specs) - const environments = await getSpecificationsEnvironments(specs) + const { environments, tags } = await getSpecificationsOptions(specs) const groups = groupSpecs(sorted, environments) const projectEnvs = new WeakMap>() @@ -149,7 +149,7 @@ export function createPool(ctx: Vitest): ProcessPool { context: { files: specs.map(spec => ({ filepath: spec.moduleId, - fileTags: [], // TODO: read from @tag + fileTags: tags.get(spec), testLocations: spec.testLines, testNamePattern: spec.testNamePattern, testIds: spec.testIds, @@ -339,7 +339,7 @@ function getMemoryLimit(config: ResolvedConfig, pool: string) { return null } -function groupSpecs(specs: TestSpecification[], environments: Awaited>) { +function groupSpecs(specs: TestSpecification[], environments: WeakMap) { // Test files are passed to test runner one at a time, except for Typechecker or when "--maxWorker=1 --no-isolate" type SpecsForRunner = TestSpecification[] diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index af1972e8fccf..d8722c104ab7 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -3,35 +3,32 @@ import type { EnvironmentOptions, VitestEnvironment } from '../node/types/config import type { ContextTestEnvironment } from '../types/worker' import { promises as fs } from 'node:fs' -export async function getSpecificationsEnvironments( +export async function getSpecificationsOptions( specifications: Array, -): Promise> { +): Promise<{ + environments: WeakMap + tags: WeakMap +}> { const environments = new WeakMap() const cache = new Map() + const tags = new WeakMap() await Promise.all( specifications.map(async (spec) => { const { moduleId: filepath, project } = spec // reuse if projects have the same test files let code = cache.get(filepath) if (!code) { - code = await fs.readFile(filepath, 'utf-8') + code = await fs.readFile(filepath, 'utf-8').catch(() => '') cache.set(filepath, code) } - // TODO: parse @tag to inject test tags into test files, and validate against config.tags + const { + env = project.config.environment || 'node', + envOptions, + tags: specTags = [], + } = detectBlockLine(code) + tags.set(spec, specTags) - // 1. Check for control comments in the file - let env = code.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] - // 2. Fallback to global env - env ||= project.config.environment || 'node' - - let envOptionsJson = code.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] - if (envOptionsJson?.endsWith('*/')) { - // Trim closing Docblock characters the above regex might have captured - envOptionsJson = envOptionsJson.slice(0, -2) - } - - const envOptions = JSON.parse(envOptionsJson || 'null') const envKey = env === 'happy-dom' ? 'happyDOM' : env const environment: ContextTestEnvironment = { name: env as VitestEnvironment, @@ -42,5 +39,23 @@ export async function getSpecificationsEnvironments( environments.set(spec, environment) }), ) - return environments + return { environments, tags } +} + +function detectBlockLine(content: string) { + const env = content.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] + let envOptionsJson = content.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] + if (envOptionsJson?.endsWith('*/')) { + // Trim closing Docblock characters the above regex might have captured + envOptionsJson = envOptionsJson.slice(0, -2) + } + const envOptions = JSON.parse(envOptionsJson || 'null') + const tags: string[] = [] + let tagMatch: RegExpMatchArray | null + // eslint-disable-next-line no-cond-assign + while (tagMatch = content.match(/(\/\/|\*)\s*@tag\s+([\w\-/]+)\b/)) { + tags.push(tagMatch[2]) + content = content.slice(tagMatch.index! + tagMatch[0].length) + } + return { env, envOptions, tags } } diff --git a/test/cli/fixtures/file-tags/error-file-tags.test.ts b/test/cli/fixtures/file-tags/error-file-tags.test.ts new file mode 100644 index 000000000000..62854f3d5d10 --- /dev/null +++ b/test/cli/fixtures/file-tags/error-file-tags.test.ts @@ -0,0 +1,10 @@ +/** + * @tag invalid + * @tag unknown + */ + +import { describe, test } from 'vitest' + +describe('suite 1', () => { + test('test 1', { tags: ['test'] }, () => {}) +}) diff --git a/test/cli/fixtures/file-tags/valid-file-tags.test.ts b/test/cli/fixtures/file-tags/valid-file-tags.test.ts new file mode 100644 index 000000000000..1eec43c8d25f --- /dev/null +++ b/test/cli/fixtures/file-tags/valid-file-tags.test.ts @@ -0,0 +1,11 @@ +/** + * @tag file + * @tag file-2 + * @tag file/slash + */ + +import { describe, test } from 'vitest' + +describe('suite 1', () => { + test('test 1', { tags: ['test'] }, () => {}) +}) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 1c94ab9c8c2b..ac7c5e94b78a 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -404,8 +404,62 @@ test('strictFlag: false does not throw an error if test has an undefined tag', a expect(stderr).toBe('') }) -test('@tag docs inject test tags', async () => {}) -test('invalid @tag throws and error', async () => {}) +test('@tag docs inject test tags', async () => { + const { stderr, buildTree } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./valid-file-tags.test.ts'], + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + expect(stderr).toBe('') + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` + { + "valid-file-tags.test.ts": { + "suite 1": { + "test 1": [ + "file", + "file-2", + "file/slash", + "test", + ], + }, + }, + } + `) +}) + +test('invalid @tag throws and error', async () => { + const { stderr } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./error-file-tags.test.ts'], + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL error-file-tags.test.ts [ error-file-tags.test.ts ] + Error: Tag "invalid" is not defined in the configuration. Available tags are: + - file + - file-2 + - file/slash + - test + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) +}) function getTestTree(builder: (fn: (test: TestCase) => any) => any) { return builder(test => test.options.tags) From 7f9df91e515567d81088f1bdd179a0d9f0091a86 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:13:46 +0100 Subject: [PATCH 11/48] feat: support filter --- docs/config/stricttags.md | 4 +- docs/config/tags.md | 9 +- docs/guide/filtering.md | 4 +- docs/guide/test-tags.md | 80 +++- packages/runner/src/collect.ts | 16 +- packages/runner/src/suite.ts | 9 +- packages/runner/src/types.ts | 1 + packages/runner/src/types/runner.ts | 9 +- packages/runner/src/types/tasks.ts | 6 +- packages/runner/src/utils/collect.ts | 35 +- packages/runner/src/utils/index.ts | 1 + packages/runner/src/utils/tags.ts | 280 +++++++++++ packages/runner/src/utils/tasks.ts | 23 - packages/vitest/src/node/cli/cli-config.ts | 6 +- .../vitest/src/node/config/resolveConfig.ts | 3 + .../vitest/src/node/config/serializeConfig.ts | 2 +- packages/vitest/src/node/pool.ts | 2 +- .../vitest/src/node/test-specification.ts | 7 +- packages/vitest/src/node/types/config.ts | 6 +- packages/vitest/src/public/index.ts | 3 +- packages/vitest/src/runtime/config.ts | 2 +- packages/vitest/src/runtime/types/utils.ts | 1 + test/cli/fixtures/test-tags/basic.test.ts | 4 +- test/cli/test/test-tags.test.ts | 68 +-- test/core/test/test-tags-filter.test.ts | 443 ++++++++++++++++++ 25 files changed, 884 insertions(+), 140 deletions(-) create mode 100644 packages/runner/src/utils/tags.ts create mode 100644 test/core/test/test-tags-filter.test.ts diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md index c3f2139ae3d3..dd56dc191139 100644 --- a/docs/config/stricttags.md +++ b/docs/config/stricttags.md @@ -9,9 +9,9 @@ outline: deep - **Default:** `true` - **CLI:** `--strict-tags`, `--no-strict-tags` -Should Vitest throw an error if test has a [`tag`](/config/tags) that is not defined in the config to avoid silently doing something surprising due to mistyped names (applying the wrong configuration or skipping the test due to a `--tag` flag). +Should Vitest throw an error if test has a [`tag`](/config/tags) that is not defined in the config to avoid silently doing something surprising due to mistyped names (applying the wrong configuration or skipping the test due to a `--tags-expr` flag). -Note that Vitest will always throw an error if `--tag` flag defines a tag not present in the config. +Note that Vitest will always throw an error if `--tags-expr` flag defines a tag not present in the config. For examle, this test will throw an error because the tag `fortend` has a typo (it should be `frontend`): diff --git a/docs/config/tags.md b/docs/config/tags.md index 191d7af68993..d0efae8eece2 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -10,14 +10,9 @@ outline: deep Defines all [available tags](/guide/test-tags) in your test project. By default, if test defines a name not listed here, Vitest will throw an error, but this can be configured via a [`strictTags`](/config/stricttags) option. -If you are using [`projects`](/config/projects), they will inherit all global tags automatically. +If you are using [`projects`](/config/projects), they will inherit all global tags definitions automatically. -To filter tags, you can pass them down as [`--tag`](/guide/cli#tag): - -```shell -vitest --tag=frontend --tag=!backend -vitest --tag="unit/*" -``` +Use [`--tags-expr`](/guide/test-tags#syntax) to filter tests by their tags. ::: tip FILTERING You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index c6d701ee419b..b0f8fb1e2049 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -91,7 +91,7 @@ describe('suite', () => { ## Filtering Tags -If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tag` option: +If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tags-expr` option: ```ts test('renders a form', { tags: ['frontend'] }, () => { @@ -104,7 +104,7 @@ test('calls an external API', { tags: ['backend'] }, () => { ``` ```shell -vitest --tag=frontend +vitest --tags-expr=frontend ``` ## Selecting Suites and Tests to Run diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 2ab5d6935c71..e45a1954328c 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -1,5 +1,6 @@ --- title: Test Tags | Guide +outline: deep --- # Test Tags 4.1.0 @@ -61,6 +62,20 @@ tet('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) ``` ::: +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings: + +```ts +declare module 'vitest' { + interface TestTags { + tags: + | 'frontend' + | 'backend' + | 'db' + | 'flaky' + } +} +``` + ## Using Tags in Tests You can apply tags to individual tests or entire suites using the `tags` option: @@ -137,20 +152,20 @@ describe('forms', () => { ## Filtering Tests by Tag -To run only tests with specific tags, use the [`--tag`](/guide/cli#tag) CLI option: +To run only tests with specific tags, use the [`--tags-expr`](/guide/cli#tagsexpr) CLI option: ```shell -vitest --tag=frontend -vitest --tag=frontend --tag=backend +vitest --tags-expr=frontend +vitest --tags-expr="frontend and backend" ``` -If you are using a programmatic API, you can pass down a `tag` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): +If you are using a programmatic API, you can pass down a `tagsExpr` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): ```ts import { startVitest } from 'vitest/node' await startVitest('test', [], { - tag: ['frontend', 'backend'], + tagsExpr: ['frontend and backend'], }) ``` @@ -160,7 +175,7 @@ Or you can create a [test specification](/api/advanced/test-specification) with const specification = vitest.getRootProject().createSpecification( '/path-to-file.js', { - testTags: ['frontend', 'backend'], + testTagsExpr: ['frontend and backend'], }, ) ``` @@ -169,26 +184,69 @@ const specification = vitest.getRootProject().createSpecification( Note that `createSpecification` does not support wildcards and will not validate if the tags are defined in the config. ::: +### Syntax + +You can combine tags in different ways. Vitest supports these keywords: + +- `and` or `&&` to include both expressions +- `or` or `||` to include at least one expression +- `not` or `!` to exclude the expression +- `*` to match any number of characters (0 or more) +- `()` to group expressions and override precedence + +The parser follows standard [operator precedence](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_precedence): `not`/`!` has the highest priority, then `and`/`&&`, then `or`/`||`. Use parentheses to override default precedence. + ### Wildcards You can use a wildcard (`*`) to match any number of characters: ```shell -vitest --tag="unit/*" +vitest --tags-expr="unit/*" ``` This will match tags like `unit/components`, `unit/utils`, etc. ### Excluding Tags -To exclude tests with a specific tag, add an exclamation mark (`!`) at the start: +To exclude tests with a specific tag, add an exclamation mark (`!`) at the start or a "not" keyword: + +```shell +vitest --tags-expr="!slow and not flaky" +``` + +### Examples + +Here are some common filtering patterns: ```shell -vitest --tag=frontend --tag=!slow +# Run only unit tests +vitest --tags-expr="unit" + +# Run tests that are both frontend AND fast +vitest --tags-expr="frontend and fast" + +# Run tests that are either unit OR e2e +vitest --tags-expr="unit or e2e" + +# Run all tests except slow ones +vitest --tags-expr="!slow" + +# Run frontend tests that are not flaky +vitest --tags-expr="frontend && !flaky" + +# Run tests matching a wildcard pattern +vitest --tags-expr="api/*" + +# Complex expression with parentheses +vitest --tags-expr="(unit || e2e) && !slow" + +# Run database tests that are either postgres or mysql, but not slow +vitest --tags-expr="db && (postgres || mysql) && !slow" ``` -This runs all tests tagged with `frontend` except those also tagged with `slow`. Note that wildcard syntax is also supported for excluded tags: +You can also pass multiple `--tags-expr` flags. They are combined with AND logic: ```shell -vitest --tag="!unit/*" +# Run tests that match (unit OR e2e) AND are NOT slow +vitest --tags-expr="unit || e2e" --tags-expr="!slow" ``` diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index cb8659cdf2db..995e2bec1544 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -16,7 +16,7 @@ import { interpretTaskModes, someTasksAreOnly, } from './utils/collect' -import { validateTags } from './utils/tasks' +import { createTagsFilter, validateTags } from './utils/tags' const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now @@ -28,6 +28,7 @@ export async function collectTests( const config = runner.config const $ = runner.trace! + let defaultTagsFilter: ((testTags: string[]) => boolean) | undefined for (const spec of specs) { const filepath = typeof spec === 'string' ? spec : spec.filepath @@ -38,7 +39,9 @@ export async function collectTests( const testLocations = typeof spec === 'string' ? undefined : spec.testLocations const testNamePattern = typeof spec === 'string' ? undefined : spec.testNamePattern const testIds = typeof spec === 'string' ? undefined : spec.testIds - const testTags = typeof spec === 'string' ? undefined : spec.testTags + const testTagsExpr = typeof spec === 'object' && spec.testTagsExpr + ? createTagsFilter(spec.testTagsExpr, config.tags) + : undefined const fileTags: string[] = typeof spec === 'string' ? [] : (spec.fileTags || []) @@ -47,13 +50,13 @@ export async function collectTests( file.tags = fileTags file.shuffle = config.sequence.shuffle - clearCollectorContext(file, runner) - try { validateTags(runner, fileTags) runner.onCollectStart?.(file) + clearCollectorContext(file, runner) + const setupFiles = toArray(config.setupFiles) if (setupFiles.length) { const setupStart = now() @@ -120,7 +123,10 @@ export async function collectTests( testNamePattern ?? config.testNamePattern, testLocations, testIds, - testTags ?? config.tagsFilter, + testTagsExpr + ?? (defaultTagsFilter ??= config.tagsExpr + ? createTagsFilter(config.tagsExpr, config.tags) + : undefined), hasOnlyTasks, false, config.allowOnly, diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index f8994e00ab58..c323d35b3784 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -40,7 +40,8 @@ import { getHooks, setFn, setHooks, setTestFixture } from './map' import { getCurrentTest } from './test-state' import { findTestFileStackTrace } from './utils' import { createChainable } from './utils/chain' -import { createNoTagsError, createTaskName, validateTags } from './utils/tasks' +import { createNoTagsError, validateTags } from './utils/tags' +import { createTaskName } from './utils/tasks' /** * Creates a suite of tests, allowing for grouping and hierarchical organization of tests. @@ -229,12 +230,12 @@ export function clearCollectorContext( file: File, currentRunner: VitestRunner, ): void { + currentTestFilepath = file.filepath + runner = currentRunner if (!defaultSuite) { defaultSuite = createDefaultSuite(currentRunner) } defaultSuite.file = file - runner = currentRunner - currentTestFilepath = file.filepath collectorContext.tasks.length = 0 defaultSuite.clear() collectorContext.currentSuite = defaultSuite @@ -320,7 +321,7 @@ function createSuiteCollector( .map((tag) => { const tagDefinition = runner.config.tags?.find(t => t.name === tag) if (!tagDefinition && runner.config.strictTags) { - throw createNoTagsError(runner, tag) + throw createNoTagsError(runner.config.tags, tag) } return tagDefinition }) diff --git a/packages/runner/src/types.ts b/packages/runner/src/types.ts index c1f2bf4199e5..659a4efd9329 100644 --- a/packages/runner/src/types.ts +++ b/packages/runner/src/types.ts @@ -56,6 +56,7 @@ export type { TestContext, TestFunction, TestOptions, + TestTags, Use, VisualRegressionArtifact, } from './types/tasks' diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 7d7afeda6b08..9c6c3d19526d 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -13,6 +13,7 @@ import type { TestArtifact, TestContext, TestOptions, + TestTags, } from './tasks' /** @@ -42,7 +43,7 @@ export interface VitestRunnerConfig { includeTaskLocation?: boolean diffOptions?: DiffOptions tags: TestTagDefinition[] - tagsFilter?: string[] + tagsExpr?: string[] strictTags: boolean } @@ -56,7 +57,7 @@ export interface FileSpecification { fileTags?: string[] testLocations: number[] | undefined testNamePattern: RegExp | undefined - testTags: string[] | undefined + testTagsExpr: string[] | undefined testIds: string[] | undefined } @@ -64,7 +65,9 @@ export interface TestTagDefinition extends Omit /** * The name of the tag. This is what you use in the `tags` array in tests. */ - name: string + name: keyof TestTags extends never + ? string + : TestTags[keyof TestTags] /** * A description for the tag. This will be shown in the CLI help and UI. */ diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index ba31b415cfb0..df22a30ad810 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -567,9 +567,13 @@ export interface TestOptions { /** * Custom tags of the test. Useful for filtering tests. */ - tags?: string[] | string + tags?: keyof TestTags extends never + ? string[] | string + : TestTags[keyof TestTags] | TestTags[keyof TestTags][] } +export interface TestTags {} + export interface SuiteOptions extends TestOptions { /** * Whether the tasks of the suite run in a random order. diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 309136e60822..6d83f7158bff 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -12,7 +12,7 @@ export function interpretTaskModes( namePattern?: string | RegExp, testLocations?: number[] | undefined, testIds?: string[] | undefined, - testTags?: string[] | undefined, + testTagsExpr?: ((testTags: string[]) => boolean) | undefined, onlyMode?: boolean, parentIsOnly?: boolean, allowOnly?: boolean, @@ -75,8 +75,7 @@ export function interpretTaskModes( if (testIds && !testIds.includes(t.id)) { t.mode = 'skip' } - // match at least one tag - if (testTags && !matchesTags(testTags, t.tags || [])) { + if (testTagsExpr && !testTagsExpr(t.tags || [])) { t.mode = 'skip' } } @@ -243,33 +242,3 @@ export function findTestFileStackTrace(testFilePath: string, error: string): Par } } } - -function matchesTags(filterTags: string[], testTags: string[]) { - if (testTags.length === 0) { - // test has no tags, cannot match any filter - return false - } - - let hasPositiveTag = false - let allNegative = true - for (const tag of filterTags) { - if (tag.startsWith('!')) { - const ignoreTag = tag.slice(1) - if (testTags.includes(ignoreTag)) { - return false - } - } - else { - allNegative = false - - if (testTags.includes(tag)) { - hasPositiveTag = true - } - } - } - // if all tags are negative, and none matched, the test passes - if (hasPositiveTag || allNegative) { - return true - } - return hasPositiveTag -} diff --git a/packages/runner/src/utils/index.ts b/packages/runner/src/utils/index.ts index 81748e5b530e..042068f4fbbe 100644 --- a/packages/runner/src/utils/index.ts +++ b/packages/runner/src/utils/index.ts @@ -10,6 +10,7 @@ export { } from './collect' export { limitConcurrency } from './limit-concurrency' export { partitionSuiteChildren } from './suite' +export { createTagsFilter } from './tags' export { createTaskName, getFullName, diff --git a/packages/runner/src/utils/tags.ts b/packages/runner/src/utils/tags.ts new file mode 100644 index 000000000000..d88754cfa69e --- /dev/null +++ b/packages/runner/src/utils/tags.ts @@ -0,0 +1,280 @@ +import type { TestTagDefinition, VitestRunner } from '../types/runner' + +export function validateTags(runner: VitestRunner, tags: string[]): void { + if (!runner.config.strictTags) { + return + } + + const availableTags = new Set(runner.config.tags.map(tag => tag.name)) + for (const tag of tags) { + if (!availableTags.has(tag)) { + throw createNoTagsError(runner.config.tags, tag) + } + } +} + +export function createNoTagsError(availableTags: TestTagDefinition[], tag: string, prefix = 'tag'): never { + if (!availableTags.length) { + throw new Error(`The Vitest config does't define any "tags", cannot apply "${tag}" ${prefix} for this test. See: https://vitest.dev/guide/test-tags`) + } + throw new Error(`The ${prefix} "${tag}" is not defined in the configuration. Available tags are:\n${availableTags + .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) + .join('\n')}`) +} + +export function createTagsFilter(tagsExpr: string[], availableTags: TestTagDefinition[]): (testTags: string[]) => boolean { + const matchers = tagsExpr.map(expr => parseTagsExpression(expr, availableTags)) + return (testTags: string[]) => { + return matchers.every(matcher => matcher(testTags)) + } +} + +type TagMatcher = (tags: string[]) => boolean + +function parseTagsExpression(expr: string, availableTags: TestTagDefinition[]): TagMatcher { + const tokens = tokenize(expr) + const stream = new TokenStream(tokens, expr) + const ast = parseOrExpression(stream, availableTags) + if (stream.peek().type !== 'EOF') { + throw new Error(`Invalid tags expression: unexpected "${formatToken(stream.peek())}" in "${expr}"`) + } + return (tags: string[]) => evaluateNode(ast, tags) +} + +function formatToken(token: Token): string { + switch (token.type) { + case 'TAG': return token.value + case 'AND': return 'and' + case 'OR': return 'or' + case 'NOT': return 'not' + case 'LPAREN': return '(' + case 'RPAREN': return ')' + case 'EOF': return 'end of expression' + } +} + +type Token + = | { type: 'TAG'; value: string } + | { type: 'AND' } + | { type: 'OR' } + | { type: 'NOT' } + | { type: 'LPAREN' } + | { type: 'RPAREN' } + | { type: 'EOF' } + +function tokenize(expr: string): Token[] { + const tokens: Token[] = [] + let i = 0 + + while (i < expr.length) { + if (expr[i] === ' ' || expr[i] === '\t') { + i++ + continue + } + + if (expr[i] === '(') { + tokens.push({ type: 'LPAREN' }) + i++ + continue + } + + if (expr[i] === ')') { + tokens.push({ type: 'RPAREN' }) + i++ + continue + } + + if (expr[i] === '!') { + tokens.push({ type: 'NOT' }) + i++ + continue + } + + if (expr.slice(i, i + 2) === '&&') { + tokens.push({ type: 'AND' }) + i += 2 + continue + } + + if (expr.slice(i, i + 2) === '||') { + tokens.push({ type: 'OR' }) + i += 2 + continue + } + + if (/^and(?:\s|\)|$)/i.test(expr.slice(i))) { + tokens.push({ type: 'AND' }) + i += 3 + continue + } + + if (/^or(?:\s|\)|$)/i.test(expr.slice(i))) { + tokens.push({ type: 'OR' }) + i += 2 + continue + } + + if (/^not\s/i.test(expr.slice(i))) { + tokens.push({ type: 'NOT' }) + i += 3 + continue + } + + let tag = '' + while (i < expr.length && expr[i] !== ' ' && expr[i] !== '\t' && expr[i] !== '(' && expr[i] !== ')' && expr[i] !== '!' && expr[i] !== '&' && expr[i] !== '|') { + const remaining = expr.slice(i) + if (/^and(?:\s|\)|$)/i.test(remaining) || /^or(?:\s|\)|$)/i.test(remaining) || /^not\s/i.test(remaining)) { + break + } + tag += expr[i] + i++ + } + + if (tag) { + tokens.push({ type: 'TAG', value: tag }) + } + } + + tokens.push({ type: 'EOF' }) + return tokens +} + +type ASTNode + = | { type: 'tag'; value: string; pattern: RegExp | null } + | { type: 'not'; operand: ASTNode } + | { type: 'and'; left: ASTNode; right: ASTNode } + | { type: 'or'; left: ASTNode; right: ASTNode } + +class TokenStream { + private pos = 0 + constructor(private tokens: Token[], public expr: string) {} + + peek(): Token { + return this.tokens[this.pos] + } + + next(): Token { + return this.tokens[this.pos++] + } + + expect(type: Token['type']): Token { + const token = this.next() + if (token.type !== type) { + if (type === 'RPAREN' && token.type === 'EOF') { + throw new Error(`Invalid tags expression: missing closing ")" in "${this.expr}"`) + } + throw new Error(`Invalid tags expression: expected "${formatTokenType(type)}" but got "${formatToken(token)}" in "${this.expr}"`) + } + return token + } + + unexpectedToken(): never { + const token = this.peek() + if (token.type === 'EOF') { + throw new Error(`Invalid tags expression: unexpected end of expression in "${this.expr}"`) + } + throw new Error(`Invalid tags expression: unexpected "${formatToken(token)}" in "${this.expr}"`) + } +} + +function formatTokenType(type: Token['type']): string { + switch (type) { + case 'TAG': return 'tag' + case 'AND': return 'and' + case 'OR': return 'or' + case 'NOT': return 'not' + case 'LPAREN': return '(' + case 'RPAREN': return ')' + case 'EOF': return 'end of expression' + } +} + +function parseOrExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { + let left = parseAndExpression(stream, availableTags) + + while (stream.peek().type === 'OR') { + stream.next() + const right = parseAndExpression(stream, availableTags) + left = { type: 'or', left, right } + } + + return left +} + +function parseAndExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { + let left = parseUnaryExpression(stream, availableTags) + + while (stream.peek().type === 'AND') { + stream.next() + const right = parseUnaryExpression(stream, availableTags) + left = { type: 'and', left, right } + } + + return left +} + +function parseUnaryExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { + if (stream.peek().type === 'NOT') { + stream.next() + const operand = parseUnaryExpression(stream, availableTags) + return { type: 'not', operand } + } + + return parsePrimaryExpression(stream, availableTags) +} + +function parsePrimaryExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { + const token = stream.peek() + + if (token.type === 'LPAREN') { + stream.next() + const expr = parseOrExpression(stream, availableTags) + stream.expect('RPAREN') + return expr + } + + if (token.type === 'TAG') { + stream.next() + const tagValue = token.value + const pattern = resolveTagPattern(tagValue, availableTags) + return { type: 'tag', value: tagValue, pattern } + } + + stream.unexpectedToken() +} + +function createWildcardRegex(pattern: string): RegExp { + return new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`) +} + +function resolveTagPattern(tagPattern: string, availableTags: TestTagDefinition[]): RegExp | null { + if (tagPattern.includes('*')) { + const regex = createWildcardRegex(tagPattern) + const hasMatch = availableTags.some(tag => regex.test(tag.name)) + if (!hasMatch) { + throw createNoTagsError(availableTags, tagPattern, 'tag pattern') + } + return regex + } + + if (!availableTags.length || !availableTags.some(tag => tag.name === tagPattern)) { + throw createNoTagsError(availableTags, tagPattern, 'tag pattern') + } + return null +} + +function evaluateNode(node: ASTNode, tags: string[]): boolean { + switch (node.type) { + case 'tag': + if (node.pattern) { + return tags.some(tag => node.pattern!.test(tag)) + } + return tags.includes(node.value) + case 'not': + return !evaluateNode(node.operand, tags) + case 'and': + return evaluateNode(node.left, tags) && evaluateNode(node.right, tags) + case 'or': + return evaluateNode(node.left, tags) || evaluateNode(node.right, tags) + } +} diff --git a/packages/runner/src/utils/tasks.ts b/packages/runner/src/utils/tasks.ts index c3173610b3db..e7a3b55529ea 100644 --- a/packages/runner/src/utils/tasks.ts +++ b/packages/runner/src/utils/tasks.ts @@ -1,5 +1,4 @@ import type { Arrayable } from '@vitest/utils' -import type { VitestRunner } from '../types/runner' import type { Suite, Task, Test } from '../types/tasks' import { toArray } from '@vitest/utils/helpers' @@ -85,25 +84,3 @@ export function getTestName(task: Task, separator = ' > '): string { export function createTaskName(names: readonly (string | undefined)[], separator = ' > '): string { return names.filter(name => name !== undefined).join(separator) } - -export function validateTags(runner: VitestRunner, tags: string[]): void { - if (!runner.config.strictTags) { - return - } - - const availableTags = new Set(runner.config.tags.map(tag => tag.name)) - for (const tag of tags) { - if (!availableTags.has(tag)) { - throw createNoTagsError(runner, tag) - } - } -} - -export function createNoTagsError(runner: VitestRunner, tag: string): never { - if (!runner.config.tags.length) { - throw new Error(`The Vitest config does't define any "tags", cannot apply "${tag}" tag for this test. See: https://vitest.dev/guide/test-tags`) - } - throw new Error(`Tag "${tag}" is not defined in the configuration. Available tags are:\n${runner.config.tags - .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) - .join('\n')}`) -} diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index f2e382804c21..bbd336fd4a82 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -788,9 +788,9 @@ export const cliOptionsConfig: VitestCLIOptions = { clearCache: { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', }, - tag: { - description: 'Run only tests with the specified tags. Multiple tags can be specified by repeating the option. To exclude tags, prefix the tag with an exclamation mark (!). Use "*" in the name as a wildcard to match any sequence of characters, e.g., "unit/*".', - argument: '', + tagsExpr: { + description: 'Run only tests with the specified tags. You can use logical operators `&&` (and), `||` (or) and `!` (not) to create complex expressions, see: https://vitest.dev/guide/test-tags#syntax for more information.', + argument: '', array: true, }, strictTags: { diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index c4b722cbff37..c8e0968d8df5 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -173,6 +173,9 @@ export function resolveConfig( if (!tag.name || typeof tag.name !== 'string') { throw new Error(`Each tag defined in "test.tags" must have a "name" property, received: ${JSON.stringify(tag)}`) } + if (tag.name.match(/\s/)) { + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain spaces.`) + } if (tag.name.startsWith('!')) { throw new Error(`Tag name "${tag.name}" cannot start with "!".`) } diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index fa556bdc05a9..79733b5b294a 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -136,7 +136,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { openTelemetry: config.experimental.openTelemetry, }, tags: config.tags || [], - tagsFilter: config.tag, + tagsExpr: config.tagsExpr, strictTags: config.strictTags ?? true, } } diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index ec698eb44fd2..8473b257436c 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -153,7 +153,7 @@ export function createPool(ctx: Vitest): ProcessPool { testLocations: spec.testLines, testNamePattern: spec.testNamePattern, testIds: spec.testIds, - testTags: spec.testTags, + testTags: spec.testTagsExpr, })), invalidates, providedContext: project.getProvidedContext(), diff --git a/packages/vitest/src/node/test-specification.ts b/packages/vitest/src/node/test-specification.ts index 835b51984dfa..f1211c28eaa8 100644 --- a/packages/vitest/src/node/test-specification.ts +++ b/packages/vitest/src/node/test-specification.ts @@ -9,7 +9,7 @@ export interface TestSpecificationOptions { testNamePattern?: RegExp testIds?: string[] testLines?: number[] - testTags?: string[] + testTagsExpr?: string[] } export class TestSpecification { @@ -45,7 +45,7 @@ export class TestSpecification { /** * The tags of tests to run. */ - public readonly testTags: string[] | undefined + public readonly testTagsExpr: string[] | undefined /** * This class represents a test suite for a test module within a single project. @@ -78,7 +78,7 @@ export class TestSpecification { this.testLines = testLinesOrOptions.testLines this.testNamePattern = testLinesOrOptions.testNamePattern this.testIds = testLinesOrOptions.testIds - this.testTags = testLinesOrOptions.testTags + this.testTagsExpr = testLinesOrOptions.testTagsExpr } } @@ -105,6 +105,7 @@ export class TestSpecification { testLines: this.testLines, testIds: this.testIds, testNamePattern: this.testNamePattern, + testTagsExpr: this.testTagsExpr, }, ] } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index ef8f4bbc8f32..2faef92465c5 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1014,7 +1014,7 @@ export interface UserConfig extends InlineConfig { /** * Tags to filter tests with. */ - tag?: string[] + tagsExpr?: string[] } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void @@ -1047,7 +1047,7 @@ export interface ResolvedConfig | 'name' | 'vmMemoryLimit' | 'fileParallelism' - | 'tag' + | 'tagsExpr' > { mode: VitestRunMode @@ -1118,7 +1118,7 @@ export interface ResolvedConfig vmMemoryLimit?: UserConfig['vmMemoryLimit'] dumpDir?: string - tag?: string[] + tagsExpr?: string[] } type NonProjectOptions diff --git a/packages/vitest/src/public/index.ts b/packages/vitest/src/public/index.ts index 4becb8f98761..466ef4af614c 100644 --- a/packages/vitest/src/public/index.ts +++ b/packages/vitest/src/public/index.ts @@ -140,8 +140,9 @@ export type { TestContext, TestFunction, TestOptions, - VitestRunnerConfig as TestRunnerConfig, + + TestTags, VitestRunner as VitestTestRunner, } from '@vitest/runner' diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 4dd7998951ad..2b98e36bbaa9 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -127,7 +127,7 @@ export interface SerializedConfig { } | undefined } tags: TestTagDefinition[] - tagsFilter: string[] | undefined + tagsExpr: string[] | undefined strictTags: boolean } diff --git a/packages/vitest/src/runtime/types/utils.ts b/packages/vitest/src/runtime/types/utils.ts index c7a4b5fdec6f..ef26a5c146da 100644 --- a/packages/vitest/src/runtime/types/utils.ts +++ b/packages/vitest/src/runtime/types/utils.ts @@ -6,5 +6,6 @@ export type SerializedTestSpecification = [ testLines?: number[] | undefined testIds?: string[] | undefined testNamePattern?: RegExp | undefined + testTagsExpr?: string[] | undefined }, ] diff --git a/test/cli/fixtures/test-tags/basic.test.ts b/test/cli/fixtures/test-tags/basic.test.ts index e19c61778cd7..4e21178d8dea 100644 --- a/test/cli/fixtures/test-tags/basic.test.ts +++ b/test/cli/fixtures/test-tags/basic.test.ts @@ -4,8 +4,8 @@ describe('suite 1', { tags: ['suite', 'alone'] }, () => { test('test 1', () => {}) test('test 2', { tags: ['test'] }, () => {}) - describe('suite 2', { tags: ['suite 2', 'suite'] }, () => { + describe('suite 2', { tags: ['suite_2', 'suite'] }, () => { test('test 3', () => {}) - test('test 4', { tags: ['test 2'] }, () => {}) + test('test 4', { tags: ['test_2'] }, () => {}) }) }) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index ac7c5e94b78a..4f2117920ea8 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -10,8 +10,8 @@ test('vitest records tags', async () => { { name: 'alone' }, { name: 'suite' }, { name: 'test' }, - { name: 'suite 2' }, - { name: 'test 2' }, + { name: 'suite_2' }, + { name: 'test_2' }, ], }) @@ -24,13 +24,13 @@ test('vitest records tags', async () => { "test 3": [ "suite", "alone", - "suite 2", + "suite_2", ], "test 4": [ "suite", "alone", - "suite 2", - "test 2", + "suite_2", + "test_2", ], }, "test 1": [ @@ -48,7 +48,7 @@ test('vitest records tags', async () => { `) }) -test('filters tests based on --tag=!ignore', async () => { +test('filters tests based on --tags-expr=!ignore', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -56,10 +56,10 @@ test('filters tests based on --tag=!ignore', async () => { { name: 'alone' }, { name: 'suite' }, { name: 'test' }, - { name: 'suite 2' }, - { name: 'test 2' }, + { name: 'suite_2' }, + { name: 'test_2' }, ], - tag: ['!suite 2'], + tagsExpr: ['!suite_2'], }) expect(stderr).toBe('') @@ -79,7 +79,7 @@ test('filters tests based on --tag=!ignore', async () => { `) }) -test('filters tests based on --tag=!ignore and --tag=include', async () => { +test('filters tests based on --tags-expr=!ignore and --tags-expr=include', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -87,10 +87,10 @@ test('filters tests based on --tag=!ignore and --tag=include', async () => { { name: 'alone' }, { name: 'suite' }, { name: 'test' }, - { name: 'suite 2' }, - { name: 'test 2' }, + { name: 'suite_2' }, + { name: 'test_2' }, ], - tag: ['!suite 2', 'test'], + tagsExpr: ['!suite_2', 'test'], }) expect(stderr).toBe('') @@ -110,7 +110,7 @@ test('filters tests based on --tag=!ignore and --tag=include', async () => { `) }) -test('filters tests based on --tag=include', async () => { +test('filters tests based on --tags-expr=include', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -118,10 +118,10 @@ test('filters tests based on --tag=include', async () => { { name: 'alone' }, { name: 'suite' }, { name: 'test' }, - { name: 'suite 2' }, - { name: 'test 2' }, + { name: 'suite_2' }, + { name: 'test_2' }, ], - tag: ['test*'], + tagsExpr: ['test*'], }) expect(stderr).toBe('') @@ -131,17 +131,17 @@ test('filters tests based on --tag=include', async () => { "suite 1": { "suite 2": { "test 3": "skipped", - "test 4": "skipped", + "test 4": "passed", }, "test 1": "skipped", - "test 2": "skipped", + "test 2": "passed", }, }, } `) }) -test.skip('throws an error if no tags are defined in the config, but in the test', async () => { +test('throws an error if no tags are defined in the config, but in the test', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': ` @@ -169,7 +169,7 @@ test.skip('throws an error if no tags are defined in the config, but in the test `) }) -test.skip('throws an error if tag is not defined in the config, but in the test', async () => { +test('throws an error if tag is not defined in the config, but in the test', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': ` @@ -187,7 +187,7 @@ test.skip('throws an error if tag is not defined in the config, but in the test' ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL basic.test.js [ basic.test.js ] - Error: Tag "unknown" is not defined in the configuration. Available tags are: + Error: The tag "unknown" is not defined in the configuration. Available tags are: - known ❯ basic.test.js:2:9 1| @@ -201,23 +201,23 @@ test.skip('throws an error if tag is not defined in the config, but in the test' `) }) -test.skip('throws an error if tag is not defined in the config, but in --tag filter', async () => { +test('throws an error if tag is not defined in the config, but in --tags-expr filter', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': '', }, { - tag: ['unknown'], + tagsExpr: ['unknown'], }, { fails: true }, ) - expect(stderr).toContain('Cannot find any tags to filter based on the --tag unknown option. Did you define them in "test.tags" in your config?') + expect(stderr).toContain('The Vitest config does\'t define any "tags", cannot apply "unknown" tag pattern for this test. See: https://vitest.dev/guide/test-tags') }) test.todo('defining a tag available only in one project', async () => { await runVitest({ config: false, - tag: ['project-2-tag'], + tagsExpr: ['project-2-tag'], projects: [ { test: { @@ -241,8 +241,8 @@ test('can specify custom options for tags', async () => { { name: 'alone' }, { name: 'suite', timeout: 1000 }, { name: 'test', retry: 2, skip: true }, - { name: 'suite 2', repeats: 3 }, - { name: 'test 2', timeout: 500, retry: 1 }, + { name: 'suite_2', repeats: 3 }, + { name: 'test_2', timeout: 500, retry: 1 }, ], }) expect(stderr).toBe('') @@ -257,7 +257,7 @@ test('can specify custom options for tags', async () => { "tags": [ "suite", "alone", - "suite 2", + "suite_2", ], "timeout": 1000, }, @@ -268,8 +268,8 @@ test('can specify custom options for tags', async () => { "tags": [ "suite", "alone", - "suite 2", - "test 2", + "suite_2", + "test_2", ], "timeout": 500, }, @@ -320,8 +320,8 @@ test('can specify custom options with priorities for tags', async () => { fails: true, priority: 2, }, - { name: 'suite 2' }, - { name: 'test 2' }, + { name: 'suite_2' }, + { name: 'test_2' }, ], }) @@ -450,7 +450,7 @@ test('invalid @tag throws and error', async () => { ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ FAIL error-file-tags.test.ts [ error-file-tags.test.ts ] - Error: Tag "invalid" is not defined in the configuration. Available tags are: + Error: The tag "invalid" is not defined in the configuration. Available tags are: - file - file-2 - file/slash diff --git a/test/core/test/test-tags-filter.test.ts b/test/core/test/test-tags-filter.test.ts new file mode 100644 index 000000000000..49e36137e160 --- /dev/null +++ b/test/core/test/test-tags-filter.test.ts @@ -0,0 +1,443 @@ +import type { TestTagDefinition } from '@vitest/runner' +import { createTagsFilter } from '@vitest/runner/utils' +import { describe, expect, test } from 'vitest' + +function tags(...names: string[]): TestTagDefinition[] { + return names.map(name => ({ name })) +} + +describe('createTagsFilter', () => { + describe('simple tag matching', () => { + test('matches a single tag', () => { + const filter = createTagsFilter(['foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(false) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter([])).toBe(false) + }) + + test('matches multiple expressions (AND between expressions)', () => { + const filter = createTagsFilter(['foo', 'bar'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(false) + expect(filter(['foo', 'bar', 'baz'])).toBe(true) + }) + }) + + describe('NOT operator', () => { + test('negates with ! prefix', () => { + const filter = createTagsFilter(['!foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + expect(filter([])).toBe(true) + }) + + test('negates with "not" keyword', () => { + const filter = createTagsFilter(['not foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + expect(filter([])).toBe(true) + }) + + test('double negation', () => { + const filter = createTagsFilter(['!!foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(false) + }) + + test('double negation with "not not"', () => { + const filter = createTagsFilter(['not not foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(false) + }) + }) + + describe('AND operator', () => { + test('matches with "and" keyword', () => { + const filter = createTagsFilter(['foo and bar'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(false) + expect(filter(['foo', 'bar', 'baz'])).toBe(true) + }) + + test('matches with && operator', () => { + const filter = createTagsFilter(['foo && bar'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(false) + }) + + test('chained AND operators', () => { + const filter = createTagsFilter(['foo and bar and baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar', 'baz'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + expect(filter(['foo', 'baz'])).toBe(false) + }) + + test('chained && operators', () => { + const filter = createTagsFilter(['foo && bar && baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar', 'baz'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + }) + }) + + describe('OR operator', () => { + test('matches with "or" keyword', () => { + const filter = createTagsFilter(['foo or bar'], tags('foo', 'bar', 'baz')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(true) + expect(filter(['baz'])).toBe(false) + expect(filter(['foo', 'bar'])).toBe(true) + }) + + test('matches with || operator', () => { + const filter = createTagsFilter(['foo || bar'], tags('foo', 'bar', 'baz')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(true) + expect(filter(['baz'])).toBe(false) + }) + + test('chained OR operators', () => { + const filter = createTagsFilter(['foo or bar or baz'], tags('foo', 'bar', 'baz', 'qux')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + expect(filter(['qux'])).toBe(false) + }) + + test('chained || operators', () => { + const filter = createTagsFilter(['foo || bar || baz'], tags('foo', 'bar', 'baz', 'qux')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + expect(filter(['qux'])).toBe(false) + }) + }) + + describe('operator precedence', () => { + test('AND has higher precedence than OR', () => { + const filter = createTagsFilter(['foo or bar and baz'], tags('foo', 'bar', 'baz')) + // Parsed as: foo or (bar and baz) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar', 'baz'])).toBe(true) + expect(filter(['bar'])).toBe(false) + expect(filter(['baz'])).toBe(false) + }) + + test('&& has higher precedence than ||', () => { + const filter = createTagsFilter(['foo || bar && baz'], tags('foo', 'bar', 'baz')) + // Parsed as: foo || (bar && baz) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar', 'baz'])).toBe(true) + expect(filter(['bar'])).toBe(false) + }) + + test('NOT has highest precedence', () => { + const filter = createTagsFilter(['!foo and bar'], tags('foo', 'bar')) + // Parsed as: (!foo) and bar + expect(filter(['bar'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + expect(filter(['foo'])).toBe(false) + }) + }) + + describe('parentheses', () => { + test('overrides precedence with parentheses', () => { + const filter = createTagsFilter(['(foo or bar) and baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'baz'])).toBe(true) + expect(filter(['bar', 'baz'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['baz'])).toBe(false) + }) + + test('nested parentheses', () => { + const filter = createTagsFilter(['((foo or bar) and baz) or qux'], tags('foo', 'bar', 'baz', 'qux')) + expect(filter(['foo', 'baz'])).toBe(true) + expect(filter(['bar', 'baz'])).toBe(true) + expect(filter(['qux'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['baz'])).toBe(false) + }) + + test('negation with parentheses', () => { + const filter = createTagsFilter(['!(foo or bar)'], tags('foo', 'bar', 'baz')) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(false) + expect(filter(['baz'])).toBe(true) + expect(filter([])).toBe(true) + }) + + test('complex expression with parentheses', () => { + const filter = createTagsFilter(['(foo && bar) || (baz && !qux)'], tags('foo', 'bar', 'baz', 'qux')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + expect(filter(['baz', 'qux'])).toBe(false) + expect(filter(['foo'])).toBe(false) + }) + }) + + describe('wildcard patterns', () => { + test('matches with * wildcard', () => { + const filter = createTagsFilter(['test*'], tags('test', 'test-unit', 'test-e2e', 'other')) + expect(filter(['test-unit'])).toBe(true) + expect(filter(['test-e2e'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('wildcard matches zero or more characters', () => { + const filter = createTagsFilter(['test*'], tags('test', 'test-unit')) + // * matches zero or more characters + expect(filter(['test'])).toBe(true) + expect(filter(['test-unit'])).toBe(true) + }) + + test('wildcard at start', () => { + const filter = createTagsFilter(['*-unit'], tags('test-unit', 'e2e-unit', 'integration')) + expect(filter(['test-unit'])).toBe(true) + expect(filter(['e2e-unit'])).toBe(true) + expect(filter(['integration'])).toBe(false) + }) + + test('wildcard in middle', () => { + const filter = createTagsFilter(['test-*-fast'], tags('test-unit-fast', 'test-e2e-fast', 'test-slow')) + expect(filter(['test-unit-fast'])).toBe(true) + expect(filter(['test-e2e-fast'])).toBe(true) + expect(filter(['test-slow'])).toBe(false) + }) + + test('multiple wildcards', () => { + const filter = createTagsFilter(['*test*'], tags('unit-test-fast', 'test-e2e', 'other')) + expect(filter(['unit-test-fast'])).toBe(true) + expect(filter(['test-e2e'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('wildcard with negation', () => { + const filter = createTagsFilter(['!test*'], tags('test-unit', 'other')) + expect(filter(['test-unit'])).toBe(false) + expect(filter(['other'])).toBe(true) + }) + + test('wildcard with AND', () => { + const filter = createTagsFilter(['test* and fast'], tags('test-unit', 'fast', 'slow')) + expect(filter(['test-unit', 'fast'])).toBe(true) + expect(filter(['test-unit', 'slow'])).toBe(false) + expect(filter(['fast'])).toBe(false) + }) + + test('wildcard with OR', () => { + const filter = createTagsFilter(['test* or fast'], tags('test-unit', 'fast', 'slow')) + expect(filter(['test-unit'])).toBe(true) + expect(filter(['fast'])).toBe(true) + expect(filter(['slow'])).toBe(false) + }) + }) + + describe('mixed operators', () => { + test('mixing "and" and &&', () => { + const filter = createTagsFilter(['foo and bar && baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar', 'baz'])).toBe(true) + expect(filter(['foo', 'bar'])).toBe(false) + }) + + test('mixing "or" and ||', () => { + const filter = createTagsFilter(['foo or bar || baz'], tags('foo', 'bar', 'baz', 'qux')) + expect(filter(['foo'])).toBe(true) + expect(filter(['bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + expect(filter(['qux'])).toBe(false) + }) + + test('mixing ! and "not"', () => { + const filter = createTagsFilter(['!foo and not bar'], tags('foo', 'bar', 'baz')) + expect(filter(['baz'])).toBe(true) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(false) + }) + }) + + describe('case sensitivity', () => { + test('operators are case-insensitive', () => { + const filter = createTagsFilter(['foo AND bar OR baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + }) + + test('NOT is case-insensitive', () => { + const filter = createTagsFilter(['NOT foo'], tags('foo', 'bar')) + expect(filter(['foo'])).toBe(false) + expect(filter(['bar'])).toBe(true) + }) + + test('tag names are case-sensitive', () => { + const filter = createTagsFilter(['Foo'], tags('Foo', 'foo')) + expect(filter(['Foo'])).toBe(true) + expect(filter(['foo'])).toBe(false) + }) + }) + + describe('whitespace handling', () => { + test('handles extra whitespace', () => { + const filter = createTagsFilter([' foo and bar '], tags('foo', 'bar')) + expect(filter(['foo', 'bar'])).toBe(true) + }) + + test('handles tabs', () => { + const filter = createTagsFilter(['foo\tand\tbar'], tags('foo', 'bar')) + expect(filter(['foo', 'bar'])).toBe(true) + }) + + test('handles no whitespace with && and ||', () => { + const filter = createTagsFilter(['foo&&bar||baz'], tags('foo', 'bar', 'baz')) + expect(filter(['foo', 'bar'])).toBe(true) + expect(filter(['baz'])).toBe(true) + }) + }) + + describe('tags with special characters', () => { + test('tags with hyphens', () => { + const filter = createTagsFilter(['test-unit'], tags('test-unit', 'test-e2e')) + expect(filter(['test-unit'])).toBe(true) + expect(filter(['test-e2e'])).toBe(false) + }) + + test('tags with underscores', () => { + const filter = createTagsFilter(['test_unit'], tags('test_unit', 'test_e2e')) + expect(filter(['test_unit'])).toBe(true) + expect(filter(['test_e2e'])).toBe(false) + }) + + test('tags with slashes', () => { + const filter = createTagsFilter(['scope/tag'], tags('scope/tag', 'other')) + expect(filter(['scope/tag'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tags with dots', () => { + const filter = createTagsFilter(['v1.0'], tags('v1.0', 'v2.0')) + expect(filter(['v1.0'])).toBe(true) + expect(filter(['v2.0'])).toBe(false) + }) + }) + + describe('edge cases', () => { + test('empty test tags array', () => { + const filter = createTagsFilter(['foo'], tags('foo')) + expect(filter([])).toBe(false) + }) + + test('empty expressions array returns true for any tags', () => { + const filter = createTagsFilter([], tags('foo')) + expect(filter(['foo'])).toBe(true) + expect(filter([])).toBe(true) + }) + + test('tag that looks like an operator but is not', () => { + const filter = createTagsFilter(['android'], tags('android', 'ios')) + expect(filter(['android'])).toBe(true) + expect(filter(['ios'])).toBe(false) + }) + + test('tag that starts with "or" but is not OR', () => { + const filter = createTagsFilter(['orange'], tags('orange', 'apple')) + expect(filter(['orange'])).toBe(true) + expect(filter(['apple'])).toBe(false) + }) + + test('tag that starts with "and" but is not AND', () => { + const filter = createTagsFilter(['android'], tags('android', 'ios')) + expect(filter(['android'])).toBe(true) + }) + + test('tag that starts with "not" but is not NOT', () => { + const filter = createTagsFilter(['nothing'], tags('nothing', 'something')) + expect(filter(['nothing'])).toBe(true) + expect(filter(['something'])).toBe(false) + }) + }) + + describe('validation errors', () => { + test('throws error for unknown tag', () => { + expect(() => createTagsFilter(['unknown'], tags('foo', 'bar'))).toThrow( + 'The tag pattern "unknown" is not defined in the configuration', + ) + }) + + test('throws error for unknown tag in expression', () => { + expect(() => createTagsFilter(['foo and unknown'], tags('foo', 'bar'))).toThrow( + 'The tag pattern "unknown" is not defined in the configuration', + ) + }) + + test('throws error when no tags defined', () => { + expect(() => createTagsFilter(['foo'], [])).toThrow( + 'The Vitest config does\'t define any "tags"', + ) + }) + + test('throws error for wildcard pattern that matches nothing', () => { + expect(() => createTagsFilter(['xyz*'], tags('foo', 'bar'))).toThrow( + 'The tag pattern "xyz*" is not defined in the configuration', + ) + }) + }) + + describe('parser errors', () => { + test('throws error for unclosed parenthesis', () => { + expect(() => createTagsFilter(['(foo and bar'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: missing closing ")" in "(foo and bar"]`) + }) + + test('throws error for unexpected closing parenthesis', () => { + expect(() => createTagsFilter(['foo and bar)'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected ")" in "foo and bar)"]`) + }) + + test('throws error for empty parentheses', () => { + expect(() => createTagsFilter(['()'], tags('foo'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected ")" in "()"]`) + }) + + test('throws error for operator without operand', () => { + expect(() => createTagsFilter(['foo and'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected end of expression in "foo and"]`) + }) + + test('throws error for leading operator', () => { + expect(() => createTagsFilter(['and foo'], tags('foo'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected "and" in "and foo"]`) + }) + }) + + describe('complex real-world scenarios', () => { + test('filter slow tests but include critical', () => { + const filter = createTagsFilter(['!slow or critical'], tags('slow', 'fast', 'critical')) + expect(filter(['fast'])).toBe(true) + expect(filter(['slow'])).toBe(false) + expect(filter(['slow', 'critical'])).toBe(true) + }) + + test('run only unit tests that are not flaky', () => { + const filter = createTagsFilter(['unit && !flaky'], tags('unit', 'e2e', 'flaky')) + expect(filter(['unit'])).toBe(true) + expect(filter(['unit', 'flaky'])).toBe(false) + expect(filter(['e2e'])).toBe(false) + }) + + test('run browser tests for chrome or firefox but not edge', () => { + const filter = createTagsFilter(['browser && (chrome || firefox) && !edge'], tags('browser', 'chrome', 'firefox', 'edge')) + expect(filter(['browser', 'chrome'])).toBe(true) + expect(filter(['browser', 'firefox'])).toBe(true) + expect(filter(['browser', 'edge'])).toBe(false) + expect(filter(['chrome'])).toBe(false) + }) + + test('multiple filter expressions act as AND', () => { + const filter = createTagsFilter(['unit || e2e', '!slow'], tags('unit', 'e2e', 'slow', 'fast')) + expect(filter(['unit'])).toBe(true) + expect(filter(['e2e'])).toBe(true) + expect(filter(['unit', 'slow'])).toBe(false) + expect(filter(['e2e', 'slow'])).toBe(false) + expect(filter(['fast'])).toBe(false) + }) + }) +}) From 9378016d30974a1ec6590f8c9f734711ff26a481 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:17:06 +0100 Subject: [PATCH 12/48] refactor: rename to tagsFilter --- docs/config/stricttags.md | 4 +-- docs/config/tags.md | 2 +- docs/guide/filtering.md | 4 +-- docs/guide/test-tags.md | 36 +++++++++---------- packages/runner/src/collect.ts | 10 +++--- packages/runner/src/types/runner.ts | 4 +-- packages/runner/src/utils/collect.ts | 4 +-- packages/vitest/src/node/cli/cli-config.ts | 2 +- .../vitest/src/node/config/serializeConfig.ts | 2 +- packages/vitest/src/node/pool.ts | 2 +- .../vitest/src/node/test-specification.ts | 8 ++--- packages/vitest/src/node/types/config.ts | 6 ++-- packages/vitest/src/runtime/config.ts | 2 +- packages/vitest/src/runtime/types/utils.ts | 2 +- test/cli/test/test-tags.test.ts | 18 +++++----- 15 files changed, 53 insertions(+), 53 deletions(-) diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md index dd56dc191139..53710f46bcba 100644 --- a/docs/config/stricttags.md +++ b/docs/config/stricttags.md @@ -9,9 +9,9 @@ outline: deep - **Default:** `true` - **CLI:** `--strict-tags`, `--no-strict-tags` -Should Vitest throw an error if test has a [`tag`](/config/tags) that is not defined in the config to avoid silently doing something surprising due to mistyped names (applying the wrong configuration or skipping the test due to a `--tags-expr` flag). +Should Vitest throw an error if test has a [`tag`](/config/tags) that is not defined in the config to avoid silently doing something surprising due to mistyped names (applying the wrong configuration or skipping the test due to a `--tags-filter` flag). -Note that Vitest will always throw an error if `--tags-expr` flag defines a tag not present in the config. +Note that Vitest will always throw an error if `--tags-filter` flag defines a tag not present in the config. For examle, this test will throw an error because the tag `fortend` has a typo (it should be `frontend`): diff --git a/docs/config/tags.md b/docs/config/tags.md index d0efae8eece2..24888db7fed2 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -12,7 +12,7 @@ Defines all [available tags](/guide/test-tags) in your test project. By default, If you are using [`projects`](/config/projects), they will inherit all global tags definitions automatically. -Use [`--tags-expr`](/guide/test-tags#syntax) to filter tests by their tags. +Use [`--tags-filter`](/guide/test-tags#syntax) to filter tests by their tags. ::: tip FILTERING You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index b0f8fb1e2049..93e8aa13d006 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -91,7 +91,7 @@ describe('suite', () => { ## Filtering Tags -If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tags-expr` option: +If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tags-filter` option: ```ts test('renders a form', { tags: ['frontend'] }, () => { @@ -104,7 +104,7 @@ test('calls an external API', { tags: ['backend'] }, () => { ``` ```shell -vitest --tags-expr=frontend +vitest --tags-filter=frontend ``` ## Selecting Suites and Tests to Run diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index e45a1954328c..19599f93c292 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -152,20 +152,20 @@ describe('forms', () => { ## Filtering Tests by Tag -To run only tests with specific tags, use the [`--tags-expr`](/guide/cli#tagsexpr) CLI option: +To run only tests with specific tags, use the [`--tags-filter`](/guide/cli#tagsfilter) CLI option: ```shell -vitest --tags-expr=frontend -vitest --tags-expr="frontend and backend" +vitest --tags-filter=frontend +vitest --tags-filter="frontend and backend" ``` -If you are using a programmatic API, you can pass down a `tagsExpr` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): +If you are using a programmatic API, you can pass down a `tagsFilter` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): ```ts import { startVitest } from 'vitest/node' await startVitest('test', [], { - tagsExpr: ['frontend and backend'], + tagsFilter: ['frontend and backend'], }) ``` @@ -175,7 +175,7 @@ Or you can create a [test specification](/api/advanced/test-specification) with const specification = vitest.getRootProject().createSpecification( '/path-to-file.js', { - testTagsExpr: ['frontend and backend'], + testTagsFilter: ['frontend and backend'], }, ) ``` @@ -201,7 +201,7 @@ The parser follows standard [operator precedence](https://developer.mozilla.org/ You can use a wildcard (`*`) to match any number of characters: ```shell -vitest --tags-expr="unit/*" +vitest --tags-filter="unit/*" ``` This will match tags like `unit/components`, `unit/utils`, etc. @@ -211,7 +211,7 @@ This will match tags like `unit/components`, `unit/utils`, etc. To exclude tests with a specific tag, add an exclamation mark (`!`) at the start or a "not" keyword: ```shell -vitest --tags-expr="!slow and not flaky" +vitest --tags-filter="!slow and not flaky" ``` ### Examples @@ -220,33 +220,33 @@ Here are some common filtering patterns: ```shell # Run only unit tests -vitest --tags-expr="unit" +vitest --tags-filter="unit" # Run tests that are both frontend AND fast -vitest --tags-expr="frontend and fast" +vitest --tags-filter="frontend and fast" # Run tests that are either unit OR e2e -vitest --tags-expr="unit or e2e" +vitest --tags-filter="unit or e2e" # Run all tests except slow ones -vitest --tags-expr="!slow" +vitest --tags-filter="!slow" # Run frontend tests that are not flaky -vitest --tags-expr="frontend && !flaky" +vitest --tags-filter="frontend && !flaky" # Run tests matching a wildcard pattern -vitest --tags-expr="api/*" +vitest --tags-filter="api/*" # Complex expression with parentheses -vitest --tags-expr="(unit || e2e) && !slow" +vitest --tags-filter="(unit || e2e) && !slow" # Run database tests that are either postgres or mysql, but not slow -vitest --tags-expr="db && (postgres || mysql) && !slow" +vitest --tags-filter="db && (postgres || mysql) && !slow" ``` -You can also pass multiple `--tags-expr` flags. They are combined with AND logic: +You can also pass multiple `--tags-filter` flags. They are combined with AND logic: ```shell # Run tests that match (unit OR e2e) AND are NOT slow -vitest --tags-expr="unit || e2e" --tags-expr="!slow" +vitest --tags-filter="unit || e2e" --tags-filter="!slow" ``` diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index 995e2bec1544..ae17e7ccbd0e 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -39,8 +39,8 @@ export async function collectTests( const testLocations = typeof spec === 'string' ? undefined : spec.testLocations const testNamePattern = typeof spec === 'string' ? undefined : spec.testNamePattern const testIds = typeof spec === 'string' ? undefined : spec.testIds - const testTagsExpr = typeof spec === 'object' && spec.testTagsExpr - ? createTagsFilter(spec.testTagsExpr, config.tags) + const testTagsFilter = typeof spec === 'object' && spec.testTagsFilter + ? createTagsFilter(spec.testTagsFilter, config.tags) : undefined const fileTags: string[] = typeof spec === 'string' ? [] : (spec.fileTags || []) @@ -123,9 +123,9 @@ export async function collectTests( testNamePattern ?? config.testNamePattern, testLocations, testIds, - testTagsExpr - ?? (defaultTagsFilter ??= config.tagsExpr - ? createTagsFilter(config.tagsExpr, config.tags) + testTagsFilter + ?? (defaultTagsFilter ??= config.tagsFilter + ? createTagsFilter(config.tagsFilter, config.tags) : undefined), hasOnlyTasks, false, diff --git a/packages/runner/src/types/runner.ts b/packages/runner/src/types/runner.ts index 9c6c3d19526d..0d1a1df0713b 100644 --- a/packages/runner/src/types/runner.ts +++ b/packages/runner/src/types/runner.ts @@ -43,7 +43,7 @@ export interface VitestRunnerConfig { includeTaskLocation?: boolean diffOptions?: DiffOptions tags: TestTagDefinition[] - tagsExpr?: string[] + tagsFilter?: string[] strictTags: boolean } @@ -57,7 +57,7 @@ export interface FileSpecification { fileTags?: string[] testLocations: number[] | undefined testNamePattern: RegExp | undefined - testTagsExpr: string[] | undefined + testTagsFilter: string[] | undefined testIds: string[] | undefined } diff --git a/packages/runner/src/utils/collect.ts b/packages/runner/src/utils/collect.ts index 6d83f7158bff..4c741ac2f478 100644 --- a/packages/runner/src/utils/collect.ts +++ b/packages/runner/src/utils/collect.ts @@ -12,7 +12,7 @@ export function interpretTaskModes( namePattern?: string | RegExp, testLocations?: number[] | undefined, testIds?: string[] | undefined, - testTagsExpr?: ((testTags: string[]) => boolean) | undefined, + testTagsFilter?: ((testTags: string[]) => boolean) | undefined, onlyMode?: boolean, parentIsOnly?: boolean, allowOnly?: boolean, @@ -75,7 +75,7 @@ export function interpretTaskModes( if (testIds && !testIds.includes(t.id)) { t.mode = 'skip' } - if (testTagsExpr && !testTagsExpr(t.tags || [])) { + if (testTagsFilter && !testTagsFilter(t.tags || [])) { t.mode = 'skip' } } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index bbd336fd4a82..38b6d3115d64 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -788,7 +788,7 @@ export const cliOptionsConfig: VitestCLIOptions = { clearCache: { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', }, - tagsExpr: { + tagsFilter: { description: 'Run only tests with the specified tags. You can use logical operators `&&` (and), `||` (or) and `!` (not) to create complex expressions, see: https://vitest.dev/guide/test-tags#syntax for more information.', argument: '', array: true, diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 79733b5b294a..bb9cb9e1a5d5 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -136,7 +136,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { openTelemetry: config.experimental.openTelemetry, }, tags: config.tags || [], - tagsExpr: config.tagsExpr, + tagsFilter: config.tagsFilter, strictTags: config.strictTags ?? true, } } diff --git a/packages/vitest/src/node/pool.ts b/packages/vitest/src/node/pool.ts index 8473b257436c..4f5a68c0ea71 100644 --- a/packages/vitest/src/node/pool.ts +++ b/packages/vitest/src/node/pool.ts @@ -153,7 +153,7 @@ export function createPool(ctx: Vitest): ProcessPool { testLocations: spec.testLines, testNamePattern: spec.testNamePattern, testIds: spec.testIds, - testTags: spec.testTagsExpr, + testTagsFilter: spec.testTagsFilter, })), invalidates, providedContext: project.getProvidedContext(), diff --git a/packages/vitest/src/node/test-specification.ts b/packages/vitest/src/node/test-specification.ts index f1211c28eaa8..f3995fa55340 100644 --- a/packages/vitest/src/node/test-specification.ts +++ b/packages/vitest/src/node/test-specification.ts @@ -9,7 +9,7 @@ export interface TestSpecificationOptions { testNamePattern?: RegExp testIds?: string[] testLines?: number[] - testTagsExpr?: string[] + testTagsFilter?: string[] } export class TestSpecification { @@ -45,7 +45,7 @@ export class TestSpecification { /** * The tags of tests to run. */ - public readonly testTagsExpr: string[] | undefined + public readonly testTagsFilter: string[] | undefined /** * This class represents a test suite for a test module within a single project. @@ -78,7 +78,7 @@ export class TestSpecification { this.testLines = testLinesOrOptions.testLines this.testNamePattern = testLinesOrOptions.testNamePattern this.testIds = testLinesOrOptions.testIds - this.testTagsExpr = testLinesOrOptions.testTagsExpr + this.testTagsFilter = testLinesOrOptions.testTagsFilter } } @@ -105,7 +105,7 @@ export class TestSpecification { testLines: this.testLines, testIds: this.testIds, testNamePattern: this.testNamePattern, - testTagsExpr: this.testTagsExpr, + testTagsFilter: this.testTagsFilter, }, ] } diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 2faef92465c5..4174fc0f0515 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1014,7 +1014,7 @@ export interface UserConfig extends InlineConfig { /** * Tags to filter tests with. */ - tagsExpr?: string[] + tagsFilter?: string[] } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void @@ -1047,7 +1047,7 @@ export interface ResolvedConfig | 'name' | 'vmMemoryLimit' | 'fileParallelism' - | 'tagsExpr' + | 'tagsFilter' > { mode: VitestRunMode @@ -1118,7 +1118,7 @@ export interface ResolvedConfig vmMemoryLimit?: UserConfig['vmMemoryLimit'] dumpDir?: string - tagsExpr?: string[] + tagsFilter?: string[] } type NonProjectOptions diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 2b98e36bbaa9..4dd7998951ad 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -127,7 +127,7 @@ export interface SerializedConfig { } | undefined } tags: TestTagDefinition[] - tagsExpr: string[] | undefined + tagsFilter: string[] | undefined strictTags: boolean } diff --git a/packages/vitest/src/runtime/types/utils.ts b/packages/vitest/src/runtime/types/utils.ts index ef26a5c146da..b20580119e31 100644 --- a/packages/vitest/src/runtime/types/utils.ts +++ b/packages/vitest/src/runtime/types/utils.ts @@ -6,6 +6,6 @@ export type SerializedTestSpecification = [ testLines?: number[] | undefined testIds?: string[] | undefined testNamePattern?: RegExp | undefined - testTagsExpr?: string[] | undefined + testTagsFilter?: string[] | undefined }, ] diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 4f2117920ea8..25e50ea66acf 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -48,7 +48,7 @@ test('vitest records tags', async () => { `) }) -test('filters tests based on --tags-expr=!ignore', async () => { +test('filters tests based on --tags-filter=!ignore', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -59,7 +59,7 @@ test('filters tests based on --tags-expr=!ignore', async () => { { name: 'suite_2' }, { name: 'test_2' }, ], - tagsExpr: ['!suite_2'], + tagsFilter: ['!suite_2'], }) expect(stderr).toBe('') @@ -79,7 +79,7 @@ test('filters tests based on --tags-expr=!ignore', async () => { `) }) -test('filters tests based on --tags-expr=!ignore and --tags-expr=include', async () => { +test('filters tests based on --tags-filter=!ignore and --tags-filter=include', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -90,7 +90,7 @@ test('filters tests based on --tags-expr=!ignore and --tags-expr=include', async { name: 'suite_2' }, { name: 'test_2' }, ], - tagsExpr: ['!suite_2', 'test'], + tagsFilter: ['!suite_2', 'test'], }) expect(stderr).toBe('') @@ -110,7 +110,7 @@ test('filters tests based on --tags-expr=!ignore and --tags-expr=include', async `) }) -test('filters tests based on --tags-expr=include', async () => { +test('filters tests based on --tags-filter=include', async () => { const { stderr, testTree } = await runVitest({ root: './fixtures/test-tags', config: false, @@ -121,7 +121,7 @@ test('filters tests based on --tags-expr=include', async () => { { name: 'suite_2' }, { name: 'test_2' }, ], - tagsExpr: ['test*'], + tagsFilter: ['test*'], }) expect(stderr).toBe('') @@ -201,13 +201,13 @@ test('throws an error if tag is not defined in the config, but in the test', asy `) }) -test('throws an error if tag is not defined in the config, but in --tags-expr filter', async () => { +test('throws an error if tag is not defined in the config, but in --tags-filter filter', async () => { const { stderr } = await runInlineTests( { 'basic.test.js': '', }, { - tagsExpr: ['unknown'], + tagsFilter: ['unknown'], }, { fails: true }, ) @@ -217,7 +217,7 @@ test('throws an error if tag is not defined in the config, but in --tags-expr fi test.todo('defining a tag available only in one project', async () => { await runVitest({ config: false, - tagsExpr: ['project-2-tag'], + tagsFilter: ['project-2-tag'], projects: [ { test: { From 6f1e45a367c609496b27ff33b91b2229dba04653 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:21:55 +0100 Subject: [PATCH 13/48] docs: cleanup --- docs/api/advanced/test-specification.md | 6 +++--- packages/vitest/src/node/types/config.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/api/advanced/test-specification.md b/docs/api/advanced/test-specification.md index 7c94270ac040..7142bba96d78 100644 --- a/docs/api/advanced/test-specification.md +++ b/docs/api/advanced/test-specification.md @@ -11,7 +11,7 @@ const specification = project.createSpecification( testLines: [20, 40], testNamePattern: /hello world/, testIds: ['1223128da3_0_0_0', '1223128da3_0_0'], - testTags: ['frontend', 'backend'], + testTagsFilter: ['frontend and backend'], } // optional test filters ) ``` @@ -83,9 +83,9 @@ A regexp that matches the name of the test in this module. This value will overr The ids of tasks inside of this specification to run. -## testTags 4.1.0 {#testtags} +## testTagsFilter 4.1.0 {#testtagsfilter} -The [tags](/guide/test-tags) that a test must have in order to be included in the run. +The [tags filter](/guide/test-tags#syntax) that a test must pass in order to be included in the run. Multiple filters are treated as `AND`. ## toJSON diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 4174fc0f0515..c5f52864c5f8 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1012,7 +1012,8 @@ export interface UserConfig extends InlineConfig { clearCache?: boolean /** - * Tags to filter tests with. + * Tags expression to filter tests to run. Multiple filters will be applied using AND logic. + * @see {@link https://vitest.dev/guide/test-tags#syntax} */ tagsFilter?: string[] } From 7dde3b8273accfbccce8ae0b67ed34cd3a56abdd Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:26:11 +0100 Subject: [PATCH 14/48] docs: document options --- docs/config/tags.md | 172 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/docs/config/tags.md b/docs/config/tags.md index 24888db7fed2..e9e317bd8d72 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -19,6 +19,178 @@ You can use a wildcard (*) to match any number of symbols. To ignore a tag, add ::: ## name + +- **Type:** `string` +- **Required:** `true` + +The name of the tag. This is what you use in the `tags` option in tests. + +```ts +export default defineConfig({ + test: { + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + ], + }, +}) +``` + +::: tip +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings: + +```ts +declare module 'vitest' { + interface TestTags { + tags: + | 'frontend' + | 'backend' + | 'db' + | 'flaky' + } +} +``` +::: + ## description + +- **Type:** `string` + +A human-readable description for the tag. This will be shown in error messages when a tag is not found and in UI. + +```ts +export default defineConfig({ + test: { + tags: [ + { + name: 'slow', + description: 'Tests that take a long time to run.', + }, + ], + }, +}) +``` + ## priority + +- **Type:** `number` +- **Default:** `undefined` + +Priority for merging options when multiple tags with the same options are applied to a test. Lower number means higher priority (e.g., priority `1` takes precedence over priority `3`). + +```ts +export default defineConfig({ + test: { + tags: [ + { + name: 'flaky', + timeout: 30_000, + priority: 1, // higher priority + }, + { + name: 'db', + timeout: 60_000, + priority: 2, // lower priority + }, + ], + }, +}) +``` + +When a test has both tags, the `timeout` will be `30_000` because `flaky` has a higher priority. + ## Test Options + +Tags can define test options that will be applied to every test marked with the tag. These options are merged with the test's own options, with the test's options taking precedence. + +### timeout + +- **Type:** `number` + +Test timeout in milliseconds. + +### retry + +- **Type:** `number | { count?: number, delay?: number, condition?: RegExp }` + +Retry configuration for the test. If a number, specifies how many times to retry. If an object, allows fine-grained retry control. + +### repeats + +- **Type:** `number` + +How many times the test will run again. + +### concurrent + +- **Type:** `boolean` + +Whether suites and tests run concurrently. + +### sequential + +- **Type:** `boolean` + +Whether tests run sequentially. + +### skip + +- **Type:** `boolean` + +Whether the test should be skipped. + +### only + +- **Type:** `boolean` + +Should this test be the only one running in a suite. + +### todo + +- **Type:** `boolean` + +Whether the test should be skipped and marked as a todo. + +### fails + +- **Type:** `boolean` + +Whether the test is expected to fail. If it does, the test will pass, otherwise it will fail. + +## Example + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + tags: [ + { + name: 'unit', + description: 'Unit tests.', + }, + { + name: 'e2e', + description: 'End-to-end tests.', + timeout: 60_000, + }, + { + name: 'flaky', + description: 'Flaky tests that need retries.', + retry: process.env.CI ? 3 : 0, + priority: 1, + }, + { + name: 'slow', + description: 'Slow tests.', + timeout: 120_000, + }, + { + name: 'skip-ci', + description: 'Tests to skip in CI.', + skip: !!process.env.CI, + }, + ], + }, +}) +``` From d4a55dedbbe663b4caad847e8545ae118a5ca210 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:29:23 +0100 Subject: [PATCH 15/48] docs: fix generator --- docs/.vitepress/scripts/cli-generator.ts | 2 +- docs/guide/cli-generated.md | 35 +++++++++++++++++++--- packages/vitest/src/node/cli/cli-config.ts | 2 +- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index d8d40fb2ff61..820c95123c89 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -42,7 +42,7 @@ const skipConfig = new Set([ 'browser.name', 'browser.fileParallelism', 'clearCache', - 'tag', + 'tagsFilter', ]) function resolveOptions(options: CLIOptions, parentName?: string) { diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md index 0ce15dfff41f..46ecea688174 100644 --- a/docs/guide/cli-generated.md +++ b/docs/guide/cli-generated.md @@ -518,12 +518,26 @@ Default hook timeout in milliseconds (default: `10000`). Use `0` to disable time Stop test execution when given number of tests have failed (default: `0`) -### retry +### retry.count -- **CLI:** `--retry ` -- **Config:** [retry](/config/retry) +- **CLI:** `--retry.count ` +- **Config:** [retry.count](/config/retry#retry-count) -Retry the test specific number of times if it fails (default: `0`) +Number of times to retry a test if it fails (default: `0`) + +### retry.delay + +- **CLI:** `--retry.delay ` +- **Config:** [retry.delay](/config/retry#retry-delay) + +Delay in milliseconds between retry attempts (default: `0`) + +### retry.condition + +- **CLI:** `--retry.condition ` +- **Config:** [retry.condition](/config/retry#retry-condition) + +Regex pattern to match error messages that should trigger a retry. Only errors matching this pattern will cause a retry (default: retry on all errors) ### diff.aAnnotation @@ -798,6 +812,19 @@ Start Vitest without running tests. Tests will be running only on change. This o Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run. +### tagsFilter + +- **CLI:** `--tagsFilter ` + +Run only tests with the specified tags. You can use logical operators `&&` (and), `||` (or) and `!` (not) to create complex expressions, see [Test Tags](/guide/test-tags#syntax) for more information. + +### strictTags + +- **CLI:** `--strictTags` +- **Config:** [strictTags](/config/stricttags) + +Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`) + ### experimental.fsModuleCache - **CLI:** `--experimental.fsModuleCache` diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 38b6d3115d64..a9ad772bee2c 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -789,7 +789,7 @@ export const cliOptionsConfig: VitestCLIOptions = { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', }, tagsFilter: { - description: 'Run only tests with the specified tags. You can use logical operators `&&` (and), `||` (or) and `!` (not) to create complex expressions, see: https://vitest.dev/guide/test-tags#syntax for more information.', + description: 'Run only tests with the specified tags. You can use logical operators `&&` (and), `||` (or) and `!` (not) to create complex expressions, see [Test Tags](https://vitest.dev/guide/test-tags#syntax) for more information.', argument: '', array: true, }, From feef1f9e538380567b5b931a33fb51704762c1ce Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:29:58 +0100 Subject: [PATCH 16/48] chore: cleanup --- packages/vitest/src/node/pools/browser.ts | 3 ++- packages/vitest/src/typecheck/collect.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index 4edf826b7c7e..b58fcf4ab374 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -62,13 +62,14 @@ export function createBrowserPool(vitest: Vitest): ProcessPool { const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { const groupedFiles = new Map() - for (const { project, moduleId, testLines, testIds, testNamePattern } of specs) { + for (const { project, moduleId, testLines, testIds, testNamePattern, testTagsFilter } of specs) { const files = groupedFiles.get(project) || [] files.push({ filepath: moduleId, testLocations: testLines, testIds, testNamePattern, + testTagsFilter, }) groupedFiles.set(project, files) } diff --git a/packages/vitest/src/typecheck/collect.ts b/packages/vitest/src/typecheck/collect.ts index c2b0ad6d96aa..c6c5e571d90d 100644 --- a/packages/vitest/src/typecheck/collect.ts +++ b/packages/vitest/src/typecheck/collect.ts @@ -229,6 +229,7 @@ export async function collectTests( ctx.config.testNamePattern, undefined, undefined, + undefined, hasOnly, false, ctx.config.allowOnly, From b1c8bc10f2defe321f90abb971b2504a2ca04e8e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:31:13 +0100 Subject: [PATCH 17/48] chore: note --- docs/config/tags.md | 6 ++++-- docs/guide/test-tags.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/config/tags.md b/docs/config/tags.md index e9e317bd8d72..1052e0ebe91d 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -37,9 +37,11 @@ export default defineConfig({ ``` ::: tip -If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings: +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings (make sure this file is included by your `tsconfig`): + +```ts [vitest.shims.ts] +import 'vitest' -```ts declare module 'vitest' { interface TestTags { tags: diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 19599f93c292..249c2b365fe7 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -62,9 +62,11 @@ tet('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) ``` ::: -If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings: +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings (make sure this file is included by your `tsconfig`): + +```ts [vitest.shims.ts] +import 'vitest' -```ts declare module 'vitest' { interface TestTags { tags: From 1882df26e2e228187593b57faecc2ed448cdcb82 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:40:22 +0100 Subject: [PATCH 18/48] docs: cleanup --- docs/api/index.md | 2 +- docs/config/stricttags.md | 4 ++-- docs/config/tags.md | 2 +- docs/guide/test-tags.md | 6 +----- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 45d2a7fce7e5..850a79a06a9f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -32,7 +32,7 @@ interface TestOptions { /** * Custom tags of the test. Useful for filtering tests. */ - tags?: string[] + tags?: string[] | string } ``` diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md index 53710f46bcba..0fdbe3cf9ee8 100644 --- a/docs/config/stricttags.md +++ b/docs/config/stricttags.md @@ -13,11 +13,11 @@ Should Vitest throw an error if test has a [`tag`](/config/tags) that is not def Note that Vitest will always throw an error if `--tags-filter` flag defines a tag not present in the config. -For examle, this test will throw an error because the tag `fortend` has a typo (it should be `frontend`): +For examle, this test will throw an error because the tag `fortnend` has a typo (it should be `frontend`): ::: code-group ```js [form.test.js] -test('renders a form', { tags: ['fortend'] }, () => { +test('renders a form', { tags: ['fortnend'] }, () => { // ... }) ``` diff --git a/docs/config/tags.md b/docs/config/tags.md index 1052e0ebe91d..4bb821aabbfc 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -58,7 +58,7 @@ declare module 'vitest' { - **Type:** `string` -A human-readable description for the tag. This will be shown in error messages when a tag is not found and in UI. +A human-readable description for the tag. This will be shown in UI and inside error messages when a tag is not found. ```ts export default defineConfig({ diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 249c2b365fe7..c0be17e5d442 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -171,7 +171,7 @@ await startVitest('test', [], { }) ``` -Or you can create a [test specification](/api/advanced/test-specification) with tags of your choice: +Or you can create a [test specification](/api/advanced/test-specification) with your custom filters: ```ts const specification = vitest.getRootProject().createSpecification( @@ -182,10 +182,6 @@ const specification = vitest.getRootProject().createSpecification( ) ``` -::: warning -Note that `createSpecification` does not support wildcards and will not validate if the tags are defined in the config. -::: - ### Syntax You can combine tags in different ways. Vitest supports these keywords: From fca2007e480af768a62bd67efc8b261a128b85f7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 17:48:55 +0100 Subject: [PATCH 19/48] test: fix failing tests --- test/cli/test/reported-tasks.test.ts | 6 ++++++ test/cli/test/reporters/__snapshots__/html.test.ts.snap | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/test/cli/test/reported-tasks.test.ts b/test/cli/test/reported-tasks.test.ts index 494c7af27abd..94d025da22d2 100644 --- a/test/cli/test/reported-tasks.test.ts +++ b/test/cli/test/reported-tasks.test.ts @@ -92,9 +92,12 @@ it('correctly reports a passed test', () => { each: undefined, concurrent: undefined, shuffle: undefined, + fails: undefined, retry: undefined, repeats: undefined, mode: 'run', + tags: [], + timeout: 5000, }) expect(passedTest.meta()).toEqual({}) @@ -128,6 +131,9 @@ it('correctly reports failed test', () => { retry: undefined, repeats: undefined, mode: 'run', + fails: undefined, + tags: [], + timeout: 5000, }) expect(passedTest.meta()).toEqual({}) diff --git a/test/cli/test/reporters/__snapshots__/html.test.ts.snap b/test/cli/test/reporters/__snapshots__/html.test.ts.snap index 151859a1ca8f..54435437bab4 100644 --- a/test/cli/test/reporters/__snapshots__/html.test.ts.snap +++ b/test/cli/test/reporters/__snapshots__/html.test.ts.snap @@ -24,6 +24,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "state": "fail", }, "setupDuration": 0, + "tags": [], "tasks": [ { "annotations": [], @@ -81,6 +82,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "startTime": 0, "state": "fail", }, + "tags": [], "timeout": 5000, "type": "test", }, @@ -148,6 +150,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "state": "pass", }, "setupDuration": 0, + "tags": [], "tasks": [ { "annotations": [], @@ -170,6 +173,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "startTime": 0, "state": "pass", }, + "tags": [], "timeout": 5000, "type": "test", }, @@ -187,6 +191,7 @@ exports[`html reporter > resolves to "passing" status for test file "all-passing "meta": {}, "mode": "skip", "name": "3 + 3 = 6", + "tags": [], "timeout": 5000, "type": "test", }, From c299640bd337869a6002cfd15c6afbd0d615793b Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 18:27:56 +0100 Subject: [PATCH 20/48] refactor: rename @tag pragma to @module-tag --- docs/guide/test-tags.md | 18 +++++++++--------- packages/vitest/src/utils/test-helpers.ts | 2 +- .../fixtures/file-tags/error-file-tags.test.ts | 4 ++-- .../fixtures/file-tags/valid-file-tags.test.ts | 6 +++--- test/cli/test/test-tags.test.ts | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index c0be17e5d442..55650ffcbcff 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -45,10 +45,10 @@ export default defineConfig({ ``` ::: warning -If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `properity` first (the lower the number, the higher the priority is): +If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `priority` first (the lower the number, the higher the priority is): ```ts -tet('flaky database test', { tags: ['flaky', 'db'] }) +test('flaky database test', { tags: ['flaky', 'db'] }) // { timeout: 30_000, retry: 3 } ``` @@ -57,7 +57,7 @@ Note that the `timeout` is 30 seconds (and not 60) because `flaky` tag has a pri If test defines its own options, they will have the highest priority: ```ts -tet('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) +test('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) // { timeout: 120_000, retry: 3 } ``` ::: @@ -102,13 +102,13 @@ describe('API endpoints', { tags: ['backend'] }, () => { Tags are inherited from parent suites, so all tests inside a tagged `describe` block will automatically have that tag. -It's also possible to define `tags` for every test in the file by using JSDoc's `@tag` at the top of the file: +It's also possible to define `tags` for every test in the file by using JSDoc's `@module-tag` at the top of the file: ```ts /** * Auth tests - * @tag admin/pages/dashboard - * @tag acceptance + * @module-tag admin/pages/dashboard + * @module-tag acceptance */ test('dashboard renders items', () => { @@ -117,19 +117,19 @@ test('dashboard renders items', () => { ``` ::: danger -Any JSDoc comment with a `@tag` will add that tag to all tests in that file. Putting it before the test does not mark that test with a tag: +Any JSDoc comment with a `@module-tag` will add that tag to all tests in that file. Putting it before the test does not mark that test with a tag: ```js{3,10} describe('forms', () => { /** - * @tag frontend + * @module-tag frontend */ test('renders a form', () => { // ... }) /** - * @tag db + * @module-tag db */ test('db returns users', () => { // ... diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index d8722c104ab7..ebc28e4e4337 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -53,7 +53,7 @@ function detectBlockLine(content: string) { const tags: string[] = [] let tagMatch: RegExpMatchArray | null // eslint-disable-next-line no-cond-assign - while (tagMatch = content.match(/(\/\/|\*)\s*@tag\s+([\w\-/]+)\b/)) { + while (tagMatch = content.match(/(\/\/|\*)\s*@module-tag\s+([\w\-/]+)\b/)) { tags.push(tagMatch[2]) content = content.slice(tagMatch.index! + tagMatch[0].length) } diff --git a/test/cli/fixtures/file-tags/error-file-tags.test.ts b/test/cli/fixtures/file-tags/error-file-tags.test.ts index 62854f3d5d10..848af2a9f3e8 100644 --- a/test/cli/fixtures/file-tags/error-file-tags.test.ts +++ b/test/cli/fixtures/file-tags/error-file-tags.test.ts @@ -1,6 +1,6 @@ /** - * @tag invalid - * @tag unknown + * @module-tag invalid + * @module-tag unknown */ import { describe, test } from 'vitest' diff --git a/test/cli/fixtures/file-tags/valid-file-tags.test.ts b/test/cli/fixtures/file-tags/valid-file-tags.test.ts index 1eec43c8d25f..57a4c91be712 100644 --- a/test/cli/fixtures/file-tags/valid-file-tags.test.ts +++ b/test/cli/fixtures/file-tags/valid-file-tags.test.ts @@ -1,7 +1,7 @@ /** - * @tag file - * @tag file-2 - * @tag file/slash + * @module-tag file + * @module-tag file-2 + * @module-tag file/slash */ import { describe, test } from 'vitest' diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 25e50ea66acf..3c46fffc2bcc 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -404,7 +404,7 @@ test('strictFlag: false does not throw an error if test has an undefined tag', a expect(stderr).toBe('') }) -test('@tag docs inject test tags', async () => { +test('@module-tag docs inject test tags', async () => { const { stderr, buildTree } = await runVitest({ config: false, root: './fixtures/file-tags', @@ -433,7 +433,7 @@ test('@tag docs inject test tags', async () => { `) }) -test('invalid @tag throws and error', async () => { +test('invalid @module-tag throws and error', async () => { const { stderr } = await runVitest({ config: false, root: './fixtures/file-tags', From 5fc1ebc88f9c1cf6bce34d154ed216a46e0b22a1 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 18:48:51 +0100 Subject: [PATCH 21/48] fix: support projects properly --- .../vitest/src/node/config/resolveConfig.ts | 3 +- packages/vitest/src/node/core.ts | 5 +- .../src/node/projects/resolveProjects.ts | 1 + packages/vitest/src/node/tags.ts | 27 +++++ test/cli/test/test-tags.test.ts | 108 ++++++++++++++++-- 5 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 packages/vitest/src/node/tags.ts diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index c8e0968d8df5..4042d2f6d3ec 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -168,7 +168,8 @@ export function resolveConfig( resolved.project = toArray(resolved.project) resolved.provide ??= {} - resolved.tags ??= [] + // shallow copy tags array to avoid mutating user config + resolved.tags = [...resolved.tags || []] resolved.tags.forEach((tag) => { if (!tag.name || typeof tag.name !== 'string') { throw new Error(`Each tag defined in "test.tags" must have a "name" property, received: ${JSON.stringify(tag)}`) diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 1f7960b521ad..0ae6512835d4 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -47,6 +47,7 @@ import { createBenchmarkReporters, createReporters } from './reporters/utils' import { VitestResolver } from './resolver' import { VitestSpecifications } from './specifications' import { StateManager } from './state' +import { validateProjectsTags } from './tags' import { TestRun } from './test-run' import { VitestWatcher } from './watcher' @@ -318,6 +319,8 @@ export class Vitest { this.configOverride.testNamePattern = this.config.testNamePattern } + validateProjectsTags(this.coreWorkspaceProject, this.projects) + this.reporters = resolved.mode === 'benchmark' ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) : await createReporters(resolved.reporters, this) @@ -328,8 +331,6 @@ export class Vitest { ...this._onSetServer.map(fn => fn()), this._traces.waitInit(), ]) - - // validate tags } /** @internal */ diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index 330537bf53d2..19c26dcbd5a5 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -58,6 +58,7 @@ export async function resolveProjects( 'inspect', 'inspectBrk', 'fileParallelism', + 'tagsFilter', ] as const const cliOverrides = overridesOptions.reduce((acc, name) => { diff --git a/packages/vitest/src/node/tags.ts b/packages/vitest/src/node/tags.ts new file mode 100644 index 000000000000..ed757f4088d1 --- /dev/null +++ b/packages/vitest/src/node/tags.ts @@ -0,0 +1,27 @@ +import type { TestTagDefinition } from '@vitest/runner' +import type { TestProject } from './project' + +export function validateProjectsTags(rootProject: TestProject, projects: TestProject[]): void { + // Include root project if not already in the list + const allProjects = projects.includes(rootProject) ? projects : [rootProject, ...projects] + + // Collect all tags from all projects (first definition wins) + const globalTags = new Map() + for (const project of allProjects) { + for (const tag of project.config.tags || []) { + if (!globalTags.has(tag.name)) { + globalTags.set(tag.name, tag) + } + } + } + + // Add missing tags to each project (without overriding local definitions) + for (const project of allProjects) { + const projectTagNames = new Set(project.config.tags.map(t => t.name)) + for (const [tagName, tagDef] of globalTags) { + if (!projectTagNames.has(tagName)) { + project.config.tags.push(tagDef) + } + } + } +} diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 3c46fffc2bcc..c1faefa88aa9 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -214,23 +214,107 @@ test('throws an error if tag is not defined in the config, but in --tags-filter expect(stderr).toContain('The Vitest config does\'t define any "tags", cannot apply "unknown" tag pattern for this test. See: https://vitest.dev/guide/test-tags') }) -test.todo('defining a tag available only in one project', async () => { - await runVitest({ - config: false, +test('defining a tag available only in one project', async () => { + const { stderr, buildTree, ctx } = await runInlineTests({ + 'basic-1.test.js': ` + test('test 1', { tags: ['project-1-tag'] }, () => {}) + `, + 'basic-2.test.js': ` + test('test 2', { tags: ['global-tag', 'project-2-tag'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'global-tag' }, + ], + projects: [ + { + extends: true, + test: { + name: 'project-1', + include: ['basic-1.test.js'], + tags: [ + { name: 'project-1-tag' }, + { name: 'override', timeout: 100 }, + ], + }, + }, + { + extends: true, + test: { + name: 'project-2', + include: ['basic-2.test.js'], + tags: [ + { name: 'project-2-tag' }, + { name: 'override', timeout: 200 }, + ], + }, + }, + ], + }, + }, + }, { tagsFilter: ['project-2-tag'], - projects: [ - { - test: { - tags: [{ name: 'project-1-tag' }], + }) + expect(stderr).toBe('') + expect(Object.fromEntries(ctx!.projects.map(p => [p.name, p.config.tags]))).toMatchInlineSnapshot(` + { + "project-1": [ + { + "name": "global-tag", + }, + { + "name": "project-1-tag", + }, + { + "name": "override", + "timeout": 100, + }, + { + "name": "project-2-tag", + }, + ], + "project-2": [ + { + "name": "global-tag", + }, + { + "name": "project-2-tag", + }, + { + "name": "override", + "timeout": 200, + }, + { + "name": "project-1-tag", + }, + ], + } + `) + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` + { + "basic-1.test.js": { + "test 1": { + "mode": "run", + "tags": [ + "project-1-tag", + ], + "timeout": 5000, }, }, - { - test: { - tags: [{ name: 'project-2-tag' }], + "basic-2.test.js": { + "test 2": { + "mode": "run", + "tags": [ + "global-tag", + "project-2-tag", + ], + "timeout": 5000, }, }, - ], - }) + } + `) }) test('can specify custom options for tags', async () => { From 68c78a52fd732081043fe14a66e09504849efd45 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 18:51:00 +0100 Subject: [PATCH 22/48] docs: typos --- docs/config/stricttags.md | 2 +- docs/config/tags.md | 2 +- docs/guide/test-tags.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config/stricttags.md b/docs/config/stricttags.md index 0fdbe3cf9ee8..f26b813472ab 100644 --- a/docs/config/stricttags.md +++ b/docs/config/stricttags.md @@ -13,7 +13,7 @@ Should Vitest throw an error if test has a [`tag`](/config/tags) that is not def Note that Vitest will always throw an error if `--tags-filter` flag defines a tag not present in the config. -For examle, this test will throw an error because the tag `fortnend` has a typo (it should be `frontend`): +For example, this test will throw an error because the tag `fortnend` has a typo (it should be `frontend`): ::: code-group ```js [form.test.js] diff --git a/docs/config/tags.md b/docs/config/tags.md index 4bb821aabbfc..16c60c75bddc 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -37,7 +37,7 @@ export default defineConfig({ ``` ::: tip -If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings (make sure this file is included by your `tsconfig`): +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that contains a union of strings (make sure this file is included by your `tsconfig`): ```ts [vitest.shims.ts] import 'vitest' diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 55650ffcbcff..20f0fd0c6b58 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -45,7 +45,7 @@ export default defineConfig({ ``` ::: warning -If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `priority` first (the lower the number, the higher the priority is): +If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `priority` first (the lower the number, the higher the priority). Tags without a defined priority are resolved last: ```ts test('flaky database test', { tags: ['flaky', 'db'] }) @@ -62,7 +62,7 @@ test('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) ``` ::: -If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that containst a union of strings (make sure this file is included by your `tsconfig`): +If you are using TypeScript, you can enforce what tags are available by augmenting the `TestTags` type with a property that contains a union of strings (make sure this file is included by your `tsconfig`): ```ts [vitest.shims.ts] import 'vitest' From 86c1a36626b47938c09ab8a314bbe7f6c67000c2 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 19:26:52 +0100 Subject: [PATCH 23/48] test: add more tests --- docs/api/advanced/test-case.md | 5 + .../vitest/src/node/config/resolveConfig.ts | 3 + packages/vitest/src/node/pools/browser.ts | 21 +- packages/vitest/src/node/reporters/json.ts | 2 + .../src/node/reporters/reported-tasks.ts | 6 + packages/vitest/src/utils/test-helpers.ts | 15 +- test/browser/specs/runner.test.ts | 30 ++ test/browser/test/tags.test.ts | 13 + test/browser/vitest.config.mts | 5 + .../error-file-one-line-comment.test.ts | 8 + .../valid-file-one-line-comment.test.ts | 9 + test/cli/fixtures/reporters/json-fail.test.ts | 2 +- .../reporters/__snapshots__/json.test.ts.snap | 3 + test/cli/test/reporters/json.test.ts | 1 + test/cli/test/test-tags.test.ts | 398 ++++++++++++++++++ 15 files changed, 516 insertions(+), 5 deletions(-) create mode 100644 test/browser/test/tags.test.ts create mode 100644 test/cli/fixtures/file-tags/error-file-one-line-comment.test.ts create mode 100644 test/cli/fixtures/file-tags/valid-file-one-line-comment.test.ts diff --git a/docs/api/advanced/test-case.md b/docs/api/advanced/test-case.md index e9bc3b818031..977fc29cc2d2 100644 --- a/docs/api/advanced/test-case.md +++ b/docs/api/advanced/test-case.md @@ -106,12 +106,17 @@ interface TaskOptions { readonly retry: number | undefined readonly repeats: number | undefined readonly tags: string[] | undefined + readonly timeout: number | undefined readonly mode: 'run' | 'only' | 'skip' | 'todo' } ``` The options that test was collected with. +## tags 4.1.0 + +[Tags](/guide/test-tags) that were implicitly or explicitly assigned to the test. + ## ok ```ts diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 4042d2f6d3ec..0bbb74737b97 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -183,6 +183,9 @@ export function resolveConfig( if (typeof tag.retry === 'object' && typeof tag.retry.condition === 'function') { throw new TypeError(`Tag "${tag.name}": retry.condition function cannot be used inside a config file. Use a RegExp pattern instead, or define the function in your test file.`) } + if (tag.priority != null && (typeof tag.priority !== 'number' || !Number.isInteger(tag.priority) || tag.priority < 0)) { + throw new TypeError(`Tag "${tag.name}": priority must be a non-negative integer.`) + } }) resolved.name = typeof options.name === 'string' diff --git a/packages/vitest/src/node/pools/browser.ts b/packages/vitest/src/node/pools/browser.ts index b58fcf4ab374..9950bdadbf93 100644 --- a/packages/vitest/src/node/pools/browser.ts +++ b/packages/vitest/src/node/pools/browser.ts @@ -8,10 +8,12 @@ import type { TestProject } from '../project' import type { TestSpecification } from '../test-specification' import type { BrowserProvider } from '../types/browser' import crypto from 'node:crypto' +import { readFile } from 'node:fs/promises' import * as nodeos from 'node:os' import { createDefer } from '@vitest/utils/helpers' import { stringify } from 'flatted' import { createDebugger } from '../../utils/debugger' +import { detectCodeBlock } from '../../utils/test-helpers' const debug = createDebugger('vitest:browser:pool') @@ -62,7 +64,23 @@ export function createBrowserPool(vitest: Vitest): ProcessPool { const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { const groupedFiles = new Map() - for (const { project, moduleId, testLines, testIds, testNamePattern, testTagsFilter } of specs) { + const testFilesCode = new Map() + const testFileTags = new WeakMap() + + await Promise.all(specs.map(async (spec) => { + let code = testFilesCode.get(spec.moduleId) + // TODO: this really should be done only once when collecting specifications + if (code == null) { + code = await readFile(spec.moduleId, 'utf-8').catch(() => '') + testFilesCode.set(spec.moduleId, code) + } + const { tags } = detectCodeBlock(code) + testFileTags.set(spec, tags) + })) + + // to keep the sorting, we need to iterate over specs separately + for (const spec of specs) { + const { project, moduleId, testLines, testIds, testNamePattern, testTagsFilter } = spec const files = groupedFiles.get(project) || [] files.push({ filepath: moduleId, @@ -70,6 +88,7 @@ export function createBrowserPool(vitest: Vitest): ProcessPool { testIds, testNamePattern, testTagsFilter, + fileTags: testFileTags.get(spec), }) groupedFiles.set(project, files) } diff --git a/packages/vitest/src/node/reporters/json.ts b/packages/vitest/src/node/reporters/json.ts index 68616bec7e85..11f71f7ee1ad 100644 --- a/packages/vitest/src/node/reporters/json.ts +++ b/packages/vitest/src/node/reporters/json.ts @@ -39,6 +39,7 @@ export interface JsonAssertionResult { duration?: Milliseconds | null failureMessages: Array | null location?: Callsite | null + tags: string[] } export interface JsonTestResult { @@ -161,6 +162,7 @@ export class JsonReporter implements Reporter { t.result?.errors?.map(e => e.stack || e.message) || [], location: t.location, meta: t.meta, + tags: t.tags || [], } satisfies JsonAssertionResult }) diff --git a/packages/vitest/src/node/reporters/reported-tasks.ts b/packages/vitest/src/node/reporters/reported-tasks.ts index d8d92c0e2f0d..06f62e54fe9e 100644 --- a/packages/vitest/src/node/reporters/reported-tasks.ts +++ b/packages/vitest/src/node/reporters/reported-tasks.ts @@ -103,6 +103,11 @@ export class TestCase extends ReportedTaskImplementation { */ public readonly parent: TestSuite | TestModule + /** + * Tags associated with the test. + */ + public readonly tags: string[] + /** @internal */ protected constructor(task: RunnerTestCase, project: TestProject) { super(task, project) @@ -117,6 +122,7 @@ export class TestCase extends ReportedTaskImplementation { this.parent = this.module } this.options = buildOptions(task) + this.tags = this.options.tags || [] } /** diff --git a/packages/vitest/src/utils/test-helpers.ts b/packages/vitest/src/utils/test-helpers.ts index ebc28e4e4337..71d0c8071959 100644 --- a/packages/vitest/src/utils/test-helpers.ts +++ b/packages/vitest/src/utils/test-helpers.ts @@ -14,7 +14,12 @@ export async function getSpecificationsOptions( const tags = new WeakMap() await Promise.all( specifications.map(async (spec) => { - const { moduleId: filepath, project } = spec + const { moduleId: filepath, project, pool } = spec + // browser pool handles its own environment + if (pool === 'browser') { + return + } + // reuse if projects have the same test files let code = cache.get(filepath) if (!code) { @@ -26,7 +31,7 @@ export async function getSpecificationsOptions( env = project.config.environment || 'node', envOptions, tags: specTags = [], - } = detectBlockLine(code) + } = detectCodeBlock(code) tags.set(spec, specTags) const envKey = env === 'happy-dom' ? 'happyDOM' : env @@ -42,7 +47,11 @@ export async function getSpecificationsOptions( return { environments, tags } } -function detectBlockLine(content: string) { +export function detectCodeBlock(content: string): { + env?: string + envOptions?: Record + tags: string[] +} { const env = content.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] let envOptionsJson = content.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] if (envOptionsJson?.endsWith('*/')) { diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 8833732de3e2..d432cad4767d 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -3,6 +3,7 @@ import { readdirSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { beforeAll, describe, expect, onTestFailed, test } from 'vitest' import { rolldownVersion } from 'vitest/node' +import { buildTestTree } from '../../test-utils' import { instances, provider, runBrowserTests } from './utils' function noop() {} @@ -76,6 +77,35 @@ describe('running browser tests', async () => { expect(failedTests).toHaveLength(0) }) + test.only('tags are collected', () => { + expect(vitest.config.tags).toEqual([ + { name: 'e2e', priority: 10 }, + { name: 'test', priority: 5 }, + { name: 'browser', priority: 1 }, + ]) + + const testModule = vitest.state.getTestModules().find(m => m.moduleId.includes('tags.test.ts')) + expect.assert(testModule) + expect(buildTestTree([testModule], t => t.tags)).toMatchInlineSnapshot(` + { + "test/tags.test.ts": { + "suite 1": { + "suite 2": { + "test 2": [ + "browser", + "e2e", + ], + }, + "test 1": [ + "browser", + "test", + ], + }, + }, + } + `) + }) + test('runs in-source tests', () => { expect(stdout).toContain('src/actions.ts') const actionsTest = passedTests.find(t => t.name.includes('/actions.ts')) diff --git a/test/browser/test/tags.test.ts b/test/browser/test/tags.test.ts new file mode 100644 index 000000000000..a2747039ee9e --- /dev/null +++ b/test/browser/test/tags.test.ts @@ -0,0 +1,13 @@ +/** + * @module-tag browser + */ + +import { describe, test } from 'vitest' + +describe('suite 1', () => { + test('test 1', { tags: ['test'] }, () => {}) + + describe('suite 2', { tags: ['e2e'] }, () => { + test('test 2', () => {}) + }) +}) diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index c4c57f3defc6..a58a540dcea7 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -97,6 +97,11 @@ export default defineConfig({ }, }, }, + tags: [ + { name: 'e2e', priority: 10 }, + { name: 'test', priority: 5 }, + { name: 'browser', priority: 1 }, + ], alias: { '#src': resolve(dir, './src'), }, diff --git a/test/cli/fixtures/file-tags/error-file-one-line-comment.test.ts b/test/cli/fixtures/file-tags/error-file-one-line-comment.test.ts new file mode 100644 index 000000000000..3aea0d76110a --- /dev/null +++ b/test/cli/fixtures/file-tags/error-file-one-line-comment.test.ts @@ -0,0 +1,8 @@ +// @module-tag invalid +// @module-tag unknown + +import { describe, test } from 'vitest' + +describe('suite 1', () => { + test('test 1', { tags: ['test'] }, () => {}) +}) diff --git a/test/cli/fixtures/file-tags/valid-file-one-line-comment.test.ts b/test/cli/fixtures/file-tags/valid-file-one-line-comment.test.ts new file mode 100644 index 000000000000..8db7d87e01f4 --- /dev/null +++ b/test/cli/fixtures/file-tags/valid-file-one-line-comment.test.ts @@ -0,0 +1,9 @@ +// @module-tag file +// @module-tag file-2 +// @module-tag file/slash + +import { describe, test } from 'vitest' + +describe('suite 1', () => { + test('test 1', { tags: ['test'] }, () => {}) +}) diff --git a/test/cli/fixtures/reporters/json-fail.test.ts b/test/cli/fixtures/reporters/json-fail.test.ts index 82e4ca6e3633..0b9b8a2a62bb 100644 --- a/test/cli/fixtures/reporters/json-fail.test.ts +++ b/test/cli/fixtures/reporters/json-fail.test.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest' // I am comment1 // I am comment2 -test('should fail', () => { +test('should fail', { tags: ['fail'] }, () => { // eslint-disable-next-line no-console console.log('json-fail>should fail') expect(2).toEqual(1) diff --git a/test/cli/test/reporters/__snapshots__/json.test.ts.snap b/test/cli/test/reporters/__snapshots__/json.test.ts.snap index f6e6f29ff286..14c5813b4568 100644 --- a/test/cli/test/reporters/__snapshots__/json.test.ts.snap +++ b/test/cli/test/reporters/__snapshots__/json.test.ts.snap @@ -14,6 +14,9 @@ exports[`json reporter > generates correct report 1`] = ` }, "meta": {}, "status": "failed", + "tags": [ + "fail", + ], "title": "should fail", } `; diff --git a/test/cli/test/reporters/json.test.ts b/test/cli/test/reporters/json.test.ts index f718712a04d6..7503297299a6 100644 --- a/test/cli/test/reporters/json.test.ts +++ b/test/cli/test/reporters/json.test.ts @@ -13,6 +13,7 @@ describe('json reporter', async () => { root, include: ['**/json-fail-import.test.ts', '**/json-fail.test.ts'], includeTaskLocation: true, + tags: [{ name: 'fail' }], }, ['json-fail']) const data = JSON.parse(stdout) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index c1faefa88aa9..7454037f699f 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -545,6 +545,404 @@ test('invalid @module-tag throws and error', async () => { `) }) +test('@module-tag on one line docs inject test tags', async () => { + const { stderr, buildTree } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./valid-file-one-line-comment.test.ts'], + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + expect(stderr).toBe('') + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` + { + "valid-file-one-line-comment.test.ts": { + "suite 1": { + "test 1": [ + "file", + "file-2", + "file/slash", + "test", + ], + }, + }, + } + `) +}) + +test('invalid @module-tag on one line throws and error', async () => { + const { stderr } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./error-file-one-line-comment.test.ts'], + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + expect(stderr).toMatchInlineSnapshot(` + " + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ + + FAIL error-file-one-line-comment.test.ts [ error-file-one-line-comment.test.ts ] + Error: The tag "invalid" is not defined in the configuration. Available tags are: + - file + - file-2 + - file/slash + - test + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ + + " + `) +}) + +test('@module-tag with strictTags: false allows undefined tags', async () => { + const { stderr, buildTree } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./error-file-tags.test.ts'], + strictTags: false, + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + expect(stderr).toBe('') + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` + { + "error-file-tags.test.ts": { + "suite 1": { + "test 1": [ + "invalid", + "unknown", + "test", + ], + }, + }, + } + `) +}) + +test('sequential tag option makes tests run sequentially', async () => { + const { stderr, buildTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['sequential-tag'] }, () => {}) + test('test 2', { tags: ['sequential-tag'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'sequential-tag', sequential: true }, + ], + }, + }, + }) + expect(stderr).toBe('') + // sequential is not visible in options, it affect "concurrent" only, which is not set if false + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": { + "mode": "run", + "tags": [ + "sequential-tag", + ], + "timeout": 5000, + }, + "test 2": { + "mode": "run", + "tags": [ + "sequential-tag", + ], + "timeout": 5000, + }, + }, + } + `) +}) + +test('only tag option marks tests as only', async () => { + const { stderr, buildTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['only-tag'] }, () => {}) + test('test 2', () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'only-tag', only: true }, + ], + }, + }, + }) + expect(stderr).toBe('') + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": { + "mode": "run", + "tags": [ + "only-tag", + ], + "timeout": 5000, + }, + "test 2": { + "mode": "skip", + "tags": [], + "timeout": 5000, + }, + }, + } + `) +}) + +test('tags without explicit priority use definition order (last wins)', async () => { + const { stderr, ctx } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['tag-a', 'tag-b'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'tag-a', timeout: 1000 }, + { name: 'tag-b', timeout: 2000 }, + ], + }, + }, + }) + expect(stderr).toBe('') + const testModule = ctx!.state.getTestModules()[0] + const testCase = testModule.children.at(0) as TestCase + expect(testCase.options.timeout).toBe(2000) +}) + +test('equal priority tags use definition order (last wins)', async () => { + const { stderr, ctx } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['tag-a', 'tag-b'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'tag-a', timeout: 1000, priority: 1 }, + { name: 'tag-b', timeout: 2000, priority: 1 }, + ], + }, + }, + }) + expect(stderr).toBe('') + const testModule = ctx!.state.getTestModules()[0] + const testCase = testModule.children.at(0) as TestCase + expect(testCase.options.timeout).toBe(2000) +}) + +test('negative priority values is not allowed', async () => { + const { stderr } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['low-priority', 'high-priority'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'low-priority', timeout: 1000, priority: -10 }, + ], + }, + }, + }, {}, { fails: true }) + expect(stderr).toContain('Tag "low-priority": priority must be a non-negative integer.') +}) + +test('strictTags: false does not allow undefined tags in filter, it only affects test definition', async () => { + const { stderr } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['known'] }, () => {}) + test('test 2', () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + strictTags: false, + tags: [{ name: 'known' }], + }, + }, + }, { + tagsFilter: ['unknown'], + }) + expect(stderr).toContain(`The tag pattern "unknown" is not defined in the configuration. Available tags are: +- known`) +}) + +test('duplicate tags from suite and test are deduplicated', async () => { + const { stderr, buildTree } = await runInlineTests({ + 'basic.test.js': ` + describe('suite', { tags: ['shared'] }, () => { + test('test 1', { tags: ['shared', 'unique'] }, () => {}) + }) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'shared' }, + { name: 'unique' }, + ], + }, + }, + }) + expect(stderr).toBe('') + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` + { + "basic.test.js": { + "suite": { + "test 1": [ + "shared", + "unique", + ], + }, + }, + } + `) +}) + +test('empty tags array on test is handled correctly', async () => { + const { stderr, buildTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: [] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [{ name: 'unused' }], + }, + }, + }) + expect(stderr).toBe('') + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": [], + }, + } + `) +}) + +test('filters tests with complex AND/OR expressions', async () => { + const { stderr, testTree } = 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' }, + ], + }, + }, + }, { + tagsFilter: ['(unit || e2e) && fast'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": "passed", + "test 2": "skipped", + "test 3": "passed", + "test 4": "skipped", + }, + } + `) +}) + +test('filters tests with NOT and parentheses', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['browser', 'chrome'] }, () => {}) + test('test 2', { tags: ['browser', 'firefox'] }, () => {}) + test('test 3', { tags: ['browser', 'edge'] }, () => {}) + test('test 4', { tags: ['node'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'browser' }, + { name: 'chrome' }, + { name: 'firefox' }, + { name: 'edge' }, + { name: 'node' }, + ], + }, + }, + }, { + tagsFilter: ['browser && !(edge)'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": "passed", + "test 2": "passed", + "test 3": "skipped", + "test 4": "skipped", + }, + } + `) +}) + +test('multiple filter expressions act as AND', async () => { + const { stderr, testTree } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', { tags: ['unit', 'fast'] }, () => {}) + test('test 2', { tags: ['unit', 'slow'] }, () => {}) + test('test 3', { tags: ['e2e', 'fast'] }, () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'unit' }, + { name: 'e2e' }, + { name: 'fast' }, + { name: 'slow' }, + ], + }, + }, + }, { + tagsFilter: ['unit || e2e', '!slow'], + }) + expect(stderr).toBe('') + expect(testTree()).toMatchInlineSnapshot(` + { + "basic.test.js": { + "test 1": "passed", + "test 2": "skipped", + "test 3": "passed", + }, + } + `) +}) + function getTestTree(builder: (fn: (test: TestCase) => any) => any) { return builder(test => test.options.tags) } From a18ee3824db7f479eeb1753117aed5faf11403de Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 19:34:20 +0100 Subject: [PATCH 24/48] test: fix tests --- test/cli/fixtures/reporters/vitest.config.ts | 3 ++ .../reporters/__snapshots__/html.test.ts.snap | 6 ++- .../__snapshots__/reporters.test.ts.snap | 54 +++++++++++++++++++ test/cli/test/reporters/json.test.ts | 1 - test/cli/test/reporters/merge-reports.test.ts | 5 ++ test/cli/test/test-tags.test.ts | 1 + 6 files changed, 67 insertions(+), 3 deletions(-) diff --git a/test/cli/fixtures/reporters/vitest.config.ts b/test/cli/fixtures/reporters/vitest.config.ts index 1b3a79748ed4..77e9d6e375f5 100644 --- a/test/cli/fixtures/reporters/vitest.config.ts +++ b/test/cli/fixtures/reporters/vitest.config.ts @@ -1,4 +1,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ + test: { + tags: [{ name: 'fail' }], + }, }) diff --git a/test/cli/test/reporters/__snapshots__/html.test.ts.snap b/test/cli/test/reporters/__snapshots__/html.test.ts.snap index 54435437bab4..723134a6c4e5 100644 --- a/test/cli/test/reporters/__snapshots__/html.test.ts.snap +++ b/test/cli/test/reporters/__snapshots__/html.test.ts.snap @@ -82,7 +82,9 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" "startTime": 0, "state": "fail", }, - "tags": [], + "tags": [ + "fail", + ], "timeout": 5000, "type": "test", }, @@ -115,7 +117,7 @@ exports[`html reporter > resolves to "failing" status for test file "json-fail" // I am comment1 // I am comment2 -test('should fail', () => { +test('should fail', { tags: ['fail'] }, () => { // eslint-disable-next-line no-console console.log('json-fail>should fail') expect(2).toEqual(1) diff --git a/test/cli/test/reporters/__snapshots__/reporters.test.ts.snap b/test/cli/test/reporters/__snapshots__/reporters.test.ts.snap index 998fc0c125a8..83345d33e225 100644 --- a/test/cli/test/reporters/__snapshots__/reporters.test.ts.snap +++ b/test/cli/test/reporters/__snapshots__/reporters.test.ts.snap @@ -151,6 +151,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -162,6 +163,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -172,6 +174,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -183,6 +186,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -194,6 +198,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -205,6 +210,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -216,6 +222,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -227,6 +234,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -237,6 +245,7 @@ exports[`json reporter (no outputFile entry) 1`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], @@ -295,6 +304,7 @@ exports[`json reporter 1`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -306,6 +316,7 @@ exports[`json reporter 1`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -316,6 +327,7 @@ exports[`json reporter 1`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -327,6 +339,7 @@ exports[`json reporter 1`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -338,6 +351,7 @@ exports[`json reporter 1`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -349,6 +363,7 @@ exports[`json reporter 1`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -360,6 +375,7 @@ exports[`json reporter 1`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -371,6 +387,7 @@ exports[`json reporter 1`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -381,6 +398,7 @@ exports[`json reporter 1`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], @@ -444,6 +462,7 @@ exports[`json reporter with outputFile 2`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -455,6 +474,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -465,6 +485,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -476,6 +497,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -487,6 +509,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -498,6 +521,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -509,6 +533,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -520,6 +545,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -530,6 +556,7 @@ exports[`json reporter with outputFile 2`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], @@ -593,6 +620,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -604,6 +632,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -614,6 +643,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -625,6 +655,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -636,6 +667,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -647,6 +679,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -658,6 +691,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -669,6 +703,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -679,6 +714,7 @@ exports[`json reporter with outputFile in non-existing directory 2`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], @@ -742,6 +778,7 @@ exports[`json reporter with outputFile object 2`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -753,6 +790,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -763,6 +801,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -774,6 +813,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -785,6 +825,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -796,6 +837,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -807,6 +849,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -818,6 +861,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -828,6 +872,7 @@ exports[`json reporter with outputFile object 2`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], @@ -891,6 +936,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` }, "meta": {}, "status": "failed", + "tags": [], "title": "Math.sqrt()", }, { @@ -902,6 +948,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite JSON", "meta": {}, "status": "passed", + "tags": [], "title": "JSON", }, { @@ -912,6 +959,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite async with timeout", "meta": {}, "status": "skipped", + "tags": [], "title": "async with timeout", }, { @@ -923,6 +971,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite timeout", "meta": {}, "status": "passed", + "tags": [], "title": "timeout", }, { @@ -934,6 +983,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite callback setup success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success ", }, { @@ -945,6 +995,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite callback test success ", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success ", }, { @@ -956,6 +1007,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite callback setup success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback setup success done(false)", }, { @@ -967,6 +1019,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite callback test success done(false)", "meta": {}, "status": "passed", + "tags": [], "title": "callback test success done(false)", }, { @@ -977,6 +1030,7 @@ exports[`json reporter with outputFile object in non-existing directory 2`] = ` "fullName": "suite todo test", "meta": {}, "status": "todo", + "tags": [], "title": "todo test", }, ], diff --git a/test/cli/test/reporters/json.test.ts b/test/cli/test/reporters/json.test.ts index 7503297299a6..f718712a04d6 100644 --- a/test/cli/test/reporters/json.test.ts +++ b/test/cli/test/reporters/json.test.ts @@ -13,7 +13,6 @@ describe('json reporter', async () => { root, include: ['**/json-fail-import.test.ts', '**/json-fail.test.ts'], includeTaskLocation: true, - tags: [{ name: 'fail' }], }, ['json-fail']) const data = JSON.parse(stdout) diff --git a/test/cli/test/reporters/merge-reports.test.ts b/test/cli/test/reporters/merge-reports.test.ts index 85b8ff6992e0..cd4f19af8b67 100644 --- a/test/cli/test/reporters/merge-reports.test.ts +++ b/test/cli/test/reporters/merge-reports.test.ts @@ -173,6 +173,7 @@ test('merge reports', async () => { "fullName": "test 1-1", "meta": {}, "status": "passed", + "tags": [], "title": "test 1-1", }, { @@ -184,6 +185,7 @@ test('merge reports', async () => { "fullName": "test 1-2", "meta": {}, "status": "failed", + "tags": [], "title": "test 1-2", }, ], @@ -204,6 +206,7 @@ test('merge reports', async () => { "fullName": "test 2-1", "meta": {}, "status": "failed", + "tags": [], "title": "test 2-1", }, { @@ -214,6 +217,7 @@ test('merge reports', async () => { "fullName": "group test 2-2", "meta": {}, "status": "passed", + "tags": [], "title": "test 2-2", }, { @@ -224,6 +228,7 @@ test('merge reports', async () => { "fullName": "group test 2-3", "meta": {}, "status": "passed", + "tags": [], "title": "test 2-3", }, ], diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 7454037f699f..596bd9bdcf86 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -682,6 +682,7 @@ test('only tag option marks tests as only', async () => { tags: [ { name: 'only-tag', only: true }, ], + allowOnly: true, }, }, }) From 93927991a9cf712f2416d9a249483b6b7c4bf1f8 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 19:44:41 +0100 Subject: [PATCH 25/48] test: remove only --- test/browser/specs/runner.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index d432cad4767d..d34e90c551bf 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -77,7 +77,7 @@ describe('running browser tests', async () => { expect(failedTests).toHaveLength(0) }) - test.only('tags are collected', () => { + test('tags are collected', () => { expect(vitest.config.tags).toEqual([ { name: 'e2e', priority: 10 }, { name: 'test', priority: 5 }, From 124a649e33608acabcb25981c3a9c59ec704ea47 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Mon, 19 Jan 2026 20:12:06 +0100 Subject: [PATCH 26/48] fix: validate test tag names --- docs/guide/test-tags.md | 4 + packages/runner/src/utils/tags.ts | 4 +- .../vitest/src/node/config/resolveConfig.ts | 7 +- test/cli/test/test-tags.test.ts | 48 +++++++++ test/core/test/test-tags-filter.test.ts | 101 ++++++++++++++++++ 5 files changed, 161 insertions(+), 3 deletions(-) diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 20f0fd0c6b58..008ca98c7d46 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -194,6 +194,10 @@ You can combine tags in different ways. Vitest supports these keywords: The parser follows standard [operator precedence](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_precedence): `not`/`!` has the highest priority, then `and`/`&&`, then `or`/`||`. Use parentheses to override default precedence. +::: warning Reserved Names +Tag names cannot be `and`, `or`, or `not` (case-insensitive) as these are reserved keywords. Tag names also cannot contain special characters (`(`, `)`, `&`, `|`, `!`, `*`, spaces) as these are used by the expression parser. +::: + ### Wildcards You can use a wildcard (`*`) to match any number of characters: diff --git a/packages/runner/src/utils/tags.ts b/packages/runner/src/utils/tags.ts index d88754cfa69e..2417fd993524 100644 --- a/packages/runner/src/utils/tags.ts +++ b/packages/runner/src/utils/tags.ts @@ -123,7 +123,9 @@ function tokenize(expr: string): Token[] { let tag = '' while (i < expr.length && expr[i] !== ' ' && expr[i] !== '\t' && expr[i] !== '(' && expr[i] !== ')' && expr[i] !== '!' && expr[i] !== '&' && expr[i] !== '|') { const remaining = expr.slice(i) - if (/^and(?:\s|\)|$)/i.test(remaining) || /^or(?:\s|\)|$)/i.test(remaining) || /^not\s/i.test(remaining)) { + // Only treat and/or/not as operators if we're at the start of a tag (after whitespace) + // This allows tags like "demand", "editor", "cannot" to work correctly + if (tag === '' && (/^and(?:\s|\)|$)/i.test(remaining) || /^or(?:\s|\)|$)/i.test(remaining) || /^not\s/i.test(remaining))) { break } tag += expr[i] diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 0bbb74737b97..ae3fb6e57acb 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -177,8 +177,11 @@ export function resolveConfig( if (tag.name.match(/\s/)) { throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain spaces.`) } - if (tag.name.startsWith('!')) { - throw new Error(`Tag name "${tag.name}" cannot start with "!".`) + if (tag.name.match(/([!()*|&])/)) { + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain "!", "*", "&", "|", "(", or ")".`) + } + if (tag.name.match(/^\s*(and|or|not)\s*$/i)) { + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot be a logical operator like "and", "or", "not".`) } if (typeof tag.retry === 'object' && typeof tag.retry.condition === 'function') { throw new TypeError(`Tag "${tag.name}": retry.condition function cannot be used inside a config file. Use a RegExp pattern instead, or define the function in your test file.`) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 596bd9bdcf86..883e49b4f929 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -766,6 +766,54 @@ test('negative priority values is not allowed', async () => { expect(stderr).toContain('Tag "low-priority": priority must be a non-negative integer.') }) +test.for([ + '!invalid', + 'inv*alid', + 'inv&alid', + 'inv|alid', + 'inv(alid', + 'inv)alid', +])('tag name "%s" containing special character "%s" is not allowed', async (tagName) => { + const { stderr } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: tagName }, + ], + }, + }, + }, {}, { fails: true }) + expect(stderr).toContain(`Tag name "${tagName}" is invalid. Tag names cannot contain "!", "*", "&", "|", "(", or ")".`) +}) + +test.for([ + 'and', + 'or', + 'not', + 'AND', + 'OR', + 'NOT', +])('tag name "%s" is a reserved keyword and is not allowed', async (tagName) => { + const { stderr } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: tagName }, + ], + }, + }, + }, {}, { fails: true }) + expect(stderr).toContain(`Tag name "${tagName}" is invalid. Tag names cannot be a logical operator like "and", "or", "not".`) +}) + test('strictTags: false does not allow undefined tags in filter, it only affects test definition', async () => { const { stderr } = await runInlineTests({ 'basic.test.js': ` diff --git a/test/core/test/test-tags-filter.test.ts b/test/core/test/test-tags-filter.test.ts index 49e36137e160..a1b07009be35 100644 --- a/test/core/test/test-tags-filter.test.ts +++ b/test/core/test/test-tags-filter.test.ts @@ -322,6 +322,62 @@ describe('createTagsFilter', () => { expect(filter(['v1.0'])).toBe(true) expect(filter(['v2.0'])).toBe(false) }) + + test('tags with @ symbol and non-alphanumeric characters', () => { + const filter = createTagsFilter(['@scope/tag'], tags('@scope/tag', '@other/tag')) + expect(filter(['@scope/tag'])).toBe(true) + expect(filter(['@other/tag'])).toBe(false) + }) + + test('tags with UTF-8 characters', () => { + const filter = createTagsFilter(['测试'], tags('测试', '测试2', 'test')) + expect(filter(['测试'])).toBe(true) + expect(filter(['测试2'])).toBe(false) + expect(filter(['test'])).toBe(false) + }) + + test('tags with @ followed by UTF-8 characters', () => { + const filter = createTagsFilter(['@日本語'], tags('@日本語', '@中文', 'english')) + expect(filter(['@日本語'])).toBe(true) + expect(filter(['@中文'])).toBe(false) + expect(filter(['english'])).toBe(false) + }) + + test('tags with mixed special characters and UTF-8', () => { + const filter = createTagsFilter(['@tag-名前_v1.0'], tags('@tag-名前_v1.0', '@tag-other_v1.0')) + expect(filter(['@tag-名前_v1.0'])).toBe(true) + expect(filter(['@tag-other_v1.0'])).toBe(false) + }) + + test('tags with emoji characters', () => { + const filter = createTagsFilter(['test-🚀'], tags('test-🚀', 'test-💚', 'test')) + expect(filter(['test-🚀'])).toBe(true) + expect(filter(['test-💚'])).toBe(false) + }) + + test('tags with special chars containing operator keywords', () => { + const filter = createTagsFilter(['or@tag || and@test || not@feature'], tags('or@tag', 'and@test', 'not@feature', 'other')) + expect(filter(['or@tag'])).toBe(true) + expect(filter(['and@test'])).toBe(true) + expect(filter(['not@feature'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tags with UTF-8 chars containing operator keywords', () => { + const filter = createTagsFilter(['or日本語 || and中文 || not한국어'], tags('or日本語', 'and中文', 'not한국어', 'english')) + expect(filter(['or日本語'])).toBe(true) + expect(filter(['and中文'])).toBe(true) + expect(filter(['not한국어'])).toBe(true) + expect(filter(['english'])).toBe(false) + }) + + test('operator keywords with @ and UTF-8 in complex expressions', () => { + const filter = createTagsFilter(['(or@tag || and@test) && not@feature'], tags('or@tag', 'and@test', 'not@feature', 'other')) + expect(filter(['or@tag', 'not@feature'])).toBe(true) + expect(filter(['and@test', 'not@feature'])).toBe(true) + expect(filter(['or@tag'])).toBe(false) + expect(filter(['other'])).toBe(false) + }) }) describe('edge cases', () => { @@ -358,6 +414,51 @@ describe('createTagsFilter', () => { expect(filter(['nothing'])).toBe(true) expect(filter(['something'])).toBe(false) }) + + test('tag containing "and" in the middle', () => { + const filter = createTagsFilter(['standalone'], tags('standalone', 'other')) + expect(filter(['standalone'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tag containing "or" in the middle', () => { + const filter = createTagsFilter(['priority'], tags('priority', 'other')) + expect(filter(['priority'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tag containing "not" in the middle', () => { + const filter = createTagsFilter(['annotation'], tags('annotation', 'other')) + expect(filter(['annotation'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tag ending with "and"', () => { + const filter = createTagsFilter(['demand'], tags('demand', 'other')) + expect(filter(['demand'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tag ending with "or"', () => { + const filter = createTagsFilter(['editor'], tags('editor', 'other')) + expect(filter(['editor'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('tag ending with "not"', () => { + const filter = createTagsFilter(['cannot'], tags('cannot', 'other')) + expect(filter(['cannot'])).toBe(true) + expect(filter(['other'])).toBe(false) + }) + + test('complex expression with tags containing operator substrings', () => { + const filter = createTagsFilter(['android and editor or nothing'], tags('android', 'editor', 'nothing')) + // Parsed as: android AND editor OR nothing + expect(filter(['android', 'editor'])).toBe(true) + expect(filter(['nothing'])).toBe(true) + expect(filter(['android'])).toBe(false) + expect(filter(['editor'])).toBe(false) + }) }) describe('validation errors', () => { From c9d7e3d8693463f1cf19bd54af65e3b50f351853 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 11:41:13 +0100 Subject: [PATCH 27/48] chore: cleanup --- docs/api/advanced/test-case.md | 2 +- docs/config/tags.md | 6 +----- docs/guide/test-tags.md | 2 +- packages/runner/src/suite.ts | 3 +-- packages/vitest/src/node/config/resolveConfig.ts | 4 ++-- test/cli/test/test-tags.test.ts | 2 +- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/docs/api/advanced/test-case.md b/docs/api/advanced/test-case.md index 977fc29cc2d2..c9601650bc40 100644 --- a/docs/api/advanced/test-case.md +++ b/docs/api/advanced/test-case.md @@ -113,7 +113,7 @@ interface TaskOptions { The options that test was collected with. -## tags 4.1.0 +## tags 4.1.0 {#tags} [Tags](/guide/test-tags) that were implicitly or explicitly assigned to the test. diff --git a/docs/config/tags.md b/docs/config/tags.md index 16c60c75bddc..d0efce554c24 100644 --- a/docs/config/tags.md +++ b/docs/config/tags.md @@ -14,10 +14,6 @@ If you are using [`projects`](/config/projects), they will inherit all global ta Use [`--tags-filter`](/guide/test-tags#syntax) to filter tests by their tags. -::: tip FILTERING -You can use a wildcard (*) to match any number of symbols. To ignore a tag, add an exclamation mark (!) at the start of the tag. -::: - ## name - **Type:** `string` @@ -76,7 +72,7 @@ export default defineConfig({ ## priority - **Type:** `number` -- **Default:** `undefined` +- **Default:** `Infinity` Priority for merging options when multiple tags with the same options are applied to a test. Lower number means higher priority (e.g., priority `1` takes precedence over priority `3`). diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 008ca98c7d46..8189f8ff98be 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -45,7 +45,7 @@ export default defineConfig({ ``` ::: warning -If several tags have the same options and are applied to the same test, they will be resolved in order of application or sorted by `priority` first (the lower the number, the higher the priority). Tags without a defined priority are resolved last: +If several tags have the same options and are used on the same test, they will be resolved in the order they were specified, or sorted by priority first (the lower the number, the higher the priority). Tags without a defined priority are resolved last: ```ts test('flaky database test', { tags: ['flaky', 'db'] }) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index c323d35b3784..c249988f8e6e 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -621,8 +621,7 @@ function createSuite() { ? 'todo' : 'run' - // passed not factory, but also didn't tag it as todo or skip - // assume it's todo + // passed as test(name), assume it's a "todo" if (mode === 'run' && !factory) { mode = 'todo' } diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index ae3fb6e57acb..95cd4398b8e0 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -186,8 +186,8 @@ export function resolveConfig( if (typeof tag.retry === 'object' && typeof tag.retry.condition === 'function') { throw new TypeError(`Tag "${tag.name}": retry.condition function cannot be used inside a config file. Use a RegExp pattern instead, or define the function in your test file.`) } - if (tag.priority != null && (typeof tag.priority !== 'number' || !Number.isInteger(tag.priority) || tag.priority < 0)) { - throw new TypeError(`Tag "${tag.name}": priority must be a non-negative integer.`) + if (tag.priority != null && (typeof tag.priority !== 'number' || tag.priority < 0)) { + throw new TypeError(`Tag "${tag.name}": priority must be a non-negative number.`) } }) diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 883e49b4f929..0d4357d360a2 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -763,7 +763,7 @@ test('negative priority values is not allowed', async () => { }, }, }, {}, { fails: true }) - expect(stderr).toContain('Tag "low-priority": priority must be a non-negative integer.') + expect(stderr).toContain('Tag "low-priority": priority must be a non-negative number.') }) test.for([ From 25c00118e41f8152d61f0d77a300cfdede8ab463 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 13:32:00 +0100 Subject: [PATCH 28/48] feat: add --list-tags --- docs/.vitepress/scripts/cli-generator.ts | 1 + docs/guide/test-tags.md | 11 + packages/vitest/src/node/cli/cac.ts | 2 +- packages/vitest/src/node/cli/cli-api.ts | 5 +- packages/vitest/src/node/cli/cli-config.ts | 3 + .../vitest/src/node/config/resolveConfig.ts | 5 + packages/vitest/src/node/core.ts | 12 +- packages/vitest/src/node/logger.ts | 27 +++ .../src/node/projects/resolveProjects.ts | 22 +- packages/vitest/src/node/tags.ts | 2 +- packages/vitest/src/node/types/config.ts | 5 + test/cli/test/test-tags.test.ts | 197 +++++++++++++++++- 12 files changed, 280 insertions(+), 12 deletions(-) diff --git a/docs/.vitepress/scripts/cli-generator.ts b/docs/.vitepress/scripts/cli-generator.ts index 820c95123c89..ee39aa62d398 100644 --- a/docs/.vitepress/scripts/cli-generator.ts +++ b/docs/.vitepress/scripts/cli-generator.ts @@ -43,6 +43,7 @@ const skipConfig = new Set([ 'browser.fileParallelism', 'clearCache', 'tagsFilter', + 'listTags', ]) function resolveOptions(options: CLIOptions, parentName?: string) { diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index 8189f8ff98be..ca588493473a 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -78,6 +78,17 @@ declare module 'vitest' { } ``` +To see all your tags, you can use [`--list-tags`](/guide/cli#listtags) command: + +```shell +vitest --list-tags + +frontend: Tests written for frontend. +backend: Tests written for backend. +db: Tests for database queries. +flaky: Flaky CI tests. +``` + ## Using Tags in Tests You can apply tags to individual tests or entire suites using the `tags` option: diff --git a/packages/vitest/src/node/cli/cac.ts b/packages/vitest/src/node/cli/cac.ts index 3cd29fcbb15e..252052dfcb59 100644 --- a/packages/vitest/src/node/cli/cac.ts +++ b/packages/vitest/src/node/cli/cac.ts @@ -288,7 +288,7 @@ function normalizeCliOptions(cliFilters: string[], argv: CliOptions): CliOptions if (typeof argv.typecheck?.only === 'boolean') { argv.typecheck.enabled ??= true } - if (argv.clearCache) { + if (argv.clearCache || argv.listTags) { argv.watch = false argv.run = true } diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index f06df2a4a9df..fdb602294948 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -91,7 +91,10 @@ export async function startVitest( }) try { - if (ctx.config.clearCache) { + if (ctx.config.listTags) { + await ctx.listTags() + } + else if (ctx.config.clearCache) { await ctx.experimental_clearCache() } else if (ctx.config.mergeReports) { diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index a9ad772bee2c..3c87959051e3 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -785,6 +785,9 @@ export const cliOptionsConfig: VitestCLIOptions = { return value }, }, + listTags: { + description: 'List all available tags instead of running tests.', + }, clearCache: { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', }, diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 95cd4398b8e0..52825ac2ef15 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -170,10 +170,14 @@ export function resolveConfig( // shallow copy tags array to avoid mutating user config resolved.tags = [...resolved.tags || []] + const definedTags = new Set() resolved.tags.forEach((tag) => { if (!tag.name || typeof tag.name !== 'string') { throw new Error(`Each tag defined in "test.tags" must have a "name" property, received: ${JSON.stringify(tag)}`) } + if (definedTags.has(tag.name)) { + throw new Error(`Tag name "${tag.name}" is already defined in "test.tags". Tag names must be unique.`) + } if (tag.name.match(/\s/)) { throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain spaces.`) } @@ -189,6 +193,7 @@ export function resolveConfig( if (tag.priority != null && (typeof tag.priority !== 'number' || tag.priority < 0)) { throw new TypeError(`Tag "${tag.name}": priority must be a non-negative number.`) } + definedTags.add(tag.name) }) resolved.name = typeof options.name === 'string' diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 0ae6512835d4..a8acd74863e9 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -47,7 +47,7 @@ import { createBenchmarkReporters, createReporters } from './reporters/utils' import { VitestResolver } from './resolver' import { VitestSpecifications } from './specifications' import { StateManager } from './state' -import { validateProjectsTags } from './tags' +import { populateProjectsTags } from './tags' import { TestRun } from './test-run' import { VitestWatcher } from './watcher' @@ -319,7 +319,11 @@ export class Vitest { this.configOverride.testNamePattern = this.config.testNamePattern } - validateProjectsTags(this.coreWorkspaceProject, this.projects) + // populate will merge all configs into every project, + // we don't want that when just listing tags + if (!this.config.listTags) { + populateProjectsTags(this.coreWorkspaceProject, this.projects) + } this.reporters = resolved.mode === 'benchmark' ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) @@ -341,6 +345,10 @@ export class Vitest { return this._coverageProvider } + public async listTags(): Promise { + this.logger.printTags() + } + public async enableCoverage(): Promise { this.configOverride.coverage = {} as any this.configOverride.coverage!.enabled = true diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 22022152cfb8..481119108f32 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -131,6 +131,33 @@ export class Logger { return code } + printTags(): void { + const vitest = this.ctx + const rootProject = vitest.getRootProject() + const projects = [ + rootProject, + ...vitest.projects.filter(p => p !== rootProject), + ] + + const hasTags = projects.some(p => p.config.tags && p.config.tags.length > 0) + + if (!hasTags) { + process.exitCode = 1 + return this.error(c.bgRed(' ERROR '), c.red('No test tags defined in any project. Exiting with code 1.')) + } + + for (const project of projects) { + const name = project.name + if (name) { + this.log(formatProjectName(project, '')) + } + project.config.tags.forEach((tag) => { + const tagLog = `${tag.name}${tag.description ? `: ${tag.description}` : ''}` + this.log(` ${tagLog}`) + }) + } + } + printNoTestFound(filters?: string[]): void { const config = this.ctx.config diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index 19c26dcbd5a5..3ef0bc098582 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -88,7 +88,27 @@ export async function resolveProjects( projectPromises.push(concurrent(() => initializeProject( index, vitest, - { ...options, root, configFile, test: { ...options.test, ...cliOverrides } }, + { + ...options, + root, + configFile, + plugins: [ + { + name: 'vitest:tags', + // don't inherit tags from workspace config, they are merged separately + configResolved(config) { + ;(config as any).test ??= {} + config.test!.tags = options.test?.tags + }, + api: { + vitest: { + experimental: { ignoreFsModuleCache: true }, + }, + }, + }, + ], + test: { ...options.test, ...cliOverrides }, + }, ))) }) diff --git a/packages/vitest/src/node/tags.ts b/packages/vitest/src/node/tags.ts index ed757f4088d1..d0972628239a 100644 --- a/packages/vitest/src/node/tags.ts +++ b/packages/vitest/src/node/tags.ts @@ -1,7 +1,7 @@ import type { TestTagDefinition } from '@vitest/runner' import type { TestProject } from './project' -export function validateProjectsTags(rootProject: TestProject, projects: TestProject[]): void { +export function populateProjectsTags(rootProject: TestProject, projects: TestProject[]): void { // Include root project if not already in the list const allProjects = projects.includes(rootProject) ? projects : [rootProject, ...projects] diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index c5f52864c5f8..eef0d6159285 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1016,6 +1016,11 @@ export interface UserConfig extends InlineConfig { * @see {@link https://vitest.dev/guide/test-tags#syntax} */ tagsFilter?: string[] + + /** + * Log all available tags instead of running tests. + */ + listTags?: boolean } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 0d4357d360a2..34e3b1aaa054 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -261,9 +261,6 @@ test('defining a tag available only in one project', async () => { expect(Object.fromEntries(ctx!.projects.map(p => [p.name, p.config.tags]))).toMatchInlineSnapshot(` { "project-1": [ - { - "name": "global-tag", - }, { "name": "project-1-tag", }, @@ -271,14 +268,14 @@ test('defining a tag available only in one project', async () => { "name": "override", "timeout": 100, }, + { + "name": "global-tag", + }, { "name": "project-2-tag", }, ], "project-2": [ - { - "name": "global-tag", - }, { "name": "project-2-tag", }, @@ -286,6 +283,9 @@ test('defining a tag available only in one project', async () => { "name": "override", "timeout": 200, }, + { + "name": "global-tag", + }, { "name": "project-1-tag", }, @@ -834,6 +834,172 @@ test('strictTags: false does not allow undefined tags in filter, it only affects - known`) }) +test('--list-tags prints error if no tags are defined', async () => { + const { stdout, stderr, exitCode } = await runVitest({ + config: false, + listTags: true, + }) + expect(stdout).toBe('') + expect(exitCode).toBe(1) + expect(stderr).toMatchInlineSnapshot(` + " ERROR No test tags defined in any project. Exiting with code 1. + " + `) +}) + +test('--list-tags prints tags defined in config', async () => { + const { stdout, stderr } = await runVitest({ + config: false, + listTags: true, + tags: [ + { name: 'unit' }, + { name: 'e2e', description: 'End-to-end tests' }, + { name: 'slow' }, + ], + }) + expect(stderr).toBe('') + expect(`\n${stdout}`).toMatchInlineSnapshot(` + " + unit + e2e: End-to-end tests + slow + " + `) +}) + +test('--list-tags prints tags from multiple projects', async () => { + const { stdout, stderr } = await runInlineTests({ + 'vitest.config.js': { + test: { + tags: [ + { name: 'global-tag', description: 'Available in all projects' }, + ], + projects: [ + { + extends: true, + test: { + name: 'project-1', + tags: [ + { name: 'project-1-tag' }, + ], + }, + }, + { + extends: true, + test: { + name: 'project-2', + tags: [ + { name: 'project-2-tag', description: 'Only in project 2' }, + { name: 'project-2-again' }, + ], + }, + }, + ], + }, + }, + }, { + listTags: true, + }) + expect(stderr).toBe('') + expect(`\n${stdout}`).toMatchInlineSnapshot(` + " + global-tag: Available in all projects + |project-1| + project-1-tag + |project-2| + project-2-tag: Only in project 2 + project-2-again + " + `) +}) + +test('--list-tags prints tags with named root project', async () => { + const { stdout, stderr } = await runInlineTests({ + 'vitest.config.js': { + test: { + name: 'root', + tags: [ + { name: 'root-tag' }, + { name: 'another-tag', description: 'From root' }, + ], + projects: [ + { + extends: true, + test: { + name: 'child', + tags: [ + { name: 'child-tag' }, + { name: 'child-2-tag' }, + ], + }, + }, + ], + }, + }, + }, { + listTags: true, + }) + expect(stderr).toBe('') + expect(`\n${stdout}`).toMatchInlineSnapshot(` + " + |root| + root-tag + another-tag: From root + |child| + child-tag + child-2-tag + " + `) +}) + +test('--list-tags aligns tags with different project name lengths', async () => { + const { stdout, stderr } = await runVitest({ + config: false, + listTags: true, + projects: [ + { + test: { + name: 'a', + tags: [ + { name: 'tag-1' }, + { name: 'tag-2' }, + ], + }, + }, + { + test: { + name: 'long-project-name', + tags: [ + { name: 'tag-3' }, + { name: 'tag-4' }, + ], + }, + }, + { + test: { + name: 'medium', + tags: [ + { name: 'tag-5' }, + ], + }, + }, + ], + }) + expect(stderr).toBe('') + expect(`\n${stdout}`).toMatchInlineSnapshot(` + " + |a| + tag-1 + tag-2 + |long-project-name| + tag-3 + tag-4 + |medium| + tag-5 + " + `) +}) + test('duplicate tags from suite and test are deduplicated', async () => { const { stderr, buildTree } = await runInlineTests({ 'basic.test.js': ` @@ -959,6 +1125,25 @@ test('filters tests with NOT and parentheses', async () => { `) }) +test('throws an error when several tags with the same name are defined', async () => { + const { stderr } = await runInlineTests({ + 'basic.test.js': ` + test('test 1', () => {}) + `, + 'vitest.config.js': { + test: { + globals: true, + tags: [ + { name: 'duplicate', timeout: 1000 }, + { name: 'unique' }, + { name: 'duplicate', timeout: 2000 }, + ], + }, + }, + }, {}, { fails: true }) + expect(stderr).toContain('Tag name "duplicate" is already defined in "test.tags". Tag names must be unique.') +}) + test('multiple filter expressions act as AND', async () => { const { stderr, testTree } = await runInlineTests({ 'basic.test.js': ` From b9e2720a0bf6cb41e69b21cd563a6624a49150b1 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 13:46:28 +0100 Subject: [PATCH 29/48] feat: support `--test-tags=json` --- docs/guide/test-tags.md | 30 ++++++++ packages/vitest/src/node/cli/cli-config.ts | 3 +- packages/vitest/src/node/core.ts | 25 ++++++- packages/vitest/src/node/logger.ts | 6 +- packages/vitest/src/node/types/config.ts | 2 +- test/cli/test/test-tags.test.ts | 80 +++++++++++++++++++++- 6 files changed, 141 insertions(+), 5 deletions(-) diff --git a/docs/guide/test-tags.md b/docs/guide/test-tags.md index ca588493473a..b270ed0679e2 100644 --- a/docs/guide/test-tags.md +++ b/docs/guide/test-tags.md @@ -89,6 +89,36 @@ db: Tests for database queries. flaky: Flaky CI tests. ``` +To print it in JSON, pass down `--list-tags=json`: + +```json +{ + "tags": [ + { + "name": "frontend", + "description": "Tests written for frontend." + }, + { + "name": "backend", + "description": "Tests written for backend." + }, + { + "name": "db", + "description": "Tests for database queries.", + "timeout": 60000 + }, + { + "name": "flaky", + "description": "Flaky CI tests.", + "retry": 0, + "timeout": 30000, + "priority": 1 + } + ], + "projects": [] +} +``` + ## Using Tags in Tests You can apply tags to individual tests or entire suites using the `tags` option: diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 3c87959051e3..6de899c8a2eb 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -786,7 +786,8 @@ export const cliOptionsConfig: VitestCLIOptions = { }, }, listTags: { - description: 'List all available tags instead of running tests.', + description: 'List all available tags instead of running tests. If --list-tags=json is used, the output will be in JSON format, unless there are no tags.', + argument: '[type]', }, clearCache: { description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index a8acd74863e9..084ee3fa07c3 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -346,7 +346,30 @@ export class Vitest { } public async listTags(): Promise { - this.logger.printTags() + const listTags = this.config.listTags + if (typeof listTags === 'boolean') { + this.logger.printTags() + } + else if (listTags === 'json') { + const hasTags = [this.getRootProject(), ...this.projects].some(p => p.config.tags && p.config.tags.length > 0) + if (!hasTags) { + process.exitCode = 1 + this.logger.printNoTestTagsFound() + } + else { + const manifest = { + tags: this.config.tags, + projects: this.projects.filter(p => p !== this.coreWorkspaceProject).map(p => ({ + name: p.name, + tags: p.config.tags, + })), + } + this.logger.log(JSON.stringify(manifest, null, 2)) + } + } + else { + throw new Error(`Unknown value for "test.listTags": ${listTags}`) + } } public async enableCoverage(): Promise { diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 481119108f32..457ea0db436a 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -131,6 +131,10 @@ export class Logger { return code } + printNoTestTagsFound(): void { + this.error(c.bgRed(' ERROR '), c.red('No test tags found in any project. Exiting with code 1.')) + } + printTags(): void { const vitest = this.ctx const rootProject = vitest.getRootProject() @@ -143,7 +147,7 @@ export class Logger { if (!hasTags) { process.exitCode = 1 - return this.error(c.bgRed(' ERROR '), c.red('No test tags defined in any project. Exiting with code 1.')) + return this.printNoTestTagsFound() } for (const project of projects) { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index eef0d6159285..98000bd24a97 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -1020,7 +1020,7 @@ export interface UserConfig extends InlineConfig { /** * Log all available tags instead of running tests. */ - listTags?: boolean + listTags?: boolean | string } export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void diff --git a/test/cli/test/test-tags.test.ts b/test/cli/test/test-tags.test.ts index 34e3b1aaa054..426f81306d58 100644 --- a/test/cli/test/test-tags.test.ts +++ b/test/cli/test/test-tags.test.ts @@ -842,7 +842,7 @@ test('--list-tags prints error if no tags are defined', async () => { expect(stdout).toBe('') expect(exitCode).toBe(1) expect(stderr).toMatchInlineSnapshot(` - " ERROR No test tags defined in any project. Exiting with code 1. + " ERROR No test tags found in any project. Exiting with code 1. " `) }) @@ -1000,6 +1000,84 @@ test('--list-tags aligns tags with different project name lengths', async () => `) }) +test('--list-tags=json prints error if no tags are defined', async () => { + const { stdout, stderr } = await runVitest({ + config: false, + listTags: 'json', + }) + expect(stdout).toBe('') + expect(stderr).toContain('No test tags found in any project. Exiting with code 1.') +}) + +test('--list-tags=json prints tags as JSON', async () => { + const { stdout, stderr } = await runVitest({ + config: false, + listTags: 'json', + tags: [ + { name: 'unit' }, + { name: 'e2e', description: 'End-to-end tests' }, + ], + }) + expect(stderr).toBe('') + const json = JSON.parse(stdout) + expect(json).toEqual({ + tags: [ + { name: 'unit' }, + { name: 'e2e', description: 'End-to-end tests' }, + ], + projects: [], + }) +}) + +test('--list-tags=json prints tags from multiple projects', async () => { + const { stdout, stderr } = await runVitest({ + config: false, + listTags: 'json', + tags: [ + { name: 'global-tag' }, + ], + projects: [ + { + test: { + name: 'project-1', + tags: [ + { name: 'project-1-tag' }, + ], + }, + }, + { + test: { + name: 'project-2', + tags: [ + { name: 'project-2-tag', description: 'Only in project 2' }, + ], + }, + }, + ], + }) + expect(stderr).toBe('') + const json = JSON.parse(stdout) + expect(json).toEqual({ + tags: [ + { name: 'global-tag' }, + ], + projects: [ + { + name: 'project-1', + tags: [ + { name: 'project-1-tag' }, + ], + }, + { + name: 'project-2', + tags: [ + { name: 'project-2-tag', description: 'Only in project 2' }, + ], + }, + ], + }) +}) + test('duplicate tags from suite and test are deduplicated', async () => { const { stderr, buildTree } = await runInlineTests({ 'basic.test.js': ` From bcaba86466bd888269e7dd4aa88f3e70e743955f Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 14:25:20 +0100 Subject: [PATCH 30/48] fix: support collecting tags statically --- packages/runner/src/utils/index.ts | 2 +- packages/runner/src/utils/tags.ts | 10 +- packages/vitest/src/node/ast-collect.ts | 95 +++++--- test/cli/test/static-collect.test.ts | 293 +++++++++++++++++++++++- 4 files changed, 364 insertions(+), 36 deletions(-) diff --git a/packages/runner/src/utils/index.ts b/packages/runner/src/utils/index.ts index 042068f4fbbe..fc3f347941cb 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 } from './tags' +export { createTagsFilter, validateTags } from './tags' export { createTaskName, getFullName, diff --git a/packages/runner/src/utils/tags.ts b/packages/runner/src/utils/tags.ts index 2417fd993524..b41843d39456 100644 --- a/packages/runner/src/utils/tags.ts +++ b/packages/runner/src/utils/tags.ts @@ -1,14 +1,14 @@ -import type { TestTagDefinition, VitestRunner } from '../types/runner' +import type { TestTagDefinition, VitestRunnerConfig } from '../types/runner' -export function validateTags(runner: VitestRunner, tags: string[]): void { - if (!runner.config.strictTags) { +export function validateTags(config: VitestRunnerConfig, tags: string[]): void { + if (!config.strictTags) { return } - const availableTags = new Set(runner.config.tags.map(tag => tag.name)) + const availableTags = new Set(config.tags.map(tag => tag.name)) for (const tag of tags) { if (!availableTags.has(tag)) { - throw createNoTagsError(runner.config.tags, tag) + throw createNoTagsError(config.tags, tag) } } } diff --git a/packages/vitest/src/node/ast-collect.ts b/packages/vitest/src/node/ast-collect.ts index bcdc0e4f47dc..2760c0481ea1 100644 --- a/packages/vitest/src/node/ast-collect.ts +++ b/packages/vitest/src/node/ast-collect.ts @@ -1,6 +1,8 @@ import type { File, Suite, Task, Test } from '@vitest/runner' +import type { SerializedConfig } from '../runtime/config' import type { TestError } from '../types/general' import type { TestProject } from './project' +import { promises as fs } from 'node:fs' import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' import { calculateSuiteHash, @@ -8,12 +10,14 @@ import { generateHash, interpretTaskModes, someTasksAreOnly, + validateTags, } from '@vitest/runner/utils' import { ancestor as walkAst } from 'acorn-walk' import { relative } from 'pathe' import { parseAst } from 'vite' import { createIndexLocationsMap } from '../utils/base' import { createDebugger } from '../utils/debugger' +import { detectCodeBlock } from '../utils/test-helpers' interface ParsedFile extends File { start: number @@ -40,6 +44,7 @@ interface LocalCallDefinition { mode: 'run' | 'skip' | 'only' | 'todo' | 'queued' task: ParsedSuite | ParsedFile | ParsedTest dynamic: boolean + tags: string[] } const debug = createDebugger('vitest:ast-collect-info') @@ -178,7 +183,31 @@ function astParseFile(filepath: string, code: string) { isDynamicEach = property === 'each' || property === 'for' } - debug?.('Found', name, message, `(${mode})`) + // Extract tags from the second argument if it's an options object + const tags: string[] = [] + const secondArg = node.arguments?.[1] + if (secondArg?.type === 'ObjectExpression') { + const tagsProperty = secondArg.properties?.find( + (p: any) => p.type === 'Property' && p.key?.type === 'Identifier' && p.key.name === 'tags', + ) as any + if (tagsProperty) { + const tagsValue = tagsProperty.value + if (tagsValue?.type === 'Literal' && typeof tagsValue.value === 'string') { + // tags: 'single-tag' + tags.push(tagsValue.value) + } + else if (tagsValue?.type === 'ArrayExpression') { + // tags: ['tag1', 'tag2'] + for (const element of tagsValue.elements || []) { + if (element?.type === 'Literal' && typeof element.value === 'string') { + tags.push(element.value) + } + } + } + } + } + + debug?.('Found', name, message, `(${mode})`, tags.length ? `[${tags.join(', ')}]` : '') definitions.push({ start, end, @@ -187,6 +216,7 @@ function astParseFile(filepath: string, code: string) { mode, task: null as any, dynamic: isDynamicEach, + tags, } satisfies LocalCallDefinition) }, }) @@ -243,35 +273,30 @@ function serializeError(ctx: TestProject, error: any): TestError[] { ] } -interface ParseOptions { - name: string - filepath: string - allowOnly: boolean - pool: string - testNamePattern?: RegExp | undefined -} - function createFileTask( testFilepath: string, code: string, requestMap: any, - options: ParseOptions, + config: SerializedConfig, + filepath: string, + fileTags: string[] | undefined, ) { const { definitions, ast } = astParseFile(testFilepath, code) const file: ParsedFile = { - filepath: options.filepath, + filepath, type: 'suite', - id: /* @__PURE__ */ generateHash(`${testFilepath}${options.name || ''}`), + id: /* @__PURE__ */ generateHash(`${testFilepath}${config.name || ''}`), name: testFilepath, fullName: testFilepath, mode: 'run', tasks: [], start: ast.start, end: ast.end, - projectName: options.name, + projectName: config.name, meta: {}, pool: 'browser', file: null!, + tags: fileTags || [], } file.file = file const indexMap = createIndexLocationsMap(code) @@ -330,6 +355,10 @@ function createFileTask( `${definition.start}`, ) } + // Inherit tags from parent suite and merge with own tags + const parentTags = latestSuite.tags || [] + const taskTags = [...new Set([...parentTags, ...definition.tags])] + if (definition.type === 'suite') { const task: ParsedSuite = { type: definition.type, @@ -347,12 +376,14 @@ function createFileTask( location, dynamic: definition.dynamic, meta: {}, + tags: taskTags, } definition.task = task latestSuite.tasks.push(task) lastSuite = task return } + validateTags(config, taskTags) const task: ParsedTest = { type: definition.type, id: '', @@ -372,6 +403,7 @@ function createFileTask( timeout: 0, annotations: [], artifacts: [], + tags: taskTags, } definition.task = task latestSuite.tasks.push(task) @@ -380,13 +412,13 @@ function createFileTask( const hasOnly = someTasksAreOnly(file) interpretTaskModes( file, - options.testNamePattern, + config.testNamePattern, undefined, undefined, undefined, hasOnly, false, - options.allowOnly, + config.allowOnly, ) markDynamicTests(file.tasks) if (!file.tasks.length) { @@ -395,7 +427,7 @@ function createFileTask( errors: [ { name: 'Error', - message: `No test suite found in file ${options.filepath}`, + message: `No test suite found in file ${filepath}`, }, ], } @@ -417,21 +449,30 @@ export async function astCollectTests( new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), ) } - return createFileTask(testFilepath, request.code, request.map, { - name: project.config.name, + return createFileTask( + testFilepath, + request.code, + request.map, + project.serializedConfig, filepath, - allowOnly: project.config.allowOnly, - testNamePattern: project.config.testNamePattern, - pool: project.browser ? 'browser' : project.config.pool, - }) + request.fileTags, + ) } async function transformSSR(project: TestProject, filepath: string) { - const environment = project.config.environment - if (environment === 'jsdom' || environment === 'happy-dom') { - return project.vite.environments.client.transformRequest(filepath) - } - return project.vite.environments.ssr.transformRequest(filepath) + // Read original file content to extract pragmas (environment, tags) + const originalCode = await fs.readFile(filepath, 'utf-8').catch(() => '') + const { env: pragmaEnv, tags: fileTags } = detectCodeBlock(originalCode) + + // Use environment from pragma if defined, otherwise fall back to config + const environment = pragmaEnv || project.config.environment + const env = environment === 'jsdom' || environment === 'happy-dom' + ? project.vite.environments.client + : project.vite.environments.ssr + + const transformResult = await env.transformRequest(filepath) + + return transformResult ? { ...transformResult, fileTags } : null } function markDynamicTests(tasks: Task[]) { diff --git a/test/cli/test/static-collect.test.ts b/test/cli/test/static-collect.test.ts index 859bd9318e7b..911257718625 100644 --- a/test/cli/test/static-collect.test.ts +++ b/test/cli/test/static-collect.test.ts @@ -1,4 +1,6 @@ import type { CliOptions, TestCase, TestModule, TestSuite } from 'vitest/node' +import { runVitest } from '#test-utils' +import { resolve } from 'pathe' import { expect, test } from 'vitest' import { createVitest, rolldownVersion } from 'vitest/node' @@ -773,7 +775,283 @@ test('collects tests when test functions are globals', async () => { `) }) -async function collectTests(code: string, options?: CliOptions) { +test('collects tests with tags as a string', async () => { + const testModule = await collectTests(` + import { test } from 'vitest' + + describe('tagged tests', () => { + test('test with single tag', { tags: 'slow' }, () => {}) + test('test without tags', () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "tagged tests": { + "test with single tag": { + "errors": [], + "fullName": "tagged tests > test with single tag", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + ], + }, + "test without tags": { + "errors": [], + "fullName": "tagged tests > test without tags", + "id": "-1732721377_0_1", + "location": "6:6", + "mode": "run", + "state": "pending", + }, + }, + } + `) +}) + +test('collects tests with tags as an array', async () => { + const testModule = await collectTests(` + import { test } from 'vitest' + + describe('tagged tests', () => { + test('test with multiple tags', { tags: ['slow', 'integration'] }, () => {}) + test('test with empty tags', { tags: [] }, () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "tagged tests": { + "test with empty tags": { + "errors": [], + "fullName": "tagged tests > test with empty tags", + "id": "-1732721377_0_1", + "location": "6:6", + "mode": "run", + "state": "pending", + }, + "test with multiple tags": { + "errors": [], + "fullName": "tagged tests > test with multiple tags", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + "integration", + ], + }, + }, + } + `) +}) + +test('collects suites with tags', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe('tagged suite', { tags: ['unit'] }, () => { + test('test in tagged suite', () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "tagged suite": { + "test in tagged suite": { + "errors": [], + "fullName": "tagged suite > test in tagged suite", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + "tags": [ + "unit", + ], + }, + }, + } + `) +}) + +test('inherits tags from parent suites', async () => { + const testModule = await collectTests(` + import { test, describe } from 'vitest' + + describe('outer suite', { tags: ['slow'] }, () => { + test('test inherits parent tag', () => {}) + + describe('inner suite', { tags: ['integration'] }, () => { + test('test inherits both tags', () => {}) + test('test with own tag', { tags: ['unit'] }, () => {}) + }) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "outer suite": { + "inner suite": { + "test inherits both tags": { + "errors": [], + "fullName": "outer suite > inner suite > test inherits both tags", + "id": "-1732721377_0_1_0", + "location": "8:8", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + "integration", + ], + }, + "test with own tag": { + "errors": [], + "fullName": "outer suite > inner suite > test with own tag", + "id": "-1732721377_0_1_1", + "location": "9:8", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + "integration", + "unit", + ], + }, + }, + "test inherits parent tag": { + "errors": [], + "fullName": "outer suite > test inherits parent tag", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + ], + }, + }, + } + `) +}) + +test('collects tags with other options', async () => { + const testModule = await collectTests(` + import { test } from 'vitest' + + describe('tests with options', () => { + test('test with tags and timeout', { tags: ['slow'], timeout: 5000 }, () => {}) + test.skip('skipped test with tags', { tags: ['unit'] }, () => {}) + }) +`) + expect(testModule).toMatchInlineSnapshot(` + { + "tests with options": { + "skipped test with tags": { + "errors": [], + "fullName": "tests with options > skipped test with tags", + "id": "-1732721377_0_1", + "location": "6:6", + "mode": "skip", + "state": "skipped", + "tags": [ + "unit", + ], + }, + "test with tags and timeout": { + "errors": [], + "fullName": "tests with options > test with tags and timeout", + "id": "-1732721377_0_0", + "location": "5:6", + "mode": "run", + "state": "pending", + "tags": [ + "slow", + ], + }, + }, + } + `) +}) + +test('reports error when using undefined tag', async () => { + const testModule = await collectTestModule(` + import { test } from 'vitest' + + describe('tests with undefined tag', () => { + test('test with undefined tag', { tags: ['undefined-tag'] }, () => {}) + }) +`) + expect(testModule.errors()[0].message).toMatchInlineSnapshot(` + "The tag "undefined-tag" is not defined in the configuration. Available tags are: + - slow + - integration + - unit" + `) +}) + +test('@module-tag docs inject test tags', async () => { + const { ctx } = await runVitest({ + config: false, + root: './fixtures/file-tags', + standalone: true, + watch: true, + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + const testModule = await ctx!.experimental_parseSpecification( + ctx!.getRootProject().createSpecification(resolve(ctx!.config.root, './valid-file-tags.test.ts')), + ) + expect(testTree(testModule)).toMatchInlineSnapshot(` + { + "suite 1": { + "test 1": { + "errors": [], + "fullName": "suite 1 > test 1", + "id": "492646822_0_0", + "location": "10:2", + "mode": "run", + "state": "pending", + "tags": [ + "file", + "file-2", + "file/slash", + "test", + ], + }, + }, + } + `) +}) + +test('invalid @module-tag throws and error', async () => { + const { ctx } = await runVitest({ + config: false, + root: './fixtures/file-tags', + include: ['./error-file-tags.test.ts'], + tags: [ + { name: 'file' }, + { name: 'file-2' }, + { name: 'file/slash' }, + { name: 'test' }, + ], + }) + const testModule = await ctx!.experimental_parseSpecification( + ctx!.getRootProject().createSpecification(resolve(ctx!.config.root, './error-file-tags.test.ts')), + ) + expect(testModule.errors()[0].message).toMatchInlineSnapshot(` + "The tag "invalid" is not defined in the configuration. Available tags are: + - file + - file-2 + - file/slash + - test" + `) +}) + +async function collectTestModule(code: string, options?: CliOptions) { const vitest = await createVitest( 'test', { @@ -781,6 +1059,11 @@ async function collectTests(code: string, options?: CliOptions) { includeTaskLocation: true, allowOnly: true, ...options, + tags: [ + { name: 'slow' }, + { name: 'integration' }, + { name: 'unit' }, + ], }, { plugins: [ @@ -795,10 +1078,13 @@ async function collectTests(code: string, options?: CliOptions) { ], }, ) - const testModule = await vitest.experimental_parseSpecification( + return vitest.experimental_parseSpecification( vitest.getRootProject().createSpecification('simple.test.ts'), ) - return testTree(testModule) +} + +async function collectTests(code: string, options?: CliOptions) { + return testTree(await collectTestModule(code, options)) } function testTree(module: TestModule | TestSuite, tree: any = {}) { @@ -832,5 +1118,6 @@ function testItem(testCase: TestCase) { errors: testCase.result().errors || [], ...(testCase.task.dynamic ? { dynamic: true } : {}), ...(testCase.options.each ? { each: true } : {}), + ...(testCase.task.tags?.length ? { tags: testCase.task.tags } : {}), } } From 958a0814075f944b958a7aa87891e7053a3e48ef Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 14:33:14 +0100 Subject: [PATCH 31/48] fixx(ui): expand nodes when searching --- packages/ui/client/composables/explorer/filter.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/ui/client/composables/explorer/filter.ts b/packages/ui/client/composables/explorer/filter.ts index 06b92036589d..4ab9ecd23175 100644 --- a/packages/ui/client/composables/explorer/filter.ts +++ b/packages/ui/client/composables/explorer/filter.ts @@ -107,6 +107,17 @@ export function* filterNode( // we still need to show the suite, but the test must be removed from the list to render. const map = explorerTree.nodes + + // When searching, expand parent nodes of matching tests so they are visible + if (search.length > 0) { + for (const id of treeNodes) { + const treeNode = map.get(id) + if (treeNode && 'expanded' in treeNode) { + treeNode.expanded = true + } + } + } + // collect files and all suites whose parent is expanded const parents = new Set( entries.filter(e => isFileNode(e) || (isParentNode(e) && map.get(e.parentId)?.expanded)).map(e => e.id), From d42bb2536514f460c36d36f5b548e7d9a1052f15 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 20 Jan 2026 16:30:43 +0100 Subject: [PATCH 32/48] feat(ui): display tags as badges, support filtering --- packages/runner/src/collect.ts | 2 +- packages/runner/src/suite.ts | 4 +- packages/ui/client/components/FileDetails.vue | 43 +++++++++++++++--- .../client/components/explorer/Explorer.vue | 12 +++-- .../components/explorer/ExplorerItem.vue | 44 +++++++++++++++++-- .../components/views/ViewTestReport.vue | 2 +- .../ui/client/composables/client/state.ts | 9 ++++ .../client/composables/explorer/collector.ts | 16 +++---- .../ui/client/composables/explorer/expand.ts | 6 +-- .../ui/client/composables/explorer/filter.ts | 28 ++++++------ .../ui/client/composables/explorer/search.ts | 2 + .../ui/client/composables/explorer/state.ts | 40 ++++++++++++++++- .../ui/client/composables/explorer/tree.ts | 16 +++---- .../ui/client/composables/explorer/types.ts | 6 ++- .../ui/client/composables/explorer/utils.ts | 4 +- packages/ui/client/utils/task.ts | 8 ++-- 16 files changed, 183 insertions(+), 59 deletions(-) diff --git a/packages/runner/src/collect.ts b/packages/runner/src/collect.ts index ae17e7ccbd0e..16f01276c20c 100644 --- a/packages/runner/src/collect.ts +++ b/packages/runner/src/collect.ts @@ -51,7 +51,7 @@ export async function collectTests( file.shuffle = config.sequence.shuffle try { - validateTags(runner, fileTags) + validateTags(runner.config, fileTags) runner.onCollectStart?.(file) diff --git a/packages/runner/src/suite.ts b/packages/runner/src/suite.ts index c249988f8e6e..0626b8690f8e 100644 --- a/packages/runner/src/suite.ts +++ b/packages/runner/src/suite.ts @@ -316,7 +316,7 @@ function createSuiteCollector( const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file const parentTags = parentTask?.tags || [] - const testTags = toArray(unique([...parentTags, ...options.tags || []])) + const testTags = unique([...parentTags, ...toArray(options.tags)]) const tagsOptions = testTags .map((tag) => { const tagDefinition = runner.config.tags?.find(t => t.name === tag) @@ -494,7 +494,7 @@ function createSuiteCollector( const currentSuite = collectorContext.currentSuite?.suite const parentTask = currentSuite ?? collectorContext.currentSuite?.file const suiteTags = toArray(suiteOptions?.tags) - validateTags(runner, suiteTags) + validateTags(runner.config, suiteTags) suite = { id: '', diff --git a/packages/ui/client/components/FileDetails.vue b/packages/ui/client/components/FileDetails.vue index ccfea5aef38b..77c7cd703cff 100644 --- a/packages/ui/client/components/FileDetails.vue +++ b/packages/ui/client/components/FileDetails.vue @@ -12,11 +12,12 @@ import { currentLogs, isReport, } from '~/composables/client' +import { tagsDefinitions } from '~/composables/client/state' import { explorerTree } from '~/composables/explorer' import { hasFailedSnapshot } from '~/composables/explorer/collector' import { getModuleGraph } from '~/composables/module-graph' import { selectedTest, viewMode } from '~/composables/params' -import { getProjectNameColor, getProjectTextColor } from '~/utils/task' +import { getBadgeNameColor, getBadgeTextColor } from '~/utils/task' import IconButton from './IconButton.vue' import StatusIcon from './StatusIcon.vue' import ViewConsoleOutput from './views/ViewConsoleOutput.vue' @@ -155,10 +156,10 @@ debouncedWatch( const projectNameColor = computed(() => { const projectName = current.value?.file.projectName || '' - return explorerTree.colors.get(projectName) || getProjectNameColor(current.value?.file.projectName) + return explorerTree.colors.get(projectName) || getBadgeNameColor(current.value?.file.projectName) }) -const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor.value)) +const projectNameTextColor = computed(() => getBadgeTextColor(projectNameColor.value)) const testTitle = computed(() => { const testId = selectedTest.value @@ -170,11 +171,24 @@ const testTitle = computed(() => { while (node) { names.push(node.name) node = node.suite - ? node.suite - : (node === node.file ? undefined : node.file) } return names.reverse().join(' > ') }) + +const tags = computed(() => { + const testId = selectedTest.value + if (!testId) { + return [] + } + const node = client.state.idMap.get(testId) + return (node?.tags || []).map(tag => ({ + name: tag, + description: tagsDefinitions.value[tag]?.description, + bg: getBadgeNameColor(tag, true), + border: getBadgeNameColor(tag), + text: 'white', + })) +})