From CLI to Config: How Vitest Boots and Resolves Configuration
Prerequisites
- ›Article 1: Architecture and Project Layout
- ›Basic understanding of Vite's plugin hook system (config, configResolved)
- ›Familiarity with CLI argument parsing concepts
From CLI to Config: How Vitest Boots and Resolves Configuration
When you type vitest in your terminal, a precisely orchestrated sequence begins. The binary entry loads compiled JavaScript. A CLI parser built on cac interprets your arguments. A config file is discovered. A Vite dev server is created with Vitest's plugin injected. That plugin's hooks fire — first config(), then configResolved(), then configureServer() — and by the end, the Vitest class is fully initialized with resolved config, projects, and reporters.
Understanding this pipeline is essential. Whether you're debugging why a config value isn't applying, building IDE integration with the programmatic API, or extending Vitest with custom plugins, you need to know exactly where in this chain your code participates.
The Binary Entry and CLI Parsing
The journey starts at packages/vitest/vitest.mjs — a two-line file:
#!/usr/bin/env node
import './dist/cli.js'
The compiled dist/cli.js originates from packages/vitest/src/node/cli.ts, which is equally minimal:
import { createCLI } from './cli/cac'
createCLI().parse()
The actual CLI construction lives in packages/vitest/src/node/cli/cac.ts. The createCLI() function creates a cac instance, registers all CLI options from a typed configuration object, and defines the available commands:
packages/vitest/src/node/cli/cac.ts#L166-L206
flowchart TD
BIN["vitest.mjs"] --> CLI["createCLI().parse()"]
CLI --> CMD{Command?}
CMD -- "run [...filters]" --> run["start('test', filters, {run: true})"]
CMD -- "watch [...filters]" --> watch["start('test', filters, {watch: true})"]
CMD -- "dev [...filters]" --> watch
CMD -- "bench [...filters]" --> bench["start('benchmark', filters)"]
CMD -- "list [...filters]" --> collect["collect('test', filters)"]
CMD -- "init <project>" --> init["init project scaffolding"]
CMD -- "[...filters] (default)" --> default_cmd["start('test', filters)"]
run & watch & default_cmd --> startVitest
The default command (no subcommand) routes to start('test', ...), which resolves the watch mode based on environment: if isCI is true or stdin isn't a TTY, watch mode is disabled. The run command forces options.run = true to explicitly disable watch mode.
Tip: The
parseCLI()function exported fromvitest/nodelets you parse Vitest CLI strings programmatically — useful for building tools that need to understand Vitest invocations without actually running them.
startVitest() — The Programmatic Entry Point
Every CLI command ultimately calls startVitest() from packages/vitest/src/node/cli/cli-api.ts#L56-L62. This function serves double duty — it's the CLI's backend and the public programmatic API exported from vitest/node.
sequenceDiagram
participant CLI as CLI / User Code
participant SV as startVitest()
participant CV as createVitest()
participant Vite as Vite Dev Server
participant Vitest as Vitest Instance
CLI->>SV: startVitest(mode, filters, options)
SV->>CV: createVitest(mode, options, overrides)
CV->>Vite: createViteServer(config + VitestPlugin)
Vite-->>Vitest: configureServer() → _setServer()
CV-->>SV: return Vitest instance
SV->>SV: Ensure coverage packages installed
SV->>SV: Register console shortcuts (if TTY + watch)
SV->>Vitest: ctx.start(cliFilters)
After creating the Vitest instance, startVitest() handles several special modes — listing tags, clearing cache, merging reports, standalone mode — before falling through to the normal ctx.start(cliFilters) path. It also ensures coverage provider packages are installed (prompting the user if needed) and sets up keyboard shortcuts for watch mode.
The function also registers an onAfterSetServer callback so that if the Vite server restarts (due to config changes), test execution resumes automatically.
Config File Discovery and Vite Server Creation
The createVitest() function in packages/vitest/src/node/create.ts is where the Vitest instance meets the Vite server:
flowchart TD
CV["createVitest()"] --> NewCtx["new Vitest(mode, options)"]
CV --> FindConfig{"Config path?"}
FindConfig -- "options.config = false" --> NoConfig["No config file"]
FindConfig -- "options.config = path" --> Resolve["resolveModule(path)"]
FindConfig -- "none specified" --> Discover["find.any(configFiles, {cwd: root})"]
Discover --> Names["CONFIG_NAMES × CONFIG_EXTENSIONS"]
Names --> |"vitest.config.ts<br>vitest.config.mts<br>vitest.config.cts<br>vitest.config.js<br>...<br>vite.config.ts<br>..."| FirstMatch["First match wins"]
NoConfig & Resolve & FirstMatch --> CreateVite["createViteServer({<br> configFile,<br> mode,<br> plugins: VitestPlugin<br>})"]
CreateVite --> Server["Vite Dev Server ready"]
Config file discovery uses empathic/find to locate the first matching file from a list defined in packages/vitest/src/constants.ts#L8-L14:
export const CONFIG_NAMES: string[] = ['vitest.config', 'vite.config']
export const CONFIG_EXTENSIONS: string[] = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
export const configFiles: string[] = CONFIG_NAMES.flatMap(name =>
CONFIG_EXTENSIONS.map(ext => name + ext),
)
This produces 12 candidates: vitest.config.ts, vitest.config.mts, ..., vite.config.cjs. Vitest-specific config files take priority over Vite config files. The Vite server is created with VitestPlugin injected as the plugins array, along with the config file path and a mode of 'test' or 'benchmark'.
The VitestPlugin Hook Lifecycle
As we saw in Part 1, VitestPlugin returns an array of Vite plugins. The core plugin's hooks fire in this order during Vite server creation:
packages/vitest/src/node/plugins/index.ts#L44-L59 — The config() hook performs a preliminary merge:
const testConfig = deepMerge(
{} as UserConfig,
configDefaults,
removeUndefinedValues(viteConfig.test ?? {}),
options,
)
The merge order is critical: configDefaults first, then the test property from your Vite config, then CLI options. CLI options win. The hook also strips define values from Vite's config (storing them separately for worker injection), disables HMR, and configures the server for testing.
The configResolved() hook at line 212 performs the final merge — this time with all other Vite plugins' modifications applied. It stashes the final config on the Vite config object via a non-enumerable _vitest property.
Finally, configureServer() at line 262 triggers the actual Vitest initialization:
configureServer: {
async handler(server) {
await vitest._setServer(options, server)
if (options.api && options.watch) {
(await import('../../api/setup')).setup(vitest)
}
if (!options.watch) {
await server.watcher.close()
}
},
},
Config Resolution and Defaults
The resolveConfig() function in packages/vitest/src/node/config/resolveConfig.ts produces a ResolvedConfig from the user config and Vite's resolved config. It normalizes paths, resolves sequencers, validates option combinations, and applies environment-specific defaults.
The default values in packages/vitest/src/defaults.ts#L63-L139 reveal Vitest's philosophy:
export const configDefaults = Object.freeze({
allowOnly: !isCI,
isolate: true,
watch: !isCI && process.stdin.isTTY && !isAgent,
globals: false,
environment: 'node',
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
exclude: ['**/node_modules/**', '**/.git/**'],
teardownTimeout: 10000,
maxConcurrency: 5,
slowTestThreshold: 300,
// ...
})
Notable defaults: isolate: true means each test file runs in a fresh module context. watch mode activates only when not in CI, stdin is a TTY, and there's no agent environment. globals: false means describe/test/expect are not injected into the global scope by default — you import them explicitly.
The default include pattern **/*.{test,spec}.?(c|m)[jt]s?(x) matches files like foo.test.ts, bar.spec.mjs, and baz.test.cjsx. The default exclude always filters node_modules and .git.
Tip: When diagnosing "why doesn't Vitest pick up my test files," check the
includepattern first. The default requires.test.or.spec.in the filename — a common source of confusion for newcomers expecting directory-based discovery.
Workspace and Multi-Project Resolution
After _setServer() resolves the base config, it triggers project resolution. The resolveProjects() function processes workspace definitions through three paths:
flowchart TD
Def["Workspace definitions"] --> Type{Type?}
Type -- "string (static path)" --> Static["resolve & stat"]
Static --> IsFile{File?}
IsFile -- "yes" --> ValidName{"Matches config pattern?"}
ValidName -- "yes" --> ConfigFiles["Add to config files"]
IsFile -- "no (directory)" --> ScanDir["Scan for config file"]
ScanDir -- "found" --> ConfigFiles
ScanDir -- "not found" --> NonConfig["Non-config directory project"]
Type -- "string (glob)" --> Glob["glob() match"]
Glob --> ConfigFiles & NonConfig
Type -- "object / function" --> Inline["Inline project config"]
ConfigFiles --> Init["initializeProject()"]
NonConfig --> Init
Inline --> Init
Init --> Unique{"Names unique?"}
Unique -- "yes" --> Browser["resolveBrowserProjects()"]
Unique -- "no" --> Error["Throw duplicate name error"]
Each project gets initialized with initializeProject(), which creates a new TestProject, sets up its own Vite server with the WorkspaceVitestPlugin, and resolves its config independently. CLI overrides for options like --pool, --globals, and --bail are propagated to all projects.
The resolution runs concurrently using limitConcurrency() based on the number of available CPU cores. Failed projects are collected and reported as an AggregateError, rather than failing on the first error.
Config Serialization for Workers
Once config is resolved on the Node side, it needs to cross process boundaries to reach worker threads. The serializeConfig() function in packages/vitest/src/node/config/serializeConfig.ts produces a SerializedConfig — a plain object stripped of functions, class instances, and anything that can't survive structuredClone() or JSON serialization.
flowchart LR
RC[ResolvedConfig] --> SC["serializeConfig()"]
SC --> Stripped["SerializedConfig<br>(plain object)"]
Stripped -- "MessagePort / process.send" --> Worker["Worker Process"]
SC -.- Note["Strips:<br>- Functions<br>- Class instances<br>- Server references<br>- Plugin arrays<br><br>Keeps:<br>- Scalar values<br>- File paths<br>- Pattern strings<br>- Timeout values"]
The function cherry-picks fields individually rather than doing a generic "strip non-serializable" pass. This is deliberate — it ensures that adding a new config field requires explicitly deciding whether workers need it. The coverage config is reduced to just reportsDirectory, provider, enabled, and customProviderModule. The deps config loses its full optimizer configuration, keeping only the enabled flag.
packages/vitest/src/node/config/serializeConfig.ts#L6-L62
This serialized config is what the test runner inside workers operates on. It's a subset of ResolvedConfig — enough to run tests, but not enough to modify the Vite server or manage reporters.
What's Next
With the boot sequence and configuration pipeline understood, we're ready to explore what happens when tests actually execute. In the next article, we'll dive into Vitest's three-layer pool architecture — Pool, PoolRunner, and PoolWorker — the birpc communication bridge, how workers boot and set up environments, and how test results flow back through StateManager and TestRun to reporters.