diff --git a/packages/cli/src/__tests__/define-config-plugins.spec.ts b/packages/cli/src/__tests__/define-config-plugins.spec.ts index 39939a00a3..da5d112e97 100644 --- a/packages/cli/src/__tests__/define-config-plugins.spec.ts +++ b/packages/cli/src/__tests__/define-config-plugins.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -29,6 +29,7 @@ vi.mock('@voidzero-dev/vite-task-client', () => taskClientMocks); const RESOLVER_PLUGIN_NAME = 'vite-plus:vitest-resolver'; const COVERAGE_GUARD_PLUGIN_NAME = 'vite-plus:coverage-version-guard'; const CACHE_PATHS_PLUGIN_NAME = 'vite-plus:vitest-cache-paths'; +const MODULES_YAML_PLUGIN_NAME = 'vite-plus:pnpm-modules-yaml'; function pluginName(p: unknown): string | undefined { if (p && typeof p === 'object' && 'name' in p && typeof p.name === 'string') { @@ -46,7 +47,8 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(result.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(result.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(result.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(result.plugins[4])).toBe('user-existing-root-plugin'); + expect(pluginName(result.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(result.plugins[5])).toBe('user-existing-root-plugin'); }); it('injects resolver + auto-inline plugins into an inline-object project entry, preserving existing plugins', () => { @@ -69,9 +71,10 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(project.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(project.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(project.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(project.plugins[4])).toBe('user-unit-project-plugin'); + expect(pluginName(project.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(project.plugins[5])).toBe('user-unit-project-plugin'); // Sanity: the existing plugin reference is preserved (clone shallow-copies the array). - expect(project.plugins[4]).toBe(existing); + expect(project.plugins[5]).toBe(existing); }); it('injects plugins into the return value of a function-shaped project entry', () => { @@ -96,7 +99,8 @@ describe('defineConfig project plugin injection', () => { expect(pluginName(resolved.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(resolved.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(resolved.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); - expect(pluginName(resolved.plugins[4])).toBe('user-fn-project-plugin'); + expect(pluginName(resolved.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); + expect(pluginName(resolved.plugins[5])).toBe('user-fn-project-plugin'); }); it('passes string-glob project entries through unchanged', () => { @@ -123,11 +127,12 @@ describe('defineConfig project plugin injection', () => { const project = result.test.projects[0] as { plugins: unknown[]; test: { name: string } }; expect(project.test.name).toBe('no-plugins'); - expect(project.plugins).toHaveLength(4); + expect(project.plugins).toHaveLength(5); expect(pluginName(project.plugins[0])).toBe(RESOLVER_PLUGIN_NAME); expect(pluginName(project.plugins[1])).toBe(AUTO_INLINE_PLUGIN_NAME); expect(pluginName(project.plugins[2])).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect(pluginName(project.plugins[3])).toBe(CACHE_PATHS_PLUGIN_NAME); + expect(pluginName(project.plugins[4])).toBe(MODULES_YAML_PLUGIN_NAME); }); it.each([undefined, true])('inherits root plugins with extends %s', (extendsRoot) => { @@ -160,6 +165,7 @@ describe('defineConfig project plugin injection', () => { AUTO_INLINE_PLUGIN_NAME, COVERAGE_GUARD_PLUGIN_NAME, CACHE_PATHS_PLUGIN_NAME, + MODULES_YAML_PLUGIN_NAME, ]); }); @@ -170,6 +176,7 @@ describe('defineConfig project plugin injection', () => { AUTO_INLINE_PLUGIN_NAME, COVERAGE_GUARD_PLUGIN_NAME, CACHE_PATHS_PLUGIN_NAME, + MODULES_YAML_PLUGIN_NAME, 'user', ]); }); @@ -180,9 +187,9 @@ describe('defineConfig project plugin injection', () => { projects: [{ extends: false, test: { name: 'child' } }, { test: { name: 'inherited' } }], }, }); - expect(config.plugins).toHaveLength(4); + expect(config.plugins).toHaveLength(5); const [independent, inherited] = config.test!.projects! as { plugins?: unknown[] }[]; - expect(independent.plugins).toHaveLength(4); + expect(independent.plugins).toHaveLength(5); expect(inherited.plugins).toBeUndefined(); }); }); @@ -583,6 +590,54 @@ describe('defineConfig vitest cache paths plugin', () => { }); }); +function runConfigHook(root: string | undefined) { + const result = defineConfig({ root }) as { plugins: unknown[] }; + const plugin = result.plugins.find( + (p): p is { config: (config: { root?: string }) => void } => + pluginName(p) === MODULES_YAML_PLUGIN_NAME, + ); + if (!plugin) { + throw new Error('pnpm modules.yaml plugin not found'); + } + plugin.config({ root }); +} + +describe('defineConfig pnpm modules.yaml plugin', () => { + afterEach(() => { + taskClientMocks.ignoreInput.mockClear(); + }); + + it('ignores the workspace node_modules/.modules.yaml before Vite reads it', () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'vp-modules-yaml-'))); + writeFileSync(join(root, 'package.json'), '{"name":"probe","private":true}\n'); + try { + runConfigHook(root); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledTimes(1); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledWith( + join(root, 'node_modules', '.modules.yaml'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('anchors at the workspace root when the Vite root is a package inside it', () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'vp-modules-yaml-ws-'))); + writeFileSync(join(root, 'package.json'), '{"name":"ws","private":true}\n'); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - pkgs/*\n'); + mkdirSync(join(root, 'pkgs', 'app'), { recursive: true }); + writeFileSync(join(root, 'pkgs', 'app', 'package.json'), '{"name":"app"}\n'); + try { + runConfigHook(join(root, 'pkgs', 'app')); + expect(taskClientMocks.ignoreInput).toHaveBeenCalledWith( + join(root, 'node_modules', '.modules.yaml'), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + /** Extract the coverage-version-guard plugin from a fresh defineConfig. */ function getCoverageGuard(): { configureVitest: (ctx: unknown) => void } { const result = defineConfig({}) as { plugins: unknown[] }; diff --git a/packages/cli/src/__tests__/index.spec.ts b/packages/cli/src/__tests__/index.spec.ts index 3c7dbbcb3b..749c618632 100644 --- a/packages/cli/src/__tests__/index.spec.ts +++ b/packages/cli/src/__tests__/index.spec.ts @@ -132,6 +132,7 @@ const RESOLVER_PLUGIN_NAME = 'vite-plus:vitest-resolver'; const AUTO_INLINE_PLUGIN_NAME = 'vite-plus:auto-inline-matcher-deps'; const COVERAGE_GUARD_PLUGIN_NAME = 'vite-plus:coverage-version-guard'; const CACHE_PATHS_PLUGIN_NAME = 'vite-plus:vitest-cache-paths'; +const MODULES_YAML_PLUGIN_NAME = 'vite-plus:pnpm-modules-yaml'; const userPlugins = (plugins: unknown): unknown[] => { expect(Array.isArray(plugins)).toBe(true); const arr = plugins as unknown[]; @@ -139,7 +140,8 @@ const userPlugins = (plugins: unknown): unknown[] => { expect((arr[1] as { name?: string })?.name).toBe(AUTO_INLINE_PLUGIN_NAME); expect((arr[2] as { name?: string })?.name).toBe(COVERAGE_GUARD_PLUGIN_NAME); expect((arr[3] as { name?: string })?.name).toBe(CACHE_PATHS_PLUGIN_NAME); - return arr.slice(4); + expect((arr[4] as { name?: string })?.name).toBe(MODULES_YAML_PLUGIN_NAME); + return arr.slice(5); }; // lazyPlugins type compatibility tests — these verify at compile time that diff --git a/packages/cli/src/define-config.ts b/packages/cli/src/define-config.ts index 2056450591..56dc433ce2 100644 --- a/packages/cli/src/define-config.ts +++ b/packages/cli/src/define-config.ts @@ -830,6 +830,24 @@ function vitePlusVitestCachePathsPlugin(): PluginOption { } as PluginOption; } +/** + * Vite reads `/node_modules/.modules.yaml` while resolving the + * server options, to find the pnpm virtual store. pnpm rewrites that file on + * every install (`prunedAt` changes), so under `vp run` the tracked read makes + * every task miss its cache after a fresh install. Tell the runner the file is + * not an input. The `config` hook runs before Vite resolves the server + * options. The call is a no-op outside `vp run`. + */ +function vitePlusPnpmModulesYamlPlugin(): PluginOption { + return { + name: 'vite-plus:pnpm-modules-yaml', + config(config) { + const root = resolve(config.root ?? process.cwd()); + ignoreInput(join(searchForWorkspaceRoot(root), 'node_modules', '.modules.yaml')); + }, + }; +} + /** * Inject the vitest resolver plugin, the auto-inline matcher plugin, the * coverage version guard, and the cache-paths plugin into a single inline @@ -845,6 +863,7 @@ const VITE_PLUS_TEST_PLUGIN_NAMES: ReadonlySet = new Set([ 'vite-plus:auto-inline-matcher-deps', 'vite-plus:coverage-version-guard', 'vite-plus:vitest-cache-paths', + 'vite-plus:pnpm-modules-yaml', ]); function withoutVitePlusTestPlugins(plugin: PluginOption): PluginOption { @@ -874,6 +893,7 @@ function injectPluginIntoInlineConfig< vitePlusAutoInlineMatcherPlugin(), vitePlusCoverageVersionGuardPlugin(), vitePlusVitestCachePathsPlugin(), + vitePlusPnpmModulesYamlPlugin(), ...(config.plugins ?? []).filter(keepUserPlugin).map(withoutVitePlusTestPlugins), ], };