Vitest · PR #10554

Separate Config Resolution from Server Creation

Reworking resolveConfig so the full config is resolved before any Vite server exists — and introducing a PluginHarness to give plugins access to the Vitest instance after resolution.

Config resolution PluginHarness Pain point / change Vite server = one Vite server instance (count the tokens)
BEFORE

main

createVitest(options, viteOverrides, vitestOptions)
Single entry. Config and server are entangled inside one flow.
↓
new Vitest(cliOptions, options) runtime
Instance built from raw CLI options. Creates its own Logger & packageInstaller internally. Config not resolved yet.
↓
createServer() → resolveConfig server-first
A Vite dev server is stood up first; the test config is resolved against the live server. You can't get a resolved config without a server.
Vite server created here
↓
vitest._setServer(options, server) coupled
Server handed back into the instance; projects resolved here too.
↓
Lazy getters w/ assert() guarded
config / vite / state / snapshot / cache are getters that throw "not set yet" until the server step runs.
core.ts — get config() / get vite() …
Limitations (the issues this PR fixes): no way to fully resolve config without booting a server (#6912), and plugins have no clean handle to the Vitest instance once config is ready (#8572).
AFTER

feat/refactor-config-resolution

createVitest(options, viteOverrides, vitestOptions)
Same public entry, but now orchestrates 3 explicit, ordered phases.
node/create.ts
↓
1 · new PluginHarness(logger, packageInstaller) harness
Owns logger, packageInstaller, version and a late-bound vitest ref. Threaded into every config-time plugin so they can call getVitest() after resolution.
node/config/pluginHarness.ts
↓
2 · resolveConfig(options, viteOverrides, harness) config-first
Fully resolves config with no running server. Runs Vite's resolveConfig through pre-plugins, then resolves test config + all projects, returning a ResolvedViteConfig.
node/config/resolveConfig.ts
CliOverride · VitestConfig
pre-plugins apply CLI & core config
resolveTestConfig
single-config resolver (extracted)
BrowserLoaderPlugin
browser contribution / single server
resolveProjectEntries
root + projects + benchmark expansion
↓
new Vitest(harness, viteConfig) runtime
Constructed from an already-resolved config. config, viteConfig, logger set immediately as real fields (no more lazy getters / assert).
node/core.ts — constructor(harness, viteConfig)
↓
3 · await vitest._start(config) server
Server created from the resolved config:
_setRootConfig → _attachRootServer → _attachProjectServers. On failure, close() cleans everything up.
Vite server created here
node/core.ts — _start()
Result: config is now a standalone, server-independent artifact (resolve once, create server later), and the harness gives plugins a stable hook to the Vitest instance post-resolution.

Highlight: the project's own Vite server is reused as the browser server

Sharing one browser server across a project's instances (chromium/firefox/webkit) was already true before — that's not what changed. The new layer is at the project level: a browser project used to run two Vite servers — its own node vite server plus a separate browser server (_parentBrowser.vite, created lazily by _initBrowserServer). This PR collapses them into one server per project.

BEFORE

two servers per browser project

project "app" (browser enabled)
Vite server node · project.vite Vite server browser · _parentBrowser.vite
2 server instances
instances chromium · firefox · webkit  →  already shared the browser server (unchanged)
Two distinct ViteDevServers coexist (the browser one spun up lazily by _initBrowserServer): teardown & cache logic had to walk both vite.environments and browser?.vite.environments.
AFTER

one server per project

project "app" (browser enabled)
Vite server node + browser · project.vite === project.browser.vite
1 server instance  ·  built once by createClusterServer
instances chromium · firefox · webkit  →  still share it via _parentBrowser.spawn() (unchanged)
Server attach dedupes by ResolvedViteConfig identity (Map<viteConfig, TestProject>): the primary owns the one server; root/benchmark siblings that resolve to the same config reuse it instead of creating their own.
Why it's possible now: with config fully resolved up front, server creation is a separate phase that can collapse the node + browser servers into one. applyBrowserOptimizeDeps also aggregates optimizeDeps for that single server before it's created.
core.ts — _attachRootServer / _attachProjectServers · browserLoader.ts — createClusterServer · resolveProjects.ts — attachProjectsFromEntries (dedupe by viteConfig)

Key differences at a glance

AspectBeforeAfter
Ordering Server created first, then config resolved against it Config fully resolved first, server created from it
Vitest constructor (cliOptions, options) + deprecated (mode, …) overloads (harness, viteConfig) — takes resolved config
State fields Lazy getters guarded by assert() ("not set yet") Plain fields set during construction / _start
Plugin → Vitest access No clean handle after config resolves PluginHarness with late-bound getVitest()
Config resolution Inline, coupled to _setServer Standalone resolveConfig() → ResolvedViteConfig
Browser project servers Two servers: node project.vite + a separate _parentBrowser.vite One server reused for both (project.vite === project.browser.vite)
Server reuse keying Ad-hoc: root by config-file path; instances/bench copy parent._vite Unified dedupe by resolved viteConfig identity (primary owns / siblings reuse)
Public API — Exports resolveConfig, resolveApiServerConfig, PluginHarness
Fixes #6912 (resolve config without a server) · #8572 (plugin access to vitest)