[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat: support tags (#9478)

authored by

Vladimir and committed by
GitHub
(Jan 22, 2026, 11:21 AM +0100) de7c8a52 77d75fd3

+3956 -165
+12
docs/.vitepress/config.ts
··· 467 467 link: '/config/sequence', 468 468 }, 469 469 { 470 + text: 'tags', 471 + link: '/config/tags', 472 + }, 473 + { 474 + text: 'strictTags', 475 + link: '/config/stricttags', 476 + }, 477 + { 470 478 text: 'typecheck', 471 479 link: '/config/typecheck', 472 480 }, ··· 768 776 { 769 777 text: 'Test Filtering', 770 778 link: '/guide/filtering', 779 + }, 780 + { 781 + text: 'Test Tags', 782 + link: '/guide/test-tags', 771 783 }, 772 784 { 773 785 text: 'Test Context',
+6
docs/api/index.md
··· 29 29 * @default 0 30 30 */ 31 31 repeats?: number 32 + /** 33 + * Custom tags of the test. Useful for filtering tests. 34 + */ 35 + tags?: string[] | string 32 36 } 33 37 ``` 38 + 39 + <!-- TODO: rewrite this into separate test files with options highlighted --> 34 40 35 41 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. 36 42
+35
docs/config/stricttags.md
··· 1 + --- 2 + title: strictTags | Config 3 + outline: deep 4 + --- 5 + 6 + # strictTags <Version>4.1.0</Version> {#stricttags} 7 + 8 + - **Type:** `boolean` 9 + - **Default:** `true` 10 + - **CLI:** `--strict-tags`, `--no-strict-tags` 11 + 12 + 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). 13 + 14 + Note that Vitest will always throw an error if `--tags-filter` flag defines a tag not present in the config. 15 + 16 + For example, this test will throw an error because the tag `fortnend` has a typo (it should be `frontend`): 17 + 18 + ::: code-group 19 + ```js [form.test.js] 20 + test('renders a form', { tags: ['fortnend'] }, () => { 21 + // ... 22 + }) 23 + ``` 24 + ```js [vitest.config.js] 25 + import { defineConfig } from 'vitest/config' 26 + 27 + export default defineConfig({ 28 + test: { 29 + tags: [ 30 + { name: 'frontend' }, 31 + ], 32 + }, 33 + }) 34 + ``` 35 + :::
+194
docs/config/tags.md
··· 1 + --- 2 + title: tags | Config 3 + outline: deep 4 + --- 5 + 6 + # tags <Version>4.1.0</Version> {#tags} 7 + 8 + - **Type:** `TestTagDefinition[]` 9 + - **Default:** `[]` 10 + 11 + 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. 12 + 13 + If you are using [`projects`](/config/projects), they will inherit all global tags definitions automatically. 14 + 15 + Use [`--tags-filter`](/guide/test-tags#syntax) to filter tests by their tags. Use [`--list-tags`](/guide/cli#listtags) to print every tag in your Vitest workspace. 16 + 17 + ## name 18 + 19 + - **Type:** `string` 20 + - **Required:** `true` 21 + 22 + The name of the tag. This is what you use in the `tags` option in tests. 23 + 24 + ```ts 25 + export default defineConfig({ 26 + test: { 27 + tags: [ 28 + { name: 'unit' }, 29 + { name: 'e2e' }, 30 + ], 31 + }, 32 + }) 33 + ``` 34 + 35 + ::: tip 36 + 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`): 37 + 38 + ```ts [vitest.shims.ts] 39 + import 'vitest' 40 + 41 + declare module 'vitest' { 42 + interface TestTags { 43 + tags: 44 + | 'frontend' 45 + | 'backend' 46 + | 'db' 47 + | 'flaky' 48 + } 49 + } 50 + ``` 51 + ::: 52 + 53 + ## description 54 + 55 + - **Type:** `string` 56 + 57 + A human-readable description for the tag. This will be shown in UI and inside error messages when a tag is not found. 58 + 59 + ```ts 60 + export default defineConfig({ 61 + test: { 62 + tags: [ 63 + { 64 + name: 'slow', 65 + description: 'Tests that take a long time to run.', 66 + }, 67 + ], 68 + }, 69 + }) 70 + ``` 71 + 72 + ## priority 73 + 74 + - **Type:** `number` 75 + - **Default:** `Infinity` 76 + 77 + 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`). 78 + 79 + ```ts 80 + export default defineConfig({ 81 + test: { 82 + tags: [ 83 + { 84 + name: 'flaky', 85 + timeout: 30_000, 86 + priority: 1, // higher priority 87 + }, 88 + { 89 + name: 'db', 90 + timeout: 60_000, 91 + priority: 2, // lower priority 92 + }, 93 + ], 94 + }, 95 + }) 96 + ``` 97 + 98 + When a test has both tags, the `timeout` will be `30_000` because `flaky` has a higher priority. 99 + 100 + ## Test Options 101 + 102 + 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. 103 + 104 + ### timeout 105 + 106 + - **Type:** `number` 107 + 108 + Test timeout in milliseconds. 109 + 110 + ### retry 111 + 112 + - **Type:** `number | { count?: number, delay?: number, condition?: RegExp }` 113 + 114 + Retry configuration for the test. If a number, specifies how many times to retry. If an object, allows fine-grained retry control. 115 + 116 + ### repeats 117 + 118 + - **Type:** `number` 119 + 120 + How many times the test will run again. 121 + 122 + ### concurrent 123 + 124 + - **Type:** `boolean` 125 + 126 + Whether suites and tests run concurrently. 127 + 128 + ### sequential 129 + 130 + - **Type:** `boolean` 131 + 132 + Whether tests run sequentially. 133 + 134 + ### skip 135 + 136 + - **Type:** `boolean` 137 + 138 + Whether the test should be skipped. 139 + 140 + ### only 141 + 142 + - **Type:** `boolean` 143 + 144 + Should this test be the only one running in a suite. 145 + 146 + ### todo 147 + 148 + - **Type:** `boolean` 149 + 150 + Whether the test should be skipped and marked as a todo. 151 + 152 + ### fails 153 + 154 + - **Type:** `boolean` 155 + 156 + Whether the test is expected to fail. If it does, the test will pass, otherwise it will fail. 157 + 158 + ## Example 159 + 160 + ```ts 161 + import { defineConfig } from 'vitest/config' 162 + 163 + export default defineConfig({ 164 + test: { 165 + tags: [ 166 + { 167 + name: 'unit', 168 + description: 'Unit tests.', 169 + }, 170 + { 171 + name: 'e2e', 172 + description: 'End-to-end tests.', 173 + timeout: 60_000, 174 + }, 175 + { 176 + name: 'flaky', 177 + description: 'Flaky tests that need retries.', 178 + retry: process.env.CI ? 3 : 0, 179 + priority: 1, 180 + }, 181 + { 182 + name: 'slow', 183 + description: 'Slow tests.', 184 + timeout: 120_000, 185 + }, 186 + { 187 + name: 'skip-ci', 188 + description: 'Tests to skip in CI.', 189 + skip: !!process.env.CI, 190 + }, 191 + ], 192 + }, 193 + }) 194 + ```
+37 -4
docs/guide/cli-generated.md
··· 518 518 519 519 Stop test execution when given number of tests have failed (default: `0`) 520 520 521 - ### retry 521 + ### retry.count 522 522 523 - - **CLI:** `--retry <times>` 524 - - **Config:** [retry](/config/retry) 523 + - **CLI:** `--retry.count <times>` 524 + - **Config:** [retry.count](/config/retry#retry-count) 525 525 526 - Retry the test specific number of times if it fails (default: `0`) 526 + Number of times to retry a test if it fails (default: `0`) 527 + 528 + ### retry.delay 529 + 530 + - **CLI:** `--retry.delay <ms>` 531 + - **Config:** [retry.delay](/config/retry#retry-delay) 532 + 533 + Delay in milliseconds between retry attempts (default: `0`) 534 + 535 + ### retry.condition 536 + 537 + - **CLI:** `--retry.condition <pattern>` 538 + - **Config:** [retry.condition](/config/retry#retry-condition) 539 + 540 + 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) 527 541 528 542 ### diff.aAnnotation 529 543 ··· 792 806 793 807 Start Vitest without running tests. Tests will be running only on change. This option is ignored when CLI file filters are passed. (default: `false`) 794 808 809 + ### listTags 810 + 811 + - **CLI:** `--listTags [type]` 812 + 813 + List all available tags instead of running tests. `--list-tags=json` will output tags in JSON format, unless there are no tags. 814 + 795 815 ### clearCache 796 816 797 817 - **CLI:** `--clearCache` 798 818 799 819 Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run. 820 + 821 + ### tagsFilter 822 + 823 + - **CLI:** `--tagsFilter <expression>` 824 + 825 + 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. 826 + 827 + ### strictTags 828 + 829 + - **CLI:** `--strictTags` 830 + - **Config:** [strictTags](/config/stricttags) 831 + 832 + Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`) 800 833 801 834 ### experimental.fsModuleCache 802 835
+18
docs/guide/filtering.md
··· 89 89 }) 90 90 ``` 91 91 92 + ## Filtering Tags 93 + 94 + If your test defines a [tag](/guide/test-tags), you can filter your tests with a `--tags-filter` option: 95 + 96 + ```ts 97 + test('renders a form', { tags: ['frontend'] }, () => { 98 + // ... 99 + }) 100 + 101 + test('calls an external API', { tags: ['backend'] }, () => { 102 + // ... 103 + }) 104 + ``` 105 + 106 + ```shell 107 + vitest --tags-filter=frontend 108 + ``` 109 + 92 110 ## Selecting Suites and Tests to Run 93 111 94 112 Use `.only` to only run certain suites or tests
+302
docs/guide/test-tags.md
··· 1 + --- 2 + title: Test Tags | Guide 3 + outline: deep 4 + --- 5 + 6 + # Test Tags <Version>4.1.0</Version> 7 + 8 + [`Tags`](/config/tags) allow you to mark tests and change their options based on the tag's definition. 9 + 10 + ## Defining Tags 11 + 12 + Tags must be defined in your configuration file — Vitest does not provide any built-in tags. If a test uses a tag that isn't defined in the config, the test runner will throw an error. This prevents unexpected behavior from mistyped tag names. You can disable this check with the [`strictTags`](/config/stricttags) option. 13 + 14 + 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). 15 + 16 + ```ts [vitest.config.js] 17 + import { defineConfig } from 'vitest/config' 18 + 19 + export default defineConfig({ 20 + test: { 21 + tags: [ 22 + { 23 + name: 'frontend', 24 + description: 'Tests written for frontend.', 25 + }, 26 + { 27 + name: 'backend', 28 + description: 'Tests written for backend.', 29 + }, 30 + { 31 + name: 'db', 32 + description: 'Tests for database queries.', 33 + timeout: 60_000, 34 + }, 35 + { 36 + name: 'flaky', 37 + description: 'Flaky CI tests.', 38 + retry: process.env.CI ? 3 : 0, 39 + timeout: 30_000, 40 + priority: 1, 41 + }, 42 + ], 43 + }, 44 + }) 45 + ``` 46 + 47 + ::: warning 48 + 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 merged first and will be overriden by higher priority ones: 49 + 50 + ```ts 51 + test('flaky database test', { tags: ['flaky', 'db'] }) 52 + // { timeout: 30_000, retry: 3 } 53 + ``` 54 + 55 + 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. 56 + 57 + If test defines its own options, they will have the highest priority: 58 + 59 + ```ts 60 + test('flaky database test', { tags: ['flaky', 'db'], timeout: 120_000 }) 61 + // { timeout: 120_000, retry: 3 } 62 + ``` 63 + ::: 64 + 65 + 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`): 66 + 67 + ```ts [vitest.shims.ts] 68 + import 'vitest' 69 + 70 + declare module 'vitest' { 71 + interface TestTags { 72 + tags: 73 + | 'frontend' 74 + | 'backend' 75 + | 'db' 76 + | 'flaky' 77 + } 78 + } 79 + ``` 80 + 81 + To see all your tags, you can use [`--list-tags`](/guide/cli#listtags) command: 82 + 83 + ```shell 84 + vitest --list-tags 85 + 86 + frontend: Tests written for frontend. 87 + backend: Tests written for backend. 88 + db: Tests for database queries. 89 + flaky: Flaky CI tests. 90 + ``` 91 + 92 + To print it in JSON, pass down `--list-tags=json`: 93 + 94 + ```json 95 + { 96 + "tags": [ 97 + { 98 + "name": "frontend", 99 + "description": "Tests written for frontend." 100 + }, 101 + { 102 + "name": "backend", 103 + "description": "Tests written for backend." 104 + }, 105 + { 106 + "name": "db", 107 + "description": "Tests for database queries.", 108 + "timeout": 60000 109 + }, 110 + { 111 + "name": "flaky", 112 + "description": "Flaky CI tests.", 113 + "retry": 0, 114 + "timeout": 30000, 115 + "priority": 1 116 + } 117 + ], 118 + "projects": [] 119 + } 120 + ``` 121 + 122 + ## Using Tags in Tests 123 + 124 + You can apply tags to individual tests or entire suites using the `tags` option: 125 + 126 + ```ts 127 + import { describe, test } from 'vitest' 128 + 129 + test('renders homepage', { tags: ['frontend'] }, () => { 130 + // ... 131 + }) 132 + 133 + describe('API endpoints', { tags: ['backend'] }, () => { 134 + test('returns user data', () => { 135 + // This test inherits the "backend" tag from the parent suite 136 + }) 137 + 138 + test('validates input', { tags: ['validation'] }, () => { 139 + // This test has both "backend" (inherited) and "validation" tags 140 + }) 141 + }) 142 + ``` 143 + 144 + Tags are inherited from parent suites, so all tests inside a tagged `describe` block will automatically have that tag. 145 + 146 + 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: 147 + 148 + ```ts 149 + /** 150 + * Auth tests 151 + * @module-tag admin/pages/dashboard 152 + * @module-tag acceptance 153 + */ 154 + 155 + test('dashboard renders items', () => { 156 + // ... 157 + }) 158 + ``` 159 + 160 + ::: danger 161 + A `@module-tag` in a JSDoc comment applies to all tests in that file, not just the test it precedes. 162 + 163 + Consider this example: 164 + 165 + ```js{3,10} 166 + describe('forms', () => { 167 + /** 168 + * @module-tag frontend 169 + */ 170 + test('renders a form', () => { 171 + // ... 172 + }) 173 + 174 + /** 175 + * @module-tag db 176 + */ 177 + test('db returns users', () => { 178 + // ... 179 + }) 180 + }) 181 + ``` 182 + 183 + In this example, every test in the file will have both the `frontend` and `db` tags. To tag individual tests, use the options argument instead: 184 + 185 + ```js{2,6} 186 + describe('forms', () => { 187 + test('renders a form', { tags: 'frontend' }, () => { 188 + // ... 189 + }) 190 + 191 + test('db returns users', { tags: 'db' }, () => { 192 + // ... 193 + }) 194 + }) 195 + ``` 196 + ::: 197 + 198 + ## Filtering Tests by Tag 199 + 200 + To run only tests with specific tags, use the [`--tags-filter`](/guide/cli#tagsfilter) CLI option: 201 + 202 + ```shell 203 + vitest --tags-filter=frontend 204 + vitest --tags-filter="frontend and backend" 205 + ``` 206 + 207 + If you are running Vitest UI, you can start a filter with a `tag:` prefix to filter out tests by tags using the same tags expression sytax: 208 + 209 + <img alt="The tags filter in Vitest UI" img-light src="/ui/light-ui-tags.png"> 210 + <img alt="The tags filter in Vitest UI" img-dark src="/ui/dark-ui-tags.png"> 211 + 212 + If you are using a programmatic API, you can pass down a `tagsFilter` option to [`startVitest`](/guide/advanced/#startvitest) or [`createVitest`](/guide/advanced/#createvitest): 213 + 214 + ```ts 215 + import { startVitest } from 'vitest/node' 216 + 217 + await startVitest('test', [], { 218 + tagsFilter: ['frontend and backend'], 219 + }) 220 + ``` 221 + 222 + Or you can create a [test specification](/api/advanced/test-specification) with your custom filters: 223 + 224 + ```ts 225 + const specification = vitest.getRootProject().createSpecification( 226 + '/path-to-file.js', 227 + { 228 + testTagsFilter: ['frontend and backend'], 229 + }, 230 + ) 231 + ``` 232 + 233 + ### Syntax 234 + 235 + You can combine tags in different ways. Vitest supports these keywords: 236 + 237 + - `and` or `&&` to include both expressions 238 + - `or` or `||` to include at least one expression 239 + - `not` or `!` to exclude the expression 240 + - `*` to match any number of characters (0 or more) 241 + - `()` to group expressions and override precedence 242 + 243 + 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. 244 + 245 + ::: warning Reserved Names 246 + 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. 247 + ::: 248 + 249 + ### Wildcards 250 + 251 + You can use a wildcard (`*`) to match any number of characters: 252 + 253 + ```shell 254 + vitest --tags-filter="unit/*" 255 + ``` 256 + 257 + This will match tags like `unit/components`, `unit/utils`, etc. 258 + 259 + ### Excluding Tags 260 + 261 + To exclude tests with a specific tag, add an exclamation mark (`!`) at the start or a "not" keyword: 262 + 263 + ```shell 264 + vitest --tags-filter="!slow and not flaky" 265 + ``` 266 + 267 + ### Examples 268 + 269 + Here are some common filtering patterns: 270 + 271 + ```shell 272 + # Run only unit tests 273 + vitest --tags-filter="unit" 274 + 275 + # Run tests that are both frontend AND fast 276 + vitest --tags-filter="frontend and fast" 277 + 278 + # Run tests that are either unit OR e2e 279 + vitest --tags-filter="unit or e2e" 280 + 281 + # Run all tests except slow ones 282 + vitest --tags-filter="!slow" 283 + 284 + # Run frontend tests that are not flaky 285 + vitest --tags-filter="frontend && !flaky" 286 + 287 + # Run tests matching a wildcard pattern 288 + vitest --tags-filter="api/*" 289 + 290 + # Complex expression with parentheses 291 + vitest --tags-filter="(unit || e2e) && !slow" 292 + 293 + # Run database tests that are either postgres or mysql, but not slow 294 + vitest --tags-filter="db && (postgres || mysql) && !slow" 295 + ``` 296 + 297 + You can also pass multiple `--tags-filter` flags. They are combined with AND logic: 298 + 299 + ```shell 300 + # Run tests that match (unit OR e2e) AND are NOT slow 301 + vitest --tags-filter="unit || e2e" --tags-filter="!slow" 302 + ```
+5
test/browser/vitest.config.mts
··· 97 97 }, 98 98 }, 99 99 }, 100 + tags: [ 101 + { name: 'e2e', priority: 10 }, 102 + { name: 'test', priority: 5 }, 103 + { name: 'browser', priority: 1 }, 104 + ], 100 105 alias: { 101 106 '#src': resolve(dir, './src'), 102 107 },
+12 -5
test/test-utils/index.ts
··· 3 3 import type { SerializedConfig, WorkerGlobalState } from 'vitest' 4 4 import type { TestProjectConfiguration } from 'vitest/config' 5 5 import type { 6 + TestCase, 6 7 TestCollection, 7 8 TestModule, 8 - TestResult, 9 9 TestSpecification, 10 10 TestUserConfig, 11 11 Vitest, ··· 225 225 }, 226 226 testTree() { 227 227 return buildTestTree(ctx?.state.getTestModules() || []) 228 + }, 229 + buildTree(onResult: (testResult: TestCase) => any) { 230 + return buildTestTree(ctx?.state.getTestModules() || [], onResult) 228 231 }, 229 232 waitForClose: async () => { 230 233 await new Promise<void>(resolve => ctx!.onClose(resolve)) ··· 472 475 testTree() { 473 476 return buildTestTree(vitest.ctx?.state.getTestModules() || []) 474 477 }, 478 + buildTree(onResult: (testResult: TestCase) => any) { 479 + return buildTestTree(vitest.ctx?.state.getTestModules() || [], onResult) 480 + }, 475 481 } 476 482 } 477 483 ··· 505 511 } 506 512 507 513 export function buildErrorTree(testModules: TestModule[]) { 508 - return buildTestTree(testModules, (result) => { 514 + return buildTestTree(testModules, (testCase) => { 515 + const result = testCase.result() 509 516 if (result.state === 'failed') { 510 517 return result.errors.map(e => e.message) 511 518 } ··· 513 520 }) 514 521 } 515 522 516 - export function buildTestTree(testModules: TestModule[], onResult?: (result: TestResult) => unknown) { 523 + export function buildTestTree(testModules: TestModule[], onTestCase?: (result: TestCase) => unknown) { 517 524 type TestTree = Record<string, any> 518 525 519 526 function walkCollection(collection: TestCollection): TestTree { ··· 527 534 } 528 535 else if (child.type === 'test') { 529 536 const result = child.result() 530 - if (onResult) { 531 - node[child.name] = onResult(result) 537 + if (onTestCase) { 538 + node[child.name] = onTestCase(child) 532 539 } 533 540 else { 534 541 node[child.name] = result.state
+4
test/ui/vitest.config.ts
··· 7 7 coverage: { 8 8 reportOnFailure: true, 9 9 }, 10 + tags: [ 11 + { name: 'db' }, 12 + { name: 'flaky' }, 13 + ], 10 14 projects: [{ 11 15 extends: true, 12 16 test: {
+2
docs/.vitepress/scripts/cli-generator.ts
··· 42 42 'browser.name', 43 43 'browser.fileParallelism', 44 44 'clearCache', 45 + 'tagsFilter', 46 + 'listTags', 45 47 ]) 46 48 47 49 function resolveOptions(options: CLIOptions<any>, parentName?: string) {
+6
docs/api/advanced/test-case.md
··· 105 105 readonly shuffle: boolean | undefined 106 106 readonly retry: number | undefined 107 107 readonly repeats: number | undefined 108 + readonly tags: string[] | undefined 109 + readonly timeout: number | undefined 108 110 readonly mode: 'run' | 'only' | 'skip' | 'todo' 109 111 } 110 112 ``` 111 113 112 114 The options that test was collected with. 115 + 116 + ## tags <Version>4.1.0</Version> {#tags} 117 + 118 + [Tags](/guide/test-tags) that were implicitly or explicitly assigned to the test. 113 119 114 120 ## ok 115 121
+5
docs/api/advanced/test-specification.md
··· 11 11 testLines: [20, 40], 12 12 testNamePattern: /hello world/, 13 13 testIds: ['1223128da3_0_0_0', '1223128da3_0_0'], 14 + testTagsFilter: ['frontend and backend'], 14 15 } // optional test filters 15 16 ) 16 17 ``` ··· 81 82 ## testIds <Version>4.1.0</Version> {#testids} 82 83 83 84 The ids of tasks inside of this specification to run. 85 + 86 + ## testTagsFilter <Version>4.1.0</Version> {#testtagsfilter} 87 + 88 + 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`. 84 89 85 90 ## toJSON 86 91
+1
docs/api/advanced/test-suite.md
··· 106 106 readonly shuffle: boolean | undefined 107 107 readonly retry: number | undefined 108 108 readonly repeats: number | undefined 109 + readonly tags: string[] | undefined 109 110 readonly mode: 'run' | 'only' | 'skip' | 'todo' 110 111 } 111 112 ```
docs/public/ui/dark-ui-tags.png

This is a binary file and will not be displayed.

docs/public/ui/light-ui-tags.png

This is a binary file and will not be displayed.

+18 -4
packages/runner/src/collect.ts
··· 16 16 interpretTaskModes, 17 17 someTasksAreOnly, 18 18 } from './utils/collect' 19 + import { createTagsFilter, validateTags } from './utils/tags' 19 20 20 21 const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now 21 22 ··· 27 28 28 29 const config = runner.config 29 30 const $ = runner.trace! 31 + let defaultTagsFilter: ((testTags: string[]) => boolean) | undefined 30 32 31 33 for (const spec of specs) { 32 34 const filepath = typeof spec === 'string' ? spec : spec.filepath ··· 37 39 const testLocations = typeof spec === 'string' ? undefined : spec.testLocations 38 40 const testNamePattern = typeof spec === 'string' ? undefined : spec.testNamePattern 39 41 const testIds = typeof spec === 'string' ? undefined : spec.testIds 42 + const testTagsFilter = typeof spec === 'object' && spec.testTagsFilter 43 + ? createTagsFilter(spec.testTagsFilter, config.tags) 44 + : undefined 45 + 46 + const fileTags: string[] = typeof spec === 'string' ? [] : (spec.fileTags || []) 40 47 41 48 const file = createFileTask(filepath, config.root, config.name, runner.pool, runner.viteEnvironment) 42 49 setFileContext(file, Object.create(null)) 50 + file.tags = fileTags 43 51 file.shuffle = config.sequence.shuffle 44 52 45 - runner.onCollectStart?.(file) 46 - 47 - clearCollectorContext(file, runner) 48 - 49 53 try { 54 + validateTags(runner.config, fileTags) 55 + 56 + runner.onCollectStart?.(file) 57 + 58 + clearCollectorContext(file, runner) 59 + 50 60 const setupFiles = toArray(config.setupFiles) 51 61 if (setupFiles.length) { 52 62 const setupStart = now() ··· 108 118 calculateSuiteHash(file) 109 119 110 120 const hasOnlyTasks = someTasksAreOnly(file) 121 + if (!testTagsFilter && !defaultTagsFilter && config.tagsFilter) { 122 + defaultTagsFilter = createTagsFilter(config.tagsFilter, config.tags) 123 + } 111 124 interpretTaskModes( 112 125 file, 113 126 testNamePattern ?? config.testNamePattern, 114 127 testLocations, 115 128 testIds, 129 + testTagsFilter ?? defaultTagsFilter, 116 130 hasOnlyTasks, 117 131 false, 118 132 config.allowOnly,
+78 -26
packages/runner/src/suite.ts
··· 9 9 SuiteCollector, 10 10 SuiteFactory, 11 11 SuiteHooks, 12 + SuiteOptions, 12 13 Task, 13 14 TaskCustomOptions, 14 15 TaskPopulated, ··· 23 24 isObject, 24 25 objectAttr, 25 26 toArray, 27 + unique, 26 28 } from '@vitest/utils/helpers' 27 29 import { 28 30 abortIfTimeout, ··· 38 40 import { getCurrentTest } from './test-state' 39 41 import { findTestFileStackTrace } from './utils' 40 42 import { createChainable } from './utils/chain' 43 + import { createNoTagsError, validateTags } from './utils/tags' 41 44 import { createTaskName } from './utils/tasks' 42 45 43 46 /** ··· 213 216 214 217 function createDefaultSuite(runner: VitestRunner) { 215 218 const config = runner.config.sequence 216 - const collector = suite('', { concurrent: config.concurrent }, () => {}) 219 + const options: SuiteOptions = {} 220 + if (config.concurrent != null) { 221 + options.concurrent = config.concurrent 222 + } 223 + const collector = suite('', options, () => {}) 217 224 // no parent suite for top-level tests 218 225 delete collector.suite 219 226 return collector ··· 223 230 file: File, 224 231 currentRunner: VitestRunner, 225 232 ): void { 233 + currentTestFilepath = file.filepath 234 + runner = currentRunner 226 235 if (!defaultSuite) { 227 236 defaultSuite = createDefaultSuite(currentRunner) 228 237 } 229 238 defaultSuite.file = file 230 - runner = currentRunner 231 - currentTestFilepath = file.filepath 232 239 collectorContext.tasks.length = 0 233 240 defaultSuite.clear() 234 241 collectorContext.currentSuite = defaultSuite ··· 249 256 afterEach: [], 250 257 } 251 258 } 259 + 260 + const POSITIVE_INFINITY = Number.POSITIVE_INFINITY 252 261 253 262 function parseArguments<T extends (...args: any[]) => any>( 254 263 optionsOrFn: T | object | undefined, ··· 294 303 factory: SuiteFactory = () => {}, 295 304 mode: RunMode, 296 305 each?: boolean, 297 - suiteOptions?: TestOptions, 306 + suiteOptions?: SuiteOptions, 298 307 parentCollectorFixtures?: FixtureItem[], 299 308 ) { 300 309 const tasks: (Test | Suite | SuiteCollector)[] = [] ··· 304 313 initSuite(true) 305 314 306 315 const task = function (name = '', options: TaskCustomOptions = {}) { 307 - const timeout = options?.timeout ?? runner.config.testTimeout 308 316 const currentSuite = collectorContext.currentSuite?.suite 317 + const parentTask = currentSuite ?? collectorContext.currentSuite?.file 318 + const parentTags = parentTask?.tags || [] 319 + const testTags = unique([...parentTags, ...toArray(options.tags)]) 320 + const tagsOptions = testTags 321 + .map((tag) => { 322 + const tagDefinition = runner.config.tags?.find(t => t.name === tag) 323 + if (!tagDefinition && runner.config.strictTags) { 324 + throw createNoTagsError(runner.config.tags, tag) 325 + } 326 + return tagDefinition 327 + }) 328 + .filter(r => r != null) 329 + // higher priority should be last, run 1, 2, 3, ... etc 330 + .sort((tag1, tag2) => (tag2.priority ?? POSITIVE_INFINITY) - (tag1.priority ?? POSITIVE_INFINITY)) 331 + .reduce((acc, tag) => { 332 + const { name, description, priority, ...options } = tag 333 + Object.assign(acc, options) 334 + return acc 335 + }, {} as TestOptions) 336 + 337 + options = { 338 + ...tagsOptions, 339 + ...options, 340 + } 341 + const timeout = options.timeout ?? runner.config.testTimeout 309 342 const task: Test = { 310 343 id: '', 311 344 name, ··· 333 366 meta: options.meta ?? Object.create(null), 334 367 annotations: [], 335 368 artifacts: [], 369 + tags: testTags, 336 370 } 337 371 const handler = options.handler 338 372 if (task.mode === 'run' && !handler) { ··· 345 379 task.concurrent = true 346 380 } 347 381 task.shuffle = suiteOptions?.shuffle 348 - 349 382 const context = createTestContext(task, runner) 350 383 // create test context 351 384 Object.defineProperty(task, 'context', { ··· 401 434 } 402 435 403 436 // inherit concurrent / sequential from suite 404 - options.concurrent 405 - = this.concurrent || (!this.sequential && options?.concurrent) 406 - options.sequential 407 - = this.sequential || (!this.concurrent && options?.sequential) 437 + const concurrent = this.concurrent ?? (!this.sequential && options?.concurrent) 438 + if (options.concurrent != null && concurrent != null) { 439 + options.concurrent = concurrent 440 + } 441 + 442 + const sequential = this.sequential ?? (!this.concurrent && options?.sequential) 443 + if (options.sequential != null && sequential != null) { 444 + options.sequential = sequential 445 + } 408 446 409 447 const test = task(formatName(name), { 410 448 ...this, ··· 454 492 } 455 493 456 494 const currentSuite = collectorContext.currentSuite?.suite 495 + const parentTask = currentSuite ?? collectorContext.currentSuite?.file 496 + const suiteTags = toArray(suiteOptions?.tags) 497 + validateTags(runner.config, suiteTags) 457 498 458 499 suite = { 459 500 id: '', ··· 472 513 tasks: [], 473 514 meta: Object.create(null), 474 515 concurrent: suiteOptions?.concurrent, 516 + tags: unique([...parentTask?.tags || [], ...suiteTags]), 475 517 } 476 518 477 519 if (runner && includeLocation && runner.config.includeTaskLocation) { ··· 541 583 function suiteFn( 542 584 this: Record<string, boolean | undefined>, 543 585 name: string | Function, 544 - factoryOrOptions?: SuiteFactory | TestOptions, 586 + factoryOrOptions?: SuiteFactory | SuiteOptions, 545 587 optionsOrFactory?: number | SuiteFactory, 546 588 ) { 547 589 if (getCurrentTest()) { ··· 550 592 ) 551 593 } 552 594 553 - let mode: RunMode = this.only 554 - ? 'only' 555 - : this.skip 556 - ? 'skip' 557 - : this.todo 558 - ? 'todo' 559 - : 'run' 560 595 const currentSuite: SuiteCollector | undefined = collectorContext.currentSuite || defaultSuite 561 596 562 597 let { options, handler: factory } = parseArguments( 563 598 factoryOrOptions, 564 599 optionsOrFactory, 565 - ) 566 - 567 - if (mode === 'run' && !factory) { 568 - mode = 'todo' 569 - } 600 + ) as { options: SuiteOptions; handler: SuiteFactory | undefined } 570 601 571 602 const isConcurrentSpecified = options.concurrent || this.concurrent || options.sequential === false 572 603 const isSequentialSpecified = options.sequential || this.sequential || options.concurrent === false ··· 575 606 options = { 576 607 ...currentSuite?.options, 577 608 ...options, 578 - shuffle: this.shuffle ?? options.shuffle ?? currentSuite?.options?.shuffle ?? runner?.config.sequence.shuffle, 609 + } 610 + 611 + const shuffle = this.shuffle ?? options.shuffle ?? currentSuite?.options?.shuffle ?? runner?.config.sequence.shuffle 612 + if (shuffle != null) { 613 + options.shuffle = shuffle 614 + } 615 + 616 + let mode: RunMode = (this.only ?? options.only) 617 + ? 'only' 618 + : (this.skip ?? options.skip) 619 + ? 'skip' 620 + : (this.todo ?? options.todo) 621 + ? 'todo' 622 + : 'run' 623 + 624 + // passed as test(name), assume it's a "todo" 625 + if (mode === 'run' && !factory) { 626 + mode = 'todo' 579 627 } 580 628 581 629 // inherit concurrent / sequential from suite 582 630 const isConcurrent = isConcurrentSpecified || (options.concurrent && !isSequentialSpecified) 583 631 const isSequential = isSequentialSpecified || (options.sequential && !isConcurrentSpecified) 584 - options.concurrent = isConcurrent && !isSequential 585 - options.sequential = isSequential && !isConcurrent 632 + if (isConcurrent != null) { 633 + options.concurrent = isConcurrent && !isSequential 634 + } 635 + if (isSequential != null) { 636 + options.sequential = isSequential && !isConcurrent 637 + } 586 638 587 639 return createSuiteCollector( 588 640 formatName(name),
+2
packages/runner/src/types.ts
··· 1 1 export type { 2 2 CancelReason, 3 3 FileSpecification, 4 + TestTagDefinition, 4 5 VitestRunner, 5 6 VitestRunnerConfig, 6 7 VitestRunnerConstructor, ··· 55 56 TestContext, 56 57 TestFunction, 57 58 TestOptions, 59 + TestTags, 58 60 Use, 59 61 VisualRegressionArtifact, 60 62 } from './types/tasks'
+4
packages/utils/src/helpers.ts
··· 398 398 399 399 return deepMerge(target, ...sources) 400 400 } 401 + 402 + export function unique<T>(array: T[]): T[] { 403 + return Array.from(new Set(array)) 404 + }
+30
test/browser/specs/runner.test.ts
··· 3 3 import { readFile } from 'node:fs/promises' 4 4 import { beforeAll, describe, expect, onTestFailed, test } from 'vitest' 5 5 import { rolldownVersion } from 'vitest/node' 6 + import { buildTestTree } from '../../test-utils' 6 7 import { instances, provider, runBrowserTests } from './utils' 7 8 8 9 function noop() {} ··· 74 75 expect(browserResultJson.testResults).toHaveLength(testFilesCount * instances.length) 75 76 expect(passedTests).toHaveLength(browserResultJson.testResults.length) 76 77 expect(failedTests).toHaveLength(0) 78 + }) 79 + 80 + test('tags are collected', () => { 81 + expect(vitest.config.tags).toEqual([ 82 + { name: 'e2e', priority: 10 }, 83 + { name: 'test', priority: 5 }, 84 + { name: 'browser', priority: 1 }, 85 + ]) 86 + 87 + const testModule = vitest.state.getTestModules().find(m => m.moduleId.includes('tags.test.ts')) 88 + expect.assert(testModule) 89 + expect(buildTestTree([testModule], t => t.tags)).toMatchInlineSnapshot(` 90 + { 91 + "test/tags.test.ts": { 92 + "suite 1": { 93 + "suite 2": { 94 + "test 2": [ 95 + "browser", 96 + "e2e", 97 + ], 98 + }, 99 + "test 1": [ 100 + "browser", 101 + "test", 102 + ], 103 + }, 104 + }, 105 + } 106 + `) 77 107 }) 78 108 79 109 test('runs in-source tests', () => {
+13
test/browser/test/tags.test.ts
··· 1 + /** 2 + * @module-tag browser 3 + */ 4 + 5 + import { describe, test } from 'vitest' 6 + 7 + describe('suite 1', () => { 8 + test('test 1', { tags: ['test'] }, () => {}) 9 + 10 + describe('suite 2', { tags: ['e2e'] }, () => { 11 + test('test 2', () => {}) 12 + }) 13 + })
+7 -3
test/cli/test/configureVitest.test.ts
··· 173 173 }) 174 174 175 175 test('adding a plugin with existing name throws and error', async () => { 176 - await expect(() => vitest({ 176 + await expect(() => throws({ 177 177 projects: [ 178 178 { 179 179 test: { ··· 196 196 }), 197 197 ).rejects.toThrowError('Project name "project-1" is not unique. All projects should have unique names. Make sure your configuration is correct.') 198 198 199 - await expect(() => vitest({ 199 + await expect(() => throws({ 200 200 projects: [ 201 201 { 202 202 plugins: [ ··· 221 221 }), 222 222 ).rejects.toThrowError('Project name "project-1" is not unique. All projects should have unique names. Make sure your configuration is correct.') 223 223 224 - await expect(() => vitest({ 224 + await expect(() => throws({ 225 225 projects: [ 226 226 { 227 227 plugins: [ ··· 248 248 }), 249 249 ).rejects.toThrowError('Project name "project-1" is not unique. All projects should have unique names. Make sure your configuration is correct.') 250 250 }) 251 + 252 + async function throws(cliOptions: TestUserConfig) { 253 + await vitest(cliOptions) 254 + }
+6
test/cli/test/reported-tasks.test.ts
··· 114 114 each: undefined, 115 115 concurrent: undefined, 116 116 shuffle: undefined, 117 + fails: undefined, 117 118 retry: undefined, 118 119 repeats: undefined, 119 120 mode: 'run', 121 + tags: [], 122 + timeout: 5000, 120 123 }) 121 124 expect(passedTest.meta()).toEqual({}) 122 125 ··· 150 153 retry: undefined, 151 154 repeats: undefined, 152 155 mode: 'run', 156 + fails: undefined, 157 + tags: [], 158 + timeout: 5000, 153 159 }) 154 160 expect(passedTest.meta()).toEqual({}) 155 161
+290 -3
test/cli/test/static-collect.test.ts
··· 1 1 import type { CliOptions, TestCase, TestModule, TestSuite } from 'vitest/node' 2 + import { runVitest } from '#test-utils' 3 + import { resolve } from 'pathe' 2 4 import { expect, test } from 'vitest' 3 5 import { createVitest, rolldownVersion } from 'vitest/node' 4 6 ··· 773 775 `) 774 776 }) 775 777 776 - async function collectTests(code: string, options?: CliOptions) { 778 + test('collects tests with tags as a string', async () => { 779 + const testModule = await collectTests(` 780 + import { test } from 'vitest' 781 + 782 + describe('tagged tests', () => { 783 + test('test with single tag', { tags: 'slow' }, () => {}) 784 + test('test without tags', () => {}) 785 + }) 786 + `) 787 + expect(testModule).toMatchInlineSnapshot(` 788 + { 789 + "tagged tests": { 790 + "test with single tag": { 791 + "errors": [], 792 + "fullName": "tagged tests > test with single tag", 793 + "id": "-1732721377_0_0", 794 + "location": "5:6", 795 + "mode": "run", 796 + "state": "pending", 797 + "tags": [ 798 + "slow", 799 + ], 800 + }, 801 + "test without tags": { 802 + "errors": [], 803 + "fullName": "tagged tests > test without tags", 804 + "id": "-1732721377_0_1", 805 + "location": "6:6", 806 + "mode": "run", 807 + "state": "pending", 808 + }, 809 + }, 810 + } 811 + `) 812 + }) 813 + 814 + test('collects tests with tags as an array', async () => { 815 + const testModule = await collectTests(` 816 + import { test } from 'vitest' 817 + 818 + describe('tagged tests', () => { 819 + test('test with multiple tags', { tags: ['slow', 'integration'] }, () => {}) 820 + test('test with empty tags', { tags: [] }, () => {}) 821 + }) 822 + `) 823 + expect(testModule).toMatchInlineSnapshot(` 824 + { 825 + "tagged tests": { 826 + "test with empty tags": { 827 + "errors": [], 828 + "fullName": "tagged tests > test with empty tags", 829 + "id": "-1732721377_0_1", 830 + "location": "6:6", 831 + "mode": "run", 832 + "state": "pending", 833 + }, 834 + "test with multiple tags": { 835 + "errors": [], 836 + "fullName": "tagged tests > test with multiple tags", 837 + "id": "-1732721377_0_0", 838 + "location": "5:6", 839 + "mode": "run", 840 + "state": "pending", 841 + "tags": [ 842 + "slow", 843 + "integration", 844 + ], 845 + }, 846 + }, 847 + } 848 + `) 849 + }) 850 + 851 + test('collects suites with tags', async () => { 852 + const testModule = await collectTests(` 853 + import { test, describe } from 'vitest' 854 + 855 + describe('tagged suite', { tags: ['unit'] }, () => { 856 + test('test in tagged suite', () => {}) 857 + }) 858 + `) 859 + expect(testModule).toMatchInlineSnapshot(` 860 + { 861 + "tagged suite": { 862 + "test in tagged suite": { 863 + "errors": [], 864 + "fullName": "tagged suite > test in tagged suite", 865 + "id": "-1732721377_0_0", 866 + "location": "5:6", 867 + "mode": "run", 868 + "state": "pending", 869 + "tags": [ 870 + "unit", 871 + ], 872 + }, 873 + }, 874 + } 875 + `) 876 + }) 877 + 878 + test('inherits tags from parent suites', async () => { 879 + const testModule = await collectTests(` 880 + import { test, describe } from 'vitest' 881 + 882 + describe('outer suite', { tags: ['slow'] }, () => { 883 + test('test inherits parent tag', () => {}) 884 + 885 + describe('inner suite', { tags: ['integration'] }, () => { 886 + test('test inherits both tags', () => {}) 887 + test('test with own tag', { tags: ['unit'] }, () => {}) 888 + }) 889 + }) 890 + `) 891 + expect(testModule).toMatchInlineSnapshot(` 892 + { 893 + "outer suite": { 894 + "inner suite": { 895 + "test inherits both tags": { 896 + "errors": [], 897 + "fullName": "outer suite > inner suite > test inherits both tags", 898 + "id": "-1732721377_0_1_0", 899 + "location": "8:8", 900 + "mode": "run", 901 + "state": "pending", 902 + "tags": [ 903 + "slow", 904 + "integration", 905 + ], 906 + }, 907 + "test with own tag": { 908 + "errors": [], 909 + "fullName": "outer suite > inner suite > test with own tag", 910 + "id": "-1732721377_0_1_1", 911 + "location": "9:8", 912 + "mode": "run", 913 + "state": "pending", 914 + "tags": [ 915 + "slow", 916 + "integration", 917 + "unit", 918 + ], 919 + }, 920 + }, 921 + "test inherits parent tag": { 922 + "errors": [], 923 + "fullName": "outer suite > test inherits parent tag", 924 + "id": "-1732721377_0_0", 925 + "location": "5:6", 926 + "mode": "run", 927 + "state": "pending", 928 + "tags": [ 929 + "slow", 930 + ], 931 + }, 932 + }, 933 + } 934 + `) 935 + }) 936 + 937 + test('collects tags with other options', async () => { 938 + const testModule = await collectTests(` 939 + import { test } from 'vitest' 940 + 941 + describe('tests with options', () => { 942 + test('test with tags and timeout', { tags: ['slow'], timeout: 5000 }, () => {}) 943 + test.skip('skipped test with tags', { tags: ['unit'] }, () => {}) 944 + }) 945 + `) 946 + expect(testModule).toMatchInlineSnapshot(` 947 + { 948 + "tests with options": { 949 + "skipped test with tags": { 950 + "errors": [], 951 + "fullName": "tests with options > skipped test with tags", 952 + "id": "-1732721377_0_1", 953 + "location": "6:6", 954 + "mode": "skip", 955 + "state": "skipped", 956 + "tags": [ 957 + "unit", 958 + ], 959 + }, 960 + "test with tags and timeout": { 961 + "errors": [], 962 + "fullName": "tests with options > test with tags and timeout", 963 + "id": "-1732721377_0_0", 964 + "location": "5:6", 965 + "mode": "run", 966 + "state": "pending", 967 + "tags": [ 968 + "slow", 969 + ], 970 + }, 971 + }, 972 + } 973 + `) 974 + }) 975 + 976 + test('reports error when using undefined tag', async () => { 977 + const testModule = await collectTestModule(` 978 + import { test } from 'vitest' 979 + 980 + describe('tests with undefined tag', () => { 981 + test('test with undefined tag', { tags: ['undefined-tag'] }, () => {}) 982 + }) 983 + `) 984 + expect(testModule.errors()[0].message).toMatchInlineSnapshot(` 985 + "The tag "undefined-tag" is not defined in the configuration. Available tags are: 986 + - slow 987 + - integration 988 + - unit" 989 + `) 990 + }) 991 + 992 + test('@module-tag docs inject test tags', async () => { 993 + const { ctx } = await runVitest({ 994 + config: false, 995 + root: './fixtures/file-tags', 996 + standalone: true, 997 + watch: true, 998 + tags: [ 999 + { name: 'file' }, 1000 + { name: 'file-2' }, 1001 + { name: 'file/slash' }, 1002 + { name: 'test' }, 1003 + ], 1004 + }) 1005 + const testModule = await ctx!.experimental_parseSpecification( 1006 + ctx!.getRootProject().createSpecification(resolve(ctx!.config.root, './valid-file-tags.test.ts')), 1007 + ) 1008 + expect(testTree(testModule)).toMatchInlineSnapshot(` 1009 + { 1010 + "suite 1": { 1011 + "test 1": { 1012 + "errors": [], 1013 + "fullName": "suite 1 > test 1", 1014 + "id": "492646822_0_0", 1015 + "location": "10:2", 1016 + "mode": "run", 1017 + "state": "pending", 1018 + "tags": [ 1019 + "file", 1020 + "file-2", 1021 + "file/slash", 1022 + "test", 1023 + ], 1024 + }, 1025 + }, 1026 + } 1027 + `) 1028 + }) 1029 + 1030 + test('invalid @module-tag throws and error', async () => { 1031 + const { ctx } = await runVitest({ 1032 + config: false, 1033 + root: './fixtures/file-tags', 1034 + include: ['./error-file-tags.test.ts'], 1035 + tags: [ 1036 + { name: 'file' }, 1037 + { name: 'file-2' }, 1038 + { name: 'file/slash' }, 1039 + { name: 'test' }, 1040 + ], 1041 + }) 1042 + const testModule = await ctx!.experimental_parseSpecification( 1043 + ctx!.getRootProject().createSpecification(resolve(ctx!.config.root, './error-file-tags.test.ts')), 1044 + ) 1045 + expect(testModule.errors()[0].message).toMatchInlineSnapshot(` 1046 + "The tag "invalid" is not defined in the configuration. Available tags are: 1047 + - file 1048 + - file-2 1049 + - file/slash 1050 + - test" 1051 + `) 1052 + }) 1053 + 1054 + async function collectTestModule(code: string, options?: CliOptions) { 777 1055 const vitest = await createVitest( 778 1056 'test', 779 1057 { ··· 781 1059 includeTaskLocation: true, 782 1060 allowOnly: true, 783 1061 ...options, 1062 + tags: [ 1063 + { name: 'slow' }, 1064 + { name: 'integration' }, 1065 + { name: 'unit' }, 1066 + ], 784 1067 }, 785 1068 { 786 1069 plugins: [ ··· 795 1078 ], 796 1079 }, 797 1080 ) 798 - const testModule = await vitest.experimental_parseSpecification( 1081 + return vitest.experimental_parseSpecification( 799 1082 vitest.getRootProject().createSpecification('simple.test.ts'), 800 1083 ) 801 - return testTree(testModule) 1084 + } 1085 + 1086 + async function collectTests(code: string, options?: CliOptions) { 1087 + return testTree(await collectTestModule(code, options)) 802 1088 } 803 1089 804 1090 function testTree(module: TestModule | TestSuite, tree: any = {}) { ··· 832 1118 errors: testCase.result().errors || [], 833 1119 ...(testCase.task.dynamic ? { dynamic: true } : {}), 834 1120 ...(testCase.options.each ? { each: true } : {}), 1121 + ...(testCase.task.tags?.length ? { tags: testCase.task.tags } : {}), 835 1122 } 836 1123 }
+1274
test/cli/test/test-tags.test.ts
··· 1 + import type { TestCase, TestSuite } from 'vitest/node' 2 + import { runInlineTests, runVitest } from '#test-utils' 3 + import { expect, test } from 'vitest' 4 + 5 + test('vitest records tags', async () => { 6 + const { stderr, buildTree } = await runVitest({ 7 + root: './fixtures/test-tags', 8 + config: false, 9 + tags: [ 10 + { name: 'alone' }, 11 + { name: 'suite' }, 12 + { name: 'test' }, 13 + { name: 'suite_2' }, 14 + { name: 'test_2' }, 15 + ], 16 + }) 17 + 18 + expect(stderr).toBe('') 19 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 20 + { 21 + "basic.test.ts": { 22 + "suite 1": { 23 + "suite 2": { 24 + "test 3": [ 25 + "suite", 26 + "alone", 27 + "suite_2", 28 + ], 29 + "test 4": [ 30 + "suite", 31 + "alone", 32 + "suite_2", 33 + "test_2", 34 + ], 35 + }, 36 + "test 1": [ 37 + "suite", 38 + "alone", 39 + ], 40 + "test 2": [ 41 + "suite", 42 + "alone", 43 + "test", 44 + ], 45 + }, 46 + }, 47 + } 48 + `) 49 + }) 50 + 51 + test('filters tests based on --tags-filter=!ignore', async () => { 52 + const { stderr, testTree } = await runVitest({ 53 + root: './fixtures/test-tags', 54 + config: false, 55 + tags: [ 56 + { name: 'alone' }, 57 + { name: 'suite' }, 58 + { name: 'test' }, 59 + { name: 'suite_2' }, 60 + { name: 'test_2' }, 61 + ], 62 + tagsFilter: ['!suite_2'], 63 + }) 64 + 65 + expect(stderr).toBe('') 66 + expect(testTree()).toMatchInlineSnapshot(` 67 + { 68 + "basic.test.ts": { 69 + "suite 1": { 70 + "suite 2": { 71 + "test 3": "skipped", 72 + "test 4": "skipped", 73 + }, 74 + "test 1": "passed", 75 + "test 2": "passed", 76 + }, 77 + }, 78 + } 79 + `) 80 + }) 81 + 82 + test('filters tests based on --tags-filter=!ignore and --tags-filter=include', async () => { 83 + const { stderr, testTree } = await runVitest({ 84 + root: './fixtures/test-tags', 85 + config: false, 86 + tags: [ 87 + { name: 'alone' }, 88 + { name: 'suite' }, 89 + { name: 'test' }, 90 + { name: 'suite_2' }, 91 + { name: 'test_2' }, 92 + ], 93 + tagsFilter: ['!suite_2', 'test'], 94 + }) 95 + 96 + expect(stderr).toBe('') 97 + expect(testTree()).toMatchInlineSnapshot(` 98 + { 99 + "basic.test.ts": { 100 + "suite 1": { 101 + "suite 2": { 102 + "test 3": "skipped", 103 + "test 4": "skipped", 104 + }, 105 + "test 1": "skipped", 106 + "test 2": "passed", 107 + }, 108 + }, 109 + } 110 + `) 111 + }) 112 + 113 + test('filters tests based on --tags-filter=include', async () => { 114 + const { stderr, testTree } = await runVitest({ 115 + root: './fixtures/test-tags', 116 + config: false, 117 + tags: [ 118 + { name: 'alone' }, 119 + { name: 'suite' }, 120 + { name: 'test' }, 121 + { name: 'suite_2' }, 122 + { name: 'test_2' }, 123 + ], 124 + tagsFilter: ['test*'], 125 + }) 126 + 127 + expect(stderr).toBe('') 128 + expect(testTree()).toMatchInlineSnapshot(` 129 + { 130 + "basic.test.ts": { 131 + "suite 1": { 132 + "suite 2": { 133 + "test 3": "skipped", 134 + "test 4": "passed", 135 + }, 136 + "test 1": "skipped", 137 + "test 2": "passed", 138 + }, 139 + }, 140 + } 141 + `) 142 + }) 143 + 144 + test('throws an error if no tags are defined in the config, but in the test', async () => { 145 + const { stderr } = await runInlineTests( 146 + { 147 + 'basic.test.js': ` 148 + test('test 1', { tags: ['unknown'] }, () => {}) 149 + `, 150 + }, 151 + { globals: true }, 152 + ) 153 + 154 + expect(stderr).toMatchInlineSnapshot(` 155 + " 156 + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ 157 + 158 + FAIL basic.test.js [ basic.test.js ] 159 + Error: The Vitest config does't define any "tags", cannot apply "unknown" tag for this test. See: https://vitest.dev/guide/test-tags 160 + ❯ basic.test.js:2:9 161 + 1| 162 + 2| test('test 1', { tags: ['unknown'] }, () => {}) 163 + | ^ 164 + 3| 165 + 166 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ 167 + 168 + " 169 + `) 170 + }) 171 + 172 + test('throws an error if tag is not defined in the config, but in the test', async () => { 173 + const { stderr } = await runInlineTests( 174 + { 175 + 'basic.test.js': ` 176 + test('test 1', { tags: ['unknown'] }, () => {}) 177 + `, 178 + }, 179 + { 180 + globals: true, 181 + tags: [{ name: 'known' }], 182 + }, 183 + ) 184 + 185 + expect(stderr).toMatchInlineSnapshot(` 186 + " 187 + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ 188 + 189 + FAIL basic.test.js [ basic.test.js ] 190 + Error: The tag "unknown" is not defined in the configuration. Available tags are: 191 + - known 192 + ❯ basic.test.js:2:9 193 + 1| 194 + 2| test('test 1', { tags: ['unknown'] }, () => {}) 195 + | ^ 196 + 3| 197 + 198 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ 199 + 200 + " 201 + `) 202 + }) 203 + 204 + test('throws an error if tag is not defined in the config, but in --tags-filter filter', async () => { 205 + const { stderr } = await runInlineTests( 206 + { 207 + 'basic.test.js': '', 208 + }, 209 + { 210 + tagsFilter: ['unknown'], 211 + }, 212 + { fails: true }, 213 + ) 214 + 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') 215 + }) 216 + 217 + test('defining a tag available only in one project', async () => { 218 + const { stderr, buildTree, ctx } = await runInlineTests({ 219 + 'basic-1.test.js': ` 220 + test('test 1', { tags: ['project-1-tag'] }, () => {}) 221 + `, 222 + 'basic-2.test.js': ` 223 + test('test 2', { tags: ['global-tag', 'project-2-tag'] }, () => {}) 224 + `, 225 + 'vitest.config.js': { 226 + test: { 227 + globals: true, 228 + tags: [ 229 + { name: 'global-tag' }, 230 + ], 231 + projects: [ 232 + { 233 + extends: true, 234 + test: { 235 + name: 'project-1', 236 + include: ['basic-1.test.js'], 237 + tags: [ 238 + { name: 'project-1-tag' }, 239 + { name: 'override', timeout: 100 }, 240 + ], 241 + }, 242 + }, 243 + { 244 + extends: true, 245 + test: { 246 + name: 'project-2', 247 + include: ['basic-2.test.js'], 248 + tags: [ 249 + { name: 'project-2-tag' }, 250 + { name: 'override', timeout: 200 }, 251 + ], 252 + }, 253 + }, 254 + ], 255 + }, 256 + }, 257 + }, { 258 + tagsFilter: ['project-2-tag'], 259 + }) 260 + expect(stderr).toBe('') 261 + expect(Object.fromEntries(ctx!.projects.map(p => [p.name, p.config.tags]))).toMatchInlineSnapshot(` 262 + { 263 + "project-1": [ 264 + { 265 + "name": "project-1-tag", 266 + }, 267 + { 268 + "name": "override", 269 + "timeout": 100, 270 + }, 271 + { 272 + "name": "global-tag", 273 + }, 274 + { 275 + "name": "project-2-tag", 276 + }, 277 + ], 278 + "project-2": [ 279 + { 280 + "name": "project-2-tag", 281 + }, 282 + { 283 + "name": "override", 284 + "timeout": 200, 285 + }, 286 + { 287 + "name": "global-tag", 288 + }, 289 + { 290 + "name": "project-1-tag", 291 + }, 292 + ], 293 + } 294 + `) 295 + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` 296 + { 297 + "basic-1.test.js": { 298 + "test 1": { 299 + "mode": "run", 300 + "tags": [ 301 + "project-1-tag", 302 + ], 303 + "timeout": 5000, 304 + }, 305 + }, 306 + "basic-2.test.js": { 307 + "test 2": { 308 + "mode": "run", 309 + "tags": [ 310 + "global-tag", 311 + "project-2-tag", 312 + ], 313 + "timeout": 5000, 314 + }, 315 + }, 316 + } 317 + `) 318 + }) 319 + 320 + test('can specify custom options for tags', async () => { 321 + const { stderr, buildTree } = await runVitest({ 322 + root: './fixtures/test-tags', 323 + config: false, 324 + tags: [ 325 + { name: 'alone' }, 326 + { name: 'suite', timeout: 1000 }, 327 + { name: 'test', retry: 2, skip: true }, 328 + { name: 'suite_2', repeats: 3 }, 329 + { name: 'test_2', timeout: 500, retry: 1 }, 330 + ], 331 + }) 332 + expect(stderr).toBe('') 333 + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` 334 + { 335 + "basic.test.ts": { 336 + "suite 1": { 337 + "suite 2": { 338 + "test 3": { 339 + "mode": "run", 340 + "repeats": 3, 341 + "tags": [ 342 + "suite", 343 + "alone", 344 + "suite_2", 345 + ], 346 + "timeout": 1000, 347 + }, 348 + "test 4": { 349 + "mode": "run", 350 + "repeats": 3, 351 + "retry": 1, 352 + "tags": [ 353 + "suite", 354 + "alone", 355 + "suite_2", 356 + "test_2", 357 + ], 358 + "timeout": 500, 359 + }, 360 + }, 361 + "test 1": { 362 + "mode": "run", 363 + "tags": [ 364 + "suite", 365 + "alone", 366 + ], 367 + "timeout": 1000, 368 + }, 369 + "test 2": { 370 + "mode": "skip", 371 + "retry": 2, 372 + "tags": [ 373 + "suite", 374 + "alone", 375 + "test", 376 + ], 377 + "timeout": 1000, 378 + }, 379 + }, 380 + }, 381 + } 382 + `) 383 + }) 384 + 385 + test('can specify custom options with priorities for tags', async () => { 386 + const { stderr, ctx } = await runVitest({ 387 + root: './fixtures/test-tags', 388 + config: false, 389 + tags: [ 390 + { name: 'alone' }, 391 + { 392 + name: 'test', 393 + timeout: 500, 394 + skip: false, 395 + concurrent: true, 396 + fails: false, 397 + priority: 1, 398 + }, 399 + { 400 + name: 'suite', 401 + timeout: 1000, 402 + skip: true, 403 + concurrent: false, 404 + fails: true, 405 + priority: 2, 406 + }, 407 + { name: 'suite_2' }, 408 + { name: 'test_2' }, 409 + ], 410 + }) 411 + 412 + expect(stderr).toBe('') 413 + const testModule = ctx!.state.getTestModules()[0] 414 + const testSuite = testModule.children.at(0) as TestSuite 415 + const testCase = testSuite.children.at(1) as TestCase 416 + 417 + expect(testCase.name).toBe('test 2') 418 + expect(testCase.options.tags).toEqual(['suite', 'alone', 'test']) 419 + // from 'test' tag (priority 1 is higher) 420 + expect(testCase.options.timeout).toBe(500) 421 + // concurrent is not set anywhere manually, so 422 + // test always gets it from the highest priority tag 423 + expect(testCase.options.concurrent).toBe(true) 424 + expect(testCase.options.fails).toBe(false) 425 + expect(testCase.result().state).toBe('passed') 426 + }) 427 + 428 + test('custom options override tag options', async () => { 429 + const { stderr, buildTree } = await runInlineTests({ 430 + 'basic.test.js': ` 431 + test.fails('test 1', { tags: ['test'], timeout: 2000, skip: false, repeats: 0 }, () => { 432 + throw new Error('fail') 433 + }) 434 + `, 435 + 'vitest.config.js': { 436 + test: { 437 + globals: true, 438 + tags: [ 439 + { 440 + name: 'test', 441 + timeout: 500, 442 + skip: true, 443 + concurrent: true, 444 + fails: false, 445 + retry: 2, 446 + repeats: 2, 447 + }, 448 + ], 449 + }, 450 + }, 451 + }) 452 + expect(stderr).toBe('') 453 + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` 454 + { 455 + "basic.test.js": { 456 + "test 1": { 457 + "concurrent": true, 458 + "fails": true, 459 + "mode": "run", 460 + "repeats": 0, 461 + "retry": 2, 462 + "tags": [ 463 + "test", 464 + ], 465 + "timeout": 2000, 466 + }, 467 + }, 468 + } 469 + `) 470 + }) 471 + 472 + test('strictFlag: false does not throw an error if test has an undefined tag', async () => { 473 + const { stderr } = await runInlineTests( 474 + { 475 + 'basic.test.js': ` 476 + test('test 1', { tags: ['unknown'] }, () => {}) 477 + `, 478 + 'vitest.config.js': { 479 + test: { 480 + globals: true, 481 + strictTags: false, 482 + tags: [{ name: 'known' }], 483 + }, 484 + }, 485 + }, 486 + ) 487 + 488 + expect(stderr).toBe('') 489 + }) 490 + 491 + test('@module-tag docs inject test tags', async () => { 492 + const { stderr, buildTree } = await runVitest({ 493 + config: false, 494 + root: './fixtures/file-tags', 495 + include: ['./valid-file-tags.test.ts'], 496 + tags: [ 497 + { name: 'file' }, 498 + { name: 'file-2' }, 499 + { name: 'file/slash' }, 500 + { name: 'test' }, 501 + ], 502 + }) 503 + expect(stderr).toBe('') 504 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 505 + { 506 + "valid-file-tags.test.ts": { 507 + "suite 1": { 508 + "test 1": [ 509 + "file", 510 + "file-2", 511 + "file/slash", 512 + "test", 513 + ], 514 + }, 515 + }, 516 + } 517 + `) 518 + }) 519 + 520 + test('invalid @module-tag throws and error', async () => { 521 + const { stderr } = await runVitest({ 522 + config: false, 523 + root: './fixtures/file-tags', 524 + include: ['./error-file-tags.test.ts'], 525 + tags: [ 526 + { name: 'file' }, 527 + { name: 'file-2' }, 528 + { name: 'file/slash' }, 529 + { name: 'test' }, 530 + ], 531 + }) 532 + expect(stderr).toMatchInlineSnapshot(` 533 + " 534 + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ 535 + 536 + FAIL error-file-tags.test.ts [ error-file-tags.test.ts ] 537 + Error: The tag "invalid" is not defined in the configuration. Available tags are: 538 + - file 539 + - file-2 540 + - file/slash 541 + - test 542 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ 543 + 544 + " 545 + `) 546 + }) 547 + 548 + test('@module-tag on one line docs inject test tags', async () => { 549 + const { stderr, buildTree } = await runVitest({ 550 + config: false, 551 + root: './fixtures/file-tags', 552 + include: ['./valid-file-one-line-comment.test.ts'], 553 + tags: [ 554 + { name: 'file' }, 555 + { name: 'file-2' }, 556 + { name: 'file/slash' }, 557 + { name: 'test' }, 558 + ], 559 + }) 560 + expect(stderr).toBe('') 561 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 562 + { 563 + "valid-file-one-line-comment.test.ts": { 564 + "suite 1": { 565 + "test 1": [ 566 + "file", 567 + "file-2", 568 + "file/slash", 569 + "test", 570 + ], 571 + }, 572 + }, 573 + } 574 + `) 575 + }) 576 + 577 + test('invalid @module-tag on one line throws and error', async () => { 578 + const { stderr } = await runVitest({ 579 + config: false, 580 + root: './fixtures/file-tags', 581 + include: ['./error-file-one-line-comment.test.ts'], 582 + tags: [ 583 + { name: 'file' }, 584 + { name: 'file-2' }, 585 + { name: 'file/slash' }, 586 + { name: 'test' }, 587 + ], 588 + }) 589 + expect(stderr).toMatchInlineSnapshot(` 590 + " 591 + ⎯⎯⎯⎯⎯⎯ Failed Suites 1 ⎯⎯⎯⎯⎯⎯⎯ 592 + 593 + FAIL error-file-one-line-comment.test.ts [ error-file-one-line-comment.test.ts ] 594 + Error: The tag "invalid" is not defined in the configuration. Available tags are: 595 + - file 596 + - file-2 597 + - file/slash 598 + - test 599 + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯ 600 + 601 + " 602 + `) 603 + }) 604 + 605 + test('@module-tag with strictTags: false allows undefined tags', async () => { 606 + const { stderr, buildTree } = await runVitest({ 607 + config: false, 608 + root: './fixtures/file-tags', 609 + include: ['./error-file-tags.test.ts'], 610 + strictTags: false, 611 + tags: [ 612 + { name: 'file' }, 613 + { name: 'file-2' }, 614 + { name: 'file/slash' }, 615 + { name: 'test' }, 616 + ], 617 + }) 618 + expect(stderr).toBe('') 619 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 620 + { 621 + "error-file-tags.test.ts": { 622 + "suite 1": { 623 + "test 1": [ 624 + "invalid", 625 + "unknown", 626 + "test", 627 + ], 628 + }, 629 + }, 630 + } 631 + `) 632 + }) 633 + 634 + test('sequential tag option makes tests run sequentially', async () => { 635 + const { stderr, buildTree } = await runInlineTests({ 636 + 'basic.test.js': ` 637 + test('test 1', { tags: ['sequential-tag'] }, () => {}) 638 + test('test 2', { tags: ['sequential-tag'] }, () => {}) 639 + `, 640 + 'vitest.config.js': { 641 + test: { 642 + globals: true, 643 + tags: [ 644 + { name: 'sequential-tag', sequential: true }, 645 + ], 646 + }, 647 + }, 648 + }) 649 + expect(stderr).toBe('') 650 + // sequential is not visible in options, it affect "concurrent" only, which is not set if false 651 + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` 652 + { 653 + "basic.test.js": { 654 + "test 1": { 655 + "mode": "run", 656 + "tags": [ 657 + "sequential-tag", 658 + ], 659 + "timeout": 5000, 660 + }, 661 + "test 2": { 662 + "mode": "run", 663 + "tags": [ 664 + "sequential-tag", 665 + ], 666 + "timeout": 5000, 667 + }, 668 + }, 669 + } 670 + `) 671 + }) 672 + 673 + test('only tag option marks tests as only', async () => { 674 + const { stderr, buildTree } = await runInlineTests({ 675 + 'basic.test.js': ` 676 + test('test 1', { tags: ['only-tag'] }, () => {}) 677 + test('test 2', () => {}) 678 + `, 679 + 'vitest.config.js': { 680 + test: { 681 + globals: true, 682 + tags: [ 683 + { name: 'only-tag', only: true }, 684 + ], 685 + allowOnly: true, 686 + }, 687 + }, 688 + }) 689 + expect(stderr).toBe('') 690 + expect(buildOptionsTree(buildTree)).toMatchInlineSnapshot(` 691 + { 692 + "basic.test.js": { 693 + "test 1": { 694 + "mode": "run", 695 + "tags": [ 696 + "only-tag", 697 + ], 698 + "timeout": 5000, 699 + }, 700 + "test 2": { 701 + "mode": "skip", 702 + "tags": [], 703 + "timeout": 5000, 704 + }, 705 + }, 706 + } 707 + `) 708 + }) 709 + 710 + test('tags without explicit priority use definition order (last wins)', async () => { 711 + const { stderr, ctx } = await runInlineTests({ 712 + 'basic.test.js': ` 713 + test('test 1', { tags: ['tag-a', 'tag-b'] }, () => {}) 714 + `, 715 + 'vitest.config.js': { 716 + test: { 717 + globals: true, 718 + tags: [ 719 + { name: 'tag-a', timeout: 1000 }, 720 + { name: 'tag-b', timeout: 2000 }, 721 + ], 722 + }, 723 + }, 724 + }) 725 + expect(stderr).toBe('') 726 + const testModule = ctx!.state.getTestModules()[0] 727 + const testCase = testModule.children.at(0) as TestCase 728 + expect(testCase.options.timeout).toBe(2000) 729 + }) 730 + 731 + test('equal priority tags use definition order (last wins)', async () => { 732 + const { stderr, ctx } = await runInlineTests({ 733 + 'basic.test.js': ` 734 + test('test 1', { tags: ['tag-a', 'tag-b'] }, () => {}) 735 + `, 736 + 'vitest.config.js': { 737 + test: { 738 + globals: true, 739 + tags: [ 740 + { name: 'tag-a', timeout: 1000, priority: 1 }, 741 + { name: 'tag-b', timeout: 2000, priority: 1 }, 742 + ], 743 + }, 744 + }, 745 + }) 746 + expect(stderr).toBe('') 747 + const testModule = ctx!.state.getTestModules()[0] 748 + const testCase = testModule.children.at(0) as TestCase 749 + expect(testCase.options.timeout).toBe(2000) 750 + }) 751 + 752 + test('negative priority values is not allowed', async () => { 753 + const { stderr } = await runInlineTests({ 754 + 'basic.test.js': ` 755 + test('test 1', { tags: ['low-priority', 'high-priority'] }, () => {}) 756 + `, 757 + 'vitest.config.js': { 758 + test: { 759 + globals: true, 760 + tags: [ 761 + { name: 'low-priority', timeout: 1000, priority: -10 }, 762 + ], 763 + }, 764 + }, 765 + }, {}, { fails: true }) 766 + expect(stderr).toContain('Tag "low-priority": priority must be a non-negative number.') 767 + }) 768 + 769 + test.for([ 770 + '!invalid', 771 + 'inv*alid', 772 + 'inv&alid', 773 + 'inv|alid', 774 + 'inv(alid', 775 + 'inv)alid', 776 + ])('tag name "%s" containing special character "%s" is not allowed', async (tagName) => { 777 + const { stderr } = await runInlineTests({ 778 + 'basic.test.js': ` 779 + test('test 1', () => {}) 780 + `, 781 + 'vitest.config.js': { 782 + test: { 783 + globals: true, 784 + tags: [ 785 + { name: tagName }, 786 + ], 787 + }, 788 + }, 789 + }, {}, { fails: true }) 790 + expect(stderr).toContain(`Tag name "${tagName}" is invalid. Tag names cannot contain "!", "*", "&", "|", "(", or ")".`) 791 + }) 792 + 793 + test.for([ 794 + 'and', 795 + 'or', 796 + 'not', 797 + 'AND', 798 + 'OR', 799 + 'NOT', 800 + ])('tag name "%s" is a reserved keyword and is not allowed', async (tagName) => { 801 + const { stderr } = await runInlineTests({ 802 + 'basic.test.js': ` 803 + test('test 1', () => {}) 804 + `, 805 + 'vitest.config.js': { 806 + test: { 807 + globals: true, 808 + tags: [ 809 + { name: tagName }, 810 + ], 811 + }, 812 + }, 813 + }, {}, { fails: true }) 814 + expect(stderr).toContain(`Tag name "${tagName}" is invalid. Tag names cannot be a logical operator like "and", "or", "not".`) 815 + }) 816 + 817 + test('strictTags: false does not allow undefined tags in filter, it only affects test definition', async () => { 818 + const { stderr } = await runInlineTests({ 819 + 'basic.test.js': ` 820 + test('test 1', { tags: ['known'] }, () => {}) 821 + test('test 2', () => {}) 822 + `, 823 + 'vitest.config.js': { 824 + test: { 825 + globals: true, 826 + strictTags: false, 827 + tags: [{ name: 'known' }], 828 + }, 829 + }, 830 + }, { 831 + tagsFilter: ['unknown'], 832 + }) 833 + expect(stderr).toContain(`The tag pattern "unknown" is not defined in the configuration. Available tags are: 834 + - known`) 835 + }) 836 + 837 + test('--list-tags prints error if no tags are defined', async () => { 838 + const { stdout, stderr, exitCode } = await runVitest({ 839 + config: false, 840 + listTags: true, 841 + }) 842 + expect(stdout).toBe('') 843 + expect(exitCode).toBe(1) 844 + expect(stderr).toMatchInlineSnapshot(` 845 + " ERROR No test tags found in any project. Exiting with code 1. 846 + " 847 + `) 848 + }) 849 + 850 + test('--list-tags prints tags defined in config', async () => { 851 + const { stdout, stderr } = await runVitest({ 852 + config: false, 853 + listTags: true, 854 + tags: [ 855 + { name: 'unit' }, 856 + { name: 'e2e', description: 'End-to-end tests' }, 857 + { name: 'slow' }, 858 + ], 859 + }) 860 + expect(stderr).toBe('') 861 + expect(`\n${stdout}`).toMatchInlineSnapshot(` 862 + " 863 + unit 864 + e2e: End-to-end tests 865 + slow 866 + " 867 + `) 868 + }) 869 + 870 + test('--list-tags prints tags from multiple projects', async () => { 871 + const { stdout, stderr } = await runInlineTests({ 872 + 'vitest.config.js': { 873 + test: { 874 + tags: [ 875 + { name: 'global-tag', description: 'Available in all projects' }, 876 + ], 877 + projects: [ 878 + { 879 + extends: true, 880 + test: { 881 + name: 'project-1', 882 + tags: [ 883 + { name: 'project-1-tag' }, 884 + ], 885 + }, 886 + }, 887 + { 888 + extends: true, 889 + test: { 890 + name: 'project-2', 891 + tags: [ 892 + { name: 'project-2-tag', description: 'Only in project 2' }, 893 + { name: 'project-2-again' }, 894 + ], 895 + }, 896 + }, 897 + ], 898 + }, 899 + }, 900 + }, { 901 + listTags: true, 902 + }) 903 + expect(stderr).toBe('') 904 + expect(`\n${stdout}`).toMatchInlineSnapshot(` 905 + " 906 + global-tag: Available in all projects 907 + |project-1| 908 + project-1-tag 909 + |project-2| 910 + project-2-tag: Only in project 2 911 + project-2-again 912 + " 913 + `) 914 + }) 915 + 916 + test('--list-tags prints tags with named root project', async () => { 917 + const { stdout, stderr } = await runInlineTests({ 918 + 'vitest.config.js': { 919 + test: { 920 + name: 'root', 921 + tags: [ 922 + { name: 'root-tag' }, 923 + { name: 'another-tag', description: 'From root' }, 924 + ], 925 + projects: [ 926 + { 927 + extends: true, 928 + test: { 929 + name: 'child', 930 + tags: [ 931 + { name: 'child-tag' }, 932 + { name: 'child-2-tag' }, 933 + ], 934 + }, 935 + }, 936 + ], 937 + }, 938 + }, 939 + }, { 940 + listTags: true, 941 + }) 942 + expect(stderr).toBe('') 943 + expect(`\n${stdout}`).toMatchInlineSnapshot(` 944 + " 945 + |root| 946 + root-tag 947 + another-tag: From root 948 + |child| 949 + child-tag 950 + child-2-tag 951 + " 952 + `) 953 + }) 954 + 955 + test('--list-tags aligns tags with different project name lengths', async () => { 956 + const { stdout, stderr } = await runVitest({ 957 + config: false, 958 + listTags: true, 959 + projects: [ 960 + { 961 + test: { 962 + name: 'a', 963 + tags: [ 964 + { name: 'tag-1' }, 965 + { name: 'tag-2' }, 966 + ], 967 + }, 968 + }, 969 + { 970 + test: { 971 + name: 'long-project-name', 972 + tags: [ 973 + { name: 'tag-3' }, 974 + { name: 'tag-4' }, 975 + ], 976 + }, 977 + }, 978 + { 979 + test: { 980 + name: 'medium', 981 + tags: [ 982 + { name: 'tag-5' }, 983 + ], 984 + }, 985 + }, 986 + ], 987 + }) 988 + expect(stderr).toBe('') 989 + expect(`\n${stdout}`).toMatchInlineSnapshot(` 990 + " 991 + |a| 992 + tag-1 993 + tag-2 994 + |long-project-name| 995 + tag-3 996 + tag-4 997 + |medium| 998 + tag-5 999 + " 1000 + `) 1001 + }) 1002 + 1003 + test('--list-tags=json prints error if no tags are defined', async () => { 1004 + const { stdout, stderr } = await runVitest({ 1005 + config: false, 1006 + listTags: 'json', 1007 + }) 1008 + expect(stdout).toBe('') 1009 + expect(stderr).toContain('No test tags found in any project. Exiting with code 1.') 1010 + }) 1011 + 1012 + test('--list-tags=json prints tags as JSON', async () => { 1013 + const { stdout, stderr } = await runVitest({ 1014 + config: false, 1015 + listTags: 'json', 1016 + tags: [ 1017 + { name: 'unit' }, 1018 + { name: 'e2e', description: 'End-to-end tests' }, 1019 + ], 1020 + }) 1021 + expect(stderr).toBe('') 1022 + const json = JSON.parse(stdout) 1023 + expect(json).toEqual({ 1024 + tags: [ 1025 + { name: 'unit' }, 1026 + { name: 'e2e', description: 'End-to-end tests' }, 1027 + ], 1028 + projects: [], 1029 + }) 1030 + }) 1031 + 1032 + test('--list-tags=json prints tags from multiple projects', async () => { 1033 + const { stdout, stderr } = await runVitest({ 1034 + config: false, 1035 + listTags: 'json', 1036 + tags: [ 1037 + { name: 'global-tag' }, 1038 + ], 1039 + projects: [ 1040 + { 1041 + test: { 1042 + name: 'project-1', 1043 + tags: [ 1044 + { name: 'project-1-tag' }, 1045 + ], 1046 + }, 1047 + }, 1048 + { 1049 + test: { 1050 + name: 'project-2', 1051 + tags: [ 1052 + { name: 'project-2-tag', description: 'Only in project 2' }, 1053 + ], 1054 + }, 1055 + }, 1056 + ], 1057 + }) 1058 + expect(stderr).toBe('') 1059 + const json = JSON.parse(stdout) 1060 + expect(json).toEqual({ 1061 + tags: [ 1062 + { name: 'global-tag' }, 1063 + ], 1064 + projects: [ 1065 + { 1066 + name: 'project-1', 1067 + tags: [ 1068 + { name: 'project-1-tag' }, 1069 + ], 1070 + }, 1071 + { 1072 + name: 'project-2', 1073 + tags: [ 1074 + { name: 'project-2-tag', description: 'Only in project 2' }, 1075 + ], 1076 + }, 1077 + ], 1078 + }) 1079 + }) 1080 + 1081 + test('duplicate tags from suite and test are deduplicated', async () => { 1082 + const { stderr, buildTree } = await runInlineTests({ 1083 + 'basic.test.js': ` 1084 + describe('suite', { tags: ['shared'] }, () => { 1085 + test('test 1', { tags: ['shared', 'unique'] }, () => {}) 1086 + }) 1087 + `, 1088 + 'vitest.config.js': { 1089 + test: { 1090 + globals: true, 1091 + tags: [ 1092 + { name: 'shared' }, 1093 + { name: 'unique' }, 1094 + ], 1095 + }, 1096 + }, 1097 + }) 1098 + expect(stderr).toBe('') 1099 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 1100 + { 1101 + "basic.test.js": { 1102 + "suite": { 1103 + "test 1": [ 1104 + "shared", 1105 + "unique", 1106 + ], 1107 + }, 1108 + }, 1109 + } 1110 + `) 1111 + }) 1112 + 1113 + test('empty tags array on test is handled correctly', async () => { 1114 + const { stderr, buildTree } = await runInlineTests({ 1115 + 'basic.test.js': ` 1116 + test('test 1', { tags: [] }, () => {}) 1117 + `, 1118 + 'vitest.config.js': { 1119 + test: { 1120 + globals: true, 1121 + tags: [{ name: 'unused' }], 1122 + }, 1123 + }, 1124 + }) 1125 + expect(stderr).toBe('') 1126 + expect(getTestTree(buildTree)).toMatchInlineSnapshot(` 1127 + { 1128 + "basic.test.js": { 1129 + "test 1": [], 1130 + }, 1131 + } 1132 + `) 1133 + }) 1134 + 1135 + test('filters tests with complex AND/OR expressions', async () => { 1136 + const { stderr, testTree } = await runInlineTests({ 1137 + 'basic.test.js': ` 1138 + test('test 1', { tags: ['unit', 'fast'] }, () => {}) 1139 + test('test 2', { tags: ['unit', 'slow'] }, () => {}) 1140 + test('test 3', { tags: ['e2e', 'fast'] }, () => {}) 1141 + test('test 4', { tags: ['e2e', 'slow'] }, () => {}) 1142 + `, 1143 + 'vitest.config.js': { 1144 + test: { 1145 + globals: true, 1146 + tags: [ 1147 + { name: 'unit' }, 1148 + { name: 'e2e' }, 1149 + { name: 'fast' }, 1150 + { name: 'slow' }, 1151 + ], 1152 + }, 1153 + }, 1154 + }, { 1155 + tagsFilter: ['(unit || e2e) && fast'], 1156 + }) 1157 + expect(stderr).toBe('') 1158 + expect(testTree()).toMatchInlineSnapshot(` 1159 + { 1160 + "basic.test.js": { 1161 + "test 1": "passed", 1162 + "test 2": "skipped", 1163 + "test 3": "passed", 1164 + "test 4": "skipped", 1165 + }, 1166 + } 1167 + `) 1168 + }) 1169 + 1170 + test('filters tests with NOT and parentheses', async () => { 1171 + const { stderr, testTree } = await runInlineTests({ 1172 + 'basic.test.js': ` 1173 + test('test 1', { tags: ['browser', 'chrome'] }, () => {}) 1174 + test('test 2', { tags: ['browser', 'firefox'] }, () => {}) 1175 + test('test 3', { tags: ['browser', 'edge'] }, () => {}) 1176 + test('test 4', { tags: ['node'] }, () => {}) 1177 + `, 1178 + 'vitest.config.js': { 1179 + test: { 1180 + globals: true, 1181 + tags: [ 1182 + { name: 'browser' }, 1183 + { name: 'chrome' }, 1184 + { name: 'firefox' }, 1185 + { name: 'edge' }, 1186 + { name: 'node' }, 1187 + ], 1188 + }, 1189 + }, 1190 + }, { 1191 + tagsFilter: ['browser && !(edge)'], 1192 + }) 1193 + expect(stderr).toBe('') 1194 + expect(testTree()).toMatchInlineSnapshot(` 1195 + { 1196 + "basic.test.js": { 1197 + "test 1": "passed", 1198 + "test 2": "passed", 1199 + "test 3": "skipped", 1200 + "test 4": "skipped", 1201 + }, 1202 + } 1203 + `) 1204 + }) 1205 + 1206 + test('throws an error when several tags with the same name are defined', async () => { 1207 + const { stderr } = await runInlineTests({ 1208 + 'basic.test.js': ` 1209 + test('test 1', () => {}) 1210 + `, 1211 + 'vitest.config.js': { 1212 + test: { 1213 + globals: true, 1214 + tags: [ 1215 + { name: 'duplicate', timeout: 1000 }, 1216 + { name: 'unique' }, 1217 + { name: 'duplicate', timeout: 2000 }, 1218 + ], 1219 + }, 1220 + }, 1221 + }, {}, { fails: true }) 1222 + expect(stderr).toContain('Tag name "duplicate" is already defined in "test.tags". Tag names must be unique.') 1223 + }) 1224 + 1225 + test('multiple filter expressions act as AND', async () => { 1226 + const { stderr, testTree } = await runInlineTests({ 1227 + 'basic.test.js': ` 1228 + test('test 1', { tags: ['unit', 'fast'] }, () => {}) 1229 + test('test 2', { tags: ['unit', 'slow'] }, () => {}) 1230 + test('test 3', { tags: ['e2e', 'fast'] }, () => {}) 1231 + `, 1232 + 'vitest.config.js': { 1233 + test: { 1234 + globals: true, 1235 + tags: [ 1236 + { name: 'unit' }, 1237 + { name: 'e2e' }, 1238 + { name: 'fast' }, 1239 + { name: 'slow' }, 1240 + ], 1241 + }, 1242 + }, 1243 + }, { 1244 + tagsFilter: ['unit || e2e', '!slow'], 1245 + }) 1246 + expect(stderr).toBe('') 1247 + expect(testTree()).toMatchInlineSnapshot(` 1248 + { 1249 + "basic.test.js": { 1250 + "test 1": "passed", 1251 + "test 2": "skipped", 1252 + "test 3": "passed", 1253 + }, 1254 + } 1255 + `) 1256 + }) 1257 + 1258 + function getTestTree(builder: (fn: (test: TestCase) => any) => any) { 1259 + return builder(test => test.options.tags) 1260 + } 1261 + 1262 + function buildOptionsTree(builder: (fn: (test: TestCase) => any) => any) { 1263 + return builder(test => removeUndefined(test.options)) 1264 + } 1265 + 1266 + function removeUndefined<T extends Record<string, any>>(obj: T): Partial<T> { 1267 + const result: Partial<T> = {} 1268 + for (const key in obj) { 1269 + if (obj[key] !== undefined) { 1270 + result[key] = obj[key] 1271 + } 1272 + } 1273 + return result 1274 + }
+544
test/core/test/test-tags-filter.test.ts
··· 1 + import type { TestTagDefinition } from '@vitest/runner' 2 + import { createTagsFilter } from '@vitest/runner/utils' 3 + import { describe, expect, test } from 'vitest' 4 + 5 + function tags(...names: string[]): TestTagDefinition[] { 6 + return names.map(name => ({ name })) 7 + } 8 + 9 + describe('createTagsFilter', () => { 10 + describe('simple tag matching', () => { 11 + test('matches a single tag', () => { 12 + const filter = createTagsFilter(['foo'], tags('foo', 'bar')) 13 + expect(filter(['foo'])).toBe(true) 14 + expect(filter(['bar'])).toBe(false) 15 + expect(filter(['foo', 'bar'])).toBe(true) 16 + expect(filter([])).toBe(false) 17 + }) 18 + 19 + test('matches multiple expressions (AND between expressions)', () => { 20 + const filter = createTagsFilter(['foo', 'bar'], tags('foo', 'bar', 'baz')) 21 + expect(filter(['foo', 'bar'])).toBe(true) 22 + expect(filter(['foo'])).toBe(false) 23 + expect(filter(['bar'])).toBe(false) 24 + expect(filter(['foo', 'bar', 'baz'])).toBe(true) 25 + }) 26 + }) 27 + 28 + describe('NOT operator', () => { 29 + test('negates with ! prefix', () => { 30 + const filter = createTagsFilter(['!foo'], tags('foo', 'bar')) 31 + expect(filter(['foo'])).toBe(false) 32 + expect(filter(['bar'])).toBe(true) 33 + expect(filter(['foo', 'bar'])).toBe(false) 34 + expect(filter([])).toBe(true) 35 + }) 36 + 37 + test('negates with "not" keyword', () => { 38 + const filter = createTagsFilter(['not foo'], tags('foo', 'bar')) 39 + expect(filter(['foo'])).toBe(false) 40 + expect(filter(['bar'])).toBe(true) 41 + expect(filter(['foo', 'bar'])).toBe(false) 42 + expect(filter([])).toBe(true) 43 + }) 44 + 45 + test('double negation', () => { 46 + const filter = createTagsFilter(['!!foo'], tags('foo', 'bar')) 47 + expect(filter(['foo'])).toBe(true) 48 + expect(filter(['bar'])).toBe(false) 49 + }) 50 + 51 + test('double negation with "not not"', () => { 52 + const filter = createTagsFilter(['not not foo'], tags('foo', 'bar')) 53 + expect(filter(['foo'])).toBe(true) 54 + expect(filter(['bar'])).toBe(false) 55 + }) 56 + }) 57 + 58 + describe('AND operator', () => { 59 + test('matches with "and" keyword', () => { 60 + const filter = createTagsFilter(['foo and bar'], tags('foo', 'bar', 'baz')) 61 + expect(filter(['foo', 'bar'])).toBe(true) 62 + expect(filter(['foo'])).toBe(false) 63 + expect(filter(['bar'])).toBe(false) 64 + expect(filter(['foo', 'bar', 'baz'])).toBe(true) 65 + }) 66 + 67 + test('matches with && operator', () => { 68 + const filter = createTagsFilter(['foo && bar'], tags('foo', 'bar', 'baz')) 69 + expect(filter(['foo', 'bar'])).toBe(true) 70 + expect(filter(['foo'])).toBe(false) 71 + expect(filter(['bar'])).toBe(false) 72 + }) 73 + 74 + test('chained AND operators', () => { 75 + const filter = createTagsFilter(['foo and bar and baz'], tags('foo', 'bar', 'baz')) 76 + expect(filter(['foo', 'bar', 'baz'])).toBe(true) 77 + expect(filter(['foo', 'bar'])).toBe(false) 78 + expect(filter(['foo', 'baz'])).toBe(false) 79 + }) 80 + 81 + test('chained && operators', () => { 82 + const filter = createTagsFilter(['foo && bar && baz'], tags('foo', 'bar', 'baz')) 83 + expect(filter(['foo', 'bar', 'baz'])).toBe(true) 84 + expect(filter(['foo', 'bar'])).toBe(false) 85 + }) 86 + }) 87 + 88 + describe('OR operator', () => { 89 + test('matches with "or" keyword', () => { 90 + const filter = createTagsFilter(['foo or bar'], tags('foo', 'bar', 'baz')) 91 + expect(filter(['foo'])).toBe(true) 92 + expect(filter(['bar'])).toBe(true) 93 + expect(filter(['baz'])).toBe(false) 94 + expect(filter(['foo', 'bar'])).toBe(true) 95 + }) 96 + 97 + test('matches with || operator', () => { 98 + const filter = createTagsFilter(['foo || bar'], tags('foo', 'bar', 'baz')) 99 + expect(filter(['foo'])).toBe(true) 100 + expect(filter(['bar'])).toBe(true) 101 + expect(filter(['baz'])).toBe(false) 102 + }) 103 + 104 + test('chained OR operators', () => { 105 + const filter = createTagsFilter(['foo or bar or baz'], tags('foo', 'bar', 'baz', 'qux')) 106 + expect(filter(['foo'])).toBe(true) 107 + expect(filter(['bar'])).toBe(true) 108 + expect(filter(['baz'])).toBe(true) 109 + expect(filter(['qux'])).toBe(false) 110 + }) 111 + 112 + test('chained || operators', () => { 113 + const filter = createTagsFilter(['foo || bar || baz'], tags('foo', 'bar', 'baz', 'qux')) 114 + expect(filter(['foo'])).toBe(true) 115 + expect(filter(['bar'])).toBe(true) 116 + expect(filter(['baz'])).toBe(true) 117 + expect(filter(['qux'])).toBe(false) 118 + }) 119 + }) 120 + 121 + describe('operator precedence', () => { 122 + test('AND has higher precedence than OR', () => { 123 + const filter = createTagsFilter(['foo or bar and baz'], tags('foo', 'bar', 'baz')) 124 + // Parsed as: foo or (bar and baz) 125 + expect(filter(['foo'])).toBe(true) 126 + expect(filter(['bar', 'baz'])).toBe(true) 127 + expect(filter(['bar'])).toBe(false) 128 + expect(filter(['baz'])).toBe(false) 129 + }) 130 + 131 + test('&& has higher precedence than ||', () => { 132 + const filter = createTagsFilter(['foo || bar && baz'], tags('foo', 'bar', 'baz')) 133 + // Parsed as: foo || (bar && baz) 134 + expect(filter(['foo'])).toBe(true) 135 + expect(filter(['bar', 'baz'])).toBe(true) 136 + expect(filter(['bar'])).toBe(false) 137 + }) 138 + 139 + test('NOT has highest precedence', () => { 140 + const filter = createTagsFilter(['!foo and bar'], tags('foo', 'bar')) 141 + // Parsed as: (!foo) and bar 142 + expect(filter(['bar'])).toBe(true) 143 + expect(filter(['foo', 'bar'])).toBe(false) 144 + expect(filter(['foo'])).toBe(false) 145 + }) 146 + }) 147 + 148 + describe('parentheses', () => { 149 + test('overrides precedence with parentheses', () => { 150 + const filter = createTagsFilter(['(foo or bar) and baz'], tags('foo', 'bar', 'baz')) 151 + expect(filter(['foo', 'baz'])).toBe(true) 152 + expect(filter(['bar', 'baz'])).toBe(true) 153 + expect(filter(['foo'])).toBe(false) 154 + expect(filter(['baz'])).toBe(false) 155 + }) 156 + 157 + test('nested parentheses', () => { 158 + const filter = createTagsFilter(['((foo or bar) and baz) or qux'], tags('foo', 'bar', 'baz', 'qux')) 159 + expect(filter(['foo', 'baz'])).toBe(true) 160 + expect(filter(['bar', 'baz'])).toBe(true) 161 + expect(filter(['qux'])).toBe(true) 162 + expect(filter(['foo'])).toBe(false) 163 + expect(filter(['baz'])).toBe(false) 164 + }) 165 + 166 + test('negation with parentheses', () => { 167 + const filter = createTagsFilter(['!(foo or bar)'], tags('foo', 'bar', 'baz')) 168 + expect(filter(['foo'])).toBe(false) 169 + expect(filter(['bar'])).toBe(false) 170 + expect(filter(['baz'])).toBe(true) 171 + expect(filter([])).toBe(true) 172 + }) 173 + 174 + test('complex expression with parentheses', () => { 175 + const filter = createTagsFilter(['(foo && bar) || (baz && !qux)'], tags('foo', 'bar', 'baz', 'qux')) 176 + expect(filter(['foo', 'bar'])).toBe(true) 177 + expect(filter(['baz'])).toBe(true) 178 + expect(filter(['baz', 'qux'])).toBe(false) 179 + expect(filter(['foo'])).toBe(false) 180 + }) 181 + }) 182 + 183 + describe('wildcard patterns', () => { 184 + test('matches with * wildcard', () => { 185 + const filter = createTagsFilter(['test*'], tags('test', 'test-unit', 'test-e2e', 'other')) 186 + expect(filter(['test-unit'])).toBe(true) 187 + expect(filter(['test-e2e'])).toBe(true) 188 + expect(filter(['other'])).toBe(false) 189 + }) 190 + 191 + test('wildcard matches zero or more characters', () => { 192 + const filter = createTagsFilter(['test*'], tags('test', 'test-unit')) 193 + // * matches zero or more characters 194 + expect(filter(['test'])).toBe(true) 195 + expect(filter(['test-unit'])).toBe(true) 196 + }) 197 + 198 + test('wildcard at start', () => { 199 + const filter = createTagsFilter(['*-unit'], tags('test-unit', 'e2e-unit', 'integration')) 200 + expect(filter(['test-unit'])).toBe(true) 201 + expect(filter(['e2e-unit'])).toBe(true) 202 + expect(filter(['integration'])).toBe(false) 203 + }) 204 + 205 + test('wildcard in middle', () => { 206 + const filter = createTagsFilter(['test-*-fast'], tags('test-unit-fast', 'test-e2e-fast', 'test-slow')) 207 + expect(filter(['test-unit-fast'])).toBe(true) 208 + expect(filter(['test-e2e-fast'])).toBe(true) 209 + expect(filter(['test-slow'])).toBe(false) 210 + }) 211 + 212 + test('multiple wildcards', () => { 213 + const filter = createTagsFilter(['*test*'], tags('unit-test-fast', 'test-e2e', 'other')) 214 + expect(filter(['unit-test-fast'])).toBe(true) 215 + expect(filter(['test-e2e'])).toBe(true) 216 + expect(filter(['other'])).toBe(false) 217 + }) 218 + 219 + test('wildcard with negation', () => { 220 + const filter = createTagsFilter(['!test*'], tags('test-unit', 'other')) 221 + expect(filter(['test-unit'])).toBe(false) 222 + expect(filter(['other'])).toBe(true) 223 + }) 224 + 225 + test('wildcard with AND', () => { 226 + const filter = createTagsFilter(['test* and fast'], tags('test-unit', 'fast', 'slow')) 227 + expect(filter(['test-unit', 'fast'])).toBe(true) 228 + expect(filter(['test-unit', 'slow'])).toBe(false) 229 + expect(filter(['fast'])).toBe(false) 230 + }) 231 + 232 + test('wildcard with OR', () => { 233 + const filter = createTagsFilter(['test* or fast'], tags('test-unit', 'fast', 'slow')) 234 + expect(filter(['test-unit'])).toBe(true) 235 + expect(filter(['fast'])).toBe(true) 236 + expect(filter(['slow'])).toBe(false) 237 + }) 238 + }) 239 + 240 + describe('mixed operators', () => { 241 + test('mixing "and" and &&', () => { 242 + const filter = createTagsFilter(['foo and bar && baz'], tags('foo', 'bar', 'baz')) 243 + expect(filter(['foo', 'bar', 'baz'])).toBe(true) 244 + expect(filter(['foo', 'bar'])).toBe(false) 245 + }) 246 + 247 + test('mixing "or" and ||', () => { 248 + const filter = createTagsFilter(['foo or bar || baz'], tags('foo', 'bar', 'baz', 'qux')) 249 + expect(filter(['foo'])).toBe(true) 250 + expect(filter(['bar'])).toBe(true) 251 + expect(filter(['baz'])).toBe(true) 252 + expect(filter(['qux'])).toBe(false) 253 + }) 254 + 255 + test('mixing ! and "not"', () => { 256 + const filter = createTagsFilter(['!foo and not bar'], tags('foo', 'bar', 'baz')) 257 + expect(filter(['baz'])).toBe(true) 258 + expect(filter(['foo'])).toBe(false) 259 + expect(filter(['bar'])).toBe(false) 260 + }) 261 + }) 262 + 263 + describe('case sensitivity', () => { 264 + test('operators are case-insensitive', () => { 265 + const filter = createTagsFilter(['foo AND bar OR baz'], tags('foo', 'bar', 'baz')) 266 + expect(filter(['foo', 'bar'])).toBe(true) 267 + expect(filter(['baz'])).toBe(true) 268 + }) 269 + 270 + test('NOT is case-insensitive', () => { 271 + const filter = createTagsFilter(['NOT foo'], tags('foo', 'bar')) 272 + expect(filter(['foo'])).toBe(false) 273 + expect(filter(['bar'])).toBe(true) 274 + }) 275 + 276 + test('tag names are case-sensitive', () => { 277 + const filter = createTagsFilter(['Foo'], tags('Foo', 'foo')) 278 + expect(filter(['Foo'])).toBe(true) 279 + expect(filter(['foo'])).toBe(false) 280 + }) 281 + }) 282 + 283 + describe('whitespace handling', () => { 284 + test('handles extra whitespace', () => { 285 + const filter = createTagsFilter([' foo and bar '], tags('foo', 'bar')) 286 + expect(filter(['foo', 'bar'])).toBe(true) 287 + }) 288 + 289 + test('handles tabs', () => { 290 + const filter = createTagsFilter(['foo\tand\tbar'], tags('foo', 'bar')) 291 + expect(filter(['foo', 'bar'])).toBe(true) 292 + }) 293 + 294 + test('handles no whitespace with && and ||', () => { 295 + const filter = createTagsFilter(['foo&&bar||baz'], tags('foo', 'bar', 'baz')) 296 + expect(filter(['foo', 'bar'])).toBe(true) 297 + expect(filter(['baz'])).toBe(true) 298 + }) 299 + }) 300 + 301 + describe('tags with special characters', () => { 302 + test('tags with hyphens', () => { 303 + const filter = createTagsFilter(['test-unit'], tags('test-unit', 'test-e2e')) 304 + expect(filter(['test-unit'])).toBe(true) 305 + expect(filter(['test-e2e'])).toBe(false) 306 + }) 307 + 308 + test('tags with underscores', () => { 309 + const filter = createTagsFilter(['test_unit'], tags('test_unit', 'test_e2e')) 310 + expect(filter(['test_unit'])).toBe(true) 311 + expect(filter(['test_e2e'])).toBe(false) 312 + }) 313 + 314 + test('tags with slashes', () => { 315 + const filter = createTagsFilter(['scope/tag'], tags('scope/tag', 'other')) 316 + expect(filter(['scope/tag'])).toBe(true) 317 + expect(filter(['other'])).toBe(false) 318 + }) 319 + 320 + test('tags with dots', () => { 321 + const filter = createTagsFilter(['v1.0'], tags('v1.0', 'v2.0')) 322 + expect(filter(['v1.0'])).toBe(true) 323 + expect(filter(['v2.0'])).toBe(false) 324 + }) 325 + 326 + test('tags with @ symbol and non-alphanumeric characters', () => { 327 + const filter = createTagsFilter(['@scope/tag'], tags('@scope/tag', '@other/tag')) 328 + expect(filter(['@scope/tag'])).toBe(true) 329 + expect(filter(['@other/tag'])).toBe(false) 330 + }) 331 + 332 + test('tags with UTF-8 characters', () => { 333 + const filter = createTagsFilter(['测试'], tags('测试', '测试2', 'test')) 334 + expect(filter(['测试'])).toBe(true) 335 + expect(filter(['测试2'])).toBe(false) 336 + expect(filter(['test'])).toBe(false) 337 + }) 338 + 339 + test('tags with @ followed by UTF-8 characters', () => { 340 + const filter = createTagsFilter(['@日本語'], tags('@日本語', '@中文', 'english')) 341 + expect(filter(['@日本語'])).toBe(true) 342 + expect(filter(['@中文'])).toBe(false) 343 + expect(filter(['english'])).toBe(false) 344 + }) 345 + 346 + test('tags with mixed special characters and UTF-8', () => { 347 + const filter = createTagsFilter(['@tag-名前_v1.0'], tags('@tag-名前_v1.0', '@tag-other_v1.0')) 348 + expect(filter(['@tag-名前_v1.0'])).toBe(true) 349 + expect(filter(['@tag-other_v1.0'])).toBe(false) 350 + }) 351 + 352 + test('tags with emoji characters', () => { 353 + const filter = createTagsFilter(['test-🚀'], tags('test-🚀', 'test-💚', 'test')) 354 + expect(filter(['test-🚀'])).toBe(true) 355 + expect(filter(['test-💚'])).toBe(false) 356 + }) 357 + 358 + test('tags with special chars containing operator keywords', () => { 359 + const filter = createTagsFilter(['or@tag || and@test || not@feature'], tags('or@tag', 'and@test', 'not@feature', 'other')) 360 + expect(filter(['or@tag'])).toBe(true) 361 + expect(filter(['and@test'])).toBe(true) 362 + expect(filter(['not@feature'])).toBe(true) 363 + expect(filter(['other'])).toBe(false) 364 + }) 365 + 366 + test('tags with UTF-8 chars containing operator keywords', () => { 367 + const filter = createTagsFilter(['or日本語 || and中文 || not한국어'], tags('or日本語', 'and中文', 'not한국어', 'english')) 368 + expect(filter(['or日本語'])).toBe(true) 369 + expect(filter(['and中文'])).toBe(true) 370 + expect(filter(['not한국어'])).toBe(true) 371 + expect(filter(['english'])).toBe(false) 372 + }) 373 + 374 + test('operator keywords with @ and UTF-8 in complex expressions', () => { 375 + const filter = createTagsFilter(['(or@tag || and@test) && not@feature'], tags('or@tag', 'and@test', 'not@feature', 'other')) 376 + expect(filter(['or@tag', 'not@feature'])).toBe(true) 377 + expect(filter(['and@test', 'not@feature'])).toBe(true) 378 + expect(filter(['or@tag'])).toBe(false) 379 + expect(filter(['other'])).toBe(false) 380 + }) 381 + }) 382 + 383 + describe('edge cases', () => { 384 + test('empty test tags array', () => { 385 + const filter = createTagsFilter(['foo'], tags('foo')) 386 + expect(filter([])).toBe(false) 387 + }) 388 + 389 + test('empty expressions array returns true for any tags', () => { 390 + const filter = createTagsFilter([], tags('foo')) 391 + expect(filter(['foo'])).toBe(true) 392 + expect(filter([])).toBe(true) 393 + }) 394 + 395 + test('tag that looks like an operator but is not', () => { 396 + const filter = createTagsFilter(['android'], tags('android', 'ios')) 397 + expect(filter(['android'])).toBe(true) 398 + expect(filter(['ios'])).toBe(false) 399 + }) 400 + 401 + test('tag that starts with "or" but is not OR', () => { 402 + const filter = createTagsFilter(['orange'], tags('orange', 'apple')) 403 + expect(filter(['orange'])).toBe(true) 404 + expect(filter(['apple'])).toBe(false) 405 + }) 406 + 407 + test('tag that starts with "and" but is not AND', () => { 408 + const filter = createTagsFilter(['android'], tags('android', 'ios')) 409 + expect(filter(['android'])).toBe(true) 410 + }) 411 + 412 + test('tag that starts with "not" but is not NOT', () => { 413 + const filter = createTagsFilter(['nothing'], tags('nothing', 'something')) 414 + expect(filter(['nothing'])).toBe(true) 415 + expect(filter(['something'])).toBe(false) 416 + }) 417 + 418 + test('tag containing "and" in the middle', () => { 419 + const filter = createTagsFilter(['standalone'], tags('standalone', 'other')) 420 + expect(filter(['standalone'])).toBe(true) 421 + expect(filter(['other'])).toBe(false) 422 + }) 423 + 424 + test('tag containing "or" in the middle', () => { 425 + const filter = createTagsFilter(['priority'], tags('priority', 'other')) 426 + expect(filter(['priority'])).toBe(true) 427 + expect(filter(['other'])).toBe(false) 428 + }) 429 + 430 + test('tag containing "not" in the middle', () => { 431 + const filter = createTagsFilter(['annotation'], tags('annotation', 'other')) 432 + expect(filter(['annotation'])).toBe(true) 433 + expect(filter(['other'])).toBe(false) 434 + }) 435 + 436 + test('tag ending with "and"', () => { 437 + const filter = createTagsFilter(['demand'], tags('demand', 'other')) 438 + expect(filter(['demand'])).toBe(true) 439 + expect(filter(['other'])).toBe(false) 440 + }) 441 + 442 + test('tag ending with "or"', () => { 443 + const filter = createTagsFilter(['editor'], tags('editor', 'other')) 444 + expect(filter(['editor'])).toBe(true) 445 + expect(filter(['other'])).toBe(false) 446 + }) 447 + 448 + test('tag ending with "not"', () => { 449 + const filter = createTagsFilter(['cannot'], tags('cannot', 'other')) 450 + expect(filter(['cannot'])).toBe(true) 451 + expect(filter(['other'])).toBe(false) 452 + }) 453 + 454 + test('complex expression with tags containing operator substrings', () => { 455 + const filter = createTagsFilter(['android and editor or nothing'], tags('android', 'editor', 'nothing')) 456 + // Parsed as: android AND editor OR nothing 457 + expect(filter(['android', 'editor'])).toBe(true) 458 + expect(filter(['nothing'])).toBe(true) 459 + expect(filter(['android'])).toBe(false) 460 + expect(filter(['editor'])).toBe(false) 461 + }) 462 + }) 463 + 464 + describe('validation errors', () => { 465 + test('throws error for unknown tag', () => { 466 + expect(() => createTagsFilter(['unknown'], tags('foo', 'bar'))).toThrow( 467 + 'The tag pattern "unknown" is not defined in the configuration', 468 + ) 469 + }) 470 + 471 + test('throws error for unknown tag in expression', () => { 472 + expect(() => createTagsFilter(['foo and unknown'], tags('foo', 'bar'))).toThrow( 473 + 'The tag pattern "unknown" is not defined in the configuration', 474 + ) 475 + }) 476 + 477 + test('throws error when no tags defined', () => { 478 + expect(() => createTagsFilter(['foo'], [])).toThrow( 479 + 'The Vitest config does\'t define any "tags"', 480 + ) 481 + }) 482 + 483 + test('throws error for wildcard pattern that matches nothing', () => { 484 + expect(() => createTagsFilter(['xyz*'], tags('foo', 'bar'))).toThrow( 485 + 'The tag pattern "xyz*" is not defined in the configuration', 486 + ) 487 + }) 488 + }) 489 + 490 + describe('parser errors', () => { 491 + test('throws error for unclosed parenthesis', () => { 492 + expect(() => createTagsFilter(['(foo and bar'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: missing closing ")" in "(foo and bar"]`) 493 + }) 494 + 495 + test('throws error for unexpected closing parenthesis', () => { 496 + expect(() => createTagsFilter(['foo and bar)'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected ")" in "foo and bar)"]`) 497 + }) 498 + 499 + test('throws error for empty parentheses', () => { 500 + expect(() => createTagsFilter(['()'], tags('foo'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected ")" in "()"]`) 501 + }) 502 + 503 + test('throws error for operator without operand', () => { 504 + expect(() => createTagsFilter(['foo and'], tags('foo', 'bar'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected end of expression in "foo and"]`) 505 + }) 506 + 507 + test('throws error for leading operator', () => { 508 + expect(() => createTagsFilter(['and foo'], tags('foo'))).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid tags expression: unexpected "and" in "and foo"]`) 509 + }) 510 + }) 511 + 512 + describe('complex real-world scenarios', () => { 513 + test('filter slow tests but include critical', () => { 514 + const filter = createTagsFilter(['!slow or critical'], tags('slow', 'fast', 'critical')) 515 + expect(filter(['fast'])).toBe(true) 516 + expect(filter(['slow'])).toBe(false) 517 + expect(filter(['slow', 'critical'])).toBe(true) 518 + }) 519 + 520 + test('run only unit tests that are not flaky', () => { 521 + const filter = createTagsFilter(['unit && !flaky'], tags('unit', 'e2e', 'flaky')) 522 + expect(filter(['unit'])).toBe(true) 523 + expect(filter(['unit', 'flaky'])).toBe(false) 524 + expect(filter(['e2e'])).toBe(false) 525 + }) 526 + 527 + test('run browser tests for chrome or firefox but not edge', () => { 528 + const filter = createTagsFilter(['browser && (chrome || firefox) && !edge'], tags('browser', 'chrome', 'firefox', 'edge')) 529 + expect(filter(['browser', 'chrome'])).toBe(true) 530 + expect(filter(['browser', 'firefox'])).toBe(true) 531 + expect(filter(['browser', 'edge'])).toBe(false) 532 + expect(filter(['chrome'])).toBe(false) 533 + }) 534 + 535 + test('multiple filter expressions act as AND', () => { 536 + const filter = createTagsFilter(['unit || e2e', '!slow'], tags('unit', 'e2e', 'slow', 'fast')) 537 + expect(filter(['unit'])).toBe(true) 538 + expect(filter(['e2e'])).toBe(true) 539 + expect(filter(['unit', 'slow'])).toBe(false) 540 + expect(filter(['e2e', 'slow'])).toBe(false) 541 + expect(filter(['fast'])).toBe(false) 542 + }) 543 + }) 544 + })
+4
test/ui/fixtures/sample.test.ts
··· 11 11 }) 12 12 expect(1 + 1).toEqual(2) 13 13 }) 14 + 15 + it('has tags', { tags: ['db', 'flaky'] }, () => { 16 + // ... 17 + })
+17 -1
test/ui/test/html-report.spec.ts
··· 66 66 await page.goto(pageUrl) 67 67 68 68 // dashboard 69 - await expect(page.locator('[aria-labelledby=tests]')).toContainText('14 Pass 1 Fail 15 Total') 69 + await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total') 70 70 71 71 // unhandled errors 72 72 await expect(page.getByTestId('unhandled-errors')).toContainText( ··· 189 189 190 190 await expect(annotations.last().getByRole('link')).toHaveAttribute('href', /data\/\w+/) 191 191 await expect(annotations.nth(3).getByRole('link')).toHaveAttribute('href', /data\/\w+/) 192 + }) 193 + 194 + test('tags filter', async ({ page }) => { 195 + await page.goto(pageUrl) 196 + 197 + await page.getByPlaceholder('Search...').fill('tag:db') 198 + 199 + // only one test with the tag "db" 200 + await expect(page.getByText('PASS (1)')).toBeVisible() 201 + await expect(page.getByTestId('explorer-item').filter({ hasText: 'has tags' })).toBeVisible() 202 + 203 + await page.getByPlaceholder('Search...').fill('tag:db && !flaky') 204 + await expect(page.getByText('No matched test')).toBeVisible() 205 + 206 + await page.getByPlaceholder('Search...').fill('tag:unknown') 207 + await expect(page.getByText('The tag pattern "unknown" is not defined in the configuration')).toBeVisible() 192 208 }) 193 209 194 210 test('visual regression in the report tab', async ({ page }) => {
+21 -4
test/ui/test/ui.spec.ts
··· 70 70 await page.goto(pageUrl) 71 71 72 72 // dashboard 73 - await expect(page.locator('[aria-labelledby=tests]')).toContainText('14 Pass 1 Fail 15 Total') 73 + await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 1 Fail 16 Total') 74 74 75 75 // unhandled errors 76 76 await expect(page.getByTestId('unhandled-errors')).toContainText( ··· 256 256 // pass files with special chars 257 257 await page.getByPlaceholder('Search...').fill('char () - Square root of nine (9)') 258 258 await expect(page.getByText('char () - Square root of nine (9)')).toBeVisible() 259 - await page.getByText('char () - Square root of nine (9)').hover() 260 - await page.getByLabel('Run current test').click() 261 - await expect(page.getByText('All tests passed in this file')).toBeVisible() 259 + const testItem = page.getByTestId('explorer-item').filter({ hasText: 'char () - Square root of nine (9)' }) 260 + await testItem.hover() 261 + await testItem.getByLabel('Run current test').click() 262 + await expect(page.getByText('The test has passed without any errors')).toBeVisible() 263 + }) 264 + 265 + test('tags filter', async ({ page }) => { 266 + await page.goto(pageUrl) 267 + 268 + await page.getByPlaceholder('Search...').fill('tag:db') 269 + 270 + // only one test with the tag "db" 271 + await expect(page.getByText('PASS (1)')).toBeVisible() 272 + await expect(page.getByTestId('explorer-item').filter({ hasText: 'has tags' })).toBeVisible() 273 + 274 + await page.getByPlaceholder('Search...').fill('tag:db && !flaky') 275 + await expect(page.getByText('No matched test')).toBeVisible() 276 + 277 + await page.getByPlaceholder('Search...').fill('tag:unknown') 278 + await expect(page.getByText('The tag pattern "unknown" is not defined in the configuration')).toBeVisible() 262 279 }) 263 280 264 281 test('dashboard entries filter tests correctly', async ({ page }) => {
+28
packages/runner/src/types/runner.ts
··· 12 12 TestAnnotation, 13 13 TestArtifact, 14 14 TestContext, 15 + TestOptions, 16 + TestTags, 15 17 } from './tasks' 16 18 17 19 /** ··· 40 42 retry: SerializableRetry 41 43 includeTaskLocation?: boolean 42 44 diffOptions?: DiffOptions 45 + tags: TestTagDefinition[] 46 + tagsFilter?: string[] 47 + strictTags: boolean 43 48 } 44 49 45 50 /** ··· 47 52 */ 48 53 export interface FileSpecification { 49 54 filepath: string 55 + // file can be marked via a jsdoc comment to have tags, 56 + // these are _not_ tags to filter tests by 57 + fileTags?: string[] 50 58 testLocations: number[] | undefined 51 59 testNamePattern: RegExp | undefined 60 + testTagsFilter: string[] | undefined 52 61 testIds: string[] | undefined 62 + } 63 + 64 + export interface TestTagDefinition extends Omit<TestOptions, 'tags' | 'shuffle'> { 65 + /** 66 + * The name of the tag. This is what you use in the `tags` array in tests. 67 + */ 68 + name: keyof TestTags extends never 69 + ? string 70 + : TestTags[keyof TestTags] 71 + /** 72 + * A description for the tag. This will be shown in the CLI help and UI. 73 + */ 74 + description?: string 75 + /** 76 + * Priority for merging options when multiple tags with the same options are applied to a test. 77 + * 78 + * Lower number means higher priority. E.g., priority 1 takes precedence over priority 3. 79 + */ 80 + priority?: number 53 81 } 54 82 55 83 export type VitestRunnerImportSource = 'collect' | 'setup'
+21 -6
packages/runner/src/types/tasks.ts
··· 114 114 * @experimental 115 115 */ 116 116 dynamic?: boolean 117 + /** 118 + * Custom tags of the task. Useful for filtering tasks. 119 + */ 120 + tags?: string[] 117 121 } 118 122 119 123 export interface TaskPopulated extends TaskBase { ··· 545 549 */ 546 550 sequential?: boolean 547 551 /** 548 - * Whether the tasks of the suite run in a random order. 549 - */ 550 - shuffle?: boolean 551 - /** 552 552 * Whether the test should be skipped. 553 553 */ 554 554 skip?: boolean ··· 564 564 * Whether the test is expected to fail. If it does, the test will pass, otherwise it will fail. 565 565 */ 566 566 fails?: boolean 567 + /** 568 + * Custom tags of the test. Useful for filtering tests. 569 + */ 570 + tags?: keyof TestTags extends never 571 + ? string[] | string 572 + : TestTags[keyof TestTags] | TestTags[keyof TestTags][] 573 + } 574 + 575 + export interface TestTags {} 576 + 577 + export interface SuiteOptions extends TestOptions { 578 + /** 579 + * Whether the tasks of the suite run in a random order. 580 + */ 581 + shuffle?: boolean 567 582 } 568 583 569 584 interface ExtendedAPI<ExtraContext> { ··· 647 662 ): SuiteCollector<OverrideExtraContext> 648 663 <OverrideExtraContext extends ExtraContext = ExtraContext>( 649 664 name: string | Function, 650 - options: TestOptions, 665 + options: SuiteOptions, 651 666 fn?: SuiteFactory<OverrideExtraContext> 652 667 ): SuiteCollector<OverrideExtraContext> 653 668 } ··· 719 734 export interface SuiteCollector<ExtraContext = object> { 720 735 readonly name: string 721 736 readonly mode: RunMode 722 - options?: TestOptions 737 + options?: SuiteOptions 723 738 type: 'collector' 724 739 test: TestAPI<ExtraContext> 725 740 tasks: (
+4
packages/runner/src/utils/collect.ts
··· 12 12 namePattern?: string | RegExp, 13 13 testLocations?: number[] | undefined, 14 14 testIds?: string[] | undefined, 15 + testTagsFilter?: ((testTags: string[]) => boolean) | undefined, 15 16 onlyMode?: boolean, 16 17 parentIsOnly?: boolean, 17 18 allowOnly?: boolean, ··· 72 73 t.mode = 'skip' 73 74 } 74 75 if (testIds && !testIds.includes(t.id)) { 76 + t.mode = 'skip' 77 + } 78 + if (testTagsFilter && !testTagsFilter(t.tags || [])) { 75 79 t.mode = 'skip' 76 80 } 77 81 }
+1
packages/runner/src/utils/index.ts
··· 10 10 } from './collect' 11 11 export { limitConcurrency } from './limit-concurrency' 12 12 export { partitionSuiteChildren } from './suite' 13 + export { createTagsFilter, validateTags } from './tags' 13 14 export { 14 15 createTaskName, 15 16 getFullName,
+277
packages/runner/src/utils/tags.ts
··· 1 + import type { TestTagDefinition, VitestRunnerConfig } from '../types/runner' 2 + 3 + export function validateTags(config: VitestRunnerConfig, tags: string[]): void { 4 + if (!config.strictTags) { 5 + return 6 + } 7 + 8 + const availableTags = new Set(config.tags.map(tag => tag.name)) 9 + for (const tag of tags) { 10 + if (!availableTags.has(tag)) { 11 + throw createNoTagsError(config.tags, tag) 12 + } 13 + } 14 + } 15 + 16 + export function createNoTagsError(availableTags: TestTagDefinition[], tag: string, prefix = 'tag'): never { 17 + if (!availableTags.length) { 18 + 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`) 19 + } 20 + throw new Error(`The ${prefix} "${tag}" is not defined in the configuration. Available tags are:\n${availableTags 21 + .map(t => `- ${t.name}${t.description ? `: ${t.description}` : ''}`) 22 + .join('\n')}`) 23 + } 24 + 25 + export function createTagsFilter(tagsExpr: string[], availableTags: TestTagDefinition[]): (testTags: string[]) => boolean { 26 + const matchers = tagsExpr.map(expr => parseTagsExpression(expr, availableTags)) 27 + return (testTags: string[]) => { 28 + return matchers.every(matcher => matcher(testTags)) 29 + } 30 + } 31 + 32 + type TagMatcher = (tags: string[]) => boolean 33 + 34 + function parseTagsExpression(expr: string, availableTags: TestTagDefinition[]): TagMatcher { 35 + const tokens = tokenize(expr) 36 + const stream = new TokenStream(tokens, expr) 37 + const ast = parseOrExpression(stream, availableTags) 38 + if (stream.peek().type !== 'EOF') { 39 + throw new Error(`Invalid tags expression: unexpected "${formatToken(stream.peek())}" in "${expr}"`) 40 + } 41 + return (tags: string[]) => evaluateNode(ast, tags) 42 + } 43 + 44 + function formatToken(token: Token): string { 45 + switch (token.type) { 46 + case 'TAG': return token.value 47 + default: return formatTokenType(token.type) 48 + } 49 + } 50 + 51 + type Token 52 + = | { type: 'TAG'; value: string } 53 + | { type: 'AND' } 54 + | { type: 'OR' } 55 + | { type: 'NOT' } 56 + | { type: 'LPAREN' } 57 + | { type: 'RPAREN' } 58 + | { type: 'EOF' } 59 + 60 + function tokenize(expr: string): Token[] { 61 + const tokens: Token[] = [] 62 + let i = 0 63 + 64 + while (i < expr.length) { 65 + if (expr[i] === ' ' || expr[i] === '\t') { 66 + i++ 67 + continue 68 + } 69 + 70 + if (expr[i] === '(') { 71 + tokens.push({ type: 'LPAREN' }) 72 + i++ 73 + continue 74 + } 75 + 76 + if (expr[i] === ')') { 77 + tokens.push({ type: 'RPAREN' }) 78 + i++ 79 + continue 80 + } 81 + 82 + if (expr[i] === '!') { 83 + tokens.push({ type: 'NOT' }) 84 + i++ 85 + continue 86 + } 87 + 88 + if (expr.slice(i, i + 2) === '&&') { 89 + tokens.push({ type: 'AND' }) 90 + i += 2 91 + continue 92 + } 93 + 94 + if (expr.slice(i, i + 2) === '||') { 95 + tokens.push({ type: 'OR' }) 96 + i += 2 97 + continue 98 + } 99 + 100 + if (/^and(?:\s|\)|$)/i.test(expr.slice(i))) { 101 + tokens.push({ type: 'AND' }) 102 + i += 3 103 + continue 104 + } 105 + 106 + if (/^or(?:\s|\)|$)/i.test(expr.slice(i))) { 107 + tokens.push({ type: 'OR' }) 108 + i += 2 109 + continue 110 + } 111 + 112 + if (/^not\s/i.test(expr.slice(i))) { 113 + tokens.push({ type: 'NOT' }) 114 + i += 3 115 + continue 116 + } 117 + 118 + let tag = '' 119 + while (i < expr.length && expr[i] !== ' ' && expr[i] !== '\t' && expr[i] !== '(' && expr[i] !== ')' && expr[i] !== '!' && expr[i] !== '&' && expr[i] !== '|') { 120 + const remaining = expr.slice(i) 121 + // Only treat and/or/not as operators if we're at the start of a tag (after whitespace) 122 + // This allows tags like "demand", "editor", "cannot" to work correctly 123 + if (tag === '' && (/^and(?:\s|\)|$)/i.test(remaining) || /^or(?:\s|\)|$)/i.test(remaining) || /^not\s/i.test(remaining))) { 124 + break 125 + } 126 + tag += expr[i] 127 + i++ 128 + } 129 + 130 + if (tag) { 131 + tokens.push({ type: 'TAG', value: tag }) 132 + } 133 + } 134 + 135 + tokens.push({ type: 'EOF' }) 136 + return tokens 137 + } 138 + 139 + type ASTNode 140 + = | { type: 'tag'; value: string; pattern: RegExp | null } 141 + | { type: 'not'; operand: ASTNode } 142 + | { type: 'and'; left: ASTNode; right: ASTNode } 143 + | { type: 'or'; left: ASTNode; right: ASTNode } 144 + 145 + class TokenStream { 146 + private pos = 0 147 + constructor(private tokens: Token[], public expr: string) {} 148 + 149 + peek(): Token { 150 + return this.tokens[this.pos] 151 + } 152 + 153 + next(): Token { 154 + return this.tokens[this.pos++] 155 + } 156 + 157 + expect(type: Token['type']): Token { 158 + const token = this.next() 159 + if (token.type !== type) { 160 + if (type === 'RPAREN' && token.type === 'EOF') { 161 + throw new Error(`Invalid tags expression: missing closing ")" in "${this.expr}"`) 162 + } 163 + throw new Error(`Invalid tags expression: expected "${formatTokenType(type)}" but got "${formatToken(token)}" in "${this.expr}"`) 164 + } 165 + return token 166 + } 167 + 168 + unexpectedToken(): never { 169 + const token = this.peek() 170 + if (token.type === 'EOF') { 171 + throw new Error(`Invalid tags expression: unexpected end of expression in "${this.expr}"`) 172 + } 173 + throw new Error(`Invalid tags expression: unexpected "${formatToken(token)}" in "${this.expr}"`) 174 + } 175 + } 176 + 177 + function formatTokenType(type: Token['type']): string { 178 + switch (type) { 179 + case 'TAG': return 'tag' 180 + case 'AND': return 'and' 181 + case 'OR': return 'or' 182 + case 'NOT': return 'not' 183 + case 'LPAREN': return '(' 184 + case 'RPAREN': return ')' 185 + case 'EOF': return 'end of expression' 186 + } 187 + } 188 + 189 + function parseOrExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { 190 + let left = parseAndExpression(stream, availableTags) 191 + 192 + while (stream.peek().type === 'OR') { 193 + stream.next() 194 + const right = parseAndExpression(stream, availableTags) 195 + left = { type: 'or', left, right } 196 + } 197 + 198 + return left 199 + } 200 + 201 + function parseAndExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { 202 + let left = parseUnaryExpression(stream, availableTags) 203 + 204 + while (stream.peek().type === 'AND') { 205 + stream.next() 206 + const right = parseUnaryExpression(stream, availableTags) 207 + left = { type: 'and', left, right } 208 + } 209 + 210 + return left 211 + } 212 + 213 + function parseUnaryExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { 214 + if (stream.peek().type === 'NOT') { 215 + stream.next() 216 + const operand = parseUnaryExpression(stream, availableTags) 217 + return { type: 'not', operand } 218 + } 219 + 220 + return parsePrimaryExpression(stream, availableTags) 221 + } 222 + 223 + function parsePrimaryExpression(stream: TokenStream, availableTags: TestTagDefinition[]): ASTNode { 224 + const token = stream.peek() 225 + 226 + if (token.type === 'LPAREN') { 227 + stream.next() 228 + const expr = parseOrExpression(stream, availableTags) 229 + stream.expect('RPAREN') 230 + return expr 231 + } 232 + 233 + if (token.type === 'TAG') { 234 + stream.next() 235 + const tagValue = token.value 236 + const pattern = resolveTagPattern(tagValue, availableTags) 237 + return { type: 'tag', value: tagValue, pattern } 238 + } 239 + 240 + stream.unexpectedToken() 241 + } 242 + 243 + function createWildcardRegex(pattern: string): RegExp { 244 + return new RegExp(`^${pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`) 245 + } 246 + 247 + function resolveTagPattern(tagPattern: string, availableTags: TestTagDefinition[]): RegExp | null { 248 + if (tagPattern.includes('*')) { 249 + const regex = createWildcardRegex(tagPattern) 250 + const hasMatch = availableTags.some(tag => regex.test(tag.name)) 251 + if (!hasMatch) { 252 + throw createNoTagsError(availableTags, tagPattern, 'tag pattern') 253 + } 254 + return regex 255 + } 256 + 257 + if (!availableTags.length || !availableTags.some(tag => tag.name === tagPattern)) { 258 + throw createNoTagsError(availableTags, tagPattern, 'tag pattern') 259 + } 260 + return null 261 + } 262 + 263 + function evaluateNode(node: ASTNode, tags: string[]): boolean { 264 + switch (node.type) { 265 + case 'tag': 266 + if (node.pattern) { 267 + return tags.some(tag => node.pattern!.test(tag)) 268 + } 269 + return tags.includes(node.value) 270 + case 'not': 271 + return !evaluateNode(node.operand, tags) 272 + case 'and': 273 + return evaluateNode(node.left, tags) && evaluateNode(node.right, tags) 274 + case 'or': 275 + return evaluateNode(node.left, tags) || evaluateNode(node.right, tags) 276 + } 277 + }
+38 -7
packages/ui/client/components/FileDetails.vue
··· 12 12 currentLogs, 13 13 isReport, 14 14 } from '~/composables/client' 15 + import { tagsDefinitions } from '~/composables/client/state' 15 16 import { explorerTree } from '~/composables/explorer' 16 17 import { hasFailedSnapshot } from '~/composables/explorer/collector' 17 18 import { getModuleGraph } from '~/composables/module-graph' 18 19 import { selectedTest, viewMode } from '~/composables/params' 19 - import { getProjectNameColor, getProjectTextColor } from '~/utils/task' 20 + import { getBadgeNameColor, getBadgeTextColor } from '~/utils/task' 20 21 import IconButton from './IconButton.vue' 21 22 import StatusIcon from './StatusIcon.vue' 22 23 import ViewConsoleOutput from './views/ViewConsoleOutput.vue' ··· 155 156 156 157 const projectNameColor = computed(() => { 157 158 const projectName = current.value?.file.projectName || '' 158 - return explorerTree.colors.get(projectName) || getProjectNameColor(current.value?.file.projectName) 159 + return explorerTree.colors.get(projectName) || getBadgeNameColor(current.value?.file.projectName) 159 160 }) 160 161 161 - const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor.value)) 162 + const projectNameTextColor = computed(() => getBadgeTextColor(projectNameColor.value)) 162 163 163 164 const testTitle = computed(() => { 164 165 const testId = selectedTest.value ··· 170 171 while (node) { 171 172 names.push(node.name) 172 173 node = node.suite 173 - ? node.suite 174 - : (node === node.file ? undefined : node.file) 175 174 } 176 175 return names.reverse().join(' > ') 176 + }) 177 + 178 + const tags = computed(() => { 179 + const testId = selectedTest.value 180 + if (!testId) { 181 + return [] 182 + } 183 + const node = client.state.idMap.get(testId) 184 + return (node?.tags || []).map(tag => ({ 185 + name: tag, 186 + description: tagsDefinitions.value[tag]?.description, 187 + bg: getBadgeNameColor(tag, true), 188 + border: getBadgeNameColor(tag), 189 + text: 'white', 190 + })) 177 191 }) 178 192 </script> 179 193 ··· 195 209 v-if="current?.file.projectName" 196 210 class="rounded-full py-0.5 px-2 text-xs font-light" 197 211 :style="{ backgroundColor: projectNameColor, color: projectNameTextColor }" 212 + cursor-default 198 213 > 199 214 {{ current.file.projectName }} 200 215 </span> 201 - <div flex-1 font-light op-50 ws-nowrap truncate text-sm> 202 - {{ testTitle }} 216 + <div flex-1 font-light overflow-hidden text-sm flex> 217 + <span op-50 truncate> 218 + {{ testTitle }} 219 + </span> 220 + 221 + <span 222 + v-for="tag of tags" 223 + :key="tag.name" 224 + v-tooltip.bottom="tag.description" 225 + class="rounded-full ml-2 px-2 text-xs font-light" 226 + :style="{ backgroundColor: tag.bg, color: tag.text, border: `1px solid ${tag.border}` }" 227 + :title="tag.description" 228 + cursor-default 229 + flex 230 + items-center 231 + > 232 + {{ tag.name }} 233 + </span> 203 234 </div> 204 235 <div class="flex text-lg"> 205 236 <IconButton
+10 -4
packages/ui/client/utils/task.ts
··· 1 1 import type { RunnerTask, RunnerTestSuite } from 'vitest' 2 + import { isDark } from '~/composables' 2 3 3 4 export function isSuite(task: RunnerTask): task is RunnerTestSuite { 4 5 return Object.hasOwn(task, 'tasks') ··· 135 136 } 136 137 } 137 138 138 - export function getProjectNameColor(name: string | undefined) { 139 + export function getBadgeNameColor(name: string | undefined, transparent = false) { 139 140 if (!name) { 140 141 return '' 141 142 } 142 143 const index = name 143 144 .split('') 144 145 .reduce((acc, v, idx) => acc + v.charCodeAt(0) + idx, 0) 145 - const colors = ['yellow', 'cyan', 'green', 'magenta'] 146 - return colors[index % colors.length] 146 + const colors = isDark.value 147 + ? ['yellow', 'cyan', '#006800', 'magenta'] 148 + : ['#ff5400', '#02a4a4', 'green', 'magenta'] 149 + const transparentColors = isDark.value 150 + ? ['#ffff0091', '#0ff6', '#5dbb5dc9', '#ff00ff80'] 151 + : ['#ff540091', '#00828266', '#5dbb5dc9', '#ff00ff80'] 152 + return (transparent ? transparentColors : colors)[index % colors.length] 147 153 } 148 154 149 - export function getProjectTextColor(color: string) { 155 + export function getBadgeTextColor(color: string) { 150 156 switch (color) { 151 157 case 'blue': 152 158 case 'green':
+71 -27
packages/vitest/src/node/ast-collect.ts
··· 1 1 import type { File, Suite, Task, Test } from '@vitest/runner' 2 + import type { Property } from 'estree' 3 + import type { SerializedConfig } from '../runtime/config' 2 4 import type { TestError } from '../types/general' 3 5 import type { TestProject } from './project' 6 + import { promises as fs } from 'node:fs' 4 7 import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' 5 8 import { 6 9 calculateSuiteHash, ··· 8 11 generateHash, 9 12 interpretTaskModes, 10 13 someTasksAreOnly, 14 + validateTags, 11 15 } from '@vitest/runner/utils' 16 + import { unique } from '@vitest/utils/helpers' 12 17 import { ancestor as walkAst } from 'acorn-walk' 13 18 import { relative } from 'pathe' 14 19 import { parseAst } from 'vite' 15 20 import { createIndexLocationsMap } from '../utils/base' 16 21 import { createDebugger } from '../utils/debugger' 22 + import { detectCodeBlock } from '../utils/test-helpers' 17 23 18 24 interface ParsedFile extends File { 19 25 start: number ··· 40 46 mode: 'run' | 'skip' | 'only' | 'todo' | 'queued' 41 47 task: ParsedSuite | ParsedFile | ParsedTest 42 48 dynamic: boolean 49 + tags: string[] 43 50 } 44 51 45 52 const debug = createDebugger('vitest:ast-collect-info') ··· 178 185 isDynamicEach = property === 'each' || property === 'for' 179 186 } 180 187 181 - debug?.('Found', name, message, `(${mode})`) 188 + // Extract tags from the second argument if it's an options object 189 + const tags: string[] = [] 190 + const secondArg = node.arguments?.[1] 191 + if (secondArg?.type === 'ObjectExpression') { 192 + const tagsProperty = secondArg.properties?.find( 193 + (p: any) => p.type === 'Property' && p.key?.type === 'Identifier' && p.key.name === 'tags', 194 + ) as Property | undefined 195 + if (tagsProperty) { 196 + const tagsValue = tagsProperty.value 197 + if (tagsValue?.type === 'Literal' && typeof tagsValue.value === 'string') { 198 + // tags: 'single-tag' 199 + tags.push(tagsValue.value) 200 + } 201 + else if (tagsValue?.type === 'ArrayExpression') { 202 + // tags: ['tag1', 'tag2'] 203 + for (const element of tagsValue.elements || []) { 204 + if (element?.type === 'Literal' && typeof element.value === 'string') { 205 + tags.push(element.value) 206 + } 207 + } 208 + } 209 + } 210 + } 211 + 212 + debug?.('Found', name, message, `(${mode})`, tags.length ? `[${tags.join(', ')}]` : '') 182 213 definitions.push({ 183 214 start, 184 215 end, ··· 187 218 mode, 188 219 task: null as any, 189 220 dynamic: isDynamicEach, 221 + tags, 190 222 } satisfies LocalCallDefinition) 191 223 }, 192 224 }) ··· 243 275 ] 244 276 } 245 277 246 - interface ParseOptions { 247 - name: string 248 - filepath: string 249 - allowOnly: boolean 250 - pool: string 251 - testNamePattern?: RegExp | undefined 252 - } 253 - 254 278 function createFileTask( 255 279 testFilepath: string, 256 280 code: string, 257 281 requestMap: any, 258 - options: ParseOptions, 282 + config: SerializedConfig, 283 + filepath: string, 284 + fileTags: string[] | undefined, 259 285 ) { 260 286 const { definitions, ast } = astParseFile(testFilepath, code) 261 287 const file: ParsedFile = { 262 - filepath: options.filepath, 288 + filepath, 263 289 type: 'suite', 264 - id: /* @__PURE__ */ generateHash(`${testFilepath}${options.name || ''}`), 290 + id: /* @__PURE__ */ generateHash(`${testFilepath}${config.name || ''}`), 265 291 name: testFilepath, 266 292 fullName: testFilepath, 267 293 mode: 'run', 268 294 tasks: [], 269 295 start: ast.start, 270 296 end: ast.end, 271 - projectName: options.name, 297 + projectName: config.name, 272 298 meta: {}, 273 299 pool: 'browser', 274 300 file: null!, 301 + tags: fileTags || [], 275 302 } 276 303 file.file = file 277 304 const indexMap = createIndexLocationsMap(code) ··· 330 357 `${definition.start}`, 331 358 ) 332 359 } 360 + // Inherit tags from parent suite and merge with own tags 361 + const parentTags = latestSuite.tags || [] 362 + const taskTags = unique([...parentTags, ...definition.tags]) 363 + 333 364 if (definition.type === 'suite') { 334 365 const task: ParsedSuite = { 335 366 type: definition.type, ··· 347 378 location, 348 379 dynamic: definition.dynamic, 349 380 meta: {}, 381 + tags: taskTags, 350 382 } 351 383 definition.task = task 352 384 latestSuite.tasks.push(task) 353 385 lastSuite = task 354 386 return 355 387 } 388 + validateTags(config, taskTags) 356 389 const task: ParsedTest = { 357 390 type: definition.type, 358 391 id: '', ··· 372 405 timeout: 0, 373 406 annotations: [], 374 407 artifacts: [], 408 + tags: taskTags, 375 409 } 376 410 definition.task = task 377 411 latestSuite.tasks.push(task) ··· 380 414 const hasOnly = someTasksAreOnly(file) 381 415 interpretTaskModes( 382 416 file, 383 - options.testNamePattern, 417 + config.testNamePattern, 418 + undefined, 384 419 undefined, 385 420 undefined, 386 421 hasOnly, 387 422 false, 388 - options.allowOnly, 423 + config.allowOnly, 389 424 ) 390 425 markDynamicTests(file.tasks) 391 426 if (!file.tasks.length) { ··· 394 429 errors: [ 395 430 { 396 431 name: 'Error', 397 - message: `No test suite found in file ${options.filepath}`, 432 + message: `No test suite found in file ${filepath}`, 398 433 }, 399 434 ], 400 435 } ··· 416 451 new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`), 417 452 ) 418 453 } 419 - return createFileTask(testFilepath, request.code, request.map, { 420 - name: project.config.name, 454 + return createFileTask( 455 + testFilepath, 456 + request.code, 457 + request.map, 458 + project.serializedConfig, 421 459 filepath, 422 - allowOnly: project.config.allowOnly, 423 - testNamePattern: project.config.testNamePattern, 424 - pool: project.browser ? 'browser' : project.config.pool, 425 - }) 460 + request.fileTags, 461 + ) 426 462 } 427 463 428 464 async function transformSSR(project: TestProject, filepath: string) { 429 - const environment = project.config.environment 430 - if (environment === 'jsdom' || environment === 'happy-dom') { 431 - return project.vite.environments.client.transformRequest(filepath) 432 - } 433 - return project.vite.environments.ssr.transformRequest(filepath) 465 + // Read original file content to extract pragmas (environment, tags) 466 + const originalCode = await fs.readFile(filepath, 'utf-8').catch(() => '') 467 + const { env: pragmaEnv, tags: fileTags } = detectCodeBlock(originalCode) 468 + 469 + // Use environment from pragma if defined, otherwise fall back to config 470 + const environment = pragmaEnv || project.config.environment 471 + const env = environment === 'jsdom' || environment === 'happy-dom' 472 + ? project.vite.environments.client 473 + : project.vite.environments.ssr 474 + 475 + const transformResult = await env.transformRequest(filepath) 476 + 477 + return transformResult ? { ...transformResult, fileTags } : null 434 478 } 435 479 436 480 function markDynamicTests(tasks: Task[]) {
+35 -1
packages/vitest/src/node/core.ts
··· 48 48 import { VitestResolver } from './resolver' 49 49 import { VitestSpecifications } from './specifications' 50 50 import { StateManager } from './state' 51 + import { populateProjectsTags } from './tags' 51 52 import { TestRun } from './test-run' 52 53 import { VitestWatcher } from './watcher' 53 54 ··· 112 113 /** @internal */ reporters: Reporter[] = [] 113 114 /** @internal */ runner!: ModuleRunner 114 115 /** @internal */ _testRun: TestRun = undefined! 116 + /** @internal */ _config?: ResolvedConfig 115 117 /** @internal */ _resolver!: VitestResolver 116 118 /** @internal */ _fetcher!: VitestFetchFunction 117 119 /** @internal */ _fsCache!: FileSystemModuleCache ··· 123 125 124 126 private readonly specifications: VitestSpecifications 125 127 private pool: ProcessPool | undefined 126 - private _config?: ResolvedConfig 127 128 private _vite?: ViteDevServer 128 129 private _state?: StateManager 129 130 private _cache?: VitestCache ··· 321 322 this.configOverride.testNamePattern = this.config.testNamePattern 322 323 } 323 324 325 + // populate will merge all configs into every project, 326 + // we don't want that when just listing tags 327 + if (!this.config.listTags) { 328 + populateProjectsTags(this.coreWorkspaceProject, this.projects) 329 + } 330 + 324 331 this.reporters = resolved.mode === 'benchmark' 325 332 ? await createBenchmarkReporters(toArray(resolved.benchmark?.reporters), this.runner) 326 333 : await createReporters(resolved.reporters, this) ··· 339 346 return null 340 347 } 341 348 return this._coverageProvider 349 + } 350 + 351 + public async listTags(): Promise<void> { 352 + const listTags = this.config.listTags 353 + if (typeof listTags === 'boolean') { 354 + this.logger.printTags() 355 + } 356 + else if (listTags === 'json') { 357 + const hasTags = [this.getRootProject(), ...this.projects].some(p => p.config.tags && p.config.tags.length > 0) 358 + if (!hasTags) { 359 + process.exitCode = 1 360 + this.logger.printNoTestTagsFound() 361 + } 362 + else { 363 + const manifest = { 364 + tags: this.config.tags, 365 + projects: this.projects.filter(p => p !== this.coreWorkspaceProject).map(p => ({ 366 + name: p.name, 367 + tags: p.config.tags, 368 + })), 369 + } 370 + this.logger.log(JSON.stringify(manifest, null, 2)) 371 + } 372 + } 373 + else { 374 + throw new Error(`Unknown value for "test.listTags": ${listTags}`) 375 + } 342 376 } 343 377 344 378 public async enableCoverage(): Promise<void> {
+31
packages/vitest/src/node/logger.ts
··· 131 131 return code 132 132 } 133 133 134 + printNoTestTagsFound(): void { 135 + this.error(c.bgRed(' ERROR '), c.red('No test tags found in any project. Exiting with code 1.')) 136 + } 137 + 138 + printTags(): void { 139 + const vitest = this.ctx 140 + const rootProject = vitest.getRootProject() 141 + const projects = [ 142 + rootProject, 143 + ...vitest.projects.filter(p => p !== rootProject), 144 + ] 145 + 146 + const hasTags = projects.some(p => p.config.tags && p.config.tags.length > 0) 147 + 148 + if (!hasTags) { 149 + process.exitCode = 1 150 + return this.printNoTestTagsFound() 151 + } 152 + 153 + for (const project of projects) { 154 + const name = project.name 155 + if (name) { 156 + this.log(formatProjectName(project, '')) 157 + } 158 + project.config.tags.forEach((tag) => { 159 + const tagLog = `${tag.name}${tag.description ? `: ${tag.description}` : ''}` 160 + this.log(` ${tagLog}`) 161 + }) 162 + } 163 + } 164 + 134 165 printNoTestFound(filters?: string[]): void { 135 166 const config = this.ctx.config 136 167
+5 -3
packages/vitest/src/node/pool.ts
··· 12 12 import { rootDir } from '../paths' 13 13 import { isWindows } from '../utils/env' 14 14 import { getWorkerMemoryLimit, stringToBytes } from '../utils/memory-limit' 15 - import { getSpecificationsEnvironments } from '../utils/test-helpers' 15 + import { getSpecificationsOptions } from '../utils/test-helpers' 16 16 import { createBrowserPool } from './pools/browser' 17 17 import { Pool } from './pools/pool' 18 18 ··· 87 87 let workerId = 0 88 88 89 89 const sorted = await sequencer.sort(specs) 90 - const environments = await getSpecificationsEnvironments(specs) 90 + const { environments, tags } = await getSpecificationsOptions(specs) 91 91 const groups = groupSpecs(sorted, environments) 92 92 93 93 const projectEnvs = new WeakMap<TestProject, Partial<NodeJS.ProcessEnv>>() ··· 149 149 context: { 150 150 files: specs.map(spec => ({ 151 151 filepath: spec.moduleId, 152 + fileTags: tags.get(spec), 152 153 testLocations: spec.testLines, 153 154 testNamePattern: spec.testNamePattern, 154 155 testIds: spec.testIds, 156 + testTagsFilter: spec.testTagsFilter, 155 157 })), 156 158 invalidates, 157 159 providedContext: project.getProvidedContext(), ··· 337 339 return null 338 340 } 339 341 340 - function groupSpecs(specs: TestSpecification[], environments: Awaited<ReturnType<typeof getSpecificationsEnvironments>>) { 342 + function groupSpecs(specs: TestSpecification[], environments: WeakMap<TestSpecification, ContextTestEnvironment>) { 341 343 // Test files are passed to test runner one at a time, except for Typechecker or when "--maxWorker=1 --no-isolate" 342 344 type SpecsForRunner = TestSpecification[] 343 345
+27
packages/vitest/src/node/tags.ts
··· 1 + import type { TestTagDefinition } from '@vitest/runner' 2 + import type { TestProject } from './project' 3 + 4 + export function populateProjectsTags(rootProject: TestProject, projects: TestProject[]): void { 5 + // Include root project if not already in the list 6 + const allProjects = projects.includes(rootProject) ? projects : [rootProject, ...projects] 7 + 8 + // Collect all tags from all projects (first definition wins) 9 + const globalTags = new Map<string, TestTagDefinition>() 10 + for (const project of allProjects) { 11 + for (const tag of project.config.tags || []) { 12 + if (!globalTags.has(tag.name)) { 13 + globalTags.set(tag.name, tag) 14 + } 15 + } 16 + } 17 + 18 + // Add missing tags to each project (without overriding local definitions) 19 + for (const project of allProjects) { 20 + const projectTagNames = new Set(project.config.tags.map(t => t.name)) 21 + for (const [tagName, tagDef] of globalTags) { 22 + if (!projectTagNames.has(tagName)) { 23 + project.config.tags.push(tagDef) 24 + } 25 + } 26 + } 27 + }
+7
packages/vitest/src/node/test-specification.ts
··· 9 9 testNamePattern?: RegExp 10 10 testIds?: string[] 11 11 testLines?: number[] 12 + testTagsFilter?: string[] 12 13 } 13 14 14 15 export class TestSpecification { ··· 41 42 * The ids of tasks inside of this specification to run. 42 43 */ 43 44 public readonly testIds: string[] | undefined 45 + /** 46 + * The tags of tests to run. 47 + */ 48 + public readonly testTagsFilter: string[] | undefined 44 49 45 50 /** 46 51 * This class represents a test suite for a test module within a single project. ··· 73 78 this.testLines = testLinesOrOptions.testLines 74 79 this.testNamePattern = testLinesOrOptions.testNamePattern 75 80 this.testIds = testLinesOrOptions.testIds 81 + this.testTagsFilter = testLinesOrOptions.testTagsFilter 76 82 } 77 83 } 78 84 ··· 99 105 testLines: this.testLines, 100 106 testIds: this.testIds, 101 107 testNamePattern: this.testNamePattern, 108 + testTagsFilter: this.testTagsFilter, 102 109 }, 103 110 ] 104 111 }
+1
packages/vitest/src/public/config.ts
··· 19 19 defaultInclude, 20 20 } from '../defaults' 21 21 export type { WatcherTriggerPattern } from '../node/watcher' 22 + export type { TestTagDefinition } from '@vitest/runner' 22 23 export { mergeConfig } from 'vite' 23 24 export type { Plugin } from 'vite' 24 25
+2 -1
packages/vitest/src/public/index.ts
··· 140 140 TestContext, 141 141 TestFunction, 142 142 TestOptions, 143 - 144 143 VitestRunnerConfig as TestRunnerConfig, 144 + 145 + TestTags, 145 146 VitestRunner as VitestTestRunner, 146 147 } from '@vitest/runner' 147 148
+4 -1
packages/vitest/src/runtime/config.ts
··· 1 1 import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' 2 2 import type { PrettyFormatOptions } from '@vitest/pretty-format' 3 - import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' 3 + import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner' 4 4 import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot' 5 5 import type { SerializedDiffOptions } from '@vitest/utils/diff' 6 6 ··· 128 128 browserSdkPath?: string 129 129 } | undefined 130 130 } 131 + tags: TestTagDefinition[] 132 + tagsFilter: string[] | undefined 133 + strictTags: boolean 131 134 } 132 135 133 136 export interface SerializedCoverageConfig {
+1
packages/vitest/src/typecheck/collect.ts
··· 229 229 ctx.config.testNamePattern, 230 230 undefined, 231 231 undefined, 232 + undefined, 232 233 hasOnly, 233 234 false, 234 235 ctx.config.allowOnly,
+42 -16
packages/vitest/src/utils/test-helpers.ts
··· 3 3 import type { ContextTestEnvironment } from '../types/worker' 4 4 import { promises as fs } from 'node:fs' 5 5 6 - export async function getSpecificationsEnvironments( 6 + export async function getSpecificationsOptions( 7 7 specifications: Array<TestSpecification>, 8 - ): Promise<WeakMap<TestSpecification, ContextTestEnvironment>> { 8 + ): Promise<{ 9 + environments: WeakMap<TestSpecification, ContextTestEnvironment> 10 + tags: WeakMap<TestSpecification, string[]> 11 + }> { 9 12 const environments = new WeakMap<TestSpecification, ContextTestEnvironment>() 10 13 const cache = new Map<string, string>() 14 + const tags = new WeakMap<TestSpecification, string[]>() 11 15 await Promise.all( 12 16 specifications.map(async (spec) => { 13 - const { moduleId: filepath, project } = spec 17 + const { moduleId: filepath, project, pool } = spec 18 + // browser pool handles its own environment 19 + if (pool === 'browser') { 20 + return 21 + } 22 + 14 23 // reuse if projects have the same test files 15 24 let code = cache.get(filepath) 16 25 if (!code) { 17 - code = await fs.readFile(filepath, 'utf-8') 26 + code = await fs.readFile(filepath, 'utf-8').catch(() => '') 18 27 cache.set(filepath, code) 19 28 } 20 29 21 - // 1. Check for control comments in the file 22 - let env = code.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] 23 - // 2. Fallback to global env 24 - env ||= project.config.environment || 'node' 30 + const { 31 + env = project.config.environment || 'node', 32 + envOptions, 33 + tags: specTags = [], 34 + } = detectCodeBlock(code) 35 + tags.set(spec, specTags) 25 36 26 - let envOptionsJson = code.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] 27 - if (envOptionsJson?.endsWith('*/')) { 28 - // Trim closing Docblock characters the above regex might have captured 29 - envOptionsJson = envOptionsJson.slice(0, -2) 30 - } 31 - 32 - const envOptions = JSON.parse(envOptionsJson || 'null') 33 37 const envKey = env === 'happy-dom' ? 'happyDOM' : env 34 38 const environment: ContextTestEnvironment = { 35 39 name: env as VitestEnvironment, ··· 40 44 environments.set(spec, environment) 41 45 }), 42 46 ) 43 - return environments 47 + return { environments, tags } 48 + } 49 + 50 + export function detectCodeBlock(content: string): { 51 + env?: string 52 + envOptions?: Record<string, any> 53 + tags: string[] 54 + } { 55 + const env = content.match(/@(?:vitest|jest)-environment\s+([\w-]+)\b/)?.[1] 56 + let envOptionsJson = content.match(/@(?:vitest|jest)-environment-options\s+(.+)/)?.[1] 57 + if (envOptionsJson?.endsWith('*/')) { 58 + // Trim closing Docblock characters the above regex might have captured 59 + envOptionsJson = envOptionsJson.slice(0, -2) 60 + } 61 + const envOptions = JSON.parse(envOptionsJson || 'null') 62 + const tags: string[] = [] 63 + let tagMatch: RegExpMatchArray | null 64 + // eslint-disable-next-line no-cond-assign 65 + while (tagMatch = content.match(/(\/\/|\*)\s*@module-tag\s+([\w\-/]+)\b/)) { 66 + tags.push(tagMatch[2]) 67 + content = content.slice(tagMatch.index! + tagMatch[0].length) 68 + } 69 + return { env, envOptions, tags } 44 70 }
+8
test/cli/fixtures/file-tags/error-file-one-line-comment.test.ts
··· 1 + // @module-tag invalid 2 + // @module-tag unknown 3 + 4 + import { describe, test } from 'vitest' 5 + 6 + describe('suite 1', () => { 7 + test('test 1', { tags: ['test'] }, () => {}) 8 + })
+10
test/cli/fixtures/file-tags/error-file-tags.test.ts
··· 1 + /** 2 + * @module-tag invalid 3 + * @module-tag unknown 4 + */ 5 + 6 + import { describe, test } from 'vitest' 7 + 8 + describe('suite 1', () => { 9 + test('test 1', { tags: ['test'] }, () => {}) 10 + })
+9
test/cli/fixtures/file-tags/valid-file-one-line-comment.test.ts
··· 1 + // @module-tag file 2 + // @module-tag file-2 3 + // @module-tag file/slash 4 + 5 + import { describe, test } from 'vitest' 6 + 7 + describe('suite 1', () => { 8 + test('test 1', { tags: ['test'] }, () => {}) 9 + })
+11
test/cli/fixtures/file-tags/valid-file-tags.test.ts
··· 1 + /** 2 + * @module-tag file 3 + * @module-tag file-2 4 + * @module-tag file/slash 5 + */ 6 + 7 + import { describe, test } from 'vitest' 8 + 9 + describe('suite 1', () => { 10 + test('test 1', { tags: ['test'] }, () => {}) 11 + })
+1 -1
test/cli/fixtures/reporters/json-fail.test.ts
··· 2 2 3 3 // I am comment1 4 4 // I am comment2 5 - test('should fail', () => { 5 + test('should fail', { tags: ['fail'] }, () => { 6 6 // eslint-disable-next-line no-console 7 7 console.log('json-fail>should fail') 8 8 expect(2).toEqual(1)
+3
test/cli/fixtures/reporters/vitest.config.ts
··· 1 1 import { defineConfig } from 'vitest/config' 2 2 3 3 export default defineConfig({ 4 + test: { 5 + tags: [{ name: 'fail' }], 6 + }, 4 7 })
+11
test/cli/fixtures/test-tags/basic.test.ts
··· 1 + import { describe, test } from 'vitest' 2 + 3 + describe('suite 1', { tags: ['suite', 'alone'] }, () => { 4 + test('test 1', () => {}) 5 + test('test 2', { tags: ['test'] }, () => {}) 6 + 7 + describe('suite 2', { tags: ['suite_2', 'suite'] }, () => { 8 + test('test 3', () => {}) 9 + test('test 4', { tags: ['test_2'] }, () => {}) 10 + }) 11 + })
+5
test/cli/test/reporters/merge-reports.test.ts
··· 173 173 "fullName": "test 1-1", 174 174 "meta": {}, 175 175 "status": "passed", 176 + "tags": [], 176 177 "title": "test 1-1", 177 178 }, 178 179 { ··· 184 185 "fullName": "test 1-2", 185 186 "meta": {}, 186 187 "status": "failed", 188 + "tags": [], 187 189 "title": "test 1-2", 188 190 }, 189 191 ], ··· 204 206 "fullName": "test 2-1", 205 207 "meta": {}, 206 208 "status": "failed", 209 + "tags": [], 207 210 "title": "test 2-1", 208 211 }, 209 212 { ··· 214 217 "fullName": "group test 2-2", 215 218 "meta": {}, 216 219 "status": "passed", 220 + "tags": [], 217 221 "title": "test 2-2", 218 222 }, 219 223 { ··· 224 228 "fullName": "group test 2-3", 225 229 "meta": {}, 226 230 "status": "passed", 231 + "tags": [], 227 232 "title": "test 2-3", 228 233 }, 229 234 ],
+8 -4
packages/ui/client/components/explorer/Explorer.vue
··· 9 9 import { availableProjects, config } from '~/composables/client' 10 10 import { useSearch } from '~/composables/explorer/search' 11 11 import { ALL_PROJECTS, projectSort } from '~/composables/explorer/state' 12 - import { activeFileId } from '~/composables/params' 12 + import { activeFileId, selectedTest } from '~/composables/params' 13 13 import DetailsPanel from '../DetailsPanel.vue' 14 14 import FilterStatus from '../FilterStatus.vue' 15 15 import IconButton from '../IconButton.vue' ··· 55 55 disableProjectSort, 56 56 clearProjectSort, 57 57 disableClearProjectSort, 58 + searchMatcher, 58 59 } = useSearch(searchBox, selectProjectRef, sortProjectRef) 59 60 60 61 const filterClass = ref<string>('grid-cols-2') ··· 188 189 <input 189 190 ref="searchBox" 190 191 v-model="search" 191 - placeholder="Search..." 192 + placeholder="Search... (e.g. test name, tag:expression)" 192 193 outline="none" 193 194 bg="transparent" 194 195 font="light" ··· 257 258 <!-- empty-state --> 258 259 <template v-if="(isFiltered || isFilteredByStatus || !!currentProjectName) && uiEntries.length === 0"> 259 260 <div v-if="initialized" flex="~ col" items-center p="x4 y4" font-light> 260 - <div op30> 261 + <div v-if="searchMatcher.error" text-red text-center> 262 + {{ searchMatcher.error }} 263 + </div> 264 + <div v-else op30> 261 265 No matched test 262 266 </div> 263 267 <button ··· 332 336 :duration="item.duration" 333 337 :opened="item.expanded" 334 338 :disable-task-location="!includeTaskLocation" 335 - :class="activeFileId === item.id ? 'bg-active' : ''" 339 + :class="selectedTest === item.id || (!selectedTest && activeFileId === item.id) ? 'bg-active' : ''" 336 340 :on-item-click="onItemClick" 337 341 /> 338 342 </template>
+42 -3
packages/ui/client/components/explorer/ExplorerItem.vue
··· 9 9 import { hasFailedSnapshot } from '~/composables/explorer/collector' 10 10 import { escapeHtml, highlightRegex } from '~/composables/explorer/state' 11 11 import { coverageEnabled, disableCoverage } from '~/composables/navigation' 12 - import { getProjectTextColor } from '~/utils/task' 12 + import { getBadgeTextColor } from '~/utils/task' 13 13 import IconAction from '../IconAction.vue' 14 14 import IconButton from '../IconButton.vue' 15 15 import StatusIcon from '../StatusIcon.vue' ··· 157 157 } 158 158 } 159 159 160 - const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor)) 160 + const projectNameTextColor = computed(() => getBadgeTextColor(projectNameColor)) 161 + 162 + /** 163 + experiments trying to show tags compactly 164 + const tagsBorderGradient = computed(() => { 165 + const t = task.value! 166 + if (!t || t.type !== 'test' || !t.tags?.length) { 167 + return null 168 + } 169 + const colors = t.tags.map(t => getBadgeNameColor(t)).reverse() 170 + const percent = 100 / colors.length 171 + const res = `linear-gradient(to bottom left, ${colors.map(c => `${c} ${percent}%`).join(', ')})` 172 + return res 173 + }) 174 + const tagsBgGradient = computed(() => { 175 + const t = task.value! 176 + if (!t || t.type !== 'test' || !t.tags?.length) { 177 + return null 178 + } 179 + const colors = t.tags.map(t => getBadgeNameColor(t, true)).reverse() 180 + const percent = 100 / colors.length 181 + const res = `linear-gradient(to bottom left, ${colors.map(c => `${c} ${percent}%`).join(', ')})` 182 + return res 183 + }) 184 + */ 161 185 </script> 162 186 163 187 <template> ··· 175 199 :style="gridStyles" 176 200 :aria-label="name" 177 201 :data-current="current" 202 + data-testid="explorer-item" 178 203 @click="toggleOpen()" 179 204 > 180 205 <template v-if="indent > 0"> ··· 196 221 {{ duration > 0 ? duration : '< 1' }}ms 197 222 </span> 198 223 </div> 199 - <div gap-1 justify-end flex-grow-1 pl-1 class="test-actions"> 224 + <div gap-1 justify-end items-center flex-grow-1 pl-1 class="test-actions"> 225 + <!-- <div 226 + v-if="tagsBorderGradient" 227 + text-xs 228 + rounded-full 229 + flex 230 + justify-center 231 + items-center 232 + class="w-[1.1rem] h-[1.1rem]" 233 + :style="{ 234 + background: tagsBorderGradient, 235 + }" 236 + > 237 + <div :style="{ background: tagsBgGradient }" class="w-[0.9rem] h-[0.9rem]" rounded-full /> 238 + </div> --> 200 239 <IconAction 201 240 v-if="!isReport && failedSnapshot" 202 241 v-tooltip.bottom="'Fix failed snapshot(s)'"
+1 -1
packages/ui/client/components/views/ViewTestReport.vue
··· 104 104 </div> 105 105 <template v-else> 106 106 <div bg="green-500/10" text="green-500 sm" p="x4 y2" m-2 rounded> 107 - All tests passed in this file 107 + The test has passed without any errors 108 108 </div> 109 109 </template> 110 110 <template v-if="test.annotations.length">
+9
packages/ui/client/composables/client/state.ts
··· 1 + import type { TestTagDefinition } from '@vitest/runner' 1 2 import type { TestError } from '@vitest/utils' 2 3 import type { Ref } from 'vue' 3 4 import type { RunState } from '../../../types' 4 5 import { computed, ref } from 'vue' 6 + import { config } from '.' 5 7 6 8 export const testRunState: Ref<RunState> = ref('idle') 7 9 export const finished = computed(() => testRunState.value === 'idle') 8 10 export const unhandledErrors: Ref<TestError[]> = ref([]) 11 + export const tagsDefinitions = computed(() => { 12 + const tags = config.value.tags || [] 13 + return tags.reduce((acc, tag) => { 14 + acc[tag.name] = tag 15 + return acc 16 + }, {} as Record<string, TestTagDefinition>) 17 + })
+8 -8
packages/ui/client/composables/explorer/collector.ts
··· 1 1 import type { File, Task, TaskResultPack, Test, TestArtifact } from '@vitest/runner' 2 2 import type { Arrayable } from '@vitest/utils' 3 - import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests } from '~/composables/explorer/types' 3 + import type { CollectFilteredTests, CollectorInfo, Filter, FilteredTests, SearchMatcher } from '~/composables/explorer/types' 4 4 import { isTestCase } from '@vitest/runner/utils' 5 5 import { toArray } from '@vitest/utils/helpers' 6 6 import { client, findById } from '~/composables/client' ··· 29 29 export function runLoadFiles( 30 30 remoteFiles: File[], 31 31 collect: boolean, 32 - search: string, 32 + search: SearchMatcher, 33 33 filter: Filter, 34 34 ) { 35 35 remoteFiles.map(f => [`${f.filepath}:${f.projectName || ''}`, f] as const) ··· 37 37 .map(([, f]) => createOrUpdateFileNode(f, collect)) 38 38 39 39 uiFiles.value = [...explorerTree.root.tasks] 40 - runFilter(search.trim(), { 40 + runFilter(search, { 41 41 failed: filter.failed, 42 42 success: filter.success, 43 43 skipped: filter.skipped, ··· 94 94 start: boolean, 95 95 end: boolean, 96 96 summary: CollectorInfo, 97 - search: string, 97 + search: SearchMatcher, 98 98 filter: Filter, 99 99 executionTime: number, 100 100 ) { ··· 219 219 } 220 220 221 221 function doRunFilter( 222 - search: string, 222 + search: SearchMatcher, 223 223 filter: Filter, 224 224 end = false, 225 225 ) { ··· 257 257 } 258 258 } 259 259 260 - function refreshExplorer(search: string, filter: Filter, end: boolean) { 260 + function refreshExplorer(search: SearchMatcher, filter: Filter, end: boolean) { 261 261 runFilter(search, filter) 262 262 // update only at the end 263 263 if (end) { ··· 384 384 summary.totalTests = data.totalTests 385 385 } 386 386 387 - function collectTests(file: File, search = '', filter?: Filter) { 387 + function collectTests(file: File, search: SearchMatcher = () => true, filter?: Filter) { 388 388 const data = { 389 389 failed: 0, 390 390 success: 0, ··· 432 432 onlyTests: boolean, 433 433 tests: File[], 434 434 filesSummary: FilteredTests, 435 - search: string, 435 + search: SearchMatcher, 436 436 filter: Filter, 437 437 ) { 438 438 if (onlyTests) {
+3 -3
packages/ui/client/composables/explorer/expand.ts
··· 1 - import type { Filter, UITaskTreeNode } from '~/composables/explorer/types' 1 + import type { Filter, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' 2 2 import { findById } from '~/composables/client' 3 3 import { filterAll, filterNode } from '~/composables/explorer/filter' 4 4 import { explorerTree } from '~/composables/explorer/index' ··· 25 25 */ 26 26 export function runExpandNode( 27 27 id: string, 28 - search: string, 28 + search: SearchMatcher, 29 29 filter: Filter, 30 30 ) { 31 31 const entry = createOrUpdateSuiteTask( ··· 86 86 * @param filter The filter applied. 87 87 */ 88 88 export function runExpandAll( 89 - search: string, 89 + search: SearchMatcher, 90 90 filter: Filter, 91 91 ) { 92 92 expandAllNodes(explorerTree.root.tasks, false)
+17 -11
packages/ui/client/composables/explorer/filter.ts
··· 1 1 import type { Task } from '@vitest/runner' 2 - import type { FileTreeNode, Filter, FilterResult, ParentTreeNode, UITaskTreeNode } from '~/composables/explorer/types' 2 + import type { FileTreeNode, Filter, FilterResult, ParentTreeNode, SearchMatcher, UITaskTreeNode } from '~/composables/explorer/types' 3 3 import { client, findById } from '~/composables/client' 4 4 import { explorerTree } from '~/composables/explorer/index' 5 5 import { currentProjectName, filteredFiles, projectSort, uiEntries } from '~/composables/explorer/state' ··· 9 9 isParentNode, 10 10 isTestNode, 11 11 } from '~/composables/explorer/utils' 12 - import { caseInsensitiveMatch } from '~/utils/task' 13 12 14 - export function testMatcher(task: Task, search: string, filter: Filter) { 13 + export function testMatcher(task: Task, search: SearchMatcher, filter: Filter) { 15 14 return task ? matchTask(task, search, filter) : false 16 15 } 17 16 /** ··· 21 20 * @param filter The filter applied. 22 21 */ 23 22 export function runFilter( 24 - search: string, 23 + search: SearchMatcher, 25 24 filter: Filter, 26 25 ) { 27 26 const entries = [...filterAll( ··· 33 32 } 34 33 35 34 export function* filterAll( 36 - search: string, 35 + search: SearchMatcher, 37 36 filter: Filter, 38 37 ) { 39 38 const project = currentProjectName.value ··· 49 48 50 49 export function* filterNode( 51 50 node: UITaskTreeNode, 52 - search: string, 51 + search: SearchMatcher, 53 52 filter: Filter, 54 53 ) { 55 54 const treeNodes = new Set<string>() ··· 113 112 // we still need to show the suite, but the test must be removed from the list to render. 114 113 115 114 const map = explorerTree.nodes 115 + 116 + // When searching, expand parent nodes of matching tests so they are visible 117 + for (const id of treeNodes) { 118 + const treeNode = map.get(id) 119 + if (treeNode && 'expanded' in treeNode) { 120 + treeNode.expanded = true 121 + } 122 + } 123 + 116 124 // collect files and all suites whose parent is expanded 117 125 const parents = new Set( 118 126 entries.filter(e => isFileNode(e) || (isParentNode(e) && map.get(e.parentId)?.expanded)).map(e => e.id), ··· 232 240 233 241 function matchTask( 234 242 task: Task, 235 - search: string, 243 + search: SearchMatcher, 236 244 filter: Filter, 237 245 ) { 238 - const match = search.length === 0 || caseInsensitiveMatch(task.name, search) 239 - 240 246 // search and filter will apply together 241 - if (match) { 247 + if (search(task)) { 242 248 if (filter.success || filter.failed || filter.skipped) { 243 249 if (matchState(task, filter)) { 244 250 return true ··· 288 294 } 289 295 } 290 296 291 - function matcher(node: UITaskTreeNode, search: string, filter: Filter) { 297 + function matcher(node: UITaskTreeNode, search: SearchMatcher, filter: Filter) { 292 298 const task = client.state.idMap.get(node.id) 293 299 return task ? matchTask(task, search, filter) : false 294 300 }
+2
packages/ui/client/composables/explorer/search.ts
··· 17 17 openedTreeItems, 18 18 projectSort, 19 19 search, 20 + searchMatcher, 20 21 testsTotal, 21 22 treeFilter, 22 23 uiEntries, ··· 149 150 filteredFiles, 150 151 testsTotal, 151 152 uiEntries, 153 + searchMatcher, 152 154 enableProjects, 153 155 disableClearProjects, 154 156 currentProject,
+39 -1
packages/ui/client/composables/explorer/state.ts
··· 1 - import type { File } from '@vitest/runner' 1 + import type { File, Task } from '@vitest/runner' 2 2 import type { FileTreeNode, Filter, FilteredTests, ProjectSortUIType, TreeFilterState, UITaskTreeNode } from './types' 3 + import { createTagsFilter } from '@vitest/runner/utils' 3 4 import { useLocalStorage } from '@vueuse/core' 4 5 import { computed, reactive, ref, shallowRef } from 'vue' 5 6 import { availableProjects } from '~/composables/client' 7 + import { caseInsensitiveMatch } from '~/utils/task' 8 + import { config } from '../client' 6 9 import { explorerTree } from './index' 7 10 8 11 export const uiFiles = shallowRef<FileTreeNode[]>([]) ··· 35 38 return !enableProjects.value || currentProject.value === ALL_PROJECTS ? undefined : currentProject.value 36 39 }) 37 40 export const search = ref<string>(treeFilter.value.search) 41 + const tagExpressionsCache = new Map<string, { error?: string; matcher: (tags: string[]) => boolean }>() 42 + export const searchMatcher = computed(() => { 43 + if (search.value.startsWith('tag:')) { 44 + if (!config.value.tags) { // config is not loaded yet 45 + return { matcher: () => true } 46 + } 47 + const tagQuery = search.value.slice(4).trim() 48 + let filter = tagExpressionsCache.get(tagQuery) 49 + if (!filter) { 50 + filter = createSafeFilter(tagQuery) 51 + tagExpressionsCache.set(tagQuery, filter) 52 + } 53 + return { 54 + matcher: (task: Task) => filter.matcher(task.tags || []), 55 + error: filter.error, 56 + } 57 + } 58 + return { 59 + matcher: (task: Task) => search.value === '' || caseInsensitiveMatch(task.name, search.value), 60 + } 61 + }) 62 + 63 + function createSafeFilter( 64 + query: string, 65 + ) { 66 + if (!query) { 67 + return { matcher: () => true } 68 + } 69 + try { 70 + return { matcher: createTagsFilter([query], config.value.tags) } 71 + } 72 + catch (error: any) { 73 + return { matcher: () => false, error: error.message } 74 + } 75 + } 38 76 const htmlEntities: Record<string, string> = { 39 77 '&': '&amp;', 40 78 '<': '&lt;',
+8 -8
packages/ui/client/composables/explorer/tree.ts
··· 14 14 import { runFilter } from '~/composables/explorer/filter' 15 15 import { 16 16 filter, 17 - search, 17 + searchMatcher, 18 18 } from '~/composables/explorer/state' 19 19 20 20 export class ExplorerTree { ··· 68 68 runLoadFiles( 69 69 remoteFiles, 70 70 true, 71 - search.value.trim(), 71 + searchMatcher.value.matcher, 72 72 { 73 73 failed: filter.failed, 74 74 success: filter.success, ··· 122 122 start, 123 123 end, 124 124 this.summary, 125 - search.value.trim(), 125 + searchMatcher.value.matcher, 126 126 { 127 127 failed: filter.failed, 128 128 success: filter.success, ··· 138 138 start, 139 139 end, 140 140 this.summary, 141 - search.value.trim(), 141 + searchMatcher.value.matcher, 142 142 { 143 143 failed: filter.failed, 144 144 success: filter.success, ··· 156 156 tests: File[], 157 157 filesSummary: FilteredTests, 158 158 ) { 159 - return collectTestsTotalData(filtered, onlyTests, tests, filesSummary, search.value.trim(), { 159 + return collectTestsTotalData(filtered, onlyTests, tests, filesSummary, searchMatcher.value.matcher, { 160 160 failed: filter.failed, 161 161 success: filter.success, 162 162 skipped: filter.skipped, ··· 172 172 173 173 expandNode(id: string) { 174 174 queueMicrotask(() => { 175 - runExpandNode(id, search.value.trim(), { 175 + runExpandNode(id, searchMatcher.value.matcher, { 176 176 failed: filter.failed, 177 177 success: filter.success, 178 178 skipped: filter.skipped, ··· 189 189 190 190 expandAllNodes() { 191 191 queueMicrotask(() => { 192 - runExpandAll(search.value.trim(), { 192 + runExpandAll(searchMatcher.value.matcher, { 193 193 failed: filter.failed, 194 194 success: filter.success, 195 195 skipped: filter.skipped, ··· 200 200 201 201 filterNodes() { 202 202 queueMicrotask(() => { 203 - runFilter(search.value.trim(), { 203 + runFilter(searchMatcher.value.matcher, { 204 204 failed: filter.failed, 205 205 success: filter.success, 206 206 skipped: filter.skipped,
+5 -1
packages/ui/client/composables/explorer/types.ts
··· 1 - import type { RunMode, TaskState } from '@vitest/runner' 1 + import type { RunMode, Task, TaskState } from '@vitest/runner' 2 2 3 3 export type FilterResult = [match: boolean, node: UITaskTreeNode] 4 4 ··· 7 7 success: number 8 8 skipped: number 9 9 running: number 10 + } 11 + 12 + export interface SearchMatcher { 13 + (task: Task): boolean 10 14 } 11 15 12 16 export interface CollectFilteredTests extends FilteredTests {
+2 -2
packages/ui/client/composables/explorer/utils.ts
··· 11 11 import { client } from '~/composables/client' 12 12 import { explorerTree } from '~/composables/explorer/index' 13 13 import { openedTreeItemsSet } from '~/composables/explorer/state' 14 - import { getProjectNameColor, isSuite as isTaskSuite } from '~/utils/task' 14 + import { getBadgeNameColor, isSuite as isTaskSuite } from '~/utils/task' 15 15 16 16 export function isTestNode(node: UITaskTreeNode): node is TestTreeNode { 17 17 return node.type === 'test' ··· 90 90 duration: typeof file.result?.duration === 'number' ? Math.round(file.result.duration) : undefined, 91 91 filepath: file.filepath, 92 92 projectName: file.projectName || '', 93 - projectNameColor: explorerTree.colors.get(file.projectName || '') || getProjectNameColor(file.projectName), 93 + projectNameColor: explorerTree.colors.get(file.projectName || '') || getBadgeNameColor(file.projectName), 94 94 collectDuration: file.collectDuration, 95 95 setupDuration: file.setupDuration, 96 96 environmentLoad: file.environmentLoad,
+1 -1
packages/vitest/src/node/cli/cac.ts
··· 290 290 if (typeof argv.typecheck?.only === 'boolean') { 291 291 argv.typecheck.enabled ??= true 292 292 } 293 - if (argv.clearCache) { 293 + if (argv.clearCache || argv.listTags) { 294 294 argv.watch = false 295 295 argv.run = true 296 296 }
+4 -1
packages/vitest/src/node/cli/cli-api.ts
··· 91 91 }) 92 92 93 93 try { 94 - if (ctx.config.clearCache) { 94 + if (ctx.config.listTags) { 95 + await ctx.listTags() 96 + } 97 + else if (ctx.config.clearCache) { 95 98 await ctx.experimental_clearCache() 96 99 } 97 100 else if (ctx.config.mergeReports) {
+13
packages/vitest/src/node/cli/cli-config.ts
··· 785 785 return value 786 786 }, 787 787 }, 788 + listTags: { 789 + description: 'List all available tags instead of running tests. `--list-tags=json` will output tags in JSON format, unless there are no tags.', 790 + argument: '[type]', 791 + }, 788 792 clearCache: { 789 793 description: 'Delete all Vitest caches, including `experimental.fsModuleCache`, without running any tests. This will reduce the performance in the subsequent test run.', 794 + }, 795 + tagsFilter: { 796 + 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.', 797 + argument: '<expression>', 798 + array: true, 799 + }, 800 + strictTags: { 801 + description: 'Should Vitest throw an error if test has a tag that is not defined in the config. (default: `true`)', 790 802 }, 791 803 792 804 experimental: { ··· 844 856 filesOnly: null, 845 857 projects: null, 846 858 watchTriggerPatterns: null, 859 + tags: null, 847 860 } 848 861 849 862 export const benchCliOptionsConfig: Pick<
+28
packages/vitest/src/node/config/resolveConfig.ts
··· 168 168 resolved.project = toArray(resolved.project) 169 169 resolved.provide ??= {} 170 170 171 + // shallow copy tags array to avoid mutating user config 172 + resolved.tags = [...resolved.tags || []] 173 + const definedTags = new Set<string>() 174 + resolved.tags.forEach((tag) => { 175 + if (!tag.name || typeof tag.name !== 'string') { 176 + throw new Error(`Each tag defined in "test.tags" must have a "name" property, received: ${JSON.stringify(tag)}`) 177 + } 178 + if (definedTags.has(tag.name)) { 179 + throw new Error(`Tag name "${tag.name}" is already defined in "test.tags". Tag names must be unique.`) 180 + } 181 + if (tag.name.match(/\s/)) { 182 + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain spaces.`) 183 + } 184 + if (tag.name.match(/([!()*|&])/)) { 185 + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot contain "!", "*", "&", "|", "(", or ")".`) 186 + } 187 + if (tag.name.match(/^\s*(and|or|not)\s*$/i)) { 188 + throw new Error(`Tag name "${tag.name}" is invalid. Tag names cannot be a logical operator like "and", "or", "not".`) 189 + } 190 + if (typeof tag.retry === 'object' && typeof tag.retry.condition === 'function') { 191 + 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.`) 192 + } 193 + if (tag.priority != null && (typeof tag.priority !== 'number' || tag.priority < 0)) { 194 + throw new TypeError(`Tag "${tag.name}": priority must be a non-negative number.`) 195 + } 196 + definedTags.add(tag.name) 197 + }) 198 + 171 199 resolved.name = typeof options.name === 'string' 172 200 ? options.name 173 201 : (options.name?.label || '')
+3
packages/vitest/src/node/config/serializeConfig.ts
··· 137 137 nodeLoader: config.experimental.nodeLoader ?? true, 138 138 openTelemetry: config.experimental.openTelemetry, 139 139 }, 140 + tags: config.tags || [], 141 + tagsFilter: config.tagsFilter, 142 + strictTags: config.strictTags ?? true, 140 143 } 141 144 }
+21 -1
packages/vitest/src/node/pools/browser.ts
··· 8 8 import type { TestSpecification } from '../test-specification' 9 9 import type { BrowserProvider } from '../types/browser' 10 10 import crypto from 'node:crypto' 11 + import { readFile } from 'node:fs/promises' 11 12 import * as nodeos from 'node:os' 12 13 import { createDefer } from '@vitest/utils/helpers' 13 14 import { stringify } from 'flatted' 14 15 import { createDebugger } from '../../utils/debugger' 16 + import { detectCodeBlock } from '../../utils/test-helpers' 15 17 16 18 const debug = createDebugger('vitest:browser:pool') 17 19 ··· 62 64 63 65 const runWorkspaceTests = async (method: 'run' | 'collect', specs: TestSpecification[]) => { 64 66 const groupedFiles = new Map<TestProject, FileSpecification[]>() 65 - for (const { project, moduleId, testLines, testIds, testNamePattern } of specs) { 67 + const testFilesCode = new Map<string, string>() 68 + const testFileTags = new WeakMap<TestSpecification, string[]>() 69 + 70 + await Promise.all(specs.map(async (spec) => { 71 + let code = testFilesCode.get(spec.moduleId) 72 + // TODO: this really should be done only once when collecting specifications 73 + if (code == null) { 74 + code = await readFile(spec.moduleId, 'utf-8').catch(() => '') 75 + testFilesCode.set(spec.moduleId, code) 76 + } 77 + const { tags } = detectCodeBlock(code) 78 + testFileTags.set(spec, tags) 79 + })) 80 + 81 + // to keep the sorting, we need to iterate over specs separately 82 + for (const spec of specs) { 83 + const { project, moduleId, testLines, testIds, testNamePattern, testTagsFilter } = spec 66 84 const files = groupedFiles.get(project) || [] 67 85 files.push({ 68 86 filepath: moduleId, 69 87 testLocations: testLines, 70 88 testIds, 71 89 testNamePattern, 90 + testTagsFilter, 91 + fileTags: testFileTags.get(spec), 72 92 }) 73 93 groupedFiles.set(project, files) 74 94 }
+23 -1
packages/vitest/src/node/projects/resolveProjects.ts
··· 58 58 'inspect', 59 59 'inspectBrk', 60 60 'fileParallelism', 61 + 'tagsFilter', 61 62 ] as const 62 63 63 64 const cliOverrides = overridesOptions.reduce((acc, name) => { ··· 87 88 projectPromises.push(concurrent(() => initializeProject( 88 89 index, 89 90 vitest, 90 - { ...options, root, configFile, test: { ...options.test, ...cliOverrides } }, 91 + { 92 + ...options, 93 + root, 94 + configFile, 95 + plugins: [ 96 + { 97 + name: 'vitest:tags', 98 + // don't inherit tags from workspace config, they are merged separately 99 + configResolved(config) { 100 + ;(config as any).test ??= {} 101 + config.test!.tags = options.test?.tags 102 + }, 103 + api: { 104 + vitest: { 105 + experimental: { ignoreFsModuleCache: true }, 106 + }, 107 + }, 108 + }, 109 + ...options.plugins || [], 110 + ], 111 + test: { ...options.test, ...cliOverrides }, 112 + }, 91 113 ))) 92 114 }) 93 115
+2
packages/vitest/src/node/reporters/json.ts
··· 39 39 duration?: Milliseconds | null 40 40 failureMessages: Array<string> | null 41 41 location?: Callsite | null 42 + tags: string[] 42 43 } 43 44 44 45 export interface JsonTestResult { ··· 161 162 t.result?.errors?.map(e => e.stack || e.message) || [], 162 163 location: t.location, 163 164 meta: t.meta, 165 + tags: t.tags || [], 164 166 } satisfies JsonAssertionResult 165 167 }) 166 168
+13
packages/vitest/src/node/reporters/reported-tasks.ts
··· 103 103 */ 104 104 public readonly parent: TestSuite | TestModule 105 105 106 + /** 107 + * Tags associated with the test. 108 + */ 109 + public readonly tags: string[] 110 + 106 111 /** @internal */ 107 112 protected constructor(task: RunnerTestCase, project: TestProject) { 108 113 super(task, project) ··· 117 122 this.parent = this.module 118 123 } 119 124 this.options = buildOptions(task) 125 + this.tags = this.options.tags || [] 120 126 } 121 127 122 128 /** ··· 565 571 readonly shuffle: boolean | undefined 566 572 readonly retry: SerializableRetry | undefined 567 573 readonly repeats: number | undefined 574 + readonly tags: string[] | undefined 575 + /** 576 + * Only tests have a `timeout` option. 577 + */ 578 + readonly timeout: number | undefined 568 579 readonly mode: 'run' | 'only' | 'skip' | 'todo' 569 580 } 570 581 ··· 578 589 shuffle: task.shuffle, 579 590 retry: task.retry as SerializableRetry | undefined, 580 591 repeats: task.repeats, 592 + tags: task.tags, 593 + timeout: task.type === 'test' ? task.timeout : undefined, 581 594 // runner types are too broad, but the public API should be more strict 582 595 // the queued state exists only on Files and this method is called 583 596 // only for tests and suites
+28 -1
packages/vitest/src/node/types/config.ts
··· 1 1 import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' 2 2 import type { PrettyFormatOptions } from '@vitest/pretty-format' 3 - import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' 3 + import type { SequenceHooks, SequenceSetupFiles, SerializableRetry, TestTagDefinition } from '@vitest/runner' 4 4 import type { SnapshotStateOptions } from '@vitest/snapshot' 5 5 import type { Arrayable } from '@vitest/utils' 6 6 import type { SerializedDiffOptions } from '@vitest/utils/diff' ··· 879 879 */ 880 880 nodeLoader?: boolean 881 881 } 882 + 883 + /** 884 + * Define tags available in your test files. 885 + * 886 + * If test defines a tag that is not listed here, an error will be thrown. 887 + */ 888 + tags?: TestTagDefinition[] 889 + 890 + /** 891 + * Should Vitest throw an error if test has a tag that is not defined in the config. 892 + * @default true 893 + */ 894 + strictTags?: boolean 882 895 } 883 896 884 897 export interface TypecheckConfig { ··· 1014 1027 * @experimental 1015 1028 */ 1016 1029 clearCache?: boolean 1030 + 1031 + /** 1032 + * Tags expression to filter tests to run. Multiple filters will be applied using AND logic. 1033 + * @see {@link https://vitest.dev/guide/test-tags#syntax} 1034 + */ 1035 + tagsFilter?: string[] 1036 + 1037 + /** 1038 + * Log all available tags instead of running tests. 1039 + */ 1040 + listTags?: boolean | 'json' 1017 1041 } 1018 1042 1019 1043 export type OnUnhandledErrorCallback = (error: (TestError | Error) & { type: string }) => boolean | void ··· 1046 1070 | 'name' 1047 1071 | 'vmMemoryLimit' 1048 1072 | 'fileParallelism' 1073 + | 'tagsFilter' 1049 1074 > { 1050 1075 mode: VitestRunMode 1051 1076 ··· 1116 1141 1117 1142 vmMemoryLimit?: UserConfig['vmMemoryLimit'] 1118 1143 dumpDir?: string 1144 + tagsFilter?: string[] 1119 1145 } 1120 1146 1121 1147 type NonProjectOptions ··· 1145 1171 | 'inspectBrk' 1146 1172 | 'coverage' 1147 1173 | 'watchTriggerPatterns' 1174 + | 'tagsFilter' // CLI option only 1148 1175 1149 1176 export interface ServerDepsOptions { 1150 1177 /**
+1
packages/vitest/src/runtime/types/utils.ts
··· 6 6 testLines?: number[] | undefined 7 7 testIds?: string[] | undefined 8 8 testNamePattern?: RegExp | undefined 9 + testTagsFilter?: string[] | undefined 9 10 }, 10 11 ]
+8 -1
test/cli/test/reporters/__snapshots__/html.test.ts.snap
··· 24 24 "state": "fail", 25 25 }, 26 26 "setupDuration": 0, 27 + "tags": [], 27 28 "tasks": [ 28 29 { 29 30 "annotations": [], ··· 81 82 "startTime": 0, 82 83 "state": "fail", 83 84 }, 85 + "tags": [ 86 + "fail", 87 + ], 84 88 "timeout": 5000, 85 89 "type": "test", 86 90 }, ··· 113 117 114 118 // I am comment1 115 119 // I am comment2 116 - test('should fail', () => { 120 + test('should fail', { tags: ['fail'] }, () => { 117 121 // eslint-disable-next-line no-console 118 122 console.log('json-fail>should fail') 119 123 expect(2).toEqual(1) ··· 148 152 "state": "pass", 149 153 }, 150 154 "setupDuration": 0, 155 + "tags": [], 151 156 "tasks": [ 152 157 { 153 158 "annotations": [], ··· 170 175 "startTime": 0, 171 176 "state": "pass", 172 177 }, 178 + "tags": [], 173 179 "timeout": 5000, 174 180 "type": "test", 175 181 }, ··· 187 193 "meta": {}, 188 194 "mode": "skip", 189 195 "name": "3 + 3 = 6", 196 + "tags": [], 190 197 "timeout": 5000, 191 198 "type": "test", 192 199 },
+3
test/cli/test/reporters/__snapshots__/json.test.ts.snap
··· 14 14 }, 15 15 "meta": {}, 16 16 "status": "failed", 17 + "tags": [ 18 + "fail", 19 + ], 17 20 "title": "should fail", 18 21 } 19 22 `;
+54
test/cli/test/reporters/__snapshots__/reporters.test.ts.snap
··· 151 151 }, 152 152 "meta": {}, 153 153 "status": "failed", 154 + "tags": [], 154 155 "title": "Math.sqrt()", 155 156 }, 156 157 { ··· 162 163 "fullName": "suite JSON", 163 164 "meta": {}, 164 165 "status": "passed", 166 + "tags": [], 165 167 "title": "JSON", 166 168 }, 167 169 { ··· 172 174 "fullName": "suite async with timeout", 173 175 "meta": {}, 174 176 "status": "skipped", 177 + "tags": [], 175 178 "title": "async with timeout", 176 179 }, 177 180 { ··· 183 186 "fullName": "suite timeout", 184 187 "meta": {}, 185 188 "status": "passed", 189 + "tags": [], 186 190 "title": "timeout", 187 191 }, 188 192 { ··· 194 198 "fullName": "suite callback setup success ", 195 199 "meta": {}, 196 200 "status": "passed", 201 + "tags": [], 197 202 "title": "callback setup success ", 198 203 }, 199 204 { ··· 205 210 "fullName": "suite callback test success ", 206 211 "meta": {}, 207 212 "status": "passed", 213 + "tags": [], 208 214 "title": "callback test success ", 209 215 }, 210 216 { ··· 216 222 "fullName": "suite callback setup success done(false)", 217 223 "meta": {}, 218 224 "status": "passed", 225 + "tags": [], 219 226 "title": "callback setup success done(false)", 220 227 }, 221 228 { ··· 227 234 "fullName": "suite callback test success done(false)", 228 235 "meta": {}, 229 236 "status": "passed", 237 + "tags": [], 230 238 "title": "callback test success done(false)", 231 239 }, 232 240 { ··· 237 245 "fullName": "suite todo test", 238 246 "meta": {}, 239 247 "status": "todo", 248 + "tags": [], 240 249 "title": "todo test", 241 250 }, 242 251 ], ··· 295 304 }, 296 305 "meta": {}, 297 306 "status": "failed", 307 + "tags": [], 298 308 "title": "Math.sqrt()", 299 309 }, 300 310 { ··· 306 316 "fullName": "suite JSON", 307 317 "meta": {}, 308 318 "status": "passed", 319 + "tags": [], 309 320 "title": "JSON", 310 321 }, 311 322 { ··· 316 327 "fullName": "suite async with timeout", 317 328 "meta": {}, 318 329 "status": "skipped", 330 + "tags": [], 319 331 "title": "async with timeout", 320 332 }, 321 333 { ··· 327 339 "fullName": "suite timeout", 328 340 "meta": {}, 329 341 "status": "passed", 342 + "tags": [], 330 343 "title": "timeout", 331 344 }, 332 345 { ··· 338 351 "fullName": "suite callback setup success ", 339 352 "meta": {}, 340 353 "status": "passed", 354 + "tags": [], 341 355 "title": "callback setup success ", 342 356 }, 343 357 { ··· 349 363 "fullName": "suite callback test success ", 350 364 "meta": {}, 351 365 "status": "passed", 366 + "tags": [], 352 367 "title": "callback test success ", 353 368 }, 354 369 { ··· 360 375 "fullName": "suite callback setup success done(false)", 361 376 "meta": {}, 362 377 "status": "passed", 378 + "tags": [], 363 379 "title": "callback setup success done(false)", 364 380 }, 365 381 { ··· 371 387 "fullName": "suite callback test success done(false)", 372 388 "meta": {}, 373 389 "status": "passed", 390 + "tags": [], 374 391 "title": "callback test success done(false)", 375 392 }, 376 393 { ··· 381 398 "fullName": "suite todo test", 382 399 "meta": {}, 383 400 "status": "todo", 401 + "tags": [], 384 402 "title": "todo test", 385 403 }, 386 404 ], ··· 444 462 }, 445 463 "meta": {}, 446 464 "status": "failed", 465 + "tags": [], 447 466 "title": "Math.sqrt()", 448 467 }, 449 468 { ··· 455 474 "fullName": "suite JSON", 456 475 "meta": {}, 457 476 "status": "passed", 477 + "tags": [], 458 478 "title": "JSON", 459 479 }, 460 480 { ··· 465 485 "fullName": "suite async with timeout", 466 486 "meta": {}, 467 487 "status": "skipped", 488 + "tags": [], 468 489 "title": "async with timeout", 469 490 }, 470 491 { ··· 476 497 "fullName": "suite timeout", 477 498 "meta": {}, 478 499 "status": "passed", 500 + "tags": [], 479 501 "title": "timeout", 480 502 }, 481 503 { ··· 487 509 "fullName": "suite callback setup success ", 488 510 "meta": {}, 489 511 "status": "passed", 512 + "tags": [], 490 513 "title": "callback setup success ", 491 514 }, 492 515 { ··· 498 521 "fullName": "suite callback test success ", 499 522 "meta": {}, 500 523 "status": "passed", 524 + "tags": [], 501 525 "title": "callback test success ", 502 526 }, 503 527 { ··· 509 533 "fullName": "suite callback setup success done(false)", 510 534 "meta": {}, 511 535 "status": "passed", 536 + "tags": [], 512 537 "title": "callback setup success done(false)", 513 538 }, 514 539 { ··· 520 545 "fullName": "suite callback test success done(false)", 521 546 "meta": {}, 522 547 "status": "passed", 548 + "tags": [], 523 549 "title": "callback test success done(false)", 524 550 }, 525 551 { ··· 530 556 "fullName": "suite todo test", 531 557 "meta": {}, 532 558 "status": "todo", 559 + "tags": [], 533 560 "title": "todo test", 534 561 }, 535 562 ], ··· 593 620 }, 594 621 "meta": {}, 595 622 "status": "failed", 623 + "tags": [], 596 624 "title": "Math.sqrt()", 597 625 }, 598 626 { ··· 604 632 "fullName": "suite JSON", 605 633 "meta": {}, 606 634 "status": "passed", 635 + "tags": [], 607 636 "title": "JSON", 608 637 }, 609 638 { ··· 614 643 "fullName": "suite async with timeout", 615 644 "meta": {}, 616 645 "status": "skipped", 646 + "tags": [], 617 647 "title": "async with timeout", 618 648 }, 619 649 { ··· 625 655 "fullName": "suite timeout", 626 656 "meta": {}, 627 657 "status": "passed", 658 + "tags": [], 628 659 "title": "timeout", 629 660 }, 630 661 { ··· 636 667 "fullName": "suite callback setup success ", 637 668 "meta": {}, 638 669 "status": "passed", 670 + "tags": [], 639 671 "title": "callback setup success ", 640 672 }, 641 673 { ··· 647 679 "fullName": "suite callback test success ", 648 680 "meta": {}, 649 681 "status": "passed", 682 + "tags": [], 650 683 "title": "callback test success ", 651 684 }, 652 685 { ··· 658 691 "fullName": "suite callback setup success done(false)", 659 692 "meta": {}, 660 693 "status": "passed", 694 + "tags": [], 661 695 "title": "callback setup success done(false)", 662 696 }, 663 697 { ··· 669 703 "fullName": "suite callback test success done(false)", 670 704 "meta": {}, 671 705 "status": "passed", 706 + "tags": [], 672 707 "title": "callback test success done(false)", 673 708 }, 674 709 { ··· 679 714 "fullName": "suite todo test", 680 715 "meta": {}, 681 716 "status": "todo", 717 + "tags": [], 682 718 "title": "todo test", 683 719 }, 684 720 ], ··· 742 778 }, 743 779 "meta": {}, 744 780 "status": "failed", 781 + "tags": [], 745 782 "title": "Math.sqrt()", 746 783 }, 747 784 { ··· 753 790 "fullName": "suite JSON", 754 791 "meta": {}, 755 792 "status": "passed", 793 + "tags": [], 756 794 "title": "JSON", 757 795 }, 758 796 { ··· 763 801 "fullName": "suite async with timeout", 764 802 "meta": {}, 765 803 "status": "skipped", 804 + "tags": [], 766 805 "title": "async with timeout", 767 806 }, 768 807 { ··· 774 813 "fullName": "suite timeout", 775 814 "meta": {}, 776 815 "status": "passed", 816 + "tags": [], 777 817 "title": "timeout", 778 818 }, 779 819 { ··· 785 825 "fullName": "suite callback setup success ", 786 826 "meta": {}, 787 827 "status": "passed", 828 + "tags": [], 788 829 "title": "callback setup success ", 789 830 }, 790 831 { ··· 796 837 "fullName": "suite callback test success ", 797 838 "meta": {}, 798 839 "status": "passed", 840 + "tags": [], 799 841 "title": "callback test success ", 800 842 }, 801 843 { ··· 807 849 "fullName": "suite callback setup success done(false)", 808 850 "meta": {}, 809 851 "status": "passed", 852 + "tags": [], 810 853 "title": "callback setup success done(false)", 811 854 }, 812 855 { ··· 818 861 "fullName": "suite callback test success done(false)", 819 862 "meta": {}, 820 863 "status": "passed", 864 + "tags": [], 821 865 "title": "callback test success done(false)", 822 866 }, 823 867 { ··· 828 872 "fullName": "suite todo test", 829 873 "meta": {}, 830 874 "status": "todo", 875 + "tags": [], 831 876 "title": "todo test", 832 877 }, 833 878 ], ··· 891 936 }, 892 937 "meta": {}, 893 938 "status": "failed", 939 + "tags": [], 894 940 "title": "Math.sqrt()", 895 941 }, 896 942 { ··· 902 948 "fullName": "suite JSON", 903 949 "meta": {}, 904 950 "status": "passed", 951 + "tags": [], 905 952 "title": "JSON", 906 953 }, 907 954 { ··· 912 959 "fullName": "suite async with timeout", 913 960 "meta": {}, 914 961 "status": "skipped", 962 + "tags": [], 915 963 "title": "async with timeout", 916 964 }, 917 965 { ··· 923 971 "fullName": "suite timeout", 924 972 "meta": {}, 925 973 "status": "passed", 974 + "tags": [], 926 975 "title": "timeout", 927 976 }, 928 977 { ··· 934 983 "fullName": "suite callback setup success ", 935 984 "meta": {}, 936 985 "status": "passed", 986 + "tags": [], 937 987 "title": "callback setup success ", 938 988 }, 939 989 { ··· 945 995 "fullName": "suite callback test success ", 946 996 "meta": {}, 947 997 "status": "passed", 998 + "tags": [], 948 999 "title": "callback test success ", 949 1000 }, 950 1001 { ··· 956 1007 "fullName": "suite callback setup success done(false)", 957 1008 "meta": {}, 958 1009 "status": "passed", 1010 + "tags": [], 959 1011 "title": "callback setup success done(false)", 960 1012 }, 961 1013 { ··· 967 1019 "fullName": "suite callback test success done(false)", 968 1020 "meta": {}, 969 1021 "status": "passed", 1022 + "tags": [], 970 1023 "title": "callback test success done(false)", 971 1024 }, 972 1025 { ··· 977 1030 "fullName": "suite todo test", 978 1031 "meta": {}, 979 1032 "status": "todo", 1033 + "tags": [], 980 1034 "title": "todo test", 981 1035 }, 982 1036 ],