Skip to content

Commit 7827363

Browse files
authored
feat: add experimental.preParse flag (#10070)
1 parent 1af865e commit 7827363

File tree

8 files changed

+469
-21
lines changed

8 files changed

+469
-21
lines changed

docs/config/experimental.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,36 @@ export default {
479479
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`.
480480

481481
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+
:::

docs/guide/cli-generated.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,3 +963,10 @@ Controls whether Vitest will use Node.js Loader API to process in-source or mock
963963
- **Config:** [experimental.vcsProvider](/config/experimental#experimental-vcsprovider)
964964

965965
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`)

packages/vitest/src/node/ast-collect.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import {
88
calculateSuiteHash,
99
createTaskName,
1010
generateHash,
11-
interpretTaskModes,
12-
someTasksAreOnly,
1311
validateTags,
1412
} from '@vitest/runner/utils'
1513
import { unique } from '@vitest/utils/helpers'
@@ -472,17 +470,6 @@ function createFileTask(
472470
latestSuite.tasks.push(task)
473471
})
474472
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-
)
486473
markDynamicTests(file.tasks)
487474
if (!file.tasks.length) {
488475
file.result = {

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,9 @@ export const cliOptionsConfig: VitestCLIOptions = {
920920
description: 'Custom provider for detecting changed files. (default: `git`)',
921921
subcommands: null,
922922
},
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+
},
923926
},
924927
},
925928
// disable CLI options

packages/vitest/src/node/core.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { Reporter } from './types/reporter'
1717
import type { TestRunResult } from './types/tests'
1818
import type { VCSProvider } from './vcs/vcs'
1919
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'
2121
import { SnapshotManager } from '@vitest/snapshot/manager'
2222
import { deepClone, deepMerge, nanoid, toArray } from '@vitest/utils/helpers'
2323
import { serializeValue } from '@vitest/utils/serialize'
@@ -749,7 +749,7 @@ export class Vitest {
749749

750750
this.filenamePattern = filters && filters?.length > 0 ? filters : undefined
751751
startSpan.setAttribute('vitest.start.filters', this.filenamePattern || [])
752-
const specifications = await this._traces.$(
752+
let specifications = await this._traces.$(
753753
'vitest.config.resolve_include_glob',
754754
async () => {
755755
const specifications = await this.specifications.getRelevantTestSpecifications(filters)
@@ -767,6 +767,14 @@ export class Vitest {
767767
},
768768
)
769769

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+
}
777+
770778
// if run with --changed, don't exit if no tests are found
771779
if (!specifications.length) {
772780
await this._traces.$('vitest.test_run', async () => {
@@ -1032,10 +1040,38 @@ export class Vitest {
10321040
? os.availableParallelism()
10331041
: os.cpus().length)
10341042
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)
10391075
}
10401076

10411077
public async experimental_parseSpecification(specification: TestSpecification): Promise<TestModule> {
@@ -1045,6 +1081,21 @@ export class Vitest {
10451081
const file = await astCollectTests(specification.project, specification.moduleId).catch((error) => {
10461082
return createFailedFileTask(specification.project, specification.moduleId, error)
10471083
})
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+
)
10481099
// register in state, so it can be retrieved by "getReportedEntity"
10491100
this.state.collectFiles(specification.project, [file])
10501101
return this.state.getReportedEntity(file) as TestModule

packages/vitest/src/node/types/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,12 @@ export interface InlineConfig {
947947
* implementation of the `VCSProvider` interface to use a different version control system.
948948
*/
949949
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
950956
}
951957

952958
/**

0 commit comments

Comments
 (0)