Vite+ ↔ Vitest integration

Architectural research for vitest-dev/vitest #10635 — "Research and feedback for downstream integrations"
Subject: voidzero-dev/vite-plus @ 2a42657 · bundles vitest@4.1.9
Scope: the Vite+ target only (Cloudflare & Astro are separate). Read-only research.
Vite+ is a Rust vp CLI that bundles Vite, Vitest, Oxlint, Oxfmt, Rolldown & tsdown. It does not fork or patch Vitest — it re-exports it and spawns its binary. Nearly all integration complexity defends one invariant: a single physical Vitest module instance across the runner, the test files, and the config. Most pain points below are facets of that one problem. The only programmatic Vitest API it binds to is the configureVitest plugin hook.
Rust CLI (vp) vite-plus (JS) upstream Vitest single-instance invariant Vitest warns / soft actionable / brittle

1How vp test reaches Vitest

Two parallel chains. Runtime: the CLI spawns the bundled Vitest binary. Build/types: vite-plus/test* are generated shims that re-export vitest. Both are resolved bundle-first so the spawned runner and the imported internals are the same physical copy.

Runtime path
vp testcli.rs:917 → delegate to local CLIdelegate.rs:10 → vite-plus/dist/bin.jsbin.ts:76 · NAPI run({test,…}) → test() resolverresolve-test.ts:37 → spawn vitest.mjsresolveBundled('vitest') · args forwarded verbatim
Rust execs the Vitest CLI as a child process. No Vitest Node API. run/watch/related/list/-t/--coverage pass straight through.
Build / types
vitest package.jsonexports map → syncTestPackageExports()BUNDLING.md step 4 · regenerated each build → dist/test/*.js shimsexport * from 'vitest/<subpath>' → vite-plus/test → vitest/test/node, /test/config, /test/browser…
Drop-in alias surface, auto-tracking upstream's export names. An earlier wrapper that vendored & bundled the entire Vitest dist was reverted in favor of this thin re-export (§5).
Config plugins
vite-plus defineConfigdefine-config.ts · wraps user config → vitest-resolverresolveId · pre · define-config.ts:163 auto-inline-matcherconfigResolved · define-config.ts:247 coverage-version-guardcustom · calls configureVitest (only Vitest API used) · define-config.ts:378
Injected into root and recursively into every test.projects entry (root plugins don't propagate per-project). The coverage-version-guard is the only custom logic Vite+ runs against a Vitest-specific hook — everything else is packaging/resolution.

Black-box consumption

Vitest is a bundled dep (transitive to the user) + spawned binary + re-export. Vite+ adds no runner logic — it inherits Vitest's entire CLI surface and config model.

Config lives in vite.config.ts

The test block goes in vite.config.ts; vitest.config.ts is discouraged. vp test relies on Vitest's own config discovery (no explicit --config).

2The single-instance invariant — root of most pain

Vitest splits its internals (mocks, expect, runner state, mock hoisting) if two physical copies load. Vite+ forces the whole tree onto one copy via a package-manager override, and several separate workarounds all trace back to this one root cause (marked single-instance).

The pin

vitest is pinned to the exact bundled version; the @vitest/* siblings are exact deps of vitest, so one pin cascades the whole family.

overrides:
  vite: npm:@voidzero-dev/vite-plus-core@latest
  vitest: 4.1.9
constants.ts:8 (VITE_PLUS_OVERRIDE_PACKAGES) · README.md:198
G1Coverage provider version skew only warns single-instanceactionable
@vitest/coverage-v8/-istanbul are project-installed peers Vite+ doesn't ship. On a provider/runner version mismatch, Vitest 4.1.9 only logs logger.warn and then runs mixed versions (the provider's _initialize warns, never throws), silently producing unreliable coverage. Vite+ wrote a guard to fail fast — covering both startup and the late enableCoverage() path.
define-config.ts:378–572 (vitePlusCoverageVersionGuardPlugin)
Vitest follow-up: upgrade coverage provider/runner mismatch from a warning to an error (or much louder). A downstream building its own hard guard is direct evidence the warning is insufficient.
G2pnpm hides transitive vitest/@vitest/* in browser mode single-instancepnpm
In a project depending only on vite-plus, the family is transitive; pnpm's isolated layout exposes only direct deps, so the browser-mode Vite dev server can't resolve vitest/internal/browser, @vitest/expect, etc. The vitest-resolver plugin re-resolves from Vite+'s own anchors. Must route through this.resolve (ESM conditions) — a raw require.resolve hits Vitest's CJS throw-stub for the bare vitest root.
define-config.ts:163–245 (vitePlusVitestResolverPlugin)
Vitest follow-up: document pnpm-isolated browser-mode family resolution + the require-condition throw-stub trap; consider a resolver recipe/helper.
G3expect.extend matchers register on the wrong instance single-instance
When matcher packages (@testing-library/jest-dom, @storybook/test, jest-extended) load in a different module graph than the test runtime, expect.extend() targets a different expect and expect(...).<matcher> is undefined (ref vitest #897). Vite+ auto-appends installed ones to test.server.deps.inline.
define-config.ts:247–350 (AUTO_INLINE_DEPS, vitePlusAutoInlineMatcherPlugin)
G4Browser provider .d.ts type-identity split single-instancetypespnpm
A plain export * from '@vitest/browser-playwright' d.ts makes TS resolve the provider's internal import { BrowserProvider } from 'vitest/node' through the provider's own pnpm edge — a possibly different vitest copy → two nominally distinct BrowserProvider types → provider: playwright() fails the user's typecheck. Vite+ inlines the d.ts with specifiers rewritten to one identity.
G5declare module 'vite' augmentation doesn't reach a fork types
Vitest augments vite's UserConfig with test? via declare module 'vite'. Because @voidzero-dev/vite-plus-core is a fork, the augmentation is specifier-bound and never applies — Vite+ must re-declare test?: VitestInlineConfig itself (and cast configureVitest / ResolvedConfig.test).
Vitest follow-up (docs): note that augmentations are bound to the literal 'vite' specifier, so forks/aliases must re-declare.
G6Root plugins don't propagate to test.projects
Vitest runs an independent Vite pipeline per project. Vite+ walks every project entry shape (string / object / function / Promise) and re-injects its plugins. String-glob entries can't be cloned, so they get no plugin — a known residual gap that only softens the coverage guard for hand-authored glob workspaces.
Vitest follow-up (lower priority): a supported "apply to all projects" plugin mechanism would delete this whole recursion class.

3Suggested Vitest-side follow-ups

  1. Coverage mismatch → error highMake provider/runner version skew fail (or far louder). Direct evidence: G1, a downstream built its own hard guard. Cleanest actionable item.
  2. Document the single-instance contract medSpell out the "one physical Vitest" requirement and/or add duplicate-instance detection with a clear error. Shrinks the workaround surface behind G1–G4 for every integrator.
  3. Document pnpm browser-mode resolution medCover family resolution under isolated layout + the require-condition CJS throw-stub trap (G2). Maybe a small resolver helper.
  4. Document specifier-bound augmentations lowNote that declare module 'vite' won't reach forks/aliases (G5).
  5. "Apply to all projects" plugin API lowOptional convenience that removes the per-project injection dance (G6).

None block Vite+ today — it has working (if intricate) solutions. These are the API-surface / docs gaps the integration reveals.

4v5 breaking-change impact

No v5-specific handling exists at this commit (bundles vitest@4.1.9; migration requires "Vite 8+ and Vitest 4.1+"). Vite+ is resilient to additive export changes (auto-shimmed) but brittle to renames and to reshaping the one API it binds to.

v5 change areaResilienceWhy
Additive exports entriesresilientvite-plus/test* regenerates from upstream's export map each build — new subpaths flow through automatically.
exports-map renamesbrittleSilently drops/renames vite-plus/test/* aliases, breaking user imports + the migration rewrite. Treat the export map as public contract across v4→v5.
configureVitest / coverage API reshapebrittleThe coverage guard binds to configureVitest → vitest.config.coverage, vitest.config.root, vitest.enableCoverage(). The single highest-risk v5 touchpoint.
@vitest/browser* package reorgbrittleThree providers + their two-alias surfaces are hard-coded in shim generation and the migrator's override/peer logic.
Default coverage provider changewatchGuard assumes unset provider defaults to v8; a default change mis-detects.
Export-condition layout changewatchG2's workaround assumes bare vitest require = throw-stub and ESM behind import/node.
@vitest/* sibling versioningwatchSingle-pin cascade relies on siblings being exact deps of vitest. If decoupled, the pin no longer cascades.

Bottom line: if v5 plans export renames, configureVitest/coverage reshaping, or browser-provider repackaging, Vite+ is a concrete downstream to coordinate with.

5Architecture rationale — necessary vs. packaging choice

Fact-based decomposition. The integration is three separable mechanisms. Only the first is technically forced; the re-export model is a product choice ("one dependency") that generates most of §2's resolution pain.

M1The vite override → fork redirection load-bearing
Vite+'s "vite" is the fork @voidzero-dev/vite-plus-core, aliased over the vite specifier. Vitest imports vite, so this alias must be tree-wide for Vitest to run on the fork. An overrides block therefore exists regardless of how vitest is delivered.
M2The vitest pin in the same override → tree-wide dedup single-instance
An override forces the entire tree (incl. transitive / other-workspace copies) onto one vitest; a plain devDependencies entry governs only the project's own direct dep. The two are composable — a direct dep + an override can coexist. This is what a re-export-free integration would still keep.
M3vite-plus/test* re-export + transitive vitest packaging choice
Makes vitest a non-declared (transitive) dependency so users install only vite-plus. This is the "single dependency" product shape — and the direct cause of the transitive-only resolution work (G2 resolver plugin, G4 provider .d.ts). Vite+ already drops it for browser mode, where the migrator adds a direct vitest devDependency.

Why it bites for Vitest only — the import-surface spectrum

The bundled tools fall into three tiers by user-facing import surface. Only Tier A (Vitest) is imported pervasively in user source. Vite+ never names this asymmetry — its docs frame every re-export uniformly as a "thin re-export / drop-in for vite" — yet the resolution complexity lands entirely on this one high-frequency specifier.

TierToolvite-plus surfaceUser-facing importsFrequency
A
per-file runtime
Vitestfull export * from 'vitest/*' runtime APIdescribe/it/expect/vi in test filesPer-test-file, hand-written, throughout the suite
B
config / plugin API
Vitevite-plus, /client, /types/*defineConfig, plugin types, vite/client ambientConfig + occasional plugin authoring; ambient set-once
Btsdownvite-plus/pack, /pack/client, core ./lib; family @tsdown/css·@tsdown/exepack/css config + types; build()Config / build-time (libs)
BRolldownnative binding vite-plus/binding; plugin API on coreplugin authoring (mostly via Vite)Plugin / build-time
C
config / CLI only
Oxlintvite-plus/lint = export type * — types onlynoneZero source imports
COxfmtvite-plus/fmt = single format fnnoneZero source imports
CVite Tasknone (run block)noneZero source imports

Implementation encodes the asymmetry even though the docs don't: lint re-exports oxlint types, fmt one function, test the whole runtime API — and only test needs the resolver plugin (G2) and inlined provider .d.ts (G4).

Corroboration — tsdown has the same family hazard, but it's resolvable

tsdown's CSS/exe support ships as @tsdown/css / @tsdown/exe, which hard-peer-depend on tsdown and import tsdown/internal. Against Vite+'s bundled tsdown (no resolvable top-level tsdown) a project install fails — the same shape as G2 (@vitest/* → vitest/internal/*) — and lightningcss is an unbundlable optional peer (like playwright/webdriverio).

But Vite+ resolves it completely: it bundles the extensions into core (tsdown-exe.js/tsdown-css.js) so tsdown/internal resolves at build time (PR #1919). That works because tsdown's extension set is finite, build-time, toolchain-controlled. Vitest's family is open-ended, runtime, and user-imported per-file — you can't pre-bundle every provider, nor remove the user's import … from 'vitest' — which is exactly why the same trick fails and G1–G4 exist.

History — Vite+ once vendored & bundled the entire Vitest, then reverted

The thin re-export is the second approach. Direct evidence the maximal "make Vitest fully ours" path was attempted and abandoned.

Then — reverted

@voidzero-dev/vite-plus-test

A dedicated package that vendored & bundled Vitest's dist (hybrid strategy):

  • Copy 11 @vitest/* packages verbatim (not bundled — preserve browser/Node split)
  • Rolldown-bundle leaf deps (chai, pathe, …) into dist/vendor/
  • EXTERNAL_BLOCKLIST + browser-optimizer exclude list
  • brandVitest() string-patched the cac chunk: cac("vitest") → cac("vp test")
Now — current

Thin re-export of upstream vitest

PR #1588 / 342fd2f4 deleted the wrapper. The wrapper's only structural job — redirect Vitest's vite import to the fork — "is now handled cleanly by package-manager overrides, so the bundle was dead weight that lagged upstream releases."

Tradeoff: loss of CLI rebranding — vp test now shows Vitest's native banner (vitest/<semver>). Per §2/§5 the only thing the heavy wrapper bought over the thin re-export is branding.

Why vendoring Vitest is uniquely hard: the copy-not-bundle rule existed because Rolldown shared chunks mixed Node-only and browser code and crashed; the browser/Node entry split, blocklist, and optimizer excludes all exist because Vitest is a runtime, browser/Node-split, plugin-extensible library — not a config-once CLI like Oxlint/Oxfmt (which need no vendoring at all). The history empirically confirms what the import-surface spectrum predicts.

6Key files

packages/cli/BUNDLING.mdShim / re-export build architecture (steps 3 & 4).
packages/cli/src/define-config.tsThe 3 injected plugins + coverage guard + lazyPlugins.
packages/cli/src/resolve-test.tsBundle-first Vitest binary resolution for vp test.
packages/cli/src/utils/constants.tsVITEST_VERSION, overrides, resolveBundled rationale.
packages/cli/src/bin.tsJS entry; NAPI run({...resolvers, args}).
rfcs/cli-output-polish.mdHistory of the reverted bundled-wrapper approach.
migrate · upgrade · test guidesUser-facing Vitest integration + pin/upgrade docs.

Research artifact for vitest #10635 · subject voidzero-dev/vite-plus @ 2a42657 · all claims verified against source. Line numbers are at the analyzed commit.