From 18fb9f5b37b6bf06fc2b21bb0bb483035013cea8 Mon Sep 17 00:00:00 2001 From: Obvious Date: Sat, 5 Sep 2026 08:11:00 +0000 Subject: [PATCH 1/2] chore(vitest): re-land removal of workarounds obsoleted by the Vitest 4 floor (re-opens #36188) --- code/addons/vitest/src/node/vitest-manager.ts | 5 - code/addons/vitest/src/vitest-plugin/index.ts | 12 -- code/core/src/actions/loaders.ts | 5 +- code/core/src/babel/index.ts | 1 - .../src/babel/vitest-config-helpers.test.ts | 66 +-------- code/core/src/babel/vitest-config-helpers.ts | 48 ------- code/core/src/cli/AddonVitestService.test.ts | 131 +++--------------- code/core/src/cli/AddonVitestService.ts | 17 +-- 8 files changed, 22 insertions(+), 263 deletions(-) diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index fc9d5e0fd098..2b81250cc9b2 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -187,11 +187,6 @@ export class VitestManager { try { await this.runningPromise; await this.vitest?.close(); - // Drop the closed instance before restarting. The coverage reporter options passed to - // createVitest reference this manager, and Vitest deep-clones its options — on Vite 6 - // that traversal reaches the closed module runner's `import.meta.env` proxy (an own - // property of ModuleRunner there), whose get trap throws on any dynamic access. - this.vitest = null; await this.startVitest({ coverage }); resolve(); } catch (e) { diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 1b54e6526173..9d11c6cba22f 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -403,18 +403,6 @@ export const storybookTest = async (options?: UserOptions): Promise => join(relative(finalOptions.vitestRoot, process.cwd()), '**/*.mdx').replaceAll(sep, '/'), ], - // if the existing deps.inline is true, we keep it as-is, because it will inline everything - // TODO: Remove the check once we don't support Vitest 3 anymore - ...(nonMutableInputConfig.test?.server?.deps?.inline !== true - ? { - server: { - deps: { - inline: ['@storybook/addon-vitest'], - }, - }, - } - : {}), - browser: { // if there is a test.browser config AND test.browser.screenshotFailures is not explicitly set, we set it to false ...(nonMutableInputConfig.test?.browser && diff --git a/code/core/src/actions/loaders.ts b/code/core/src/actions/loaders.ts index a59b671733b4..4c985f929b7b 100644 --- a/code/core/src/actions/loaders.ts +++ b/code/core/src/actions/loaders.ts @@ -17,9 +17,8 @@ const logActionsWhenMockCalled: LoaderFunction = (context) => { onMockCall((mock, args) => { const name = mock.getMockName(); - // Default name provided by vi.fn(), which we don't want to log. - // TODO: Can be removed as soon as we drop Vitest 3 support - // https://main.vitest.dev/guide/migration.html#changes-to-mocking + // Default name provided by vi.fn() under storybook/test's pinned @vitest/spy@3.2.4, + // which we don't want to log. Vitest 4's own spy defaults to 'vi.fn()' instead. if (name === 'spy') { return; } diff --git a/code/core/src/babel/index.ts b/code/core/src/babel/index.ts index 59650a0f070f..771dcede8ca8 100644 --- a/code/core/src/babel/index.ts +++ b/code/core/src/babel/index.ts @@ -22,7 +22,6 @@ export { getEffectiveMergeConfigCall, getTargetConfigObject, canUpdateVitestConfigFile, - canUpdateVitestWorkspaceFile, } from './vitest-config-helpers.ts'; // @ts-expect-error (needed due to it's use of `exports.default`) diff --git a/code/core/src/babel/vitest-config-helpers.test.ts b/code/core/src/babel/vitest-config-helpers.test.ts index 26a7684e4fc9..5a0a8bcd2d09 100644 --- a/code/core/src/babel/vitest-config-helpers.test.ts +++ b/code/core/src/babel/vitest-config-helpers.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - canUpdateVitestConfigFile, - canUpdateVitestWorkspaceFile, -} from './vitest-config-helpers.ts'; +import { canUpdateVitestConfigFile } from './vitest-config-helpers.ts'; describe('canUpdateVitestConfigFile', () => { it('returns true for plain export default object literal', () => { @@ -293,64 +290,3 @@ describe('canUpdateVitestConfigFile', () => { ); }); }); - -// ─── canUpdateVitestWorkspaceFile ──────────────────────────────────────────── - -describe('canUpdateVitestWorkspaceFile', () => { - // ----- Supported patterns (should return true) ----- - - it('returns true for plain array workspace', () => { - expect(canUpdateVitestWorkspaceFile('export default ["project1", "project2"]')).toBe(true); - }); - - it('returns true for array with object entries', () => { - expect(canUpdateVitestWorkspaceFile('export default [{ test: {} }, "project"]')).toBe(true); - }); - - it('returns true for empty array', () => { - expect(canUpdateVitestWorkspaceFile('export default []')).toBe(true); - }); - - it('returns true for defineWorkspace([...]) pattern', () => { - expect( - canUpdateVitestWorkspaceFile( - ` - import { defineWorkspace } from 'vitest/config'; - export default defineWorkspace(['project1', 'project2']) - ` - ) - ).toBe(true); - }); - - it('returns true for defineWorkspace with object entries', () => { - expect( - canUpdateVitestWorkspaceFile( - ` - import { defineWorkspace } from 'vitest/config'; - export default defineWorkspace([{ test: { name: 'unit' } }]) - ` - ) - ).toBe(true); - }); - - // ----- Unsupported patterns (should return false) ----- - - it('returns false when there is no export default', () => { - expect(canUpdateVitestWorkspaceFile('const projects = ["a"];')).toBe(false); - }); - - it('returns false for defineWorkspace with a non-array argument', () => { - expect( - canUpdateVitestWorkspaceFile( - ` - import { defineWorkspace } from 'vitest/config'; - export default defineWorkspace('glob/**') - ` - ) - ).toBe(false); - }); - - it('returns false for syntax errors', () => { - expect(canUpdateVitestWorkspaceFile('this is not valid syntax !!!')).toBe(false); - }); -}); diff --git a/code/core/src/babel/vitest-config-helpers.ts b/code/core/src/babel/vitest-config-helpers.ts index 12b4318ebe33..d131fd419620 100644 --- a/code/core/src/babel/vitest-config-helpers.ts +++ b/code/core/src/babel/vitest-config-helpers.ts @@ -206,51 +206,3 @@ export const canUpdateVitestConfigFile = (fileContent: string): boolean => { getEffectiveMergeConfigCall(exportDefault.declaration, parsedAst) !== null ); }; - -/** - * Returns `true` when a Vitest workspace file's default export uses a pattern that - * `updateWorkspaceFile` (from addon-vitest) can auto-update. - * - * Supported patterns: - * - * - `export default ["project1", "project2"]` - * - `export default [{...}, "project"]` - * - `export default defineWorkspace(["project1", "project2"])` - * - * Returns `false` for any other shape (e.g. JSON files should be rejected before parsing, CommonJS - * files, unrecognizable exports). - */ -export const canUpdateVitestWorkspaceFile = (fileContent: string): boolean => { - let parsedAst: AST; - try { - parsedAst = babelParse(fileContent); - } catch { - return false; - } - - const exportDefault = parsedAst.program.body.find( - (n): n is t.ExportDefaultDeclaration => n.type === 'ExportDefaultDeclaration' - ); - if (!exportDefault) { - return false; - } - - const decl = exportDefault.declaration; - - // export default [...] - if (decl.type === 'ArrayExpression') { - return true; - } - - // export default defineWorkspace([...]) - if ( - decl.type === 'CallExpression' && - decl.callee.type === 'Identifier' && - decl.callee.name === 'defineWorkspace' && - decl.arguments[0]?.type === 'ArrayExpression' - ) { - return true; - } - - return false; -}; diff --git a/code/core/src/cli/AddonVitestService.test.ts b/code/core/src/cli/AddonVitestService.test.ts index f927c27d8dbe..65ddc84d456b 100644 --- a/code/core/src/cli/AddonVitestService.test.ts +++ b/code/core/src/cli/AddonVitestService.test.ts @@ -364,7 +364,7 @@ describe('AddonVitestService', () => { }); it('should validate config files when configDir provided', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.json'); + vi.mocked(find.any).mockReturnValueOnce('vitest.config.cjs'); const result = await service.validateCompatibility({ framework: SupportedFramework.REACT_VITE, @@ -373,12 +373,10 @@ describe('AddonVitestService', () => { }); expect(result.compatible).toBe(false); - expect(result.reasons!.some((r) => r.includes('JSON projects'))).toBe(true); + expect(result.reasons!.some((r) => r.includes('CommonJS config'))).toBe(true); }); it('should skip config file validation when no configDir provided', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.json'); - const result = await service.validateCompatibility({ framework: SupportedFramework.REACT_VITE, builder: SupportedBuilder.VITE, @@ -624,41 +622,9 @@ describe('AddonVitestService', () => { expect(result.compatible).toBe(true); }); - it('should reject JSON projects files', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.json'); - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(false); - expect(result.reasons).toBeDefined(); - expect(result.reasons!.some((r) => r.includes('JSON projects'))).toBe(true); - }); - - it('should validate non-JSON projects files', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.ts'); - vi.mocked(fs.readFile).mockResolvedValue('export default ["project1", "project2"]'); - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(true); - expect(fs.readFile).toHaveBeenCalledWith('vitest.projects.ts', 'utf8'); - }); - - it('should reject invalid projects config', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.ts'); - vi.mocked(fs.readFile).mockResolvedValue('export default "invalid"'); - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(false); - expect(result.reasons!.some((r) => r.includes('invalid projects'))).toBe(true); - }); - it('should reject CommonJS config files (.cts)', async () => { vi.mocked(find.any).mockReset(); - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.cts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.cts'); // config const result = await service.validateConfigFiles('.storybook'); @@ -669,9 +635,7 @@ describe('AddonVitestService', () => { }); it('should reject CommonJS config files (.cjs)', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.cjs'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.cjs'); // config const result = await service.validateConfigFiles('.storybook'); @@ -680,9 +644,7 @@ describe('AddonVitestService', () => { }); it('should validate non-CommonJS config files', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue('export default defineConfig({ test: {} })'); const result = await service.validateConfigFiles('.storybook'); @@ -691,9 +653,7 @@ describe('AddonVitestService', () => { }); it('should accept plain export default {}', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue('export default {}'); const result = await service.validateConfigFiles('.storybook'); @@ -702,9 +662,7 @@ describe('AddonVitestService', () => { }); it('should reject arrow function vitest config with dynamic control flow (unsupported)', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config // A callback config that returns object literals directly is supported; one with branching // control flow in a block body is not, and must be rejected. vi.mocked(fs.readFile).mockResolvedValue( @@ -723,28 +681,8 @@ export default defineConfig(({ mode }) => { expect(result.reasons!.some((r) => r.includes('invalid Vitest config'))).toBe(true); }); - it('should validate projects file with defineWorkspace expression', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.ts'); - vi.mocked(fs.readFile).mockResolvedValue('export default defineWorkspace(["project1"])'); - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(true); - }); - - it('should validate projects file with object expressions', async () => { - vi.mocked(find.any).mockReturnValueOnce('vitest.projects.ts'); - vi.mocked(fs.readFile).mockResolvedValue('export default [{ test: {} }, "project"]'); - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(true); - }); - it('should validate config with projects array in test', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( 'export default defineConfig({ test: { projects: [] } })' ); @@ -754,23 +692,8 @@ export default defineConfig(({ mode }) => { expect(result.compatible).toBe(true); }); - it('should accumulate multiple config validation errors', async () => { - vi.mocked(find.any).mockReset(); - vi.mocked(find.any) - .mockReturnValueOnce('vitest.projects.json') // projects JSON - .mockReturnValueOnce('vitest.config.cjs'); // config CJS - - const result = await service.validateConfigFiles('.storybook'); - - expect(result.compatible).toBe(false); - expect(result.reasons).toBeDefined(); - expect(result.reasons!.length).toBe(2); - }); - it('should validate mergeConfig with plain object literal', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( 'export default mergeConfig(viteConfig, { test: { name: "node" } })' ); @@ -779,9 +702,7 @@ export default defineConfig(({ mode }) => { }); it('should validate mergeConfig with defineConfig call', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( 'export default mergeConfig(viteConfig, defineConfig({ test: { name: "node" } }))' ); @@ -790,9 +711,7 @@ export default defineConfig(({ mode }) => { }); it('should validate mergeConfig with multiple plain objects', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( 'export default mergeConfig({ test: {} }, { plugins: [] })' ); @@ -801,9 +720,7 @@ export default defineConfig(({ mode }) => { }); it('should accept defineConfig(mergeConfig(...)) pattern', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { defineConfig, mergeConfig } from 'vitest/config'; @@ -819,9 +736,7 @@ export default defineConfig(({ mode }) => { }); it('should accept defineConfig(mergeConfig(...) satisfies ViteUserConfig) pattern', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { defineConfig, mergeConfig } from 'vitest/config'; @@ -838,9 +753,7 @@ export default defineConfig(({ mode }) => { }); it('should accept mergeConfig(...) as ViteUserConfig pattern', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { mergeConfig } from 'vitest/config'; @@ -855,9 +768,7 @@ export default defineConfig(({ mode }) => { }); it('should accept mergeConfig with shorthand test variable', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { mergeConfig } from 'vitest/config'; @@ -870,9 +781,7 @@ export default defineConfig(({ mode }) => { }); it('should accept mergeConfig with external vitestConfig variable', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { mergeConfig } from 'vitest/config'; @@ -885,9 +794,7 @@ export default defineConfig(({ mode }) => { }); it('should accept const config = mergeConfig(...); export default config pattern', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { defineConfig, mergeConfig } from 'vitest/config'; @@ -903,9 +810,7 @@ export default defineConfig(({ mode }) => { }); it('should accept defineProject({}) pattern', async () => { - vi.mocked(find.any) - .mockReturnValueOnce(undefined) // projects - .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(find.any).mockReturnValueOnce('vitest.config.ts'); // config vi.mocked(fs.readFile).mockResolvedValue( ` import { defineProject } from 'vitest/config'; diff --git a/code/core/src/cli/AddonVitestService.ts b/code/core/src/cli/AddonVitestService.ts index abfc5aa3311f..2aa117293c8b 100644 --- a/code/core/src/cli/AddonVitestService.ts +++ b/code/core/src/cli/AddonVitestService.ts @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; -import { canUpdateVitestConfigFile, canUpdateVitestWorkspaceFile } from 'storybook/internal/babel'; +import { canUpdateVitestConfigFile } from 'storybook/internal/babel'; import type { JsPackageManager } from 'storybook/internal/common'; import { getProjectRoot } from 'storybook/internal/common'; import { CLI_COLORS } from 'storybook/internal/node-logger'; @@ -294,21 +294,6 @@ export class AddonVitestService { const reasons: string[] = []; const projectRoot = getProjectRoot(); - // Check projects files - const vitestProjectsFile = find.any( - ['ts', 'js', 'json'].map((ex) => `vitest.projects.${ex}`), - { cwd: directory, last: projectRoot } - ); - - if (vitestProjectsFile?.endsWith('.json')) { - reasons.push(`Cannot auto-update JSON projects file: ${vitestProjectsFile}`); - } else if (vitestProjectsFile) { - const fileContents = await fs.readFile(vitestProjectsFile, 'utf8'); - if (!canUpdateVitestWorkspaceFile(fileContents)) { - reasons.push(`Found an invalid projects config file: ${vitestProjectsFile}`); - } - } - // Check config files const vitestConfigFile = find.any( ['ts', 'js', 'tsx', 'jsx', 'cts', 'cjs', 'mts', 'mjs'].map((ex) => `vitest.config.${ex}`), From 66aa8e713b3e057dc63eb3724fd0ee94de154069 Mon Sep 17 00:00:00 2001 From: Obvious Date: Mon, 7 Sep 2026 10:02:57 +0000 Subject: [PATCH 2/2] fix(vitest): retain closed-instance drop workaround before coverage restarts --- code/addons/vitest/src/node/vitest-manager.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 2b81250cc9b2..fc9d5e0fd098 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -187,6 +187,11 @@ export class VitestManager { try { await this.runningPromise; await this.vitest?.close(); + // Drop the closed instance before restarting. The coverage reporter options passed to + // createVitest reference this manager, and Vitest deep-clones its options — on Vite 6 + // that traversal reaches the closed module runner's `import.meta.env` proxy (an own + // property of ModuleRunner there), whose get trap throws on any dynamic access. + this.vitest = null; await this.startVitest({ coverage }); resolve(); } catch (e) {