From 09a3fdbe9e3d85fdee1723c2f5c1389b6a4d3882 Mon Sep 17 00:00:00 2001 From: ia319 Date: Fri, 5 Dec 2025 14:35:53 +0800 Subject: [PATCH 01/26] fix(core): add showSelectedLabel prop to Select to control selected label rendering --- code/core/src/components/components/Select/Select.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 961e2990e9e6..1727594b0e8c 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -76,6 +76,11 @@ export interface SelectProps onSelect?: (option: Value) => void; onDeselect?: (option: Value) => void; onChange?: (selected: Value[]) => void; + /** + * Show the selected option's title in place of the children when an option is selected. + * Defaults to true. + */ + showSelectedLabel?: boolean; } function valueToId(parentId: string, { value }: InternalOption | ResetOption): string { @@ -200,6 +205,7 @@ export const Select = forwardRef( onChange, tooltip, ariaLabel, + showSelectedLabel = true, ...props }, ref @@ -501,7 +507,7 @@ export const Select = forwardRef( {!multiSelect && ( <> {icon} - {selectedOptions[0]?.title ?? children} + {(showSelectedLabel && selectedOptions[0]?.title) || children} )} From a3bc0893507fedf31748b632248ec4e35442fc98 Mon Sep 17 00:00:00 2001 From: ia319 Date: Fri, 5 Dec 2025 14:37:07 +0800 Subject: [PATCH 02/26] fix(core): disable Select selected-label override in ToolbarMenuSelect --- code/core/src/toolbar/components/ToolbarMenuSelect.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index 886da8cb4acc..084c69f0af5d 100644 --- a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx +++ b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx @@ -101,6 +101,7 @@ export const ToolbarMenuSelect: FC = withKeyboardCycle( onReset={() => updateGlobals({ [id]: '_reset' })} onSelect={(selected) => updateGlobals({ [id]: selected })} icon={icon && } + showSelectedLabel={false} > {title} From 6e1cae051c475f40625f477a5f0701894aa599f5 Mon Sep 17 00:00:00 2001 From: ia319 Date: Fri, 5 Dec 2025 15:22:11 +0800 Subject: [PATCH 03/26] fix lint error --- code/core/src/components/components/Select/Select.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 1727594b0e8c..97f97fec0e57 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -77,8 +77,8 @@ export interface SelectProps onDeselect?: (option: Value) => void; onChange?: (selected: Value[]) => void; /** - * Show the selected option's title in place of the children when an option is selected. - * Defaults to true. + * Whether to show the selected option's title in place of the children when an option is + * selected. Defaults to true. */ showSelectedLabel?: boolean; } From a0befe0ac7f8c0a3d41725c50b01aad976f4d6cc Mon Sep 17 00:00:00 2001 From: ia319 Date: Sat, 6 Dec 2025 19:25:37 +0800 Subject: [PATCH 04/26] refactor (ToolbarMenuSelect): remove manual title calculation, remove unused import, pass dynamicTitle via showSelectedLabel --- code/core/src/toolbar/components/ToolbarMenuSelect.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index 084c69f0af5d..8edaa9559046 100644 --- a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx +++ b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx @@ -10,7 +10,7 @@ import { Icons } from '../../components/components/icon/icon'; import type { WithKeyboardCycleProps } from '../hoc/withKeyboardCycle'; import { withKeyboardCycle } from '../hoc/withKeyboardCycle'; import type { ToolbarItem, ToolbarMenuProps } from '../types'; -import { getSelectedIcon, getSelectedTitle } from '../utils/get-selected'; +import { getSelectedIcon } from '../utils/get-selected'; // We can't remove the Icons component just yet because there's no way for now to import icons // in the preview directly. Before having a better solution, we are going to keep the Icons component @@ -40,16 +40,12 @@ export const ToolbarMenuSelect: FC = withKeyboardCycle( const currentValue = globals[id]; const isOverridden = id in storyGlobals; let icon = _icon; - let title = _title; + const title = _title; if (!preventDynamicIcon) { icon = getSelectedIcon({ currentValue, items }) || icon; } - if (dynamicTitle) { - title = getSelectedTitle({ currentValue, items }) || title; - } - if (!title && !icon) { console.warn(`Toolbar '${name}' has no title or icon`); } @@ -101,7 +97,7 @@ export const ToolbarMenuSelect: FC = withKeyboardCycle( onReset={() => updateGlobals({ [id]: '_reset' })} onSelect={(selected) => updateGlobals({ [id]: selected })} icon={icon && } - showSelectedLabel={false} + showSelectedLabel={dynamicTitle} > {title} From 75c940328929e4ee4d43634f1c24bfdcc8d548c7 Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 11 Dec 2025 14:29:35 +0800 Subject: [PATCH 05/26] refactor: rename legacy Select prop and clean up ToolbarMenuSelect title handling - renamed `showSelectedLabel` to `showSelectedOptionTitle` and marked as legacy - removed unused `_title` alias and redundant title reassignment - enabled `dynamicTitle` by default --- .../core/src/components/components/Select/Select.tsx | 12 +++++++----- .../src/toolbar/components/ToolbarMenuSelect.tsx | 5 ++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 97f97fec0e57..102d756e2dfa 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -77,10 +77,12 @@ export interface SelectProps onDeselect?: (option: Value) => void; onChange?: (selected: Value[]) => void; /** - * Whether to show the selected option's title in place of the children when an option is - * selected. Defaults to true. + * Legacy option for ToolbarMenuSelect. Do not use in new code. Controls whether to show the + * selected option's title. + * + * @default true */ - showSelectedLabel?: boolean; + showSelectedOptionTitle?: boolean; } function valueToId(parentId: string, { value }: InternalOption | ResetOption): string { @@ -205,7 +207,7 @@ export const Select = forwardRef( onChange, tooltip, ariaLabel, - showSelectedLabel = true, + showSelectedOptionTitle = true, ...props }, ref @@ -507,7 +509,7 @@ export const Select = forwardRef( {!multiSelect && ( <> {icon} - {(showSelectedLabel && selectedOptions[0]?.title) || children} + {(showSelectedOptionTitle && selectedOptions[0]?.title) || children} )} diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index 8edaa9559046..918b9d600c9a 100644 --- a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx +++ b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx @@ -33,14 +33,13 @@ export const ToolbarMenuSelect: FC = withKeyboardCycle( id, name, description, - toolbar: { icon: _icon, items, title: _title, preventDynamicIcon, dynamicTitle }, + toolbar: { icon: _icon, items, title, preventDynamicIcon, dynamicTitle = true }, }) => { const [globals, updateGlobals, storyGlobals] = useGlobals(); const currentValue = globals[id]; const isOverridden = id in storyGlobals; let icon = _icon; - const title = _title; if (!preventDynamicIcon) { icon = getSelectedIcon({ currentValue, items }) || icon; @@ -97,7 +96,7 @@ export const ToolbarMenuSelect: FC = withKeyboardCycle( onReset={() => updateGlobals({ [id]: '_reset' })} onSelect={(selected) => updateGlobals({ [id]: selected })} icon={icon && } - showSelectedLabel={dynamicTitle} + showSelectedOptionTitle={dynamicTitle} > {title} From 1134d29371cb82e5f4db6e2d2e7c29d505caec9c Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 11 Dec 2025 14:35:09 +0800 Subject: [PATCH 06/26] chore: update globaltypes snippet and remove unused getSelectedTitle --- code/core/src/toolbar/utils/get-selected.ts | 8 -------- docs/_snippets/storybook-preview-configure-globaltypes.md | 8 ++++---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/code/core/src/toolbar/utils/get-selected.ts b/code/core/src/toolbar/utils/get-selected.ts index 610e1fa9a68f..51821cb7582e 100644 --- a/code/core/src/toolbar/utils/get-selected.ts +++ b/code/core/src/toolbar/utils/get-selected.ts @@ -19,11 +19,3 @@ export const getSelectedIcon = ({ currentValue, items }: GetSelectedItemProps) = } return undefined; }; - -export const getSelectedTitle = ({ currentValue, items }: GetSelectedItemProps) => { - const selectedItem = getSelectedItem({ currentValue, items }); - if (selectedItem) { - return selectedItem.title; - } - return undefined; -}; diff --git a/docs/_snippets/storybook-preview-configure-globaltypes.md b/docs/_snippets/storybook-preview-configure-globaltypes.md index 06b30b0a0bae..d7c409e3e739 100644 --- a/docs/_snippets/storybook-preview-configure-globaltypes.md +++ b/docs/_snippets/storybook-preview-configure-globaltypes.md @@ -9,7 +9,7 @@ const preview = { icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -36,7 +36,7 @@ const preview: Preview = { icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -63,7 +63,7 @@ export default definePreview({ icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, @@ -90,7 +90,7 @@ export default definePreview({ icon: 'circlehollow', // Array of plain string values or MenuItem shape (see below) items: ['light', 'dark'], - // Change title based on selected value + // Change title based on selected value (recommended for consistency with the Storybook UI) dynamicTitle: true, }, }, From 303fccc9405fa20875ded2101ca87f5ddcd06fac Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 11 Dec 2025 21:55:44 +0800 Subject: [PATCH 07/26] test(core): add four stories validating Select label behavior added new stories demonstrating how labels render when: - single-select: showSelectedOptionTitle = true / false - multi-select: showSelectedOptionTitle = true / false --- .../components/Select/Select.stories.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/code/core/src/components/components/Select/Select.stories.tsx b/code/core/src/components/components/Select/Select.stories.tsx index 18b232b3a7c5..4a59912b16bd 100644 --- a/code/core/src/components/components/Select/Select.stories.tsx +++ b/code/core/src/components/components/Select/Select.stories.tsx @@ -1331,3 +1331,61 @@ export const ResetWithUndefinedOption = meta.story({ }); }, }); + +export const ShowSelectedOptionTitleTrue = meta.story({ + name: 'Show Selected Option Title (prop=true)', + args: { + showSelectedOptionTitle: true, + defaultOptions: 'frog', + }, + play: async ({ canvas, step }) => { + await step('Verify selected option title is shown', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Frog'); + }); + }, +}); + +export const ShowSelectedOptionTitleFalse = meta.story({ + name: 'Show Selected Option Title (prop=false)', + args: { + showSelectedOptionTitle: false, + defaultOptions: 'frog', + }, + play: async ({ canvas, step }) => { + await step('Verify default title is shown instead of selected option', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Animal'); + }); + }, +}); + +export const ShowSelectedOptionTitleFalseMulti = meta.story({ + name: 'Show Selected Option Title (prop=false, multi)', + args: { + showSelectedOptionTitle: false, + multiSelect: true, + defaultOptions: ['frog', 'tadpole'], + }, + play: async ({ canvas, step }) => { + await step('Verify default title is shown for multi-select', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('Animal'); + }); + }, +}); + +export const ShowSelectedOptionTitleTrueMulti = meta.story({ + name: 'Show Selected Option Title (prop=true, multi)', + args: { + showSelectedOptionTitle: true, + multiSelect: true, + defaultOptions: ['frog'], + }, + play: async ({ canvas, step }) => { + await step('Verify option count is shown for multi-select', async () => { + const selectButton = await canvas.findByRole('button'); + expect(selectButton).toHaveTextContent('1'); + }); + }, +}); From 304eb5aa8ca0f3ab97afc5b4c7919dcd0ded88ab Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 30 Jan 2026 13:45:34 +0100 Subject: [PATCH 08/26] CLI: Support addon-vitest setup when --skip-install is passed --- code/addons/a11y/src/postinstall.ts | 4 +- code/addons/vitest/src/postinstall.ts | 3 +- .../cli-storybook/src/automigrate/index.ts | 7 +-- .../src/commands/AddonConfigurationCommand.ts | 56 +++---------------- code/lib/create-storybook/src/initiate.ts | 1 - 5 files changed, 15 insertions(+), 56 deletions(-) diff --git a/code/addons/a11y/src/postinstall.ts b/code/addons/a11y/src/postinstall.ts index d8cd0bf18b04..6176a3fceb15 100644 --- a/code/addons/a11y/src/postinstall.ts +++ b/code/addons/a11y/src/postinstall.ts @@ -1,9 +1,9 @@ -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 = [`storybook@${versions.storybook}`, 'automigrate', 'addon-a11y-addon-test']; args.push('--loglevel', 'silent'); args.push('--skip-doctor'); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 37abe7af4560..3b2813b7f88b 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'; @@ -358,7 +359,7 @@ export default async function postInstall(options: PostinstallOptions) { if (a11yAddon) { try { const command = [ - 'storybook', + `storybook@${versions.storybook}`, 'automigrate', 'addon-a11y-addon-test', '--loglevel', 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.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index 222710d0f186..43a62bcbd32d 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -3,20 +3,13 @@ import { type JsPackageManager } from 'storybook/internal/common'; import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; import { ErrorCollector } from 'storybook/internal/telemetry'; -import { dedent } from 'ts-dedent'; - +import addonVitestPostinstall from '../../../../addons/vitest/src/postinstall'; import type { CommandOptions } from '../generators/types'; import { TelemetryService } from '../services'; -const ADDON_INSTALLATION_INSTRUCTIONS = { - '@storybook/addon-vitest': - 'https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced', -} as { [key: string]: string }; - type ExecuteAddonConfigurationParams = { addons: string[]; configDir?: string; - dependencyInstallationResult: { status: 'success' | 'failed' }; }; export type ExecuteAddonConfigurationResult = { @@ -44,16 +37,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' }; } @@ -77,34 +61,6 @@ export class AddonConfigurationCommand { } } - private getAddonsWithInstructions(addons: string[]): string[] { - return addons.filter((addon) => ADDON_INSTALLATION_INSTRUCTIONS[addon]); - } - - private logManualAddonInstructions(addons: string[]): void { - const addonsWithInstructions = this.getAddonsWithInstructions(addons); - - if (addonsWithInstructions.length > 0) { - logger.warn(dedent` - The following addons couldn't be configured: - - ${addonsWithInstructions - .map((addon) => { - const manualInstructionLink = ADDON_INSTALLATION_INSTRUCTIONS[addon]; - - return `- ${addon}: ${manualInstructionLink}`; - }) - .join('\n')} - - ${ - addonsWithInstructions.length > 0 - ? `Please follow each addon's configuration instructions manually.` - : '' - } - `); - } - } - /** Configure test addons (a11y and vitest) */ private async configureAddons(configDir: string, addons: string[]) { // Import postinstallAddon from cli-storybook package @@ -123,7 +79,7 @@ export class AddonConfigurationCommand { try { task.message(`Configuring ${addon}...`); - await postinstallAddon(addon, { + const options = { packageManager: this.packageManager.type, configDir, yes: this.commandOptions.yes, @@ -131,7 +87,13 @@ export class AddonConfigurationCommand { skipDependencyManagement: true, logger, prompt, - }); + }; + + if (addon === '@storybook/addon-vitest') { + await addonVitestPostinstall(options); + } else { + await postinstallAddon(addon, options); + } task.message(`${addon} configured\n`); addonResults.set(addon, null); 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, }); From 906df16cdb81f5b3b431861e92e32cbbd0b6004d Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 30 Jan 2026 15:20:44 +0100 Subject: [PATCH 09/26] Refactor addon-vitest templates and postinstall logic to use .txt extensions. Update template loading mechanism to dynamically import templates as plain text. Enhance error logging in AddonConfigurationCommand. --- code/addons/vitest/src/postinstall.ts | 8 ++++---- code/addons/vitest/src/updateVitestFile.ts | 13 ++++--------- ...2.template.ts => vitest.config.3.2.template.txt} | 0 ...g.4.template.ts => vitest.config.4.template.txt} | 0 ...onfig.template.ts => vitest.config.template.txt} | 0 ...ce.template.ts => vitest.workspace.template.txt} | 0 .../src/commands/AddonConfigurationCommand.ts | 1 + 7 files changed, 9 insertions(+), 13 deletions(-) rename code/addons/vitest/templates/{vitest.config.3.2.template.ts => vitest.config.3.2.template.txt} (100%) rename code/addons/vitest/templates/{vitest.config.4.template.ts => vitest.config.4.template.txt} (100%) rename code/addons/vitest/templates/{vitest.config.template.ts => vitest.config.template.txt} (100%) rename code/addons/vitest/templates/{vitest.workspace.template.ts => vitest.workspace.template.txt} (100%) diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 3b2813b7f88b..9a7ca8d4b785 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -230,11 +230,11 @@ export default async function postInstall(options: PostinstallOptions) { const getTemplateName = () => { if (isVitest4OrNewer) { - return 'vitest.config.4.template.ts'; + return 'vitest.config.4.template.txt'; } else if (isVitest3_2To4) { - return 'vitest.config.3.2.template.ts'; + return 'vitest.config.3.2.template.txt'; } - return 'vitest.config.template.ts'; + return 'vitest.config.template.txt'; }; // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. @@ -250,7 +250,7 @@ export default async function postInstall(options: PostinstallOptions) { return; } - const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { + const workspaceTemplate = await loadTemplate('vitest.workspace.template.txt', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 46661c099849..c27914b4040d 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -1,16 +1,11 @@ -import * as fs from 'node:fs/promises'; - import type { BabelFile, types as t } from 'storybook/internal/babel'; -import { join, normalize } from 'pathe'; - -import { resolvePackageDir } from '../../../core/src/shared/utils/module'; +import { normalize } from 'pathe'; 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 import(`../templates/${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/addons/vitest/templates/vitest.config.3.2.template.ts b/code/addons/vitest/templates/vitest.config.3.2.template.txt similarity index 100% rename from code/addons/vitest/templates/vitest.config.3.2.template.ts rename to code/addons/vitest/templates/vitest.config.3.2.template.txt diff --git a/code/addons/vitest/templates/vitest.config.4.template.ts b/code/addons/vitest/templates/vitest.config.4.template.txt similarity index 100% rename from code/addons/vitest/templates/vitest.config.4.template.ts rename to code/addons/vitest/templates/vitest.config.4.template.txt diff --git a/code/addons/vitest/templates/vitest.config.template.ts b/code/addons/vitest/templates/vitest.config.template.txt similarity index 100% rename from code/addons/vitest/templates/vitest.config.template.ts rename to code/addons/vitest/templates/vitest.config.template.txt diff --git a/code/addons/vitest/templates/vitest.workspace.template.ts b/code/addons/vitest/templates/vitest.workspace.template.txt similarity index 100% rename from code/addons/vitest/templates/vitest.workspace.template.ts rename to code/addons/vitest/templates/vitest.workspace.template.txt diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index 43a62bcbd32d..4cb5291e5d00 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -98,6 +98,7 @@ export class AddonConfigurationCommand { task.message(`${addon} configured\n`); addonResults.set(addon, null); } catch (e) { + logger.debug(e); ErrorCollector.addError(e); addonResults.set(addon, e); } From 7a5f4d76408cbc3ccf3b56982f159553b4b0fcdb Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Fri, 30 Jan 2026 15:50:09 +0100 Subject: [PATCH 10/26] Fix tests --- .../vitest/src/updateVitestFile.test.ts | 56 +++++++++++++------ .../AddonConfigurationCommand.test.ts | 4 -- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 89cfe368a6c1..9dec63b00612 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -1,9 +1,12 @@ +import { fstat, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import * as babel from 'storybook/internal/babel'; +import { normalize } from 'pathe'; + import { getDiff } from '../../../core/src/core-server/utils/save-story/getDiff'; import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVitestFile'; @@ -19,10 +22,27 @@ vi.mock('../../../core/src/shared/utils/module', () => ({ resolvePackageDir: vi.fn().mockImplementation(() => join(__dirname, '..')), })); +vi.mock(import('./updateVitestFile'), async (actualModule) => { + const mod = await actualModule(); + return { + ...mod, + loadTemplate: async (name: string, replacements: Record) => { + // Dynamically import the template file as plain text + const templateModule = await import(`../templates/${name}?raw`); + 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))) + ); + return template; + }, + }; +}); + describe('updateConfigFile', () => { it('updates vite config file', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -102,7 +122,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -182,7 +202,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -214,7 +234,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -293,7 +313,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -373,7 +393,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -452,7 +472,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -527,7 +547,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -613,7 +633,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -696,7 +716,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -772,7 +792,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -856,7 +876,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -945,7 +965,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1044,7 +1064,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.txt', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1145,7 +1165,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.txt', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1201,7 +1221,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.txt', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1264,7 +1284,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.txt', { CONFIG_DIR: windowsPath, SETUP_FILE: '.\\apps\\frontend-storybook\\.storybook\\vitest.setup.ts', }); @@ -1278,7 +1298,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.txt', { CONFIG_DIR: unixPath, SETUP_FILE: './apps/frontend-storybook/.storybook/vitest.setup.ts', }); diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts index f852a85fa2e7..da9820ec877c 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts @@ -86,7 +86,6 @@ describe('AddonConfigurationCommand', () => { const addons: string[] = []; const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, addons, configDir: '.storybook', }); @@ -100,7 +99,6 @@ describe('AddonConfigurationCommand', () => { const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, addons, configDir: '.storybook', }); @@ -119,7 +117,6 @@ describe('AddonConfigurationCommand', () => { mockPostinstallAddon.mockRejectedValue(error); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, addons, configDir: '.storybook', }); @@ -134,7 +131,6 @@ describe('AddonConfigurationCommand', () => { const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, addons, configDir: '.storybook', }); From 5184bfaad8955981120b8c51fd71ff1c9e45c142 Mon Sep 17 00:00:00 2001 From: yatishgoel Date: Sat, 31 Jan 2026 14:00:39 +0530 Subject: [PATCH 11/26] Add support for cross-file story imports in story-to-csf transformation Enhance the story-to-csf factory to handle cross-file story imports, allowing for the transformation of references like `ImportedStories.Story.xyz` to `ImportedStories.Story.input.xyz`. This includes updates to the test suite to verify the new functionality and ensure proper migration of story properties from imported stories. --- .../helpers/story-to-csf-factory.test.ts | 56 +++++ .../codemod/helpers/story-to-csf-factory.ts | 197 +++++++++++++++++- 2 files changed, 251 insertions(+), 2 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts index a621f15f6d34..1855be5d9885 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.test.ts @@ -297,6 +297,62 @@ describe('stories codemod', () => { `); }); + it('migrate cross-file story imports from `ImportedStories.Story.xyz` to `ImportedStories.Story.input.xyz`', async () => { + await expect( + transform(dedent` + import * as BaseStories from './Button.stories'; + import { Primary as ImportedPrimary } from './Card.stories'; + + export default { title: 'Component' }; + + export const A = { + args: BaseStories.Primary.args, + }; + + export const B = { + ...BaseStories.Secondary, + args: { + ...BaseStories.Secondary.args, + label: 'Custom', + }, + }; + + export const C = { + args: { + ...ImportedPrimary.args, + }, + }; + `) + ).resolves.toMatchInlineSnapshot(` + import preview from '#.storybook/preview'; + + import * as BaseStories from './Button.stories'; + import { Primary as ImportedPrimary } from './Card.stories'; + + const meta = preview.meta({ + title: 'Component', + }); + + export const A = meta.story({ + args: BaseStories.Primary.input.args, + }); + + export const B = meta.story({ + ...BaseStories.Secondary.input, + args: { + ...BaseStories.Secondary.input.args, + label: 'Custom', + }, + }); + + export const C = meta.story({ + args: { + ...ImportedPrimary.input.args, + }, + }); + `); + }); + it('does not migrate reused properties from disallowed list', async () => { await expect( transform(dedent` diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index 8e4a23c3d44a..01c4d8107cfe 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -97,6 +97,58 @@ export async function storyToCsfFactory( const hasMeta = !!csf._meta; + /** + * Collect imports from other .stories files. + * + * When we see: + * import * as BaseStories from './Button.stories'; + * import { Primary } from './Card.stories'; + * + * We store the local names ("BaseStories", "Primary") so we can later + * transform references like `BaseStories.Primary.args` → `BaseStories.Primary.input.args` + * + * Why? Because those imported stories will ALSO be transformed to CSF4, + * so their properties will be under `.input` instead of directly on the object. + * + * We track TWO types of imports: + * - Namespace imports (import * as X): X.Story.args → X.Story.input.args + * - Named imports (import { Story }): Story.args → Story.input.args + */ + const namespaceStoryImports = new Set(); // import * as X + const namedStoryImports = new Set(); // import { X } or import X + + programNode.body.forEach((node) => { + if (t.isImportDeclaration(node)) { + const importPath = node.source.value; + + // Check if this import is from a .stories file + // Matches: ./Button.stories, ../components/Card.stories.tsx, etc. + const isStoryFileImport = /\.stories(\.(ts|tsx|js|jsx|mjs|mts))?$/.test(importPath); + + if (isStoryFileImport) { + // Collect all imported names from this story file + node.specifiers.forEach((specifier) => { + if (t.isImportNamespaceSpecifier(specifier)) { + // import * as BaseStories from './Button.stories' + // BaseStories.Primary is a story, so we need: BaseStories.Primary.input + namespaceStoryImports.add(specifier.local.name); + } else if (t.isImportSpecifier(specifier)) { + // import { Primary } from './Button.stories' + // Primary itself is a story, so we need: Primary.input + namedStoryImports.add(specifier.local.name); + } else if (t.isImportDefaultSpecifier(specifier)) { + // import ButtonStories from './Button.stories' + // This typically imports the meta, not stories, so we treat it like namespace + namespaceStoryImports.add(specifier.local.name); + } + }); + } + } + }); + + // Combined set for quick lookup + const storyFileImports = new Set([...namespaceStoryImports, ...namedStoryImports]); + // @TODO: Support unconventional formats: // `export function Story() { };` and `export { Story }; // These are not part of csf._storyExports but rather csf._storyStatements and are tricky to support. @@ -180,7 +232,15 @@ export async function storyToCsfFactory( // For each story, replace any reference of story reuse e.g. // Story.args -> Story.input.args // meta.args -> meta.input.args + // BaseStories.Primary.args -> BaseStories.Primary.input.args (cross-file) traverse(csf._ast, { + /** + * Handle SAME-FILE story references. + * + * Examples: + * Primary.args → Primary.input.args + * meta.args → meta.input.args + */ Identifier(nodePath) { const identifierName = nodePath.node.name; const binding = nodePath.scope.getBinding(identifierName); @@ -227,8 +287,8 @@ export async function storyToCsfFactory( t.memberExpression(t.identifier(identifierName), t.identifier('input')) ); } catch (err: any) { - // This is a tough one to support, we just skip for now. - // Relates to `Stories.Story.args` where Stories is coming from another file. We can't know whether it should be transformed or not. + // This error occurs for cross-file references like `Stories.Story.args` + // which are handled by the MemberExpression visitor below. if (err.message.includes(`instead got "MemberExpression"`)) { return; } else { @@ -237,6 +297,139 @@ export async function storyToCsfFactory( } } }, + + /** + * Handle CROSS-FILE story references. + * + * When we import stories from another file: + * import * as BaseStories from './Button.stories'; + * + * And use them like: + * BaseStories.Primary.args + * + * We need to transform to: + * BaseStories.Primary.input.args + * + * Why? Because the imported file will ALSO be transformed to CSF4, + * where story properties are accessed via `.input`. + */ + MemberExpression(nodePath) { + const node = nodePath.node; + + // We're looking for patterns like: BaseStories.Primary.args + // Which is: MemberExpression { object: MemberExpression { object: Identifier, property }, property } + // + // We want to find the inner MemberExpression (BaseStories.Primary) + // and check if its object (BaseStories) is from a story file import. + + // Check if this is a nested member expression (e.g., BaseStories.Primary.args) + // We want to transform BaseStories.Primary → BaseStories.Primary.input + // So we look for MemberExpression where object is also a MemberExpression + + const innerObject = node.object; + + // Check if the object is a MemberExpression like BaseStories.Primary + if (t.isMemberExpression(innerObject)) { + const importName = innerObject.object; // BaseStories + const storyName = innerObject.property; // Primary + const accessedProperty = node.property; // args + + // Verify: importName is an Identifier that's in our storyFileImports set + if ( + t.isIdentifier(importName) && + storyFileImports.has(importName.name) && + t.isIdentifier(storyName) + ) { + // Skip if already transformed: BaseStories.Primary.input.args + // This check prevents infinite loops when the traverser revisits modified nodes + if (t.isIdentifier(storyName, { name: 'input' })) { + return; + } + + // Only process if the accessed property is an Identifier + if (!t.isIdentifier(accessedProperty)) { + return; + } + + // Skip if the current property being accessed is 'input' + // This means we're looking at something like: BaseStories.Primary.input + // which was already transformed in a previous iteration + if (accessedProperty.name === 'input') { + return; + } + + // Skip if accessing a property in the disallow list + if (reuseDisallowList.includes(accessedProperty.name)) { + return; + } + + // Transform: BaseStories.Primary.args → BaseStories.Primary.input.args + // We do this by replacing the inner object (BaseStories.Primary) + // with (BaseStories.Primary.input) + nodePath.node.object = t.memberExpression(innerObject, t.identifier('input')); + + // Skip traversing into the newly created node to prevent infinite loops + nodePath.skip(); + } + } + + // Handle NAMED IMPORTS: import { Primary } from './Button.stories' + // Usage: Primary.args → Primary.input.args + // + // Pattern: MemberExpression { object: Identifier("Primary"), property: Identifier("args") } + // Where "Primary" is in our namedStoryImports set (NOT namespace imports) + if (t.isIdentifier(innerObject) && namedStoryImports.has(innerObject.name)) { + const accessedProperty = node.property; + + // Only process if the property is an Identifier + if (!t.isIdentifier(accessedProperty)) { + return; + } + + // Skip if this is already accessing .input + if (accessedProperty.name === 'input') { + return; + } + + // Skip if accessing a property in the disallow list + if (reuseDisallowList.includes(accessedProperty.name)) { + return; + } + + // Transform: Primary.args → Primary.input.args + nodePath.replaceWith( + t.memberExpression( + t.memberExpression(innerObject, t.identifier('input')), + accessedProperty + ) + ); + nodePath.skip(); + return; + } + + // Handle NAMESPACE IMPORTS spread: import * as BaseStories from './Button.stories' + // Usage: ...BaseStories.Secondary → ...BaseStories.Secondary.input + // + // Pattern: SpreadElement containing MemberExpression { object: Identifier("BaseStories"), property: Identifier("Secondary") } + if (t.isIdentifier(innerObject) && namespaceStoryImports.has(innerObject.name)) { + const storyName = node.property; + + // Skip if this is already .input + if (t.isIdentifier(storyName, { name: 'input' })) { + return; + } + + // Check if parent is a SpreadElement (...BaseStories.Secondary) + const parent = nodePath.parent; + if (t.isSpreadElement(parent)) { + // Transform: ...BaseStories.Secondary → ...BaseStories.Secondary.input + nodePath.replaceWith(t.memberExpression(node, t.identifier('input'))); + nodePath.skip(); + } + // Note: For non-spread namespace access like BaseStories.Primary.args, + // it's handled by the nested MemberExpression case above + } + }, }); // If no stories were transformed, bail early to avoid having a mixed CSF syntax and therefore a broken indexer. From b6464e02162894c90cdeb7f56d6c3d98c4b842f0 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 11:41:27 +0100 Subject: [PATCH 12/26] Hardcode list of templates --- .../vitest/src/updateVitestFile.test.ts | 20 ------------------ code/addons/vitest/src/updateVitestFile.ts | 21 ++++++++++++++++++- ...late.txt => vitest.config.3.2.template.ts} | 0 ...mplate.txt => vitest.config.4.template.ts} | 0 ...template.txt => vitest.config.template.ts} | 0 ...plate.txt => vitest.workspace.template.ts} | 0 6 files changed, 20 insertions(+), 21 deletions(-) rename code/addons/vitest/templates/{vitest.config.3.2.template.txt => vitest.config.3.2.template.ts} (100%) rename code/addons/vitest/templates/{vitest.config.4.template.txt => vitest.config.4.template.ts} (100%) rename code/addons/vitest/templates/{vitest.config.template.txt => vitest.config.template.ts} (100%) rename code/addons/vitest/templates/{vitest.workspace.template.txt => vitest.workspace.template.ts} (100%) diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 9dec63b00612..e898399e29ff 100644 --- a/code/addons/vitest/src/updateVitestFile.test.ts +++ b/code/addons/vitest/src/updateVitestFile.test.ts @@ -1,12 +1,9 @@ -import { fstat, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; import * as babel from 'storybook/internal/babel'; -import { normalize } from 'pathe'; - import { getDiff } from '../../../core/src/core-server/utils/save-story/getDiff'; import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVitestFile'; @@ -22,23 +19,6 @@ vi.mock('../../../core/src/shared/utils/module', () => ({ resolvePackageDir: vi.fn().mockImplementation(() => join(__dirname, '..')), })); -vi.mock(import('./updateVitestFile'), async (actualModule) => { - const mod = await actualModule(); - return { - ...mod, - loadTemplate: async (name: string, replacements: Record) => { - // Dynamically import the template file as plain text - const templateModule = await import(`../templates/${name}?raw`); - 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))) - ); - return template; - }, - }; -}); - describe('updateConfigFile', () => { it('updates vite config file', async () => { const source = babel.babelParse( diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index c27914b4040d..130c533c7c94 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -2,9 +2,28 @@ import type { BabelFile, types as t } from 'storybook/internal/babel'; import { normalize } from 'pathe'; +async function getTemplatePath(name: string) { + switch (name) { + case 'vitest.config.template.txt': + // @ts-expect-error - Errors due to query string + return import('../templates/vitest.config.template?raw'); + case 'vitest.config.4.template.txt': + // @ts-expect-error - Errors due to query string + return import('../templates/vitest.config.4.template?raw'); + case 'vitest.config.3.2.template.txt': + // @ts-expect-error - Errors due to query string + return import('../templates/vitest.config.3.2.template?raw'); + case 'vitest.workspace.template.txt': + // @ts-expect-error - Errors due to query string + return import('../templates/vitest.workspace.template?raw'); + default: + throw new Error(`Unknown template: ${name}`); + } +} + export const loadTemplate = async (name: string, replacements: Record) => { // Dynamically import the template file as plain text - const templateModule = await import(`../templates/${name}`); + const templateModule = await getTemplatePath(name); let template = templateModule.default; // Normalize Windows paths (backslashes) to forward slashes for JavaScript string compatibility Object.entries(replacements).forEach( diff --git a/code/addons/vitest/templates/vitest.config.3.2.template.txt b/code/addons/vitest/templates/vitest.config.3.2.template.ts similarity index 100% rename from code/addons/vitest/templates/vitest.config.3.2.template.txt rename to code/addons/vitest/templates/vitest.config.3.2.template.ts diff --git a/code/addons/vitest/templates/vitest.config.4.template.txt b/code/addons/vitest/templates/vitest.config.4.template.ts similarity index 100% rename from code/addons/vitest/templates/vitest.config.4.template.txt rename to code/addons/vitest/templates/vitest.config.4.template.ts diff --git a/code/addons/vitest/templates/vitest.config.template.txt b/code/addons/vitest/templates/vitest.config.template.ts similarity index 100% rename from code/addons/vitest/templates/vitest.config.template.txt rename to code/addons/vitest/templates/vitest.config.template.ts diff --git a/code/addons/vitest/templates/vitest.workspace.template.txt b/code/addons/vitest/templates/vitest.workspace.template.ts similarity index 100% rename from code/addons/vitest/templates/vitest.workspace.template.txt rename to code/addons/vitest/templates/vitest.workspace.template.ts From c5e96764b9e8521e903c6e5aa712a99c7179b3f1 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 11:48:37 +0100 Subject: [PATCH 13/26] Add manual configuration instructions for failed addon installations in AddonConfigurationCommand. Enhance logging to inform users about addons that couldn't be configured and provide links to setup instructions. --- .../src/commands/AddonConfigurationCommand.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index 4cb5291e5d00..4c8a94e1725e 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -3,10 +3,18 @@ import { type JsPackageManager } from 'storybook/internal/common'; import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; 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'; +const ADDON_INSTALLATION_INSTRUCTIONS = { + '@storybook/addon-vitest': + 'https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced', +} as { [key: string]: string }; + type ExecuteAddonConfigurationParams = { addons: string[]; configDir?: string; @@ -53,6 +61,13 @@ export class AddonConfigurationCommand { 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:'); @@ -61,6 +76,34 @@ export class AddonConfigurationCommand { } } + private getAddonsWithInstructions(addons: string[]): string[] { + return addons.filter((addon) => ADDON_INSTALLATION_INSTRUCTIONS[addon]); + } + + private logManualAddonInstructions(addons: string[]): void { + const addonsWithInstructions = this.getAddonsWithInstructions(addons); + + if (addonsWithInstructions.length > 0) { + logger.warn(dedent` + The following addons couldn't be configured: + + ${addonsWithInstructions + .map((addon) => { + const manualInstructionLink = ADDON_INSTALLATION_INSTRUCTIONS[addon]; + + return `- ${addon}: ${manualInstructionLink}`; + }) + .join('\n')} + + ${ + addonsWithInstructions.length > 0 + ? `Please follow each addon's configuration instructions manually.` + : '' + } + `); + } + } + /** Configure test addons (a11y and vitest) */ private async configureAddons(configDir: string, addons: string[]) { // Import postinstallAddon from cli-storybook package @@ -91,6 +134,8 @@ export class AddonConfigurationCommand { if (addon === '@storybook/addon-vitest') { await addonVitestPostinstall(options); + } else if (addon === '@storybook/addon-a11y') { + await addonA11yPostinstall(options); } else { await postinstallAddon(addon, options); } From 73addfb51fe72538fd11cb2b5ec00944875ffb08 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 12:23:00 +0100 Subject: [PATCH 14/26] Fix template naming --- code/addons/vitest/src/postinstall.ts | 8 ++--- .../vitest/src/updateVitestFile.test.ts | 36 +++++++++---------- code/addons/vitest/src/updateVitestFile.ts | 8 ++--- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 9a7ca8d4b785..3b2813b7f88b 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -230,11 +230,11 @@ export default async function postInstall(options: PostinstallOptions) { const getTemplateName = () => { if (isVitest4OrNewer) { - return 'vitest.config.4.template.txt'; + return 'vitest.config.4.template.ts'; } else if (isVitest3_2To4) { - return 'vitest.config.3.2.template.txt'; + return 'vitest.config.3.2.template.ts'; } - return 'vitest.config.template.txt'; + return 'vitest.config.template.ts'; }; // If there's an existing workspace file, we update that file to include the Storybook Addon Vitest plugin. @@ -250,7 +250,7 @@ export default async function postInstall(options: PostinstallOptions) { return; } - const workspaceTemplate = await loadTemplate('vitest.workspace.template.txt', { + const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index e898399e29ff..89cfe368a6c1 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.txt', { + await loadTemplate('vitest.config.template.ts', { 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.txt', { + await loadTemplate('vitest.config.template.ts', { 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.txt', { + await loadTemplate('vitest.config.template.ts', { 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.txt', { + await loadTemplate('vitest.config.3.2.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -293,7 +293,7 @@ describe('updateConfigFile', () => { it('edits projects property of test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.txt', { + await loadTemplate('vitest.config.3.2.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -373,7 +373,7 @@ describe('updateConfigFile', () => { it('adds workspace property to test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -452,7 +452,7 @@ describe('updateConfigFile', () => { it('adds test property to vite config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -527,7 +527,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.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -613,7 +613,7 @@ describe('updateConfigFile', () => { }); it('supports mergeConfig without defineConfig calls', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -696,7 +696,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig without config containing test property', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -772,7 +772,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.txt', { + await loadTemplate('vitest.config.3.2.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -856,7 +856,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.txt', { + await loadTemplate('vitest.config.3.2.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -945,7 +945,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.txt', { + await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1044,7 +1044,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.txt', { + await loadTemplate('vitest.config.3.2.template.ts', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1145,7 +1145,7 @@ describe('updateConfigFile', () => { describe('updateWorkspaceFile', () => { it('updates vitest workspace file using array syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.txt', { + await loadTemplate('vitest.workspace.template.ts', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1201,7 +1201,7 @@ describe('updateWorkspaceFile', () => { it('updates vitest workspace file using defineWorkspace syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.txt', { + await loadTemplate('vitest.workspace.template.ts', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1264,7 +1264,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.txt', { + const result = await loadTemplate('vitest.config.template.ts', { CONFIG_DIR: windowsPath, SETUP_FILE: '.\\apps\\frontend-storybook\\.storybook\\vitest.setup.ts', }); @@ -1278,7 +1278,7 @@ describe('loadTemplate', () => { // Unix-style path with forward slashes const unixPath = './apps/frontend-storybook/.storybook'; - const result = await loadTemplate('vitest.config.template.txt', { + const result = await loadTemplate('vitest.config.template.ts', { 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 130c533c7c94..826b91e5699c 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -4,16 +4,16 @@ import { normalize } from 'pathe'; async function getTemplatePath(name: string) { switch (name) { - case 'vitest.config.template.txt': + case 'vitest.config.template.ts': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.template?raw'); - case 'vitest.config.4.template.txt': + case 'vitest.config.4.template.ts': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.4.template?raw'); - case 'vitest.config.3.2.template.txt': + case 'vitest.config.3.2.template.ts': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.3.2.template?raw'); - case 'vitest.workspace.template.txt': + case 'vitest.workspace.template.ts': // @ts-expect-error - Errors due to query string return import('../templates/vitest.workspace.template?raw'); default: From 388db38e87e8bfdd955c264492d4c98c5bbdbed7 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 12:24:19 +0100 Subject: [PATCH 15/26] Simplify template naming handling --- code/addons/vitest/src/postinstall.ts | 8 ++--- .../vitest/src/updateVitestFile.test.ts | 36 +++++++++---------- code/addons/vitest/src/updateVitestFile.ts | 8 ++--- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 3b2813b7f88b..e37b4da59157 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -230,11 +230,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. @@ -250,7 +250,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) : '', diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 89cfe368a6c1..309672f856d4 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', @@ -293,7 +293,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', @@ -373,7 +373,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', @@ -452,7 +452,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', @@ -527,7 +527,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', @@ -613,7 +613,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', @@ -696,7 +696,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', @@ -772,7 +772,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', @@ -856,7 +856,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', @@ -945,7 +945,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', @@ -1044,7 +1044,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', @@ -1145,7 +1145,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' }", @@ -1201,7 +1201,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' }", @@ -1264,7 +1264,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', }); @@ -1278,7 +1278,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 826b91e5699c..638772cc4f93 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -4,16 +4,16 @@ import { normalize } from 'pathe'; async function getTemplatePath(name: string) { switch (name) { - case 'vitest.config.template.ts': + case 'vitest.config.template': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.template?raw'); - case 'vitest.config.4.template.ts': + case 'vitest.config.4.template': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.4.template?raw'); - case 'vitest.config.3.2.template.ts': + case 'vitest.config.3.2.template': // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.3.2.template?raw'); - case 'vitest.workspace.template.ts': + case 'vitest.workspace.template': // @ts-expect-error - Errors due to query string return import('../templates/vitest.workspace.template?raw'); default: From 1bee8514d1135e5c3b6f49b3ad13648ed1816e88 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 12:28:21 +0100 Subject: [PATCH 16/26] Add raw plugin --- scripts/build/utils/generate-bundle.ts | 2 ++ scripts/package.json | 1 + yarn.lock | 8 ++++++++ 3 files changed, 11 insertions(+) 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 1efe45f57924339fd437f0c76d810d9074e253f3 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 12:31:27 +0100 Subject: [PATCH 17/26] Add support for importing raw content in TypeScript typings and remove TypeScript error suppressions in template loading --- code/addons/vitest/src/typings.d.ts | 5 +++++ code/addons/vitest/src/updateVitestFile.ts | 4 ---- 2 files changed, 5 insertions(+), 4 deletions(-) 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.ts b/code/addons/vitest/src/updateVitestFile.ts index 638772cc4f93..6bad82a5d591 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -5,16 +5,12 @@ import { normalize } from 'pathe'; async function getTemplatePath(name: string) { switch (name) { case 'vitest.config.template': - // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.template?raw'); case 'vitest.config.4.template': - // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.4.template?raw'); case 'vitest.config.3.2.template': - // @ts-expect-error - Errors due to query string return import('../templates/vitest.config.3.2.template?raw'); case 'vitest.workspace.template': - // @ts-expect-error - Errors due to query string return import('../templates/vitest.workspace.template?raw'); default: throw new Error(`Unknown template: ${name}`); From 6755af1976a28f3179f9206b3d2a20f63810b810 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Mon, 2 Feb 2026 16:30:05 +0100 Subject: [PATCH 18/26] Ensure postinstall script executions take place even when skipInstall = true --- code/addons/a11y/src/postinstall.ts | 8 +- code/addons/vitest/src/postinstall.ts | 10 +- code/core/src/cli/AddonVitestService.ts | 7 +- .../js-package-manager/JsPackageManager.ts | 2 +- .../common/js-package-manager/PNPMProxy.ts | 8 +- .../AddonConfigurationCommand.test.ts | 388 +++++++++++++----- .../src/commands/AddonConfigurationCommand.ts | 1 + 7 files changed, 315 insertions(+), 109 deletions(-) diff --git a/code/addons/a11y/src/postinstall.ts b/code/addons/a11y/src/postinstall.ts index 6176a3fceb15..6fff2274b5ef 100644 --- a/code/addons/a11y/src/postinstall.ts +++ b/code/addons/a11y/src/postinstall.ts @@ -3,7 +3,11 @@ 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@${versions.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 e37b4da59157..85a178ff3aa7 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -162,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` @@ -359,7 +360,7 @@ export default async function postInstall(options: PostinstallOptions) { if (a11yAddon) { try { const command = [ - `storybook@${versions.storybook}`, + options.skipInstall ? `storybook@${versions.storybook}` : `storybook`, 'automigrate', 'addon-a11y-addon-test', '--loglevel', @@ -382,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/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 9d442f31e4d1..1a6bb6839124 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/create-storybook/src/commands/AddonConfigurationCommand.test.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts index da9820ec877c..2df6ed0ca21a 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts @@ -1,160 +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({ - 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({ - 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({ - 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({ - 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 4c8a94e1725e..017fdb166150 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -56,6 +56,7 @@ 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); From 5308634d1bf35a969d9b0de742aa169907edeed5 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Tue, 3 Feb 2026 15:04:25 +0100 Subject: [PATCH 19/26] Small refactoring --- .../codemod/helpers/story-to-csf-factory.ts | 65 +++++++++---------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts index 01c4d8107cfe..d92681c1e353 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory.ts @@ -79,38 +79,20 @@ export async function storyToCsfFactory( const sbConfigImportSpecifier = t.importDefaultSpecifier(t.identifier(sbConfigImportName)); - programNode.body.forEach((node) => { - if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { - const defaultImportSpecifier = node.specifiers.find((specifier) => - t.isImportDefaultSpecifier(specifier) - ); - - if (!defaultImportSpecifier) { - node.specifiers.push(sbConfigImportSpecifier); - } else if (defaultImportSpecifier.local.name !== sbConfigImportName) { - sbConfigImportName = defaultImportSpecifier.local.name; - } - - previewImport = node; - } - }); - - const hasMeta = !!csf._meta; - /** * Collect imports from other .stories files. * - * When we see: - * import * as BaseStories from './Button.stories'; - * import { Primary } from './Card.stories'; + * When we see: import * as BaseStories from './Button.stories'; import { Primary } from + * './Card.stories'; * - * We store the local names ("BaseStories", "Primary") so we can later - * transform references like `BaseStories.Primary.args` → `BaseStories.Primary.input.args` + * We store the local names ("BaseStories", "Primary") so we can later transform references like + * `BaseStories.Primary.args` → `BaseStories.Primary.input.args` * - * Why? Because those imported stories will ALSO be transformed to CSF4, - * so their properties will be under `.input` instead of directly on the object. + * Why? Because those imported stories will ALSO be transformed to CSF4, so their properties will + * be under `.input` instead of directly on the object. * * We track TWO types of imports: + * * - Namespace imports (import * as X): X.Story.args → X.Story.input.args * - Named imports (import { Story }): Story.args → Story.input.args */ @@ -144,8 +126,24 @@ export async function storyToCsfFactory( }); } } + + if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { + const defaultImportSpecifier = node.specifiers.find((specifier) => + t.isImportDefaultSpecifier(specifier) + ); + + if (!defaultImportSpecifier) { + node.specifiers.push(sbConfigImportSpecifier); + } else if (defaultImportSpecifier.local.name !== sbConfigImportName) { + sbConfigImportName = defaultImportSpecifier.local.name; + } + + previewImport = node; + } }); + const hasMeta = !!csf._meta; + // Combined set for quick lookup const storyFileImports = new Set([...namespaceStoryImports, ...namedStoryImports]); @@ -237,9 +235,7 @@ export async function storyToCsfFactory( /** * Handle SAME-FILE story references. * - * Examples: - * Primary.args → Primary.input.args - * meta.args → meta.input.args + * Examples: Primary.args → Primary.input.args meta.args → meta.input.args */ Identifier(nodePath) { const identifierName = nodePath.node.name; @@ -301,17 +297,14 @@ export async function storyToCsfFactory( /** * Handle CROSS-FILE story references. * - * When we import stories from another file: - * import * as BaseStories from './Button.stories'; + * When we import stories from another file: import * as BaseStories from './Button.stories'; * - * And use them like: - * BaseStories.Primary.args + * And use them like: BaseStories.Primary.args * - * We need to transform to: - * BaseStories.Primary.input.args + * We need to transform to: BaseStories.Primary.input.args * - * Why? Because the imported file will ALSO be transformed to CSF4, - * where story properties are accessed via `.input`. + * Why? Because the imported file will ALSO be transformed to CSF4, where story properties are + * accessed via `.input`. */ MemberExpression(nodePath) { const node = nodePath.node; From 5f277da4c50c1344e70137102452273fcb10029c Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Wed, 4 Feb 2026 12:04:30 +0100 Subject: [PATCH 20/26] Add documentation comment to clarify template import handling in getTemplatePath function --- code/addons/vitest/src/updateVitestFile.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 6bad82a5d591..f8c7a3646da8 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -2,6 +2,10 @@ import type { BabelFile, types as t } from 'storybook/internal/babel'; import { normalize } from 'pathe'; +/** + * 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': From 089bd3f4f6a95215f1569b9742c992e7b96c6f47 Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:13:59 +0000 Subject: [PATCH 21/26] Update CHANGELOG.md for v10.2.6 [skip ci] --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) 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! From 9eaacdbee9c09f6a465971d10bd9dd6e50b59138 Mon Sep 17 00:00:00 2001 From: ia319 Date: Thu, 5 Feb 2026 04:16:18 +0800 Subject: [PATCH 22/26] Merge branch 'next' into bug/33281-dynamic-title-select --- code/core/src/toolbar/components/ToolbarMenuSelect.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index f9713c5d320c..52aee50efbe4 100644 --- a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx +++ b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx @@ -29,7 +29,14 @@ export const ToolbarMenuSelect: FC = ({ id, name, description, - toolbar: { icon: _icon, items, title: _title, preventDynamicIcon, dynamicTitle = true, shortcuts }, + toolbar: { + icon: _icon, + items, + title: _title, + preventDynamicIcon, + dynamicTitle = true, + shortcuts, + }, }) => { const api = useStorybookApi(); const [globals, updateGlobals, storyGlobals] = useGlobals(); From 34637957bcd444a476e9891df47bc830f488127f Mon Sep 17 00:00:00 2001 From: Louis Shawn Date: Thu, 5 Feb 2026 11:12:32 +0800 Subject: [PATCH 23/26] fix(core): apply --loglevel to npmlog --- code/core/src/bin/core.ts | 13 ++++++++----- code/core/src/node-logger/index.test.ts | 17 +++++++++++++++++ code/core/src/node-logger/index.ts | 22 ++++++++++++++++++---- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 280e1888efe2..c930896899e7 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -2,7 +2,7 @@ import { getEnvConfig, optionalEnvToBoolean, parseList } from 'storybook/interna import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; -import { program } from 'commander'; +import { Option, program } from 'commander'; import leven from 'leven'; import picocolors from 'picocolors'; @@ -45,7 +45,11 @@ const command = (name: string) => ) .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') - .option('--loglevel ', 'Define log level', 'info') + .addOption( + new Option('--loglevel ', 'Define log level') + .choices(['trace', 'debug', 'info', 'warn', 'error', 'silent']) + .default('info') + ) .option( '--logfile [path]', 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' @@ -53,9 +57,8 @@ const command = (name: string) => .hook('preAction', async (self) => { try { const options = self.opts(); - if (options.loglevel) { - logger.setLogLevel(options.loglevel); - } + const loglevel = options.debug ? 'debug' : options.loglevel; + logger.setLogLevel(loglevel); if (options.logfile) { logTracker.enableLogWriting(); diff --git a/code/core/src/node-logger/index.test.ts b/code/core/src/node-logger/index.test.ts index 397a13d4c794..047b3a77838f 100644 --- a/code/core/src/node-logger/index.test.ts +++ b/code/core/src/node-logger/index.test.ts @@ -48,6 +48,23 @@ describe('node-logger', () => { logger.warn(message); expect(loggerMock.warn).toHaveBeenCalledWith(message); }); + + it('should sync --loglevel with npmlog', () => { + logger.setLogLevel('debug'); + expect(npmlog.level).toBe('verbose'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('debug'); + + logger.setLogLevel('trace'); + expect(npmlog.level).toBe('silly'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('trace'); + }); + + it('should keep setLevel and setLogLevel consistent', () => { + logger.setLevel('warn'); + expect(npmlog.level).toBe('warn'); + expect(loggerMock.setLogLevel).toHaveBeenCalledWith('warn'); + }); + it('should have an error method', () => { const message = 'error message'; logger.error(message); diff --git a/code/core/src/node-logger/index.ts b/code/core/src/node-logger/index.ts index ae2de0410ed1..e4db4c15bbf3 100644 --- a/code/core/src/node-logger/index.ts +++ b/code/core/src/node-logger/index.ts @@ -17,6 +17,22 @@ export type { LogLevel } from './logger/logger'; // there are issues with the build: https://github.com/storybookjs/storybook/issues/14621 npmLog.stream = process.stdout; +const toNpmLogLevel = (level: newLogger.LogLevel): string => { + switch (level) { + case 'trace': + return 'silly'; + case 'debug': + return 'verbose'; + default: + return level; + } +}; + +const setLoggerLevel = (level: newLogger.LogLevel = 'info'): void => { + npmLog.level = toNpmLogLevel(level); + newLogger.setLogLevel(level); +}; + function hex(hexColor: string) { // Ensure the hex color is 6 characters long and starts with '#' if (!/^#?[0-9A-Fa-f]{6}$/.test(hexColor)) { @@ -57,10 +73,8 @@ export const logger = { warn: (message: string): void => newLogger.warn(message), trace: ({ message, time }: { message: string; time: [number, number] }): void => newLogger.debug(`${message} (${colors.purple(prettyTime(time))})`), - setLevel: (level: newLogger.LogLevel = 'info'): void => { - npmLog.level = level; - newLogger.setLogLevel(level); - }, + setLevel: setLoggerLevel, + setLogLevel: setLoggerLevel, error: (message: unknown): void => { let msg: string; if (message instanceof Error && message.stack) { From fecb2ef780a853b5f86f1c10179a47c2dcaf3117 Mon Sep 17 00:00:00 2001 From: Valentin Palkovic Date: Thu, 5 Feb 2026 11:09:30 +0100 Subject: [PATCH 24/26] Build: Fix test --- 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', From fc65414322fb446d2e5a80532071b2b48e5848a9 Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 5 Feb 2026 15:01:00 +0100 Subject: [PATCH 25/26] ignore empty story files when indexing --- code/.eslintignore | 1 + .../src/core-server/presets/common-preset.ts | 11 +++- .../utils/StoryIndexGenerator.test.ts | 61 ++++++++++++------- .../utils/__mockdata__/src/Empty.stories.ts | 0 4 files changed, 50 insertions(+), 23 deletions(-) create mode 100644 code/core/src/core-server/utils/__mockdata__/src/Empty.stories.ts diff --git a/code/.eslintignore b/code/.eslintignore index 5efb1ab25edc..0ee7fd976934 100644 --- a/code/.eslintignore +++ b/code/.eslintignore @@ -19,4 +19,5 @@ ember-output !.storybook core/assets core/src/core-server/utils/__search-files-tests__ +core/src/core-server/utils/__mockdata__/src/Empty.stories.ts core/report diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 36f40b823c94..cd9ab70adc9d 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -13,7 +13,7 @@ import { removeAddon as removeAddonBase, } from 'storybook/internal/common'; import { StoryIndexGenerator } from 'storybook/internal/core-server'; -import { readCsf } from 'storybook/internal/csf-tools'; +import { loadCsf } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import type { @@ -215,7 +215,14 @@ export const features: PresetProperty<'features'> = async (existing) => ({ export const csfIndexer: Indexer = { test: /(stories|story)\.(m?js|ts)x?$/, - createIndex: async (fileName, options) => (await readCsf(fileName, options)).parse().indexInputs, + createIndex: async (fileName, options) => { + const code = (await readFile(fileName, 'utf-8')).toString(); + if (code.trim().length === 0) { + logger.debug(`The file ${fileName} is empty. Skipping indexing.`); + return []; + } + return loadCsf(code, { ...options, fileName }).parse().indexInputs; + }, }; export const experimental_indexers: PresetProperty<'experimental_indexers'> = (existingIndexers) => diff --git a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts index 857f06b12542..6692ea1881b4 100644 --- a/code/core/src/core-server/utils/StoryIndexGenerator.test.ts +++ b/code/core/src/core-server/utils/StoryIndexGenerator.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { normalizeStoriesEntry } from 'storybook/internal/common'; import { toId } from 'storybook/internal/csf'; -import { getStorySortParameter, readCsf } from 'storybook/internal/csf-tools'; +import { getStorySortParameter, loadCsf } from 'storybook/internal/csf-tools'; import { logger, once } from 'storybook/internal/node-logger'; import type { NormalizedStoriesSpecifier, StoryIndexEntry } from 'storybook/internal/types'; @@ -34,13 +34,13 @@ vi.mock('storybook/internal/csf-tools', async (importOriginal) => { const csfTools = await importOriginal(); return { ...csfTools, - readCsf: vi.fn(csfTools.readCsf), + loadCsf: vi.fn(csfTools.loadCsf), getStorySortParameter: vi.fn(csfTools.getStorySortParameter), }; }); const toIdMock = vi.mocked(toId); -const readCsfMock = vi.mocked(readCsf); +const loadCsfMock = vi.mocked(loadCsf); const getStorySortParameterMock = vi.mocked(getStorySortParameter); const options: StoryIndexGeneratorOptions = { @@ -55,7 +55,7 @@ describe('StoryIndexGenerator', () => { vi.mocked(logger.warn).mockClear(); vi.mocked(once.warn).mockClear(); toIdMock.mockClear(); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); getStorySortParameterMock.mockClear(); StoryIndexGenerator.clearFindMatchingFilesCache(); }); @@ -221,6 +221,25 @@ describe('StoryIndexGenerator', () => { `); }); }); + describe('empty or whitespace-only files', () => { + it('ignores story files that only contain whitespace (e.g. just a newline)', async () => { + const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( + './src/Empty.stories.ts', + options + ); + + const generator = new StoryIndexGenerator([specifier], options); + await generator.initialize(); + + const { storyIndex } = await generator.getIndexAndStats(); + expect(storyIndex).toMatchInlineSnapshot(` + { + "entries": {}, + "v": 5, + } + `); + }); + }); describe('non-recursive specifier', () => { it('extracts stories from the right files', async () => { const specifier: NormalizedStoriesSpecifier = normalizeStoriesEntry( @@ -2135,16 +2154,16 @@ describe('StoryIndexGenerator', () => { options ); - readCsfMock.mockClear(); - expect(readCsfMock).toHaveBeenCalledTimes(0); + loadCsfMock.mockClear(); + expect(loadCsfMock).toHaveBeenCalledTimes(0); const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(readCsfMock).toHaveBeenCalledTimes(12); + expect(loadCsfMock).toHaveBeenCalledTimes(12); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); await generator.getIndex(); - expect(readCsfMock).not.toHaveBeenCalled(); + expect(loadCsfMock).not.toHaveBeenCalled(); }); it('does not extract docs files a second time', async () => { @@ -2156,8 +2175,8 @@ describe('StoryIndexGenerator', () => { './src/docs2/*.mdx', options ); - readCsfMock.mockClear(); - expect(readCsfMock).toHaveBeenCalledTimes(0); + loadCsfMock.mockClear(); + expect(loadCsfMock).toHaveBeenCalledTimes(0); const generator = new StoryIndexGenerator([storiesSpecifier, docsSpecifier], options); await generator.initialize(); await generator.getIndex(); @@ -2194,17 +2213,17 @@ describe('StoryIndexGenerator', () => { options ); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(readCsfMock).toHaveBeenCalledTimes(12); + expect(loadCsfMock).toHaveBeenCalledTimes(12); generator.invalidate('./src/B.stories.ts', false); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); await generator.getIndex(); - expect(readCsfMock).toHaveBeenCalledTimes(1); + expect(loadCsfMock).toHaveBeenCalledTimes(1); }); it('calls extract docs file for just the one file', async () => { @@ -2279,17 +2298,17 @@ describe('StoryIndexGenerator', () => { options ); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(readCsfMock).toHaveBeenCalledTimes(12); + expect(loadCsfMock).toHaveBeenCalledTimes(12); generator.invalidate('./src/B.stories.ts', true); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); await generator.getIndex(); - expect(readCsfMock).not.toHaveBeenCalled(); + expect(loadCsfMock).not.toHaveBeenCalled(); }); it('does call the sort function a second time', async () => { @@ -2318,11 +2337,11 @@ describe('StoryIndexGenerator', () => { options ); - readCsfMock.mockClear(); + loadCsfMock.mockClear(); const generator = new StoryIndexGenerator([specifier], options); await generator.initialize(); await generator.getIndex(); - expect(readCsfMock).toHaveBeenCalledTimes(12); + expect(loadCsfMock).toHaveBeenCalledTimes(12); generator.invalidate('./src/B.stories.ts', true); diff --git a/code/core/src/core-server/utils/__mockdata__/src/Empty.stories.ts b/code/core/src/core-server/utils/__mockdata__/src/Empty.stories.ts new file mode 100644 index 000000000000..e69de29bb2d1 From 48dee50ee5745a0068fdb8e8eb95dbbd10f73ace Mon Sep 17 00:00:00 2001 From: storybook-bot <32066757+storybook-bot@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:47:05 +0000 Subject: [PATCH 26/26] Write changelog for 10.3.0-alpha.5 [skip ci] --- CHANGELOG.prerelease.md | 8 ++++++++ code/package.json | 3 ++- docs/versions/next.json | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 5ed5d7dbdb9a..5d26eb530d5a 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,11 @@ +## 10.3.0-alpha.5 + +- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic! +- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel! +- Core: Ignore empty files when indexing - [#33782](https://github.com/storybookjs/storybook/pull/33782), thanks @JReinhold! +- Globals: Repair dynamicTitle: false for user-defined tools - [#33284](https://github.com/storybookjs/storybook/pull/33284), thanks @ia319! +- Logger: Honor --loglevel for npmlog output - [#33776](https://github.com/storybookjs/storybook/pull/33776), thanks @LouisLau-art! + ## 10.3.0-alpha.4 - Addon-Vitest: Support vite/vitest config with deferred export - [#33755](https://github.com/storybookjs/storybook/pull/33755), thanks @valentinpalkovic! diff --git a/code/package.json b/code/package.json index 61819dbadfa0..34f6da49e68d 100644 --- a/code/package.json +++ b/code/package.json @@ -220,5 +220,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.3.0-alpha.5" } diff --git a/docs/versions/next.json b/docs/versions/next.json index 370ef3231d50..85719cf980ba 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"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 +{"version":"10.3.0-alpha.5","info":{"plain":"- CLI: Support addon-vitest setup when --skip-install is passed - [#33718](https://github.com/storybookjs/storybook/pull/33718), thanks @valentinpalkovic!\n- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel!\n- Core: Ignore empty files when indexing - [#33782](https://github.com/storybookjs/storybook/pull/33782), thanks @JReinhold!\n- Globals: Repair dynamicTitle: false for user-defined tools - [#33284](https://github.com/storybookjs/storybook/pull/33284), thanks @ia319!\n- Logger: Honor --loglevel for npmlog output - [#33776](https://github.com/storybookjs/storybook/pull/33776), thanks @LouisLau-art!"}} \ No newline at end of file