From 05d356b7cbf883c88493ab8ef196913c6d227b10 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:50:12 +0000 Subject: [PATCH 1/8] Update ./docs/versions/next.json for v10.3.0-alpha.3 --- docs/versions/next.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versions/next.json b/docs/versions/next.json index 0303d19f6a1b..b880e1259b34 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.2","info":{"plain":"- Addon Vitest: Support simple vite.config without defineConfig helper - [#33694](https://github.com/storybookjs/storybook/pull/33694), thanks @valentinpalkovic!\n- Addon-Docs: Add support for `sourceState: 'none'` to canvas block parameters - [#33627](https://github.com/storybookjs/storybook/pull/33627), thanks @quisido!\n- Addon-Vitest: Append Storybook project to existing test.projects array without double nesting - [#33708](https://github.com/storybookjs/storybook/pull/33708), thanks @valentinpalkovic!\n- Addon-Vitest: Normalize Windows paths in addon-vitest automigration - [#33340](https://github.com/storybookjs/storybook/pull/33340), thanks @tanujbhaud!\n- Addon-Vitest: Skip postinstall setup when configured - [#33712](https://github.com/storybookjs/storybook/pull/33712), thanks @valentinpalkovic!\n- Addon-Vitest: Update Vitest plugin configuration to disable requireAssertions for expect - [#33693](https://github.com/storybookjs/storybook/pull/33693), thanks @valentinpalkovic!\n- CSF-Factories: Fix codemod for preview files without exports - [#33673](https://github.com/storybookjs/storybook/pull/33673), thanks @kasperpeulen!\n- CSF: Fix false positive detection of Zod v4 .meta() as CSF Factory - [#33666](https://github.com/storybookjs/storybook/pull/33666), thanks @kasperpeulen!\n- CSFFactories: Add non-interactive mode and --glob flag - [#33648](https://github.com/storybookjs/storybook/pull/33648), thanks @kasperpeulen!\n- CSFFactories: Preserve leading comments when adding imports - [#33645](https://github.com/storybookjs/storybook/pull/33645), thanks @kasperpeulen!\n- Cli: Use npm for registry URL in PNPMProxy to avoid workspace errors - [#33571](https://github.com/storybookjs/storybook/pull/33571), thanks @ia319!\n- Codemod: Fix csf-2-to-3 failing due to quoted filenames - [#33646](https://github.com/storybookjs/storybook/pull/33646), thanks @kasperpeulen!\n- Codemod: Fix glob pattern handling on Windows - [#33714](https://github.com/storybookjs/storybook/pull/33714), thanks @kasperpeulen!\n- Composition: Handle 401 responses with loginUrl from Chromatic - [#33705](https://github.com/storybookjs/storybook/pull/33705), thanks @kasperpeulen!\n- Core: Fix false-positive CJS warning when 'exports' appears in strings or comments - [#33572](https://github.com/storybookjs/storybook/pull/33572), thanks @reeseo3o!\n- Telemetry: Add agent detection - [#33675](https://github.com/storybookjs/storybook/pull/33675), thanks @valentinpalkovic!"}} \ No newline at end of file +{"version":"10.3.0-alpha.3","info":{"plain":"- Angular: Storybook fails with unknown option silent - [#33736](https://github.com/storybookjs/storybook/pull/33736), thanks @tanujbhaud!\n- Angular: fix --loglevel options in docs and descriptions - [#33726](https://github.com/storybookjs/storybook/pull/33726), thanks @theRuslan!\n- Builder-Vite: Add plugin to enforce Storybook's output directory in Vite build configuration - [#33740](https://github.com/storybookjs/storybook/pull/33740), thanks @valentinpalkovic!\n- Core: Fix typos: occured -> occurred, recieves -> receives - [#33727](https://github.com/storybookjs/storybook/pull/33727), thanks @jonathan-fulton!\n- Core: Handle BROWSER=none correctly and improve error messages - [#33730](https://github.com/storybookjs/storybook/pull/33730), thanks @jonathan-fulton!\n- Core: Invalidate cache on Storybook version upgrade - [#33717](https://github.com/storybookjs/storybook/pull/33717), thanks @copilot-swe-agent!\n- Core: Register CORS middleware before index.json route - [#33728](https://github.com/storybookjs/storybook/pull/33728), thanks @jonathan-fulton!\n- Manager: Remove deprecated `active` prop warning in ZoomButton - [#33697](https://github.com/storybookjs/storybook/pull/33697), thanks @yatishgoel!\n- Next.js: Alias AppRouterContext to shared runtime to fix Link navigation - [#33419](https://github.com/storybookjs/storybook/pull/33419), thanks @pallaprolus!\n- UI: Fix `z-index` problem with `popover`s and `modal`s nesting - [#33757](https://github.com/storybookjs/storybook/pull/33757), thanks @ndelangen!\n- Vue: Make globals reactive in decorators - [#33562](https://github.com/storybookjs/storybook/pull/33562), thanks @Sidnioulz!"}} \ No newline at end of file From 439af855755105484609c5cc4dfa516c530764e0 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:40:50 +0000 Subject: [PATCH 2/8] Update ./docs/versions/next.json for v10.3.0-alpha.4 --- docs/versions/next.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versions/next.json b/docs/versions/next.json index b880e1259b34..370ef3231d50 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.3.0-alpha.3","info":{"plain":"- Angular: Storybook fails with unknown option silent - [#33736](https://github.com/storybookjs/storybook/pull/33736), thanks @tanujbhaud!\n- Angular: fix --loglevel options in docs and descriptions - [#33726](https://github.com/storybookjs/storybook/pull/33726), thanks @theRuslan!\n- Builder-Vite: Add plugin to enforce Storybook's output directory in Vite build configuration - [#33740](https://github.com/storybookjs/storybook/pull/33740), thanks @valentinpalkovic!\n- Core: Fix typos: occured -> occurred, recieves -> receives - [#33727](https://github.com/storybookjs/storybook/pull/33727), thanks @jonathan-fulton!\n- Core: Handle BROWSER=none correctly and improve error messages - [#33730](https://github.com/storybookjs/storybook/pull/33730), thanks @jonathan-fulton!\n- Core: Invalidate cache on Storybook version upgrade - [#33717](https://github.com/storybookjs/storybook/pull/33717), thanks @copilot-swe-agent!\n- Core: Register CORS middleware before index.json route - [#33728](https://github.com/storybookjs/storybook/pull/33728), thanks @jonathan-fulton!\n- Manager: Remove deprecated `active` prop warning in ZoomButton - [#33697](https://github.com/storybookjs/storybook/pull/33697), thanks @yatishgoel!\n- Next.js: Alias AppRouterContext to shared runtime to fix Link navigation - [#33419](https://github.com/storybookjs/storybook/pull/33419), thanks @pallaprolus!\n- UI: Fix `z-index` problem with `popover`s and `modal`s nesting - [#33757](https://github.com/storybookjs/storybook/pull/33757), thanks @ndelangen!\n- Vue: Make globals reactive in decorators - [#33562](https://github.com/storybookjs/storybook/pull/33562), thanks @Sidnioulz!"}} \ No newline at end of file +{"version":"10.3.0-alpha.4","info":{"plain":"- Addon-Vitest: Support vite/vitest config with deferred export - [#33755](https://github.com/storybookjs/storybook/pull/33755), thanks @valentinpalkovic!\n- Controls: Allow story argTypes to override control: false from meta - [#33729](https://github.com/storybookjs/storybook/pull/33729), thanks @jonathan-fulton!\n- Manager: Update logic to use base path instead of full pathname - [#33686](https://github.com/storybookjs/storybook/pull/33686), thanks @JSMike!\n- Manifests: Use correct story name - [#33709](https://github.com/storybookjs/storybook/pull/33709), thanks @JReinhold!\n- Toolbar: Remove extra toolbar divider when zoom controls not shown - [#33731](https://github.com/storybookjs/storybook/pull/33731), thanks @jonathan-fulton!"}} \ No newline at end of file From 2f8d01de3dbf3d2707d06707347e4a5df80452a9 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 3 Feb 2026 15:34:43 +0100 Subject: [PATCH 3/8] Merge pull request #33686 from JSMike/33548-fix-base-path Manager: Update logic to use base path instead of full pathname (cherry picked from commit 7ab7f4112441f4ae6faafde671088493e67d4a9d) --- code/core/src/manager-api/modules/refs.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/code/core/src/manager-api/modules/refs.ts b/code/core/src/manager-api/modules/refs.ts index 151003529eed..040b89d9588c 100644 --- a/code/core/src/manager-api/modules/refs.ts +++ b/code/core/src/manager-api/modules/refs.ts @@ -81,10 +81,8 @@ export const getSourceType = (source: string, refId?: string) => { const { origin: localOrigin, pathname: localPathname } = location; const { origin: sourceOrigin, pathname: sourcePathname } = new URL(source); - const localFull = `${localOrigin + localPathname}`.replace('/iframe.html', '').replace(/\/$/, ''); - const sourceFull = `${sourceOrigin + sourcePathname}` - .replace('/iframe.html', '') - .replace(/\/$/, ''); + const localFull = `${localOrigin + localPathname}`.replace(/\/[^\/]*$/, ''); + const sourceFull = `${sourceOrigin + sourcePathname}`.replace(/\/[^\/]*$/, ''); if (localFull === sourceFull) { return ['local', sourceFull]; From cae3d072b7daaba62fb4bbc34148a2a0a48801de Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 4 Feb 2026 11:12:37 +0100 Subject: [PATCH 4/8] Merge pull request #33755 from storybookjs/valentin/support-vitest-config-with-deferred-export Addon-Vitest: Support vite/vitest config with deferred export (cherry picked from commit 23494eeaca0b1af36d5a2dd883bb05bb22a2e751) --- .../vitest/src/updateVitestFile.test.ts | 85 ++++++++++++++ code/addons/vitest/src/updateVitestFile.ts | 110 ++++++++++++++---- 2 files changed, 171 insertions(+), 24 deletions(-) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 89cfe368a6c1..02e8b4446579 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -291,6 +291,91 @@ describe('updateConfigFile', () => { `); }); + it('updates config which is not exported immediately', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.3.2.template.ts', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { defineConfig } from 'vite' + import viteReact from '@vitejs/plugin-react' + import { fileURLToPath, URL } from 'url' + + const config = defineConfig({ + resolve: { + preserveSymlinks: true, + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + plugins: [ + viteReact(), + ], + }) + + export default config + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { defineConfig } from 'vite'; + import viteReact from '@vitejs/plugin-react'; + import { fileURLToPath, URL } from 'url'; + + + import path from 'node:path'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + const config = defineConfig({ + resolve: { + preserveSymlinks: true, + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + + - plugins: [viteReact()] + - + + plugins: [viteReact()], + + test: { + + projects: [{ + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + }] + + } + + + }); + export default config;" +`); + }); + it('edits projects property of test config', async () => { const source = babel.babelParse( await loadTemplate('vitest.config.3.2.template.ts', { diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 46661c099849..6f0a782f5aa1 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -54,6 +54,60 @@ const mergeProperties = ( } }; +/** + * Resolves the target's default export to the actual config object expression we can merge into. + * Handles: export default defineConfig({}), export default {}, and export default config (where + * config is a variable holding defineConfig({}) or {}). + */ +const getTargetConfigObject = ( + target: BabelFile['ast'], + exportDefault: t.ExportDefaultDeclaration +): t.ObjectExpression | null => { + const decl = exportDefault.declaration; + if (decl.type === 'ObjectExpression') { + return decl; + } + if ( + decl.type === 'CallExpression' && + decl.callee.type === 'Identifier' && + decl.callee.name === 'defineConfig' && + decl.arguments[0]?.type === 'ObjectExpression' + ) { + return decl.arguments[0] as t.ObjectExpression; + } + if (decl.type === 'Identifier') { + const varName = decl.name; + const varDecl = target.program.body.find( + (n): n is t.VariableDeclaration => + n.type === 'VariableDeclaration' && + n.declarations.some((d) => d.id.type === 'Identifier' && d.id.name === varName) + ); + if (!varDecl) { + return null; + } + const declarator = varDecl.declarations.find( + (d) => d.id.type === 'Identifier' && d.id.name === varName + ); + if (!declarator?.init) { + return null; + } + const init = declarator.init; + if ( + init.type === 'CallExpression' && + init.callee.type === 'Identifier' && + init.callee.name === 'defineConfig' && + init.arguments[0]?.type === 'ObjectExpression' + ) { + return init.arguments[0] as t.ObjectExpression; + } + if (init.type === 'ObjectExpression') { + return init; + } + return null; + } + return null; +}; + /** * Merges a source Vitest configuration AST into a target configuration AST. * @@ -98,27 +152,42 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as } // Check if this is a function notation that we don't support + const rejectFunctionNotation = (decl: t.ExportDefaultDeclaration['declaration']) => { + if ( + decl.type === 'CallExpression' && + decl.callee.type === 'Identifier' && + decl.callee.name === 'defineConfig' && + decl.arguments.length > 0 && + decl.arguments[0].type === 'ArrowFunctionExpression' + ) { + return true; + } + return false; + }; if ( targetExportDefault.declaration.type === 'CallExpression' && - targetExportDefault.declaration.callee.type === 'Identifier' && - targetExportDefault.declaration.callee.name === 'defineConfig' && - targetExportDefault.declaration.arguments.length > 0 && - targetExportDefault.declaration.arguments[0].type === 'ArrowFunctionExpression' + rejectFunctionNotation(targetExportDefault.declaration) ) { - // This is function notation that we don't support return false; } + if (targetExportDefault.declaration.type === 'Identifier') { + const varName = targetExportDefault.declaration.name; + const varDecl = target.program.body.find( + (n): n is t.VariableDeclaration => + n.type === 'VariableDeclaration' && + n.declarations.some((d) => d.id.type === 'Identifier' && d.id.name === varName) + ); + const declarator = varDecl?.declarations.find( + (d) => d.id.type === 'Identifier' && d.id.name === varName + ); + if (declarator?.init?.type === 'CallExpression' && rejectFunctionNotation(declarator.init)) { + return false; + } + } - // Check if we can handle mergeConfig patterns + // Check if we can handle mergeConfig patterns (including export default config where config = defineConfig({})) let canHandleConfig = false; - if (targetExportDefault.declaration.type === 'ObjectExpression') { - canHandleConfig = true; - } else if ( - targetExportDefault.declaration.type === 'CallExpression' && - targetExportDefault.declaration.callee.type === 'Identifier' && - targetExportDefault.declaration.callee.name === 'defineConfig' && - targetExportDefault.declaration.arguments[0]?.type === 'ObjectExpression' - ) { + if (getTargetConfigObject(target, targetExportDefault) !== null) { canHandleConfig = true; } else if ( targetExportDefault.declaration.type === 'CallExpression' && @@ -173,16 +242,9 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as sourceNode.declaration.arguments[0].type === 'ObjectExpression' ) { const { properties } = sourceNode.declaration.arguments[0]; - if (exportDefault.declaration.type === 'ObjectExpression') { - mergeProperties(properties, exportDefault.declaration.properties); - updated = true; - } else if ( - exportDefault.declaration.type === 'CallExpression' && - exportDefault.declaration.callee.type === 'Identifier' && - exportDefault.declaration.callee.name === 'defineConfig' && - exportDefault.declaration.arguments[0]?.type === 'ObjectExpression' - ) { - mergeProperties(properties, exportDefault.declaration.arguments[0].properties); + const targetConfigObject = getTargetConfigObject(target, exportDefault); + if (targetConfigObject !== null) { + mergeProperties(properties, targetConfigObject.properties); updated = true; } else if ( exportDefault.declaration.type === 'CallExpression' && From babcf8559ae8a6f2022bb78ea3c0cfc20f10780d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 30 Jan 2026 10:41:51 +0100 Subject: [PATCH 5/8] Merge pull request #33712 from storybookjs/valentin/addon-vitest-skip-existing-configs Addon-Vitest: Skip postinstall setup when configured (cherry picked from commit 0da33fbb97ba168c0340f09ae3dc361e764ba5d9) --- code/addons/vitest/src/postinstall.test.ts | 49 +++++++++++ code/addons/vitest/src/postinstall.ts | 96 ++++++++++++++++++---- code/core/src/server-errors.ts | 16 ---- 3 files changed, 127 insertions(+), 34 deletions(-) create mode 100644 code/addons/vitest/src/postinstall.test.ts diff --git a/code/addons/vitest/src/postinstall.test.ts b/code/addons/vitest/src/postinstall.test.ts new file mode 100644 index 000000000000..e738d7abb4e2 --- /dev/null +++ b/code/addons/vitest/src/postinstall.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { isConfigAlreadySetup } from './postinstall'; + +describe('postinstall helpers', () => { + it('detects a fully configured Vitest config with addon plugin', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + plugins: [storybookTest({ configDir: '.storybook' })], + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(true); + }); + + it('returns false when storybookTest plugin is not used', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(false); + }); +}); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 76a89d325f6f..37abe7af4560 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; -import { babelParse, generate } from 'storybook/internal/babel'; +import { babelParse, generate, traverse } from 'storybook/internal/babel'; import { AddonVitestService } from 'storybook/internal/cli'; import { JsPackageManagerFactory, @@ -15,7 +15,6 @@ import type { StorybookError } from 'storybook/internal/server-errors'; import { AddonVitestPostinstallConfigUpdateError, AddonVitestPostinstallError, - AddonVitestPostinstallExistingSetupFileError, AddonVitestPostinstallFailedAddonA11yError, AddonVitestPostinstallPrerequisiteCheckError, AddonVitestPostinstallWorkspaceUpdateError, @@ -33,6 +32,7 @@ import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVit const ADDON_NAME = '@storybook/addon-vitest' as const; const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs']; +const STORYBOOK_TEST_PLUGIN_SOURCE = `${ADDON_NAME}/vitest-plugin`; const addonA11yName = '@storybook/addon-a11y'; @@ -174,17 +174,13 @@ export default async function postInstall(options: PostinstallOptions) { allDeps.typescript || findFile('tsconfig', [...EXTENSIONS, '.json']) ? 'ts' : 'js'; const vitestSetupFile = resolve(options.configDir, `vitest.setup.${fileExtension}`); + const existingSetupFile = + EXTENSIONS.map((ext) => resolve(options.configDir, `vitest.setup${ext}`)).find(existsSync) || + null; - if (existsSync(vitestSetupFile)) { - const errorMessage = dedent` - Found an existing Vitest setup file: - ${vitestSetupFile} - Please refer to the documentation to complete the setup manually: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced - `; - logger.line(); - logger.error(`${errorMessage}\n`); - errors.push(new AddonVitestPostinstallExistingSetupFileError({ filePath: vitestSetupFile })); + if (existingSetupFile) { + logger.step(`Found existing Vitest setup file, reusing:`); + logger.log(`${existingSetupFile}\n`); } else { logger.step(`Creating a Vitest setup file for Storybook:`); logger.log(`${vitestSetupFile}\n`); @@ -243,16 +239,25 @@ export default async function postInstall(options: PostinstallOptions) { // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. // We assume the existing workspaces include the Vite(st) config, so we won't add it. if (vitestWorkspaceFile) { + const workspaceFileContent = await fs.readFile(vitestWorkspaceFile, 'utf8'); + const alreadyConfigured = isConfigAlreadySetup(vitestWorkspaceFile, workspaceFileContent); + + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + return; + } + const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(vitestWorkspaceFile), vitestSetupFile), + SETUP_FILE: relative(dirname(vitestWorkspaceFile), existingSetupFile ?? vitestSetupFile), }).then((t) => t.replace(`\n 'ROOT_CONFIG',`, '').replace(/\s+extends: '',/, '')); - const workspaceFile = await fs.readFile(vitestWorkspaceFile, 'utf8'); const source = babelParse(workspaceTemplate); - const target = babelParse(workspaceFile); + const target = babelParse(workspaceFileContent); const updated = updateWorkspaceFile(source, target); if (updated) { @@ -290,10 +295,12 @@ export default async function postInstall(options: PostinstallOptions) { const templateName = getTemplateName(); - if (templateName) { + const alreadyConfigured = isConfigAlreadySetup(rootConfig, configFile); + + if (templateName && !alreadyConfigured) { const configTemplate = await loadTemplate(templateName, { CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(rootConfig), vitestSetupFile), + SETUP_FILE: relative(dirname(rootConfig), existingSetupFile ?? vitestSetupFile), }); const source = babelParse(configTemplate); @@ -301,7 +308,11 @@ export default async function postInstall(options: PostinstallOptions) { updated = updateConfigFile(source, target); } - if (target && updated) { + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + } else if (target && updated) { logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); logger.log(` ${rootConfig}`); @@ -412,3 +423,52 @@ export default async function postInstall(options: PostinstallOptions) { throw new AddonVitestPostinstallError({ errors }); } } + +function isStorybookTestPluginSource(value: string) { + return value === STORYBOOK_TEST_PLUGIN_SOURCE; +} + +export function isConfigAlreadySetup(_configPath: string, configContent: string) { + let ast: ReturnType; + try { + ast = babelParse(configContent); + } catch (e) { + return false; + } + + const pluginIdentifiers = new Set(); + + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + if (typeof source === 'string' && isStorybookTestPluginSource(source)) { + path.node.specifiers.forEach((specifier) => { + if ('local' in specifier && specifier.local?.name) { + pluginIdentifiers.add(specifier.local.name); + } + }); + } + }, + }); + + let pluginReferenced = false; + + traverse(ast, { + CallExpression(path) { + if (pluginReferenced) { + path.stop(); + return; + } + const callee = path.node.callee; + if ( + callee.type === 'Identifier' && + (pluginIdentifiers.has(callee.name) || callee.name === 'storybookTest') + ) { + pluginReferenced = true; + path.stop(); + } + }, + }); + + return pluginReferenced; +} diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index d163f611864d..9da582ca418b 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -482,22 +482,6 @@ export class AddonVitestPostinstallFailedAddonA11yError extends StorybookError { } } -export class AddonVitestPostinstallExistingSetupFileError extends StorybookError { - constructor(public data: { filePath: string }) { - super({ - name: 'AddonVitestPostinstallExistingSetupFileError', - category: Category.CLI_INIT, - isHandledError: true, - code: 7, - documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced`, - message: dedent` - Found an existing Vitest setup file: ${data.filePath} - Please refer to the documentation to complete the setup manually. - `, - }); - } -} - export class AddonVitestPostinstallWorkspaceUpdateError extends StorybookError { constructor(public data: { filePath: string }) { super({ From 69d8d3bac672cae7baacf8f77168cc74dcce4a83 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:39:35 +0000 Subject: [PATCH 6/8] Write changelog for 10.2.6 [skip ci] --- CHANGELOG.md | 7 +++++++ code/package.json | 3 ++- docs/versions/latest.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a63902dfa6c..6493743dae6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 10.2.6 + +- Addon-Vitest: Skip postinstall setup when configured - [#33712](https://github.com/storybookjs/storybook/pull/33712), thanks @valentinpalkovic! +- Addon-Vitest: Support vite/vitest config with deferred export - [#33755](https://github.com/storybookjs/storybook/pull/33755), thanks @valentinpalkovic! +- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic! +- Manager: Update logic to use base path instead of full pathname - [#33686](https://github.com/storybookjs/storybook/pull/33686), thanks @JSMike! + ## 10.2.5 - Angular: fix --loglevel options in docs and descriptions - [#33726](https://github.com/storybookjs/storybook/pull/33726), thanks @theRuslan! diff --git a/code/package.json b/code/package.json index fdf5ed207b79..abad2504fc30 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.2.6" } diff --git a/docs/versions/latest.json b/docs/versions/latest.json index dd857129d61f..1c90f745339f 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"10.2.5","info":{"plain":"- Angular: fix --loglevel options in docs and descriptions - [#33726](https://github.com/storybookjs/storybook/pull/33726), thanks @theRuslan!\n- Builder-Vite: Add plugin to enforce Storybook's output directory in Vite build configuration - [#33740](https://github.com/storybookjs/storybook/pull/33740), thanks @valentinpalkovic!\n- Core: Invalidate cache on Storybook version upgrade - [#33717](https://github.com/storybookjs/storybook/pull/33717), thanks @copilot-swe-agent!"}} \ No newline at end of file +{"version":"10.2.6","info":{"plain":"- Addon-Vitest: Skip postinstall setup when configured - [#33712](https://github.com/storybookjs/storybook/pull/33712), thanks @valentinpalkovic!\n- Addon-Vitest: Support vite/vitest config with deferred export - [#33755](https://github.com/storybookjs/storybook/pull/33755), thanks @valentinpalkovic!\n- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic!\n- Manager: Update logic to use base path instead of full pathname - [#33686](https://github.com/storybookjs/storybook/pull/33686), thanks @JSMike!"}} \ No newline at end of file From 9b895ecf30c8931d3c48bd2ccee64c9ea8cc881e Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 4 Feb 2026 12:04:45 +0100 Subject: [PATCH 7/8] Merge pull request #33718 from storybookjs/valentin/bundle-addon-vitest-postinstall-into-storybook CLI: Support addon-vitest setup when --skip-install is passed (cherry picked from commit 6d0104a9e075db372b889d5e836b6ef28f3c15eb) --- code/addons/a11y/src/postinstall.ts | 10 +- code/addons/vitest/src/postinstall.ts | 19 +- code/addons/vitest/src/typings.d.ts | 5 + .../vitest/src/updateVitestFile.test.ts | 36 +- code/addons/vitest/src/updateVitestFile.ts | 30 +- code/core/src/cli/AddonVitestService.ts | 7 +- .../js-package-manager/JsPackageManager.ts | 2 +- .../common/js-package-manager/PNPMProxy.ts | 8 +- .../cli-storybook/src/automigrate/index.ts | 7 +- .../AddonConfigurationCommand.test.ts | 392 +++++++++++++----- .../src/commands/AddonConfigurationCommand.ts | 33 +- code/lib/create-storybook/src/initiate.ts | 1 - scripts/build/utils/generate-bundle.ts | 2 + scripts/package.json | 1 + yarn.lock | 8 + 15 files changed, 399 insertions(+), 162 deletions(-) diff --git a/code/addons/a11y/src/postinstall.ts b/code/addons/a11y/src/postinstall.ts index d8cd0bf18b04..6fff2274b5ef 100644 --- a/code/addons/a11y/src/postinstall.ts +++ b/code/addons/a11y/src/postinstall.ts @@ -1,9 +1,13 @@ -import { JsPackageManagerFactory } from 'storybook/internal/common'; +import { JsPackageManagerFactory, versions } from 'storybook/internal/common'; import type { PostinstallOptions } from '../../../lib/cli-storybook/src/add'; export default async function postinstall(options: PostinstallOptions) { - const args = ['storybook', 'automigrate', 'addon-a11y-addon-test']; + const args = [ + options.skipInstall ? `storybook@${versions.storybook}` : `storybook`, + 'automigrate', + 'addon-a11y-addon-test', + ]; args.push('--loglevel', 'silent'); args.push('--skip-doctor'); @@ -25,5 +29,5 @@ export default async function postinstall(options: PostinstallOptions) { configDir: options.configDir, }); - await jsPackageManager.runPackageCommand({ args }); + await jsPackageManager.runPackageCommand({ args, useRemotePkg: !!options.skipInstall }); } diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 37abe7af4560..85a178ff3aa7 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -9,6 +9,7 @@ import { formatFileContent, getProjectRoot, getStorybookInfo, + versions, } from 'storybook/internal/common'; import { CLI_COLORS } from 'storybook/internal/node-logger'; import type { StorybookError } from 'storybook/internal/server-errors'; @@ -161,6 +162,7 @@ export default async function postInstall(options: PostinstallOptions) { if (!options.skipInstall) { await addonVitestService.installPlaywright({ yes: options.yes, + useRemotePkg: !!options.skipInstall, }); } else { logger.warn(dedent` @@ -229,11 +231,11 @@ export default async function postInstall(options: PostinstallOptions) { const getTemplateName = () => { if (isVitest4OrNewer) { - return 'vitest.config.4.template.ts'; + return 'vitest.config.4.template'; } else if (isVitest3_2To4) { - return 'vitest.config.3.2.template.ts'; + return 'vitest.config.3.2.template'; } - return 'vitest.config.template.ts'; + return 'vitest.config.template'; }; // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. @@ -249,7 +251,7 @@ export default async function postInstall(options: PostinstallOptions) { return; } - const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { + const workspaceTemplate = await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', @@ -358,7 +360,7 @@ export default async function postInstall(options: PostinstallOptions) { if (a11yAddon) { try { const command = [ - 'storybook', + options.skipInstall ? `storybook@${versions.storybook}` : `storybook`, 'automigrate', 'addon-a11y-addon-test', '--loglevel', @@ -381,7 +383,12 @@ export default async function postInstall(options: PostinstallOptions) { await prompt.executeTask( // TODO: Remove stdio: 'ignore' once we have a way to log the output of the command properly - () => packageManager.runPackageCommand({ args: command, stdio: 'ignore' }), + () => + packageManager.runPackageCommand({ + args: command, + stdio: 'ignore', + useRemotePkg: !!options.skipInstall, + }), { intro: 'Setting up a11y addon for @storybook/addon-vitest', error: 'Failed to setup a11y addon for @storybook/addon-vitest', diff --git a/code/addons/vitest/src/typings.d.ts b/code/addons/vitest/src/typings.d.ts index 235b6170bdd9..2a7a0f8e7a27 100644 --- a/code/addons/vitest/src/typings.d.ts +++ b/code/addons/vitest/src/typings.d.ts @@ -8,3 +8,8 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 02e8b4446579..c6e122eb3008 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -22,7 +22,7 @@ vi.mock('../../../core/src/shared/utils/module', () => ({ describe('updateConfigFile', () => { it('updates vite config file', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -102,7 +102,7 @@ describe('updateConfigFile', () => { it('supports object notation without defineConfig', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -182,7 +182,7 @@ describe('updateConfigFile', () => { it('does not support function notation', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -214,7 +214,7 @@ describe('updateConfigFile', () => { it('adds projects property to test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -378,7 +378,7 @@ describe('updateConfigFile', () => { it('edits projects property of test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -458,7 +458,7 @@ describe('updateConfigFile', () => { it('adds workspace property to test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -537,7 +537,7 @@ describe('updateConfigFile', () => { it('adds test property to vite config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -612,7 +612,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig with multiple defineConfig calls, finding the one with test', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -698,7 +698,7 @@ describe('updateConfigFile', () => { }); it('supports mergeConfig without defineConfig calls', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -781,7 +781,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig without config containing test property', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -857,7 +857,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig with defineConfig pattern using projects (Vitest 3.2+)', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -941,7 +941,7 @@ describe('updateConfigFile', () => { it('appends storybook project to existing test.projects array (no double nesting)', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1030,7 +1030,7 @@ describe('updateConfigFile', () => { it('extracts coverage config and keeps it at top level when using workspace', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1129,7 +1129,7 @@ describe('updateConfigFile', () => { it('extracts coverage config and keeps it at top level when using projects', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1230,7 +1230,7 @@ describe('updateConfigFile', () => { describe('updateWorkspaceFile', () => { it('updates vitest workspace file using array syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.ts', { + await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1286,7 +1286,7 @@ describe('updateWorkspaceFile', () => { it('updates vitest workspace file using defineWorkspace syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.ts', { + await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1349,7 +1349,7 @@ describe('loadTemplate', () => { // Windows-style path with backslashes (need to escape them in JS strings) const windowsPath = '.\\apps\\frontend-storybook\\.storybook'; - const result = await loadTemplate('vitest.config.template.ts', { + const result = await loadTemplate('vitest.config.template', { CONFIG_DIR: windowsPath, SETUP_FILE: '.\\apps\\frontend-storybook\\.storybook\\vitest.setup.ts', }); @@ -1363,7 +1363,7 @@ describe('loadTemplate', () => { // Unix-style path with forward slashes const unixPath = './apps/frontend-storybook/.storybook'; - const result = await loadTemplate('vitest.config.template.ts', { + const result = await loadTemplate('vitest.config.template', { CONFIG_DIR: unixPath, SETUP_FILE: './apps/frontend-storybook/.storybook/vitest.setup.ts', }); diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 6f0a782f5aa1..ca9a01bffd9a 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -1,16 +1,30 @@ -import * as fs from 'node:fs/promises'; - import type { BabelFile, types as t } from 'storybook/internal/babel'; -import { join, normalize } from 'pathe'; +import { normalize } from 'pathe'; -import { resolvePackageDir } from '../../../core/src/shared/utils/module'; +/** + * Each template is imported separately to allow the build system to process the template as raw + * text. A mix of globs and the "?raw" string query is not supported in esbuild + */ +async function getTemplatePath(name: string) { + switch (name) { + case 'vitest.config.template': + return import('../templates/vitest.config.template?raw'); + case 'vitest.config.4.template': + return import('../templates/vitest.config.4.template?raw'); + case 'vitest.config.3.2.template': + return import('../templates/vitest.config.3.2.template?raw'); + case 'vitest.workspace.template': + return import('../templates/vitest.workspace.template?raw'); + default: + throw new Error(`Unknown template: ${name}`); + } +} export const loadTemplate = async (name: string, replacements: Record) => { - let template = await fs.readFile( - join(resolvePackageDir('@storybook/addon-vitest'), 'templates', name), - 'utf8' - ); + // Dynamically import the template file as plain text + const templateModule = await getTemplatePath(name); + let template = templateModule.default; // Normalize Windows paths (backslashes) to forward slashes for JavaScript string compatibility Object.entries(replacements).forEach( ([key, value]) => (template = template.replace(key, normalize(value))) diff --git a/code/core/src/cli/AddonVitestService.ts b/code/core/src/cli/AddonVitestService.ts index a9771ca53438..d959c843c718 100644 --- a/code/core/src/cli/AddonVitestService.ts +++ b/code/core/src/cli/AddonVitestService.ts @@ -115,7 +115,11 @@ export class AddonVitestService { * @returns Array of error messages if installation fails */ async installPlaywright( - options: { yes?: boolean } = {} + options: { + yes?: boolean; + /** Is set to true if Storybook didn't install the dependencies yet */ + useRemotePkg?: boolean; + } = {} ): Promise<{ errors: string[]; result: 'installed' | 'skipped' | 'aborted' | 'failed' }> { const errors: string[] = []; @@ -148,6 +152,7 @@ export class AddonVitestService { (signal) => this.packageManager.runPackageCommand({ args: playwrightCommand, + useRemotePkg: options.useRemotePkg, stdio: ['inherit', 'pipe', 'pipe'], signal, }), diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 7221aa4c08d6..53be2316dbca 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -624,7 +624,7 @@ export abstract class JsPackageManager { stdio?: 'inherit' | 'pipe' | 'ignore' ): ResultPromise; public abstract runPackageCommand( - options: Omit & { args: string[] } + options: Omit & { args: string[]; useRemotePkg?: boolean } ): ResultPromise; public abstract findInstallations(pattern?: string[]): Promise; public abstract findInstallations( diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 29faee1efbe3..1123f792df44 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -79,11 +79,15 @@ export class PNPMProxy extends JsPackageManager { public runPackageCommand({ args, + useRemotePkg = false, ...options - }: Omit & { args: string[] }): ResultPromise { + }: Omit & { + args: string[]; + useRemotePkg?: boolean; + }): ResultPromise { return executeCommand({ command: 'pnpm', - args: ['exec', ...args], + args: [useRemotePkg ? 'dlx' : 'exec', ...args], ...options, }); } diff --git a/code/lib/cli-storybook/src/automigrate/index.ts b/code/lib/cli-storybook/src/automigrate/index.ts index c7a2a5b21a12..1b3820e9176b 100644 --- a/code/lib/cli-storybook/src/automigrate/index.ts +++ b/code/lib/cli-storybook/src/automigrate/index.ts @@ -1,4 +1,5 @@ import { type JsPackageManager } from 'storybook/internal/common'; +import { versions } from 'storybook/internal/common'; import { logTracker, logger, prompt } from 'storybook/internal/node-logger'; import { AutomigrateError } from 'storybook/internal/server-errors'; import type { StorybookConfigRaw } from 'storybook/internal/types'; @@ -56,10 +57,6 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { packageManagerName: options.packageManager, }); - if (!versionInstalled) { - throw new Error('Could not determine Storybook version'); - } - if (!mainConfigPath) { throw new Error('Could not determine main config path'); } @@ -67,7 +64,7 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { const outcome = await automigrate({ ...options, packageManager, - storybookVersion: versionInstalled, + storybookVersion: versionInstalled || versions.storybook, mainConfigPath, mainConfig, previewConfigPath, diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts index f852a85fa2e7..2df6ed0ca21a 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts @@ -1,164 +1,346 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { type JsPackageManager } from 'storybook/internal/common'; +import type { AddonVitestService } from 'storybook/internal/cli'; +import { type JsPackageManager, PackageManagerName } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; -import { TelemetryService } from '../services/TelemetryService'; -import { VersionService } from '../services/VersionService'; -import { AddonConfigurationCommand } from './AddonConfigurationCommand'; +import addonA11yPostinstall from '../../../../addons/a11y/src/postinstall'; +import addonVitestPostinstall from '../../../../addons/vitest/src/postinstall'; +import type { TelemetryService } from '../services'; +import { AddonConfigurationCommand, executeAddonConfiguration } from './AddonConfigurationCommand'; -vi.mock('storybook/internal/cli', { spy: true }); vi.mock('storybook/internal/node-logger', { spy: true }); -vi.mock('../../../cli-storybook/src/postinstallAddon', { spy: true }); -vi.mock('../services/TelemetryService', { spy: true }); -vi.mock('../services/VersionService', { spy: true }); +vi.mock('storybook/internal/telemetry', { spy: true }); +vi.mock('../../../../addons/a11y/src/postinstall', { spy: true }); +vi.mock('../../../../addons/vitest/src/postinstall', { spy: true }); +vi.mock('../../../cli-storybook/src/postinstallAddon', () => ({ + postinstallAddon: vi.fn().mockResolvedValue(undefined), +})); describe('AddonConfigurationCommand', () => { let command: AddonConfigurationCommand; - const mockPackageManager = { - type: 'npm', - getVersionedPackages: vi.fn(), - executeCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }), - } as Partial as JsPackageManager; - let mockTask: { - success: ReturnType; - error: ReturnType; - message: ReturnType; - group: ReturnType; - }; - let mockPostinstallAddon: ReturnType; - let mockAddonVitestService: ReturnType; - - beforeEach(async () => { - const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); - mockPostinstallAddon = vi.mocked(postinstallAddon); - mockPostinstallAddon.mockResolvedValue(undefined); - - // Mock the AddonVitestService - const { AddonVitestService } = await import('storybook/internal/cli'); - mockAddonVitestService = vi.mocked(AddonVitestService); - const mockInstance = { - installPlaywright: vi.fn().mockResolvedValue({ errors: [] }), - }; - mockAddonVitestService.mockImplementation(() => mockInstance as any); - - vi.mocked(VersionService).mockImplementation(() => ({})); - - vi.mocked(TelemetryService).mockImplementation((disableTelemetry: boolean = false) => { - return { - disableTelemetry, - versionService: new VersionService(), - }; - }); - - const mockAddonVitestServiceInstance = { - installPlaywright: vi.fn().mockResolvedValue({ errors: [] }), - }; - const mockTelemetryServiceInstance = { - trackPlaywrightPromptDecision: vi.fn(), - }; + let mockPackageManager: JsPackageManager; + let mockAddonVitestService: AddonVitestService; + let mockTelemetryService: TelemetryService; + let mockTaskLog: ReturnType; - command = new AddonConfigurationCommand( - mockPackageManager, - { - yes: true, - disableTelemetry: true, - } as any, - mockAddonVitestServiceInstance as any, - mockTelemetryServiceInstance as any - ); + beforeEach(() => { + mockPackageManager = { + type: 'npm', + } as Partial as JsPackageManager; - mockTask = { + mockAddonVitestService = { + installPlaywright: vi.fn().mockResolvedValue({ errors: [], result: 'installed' }), + } as Partial as AddonVitestService; + + mockTelemetryService = { + trackPlaywrightPromptDecision: vi.fn().mockResolvedValue(undefined), + } as Partial as TelemetryService; + + mockTaskLog = { + message: vi.fn(), success: vi.fn(), error: vi.fn(), - message: vi.fn(), - group: vi.fn(), - }; + } as unknown as ReturnType; - vi.mocked(prompt.taskLog).mockReturnValue(mockTask as any); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.error).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(ErrorCollector.addError).mockImplementation(() => {}); + vi.mocked(addonA11yPostinstall).mockResolvedValue(undefined); + vi.mocked(addonVitestPostinstall).mockResolvedValue(undefined); + + command = new AddonConfigurationCommand( + mockPackageManager, + { packageManager: PackageManagerName.NPM, yes: false, disableTelemetry: false }, + mockAddonVitestService, + mockTelemetryService + ); vi.clearAllMocks(); }); describe('execute', () => { - it('should skip configuration when no addons are provided', async () => { - const addons: string[] = []; + it('should return success when no configDir is provided', async () => { + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: undefined, + }); + + expect(result).toEqual({ status: 'success' }); + expect(prompt.taskLog).not.toHaveBeenCalled(); + }); + it('should return success when addons array is empty', async () => { const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: [], configDir: '.storybook', }); - expect(result.status).toBe('success'); + expect(result).toEqual({ status: 'success' }); expect(prompt.taskLog).not.toHaveBeenCalled(); - expect(mockPackageManager.getVersionedPackages).not.toHaveBeenCalled(); }); - it('should configure test addons when test feature is enabled', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + it('should configure vitest addon successfully', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-vitest'], configDir: '.storybook', }); - expect(result.status).toBe('success'); - expect(prompt.taskLog).toHaveBeenCalledWith({ - id: 'configure-addons', - title: 'Configuring addons...', + expect(result).toEqual({ status: 'success' }); + expect(addonVitestPostinstall).toHaveBeenCalledWith({ + packageManager: 'npm', + configDir: '.storybook', + yes: false, + skipInstall: true, + skipDependencyManagement: true, + logger, + prompt, }); + expect(mockAddonVitestService.installPlaywright).toHaveBeenCalledWith({ + yes: false, + useRemotePkg: false, + }); + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('installed'); + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); }); - it('should handle configuration errors gracefully', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; - const error = new Error('Configuration failed'); - - mockPostinstallAddon.mockRejectedValue(error); + it('should configure a11y addon successfully', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-a11y'], configDir: '.storybook', }); - expect(result.status).toBe('failed'); - expect(mockTask.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to configure addons') - ); + expect(result).toEqual({ status: 'success' }); + expect(addonA11yPostinstall).toHaveBeenCalledWith({ + packageManager: 'npm', + configDir: '.storybook', + yes: false, + skipInstall: true, + skipDependencyManagement: true, + logger, + prompt, + }); + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); }); - it('should complete successfully with valid configuration', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + it('should configure generic addon via postinstallAddon', async () => { + const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-docs'], configDir: '.storybook', }); - expect(result.status).toBe('success'); - expect(mockPostinstallAddon).toHaveBeenCalledTimes(2); - expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-a11y', { + expect(result).toEqual({ status: 'success' }); + expect(postinstallAddon).toHaveBeenCalledWith('@storybook/addon-docs', { packageManager: 'npm', configDir: '.storybook', - yes: true, + yes: false, skipInstall: true, skipDependencyManagement: true, - logger: expect.any(Object), - prompt: expect.any(Object), + logger, + prompt, }); - expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-vitest', { - packageManager: 'npm', + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); + }); + + it('should configure multiple addons', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest', '@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + expect(addonVitestPostinstall).toHaveBeenCalled(); + expect(addonA11yPostinstall).toHaveBeenCalled(); + expect(mockTaskLog.message).toHaveBeenCalledWith('Configuring @storybook/addon-vitest...'); + expect(mockTaskLog.message).toHaveBeenCalledWith('Configuring @storybook/addon-a11y...'); + }); + + it('should handle addon configuration failure gracefully', async () => { + const error = new Error('Configuration failed'); + vi.mocked(addonVitestPostinstall).mockRejectedValue(error); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(logger.debug).toHaveBeenCalledWith(error); + expect(ErrorCollector.addError).toHaveBeenCalledWith(error); + expect(mockTaskLog.error).toHaveBeenCalledWith('Failed to configure addons'); + }); + + it('should handle partial addon failures', async () => { + const error = new Error('Vitest configuration failed'); + vi.mocked(addonVitestPostinstall).mockRejectedValue(error); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest', '@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(addonA11yPostinstall).toHaveBeenCalled(); + expect(mockTaskLog.error).toHaveBeenCalledWith('Failed to configure addons'); + }); + + it('should handle unexpected errors during execution', async () => { + const unexpectedError = new Error('Unexpected error'); + vi.mocked(prompt.taskLog).mockImplementation(() => { + throw unexpectedError; + }); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(logger.error).toHaveBeenCalledWith('Unexpected error during addon configuration:'); + expect(logger.error).toHaveBeenCalledWith(unexpectedError); + }); + + it('should not install Playwright when vitest addon is not configured', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(mockAddonVitestService.installPlaywright).not.toHaveBeenCalled(); + expect(mockTelemetryService.trackPlaywrightPromptDecision).not.toHaveBeenCalled(); + }); + + it('should track skipped Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: [], + result: 'skipped', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('skipped'); + }); + + it('should track aborted Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: [], + result: 'aborted', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('aborted'); + }); + + it('should track failed Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: ['Installation error'], + result: 'failed', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('failed'); + }); + + it('should pass yes option to addon postinstall functions', async () => { + const commandWithYes = new AddonConfigurationCommand( + mockPackageManager, + { packageManager: PackageManagerName.NPM, yes: true, disableTelemetry: false }, + mockAddonVitestService, + mockTelemetryService + ); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await commandWithYes.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(addonVitestPostinstall).toHaveBeenCalledWith(expect.objectContaining({ yes: true })); + expect(mockAddonVitestService.installPlaywright).toHaveBeenCalledWith({ yes: true, - skipInstall: true, - skipDependencyManagement: true, - logger: expect.any(Object), - prompt: expect.any(Object), + useRemotePkg: false, }); }); }); }); + +describe('executeAddonConfiguration', () => { + let mockPackageManager: JsPackageManager; + let mockTaskLog: ReturnType; + + beforeEach(() => { + mockPackageManager = { + type: 'npm', + } as Partial as JsPackageManager; + + mockTaskLog = { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType; + + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.error).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(addonA11yPostinstall).mockResolvedValue(undefined); + vi.mocked(addonVitestPostinstall).mockResolvedValue(undefined); + + vi.clearAllMocks(); + }); + + it('should create command and execute with provided parameters', async () => { + const result = await executeAddonConfiguration({ + packageManager: mockPackageManager, + options: { packageManager: PackageManagerName.NPM, yes: false, disableTelemetry: true }, + addons: [], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + }); + + it('should execute addon configuration through helper function', async () => { + const result = await executeAddonConfiguration({ + packageManager: mockPackageManager, + options: { packageManager: PackageManagerName.NPM, yes: true, disableTelemetry: false }, + addons: ['@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + expect(addonA11yPostinstall).toHaveBeenCalled(); + }); +}); diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index 222710d0f186..017fdb166150 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -5,6 +5,8 @@ import { ErrorCollector } from 'storybook/internal/telemetry'; import { dedent } from 'ts-dedent'; +import addonA11yPostinstall from '../../../../addons/a11y/src/postinstall'; +import addonVitestPostinstall from '../../../../addons/vitest/src/postinstall'; import type { CommandOptions } from '../generators/types'; import { TelemetryService } from '../services'; @@ -16,7 +18,6 @@ const ADDON_INSTALLATION_INSTRUCTIONS = { type ExecuteAddonConfigurationParams = { addons: string[]; configDir?: string; - dependencyInstallationResult: { status: 'success' | 'failed' }; }; export type ExecuteAddonConfigurationResult = { @@ -44,16 +45,7 @@ export class AddonConfigurationCommand { async execute({ addons, configDir, - dependencyInstallationResult, }: ExecuteAddonConfigurationParams): Promise { - const areDependenciesInstalled = - dependencyInstallationResult.status === 'success' && !this.commandOptions.skipInstall; - - if (!areDependenciesInstalled && this.getAddonsWithInstructions(addons).length > 0) { - this.logManualAddonInstructions(addons); - return { status: 'failed' }; - } - if (!configDir || addons.length === 0) { return { status: 'success' }; } @@ -64,11 +56,19 @@ export class AddonConfigurationCommand { if (addonResults.has('@storybook/addon-vitest')) { const { result } = await this.addonVitestService.installPlaywright({ yes: this.commandOptions.yes, + useRemotePkg: !!this.commandOptions.skipInstall, }); // Map outcome to telemetry decision await this.telemetryService.trackPlaywrightPromptDecision(result); } + // some addons failed + if (hasFailures) { + this.logManualAddonInstructions( + addons.filter((addon) => addonResults.get(addon)?.result === 'failed') + ); + } + return { status: hasFailures ? 'failed' : 'success' }; } catch (e) { logger.error('Unexpected error during addon configuration:'); @@ -123,7 +123,7 @@ export class AddonConfigurationCommand { try { task.message(`Configuring ${addon}...`); - await postinstallAddon(addon, { + const options = { packageManager: this.packageManager.type, configDir, yes: this.commandOptions.yes, @@ -131,11 +131,20 @@ export class AddonConfigurationCommand { skipDependencyManagement: true, logger, prompt, - }); + }; + + if (addon === '@storybook/addon-vitest') { + await addonVitestPostinstall(options); + } else if (addon === '@storybook/addon-a11y') { + await addonA11yPostinstall(options); + } else { + await postinstallAddon(addon, options); + } task.message(`${addon} configured\n`); addonResults.set(addon, null); } catch (e) { + logger.debug(e); ErrorCollector.addError(e); addonResults.set(addon, e); } diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 96759086aa20..fafee5b90be7 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -98,7 +98,6 @@ export async function doInitiate(options: CommandOptions): Promise< packageManager, addons: extraAddons, configDir, - dependencyInstallationResult, options, }); diff --git a/scripts/build/utils/generate-bundle.ts b/scripts/build/utils/generate-bundle.ts index 5d9c30470fcb..a35397324568 100644 --- a/scripts/build/utils/generate-bundle.ts +++ b/scripts/build/utils/generate-bundle.ts @@ -7,6 +7,7 @@ import * as esbuild from 'esbuild'; import { basename, join, relative } from 'pathe'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; +import {raw as rawPlugin} from 'esbuild-raw-plugin'; import { globalsModuleInfoMap } from '../../../code/core/src/manager/globals/globals-module-info'; import { @@ -99,6 +100,7 @@ export async function generateBundle({ 'process.env.NODE_ENV': 'process.env.NODE_ENV', }, plugins: [ + rawPlugin(), { name: 'postbuild', setup(build) { diff --git a/scripts/package.json b/scripts/package.json index 79b3a1f2ca54..46065ad6d5ef 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -103,6 +103,7 @@ "empathic": "^2.0.0", "es-toolkit": "^1.43.0", "esbuild": "^0.27.0", + "esbuild-raw-plugin": "^0.3.1", "eslint": "^8.57.1", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.2", diff --git a/yarn.lock b/yarn.lock index c007770a0eb0..4cecb506d0ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8514,6 +8514,7 @@ __metadata: empathic: "npm:^2.0.0" es-toolkit: "npm:^1.43.0" esbuild: "npm:^0.27.0" + esbuild-raw-plugin: "npm:^0.3.1" eslint: "npm:^8.57.1" eslint-config-airbnb-typescript: "npm:^18.0.0" eslint-config-prettier: "npm:^9.1.2" @@ -15930,6 +15931,13 @@ __metadata: languageName: node linkType: hard +"esbuild-raw-plugin@npm:^0.3.1": + version: 0.3.1 + resolution: "esbuild-raw-plugin@npm:0.3.1" + checksum: 10c0/25f8934cdddfc49bd1942a76d9d742e0abf50d85729ba506c1804475e318672143781039dc0614b47c7eb01aa1a797901245cc37fb1ce968974a038f8846b9d2 + languageName: node + linkType: hard + "esbuild-wasm@npm:0.25.4": version: 0.25.4 resolution: "esbuild-wasm@npm:0.25.4" From 40ef0ea51d51e4c4650ee5819b0ea0cc06241b38 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 4 Feb 2026 14:41:43 +0100 Subject: [PATCH 8/8] Fix tests --- code/addons/vitest/src/updateVitestFile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index c6e122eb3008..5bf73bfee6e0 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -293,7 +293,7 @@ describe('updateConfigFile', () => { it('updates config which is not exported immediately', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts',