Skip to content

Commit 03e24a2

Browse files
Brooooooklynclaude
andcommitted
fix(cli): add direct vitest dep for browser-mode packages on migration
vitest browser mode injects `optimizeDeps.include` entries like `vitest > expect-type` that Vite resolves from the config root. In a pnpm strict (non-hoisted) layout, a `vitest` pulled in only transitively via `vite-plus` is not resolvable from the package directory, so the optimizer fails and the browser test page hangs forever — this broke the `vibe-dashboard` E2E job across the whole branch. `vp migrate` now detects vitest browser mode (recursive scan for `@vitest/browser` / `vite-plus/test/browser` specifiers, stopping at nested package.json boundaries) and adds `vitest` as a direct devDependency so pnpm hoists it next to the package. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2d88fd9 commit 03e24a2

2 files changed

Lines changed: 281 additions & 1 deletion

File tree

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import { parse as parseYaml } from 'yaml';
77

88
import { PackageManager } from '../../types/index.js';
9+
import { VITEST_VERSION } from '../../utils/constants.js';
910
import { createMigrationReport } from '../report.js';
1011

1112
// Mock VITE_PLUS_VERSION to a stable value for snapshot tests.
@@ -330,6 +331,54 @@ describe('rewritePackageJson', () => {
330331
expect(pkg.dependencies).toHaveProperty('playwright', '^1.40.0');
331332
expect(pkg.devDependencies).not.toHaveProperty('playwright');
332333
});
334+
335+
it('adds a direct vitest devDependency when the package uses browser mode', async () => {
336+
// A package that drives vitest browser mode but has no direct vitest dep
337+
// (e.g. it only imports `vite-plus/test/browser-playwright`). `@vitest/browser`
338+
// needs `vitest` resolvable from the package root, so the migration must
339+
// pin it as a direct devDependency.
340+
const pkg = {
341+
devDependencies: {
342+
playwright: '^1.58.0',
343+
},
344+
};
345+
rewritePackageJson(pkg, PackageManager.pnpm, true, undefined, undefined, true);
346+
expect(pkg.devDependencies).toHaveProperty('vitest', 'catalog:');
347+
expect(pkg.devDependencies).toHaveProperty('vite-plus', 'catalog:');
348+
});
349+
350+
it('uses a concrete vitest version for browser mode in non-catalog package managers', async () => {
351+
const pkg = {
352+
devDependencies: {
353+
playwright: '^1.58.0',
354+
},
355+
};
356+
rewritePackageJson(pkg, PackageManager.npm, false, undefined, undefined, true);
357+
expect((pkg as { devDependencies?: Record<string, string> }).devDependencies?.vitest).toBe(
358+
VITEST_VERSION,
359+
);
360+
});
361+
362+
it('does not overwrite an existing direct vitest dep in browser mode', async () => {
363+
const pkg = {
364+
devDependencies: {
365+
vitest: '^4.0.0',
366+
},
367+
};
368+
rewritePackageJson(pkg, PackageManager.pnpm, true, undefined, undefined, true);
369+
// existing direct dep is normalized through the override path, not replaced
370+
expect(pkg.devDependencies.vitest).toBe('catalog:');
371+
});
372+
373+
it('does not add vitest when browser mode is not detected', async () => {
374+
const pkg = {
375+
devDependencies: {
376+
vite: '^7.0.0',
377+
},
378+
};
379+
rewritePackageJson(pkg, PackageManager.pnpm, true, undefined, undefined, false);
380+
expect(pkg.devDependencies).not.toHaveProperty('vitest');
381+
});
333382
});
334383

