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.
vp)
vite-plus (JS)
upstream Vitest
single-instance invariant
Vitest warns / soft
actionable / brittle
vp test reaches VitestTwo 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.
run/watch/related/list/-t/--coverage pass straight through.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.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.
vite.config.tsThe test block goes in vite.config.ts; vitest.config.ts is discouraged. vp test relies on Vitest's own config discovery (no explicit --config).
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).
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
@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.
vitest/@vitest/* in browser mode
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.
require-condition throw-stub trap; consider a resolver recipe/helper.expect.extend matchers register on the wrong instance
@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.
.d.ts type-identity split
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.
declare module 'vite' augmentation doesn't reach a fork
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).
'vite' specifier, so forks/aliases must re-declare.test.projects
require-condition CJS throw-stub trap (G2). Maybe a small resolver helper.declare module 'vite' won't reach forks/aliases (G5).None block Vite+ today — it has working (if intricate) solutions. These are the API-surface / docs gaps the integration reveals.
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 area | Resilience | Why |
|---|---|---|
Additive exports entries | resilient | vite-plus/test* regenerates from upstream's export map each build — new subpaths flow through automatically. |
exports-map renames | brittle | Silently 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 reshape | brittle | The coverage guard binds to configureVitest → vitest.config.coverage, vitest.config.root, vitest.enableCoverage(). The single highest-risk v5 touchpoint. |
@vitest/browser* package reorg | brittle | Three providers + their two-alias surfaces are hard-coded in shim generation and the migrator's override/peer logic. |
| Default coverage provider change | watch | Guard assumes unset provider defaults to v8; a default change mis-detects. |
| Export-condition layout change | watch | G2's workaround assumes bare vitest require = throw-stub and ESM behind import/node. |
@vitest/* sibling versioning | watch | Single-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.
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.
vite override → fork redirection
@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.
vitest pin in the same override → tree-wide dedup
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.
vite-plus/test* re-export + transitive vitest
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.
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.
| Tier | Tool | vite-plus surface | User-facing imports | Frequency |
|---|---|---|---|---|
| A per-file runtime | Vitest | full export * from 'vitest/*' runtime API | describe/it/expect/vi in test files | Per-test-file, hand-written, throughout the suite |
| B config / plugin API | Vite | vite-plus, /client, /types/* | defineConfig, plugin types, vite/client ambient | Config + occasional plugin authoring; ambient set-once |
| B | tsdown | vite-plus/pack, /pack/client, core ./lib; family @tsdown/css·@tsdown/exe | pack/css config + types; build() | Config / build-time (libs) |
| B | Rolldown | native binding vite-plus/binding; plugin API on core | plugin authoring (mostly via Vite) | Plugin / build-time |
| C config / CLI only | Oxlint | vite-plus/lint = export type * — types only | none | Zero source imports |
| C | Oxfmt | vite-plus/fmt = single format fn | none | Zero source imports |
| C | Vite Task | none (run block) | none | Zero 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).
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.
The thin re-export is the second approach. Direct evidence the maximal "make Vitest fully ours" path was attempted and abandoned.
@voidzero-dev/vite-plus-testA dedicated package that vendored & bundled Vitest's dist (hybrid strategy):
@vitest/* packages verbatim (not bundled — preserve browser/Node split)chai, pathe, …) into dist/vendor/EXTERNAL_BLOCKLIST + browser-optimizer exclude listbrandVitest() string-patched the cac chunk: cac("vitest") → cac("vp test")vitestPR #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.
vp test.run({...resolvers, args}).Research artifact for vitest #10635 · subject voidzero-dev/vite-plus @ 2a42657 · all claims verified against source. Line numbers are at the analyzed commit.