diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9bfd7415fc..c7b094cbae 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -285,6 +285,10 @@ jobs: vp run lint vp run test:types vp test --project unit + - name: vite-plus-jest-dom-repro + node-version: 24 + command: | + vp test run exclude: # frm-stack uses Docker (testcontainers) which doesn't work the same way on Windows - os: windows-latest diff --git a/ecosystem-ci/repo.json b/ecosystem-ci/repo.json index 796c5658a2..06fcd1088d 100644 --- a/ecosystem-ci/repo.json +++ b/ecosystem-ci/repo.json @@ -91,5 +91,11 @@ "branch": "main", "hash": "230b7c7ddb6bb8551ce797144f0ce0f047ff8d7d", "forceFreshMigration": true + }, + "vite-plus-jest-dom-repro": { + "repository": "https://github.com/why-reproductions-are-required/vite-plus-jest-dom-repro.git", + "branch": "master", + "hash": "01bd9ce1ac66ee3c21ed8a7f14311317d87fb999", + "forceFreshMigration": true } } diff --git a/packages/test/__tests__/build-artifacts.spec.ts b/packages/test/__tests__/build-artifacts.spec.ts index 21d963dd17..0767207323 100644 --- a/packages/test/__tests__/build-artifacts.spec.ts +++ b/packages/test/__tests__/build-artifacts.spec.ts @@ -3,9 +3,8 @@ * contains the expected files and that patches applied during the build * (in build.ts) produce correct artifacts. * - * This is important because vite-plus re-packages vitest with custom - * patches, and missing exports or incorrect patches can break - * third-party integrations (e.g., @storybook/addon-vitest, #1086). + * These tests run against the already-built dist/ directory, ensuring + * that re-packaging patches produce correct artifacts. */ import fs from 'node:fs'; import path from 'node:path'; @@ -16,6 +15,16 @@ import { describe, expect, it } from 'vitest'; const testPkgDir = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..'); const distDir = path.join(testPkgDir, 'dist'); +function findCliApiChunk(): string { + const chunksDir = path.join(distDir, 'chunks'); + const files = fs.readdirSync(chunksDir); + const chunk = files.find((f) => f.startsWith('cli-api.') && f.endsWith('.js')); + if (!chunk) { + throw new Error('cli-api chunk not found in dist/chunks/'); + } + return path.join(chunksDir, chunk); +} + describe('build artifacts', () => { describe('@vitest/browser/context.js', () => { const contextPath = path.join(distDir, '@vitest/browser/context.js'); @@ -55,4 +64,31 @@ describe('build artifacts', () => { expect(vendorMapContent).not.toContain("'@vitest/browser/context'"); }); }); + + /** + * Third-party packages that call `expect.extend()` internally + * (e.g., @testing-library/jest-dom) break under npm override because + * the vitest module instance is split, causing matchers to be registered + * on a different `chai` instance than the test runner uses. + * + * The build patches vitest's ModuleRunnerTransform plugin to auto-add + * these packages to `server.deps.inline`, so they are processed through + * Vite's transform pipeline and share the same module instance. + * + * See: https://github.com/voidzero-dev/vite-plus/issues/897 + */ + describe('server.deps.inline auto-inline (regression test for #897)', () => { + it('should contain the expected auto-inline packages', () => { + const content = fs.readFileSync(findCliApiChunk(), 'utf-8'); + expect(content).toContain('Auto-inline packages'); + expect(content).toContain('"@testing-library/jest-dom"'); + expect(content).toContain('"@storybook/test"'); + expect(content).toContain('"jest-extended"'); + }); + + it('should not override user inline config when set to true', () => { + const content = fs.readFileSync(findCliApiChunk(), 'utf-8'); + expect(content).toContain('server.deps.inline !== true'); + }); + }); }); diff --git a/packages/test/build.ts b/packages/test/build.ts index f4276d0de5..f96cd14f33 100644 --- a/packages/test/build.ts +++ b/packages/test/build.ts @@ -224,6 +224,7 @@ await createBrowserEntryFiles(); await patchModuleAugmentations(); await patchChaiTypeReference(); await patchMockerHoistedModule(); +await patchServerDepsInline(); const pluginExports = await createPluginExports(); await mergePackageJson(pluginExports); generateLicenseFile({ @@ -2360,6 +2361,83 @@ async function patchMockerHoistedModule() { } } +/** + * Patch vitest's ModuleRunnerTransform plugin to automatically add known + * packages that use `expect.extend()` internally to `server.deps.inline`. + * + * When third-party libraries (e.g., @testing-library/jest-dom) call + * `require('vitest').expect.extend(matchers)`, the npm override causes + * a separate module instance to be created, so matchers are registered + * on a different `chai` instance than the one used by the test runner. + * + * By inlining these packages via `server.deps.inline`, the Vite module + * runner processes them through its transform pipeline, ensuring they + * share the same module instance as the test runner. + * + * See: https://github.com/voidzero-dev/vite-plus/issues/897 + */ +async function patchServerDepsInline() { + console.log('\nPatching server.deps.inline for expect.extend compatibility...'); + + let cliApiChunk: string | undefined; + for await (const chunk of fsGlob(join(distDir, 'chunks/cli-api.*.js'))) { + cliApiChunk = chunk; + break; + } + + if (!cliApiChunk) { + throw new Error('cli-api chunk not found for patchServerDepsInline'); + } + + let content = await readFile(cliApiChunk, 'utf-8'); + + // Packages that internally call expect.extend() and break under npm override. + // These must be inlined so they share the same vitest module instance. + const inlinePackages = ['@testing-library/jest-dom', '@storybook/test', 'jest-extended']; + + // Find the configResolved handler in ModuleRunnerTransform (vitest:environments-module-runner) + // and inject our inline packages after the existing server.deps.inline logic. + const original = `if (external.length) { + testConfig.server.deps.external ??= []; + testConfig.server.deps.external.push(...external); + }`; + + const patched = `if (external.length) { + testConfig.server.deps.external ??= []; + testConfig.server.deps.external.push(...external); + } + // Auto-inline packages that use expect.extend() internally (#897) + // Only inline packages that are actually installed in the project. + if (testConfig.server.deps.inline !== true) { + testConfig.server.deps.inline ??= []; + if (Array.isArray(testConfig.server.deps.inline)) { + const _require = createRequire(config.root + "/package.json"); + const autoInline = ${JSON.stringify(inlinePackages)}; + for (const pkg of autoInline) { + if (testConfig.server.deps.inline.includes(pkg)) continue; + try { + _require.resolve(pkg); + testConfig.server.deps.inline.push(pkg); + } catch { + // Package not installed in the project — skip silently + } + } + } + }`; + + if (!content.includes(original)) { + throw new Error( + 'Could not find server.deps.external pattern in ' + + cliApiChunk + + '. This likely means vitest code has changed and the patch needs to be updated.', + ); + } + + content = content.replace(original, patched); + await writeFile(cliApiChunk, content, 'utf-8'); + console.log(` Added auto-inline for: ${inlinePackages.join(', ')}`); +} + /** * Create /plugins/* exports for all copied @vitest/* packages. * This allows pnpm overrides to redirect @vitest/* imports to our copied versions.