335384
describe('parseNvmrcVersion', () => {
@@ -779,6 +828,72 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => {
779828
expect(pkg.peerDependencies).not.toHaveProperty('tsdown');
780829
});
781830

831+
it('adds a direct vitest dep when a vite config enables browser mode', () => {
832+
// A package whose vite config imports a browser provider but has no direct
833+
// vitest dep — `@vitest/browser` needs `vitest` resolvable from the package
834+
// root, so the migration must pin it. Mirrors the vibe-dashboard regression.
835+
fs.writeFileSync(
836+
path.join(tmpDir, 'package.json'),
837+
JSON.stringify({ name: 'test', devDependencies: { playwright: '^1.58.0' } }),
838+
);
839+
fs.writeFileSync(
840+
path.join(tmpDir, 'vite.config.ts'),
841+
[
842+
"import { playwright } from 'vite-plus/test/browser-playwright';",
843+
"import { defineConfig } from 'vite-plus';",
844+
'export default defineConfig({',
845+
' test: { browser: { enabled: true, provider: playwright() } },',
846+
'});',
847+
'',
848+
].join('\n'),
849+
);
850+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
851+
852+
const pkg = readJson(path.join(tmpDir, 'package.json'));
853+
const devDeps = pkg.devDependencies as Record<string, string>;
854+
expect(devDeps.vitest).toBe('catalog:');
855+
expect(devDeps['vite-plus']).toBe('catalog:');
856+
});
857+
858+
it('detects browser mode from a test file when the config has no hint', () => {
859+
// Browser config can live in a workspace-referenced config under any name;
860+
// the source scan also catches `@vitest/browser*` imports in test files.
861+
fs.writeFileSync(
862+
path.join(tmpDir, 'package.json'),
863+
JSON.stringify({ name: 'test', devDependencies: { vite: '^7.0.0' } }),
864+
);
865+
fs.mkdirSync(path.join(tmpDir, 'src', '__tests__'), { recursive: true });
866+
fs.writeFileSync(
867+
path.join(tmpDir, 'src', '__tests__', 'app.test.ts'),
868+
"import { page } from '@vitest/browser/context';\n",
869+
);
870+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
871+
872+
const devDeps = readJson(path.join(tmpDir, 'package.json')).devDependencies as Record<
873+
string,
874+
string
875+
>;
876+
expect(devDeps.vitest).toBe('catalog:');
877+
});
878+
879+
it('does not add vitest for a package without browser mode', () => {
880+
fs.writeFileSync(
881+
path.join(tmpDir, 'package.json'),
882+
JSON.stringify({ name: 'test', devDependencies: { vite: '^7.0.0' } }),
883+
);
884+
fs.writeFileSync(
885+
path.join(tmpDir, 'vite.config.ts'),
886+
"import { defineConfig } from 'vite';\nexport default defineConfig({});\n",
887+
);
888+
rewriteStandaloneProject(tmpDir, makeWorkspaceInfo(tmpDir, PackageManager.pnpm), true, true);
889+
890+
const devDeps = readJson(path.join(tmpDir, 'package.json')).devDependencies as Record<
891+
string,
892+
string
893+
>;
894+
expect(devDeps).not.toHaveProperty('vitest');
895+
});
896+
782897
it('preserves named pnpm overrides when moving root overrides to pnpm-workspace.yaml', () => {
783898
fs.writeFileSync(
784899
path.join(tmpDir, 'package.json'),
@@ -888,6 +1003,51 @@ describe('rewriteStandaloneProject pnpm workspace yaml', () => {
8881003
// resolve to the override target are coerced to '*'.
8891004
expect(pkg.peerDependencies.vitest).toBe('*');
8901005
});
1006+
1007+
it('adds vitest only to the monorepo package that uses browser mode', () => {
1008+
// Root has no browser config; only `apps/dashboard` does. The browser-mode
1009+
// scan must stop at the nested package.json boundary so the root package
1010+
// does not inherit the sub-package's signal.
1011+
fs.writeFileSync(
1012+
path.join(tmpDir, 'package.json'),
1013+
JSON.stringify({ name: 'root', devDependencies: {} }),
1014+
);
1015+
fs.writeFileSync(path.join(tmpDir, 'pnpm-workspace.yaml'), 'packages:\n - apps/*\n');
1016+
const appDir = path.join(tmpDir, 'apps', 'dashboard');
1017+
fs.mkdirSync(appDir, { recursive: true });
1018+
fs.writeFileSync(
1019+
path.join(appDir, 'package.json'),
1020+
JSON.stringify({ name: '@vibe/dashboard', devDependencies: { playwright: '^1.58.0' } }),
1021+
);
1022+
fs.writeFileSync(
1023+
path.join(appDir, 'vite.config.ts'),
1024+
[
1025+
"import { playwright } from 'vite-plus/test/browser-playwright';",
1026+
"import { defineConfig } from 'vite-plus';",
1027+
'export default defineConfig({ test: { browser: { provider: playwright() } } });',
1028+
'',
1029+
].join('\n'),
1030+
);
1031+
1032+
const workspaceInfo = makeWorkspaceInfo(tmpDir, PackageManager.pnpm);
1033+
workspaceInfo.isMonorepo = true;
1034+
workspaceInfo.packages = [
1035+
{ name: '@vibe/dashboard', path: 'apps/dashboard', isTemplatePackage: false },
1036+
];
1037+
rewriteMonorepo(workspaceInfo, true);
1038+
1039+
const rootDeps = (readJson(path.join(tmpDir, 'package.json')).devDependencies ?? {}) as Record<
1040+
string,
1041+
string
1042+
>;
1043+
expect(rootDeps).not.toHaveProperty('vitest');
1044+
1045+
const appDeps = readJson(path.join(appDir, 'package.json')).devDependencies as Record<
1046+
string,
1047+
string
1048+
>;
1049+
expect(appDeps.vitest).toBe('catalog:');
1050+
});
8911051
});
8921052

8931053
describe('rewriteMonorepo yarn catalog', () => {

packages/cli/src/migration/migrator.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,7 @@ export function rewriteStandaloneProject(
992992
usePnpmWorkspaceYaml,
993993
skipStagedMigration,
994994
catalogDependencyResolver,
995+
usesVitestBrowserMode(projectPath),
995996
);
996997

997998
// ensure vite-plus is in devDependencies
@@ -1144,6 +1145,7 @@ export function rewriteMonorepoProject(
11441145
true,
11451146
skipStagedMigration,
11461147
catalogDependencyResolver,
1148+
usesVitestBrowserMode(projectPath),
11471149
);
11481150
return pkg;
11491151
});
@@ -1853,6 +1855,105 @@ function readPrepareRulesYaml(): string {
18531855
return cachedPrepareRulesYaml;
18541856
}
18551857

1858+
// Specifier fragments that signal vitest browser mode. `@vitest/browser`
1859+
// (upstream, pre-migration) and `vite-plus/test/browser` (post-migration, e.g.
1860+
// re-migrating an already-migrated project) both indicate the package drives
1861+
// vitest's browser runner.
1862+
const VITEST_BROWSER_SPECIFIER_HINTS = ['@vitest/browser', 'vite-plus/test/browser'] as const;
1863+
1864+
// TypeScript/JavaScript source extensions scanned for browser-mode hints.
1865+
const VITEST_SCAN_EXTENSIONS = new Set([
1866+
'.ts',
1867+
'.mts',
1868+
'.cts',
1869+
'.tsx',
1870+
'.js',
1871+
'.mjs',
1872+
'.cjs',
1873+
'.jsx',
1874+
]);
1875+
1876+
// Directories never worth scanning for browser-mode hints — generated output,
1877+
// installed deps, VCS metadata. Skipped at every recursion level.
1878+
const VITEST_SCAN_SKIP_DIRS = new Set([
1879+
'node_modules',
1880+
'dist',
1881+
'build',
1882+
'out',
1883+
'coverage',
1884+
'.git',
1885+
'.next',
1886+
'.nuxt',
1887+
'.svelte-kit',
1888+
'.vite',
1889+
'.cache',
1890+
]);
1891+
1892+
/**
1893+
* Detect whether a package uses vitest's browser mode.
1894+
*
1895+
* Upstream `@vitest/browser` injects `optimizeDeps.include` entries of the form
1896+
* `vitest > expect-type` (and `vitest > @vitest/snapshot > magic-string`,
1897+
* `vitest > @vitest/expect > chai`). Vite resolves the leading `vitest` segment
1898+
* from the Vite config root, so `vitest` MUST be resolvable as a package from
1899+
* the consuming package's directory. In a pnpm strict (non-hoisted) layout,
1900+
* `vitest` pulled in only transitively via `vite-plus` is NOT reachable from the
1901+
* package root — the optimizer then fails with `Failed to resolve dependency`
1902+
* and the browser test page hangs forever.
1903+
*
1904+
* When this returns true the migration adds `vitest` as a direct
1905+
* devDependency so it is hoisted next to the package and the optimizer chain
1906+
* resolves. The signal is any of the package's TS/JS files (config, workspace
1907+
* config under any name, or test file) referencing `@vitest/browser*` or
1908+
* `vite-plus/test/browser*`. The scan recurses through the package directory
1909+
* (skipping `node_modules`, build output, VCS metadata) so browser config in a
1910+
* non-standard filename or browser imports in test files are all caught.
1911+
*
1912+
* Recursion stops at nested `package.json` boundaries: a workspace sub-package
1913+
* is a separate package that the migration scans on its own pass, so the root
1914+
* package must not inherit a browser-mode signal from a sub-package.
1915+
*/
1916+
function usesVitestBrowserMode(projectPath: string): boolean {
1917+
const matchesBrowserHint = (content: string): boolean =>
1918+
VITEST_BROWSER_SPECIFIER_HINTS.some((hint) => content.includes(hint));
1919+
1920+
const scanDir = (dir: string, isRoot: boolean): boolean => {
1921+
let entries: fs.Dirent[];
1922+
try {
1923+
entries = fs.readdirSync(dir, { withFileTypes: true });
1924+
} catch {
1925+
return false;
1926+
}
1927+
// A nested package.json marks a separate workspace package — it is migrated
1928+
// (and scanned) on its own pass, so don't let its files leak into this one.
1929+
if (!isRoot && entries.some((e) => e.isFile() && e.name === 'package.json')) {
1930+
return false;
1931+
}
1932+
for (const entry of entries) {
1933+
const entryPath = path.join(dir, entry.name);
1934+
if (entry.isDirectory()) {
1935+
if (VITEST_SCAN_SKIP_DIRS.has(entry.name)) {
1936+
continue;
1937+
}
1938+
if (scanDir(entryPath, false)) {
1939+
return true;
1940+
}
1941+
} else if (entry.isFile() && VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name))) {
1942+
try {
1943+
if (matchesBrowserHint(fs.readFileSync(entryPath, 'utf8'))) {
1944+
return true;
1945+
}
1946+
} catch {
1947+
// Unreadable file — ignore and keep scanning.
1948+
}
1949+
}
1950+
}
1951+
return false;
1952+
};
1953+
1954+
return scanDir(projectPath, true);
1955+
}
1956+
18561957
export function rewritePackageJson(
18571958
pkg: {
18581959
scripts?: Record<string, string>;
@@ -1866,6 +1967,7 @@ export function rewritePackageJson(
18661967
isMonorepo?: boolean,
18671968
skipStagedMigration?: boolean,
18681969
catalogDependencyResolver?: CatalogDependencyResolver,
1970+
vitestBrowserMode?: boolean,
18691971
): Record<string, string | string[]> | null {
18701972
if (pkg.scripts) {
18711973
const updated = rewriteScripts(
@@ -1973,6 +2075,15 @@ export function rewritePackageJson(
19732075
if (isVitestAdjacent) {
19742076
needVitePlus = true;
19752077
}
2078+
// A package running vitest browser mode must have `vitest` directly
2079+
// resolvable from its own directory — `@vitest/browser` injects
2080+
// `optimizeDeps.include` entries (`vitest > expect-type`, etc.) that Vite
2081+
// resolves from the config root. A `vitest` pulled in only transitively via
2082+
// `vite-plus` is invisible in a pnpm strict layout, so the optimizer fails
2083+
// and the browser test page hangs. See `usesVitestBrowserMode`.
2084+
if (vitestBrowserMode && !installableNames.includes('vitest')) {
2085+
needVitePlus = true;
2086+
}
19762087
if (needVitePlus) {
19772088
// add vite-plus to devDependencies
19782089
const version =
@@ -1993,9 +2104,18 @@ export function rewritePackageJson(
19932104
...pkg.devDependencies,
19942105
...pkg.optionalDependencies,
19952106
};
2107+
// Add `vitest` as a direct devDependency when:
2108+
// - a remaining dependency likely peer-depends on vitest (e.g.
2109+
// vitest-browser-svelte), OR
2110+
// - the package runs vitest browser mode (`@vitest/browser` needs
2111+
// `vitest` resolvable from the package root — see usesVitestBrowserMode).
2112+
// Vite-plus already bundles upstream vitest 4.1.7 as a direct dep, but a
2113+
// strict pnpm / yarn PnP layout will not expose that transitive `vitest`
2114+
// to the package. Pinning it here points the dep at the same upstream
2115+
// version vite-plus ships with.
19962116
if (
19972117
!installableDeps.vitest &&
1998-
Object.keys(installableDeps).some((name) => name.includes('vitest'))
2118+
(vitestBrowserMode || Object.keys(installableDeps).some((name) => name.includes('vitest')))
19992119
) {
20002120
pkg.devDependencies.vitest = getCatalogDependencySpec(
20012121
undefined,

0 commit comments

Comments
 (0)