This document explains how @voidzero-dev/vite-plus-test bundles vitest and its dependencies.
The test package uses a hybrid bundling strategy:
- COPY all
@vitest/*packages (preserves browser/Node.js separation) - BUNDLE only leaf dependencies like
chai,pathe(reduces install size) - Separate entries (
index.jsvsindex-node.js) prevent Node.js code from loading in browsers
This approach avoids the critical issue of Rolldown creating shared chunks that mix Node.js-only code (like __vite__injectQuery) with browser code, which causes runtime crashes.
These 11 @vitest/* packages are copied (not bundled) to preserve their original file structure:
| Package | Purpose |
|---|---|
@vitest/runner |
Test runner core |
@vitest/utils |
Utilities (source-map, error, display, timers, etc.) |
@vitest/spy |
Spy/mock implementation |
@vitest/expect |
Assertion library |
@vitest/snapshot |
Snapshot testing |
@vitest/mocker |
Module mocking (node, browser, automock) |
@vitest/pretty-format |
Output formatting |
@vitest/browser |
Browser testing support |
@vitest/browser-playwright |
Playwright integration |
@vitest/browser-webdriverio |
WebdriverIO integration |
@vitest/browser-preview |
Preview (testing-library) integration |
Why copy instead of bundle? Bundling would create shared chunks that mix browser-safe and Node.js-only code. Copying preserves the original separation.
These packages are bundled using Rolldown into dist/vendor/*.mjs:
| Package | Purpose |
|---|---|
chai |
Assertion library (core of expect) |
pathe |
Path utilities |
tinyrainbow |
Terminal colors |
magic-string |
String manipulation for source maps |
estree-walker |
AST traversal |
why-is-node-running |
Debug tool for hanging processes |
These were moved from dependencies to devDependencies since they're bundled.
These remain in dependencies and are installed with the package:
| Package | Reason Not Bundled |
|---|---|
sirv |
Static file server - complex runtime behavior |
ws |
WebSocket server - native bindings |
pixelmatch |
Image comparison - optional feature |
pngjs |
PNG handling - optional feature |
es-module-lexer |
ESM parsing - small, used at runtime |
expect-type |
Type testing - small |
obug |
Debugging - small |
picomatch |
Glob matching - small |
std-env |
Environment detection - small |
tinybench |
Benchmarking - optional feature |
tinyexec |
Command execution - small |
tinyglobby |
File globbing - small |
These packages are explicitly kept external in EXTERNAL_BLOCKLIST during the Rolldown build:
| Package | Reason |
|---|---|
playwright |
Native bindings, user must install |
webdriverio |
Native bindings, user must install |
debug |
Environment detection breaks when bundled |
happy-dom |
Optional peer dependency |
jsdom |
Optional peer dependency |
@edge-runtime/vm |
Optional peer dependency |
@standard-schema/spec |
Types-only import from @vitest/expect |
msw, msw/* |
Optional peer dependency for mocking |
Additionally, these packages are added to the browser plugin's exclude list (in patchVitestBrowserPackage), which prevents Vite's optimizer from bundling them during browser tests:
| Package | Reason |
|---|---|
lightningcss |
Native bindings |
@tailwindcss/oxide |
Native bindings |
tailwindcss |
Pulls in @tailwindcss/oxide |
@vitest/browser |
Needs vendor-aliases plugin resolution |
@vitest/ui |
Optional peer dependency |
@vitest/mocker/node |
Imports @voidzero-dev/vite-plus-core (Node-only) |
This is a different mechanism than EXTERNAL_BLOCKLIST - it controls runtime optimization, not build-time bundling.
For maintainers developing the vitest/vite migration feature, here are the transformations needed.
| Original Import | Rewritten Import |
|---|---|
from "@vitest/browser-playwright" |
from "@voidzero-dev/vite-plus-test/browser-playwright" |
from "@vitest/browser-webdriverio" |
from "@voidzero-dev/vite-plus-test/browser-webdriverio" |
from "@vitest/browser-preview" |
from "@voidzero-dev/vite-plus-test/browser-preview" |
from "vite" |
from "@voidzero-dev/vite-plus-core" |
from "vite/module-runner" |
from "@voidzero-dev/vite-plus-core/module-runner" |
import('vitest') |
import('@voidzero-dev/vite-plus-test') |
Note: @voidzero-dev/vite-plus-core is the bundled version of upstream vite (Vite v8 beta). See Core Package Bundling for details on what it contains.
Note: The import('vitest') → import('@voidzero-dev/vite-plus-test') rewrite is critical for globals.d.ts, which declares global types like typeof import('vitest')['test']. Without this rewrite, vitest is not resolvable from the @voidzero-dev/vite-plus-test package context in pnpm's strict node_modules layout. TypeScript silently treats unresolved dynamic type imports as any, but oxlint's type-aware linting treats them as error types, causing no-unsafe-call errors. The rewrite turns this into a self-reference that resolves correctly via Node.js package self-referencing.
Note: When using pnpm overrides, you have three options for browser provider imports:
vitest/browser-playwright(orvitest/browser-webdriverio,vitest/browser-preview) - works whenvitestis overridden to our package (Recommended)@voidzero-dev/vite-plus-test/browser-playwright- direct import from test packagevite-plus/test/plugins/browser-playwright- direct import from CLI package
Importing from @vitest/browser-* packages directly requires additional overrides for those specific packages.
Remove these devDependencies (now bundled):
{
"devDependencies": {
"@vitest/browser": "...", // Remove
"@vitest/browser-playwright": "...", // Remove (if using playwright)
"@vitest/browser-webdriverio": "...", // Remove (if using webdriverio)
"@vitest/browser-preview": "...", // Remove (if using testing-library)
"@vitest/ui": "..." // Remove (peer dep, not bundled but optional)
}
}Add pnpm overrides:
# pnpm-workspace.yaml
overrides:
vite: 'file:path/to/vite-plus-core.tgz'
vitest: 'file:path/to/vite-plus-test.tgz'
'@vitest/browser': 'file:path/to/vite-plus-test.tgz'
'@vitest/browser-playwright': 'file:path/to/vite-plus-test.tgz'
'@vitest/browser-webdriverio': 'file:path/to/vite-plus-test.tgz'
'@vitest/browser-preview': 'file:path/to/vite-plus-test.tgz'Or using npm package names:
overrides:
vite: 'npm:@voidzero-dev/vite-plus-core'
vitest: 'npm:@voidzero-dev/vite-plus-test'
'@vitest/browser': 'npm:@voidzero-dev/vite-plus-test'
'@vitest/browser-playwright': 'npm:@voidzero-dev/vite-plus-test'
'@vitest/browser-webdriverio': 'npm:@voidzero-dev/vite-plus-test'
'@vitest/browser-preview': 'npm:@voidzero-dev/vite-plus-test'// Before (playwright)
import { playwright } from '@vitest/browser-playwright';
// After - Option 1 (Recommended): Via vitest subpath (works when vitest is overridden)
import { playwright } from 'vitest/browser-playwright';
// After - Option 2: Direct import from test package
import { playwright } from '@voidzero-dev/vite-plus-test/browser-playwright';
// After - Option 3: Direct import from CLI package
import { playwright } from 'vite-plus/test/plugins/browser-playwright';Similarly for WebdriverIO:
import { webdriverio } from 'vitest/browser-webdriverio';And for Preview (testing-library):
import { preview } from 'vitest/browser-preview';The package provides ./plugins/* exports to enable pnpm overrides for all @vitest/* packages:
@vitest/runner -> @voidzero-dev/vite-plus-test/plugins/runner
@vitest/utils -> @voidzero-dev/vite-plus-test/plugins/utils
@vitest/utils/error -> @voidzero-dev/vite-plus-test/plugins/utils-error
@vitest/spy -> @voidzero-dev/vite-plus-test/plugins/spy
@vitest/expect -> @voidzero-dev/vite-plus-test/plugins/expect
@vitest/snapshot -> @voidzero-dev/vite-plus-test/plugins/snapshot
@vitest/mocker -> @voidzero-dev/vite-plus-test/plugins/mocker
@vitest/pretty-format -> @voidzero-dev/vite-plus-test/plugins/pretty-format
@vitest/browser -> @voidzero-dev/vite-plus-test/plugins/browser
@vitest/browser-playwright -> @voidzero-dev/vite-plus-test/plugins/browser-playwright
@vitest/browser-webdriverio -> @voidzero-dev/vite-plus-test/plugins/browser-webdriverio
@vitest/browser-preview -> @voidzero-dev/vite-plus-test/plugins/browser-preview
The build script includes validateExternalDeps() which:
- Scans all bundled JS files using
oxc-parser - Extracts all external import specifiers
- Verifies every external dependency is declared in
dependenciesorpeerDependencies - Reports undeclared externals that would fail at runtime
If this validation fails, the build will report which packages need to be added.
| Test Type | What It Validates |
|---|---|
Snap tests (packages/cli/snap-tests/vitest-browser-mode/) |
Browser mode works after bundling |
Ecosystem CI (ecosystem-ci/) |
Real-world projects work with bundled vitest |
| CI workflows | Multi-platform validation (Ubuntu, Windows, macOS) |
When upgrading the vitest version:
-
Update version in
packages/test/package.json:{ "devDependencies": { "vitest-dev": "^NEW_VERSION", "@vitest/runner": "NEW_VERSION", "@vitest/utils": "NEW_VERSION" // ... all @vitest/* packages } } -
Run build:
pnpm -C packages/test build
-
Check for new externals: If
validateExternalDeps()reports new undeclared dependencies:- Add to
dependenciesif it should be installed at runtime - Add to
EXTERNAL_BLOCKLISTif it should remain external (native bindings, optional) - If it's a new leaf dep, it will be automatically bundled
- Add to
-
Run tests:
pnpm test -
Run ecosystem CI:
pnpm -C ecosystem-ci test
| Issue | Cause | Solution |
|---|---|---|
| New undeclared external | New vitest dependency | Add to dependencies or EXTERNAL_BLOCKLIST |
| Browser test crashes | Node.js code leaked to browser | Check import rewriting in rewriteVitestImports() |
| Missing export | New @vitest/* subpath export | Add to VITEST_PACKAGE_TO_PATH |
| pnpm override fails | New plugin export needed | Add to createPluginExports() |
1. bundleVitest() Copy vitest-dev dist/ -> dist/
2. copyVitestPackages() Copy @vitest/* -> dist/@vitest/
3. convertTabsToSpaces() Normalize formatting for patches
4. collectLeafDependencies() Parse imports with oxc-parser
5. bundleLeafDeps() Bundle chai, pathe, etc -> dist/vendor/
6. rewriteVitestImports() Rewrite @vitest/*, vitest/*, vite
7. patchVitestPkgRootPaths() Fix distRoot for relocated files
8. patchVitestBrowserPackage() Inject vendor-aliases plugin
9. patchBrowserProviderLocators() Fix browser-safe imports
10. Post-processing:
- patchVendorPaths()
- createBrowserCompatShim()
- createModuleRunnerStub() Browser-safe stub
- createNodeEntry() index-node.js with browser-provider
- copyBrowserClientFiles()
- createBrowserEntryFiles() browser/ entry files at package root
- createPluginExports() dist/plugins/* for pnpm overrides
- mergePackageJson()
- validateExternalDeps()
browser/ # Entry files for ./browser export
├── context.js # Runtime guard (throws if not in browser)
└── context.d.ts # Re-exports from dist/@vitest/browser/context.d.ts
dist/
├── @vitest/ # Copied packages (browser/Node.js safe)
│ ├── runner/
│ ├── utils/
│ ├── spy/
│ ├── expect/
│ ├── snapshot/
│ ├── mocker/
│ ├── pretty-format/
│ ├── browser/
│ └── browser-playwright/
├── vendor/ # Bundled leaf dependencies
│ ├── chai.mjs
│ ├── pathe.mjs
│ ├── tinyrainbow.mjs
│ ├── magic-string.mjs
│ ├── estree-walker.mjs
│ ├── why-is-node-running.mjs
│ └── vitest_*.mjs # Browser stubs
├── plugins/ # Shims for pnpm overrides
│ ├── runner.mjs
│ ├── utils.mjs
│ └── ... (33+ files)
├── chunks/ # Vitest core chunks
├── client/ # Browser client files
├── index.js # Browser-safe entry
├── index-node.js # Node.js entry (includes browser-provider)
├── module-runner-stub.js # Browser-safe module-runner
└── browser-compat.js # @vitest/browser compatibility shim
The critical design decision is maintaining separation between browser and Node.js code:
| Entry Point | Used By | Contains |
|---|---|---|
dist/index.js |
Browser tests | No Node.js-only code |
dist/index-node.js |
Node.js (config, CLI) | Includes browser-provider exports |
This is achieved through:
- Conditional exports in package.json (
"node": "./dist/index-node.js") - Browser-safe stubs for
module-runner - Import rewriting to prevent Node.js code from being pulled into browser bundles
vendor-aliasesplugin injection to resolve imports at runtime:- Handles
@vitest/*imports → resolves to copieddist/@vitest/files - Handles
vitest/*subpaths → resolves to dist files (enablesvitest/browser-playwrightusage) - Handles
vitest/browser-playwright,vitest/browser-webdriverio,vitest/browser-preview→ resolves to bundled browser providers - Handles
@voidzero-dev/vite-plus-test/*subpaths → maps to equivalent vitest paths - Handles
vite-plus/test/*subpaths → maps to equivalent vitest paths (CLI package) - Intercepts
vitest/browser,@voidzero-dev/vite-plus-test/browser,vite-plus/test/browser→ returns virtual module ID for BrowserContext plugin
- Handles
// Packages copied to dist/@vitest/
const VITEST_PACKAGES_TO_COPY = [
'@vitest/runner',
'@vitest/utils',
'@vitest/spy',
'@vitest/expect',
'@vitest/snapshot',
'@vitest/mocker',
'@vitest/pretty-format',
'@vitest/browser',
'@vitest/browser-playwright',
'@vitest/browser-webdriverio',
'@vitest/browser-preview',
];
// Packages that must NOT be bundled (from build.ts lines 131-158)
const EXTERNAL_BLOCKLIST = new Set([
// Our own packages - resolved at runtime
'@voidzero-dev/vite-plus-core',
'@voidzero-dev/vite-plus-core/module-runner',
'vite',
'vitest',
// Peer dependencies - consumers must provide these
'@edge-runtime/vm',
'@opentelemetry/api',
'@standard-schema/spec', // Types-only import from @vitest/expect
'happy-dom',
'jsdom',
// Optional dependencies with bundling issues or native bindings
'debug', // environment detection broken when bundled
'playwright', // native bindings
'webdriverio', // native bindings
// Runtime deps (in package.json dependencies) - not bundled, resolved at install time
'sirv',
'ws',
'pixelmatch',
'pngjs',
// MSW (Mock Service Worker) - optional peer dep of @vitest/mocker
'msw',
'msw/browser',
'msw/core/http',
]);