[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: add `experimental.preParse` flag (#10070)

authored by

Vladimir and committed by
GitHub
(Apr 7, 2026, 11:49 AM +0200) 7827363b 1af865e2

+469 -21
+33
docs/config/experimental.md
··· 479 479 If module runner is disabled, Vitest uses a native [Node.js module loader](https://nodejs.org/api/module.html#customization-hooks) to transform files to support `import.meta.vitest`, `vi.mock` and `vi.hoisted`. 480 480 481 481 If you don't use these features, you can disable this to improve performance. 482 + 483 + ## experimental.preParse <Version type="experimental">4.1.3</Version> {#experimental-preparse} 484 + 485 + - **Type:** `boolean` 486 + - **Default:** `false` 487 + 488 + Parses test specifications before running them. This applies the [`.only`](/api/test#test-only) modifier, the [`-t`](/config/testnamepattern) test name pattern, [`--tags-filter`](/guide/test-tags#syntax), [test lines](/api/advanced/test-specification#testlines), and [test IDs](/api/advanced/test-specification#testids) across all files without executing them. For example, if only a single test is marked with `.only`, Vitest will skip all other tests in all files. 489 + 490 + ::: tip 491 + This option is recommended when using [`.only`](/api/test#test-only), the [`-t`](/config/testnamepattern) flag, or [`--tags-filter`](/guide/test-tags#syntax). 492 + 493 + Enabling it unconditionally may slow down your test runs due to the additional parsing step. 494 + ::: 495 + 496 + ::: warning 497 + Pre-parsing uses static analysis (AST parsing) instead of executing your test files. This means that test names, tags, and modifiers (`.only`, `.skip`, `.todo`) must be statically analyzable. Dynamic test names (e.g., names stored in variables or returned from function calls) and non-literal tags will not be resolved correctly. 498 + 499 + ```ts 500 + // ✅ works — static string literal 501 + test('adds numbers', () => {}) 502 + 503 + // ✅ works — static tags 504 + test('my test', { tags: ['unit'] }, () => {}) 505 + 506 + // ❌ won't match correctly — dynamic name 507 + const name = getName() 508 + test(name, () => {}) 509 + 510 + // ❌ won't match correctly — dynamic tags 511 + const tags = getTags() 512 + test('my test', { tags }, () => {}) 513 + ``` 514 + :::
+7
docs/guide/cli-generated.md
··· 963 963 - **Config:** [experimental.vcsProvider](/config/experimental#experimental-vcsprovider) 964 964 965 965 Custom provider for detecting changed files. (default: `git`) 966 + 967 + ### experimental.preParse 968 + 969 + - **CLI:** `--experimental.preParse` 970 + - **Config:** [experimental.preParse](/config/experimental#experimental-preparse) 971 + 972 + Parse test specifications before running them. This will apply `.only` flag and test name pattern across all files without running them. (default: `false`)
+3 -2
test/test-utils/index.ts
··· 41 41 export interface RunVitestConfig extends TestUserConfig { 42 42 $viteConfig?: Omit<ViteUserConfig, 'test'> 43 43 $cliOptions?: TestCliOptions 44 + $cliFilters?: string[] 44 45 } 45 46 46 47 const process_ = process ··· 57 58 */ 58 59 export async function runVitest( 59 60 config: RunVitestConfig, 60 - cliFilters: string[] = [], 61 + cliFilters: string[] = config.$cliFilters || [], 61 62 runnerOptions: VitestRunnerCLIOptions = {}, 62 63 ) { 63 64 // Reset possible previous runs ··· 517 518 const vitest = await runVitest({ 518 519 root, 519 520 ...config, 520 - }, [], options) 521 + }, config?.$cliFilters ?? [], options) 521 522 return { 522 523 fs, 523 524 root,
+360
test/cli/test/pre-parse.test.ts
··· 1 + import type { TestCase } from 'vitest/node' 2 + import { expect, test } from 'vitest' 3 + import { runInlineTests } from '../../test-utils' 4 + 5 + test('only runs .only tests across files', async () => { 6 + const { buildTree, stderr } = await runInlineTests({ 7 + 'a.test.js': ` 8 + import { test } from 'vitest' 9 + test('test 1', { meta: { ran: true } }, () => {}) 10 + test('test 2', { meta: { ran: true } }, () => {}) 11 + `, 12 + 'b.test.js': ` 13 + import { test } from 'vitest' 14 + test('test 3', { meta: { ran: true } }, () => {}) 15 + test.only('test 4', { meta: { ran: true } }, () => {}) 16 + `, 17 + }, { 18 + experimental: { preParse: true }, 19 + allowOnly: true, 20 + }) 21 + 22 + expect(stderr).toBe('') 23 + expect(buildTree(t => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 24 + { 25 + "a.test.js": { 26 + "test 1": { 27 + "meta": {}, 28 + "state": "skipped", 29 + }, 30 + "test 2": { 31 + "meta": {}, 32 + "state": "skipped", 33 + }, 34 + }, 35 + "b.test.js": { 36 + "test 3": { 37 + "meta": { 38 + "ran": true, 39 + }, 40 + "state": "skipped", 41 + }, 42 + "test 4": { 43 + "meta": { 44 + "ran": true, 45 + }, 46 + "state": "passed", 47 + }, 48 + }, 49 + } 50 + `) 51 + }) 52 + 53 + test('runs all tests when no .only is present', async () => { 54 + const { buildTree, stderr } = await runInlineTests({ 55 + 'a.test.js': ` 56 + import { test } from 'vitest' 57 + test('test 1', { meta: { ran: true } }, () => {}) 58 + `, 59 + 'b.test.js': ` 60 + import { test } from 'vitest' 61 + test('test 2', { meta: { ran: true } }, () => {}) 62 + `, 63 + }, { 64 + experimental: { preParse: true }, 65 + }) 66 + 67 + expect(stderr).toBe('') 68 + expect(buildTree(t => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 69 + { 70 + "a.test.js": { 71 + "test 1": { 72 + "meta": { 73 + "ran": true, 74 + }, 75 + "state": "passed", 76 + }, 77 + }, 78 + "b.test.js": { 79 + "test 2": { 80 + "meta": { 81 + "ran": true, 82 + }, 83 + "state": "passed", 84 + }, 85 + }, 86 + } 87 + `) 88 + }) 89 + 90 + test('filters tests by testNamePattern', async () => { 91 + const { buildTree, stderr } = await runInlineTests({ 92 + 'a.test.js': ` 93 + import { test } from 'vitest' 94 + test('hello world', { meta: { ran: true } }, () => {}) 95 + test('foo bar', { meta: { ran: true } }, () => {}) 96 + `, 97 + 'b.test.js': ` 98 + import { test } from 'vitest' 99 + test('another foo', { meta: { ran: true } }, () => {}) 100 + test('unrelated', { meta: { ran: true } }, () => {}) 101 + `, 102 + }, { 103 + experimental: { preParse: true }, 104 + testNamePattern: 'foo', 105 + }) 106 + 107 + expect(stderr).toBe('') 108 + expect(buildTree(t => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 109 + { 110 + "a.test.js": { 111 + "foo bar": { 112 + "meta": { 113 + "ran": true, 114 + }, 115 + "state": "passed", 116 + }, 117 + "hello world": { 118 + "meta": { 119 + "ran": true, 120 + }, 121 + "state": "skipped", 122 + }, 123 + }, 124 + "b.test.js": { 125 + "another foo": { 126 + "meta": { 127 + "ran": true, 128 + }, 129 + "state": "passed", 130 + }, 131 + "unrelated": { 132 + "meta": { 133 + "ran": true, 134 + }, 135 + "state": "skipped", 136 + }, 137 + }, 138 + } 139 + `) 140 + }) 141 + 142 + test('does not execute files where all tests are filtered by testNamePattern', async () => { 143 + const { buildTree, stderr } = await runInlineTests({ 144 + 'a.test.js': ` 145 + import { test } from 'vitest' 146 + test('hello world', { meta: { ran: true } }, () => {}) 147 + `, 148 + 'b.test.js': ` 149 + import { test } from 'vitest' 150 + test('foo bar', { meta: { ran: true } }, () => {}) 151 + `, 152 + }, { 153 + experimental: { preParse: true }, 154 + testNamePattern: 'foo', 155 + }) 156 + 157 + expect(stderr).toBe('') 158 + expect(buildTree((t: TestCase) => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 159 + { 160 + "a.test.js": { 161 + "hello world": { 162 + "meta": {}, 163 + "state": "skipped", 164 + }, 165 + }, 166 + "b.test.js": { 167 + "foo bar": { 168 + "meta": { 169 + "ran": true, 170 + }, 171 + "state": "passed", 172 + }, 173 + }, 174 + } 175 + `) 176 + }) 177 + 178 + test('filters tests by tagsFilter', async () => { 179 + const { buildTree, stderr } = await runInlineTests({ 180 + 'a.test.js': ` 181 + import { test } from 'vitest' 182 + test('unit test', { tags: ['unit'], meta: { ran: true } }, () => {}) 183 + test('e2e test', { tags: ['e2e'], meta: { ran: true } }, () => {}) 184 + `, 185 + 'b.test.js': ` 186 + import { test } from 'vitest' 187 + test('another unit', { tags: ['unit'], meta: { ran: true } }, () => {}) 188 + test('integration', { tags: ['integration'], meta: { ran: true } }, () => {}) 189 + `, 190 + }, { 191 + experimental: { preParse: true }, 192 + tags: [ 193 + { name: 'unit' }, 194 + { name: 'e2e' }, 195 + { name: 'integration' }, 196 + ], 197 + tagsFilter: ['unit'], 198 + }) 199 + 200 + expect(stderr).toBe('') 201 + expect(buildTree(t => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 202 + { 203 + "a.test.js": { 204 + "e2e test": { 205 + "meta": { 206 + "ran": true, 207 + }, 208 + "state": "skipped", 209 + }, 210 + "unit test": { 211 + "meta": { 212 + "ran": true, 213 + }, 214 + "state": "passed", 215 + }, 216 + }, 217 + "b.test.js": { 218 + "another unit": { 219 + "meta": { 220 + "ran": true, 221 + }, 222 + "state": "passed", 223 + }, 224 + "integration": { 225 + "meta": { 226 + "ran": true, 227 + }, 228 + "state": "skipped", 229 + }, 230 + }, 231 + } 232 + `) 233 + }) 234 + 235 + test('does not execute files where all tests are filtered by tagsFilter', async () => { 236 + const { buildTree, stderr } = await runInlineTests({ 237 + 'a.test.js': ` 238 + import { test } from 'vitest' 239 + test('e2e test', { tags: ['e2e'], meta: { ran: true } }, () => {}) 240 + `, 241 + 'b.test.js': ` 242 + import { test } from 'vitest' 243 + test('unit test', { tags: ['unit'], meta: { ran: true } }, () => {}) 244 + `, 245 + }, { 246 + experimental: { preParse: true }, 247 + tags: [ 248 + { name: 'unit' }, 249 + { name: 'e2e' }, 250 + ], 251 + tagsFilter: ['unit'], 252 + }) 253 + 254 + expect(stderr).toBe('') 255 + expect(buildTree(t => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 256 + { 257 + "a.test.js": { 258 + "e2e test": { 259 + "meta": {}, 260 + "state": "skipped", 261 + }, 262 + }, 263 + "b.test.js": { 264 + "unit test": { 265 + "meta": { 266 + "ran": true, 267 + }, 268 + "state": "passed", 269 + }, 270 + }, 271 + } 272 + `) 273 + }) 274 + 275 + test('filters tests by testLines', async () => { 276 + const { fs, ctx, buildTree } = await runInlineTests({ 277 + // line 1: import 278 + // line 2: test 1 279 + // line 3: test 2 280 + 'a.test.js': `import { test } from 'vitest' 281 + test('test 1', { meta: { ran: true } }, () => {}) 282 + test('test 2', { meta: { ran: true } }, () => {}) 283 + `, 284 + 'b.test.js': `import { test } from 'vitest' 285 + test('test 3', { meta: { ran: true } }, () => {}) 286 + test('test 4', { meta: { ran: true } }, () => {}) 287 + `, 288 + }, { 289 + experimental: { preParse: true }, 290 + includeTaskLocation: true, 291 + standalone: true, 292 + watch: true, 293 + }) 294 + 295 + const vitest = ctx! 296 + const project = vitest.getRootProject() 297 + const specifications = [ 298 + project.createSpecification(fs.resolveFile('./a.test.js'), { testLines: [3] }), 299 + project.createSpecification(fs.resolveFile('./b.test.js')), 300 + ] 301 + 302 + await vitest.experimental_parseSpecifications(specifications) 303 + 304 + expect(buildTree(t => t.task.mode)).toMatchInlineSnapshot(` 305 + { 306 + "a.test.js": { 307 + "test 1": "skip", 308 + "test 2": "run", 309 + }, 310 + "b.test.js": { 311 + "test 3": "run", 312 + "test 4": "run", 313 + }, 314 + } 315 + `) 316 + }) 317 + 318 + test('handles describe.only across files', async () => { 319 + const { buildTree, stderr } = await runInlineTests({ 320 + 'a.test.js': ` 321 + import { describe, test } from 'vitest' 322 + describe('suite a', () => { 323 + test('test 1', { meta: { ran: true } }, () => {}) 324 + }) 325 + `, 326 + 'b.test.js': ` 327 + import { describe, test } from 'vitest' 328 + describe.only('suite b', () => { 329 + test('test 2', { meta: { ran: true } }, () => {}) 330 + }) 331 + `, 332 + }, { 333 + experimental: { preParse: true }, 334 + allowOnly: true, 335 + }) 336 + 337 + expect(stderr).toBe('') 338 + expect(buildTree((t: TestCase) => ({ state: t.result().state, meta: t.meta() }))).toMatchInlineSnapshot(` 339 + { 340 + "a.test.js": { 341 + "suite a": { 342 + "test 1": { 343 + "meta": {}, 344 + "state": "skipped", 345 + }, 346 + }, 347 + }, 348 + "b.test.js": { 349 + "suite b": { 350 + "test 2": { 351 + "meta": { 352 + "ran": true, 353 + }, 354 + "state": "passed", 355 + }, 356 + }, 357 + }, 358 + } 359 + `) 360 + })
-13
packages/vitest/src/node/ast-collect.ts
··· 8 8 calculateSuiteHash, 9 9 createTaskName, 10 10 generateHash, 11 - interpretTaskModes, 12 - someTasksAreOnly, 13 11 validateTags, 14 12 } from '@vitest/runner/utils' 15 13 import { unique } from '@vitest/utils/helpers' ··· 472 470 latestSuite.tasks.push(task) 473 471 }) 474 472 calculateSuiteHash(file) 475 - const hasOnly = someTasksAreOnly(file) 476 - interpretTaskModes( 477 - file, 478 - config.testNamePattern, 479 - undefined, 480 - undefined, 481 - undefined, 482 - hasOnly, 483 - false, 484 - config.allowOnly, 485 - ) 486 473 markDynamicTests(file.tasks) 487 474 if (!file.tasks.length) { 488 475 file.result = {
+57 -6
packages/vitest/src/node/core.ts
··· 17 17 import type { TestRunResult } from './types/tests' 18 18 import type { VCSProvider } from './vcs/vcs' 19 19 import os, { tmpdir } from 'node:os' 20 - import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils' 20 + import { createTagsFilter, getTasks, hasFailed, interpretTaskModes, limitConcurrency, someTasksAreOnly } from '@vitest/runner/utils' 21 21 import { SnapshotManager } from '@vitest/snapshot/manager' 22 22 import { deepClone, deepMerge, nanoid, toArray } from '@vitest/utils/helpers' 23 23 import { serializeValue } from '@vitest/utils/serialize' ··· 749 749 750 750 this.filenamePattern = filters && filters?.length > 0 ? filters : undefined 751 751 startSpan.setAttribute('vitest.start.filters', this.filenamePattern || []) 752 - const specifications = await this._traces.$( 752 + let specifications = await this._traces.$( 753 753 'vitest.config.resolve_include_glob', 754 754 async () => { 755 755 const specifications = await this.specifications.getRelevantTestSpecifications(filters) ··· 766 766 return specifications 767 767 }, 768 768 ) 769 + 770 + if (this.config.experimental.preParse) { 771 + // This populates specification.testModule with parsed information 772 + await this.experimental_parseSpecifications(specifications) 773 + specifications = specifications.filter(({ testModule }) => { 774 + return !testModule || testModule.task.mode !== 'skip' 775 + }) 776 + } 769 777 770 778 // if run with --changed, don't exit if no tests are found 771 779 if (!specifications.length) { ··· 1032 1040 ? os.availableParallelism() 1033 1041 : os.cpus().length) 1034 1042 const limit = limitConcurrency(concurrency) 1035 - const promises = specifications.map(specification => 1036 - limit(() => this.experimental_parseSpecification(specification)), 1037 - ) 1038 - return Promise.all(promises) 1043 + 1044 + // Phase 1: parse all files in parallel (without mode interpretation) 1045 + const results = await Promise.all(specifications.map(specification => 1046 + limit(async () => { 1047 + const file = await astCollectTests(specification.project, specification.moduleId).catch((error) => { 1048 + return createFailedFileTask(specification.project, specification.moduleId, error) 1049 + }) 1050 + return { file, specification } 1051 + }), 1052 + )) 1053 + 1054 + const tagsFilter = this.config.tagsFilter 1055 + ? createTagsFilter(this.config.tagsFilter, this.config.tags) 1056 + : undefined 1057 + // Phase 2: cross-file .only resolution 1058 + const globalHasOnly = results.some(({ file }) => someTasksAreOnly(file)) 1059 + for (const { file, specification } of results) { 1060 + const config = specification.project.config 1061 + interpretTaskModes( 1062 + file, 1063 + config.testNamePattern, 1064 + specification.testLines, 1065 + specification.testIds, 1066 + tagsFilter, 1067 + globalHasOnly, 1068 + false, 1069 + config.allowOnly, 1070 + ) 1071 + this.state.collectFiles(specification.project, [file]) 1072 + } 1073 + 1074 + return results.map(({ file }) => this.state.getReportedEntity(file) as TestModule) 1039 1075 } 1040 1076 1041 1077 public async experimental_parseSpecification(specification: TestSpecification): Promise<TestModule> { ··· 1045 1081 const file = await astCollectTests(specification.project, specification.moduleId).catch((error) => { 1046 1082 return createFailedFileTask(specification.project, specification.moduleId, error) 1047 1083 }) 1084 + const config = specification.project.config 1085 + const hasOnly = someTasksAreOnly(file) 1086 + const tagsFilter = this.config.tagsFilter 1087 + ? createTagsFilter(this.config.tagsFilter, this.config.tags) 1088 + : undefined 1089 + interpretTaskModes( 1090 + file, 1091 + config.testNamePattern, 1092 + specification.testLines, 1093 + specification.testIds, 1094 + tagsFilter, 1095 + hasOnly, 1096 + false, 1097 + config.allowOnly, 1098 + ) 1048 1099 // register in state, so it can be retrieved by "getReportedEntity" 1049 1100 this.state.collectFiles(specification.project, [file]) 1050 1101 return this.state.getReportedEntity(file) as TestModule
+3
packages/vitest/src/node/cli/cli-config.ts
··· 920 920 description: 'Custom provider for detecting changed files. (default: `git`)', 921 921 subcommands: null, 922 922 }, 923 + preParse: { 924 + description: 'Parse test specifications before running them. This will apply `.only` flag and test name pattern across all files without running them. (default: `false`)', 925 + }, 923 926 }, 924 927 }, 925 928 // disable CLI options
+6
packages/vitest/src/node/types/config.ts
··· 947 947 * implementation of the `VCSProvider` interface to use a different version control system. 948 948 */ 949 949 vcsProvider?: VCSProvider | string 950 + 951 + /** 952 + * Parse test specifications before running them. 953 + * This will apply `.only` flag and test name pattern across all files without running them. 954 + */ 955 + preParse?: boolean 950 956 } 951 957 952 958 /**