Skip to content

Commit 15afbc0

Browse files
authored
fix(test): auto-inline packages that use expect.extend() to fix module instance splitting (#1113)
## Summary Fixes `expect.extend()` matchers from third-party packages (e.g., `@testing-library/jest-dom`) not being registered on the test runner's `expect` instance, causing errors like `Invalid Chai property: toBeInTheDocument`. resolve #897 ## Root Cause When third-party packages call `require('vitest').expect.extend(matchers)` internally, the npm override (`vitest` → `@voidzero-dev/vite-plus-test`) causes Node.js to load a **separate module instance** with its own `chai` and `expect`. Matchers are registered on this separate instance, not on the test runner's `expect`. The root issue is the **externalization behavior** of vitest's module runner. By default, third-party packages in `node_modules` are externalized — loaded via Node.js native `require`/`import`. When an externalized package calls `require('vitest')`, Node.js resolves it through the npm override, producing a different module instance with a separate `chai`. ## Fix Patch vitest's `ModuleRunnerTransform` plugin during re-packaging (`build.ts`) to automatically add known affected packages to `server.deps.inline` in the `configResolved` hook. This forces the Vite module runner to process these packages through its transform pipeline instead of externalizing them to Node.js. ### Normal flow (plain vitest — no npm override) ```mermaid sequenceDiagram participant Runner as Test Runner participant JestDOM as @testing-library/jest-dom participant NodeJS as Node.js require() participant Vitest as vitest module Runner->>Vitest: import vitest (cached by Node.js) Runner->>Runner: createExpect() → globalExpect Note over JestDOM: setupFiles execution JestDOM->>NodeJS: require('vitest') NodeJS-->>JestDOM: Same vitest module (cached) JestDOM->>Vitest: expect.extend(matchers) Note over Vitest: OK: Matchers registered on<br/>the same chai instance Runner->>Runner: expect(el).toBeInTheDocument() OK: ``` ### Bug flow (vite-plus — npm override splits module instances) ```mermaid sequenceDiagram participant Runner as Test Runner participant JestDOM as @testing-library/jest-dom participant NodeJS as Node.js require() participant VitestA as vitest (runner instance) participant VitestB as @voidzero-dev/vite-plus-test<br/>(override instance) Runner->>VitestA: import vitest Runner->>Runner: createExpect() with chai-A Note over JestDOM: setupFiles execution (externalized by default) JestDOM->>NodeJS: require('vitest') NodeJS->>NodeJS: npm override rewrites to<br/>@voidzero-dev/vite-plus-test NodeJS-->>JestDOM: Different module instance JestDOM->>VitestB: expect.extend(matchers) Note over VitestB: Matchers registered on chai-B Runner->>Runner: expect(el).toBeInTheDocument() Note over Runner: NG: chai-A has no matchers<br/>Invalid Chai property ``` ### Fix flow (with `server.deps.inline` patch) ```mermaid sequenceDiagram participant Runner as Test Runner participant JestDOM as @testing-library/jest-dom participant ModRunner as Vite Module Runner participant Resolve as vitest:resolve-core participant Vitest as vitest (single instance) Runner->>Vitest: import vitest (cached) Runner->>Runner: createExpect() with chai Note over JestDOM: setupFiles execution (inlined by patch) JestDOM->>ModRunner: require('vitest') ModRunner->>ModRunner: Transform to __vite_ssr_import__ ModRunner->>Resolve: resolve 'vitest' Resolve-->>ModRunner: dist/index.js (same file) ModRunner-->>JestDOM: Same module instance (cached) JestDOM->>Vitest: expect.extend(matchers) Note over Vitest: OK: Matchers registered on<br/>the same chai instance Runner->>Runner: expect(el).toBeInTheDocument() OK: ``` ### Auto-inlined packages - [testing-library/jest-dom](https://github.com/testing-library/jest-dom): 17.7M/weekly download, Calls `require('vitest').expect.extend(matchers)` via `./vitest` subpath - [storybookjs/storybook](https://github.com/storybookjs/storybook): 2M/weekly download, Uses `@vitest/expect` internally with `expect.extend()` - [jest-community/jest-extended](https://github.com/jest-community/jest-extended): 958K/weekly download, Calls `expect.extend()` on import These three packages cover **99.5%** of weekly downloads among all affected packages. The list was determined by downloading npm tarballs of all known `expect.extend` using packages and inspecting their source code.
1 parent 34caff3 commit 15afbc0

4 files changed

Lines changed: 127 additions & 3 deletions

File tree

.github/workflows/e2e-test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,10 @@ jobs:
285285
vp run lint
286286
vp run test:types
287287
vp test --project unit
288+
- name: vite-plus-jest-dom-repro
289+
node-version: 24
290+
command: |
291+
vp test run
288292
exclude:
289293
# frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows
290294
- os: windows-latest

ecosystem-ci/repo.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,11 @@
9191
"branch": "main",
9292
"hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d",
9393
"forceFreshMigration": true
94+
},
95+
"vite-plus-jest-dom-repro": {
96+
"repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git",
97+
"branch": "master",
98+
"hash": "01bd9ce1ac66ee3c21ed8a7f14311317d87fb999",
99+
"forceFreshMigration": true
94100
}
95101
}

packages/test/__tests__/build-artifacts.spec.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@
33
* contains the expected files and that patches applied during the build
44
* (in build.ts) produce correct artifacts.
55
*
6-
* This is important because vite-plus re-packages vitest with custom
7-
* patches, and missing exports or incorrect patches can break
8-
* third-party integrations (e.g., @storybook/addon-vitest, #1086).
6+
* These tests run against the already-built dist/ directory, ensuring
7+
* that re-packaging patches produce correct artifacts.
98
*/
109
import fs from 'node:fs';
1110
import path from 'node:path';
@@ -16,6 +15,16 @@ import { describe, expect, it } from 'vitest';
1615
const testPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
1716
const distDir = path.join(testPkgDir, 'dist');
1817

18+
function findCliApiChunk(): string {
19+
const chunksDir = path.join(distDir, 'chunks');
20+
const files = fs.readdirSync(chunksDir);
21+
const chunk = files.find((f) => f.startsWith('cli-api.') && f.endsWith('.js'));
22+
if (!chunk) {
23+
throw new Error('cli-api chunk not found in dist/chunks/');
24+
}
25+
return path.join(chunksDir, chunk);
26+
}
27+
1928
describe('build artifacts', () => {
2029
describe('@vitest/browser/context.js', () => {
2130
const contextPath = path.join(distDir, '@vitest/browser/context.js');
@@ -55,4 +64,31 @@ describe('build artifacts', () => {
5564
expect(vendorMapContent).not.toContain("'@vitest/browser/context'");
5665
});
5766
});
67+
68+
/**
69+
* Third-party packages that call `expect.extend()` internally
70+
* (e.g., @testing-library/jest-dom) break under npm override because
71+
* the vitest module instance is split, causing matchers to be registered
72+
* on a different `chai` instance than the test runner uses.
73+
*
74+
* The build patches vitest's ModuleRunnerTransform plugin to auto-add
75+
* these packages to `server.deps.inline`, so they are processed through
76+
* Vite's transform pipeline and share the same module instance.
77+
*
78+
* See: https://github.com/voidzero-dev/vite-plus/issues/897
79+
*/
80+
describe('server.deps.inline auto-inline (regression test for #897)', () => {
81+
it('should contain the expected auto-inline packages', () => {
82+
const content = fs.readFileSync(findCliApiChunk(), 'utf-8');
83+
expect(content).toContain('Auto-inline packages');
84+
expect(content).toContain('"@testing-library/jest-dom"');
85+
expect(content).toContain('"@storybook/test"');
86+
expect(content).toContain('"jest-extended"');
87+
});
88+
89+
it('should not override user inline config when set to true', () => {
90+
const content = fs.readFileSync(findCliApiChunk(), 'utf-8');
91+
expect(content).toContain('server.deps.inline !== true');
92+
});
93+
});
5894
});

packages/test/build.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ await createBrowserEntryFiles();
224224
await patchModuleAugmentations();
225225
await patchChaiTypeReference();
226226
await patchMockerHoistedModule();
227+
await patchServerDepsInline();
227228
const pluginExports = await createPluginExports();
228229
await mergePackageJson(pluginExports);
229230
generateLicenseFile({
@@ -2360,6 +2361,83 @@ async function patchMockerHoistedModule() {
23602361
}
23612362
}
23622363

2364+
/**
2365+
* Patch vitest's ModuleRunnerTransform plugin to automatically add known
2366+
* packages that use `expect.extend()` internally to `server.deps.inline`.
2367+
*
2368+
* When third-party libraries (e.g., @testing-library/jest-dom) call
2369+
* `require('vitest').expect.extend(matchers)`, the npm override causes
2370+
* a separate module instance to be created, so matchers are registered
2371+
* on a different `chai` instance than the one used by the test runner.
2372+
*
2373+
* By inlining these packages via `server.deps.inline`, the Vite module
2374+
* runner processes them through its transform pipeline, ensuring they
2375+
* share the same module instance as the test runner.
2376+
*
2377+
* See: https://github.com/voidzero-dev/vite-plus/issues/897
2378+
*/
2379+
async function patchServerDepsInline() {
2380+
console.log('\nPatching server.deps.inline for expect.extend compatibility...');
2381+
2382+
let cliApiChunk: string | undefined;
2383+
for await (const chunk of fsGlob(join(distDir, 'chunks/cli-api.*.js'))) {
2384+
cliApiChunk = chunk;
2385+
break;
2386+
}
2387+
2388+
if (!cliApiChunk) {
2389+
throw new Error('cli-api chunk not found for patchServerDepsInline');
2390+
}
2391+
2392+
let content = await readFile(cliApiChunk, 'utf-8');
2393+
2394+
// Packages that internally call expect.extend() and break under npm override.
2395+
// These must be inlined so they share the same vitest module instance.
2396+
const inlinePackages = ['@testing-library/jest-dom', '@storybook/test', 'jest-extended'];
2397+
2398+
// Find the configResolved handler in ModuleRunnerTransform (vitest:environments-module-runner)
2399+
// and inject our inline packages after the existing server.deps.inline logic.
2400+
const original = `if (external.length) {
2401+
testConfig.server.deps.external ??= [];
2402+
testConfig.server.deps.external.push(...external);
2403+
}`;
2404+
2405+
const patched = `if (external.length) {
2406+
testConfig.server.deps.external ??= [];
2407+
testConfig.server.deps.external.push(...external);
2408+
}
2409+
// Auto-inline packages that use expect.extend() internally (#897)
2410+
// Only inline packages that are actually installed in the project.
2411+
if (testConfig.server.deps.inline !== true) {
2412+
testConfig.server.deps.inline ??= [];
2413+
if (Array.isArray(testConfig.server.deps.inline)) {
2414+
const _require = createRequire(config.root + "/package.json");
2415+
const autoInline = ${JSON.stringify(inlinePackages)};
2416+
for (const pkg of autoInline) {
2417+
if (testConfig.server.deps.inline.includes(pkg)) continue;
2418+
try {
2419+
_require.resolve(pkg);
2420+
testConfig.server.deps.inline.push(pkg);
2421+
} catch {
2422+
// Package not installed in the project — skip silently
2423+
}
2424+
}
2425+
}
2426+
}`;
2427+
2428+
if (!content.includes(original)) {
2429+
throw new Error(
2430+
'Could not find server.deps.external pattern in ' +
2431+
cliApiChunk +
2432+
'. This likely means vitest code has changed and the patch needs to be updated.',
2433+
);
2434+
}
2435+
2436+
content = content.replace(original, patched);
2437+
await writeFile(cliApiChunk, content, 'utf-8');
2438+
console.log(` Added auto-inline for: ${inlinePackages.join(', ')}`);
2439+
}
2440+
23632441
/**
23642442
* Create /plugins/* exports for all copied @vitest/* packages.
23652443
* This allows pnpm overrides to redirect @vitest/* imports to our copied versions.

0 commit comments

Comments
 (0)