diff --git a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch index bc8d83558798..3d48828863d7 100644 --- a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch +++ b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch @@ -1,3 +1,25 @@ +diff --git a/dist/cjs/event/createEvent.js b/dist/cjs/event/createEvent.js +index b63c0093f492f5072ce2eabb74ca290144353548..5e68db5bb9f62aa43f1c849b185912b0b34021c8 100644 +--- a/dist/cjs/event/createEvent.js ++++ b/dist/cjs/event/createEvent.js +@@ -38,7 +38,16 @@ function createEvent(type, target, init) { + const window = getWindow.getWindow(target); + const { EventType, defaultInit } = eventMap.eventMap[type]; + const event = new (getEventConstructors(window))[EventType](type, defaultInit); +- eventInitializer[EventType].forEach((f)=>f(event, init !== null && init !== undefined ? init : {})); ++ var eventInit = {}; ++ for (var key in init) { ++ if (Object.prototype.hasOwnProperty.call(init, key)) { ++ eventInit[key] = init[key]; ++ } ++ } ++ eventInit.view = window; ++ eventInitializer[EventType].forEach(function(f) { ++ f(event, eventInit); ++ }); + return event; + } + /* istanbul ignore next */ function getEventConstructors(window) { diff --git a/dist/cjs/utils/dataTransfer/Clipboard.js b/dist/cjs/utils/dataTransfer/Clipboard.js index 434be791b156984a8b76287bc0cc6c8955df4203..e28a15e85e2dccff058a18b4b80b099b7016d688 100644 --- a/dist/cjs/utils/dataTransfer/Clipboard.js @@ -23,6 +45,28 @@ index 434be791b156984a8b76287bc0cc6c8955df4203..e28a15e85e2dccff058a18b4b80b099b } exports.attachClipboardStubToView = attachClipboardStubToView; +diff --git a/dist/esm/event/createEvent.js b/dist/esm/event/createEvent.js +index 1c741ba446f40917727236e9e4ad29a20357d03a..9a4b63935923a9a271bfc9fd161fe7c477303256 100644 +--- a/dist/esm/event/createEvent.js ++++ b/dist/esm/event/createEvent.js +@@ -36,7 +36,16 @@ function createEvent(type, target, init) { + const window = getWindow(target); + const { EventType, defaultInit } = eventMap[type]; + const event = new (getEventConstructors(window))[EventType](type, defaultInit); +- eventInitializer[EventType].forEach((f)=>f(event, init !== null && init !== undefined ? init : {})); ++ var eventInit = {}; ++ for (var key in init) { ++ if (Object.prototype.hasOwnProperty.call(init, key)) { ++ eventInit[key] = init[key]; ++ } ++ } ++ eventInit.view = window; ++ eventInitializer[EventType].forEach(function(f) { ++ f(event, eventInit); ++ }); + return event; + } + /* istanbul ignore next */ function getEventConstructors(window) { diff --git a/dist/esm/utils/dataTransfer/Clipboard.js b/dist/esm/utils/dataTransfer/Clipboard.js index 2ed2676b52adaee045d2594b051c08a4b133e7df..337e644ed268ad4ad0ce9a601d6d0aec73264d5e 100644 --- a/dist/esm/utils/dataTransfer/Clipboard.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b54804527df..e70321271c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,66 @@ +## 10.2.9 + +- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic! +- Builder-Vite: Update dependencies react-vite framework - [#33810](https://github.com/storybookjs/storybook/pull/33810), thanks @valentinpalkovic! +- Builder-Vite: Use relative path for mocker entry in production builds - [#33792](https://github.com/storybookjs/storybook/pull/33792), thanks @DukeDeSouth! +- Next.js: Fix Link component override in appDirectory configuration - [#31251](https://github.com/storybookjs/storybook/pull/31251), thanks @yatishgoel! + +## 10.2.8 + +- Telemetry: Add Expo metaframework - [#33783](https://github.com/storybookjs/storybook/pull/33783), thanks @copilot-swe-agent! +- Telemetry: Add init exit event - [#33773](https://github.com/storybookjs/storybook/pull/33773), thanks @valentinpalkovic! +- Telemetry: Add share events - [#33766](https://github.com/storybookjs/storybook/pull/33766), thanks @ndelangen! +- Test: Update event creation logic in user-event package - [#33787](https://github.com/storybookjs/storybook/pull/33787), thanks @valentinpalkovic! + +## 10.2.7 + +- CSF: Fix cross-file story imports in csf-factories codemod - [#33723](https://github.com/storybookjs/storybook/pull/33723), thanks @yatishgoel! +- Core: Fix rendering of View Transitions in Firefox - [#33651](https://github.com/storybookjs/storybook/pull/33651), thanks @ghengeveld! +- 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.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! +- Builder-Vite: Add plugin to enforce Storybook's output directory in Vite build configuration - [#33740](https://github.com/storybookjs/storybook/pull/33740), thanks @valentinpalkovic! +- Core: Invalidate cache on Storybook version upgrade - [#33717](https://github.com/storybookjs/storybook/pull/33717), thanks @copilot-swe-agent! + +## 10.2.4 + +- CSF-Factories: Fix codemod for preview files without exports - [#33673](https://github.com/storybookjs/storybook/pull/33673), thanks @kasperpeulen! +- CSF: Fix false positive detection of Zod v4 .meta() as CSF Factory - [#33666](https://github.com/storybookjs/storybook/pull/33666), thanks @kasperpeulen! +- CSFFactories: Add non-interactive mode and --glob flag - [#33648](https://github.com/storybookjs/storybook/pull/33648), thanks @kasperpeulen! +- CSFFactories: Preserve leading comments when adding imports - [#33645](https://github.com/storybookjs/storybook/pull/33645), thanks @kasperpeulen! +- Codemod: Fix csf-2-to-3 failing due to quoted filenames - [#33646](https://github.com/storybookjs/storybook/pull/33646), thanks @kasperpeulen! +- Codemod: Fix glob pattern handling on Windows - [#33714](https://github.com/storybookjs/storybook/pull/33714), thanks @kasperpeulen! +- Manager: Remove deprecated `active` prop warning in ZoomButton - [#33697](https://github.com/storybookjs/storybook/pull/33697), thanks @yatishgoel! +- Next.js: Alias AppRouterContext to shared runtime to fix Link navigation - [#33419](https://github.com/storybookjs/storybook/pull/33419), thanks @pallaprolus! + +## 10.2.3 + +- Addon-Vitest: Normalize Windows paths in addon-vitest automigration - [#33340](https://github.com/storybookjs/storybook/pull/33340), thanks @tanujbhaud! +- Core: Fix `previewHref` when current path does not end with a slash - [#33647](https://github.com/storybookjs/storybook/pull/33647), thanks @ghengeveld! + +## 10.2.2 + +- Addon Vitest: Support simple vite.config without defineConfig helper - [#33694](https://github.com/storybookjs/storybook/pull/33694), thanks @valentinpalkovic! +- Addon-Vitest: Append Storybook project to existing test.projects array without double nesting - [#33708](https://github.com/storybookjs/storybook/pull/33708), thanks @valentinpalkovic! +- Addon-Vitest: Update Vitest plugin configuration to disable requireAssertions for expect - [#33693](https://github.com/storybookjs/storybook/pull/33693), thanks @valentinpalkovic! +- Composition: Handle 401 responses with loginUrl from Chromatic - [#33705](https://github.com/storybookjs/storybook/pull/33705), thanks @kasperpeulen! +- Telemetry: Add agent detection - [#33675](https://github.com/storybookjs/storybook/pull/33675), thanks @valentinpalkovic! + +## 10.2.1 + +- Builder-Webpack5: Fix @vitest/mocker resolution issue - [#33315](https://github.com/storybookjs/storybook/pull/33315), thanks @valentinpalkovic! +- CLI: Add init telemetry for CLI integrations - [#33603](https://github.com/storybookjs/storybook/pull/33603), thanks @shilman! + ## 10.2.0 > Improved UI and story authoring ergonomics diff --git a/code/.eslintrc.js b/code/.eslintrc.js index 1d4c233d3caf..b1e2ad68d238 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -160,10 +160,9 @@ module.exports = { 'import-x/no-named-as-default-member': 'warn', 'react/destructuring-assignment': 'warn', - // This warns about importing interfaces and types in a normal import, it's arguably better to import with the `type` prefix separate from the runtime imports, - // I leave this as a warning right now because we haven't really decided yet, and the codebase is riddled with errors if I set to 'error'. + // Our codebase is mostly TypeScript, and typescript will warn when imports are not found. // It IS set to 'error' for JS files. - 'import-x/named': 'warn', + 'import-x/named': 'off', }, }, { diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 850f3d759aa6..65e1133fc4d1 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-a11y", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Addon A11y: Test UI component compliance with WCAG web accessibility standards", "keywords": [ "a11y", diff --git a/code/addons/a11y/src/postinstall.ts b/code/addons/a11y/src/postinstall.ts index d8cd0bf18b04..6fff2274b5ef 100644 --- a/code/addons/a11y/src/postinstall.ts +++ b/code/addons/a11y/src/postinstall.ts @@ -1,9 +1,13 @@ -import { JsPackageManagerFactory } from 'storybook/internal/common'; +import { JsPackageManagerFactory, versions } from 'storybook/internal/common'; import type { PostinstallOptions } from '../../../lib/cli-storybook/src/add'; export default async function postinstall(options: PostinstallOptions) { - const args = ['storybook', 'automigrate', 'addon-a11y-addon-test']; + const args = [ + options.skipInstall ? `storybook@${versions.storybook}` : `storybook`, + 'automigrate', + 'addon-a11y-addon-test', + ]; args.push('--loglevel', 'silent'); args.push('--skip-doctor'); @@ -25,5 +29,5 @@ export default async function postinstall(options: PostinstallOptions) { configDir: options.configDir, }); - await jsPackageManager.runPackageCommand({ args }); + await jsPackageManager.runPackageCommand({ args, useRemotePkg: !!options.skipInstall }); } diff --git a/code/addons/docs/package.json b/code/addons/docs/package.json index 0818372188a1..48236adef6bb 100644 --- a/code/addons/docs/package.json +++ b/code/addons/docs/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-docs", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Docs: Document UI components automatically with stories and MDX", "keywords": [ "docs", diff --git a/code/addons/links/package.json b/code/addons/links/package.json index b8d023b100ea..f394a3b20a86 100644 --- a/code/addons/links/package.json +++ b/code/addons/links/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-links", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Links: Link stories together to build demos and prototypes with your UI components", "keywords": [ "storybook", diff --git a/code/addons/onboarding/package.json b/code/addons/onboarding/package.json index 797f98cdd6d0..4d95466f1d16 100644 --- a/code/addons/onboarding/package.json +++ b/code/addons/onboarding/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-onboarding", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Onboarding: Help new users learn how to write stories", "keywords": [ "storybook", diff --git a/code/addons/pseudo-states/package.json b/code/addons/pseudo-states/package.json index c747cee8f487..ff8f9fbbea96 100644 --- a/code/addons/pseudo-states/package.json +++ b/code/addons/pseudo-states/package.json @@ -1,6 +1,6 @@ { "name": "storybook-addon-pseudo-states", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Pseudo-states addon: Manipulate CSS pseudo states", "keywords": [ "storybook", diff --git a/code/addons/themes/package.json b/code/addons/themes/package.json index c3b6006dc47a..ce9509c3aadf 100644 --- a/code/addons/themes/package.json +++ b/code/addons/themes/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-themes", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Themes addon: Switch between themes from the toolbar", "keywords": [ "css", diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 9ab222e6188d..2642c0afa288 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-vitest", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Vitest addon: Blazing fast component testing using stories", "keywords": [ "storybook", diff --git a/code/addons/vitest/src/node/vitest-manager.ts b/code/addons/vitest/src/node/vitest-manager.ts index 8cf3e0f15e7e..f787556ed4b4 100644 --- a/code/addons/vitest/src/node/vitest-manager.ts +++ b/code/addons/vitest/src/node/vitest-manager.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import type { CoverageOptions, @@ -13,7 +13,8 @@ import { Tag } from 'storybook/internal/core-server'; import type { StoryId, StoryIndex, StoryIndexEntry } from 'storybook/internal/types'; import * as find from 'empathic/find'; -import path, { dirname, join, normalize } from 'pathe'; +import * as walk from 'empathic/walk'; +import path, { dirname, join, normalize, resolve } from 'pathe'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; @@ -75,19 +76,53 @@ export class VitestManager { : { enabled: false } ) as CoverageOptions; - const vitestWorkspaceConfig = find.any( - [ - ...VITEST_WORKSPACE_FILE_EXTENSION.map((ext) => `vitest.workspace.${ext}`), - ...VITEST_CONFIG_FILE_EXTENSIONS.map((ext) => `vitest.config.${ext}`), - ], - { last: getProjectRoot() } - ); + // In monorepos, the Storybook configDir (e.g. packages/web-app/.storybook) identifies + // the sub-package. We start the Vitest config search from its parent (the package root) + // and traverse upward to the project root, so configs in both sub-packages and the + // monorepo root are found. Without this, find.any defaults to process.cwd() which may + // be the monorepo root and would miss sub-package configs entirely. + const configDir = this.testManager.storybookOptions.configDir; + const packageRoot = configDir ? dirname(resolve(configDir)) : undefined; + + const configFiles = [ + ...VITEST_WORKSPACE_FILE_EXTENSION.map((ext) => `vitest.workspace.${ext}`), + ...VITEST_CONFIG_FILE_EXTENSIONS.flatMap((ext) => [ + `vitest.config.${ext}`, + `vite.config.${ext}`, + ]), + ]; + + const potentialConfigFileLocations = walk.up(packageRoot || process.cwd(), { + last: getProjectRoot(), + }); + + let vitestWorkspaceConfig: string | undefined; + let firstVitestConfig: string | undefined; + + for (const location of potentialConfigFileLocations) { + for (const file of configFiles) { + const maybe = find.any([file], { cwd: location, last: getProjectRoot() }); + if (maybe && existsSync(maybe)) { + firstVitestConfig ??= maybe; + const content = readFileSync(maybe, 'utf8'); + if (content.includes('storybookTest') || content.includes('@storybook/addon-vitest')) { + vitestWorkspaceConfig = dirname(maybe); + break; + } + } + } + if (vitestWorkspaceConfig) { + break; + } + } const projectName = 'storybook:' + process.env.STORYBOOK_CONFIG_DIR; + const vitestConfigFallbackLocation = firstVitestConfig || packageRoot || process.cwd(); + try { this.vitest = await createVitest('test', { - root: vitestWorkspaceConfig ? dirname(vitestWorkspaceConfig) : process.cwd(), + root: vitestWorkspaceConfig ?? vitestConfigFallbackLocation, watch: true, passWithNoTests: false, project: [projectName], diff --git a/code/addons/vitest/src/postinstall.test.ts b/code/addons/vitest/src/postinstall.test.ts new file mode 100644 index 000000000000..e738d7abb4e2 --- /dev/null +++ b/code/addons/vitest/src/postinstall.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { isConfigAlreadySetup } from './postinstall'; + +describe('postinstall helpers', () => { + it('detects a fully configured Vitest config with addon plugin', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + plugins: [storybookTest({ configDir: '.storybook' })], + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(true); + }); + + it('returns false when storybookTest plugin is not used', () => { + const config = ` + import { defineConfig } from 'vitest/config'; + + export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { + setupFiles: ['./.storybook/vitest.setup.ts'], + }, + }, + ], + }, + }); + `; + + expect(isConfigAlreadySetup('/project/vitest.config.ts', config)).toBe(false); + }); +}); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 76a89d325f6f..85a178ff3aa7 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -2,20 +2,20 @@ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; -import { babelParse, generate } from 'storybook/internal/babel'; +import { babelParse, generate, traverse } from 'storybook/internal/babel'; import { AddonVitestService } from 'storybook/internal/cli'; import { JsPackageManagerFactory, formatFileContent, getProjectRoot, getStorybookInfo, + versions, } from 'storybook/internal/common'; import { CLI_COLORS } from 'storybook/internal/node-logger'; import type { StorybookError } from 'storybook/internal/server-errors'; import { AddonVitestPostinstallConfigUpdateError, AddonVitestPostinstallError, - AddonVitestPostinstallExistingSetupFileError, AddonVitestPostinstallFailedAddonA11yError, AddonVitestPostinstallPrerequisiteCheckError, AddonVitestPostinstallWorkspaceUpdateError, @@ -33,6 +33,7 @@ import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVit const ADDON_NAME = '@storybook/addon-vitest' as const; const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs']; +const STORYBOOK_TEST_PLUGIN_SOURCE = `${ADDON_NAME}/vitest-plugin`; const addonA11yName = '@storybook/addon-a11y'; @@ -161,6 +162,7 @@ export default async function postInstall(options: PostinstallOptions) { if (!options.skipInstall) { await addonVitestService.installPlaywright({ yes: options.yes, + useRemotePkg: !!options.skipInstall, }); } else { logger.warn(dedent` @@ -174,17 +176,13 @@ export default async function postInstall(options: PostinstallOptions) { allDeps.typescript || findFile('tsconfig', [...EXTENSIONS, '.json']) ? 'ts' : 'js'; const vitestSetupFile = resolve(options.configDir, `vitest.setup.${fileExtension}`); + const existingSetupFile = + EXTENSIONS.map((ext) => resolve(options.configDir, `vitest.setup${ext}`)).find(existsSync) || + null; - if (existsSync(vitestSetupFile)) { - const errorMessage = dedent` - Found an existing Vitest setup file: - ${vitestSetupFile} - Please refer to the documentation to complete the setup manually: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup-advanced - `; - logger.line(); - logger.error(`${errorMessage}\n`); - errors.push(new AddonVitestPostinstallExistingSetupFileError({ filePath: vitestSetupFile })); + if (existingSetupFile) { + logger.step(`Found existing Vitest setup file, reusing:`); + logger.log(`${existingSetupFile}\n`); } else { logger.step(`Creating a Vitest setup file for Storybook:`); logger.log(`${vitestSetupFile}\n`); @@ -233,26 +231,35 @@ 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. // We assume the existing workspaces include the Vite(st) config, so we won't add it. if (vitestWorkspaceFile) { - const workspaceTemplate = await loadTemplate('vitest.workspace.template.ts', { + const workspaceFileContent = await fs.readFile(vitestWorkspaceFile, 'utf8'); + const alreadyConfigured = isConfigAlreadySetup(vitestWorkspaceFile, workspaceFileContent); + + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + return; + } + + const workspaceTemplate = await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: viteConfigFile ? relative(dirname(vitestWorkspaceFile), viteConfigFile) : '', CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(vitestWorkspaceFile), vitestSetupFile), + SETUP_FILE: relative(dirname(vitestWorkspaceFile), existingSetupFile ?? vitestSetupFile), }).then((t) => t.replace(`\n 'ROOT_CONFIG',`, '').replace(/\s+extends: '',/, '')); - const workspaceFile = await fs.readFile(vitestWorkspaceFile, 'utf8'); const source = babelParse(workspaceTemplate); - const target = babelParse(workspaceFile); + const target = babelParse(workspaceFileContent); const updated = updateWorkspaceFile(source, target); if (updated) { @@ -290,10 +297,12 @@ export default async function postInstall(options: PostinstallOptions) { const templateName = getTemplateName(); - if (templateName) { + const alreadyConfigured = isConfigAlreadySetup(rootConfig, configFile); + + if (templateName && !alreadyConfigured) { const configTemplate = await loadTemplate(templateName, { CONFIG_DIR: options.configDir, - SETUP_FILE: relative(dirname(rootConfig), vitestSetupFile), + SETUP_FILE: relative(dirname(rootConfig), existingSetupFile ?? vitestSetupFile), }); const source = babelParse(configTemplate); @@ -301,7 +310,11 @@ export default async function postInstall(options: PostinstallOptions) { updated = updateConfigFile(source, target); } - if (target && updated) { + if (alreadyConfigured) { + logger.step( + CLI_COLORS.success('Vitest for Storybook is already properly configured. Skipping setup.') + ); + } else if (target && updated) { logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); logger.log(` ${rootConfig}`); @@ -347,7 +360,7 @@ export default async function postInstall(options: PostinstallOptions) { if (a11yAddon) { try { const command = [ - 'storybook', + options.skipInstall ? `storybook@${versions.storybook}` : `storybook`, 'automigrate', 'addon-a11y-addon-test', '--loglevel', @@ -370,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', @@ -412,3 +430,52 @@ export default async function postInstall(options: PostinstallOptions) { throw new AddonVitestPostinstallError({ errors }); } } + +function isStorybookTestPluginSource(value: string) { + return value === STORYBOOK_TEST_PLUGIN_SOURCE; +} + +export function isConfigAlreadySetup(_configPath: string, configContent: string) { + let ast: ReturnType; + try { + ast = babelParse(configContent); + } catch (e) { + return false; + } + + const pluginIdentifiers = new Set(); + + traverse(ast, { + ImportDeclaration(path) { + const source = path.node.source.value; + if (typeof source === 'string' && isStorybookTestPluginSource(source)) { + path.node.specifiers.forEach((specifier) => { + if ('local' in specifier && specifier.local?.name) { + pluginIdentifiers.add(specifier.local.name); + } + }); + } + }, + }); + + let pluginReferenced = false; + + traverse(ast, { + CallExpression(path) { + if (pluginReferenced) { + path.stop(); + return; + } + const callee = path.node.callee; + if ( + callee.type === 'Identifier' && + (pluginIdentifiers.has(callee.name) || callee.name === 'storybookTest') + ) { + pluginReferenced = true; + path.stop(); + } + }, + }); + + return pluginReferenced; +} diff --git a/code/addons/vitest/src/typings.d.ts b/code/addons/vitest/src/typings.d.ts index 235b6170bdd9..2a7a0f8e7a27 100644 --- a/code/addons/vitest/src/typings.d.ts +++ b/code/addons/vitest/src/typings.d.ts @@ -8,3 +8,8 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +declare module '*?raw' { + const content: string; + export default content; +} diff --git a/code/addons/vitest/src/updateVitestFile.test.ts b/code/addons/vitest/src/updateVitestFile.test.ts index 6e08aa611eef..5bf73bfee6e0 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', @@ -291,9 +291,94 @@ describe('updateConfigFile', () => { `); }); + it('updates config which is not exported immediately', async () => { + const source = babel.babelParse( + await loadTemplate('vitest.config.3.2.template', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { defineConfig } from 'vite' + import viteReact from '@vitejs/plugin-react' + import { fileURLToPath, URL } from 'url' + + const config = defineConfig({ + resolve: { + preserveSymlinks: true, + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + plugins: [ + viteReact(), + ], + }) + + export default config + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { defineConfig } from 'vite'; + import viteReact from '@vitejs/plugin-react'; + import { fileURLToPath, URL } from 'url'; + + + import path from 'node:path'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + const config = defineConfig({ + resolve: { + preserveSymlinks: true, + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + + - plugins: [viteReact()] + - + + plugins: [viteReact()], + + test: { + + projects: [{ + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + }] + + } + + + }); + export default config;" +`); + }); + it('edits projects property of test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -373,7 +458,7 @@ describe('updateConfigFile', () => { it('adds workspace property to test config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -452,7 +537,7 @@ describe('updateConfigFile', () => { it('adds test property to vite config', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -527,7 +612,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig with multiple defineConfig calls, finding the one with test', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -613,7 +698,7 @@ describe('updateConfigFile', () => { }); it('supports mergeConfig without defineConfig calls', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -696,7 +781,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig without config containing test property', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.template.ts', { + await loadTemplate('vitest.config.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -772,7 +857,7 @@ describe('updateConfigFile', () => { it('supports mergeConfig with defineConfig pattern using projects (Vitest 3.2+)', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -854,9 +939,98 @@ 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', { + CONFIG_DIR: '.storybook', + BROWSER_CONFIG: "{ provider: 'playwright' }", + SETUP_FILE: '../.storybook/vitest.setup.ts', + }) + ); + const target = babel.babelParse(` + import { mergeConfig, defineConfig } from 'vitest/config' + import viteConfig from './vite.config' + + export default mergeConfig( + viteConfig, + defineConfig({ + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: "./vite.config.ts", + test: { name: "client" }, + }, + { + extends: "./vite.config.ts", + test: { name: "server" }, + }, + ], + }, + }) + ) + `); + + const before = babel.generate(target).code; + const updated = updateConfigFile(source, target); + expect(updated).toBe(true); + + const after = babel.generate(target).code; + + // check if the code was updated at all + expect(after).not.toBe(before); + + // check if the code was updated correctly (storybook project appended to existing projects, no double nesting) + expect(getDiff(before, after)).toMatchInlineSnapshot(` + " import { mergeConfig, defineConfig } from 'vitest/config'; + import viteConfig from './vite.config'; + + + import path from 'node:path'; + + import { fileURLToPath } from 'node:url'; + + import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; + + const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + + + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + + + export default mergeConfig(viteConfig, defineConfig({ + test: { + expect: { + requireAssertions: true + ... + test: { + name: "server" + } + + + }, { + + extends: true, + + plugins: [ + + // The plugin will run tests for the stories defined in your Storybook config + + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + + storybookTest({ + + configDir: path.join(dirname, '.storybook') + + })], + + test: { + + name: 'storybook', + + browser: { + + enabled: true, + + headless: true, + + provider: 'playwright', + + instances: [{ + + browser: 'chromium' + + }] + + }, + + setupFiles: ['../.storybook/vitest.setup.ts'] + + } + + + }] + } + }));" + `); + }); + 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', @@ -955,7 +1129,7 @@ describe('updateConfigFile', () => { it('extracts coverage config and keeps it at top level when using projects', async () => { const source = babel.babelParse( - await loadTemplate('vitest.config.3.2.template.ts', { + await loadTemplate('vitest.config.3.2.template', { CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", SETUP_FILE: '../.storybook/vitest.setup.ts', @@ -1056,7 +1230,7 @@ describe('updateConfigFile', () => { describe('updateWorkspaceFile', () => { it('updates vitest workspace file using array syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.ts', { + await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1087,7 +1261,7 @@ describe('updateWorkspaceFile', () => { + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + export default ['packages/*', 'ROOT_CONFIG', { - + extends: '', + + extends: '.', + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest @@ -1112,7 +1286,7 @@ describe('updateWorkspaceFile', () => { it('updates vitest workspace file using defineWorkspace syntax', async () => { const source = babel.babelParse( - await loadTemplate('vitest.workspace.template.ts', { + await loadTemplate('vitest.workspace.template', { EXTENDS_WORKSPACE: '', CONFIG_DIR: '.storybook', BROWSER_CONFIG: "{ provider: 'playwright' }", @@ -1146,7 +1320,7 @@ describe('updateWorkspaceFile', () => { + + // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon + export default defineWorkspace(['packages/*', 'ROOT_CONFIG', { - + extends: '', + + extends: '.', + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest @@ -1169,3 +1343,32 @@ describe('updateWorkspaceFile', () => { `); }); }); + +describe('loadTemplate', () => { + it('normalizes Windows paths to forward slashes', async () => { + // 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', { + CONFIG_DIR: windowsPath, + SETUP_FILE: '.\\apps\\frontend-storybook\\.storybook\\vitest.setup.ts', + }); + + // Should contain forward slashes, not backslashes + expect(result).toContain('apps/frontend-storybook/.storybook'); + expect(result).not.toContain('\\apps\\'); + }); + + it('preserves forward slashes in paths', async () => { + // Unix-style path with forward slashes + const unixPath = './apps/frontend-storybook/.storybook'; + + const result = await loadTemplate('vitest.config.template', { + CONFIG_DIR: unixPath, + SETUP_FILE: './apps/frontend-storybook/.storybook/vitest.setup.ts', + }); + + // Should still contain forward slashes + expect(result).toContain('apps/frontend-storybook/.storybook'); + }); +}); diff --git a/code/addons/vitest/src/updateVitestFile.ts b/code/addons/vitest/src/updateVitestFile.ts index 0f544dde3482..ca9a01bffd9a 100644 --- a/code/addons/vitest/src/updateVitestFile.ts +++ b/code/addons/vitest/src/updateVitestFile.ts @@ -1,17 +1,34 @@ -import * as fs from 'node:fs/promises'; - import type { BabelFile, types as t } from 'storybook/internal/babel'; -import { join } from 'pathe'; +import { normalize } from 'pathe'; -import { resolvePackageDir } from '../../../core/src/shared/utils/module'; +/** + * Each template is imported separately to allow the build system to process the template as raw + * text. A mix of globs and the "?raw" string query is not supported in esbuild + */ +async function getTemplatePath(name: string) { + switch (name) { + case 'vitest.config.template': + return import('../templates/vitest.config.template?raw'); + case 'vitest.config.4.template': + return import('../templates/vitest.config.4.template?raw'); + case 'vitest.config.3.2.template': + return import('../templates/vitest.config.3.2.template?raw'); + case 'vitest.workspace.template': + return import('../templates/vitest.workspace.template?raw'); + default: + throw new Error(`Unknown template: ${name}`); + } +} export const loadTemplate = async (name: string, replacements: Record) => { - let template = await fs.readFile( - join(resolvePackageDir('@storybook/addon-vitest'), 'templates', name), - 'utf8' + // Dynamically import the template file as plain text + const templateModule = await getTemplatePath(name); + let template = templateModule.default; + // Normalize Windows paths (backslashes) to forward slashes for JavaScript string compatibility + Object.entries(replacements).forEach( + ([key, value]) => (template = template.replace(key, normalize(value))) ); - Object.entries(replacements).forEach(([key, value]) => (template = template.replace(key, value))); return template; }; @@ -51,6 +68,60 @@ const mergeProperties = ( } }; +/** + * Resolves the target's default export to the actual config object expression we can merge into. + * Handles: export default defineConfig({}), export default {}, and export default config (where + * config is a variable holding defineConfig({}) or {}). + */ +const getTargetConfigObject = ( + target: BabelFile['ast'], + exportDefault: t.ExportDefaultDeclaration +): t.ObjectExpression | null => { + const decl = exportDefault.declaration; + if (decl.type === 'ObjectExpression') { + return decl; + } + if ( + decl.type === 'CallExpression' && + decl.callee.type === 'Identifier' && + decl.callee.name === 'defineConfig' && + decl.arguments[0]?.type === 'ObjectExpression' + ) { + return decl.arguments[0] as t.ObjectExpression; + } + if (decl.type === 'Identifier') { + const varName = decl.name; + const varDecl = target.program.body.find( + (n): n is t.VariableDeclaration => + n.type === 'VariableDeclaration' && + n.declarations.some((d) => d.id.type === 'Identifier' && d.id.name === varName) + ); + if (!varDecl) { + return null; + } + const declarator = varDecl.declarations.find( + (d) => d.id.type === 'Identifier' && d.id.name === varName + ); + if (!declarator?.init) { + return null; + } + const init = declarator.init; + if ( + init.type === 'CallExpression' && + init.callee.type === 'Identifier' && + init.callee.name === 'defineConfig' && + init.arguments[0]?.type === 'ObjectExpression' + ) { + return init.arguments[0] as t.ObjectExpression; + } + if (init.type === 'ObjectExpression') { + return init; + } + return null; + } + return null; +}; + /** * Merges a source Vitest configuration AST into a target configuration AST. * @@ -95,27 +166,42 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as } // Check if this is a function notation that we don't support + const rejectFunctionNotation = (decl: t.ExportDefaultDeclaration['declaration']) => { + if ( + decl.type === 'CallExpression' && + decl.callee.type === 'Identifier' && + decl.callee.name === 'defineConfig' && + decl.arguments.length > 0 && + decl.arguments[0].type === 'ArrowFunctionExpression' + ) { + return true; + } + return false; + }; if ( targetExportDefault.declaration.type === 'CallExpression' && - targetExportDefault.declaration.callee.type === 'Identifier' && - targetExportDefault.declaration.callee.name === 'defineConfig' && - targetExportDefault.declaration.arguments.length > 0 && - targetExportDefault.declaration.arguments[0].type === 'ArrowFunctionExpression' + rejectFunctionNotation(targetExportDefault.declaration) ) { - // This is function notation that we don't support return false; } + if (targetExportDefault.declaration.type === 'Identifier') { + const varName = targetExportDefault.declaration.name; + const varDecl = target.program.body.find( + (n): n is t.VariableDeclaration => + n.type === 'VariableDeclaration' && + n.declarations.some((d) => d.id.type === 'Identifier' && d.id.name === varName) + ); + const declarator = varDecl?.declarations.find( + (d) => d.id.type === 'Identifier' && d.id.name === varName + ); + if (declarator?.init?.type === 'CallExpression' && rejectFunctionNotation(declarator.init)) { + return false; + } + } - // Check if we can handle mergeConfig patterns + // Check if we can handle mergeConfig patterns (including export default config where config = defineConfig({})) let canHandleConfig = false; - if (targetExportDefault.declaration.type === 'ObjectExpression') { - canHandleConfig = true; - } else if ( - targetExportDefault.declaration.type === 'CallExpression' && - targetExportDefault.declaration.callee.type === 'Identifier' && - targetExportDefault.declaration.callee.name === 'defineConfig' && - targetExportDefault.declaration.arguments[0]?.type === 'ObjectExpression' - ) { + if (getTargetConfigObject(target, targetExportDefault) !== null) { canHandleConfig = true; } else if ( targetExportDefault.declaration.type === 'CallExpression' && @@ -170,16 +256,9 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as sourceNode.declaration.arguments[0].type === 'ObjectExpression' ) { const { properties } = sourceNode.declaration.arguments[0]; - if (exportDefault.declaration.type === 'ObjectExpression') { - mergeProperties(properties, exportDefault.declaration.properties); - updated = true; - } else if ( - exportDefault.declaration.type === 'CallExpression' && - exportDefault.declaration.callee.type === 'Identifier' && - exportDefault.declaration.callee.name === 'defineConfig' && - exportDefault.declaration.arguments[0]?.type === 'ObjectExpression' - ) { - mergeProperties(properties, exportDefault.declaration.arguments[0].properties); + const targetConfigObject = getTargetConfigObject(target, exportDefault); + if (targetConfigObject !== null) { + mergeProperties(properties, targetConfigObject.properties); updated = true; } else if ( exportDefault.declaration.type === 'CallExpression' && @@ -232,8 +311,64 @@ export const updateConfigFile = (source: BabelFile['ast'], target: BabelFile['as p.type === 'ObjectProperty' && p.key.type === 'Identifier' && p.key.name === 'test' ) as t.ObjectProperty | undefined; - if (templateTestProp && templateTestProp.value.type === 'ObjectExpression') { - // Find the workspace/projects array in the template + const hasProjectsProp = ( + p: t.ObjectMethod | t.ObjectProperty | t.SpreadElement + ): p is t.ObjectProperty => + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'projects' && + p.value.type === 'ArrayExpression'; + + // Check if the existing config already uses a projects array (multi-project setup). + // If so, we must append the storybook project to that array instead of wrapping + // the entire test config as a single project (which would cause double nesting). + const existingProjectsProp = existingTestProp.value.properties.find(hasProjectsProp); + + if (existingProjectsProp) { + // Existing config already has test.projects: append storybook project(s) to it + if (templateTestProp && templateTestProp.value.type === 'ObjectExpression') { + const templateProjectsProp = + templateTestProp.value.properties.find(hasProjectsProp); + if (templateProjectsProp && templateProjectsProp.value.type === 'ArrayExpression') { + const templateElements = (templateProjectsProp.value as t.ArrayExpression) + .elements; + (existingProjectsProp.value as t.ArrayExpression).elements.push( + ...templateElements + ); + } + // Merge other test-level options from template (e.g. coverage) into existing test + for (const templateProp of templateTestProp.value.properties) { + if ( + templateProp.type === 'ObjectProperty' && + templateProp.key.type === 'Identifier' && + (templateProp.key as t.Identifier).name !== 'projects' + ) { + const existingProp = existingTestProp.value.properties.find( + (p) => + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + (p.key as t.Identifier).name === (templateProp.key as t.Identifier).name + ); + if (!existingProp && templateProp.type === 'ObjectProperty') { + existingTestProp.value.properties.push(templateProp); + } + } + } + } + // Merge only non-test properties from template to avoid re-adding storybook project + const otherTemplateProps = properties.filter( + (p) => + !( + p.type === 'ObjectProperty' && + p.key.type === 'Identifier' && + p.key.name === 'test' + ) + ); + if (otherTemplateProps.length > 0) { + mergeProperties(otherTemplateProps, targetConfigObject.properties); + } + } else if (templateTestProp && templateTestProp.value.type === 'ObjectExpression') { + // Existing test has no projects array: wrap entire test config as one project const workspaceOrProjectsProp = templateTestProp.value.properties.find( (p) => p.type === 'ObjectProperty' && diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 9dc01100b2bd..b07edb2bfd00 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -297,6 +297,7 @@ export const storybookTest = async (options?: UserOptions): Promise => const baseConfig: Omit = { cacheDir: resolvePathInStorybookCache('sb-vitest', projectId), test: { + expect: { requireAssertions: false }, setupFiles: [ fileURLToPath(import.meta.resolve('@storybook/addon-vitest/internal/setup-file')), // if the existing setupFiles is a string, we have to include it otherwise we're overwriting it diff --git a/code/builders/builder-vite/package.json b/code/builders/builder-vite/package.json index f7abbd29ef07..0ad06232d6a9 100644 --- a/code/builders/builder-vite/package.json +++ b/code/builders/builder-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/builder-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "A Storybook builder to dev and build with Vite", "keywords": [ "storybook", @@ -47,7 +47,6 @@ ], "dependencies": { "@storybook/csf-plugin": "workspace:*", - "@vitest/mocker": "3.2.4", "ts-dedent": "^2.0.0" }, "devDependencies": { diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index db5eb1eb17c0..f25f14857b61 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -40,6 +40,29 @@ export async function build(options: Options) { const finalConfig = (await presets.apply('viteFinal', config, options)) as InlineConfig; + // Add a plugin to enforce Storybook's outDir after all other plugins. + // This prevents frameworks like Nitro from redirecting + // build output to their own directories (e.g., .output/public/). + // The 'enforce: post' ensures this runs after all other config hooks. + finalConfig.plugins?.push({ + name: 'storybook:enforce-output-dir', + enforce: 'post', + config: (config) => ({ + ...config, + build: { + outDir: options.outputDir, + }, + }), + // configEnvironment is a new method in Vite 6 + // It is used to configure configs based on the environment + // E.g. Nitro uses this method to set the output directory to .output/public/ + configEnvironment: () => ({ + build: { + outDir: options.outputDir, + }, + }), + }); + if (options.features?.developmentModeForBuild) { finalConfig.plugins?.push({ name: 'storybook:define-env', diff --git a/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.test.ts b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.test.ts new file mode 100644 index 000000000000..6d9a7e6f9061 --- /dev/null +++ b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; + +/** + * Unit-tests for the vite-inject-mocker plugin, focused on the `transformIndexHtml` hook which must + * emit a **relative** `src` during production builds (so Storybook artifacts load when hosted at + * non-root paths, e.g. GitHub Pages) and an **absolute** `src` during development (so Vite's + * dev-server `resolveId` can match it). + * + * @see https://github.com/storybookjs/storybook/issues/32428 + */ + +// We need to mock the import.meta.resolve call and node:url before +// importing the plugin, because the module resolves the mocker +// runtime path at import time. +vi.mock('node:url', () => ({ + fileURLToPath: vi.fn(() => '/fake/mocker-runtime.js'), +})); + +// Mock import.meta.resolve +vi.stubGlobal('import', { meta: { resolve: () => 'file:///fake/mocker-runtime.js' } }); + +// Dynamic import after mocks are set up +const { viteInjectMockerRuntime } = await import('./plugin.js'); + +function makeHtml(headAttrs = '') { + return ``; +} + +describe('vite-inject-mocker plugin β€” transformIndexHtml', () => { + function createPlugin(command: 'build' | 'serve') { + const plugin = viteInjectMockerRuntime({ previewConfigPath: null }) as any; + // Simulate Vite calling configResolved + plugin.configResolved({ command } as any); + return plugin; + } + + it('uses a relative path (./…) in build mode', () => { + const plugin = createPlugin('build'); + const html = makeHtml(); + const result = plugin.transformIndexHtml(html); + + expect(result).toContain('src="./vite-inject-mocker-entry.js"'); + expect(result).not.toContain('src="/vite-inject-mocker-entry.js"'); + }); + + it('uses an absolute path (/…) in dev mode', () => { + const plugin = createPlugin('serve'); + const html = makeHtml(); + const result = plugin.transformIndexHtml(html); + + expect(result).toContain('src="/vite-inject-mocker-entry.js"'); + // Ensure it's not the relative form + expect(result).not.toContain('src="./vite-inject-mocker-entry.js"'); + }); + + it('injects the script tag right after ', () => { + const plugin = createPlugin('build'); + const html = makeHtml(); + const result = plugin.transformIndexHtml(html); + + const headIndex = result.indexOf(''); + const scriptIndex = result.indexOf('', + '' + ); + expect(cleaned).toBe(html); + }); +}); diff --git a/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts index 8937c9a33bce..79dfaa64410f 100644 --- a/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts @@ -1,63 +1,35 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { resolvePackageDir } from 'storybook/internal/common'; +import type { ResolvedConfig } from 'vite'; -import { exactRegex } from '@rolldown/pluginutils'; -import { dedent } from 'ts-dedent'; -import type { ResolvedConfig, ViteDevServer } from 'vite'; - -const entryPath = '/vite-inject-mocker-entry.js'; - -const entryCode = dedent` - - `; - -let server: ViteDevServer; +const ENTRY_PATH = '/vite-inject-mocker-entry.js'; export const viteInjectMockerRuntime = (options: { previewConfigPath?: string | null; }): import('vite').Plugin => { + // Get the actual file path so Vite can resolve relative imports + const mockerRuntimePath = fileURLToPath( + import.meta.resolve('storybook/internal/mocking-utils/mocker-runtime') + ); + let viteConfig: ResolvedConfig; return { name: 'vite:storybook-inject-mocker-runtime', + enforce: 'pre', buildStart() { if (viteConfig.command === 'build') { this.emitFile({ type: 'chunk', - id: join( - resolvePackageDir('storybook'), - 'assets', - 'server', - 'mocker-runtime.template.js' - ), - fileName: entryPath.slice(1), + id: mockerRuntimePath, + fileName: ENTRY_PATH.slice(1), }); } }, - config() { - return { - optimizeDeps: { - include: ['@vitest/mocker', '@vitest/mocker/browser'], - }, - resolve: { - // Aliasing necessary for package managers like pnpm, since resolving modules from a virtual module - // leads to errors, if the imported module is not a dependency of the project. - // By resolving the module to the real path, we can avoid this issue. - alias: { - '@vitest/mocker/browser': fileURLToPath(import.meta.resolve('@vitest/mocker/browser')), - '@vitest/mocker': fileURLToPath(import.meta.resolve('@vitest/mocker')), - }, - }, - }; - }, configResolved(config) { viteConfig = config; }, - configureServer(server_) { - server = server_; + configureServer(server) { if (options.previewConfigPath) { server.watcher.on('change', (file) => { if (file === options.previewConfigPath) { @@ -69,31 +41,22 @@ export const viteInjectMockerRuntime = (options: { }); } }, - resolveId: { - filter: { - id: [exactRegex(entryPath)], - }, - handler(id) { - if (exactRegex(id).test(entryPath)) { - return id; - } - return null; - }, - }, - async load(id) { - if (exactRegex(id).test(entryPath)) { - return readFileSync( - join(resolvePackageDir('storybook'), 'assets', 'server', 'mocker-runtime.template.js'), - 'utf-8' - ); + resolveId(source) { + if (source === ENTRY_PATH) { + return mockerRuntimePath; } - - return null; + return undefined; }, transformIndexHtml(html: string) { const headTag = html.match(/]*>/); if (headTag) { + // Use a relative path for production builds so the script loads + // correctly when artifacts are hosted at non-root paths (e.g., + // GitHub Pages subdirectories). In dev mode, the absolute path + // is required so Vite's dev server can match it in resolveId. + const src = viteConfig.command === 'build' ? `.${ENTRY_PATH}` : ENTRY_PATH; + const entryCode = ``; const headTagIndex = html.indexOf(headTag[0]); const newHtml = html.slice(0, headTagIndex + headTag[0].length) + diff --git a/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts index 23861a121259..5f60995c05b0 100644 --- a/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { babelParser, extractMockCalls, + findMockRedirect, getAutomockCode, getRealPath, rewriteSbMockImportCalls, @@ -10,7 +11,6 @@ import { import { logger } from 'storybook/internal/node-logger'; import type { CoreConfig } from 'storybook/internal/types'; -import { findMockRedirect } from '@vitest/mocker/redirect'; import { normalize } from 'pathe'; import type { Plugin, ResolvedConfig } from 'vite'; diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index a5b5e95d2743..9bebe0f0b4b1 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/builder-webpack5", - "version": "10.2.0", + "version": "10.2.9", "description": "A Storybook builder to dev and build with Webpack", "keywords": [ "storybook", @@ -52,7 +52,6 @@ ], "dependencies": { "@storybook/core-webpack": "workspace:*", - "@vitest/mocker": "3.2.4", "case-sensitive-paths-webpack-plugin": "^2.4.0", "cjs-module-lexer": "^1.2.3", "css-loader": "^7.1.2", diff --git a/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts index 7a3f4b8f2dfe..dd53310fd231 100644 --- a/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts +++ b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts @@ -4,12 +4,12 @@ import { fileURLToPath } from 'node:url'; import { babelParser, extractMockCalls, + findMockRedirect, getIsExternal, resolveExternalModule, resolveWithExtensions, } from 'storybook/internal/mocking-utils'; -import { findMockRedirect } from '@vitest/mocker/redirect'; import type { Compiler } from 'webpack'; // --- Type Definitions --- diff --git a/code/core/build-config.ts b/code/core/build-config.ts index ee417c6603aa..3c72209d7694 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -190,6 +190,16 @@ const config: BuildEntries = { entryPoint: './src/manager/globals-runtime.ts', dts: false, }, + /** + * It is required to be a runtime entry point, because it is used to inject the mocker runtime + * into the preview iframe in builder-vite and builder-webpack5. To guarantee that the mocker + * runtime is transpiled correctly, code splitting needs to be disabled for this entry point. + */ + { + exportEntries: ['./internal/mocking-utils/mocker-runtime'], + entryPoint: './src/mocking-utils/mocker-runtime.js', + dts: false, + }, ], globalizedRuntime: [ { diff --git a/code/core/package.json b/code/core/package.json index 82bf0dbb653e..bbfabcd60555 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook: Develop, document, and test UI components in isolation", "keywords": [ "storybook", @@ -126,6 +126,7 @@ "types": "./dist/mocking-utils/index.d.ts", "default": "./dist/mocking-utils/index.js" }, + "./internal/mocking-utils/mocker-runtime": "./dist/mocking-utils/mocker-runtime.js", "./internal/node-logger": { "types": "./dist/node-logger/index.d.ts", "default": "./dist/node-logger/index.js" @@ -255,6 +256,7 @@ "@types/react-syntax-highlighter": "11.0.5", "@types/semver": "^7.7.1", "@types/ws": "^8", + "@vitest/mocker": "3.2.4", "@vitest/utils": "^3.2.4", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", 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/cli/AddonVitestService.test.ts b/code/core/src/cli/AddonVitestService.test.ts index f457886880ba..6c2f9fa19d2a 100644 --- a/code/core/src/cli/AddonVitestService.test.ts +++ b/code/core/src/cli/AddonVitestService.test.ts @@ -601,5 +601,48 @@ describe('AddonVitestService', () => { expect(result.reasons).toBeDefined(); expect(result.reasons!.length).toBe(2); }); + + it('should validate mergeConfig with plain object literal', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue( + 'export default mergeConfig(viteConfig, { test: { name: "node" } })' + ); + const result = await service.validateConfigFiles('.storybook'); + expect(result.compatible).toBe(true); + }); + + it('should validate mergeConfig with defineConfig call', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue( + 'export default mergeConfig(viteConfig, defineConfig({ test: { name: "node" } }))' + ); + const result = await service.validateConfigFiles('.storybook'); + expect(result.compatible).toBe(true); + }); + + it('should validate mergeConfig with multiple plain objects', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue( + 'export default mergeConfig({ test: {} }, { plugins: [] })' + ); + const result = await service.validateConfigFiles('.storybook'); + expect(result.compatible).toBe(true); + }); + + it('should reject mergeConfig with invalid object (non-object argument)', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue('export default mergeConfig(viteConfig, "string")'); + const result = await service.validateConfigFiles('.storybook'); + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('invalid Vitest config'))).toBe(true); + }); }); }); diff --git a/code/core/src/cli/AddonVitestService.ts b/code/core/src/cli/AddonVitestService.ts index a8375d0fa5f0..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, }), @@ -326,9 +331,7 @@ export class AddonVitestService { babel.traverse(parsedConfig, { ExportDefaultDeclaration: (path: any) => { if (this.isDefineConfigExpression(path.node.declaration)) { - isValidVitestConfig = this.isSafeToExtendWorkspace( - path.node.declaration as CallExpression - ); + isValidVitestConfig = this.isSafeToExtendWorkspace(path.node.declaration); } else if (this.isMergeConfigExpression(path.node.declaration)) { // the config could be anywhere in the mergeConfig call, so we need to check each argument const mergeCall = path.node.declaration as CallExpression; @@ -372,20 +375,34 @@ export class AddonVitestService { return babel.types.isCallExpression(path) && (path.callee as any)?.name === 'mergeConfig'; } - private isSafeToExtendWorkspace(node: CallExpression): boolean { - return ( - babel.types.isCallExpression(node) && - node.arguments.length > 0 && - babel.types.isObjectExpression(node.arguments?.[0]) && - node.arguments[0]?.properties.every( - (p: any) => - p.key?.name !== 'test' || - (babel.types.isObjectExpression(p.value) && - p.value.properties.every( - ({ key, value }: any) => - key?.name !== 'workspace' || babel.types.isArrayExpression(value) - )) - ) + private isSafeToExtendWorkspace(node: babel.types.Node): boolean { + // Extract the object expression to validate + let objectToValidate: babel.types.ObjectExpression | null = null; + + if (babel.types.isCallExpression(node)) { + // Handle function calls like defineConfig({...}) + if (node.arguments.length > 0 && babel.types.isObjectExpression(node.arguments[0])) { + objectToValidate = node.arguments[0]; + } + } else if (babel.types.isObjectExpression(node)) { + // Handle plain object literals like {...} + objectToValidate = node; + } + + // If we couldn't extract a valid object, it's not safe + if (!objectToValidate) { + return false; + } + + // Check that the object doesn't have problematic test.workspace properties + return objectToValidate.properties.every( + (p: any) => + p.key?.name !== 'test' || + (babel.types.isObjectExpression(p.value) && + p.value.properties.every( + ({ key, value }: any) => + key?.name !== 'workspace' || babel.types.isArrayExpression(value) + )) ); } } diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 7221aa4c08d6..53be2316dbca 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -624,7 +624,7 @@ export abstract class JsPackageManager { stdio?: 'inherit' | 'pipe' | 'ignore' ): ResultPromise; public abstract runPackageCommand( - options: Omit & { args: string[] } + options: Omit & { args: string[]; useRemotePkg?: boolean } ): ResultPromise; public abstract findInstallations(pattern?: string[]): Promise; public abstract findInstallations( diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index 29faee1efbe3..1123f792df44 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -79,11 +79,15 @@ export class PNPMProxy extends JsPackageManager { public runPackageCommand({ args, + useRemotePkg = false, ...options - }: Omit & { args: string[] }): ResultPromise { + }: Omit & { + args: string[]; + useRemotePkg?: boolean; + }): ResultPromise { return executeCommand({ command: 'pnpm', - args: ['exec', ...args], + args: [useRemotePkg ? 'dlx' : 'exec', ...args], ...options, }); } diff --git a/code/core/src/common/utils/get-storybook-refs.test.ts b/code/core/src/common/utils/get-storybook-refs.test.ts new file mode 100644 index 000000000000..9c1c4f540945 --- /dev/null +++ b/code/core/src/common/utils/get-storybook-refs.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { checkRef } from './get-storybook-refs'; + +describe('checkRef', () => { + afterEach(() => vi.restoreAllMocks()); + + it('returns true when fetch returns 200', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); + expect(await checkRef('https://chromatic.com')).toBe(true); + }); + + it('returns false when fetch returns 401', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 401 } as Response); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); + + it('returns false when fetch returns 200 with loginUrl', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ loginUrl: 'https://chromatic.com/login' }), + } as Response); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); + + it('returns false when fetch fails', async () => { + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + expect(await checkRef('https://chromatic.com')).toBe(false); + }); +}); diff --git a/code/core/src/common/utils/get-storybook-refs.ts b/code/core/src/common/utils/get-storybook-refs.ts index 457025541997..c6e1d10d1427 100644 --- a/code/core/src/common/utils/get-storybook-refs.ts +++ b/code/core/src/common/utils/get-storybook-refs.ts @@ -58,7 +58,7 @@ export const getAutoRefs = async (options: Options): Promise ); }; -const checkRef = (url: string) => +export const checkRef = (url: string) => fetch(`${url}/iframe.html`).then( async ({ ok, status }) => { if (ok) { diff --git a/code/core/src/common/utils/resolve-path-in-sb-cache.test.ts b/code/core/src/common/utils/resolve-path-in-sb-cache.test.ts new file mode 100644 index 000000000000..d559817f50e1 --- /dev/null +++ b/code/core/src/common/utils/resolve-path-in-sb-cache.test.ts @@ -0,0 +1,107 @@ +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as pkg from 'empathic/package'; + +import versions from '../versions'; +import { resolvePathInStorybookCache } from './resolve-path-in-sb-cache'; + +vi.mock('empathic/package', () => ({ + cache: vi.fn(), +})); + +vi.mock('../versions', () => ({ + default: { + storybook: '10.3.0-alpha.1', + }, +})); + +describe('resolvePathInStorybookCache', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should include version in the cache path when using empathic cache', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + const result = resolvePathInStorybookCache('test-file', 'test-sub'); + + expect(result).toContain(versions.storybook); + expect(result).toBe(join(mockCacheDir, versions.storybook, 'test-sub', 'test-file')); + }); + + it('should include version in the cache path when falling back to cwd', () => { + vi.mocked(pkg.cache).mockReturnValue(undefined); + const cwd = process.cwd(); + + const result = resolvePathInStorybookCache('test-file', 'test-sub'); + + expect(result).toContain(versions.storybook); + expect(result).toBe( + join(cwd, 'node_modules', '.cache', 'storybook', versions.storybook, 'test-sub', 'test-file') + ); + }); + + it('should use default sub directory when not provided', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + const result = resolvePathInStorybookCache('test-file'); + + expect(result).toBe(join(mockCacheDir, versions.storybook, 'default', 'test-file')); + }); + + it('should handle empty file or directory name', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + const result = resolvePathInStorybookCache('', 'test-sub'); + + // Note: path.join() normalizes away the trailing slash for empty strings + expect(result).toBe(join(mockCacheDir, versions.storybook, 'test-sub')); + }); + + it('should create consistent paths for the same version', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + const result1 = resolvePathInStorybookCache('file1', 'sub1'); + const result2 = resolvePathInStorybookCache('file2', 'sub1'); + + expect(result1).toContain(versions.storybook); + expect(result2).toContain(versions.storybook); + // Verify both paths share the same base directory by comparing parent directories + const parent1 = result1.substring(0, result1.lastIndexOf(join('sub1', 'file1'))); + const parent2 = result2.substring(0, result2.lastIndexOf(join('sub1', 'file2'))); + expect(parent1).toBe(parent2); + }); + + it('should handle different subdirectories', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + const result1 = resolvePathInStorybookCache('test-file', 'dev-server'); + const result2 = resolvePathInStorybookCache('test-file', 'telemetry'); + + expect(result1).toBe(join(mockCacheDir, versions.storybook, 'dev-server', 'test-file')); + expect(result2).toBe(join(mockCacheDir, versions.storybook, 'telemetry', 'test-file')); + }); + + it('should use "unknown" as version when storybook version is not available', () => { + const mockCacheDir = '/mock/node_modules/.cache/storybook'; + vi.mocked(pkg.cache).mockReturnValue(mockCacheDir); + + // Mock the versions module to return a falsy value + vi.mocked(versions).storybook = '' as any; + + const result = resolvePathInStorybookCache('test-file', 'test-sub'); + + expect(result).toContain('unknown'); + expect(result).toBe(join(mockCacheDir, 'unknown', 'test-sub', 'test-file')); + + // Reset the mock + vi.mocked(versions).storybook = '10.3.0-alpha.1'; + }); +}); diff --git a/code/core/src/common/utils/resolve-path-in-sb-cache.ts b/code/core/src/common/utils/resolve-path-in-sb-cache.ts index a45cc73e99d8..1a4fdfe51307 100644 --- a/code/core/src/common/utils/resolve-path-in-sb-cache.ts +++ b/code/core/src/common/utils/resolve-path-in-sb-cache.ts @@ -2,18 +2,28 @@ import { join } from 'node:path'; import * as pkg from 'empathic/package'; +import versions from '../versions'; + /** * Get the path of the file or directory with input name inside the Storybook cache directory: * - * - `node_modules/.cache/storybook/{directoryName}` in a Node.js project or npm package - * - `.cache/storybook/{directoryName}` otherwise + * - `node_modules/.cache/storybook/{version}/{directoryName}` in a Node.js project or npm package + * - `.cache/storybook/{version}/{directoryName}` otherwise + * + * The cache directory includes the Storybook version to ensure that upgrading Storybook + * automatically invalidates the cache, preventing stale cache issues. * * @param fileOrDirectoryName {string} Name of the file or directory + * @param sub {string} Optional subdirectory name (defaults to 'default') * @returns {string} Absolute path to the file or directory */ export function resolvePathInStorybookCache(fileOrDirectoryName: string, sub = 'default'): string { let cacheDirectory = pkg.cache('storybook'); cacheDirectory ||= join(process.cwd(), 'node_modules', '.cache', 'storybook'); - return join(cacheDirectory, sub, fileOrDirectoryName); + // Include the storybook version in the cache path to automatically invalidate + // cache when upgrading to a new version + const version = versions.storybook || 'unknown'; + + return join(cacheDirectory, version, sub, fileOrDirectoryName); } diff --git a/code/core/src/common/versions.ts b/code/core/src/common/versions.ts index 4d6ff4d49c6e..ff7c2b707d97 100644 --- a/code/core/src/common/versions.ts +++ b/code/core/src/common/versions.ts @@ -1,45 +1,45 @@ // auto generated file, do not edit export default { - '@storybook/addon-a11y': '10.2.0', - '@storybook/addon-docs': '10.2.0', - '@storybook/addon-links': '10.2.0', - '@storybook/addon-onboarding': '10.2.0', - 'storybook-addon-pseudo-states': '10.2.0', - '@storybook/addon-themes': '10.2.0', - '@storybook/addon-vitest': '10.2.0', - '@storybook/builder-vite': '10.2.0', - '@storybook/builder-webpack5': '10.2.0', - storybook: '10.2.0', - '@storybook/angular': '10.2.0', - '@storybook/ember': '10.2.0', - '@storybook/html-vite': '10.2.0', - '@storybook/nextjs': '10.2.0', - '@storybook/nextjs-vite': '10.2.0', - '@storybook/preact-vite': '10.2.0', - '@storybook/react-native-web-vite': '10.2.0', - '@storybook/react-vite': '10.2.0', - '@storybook/react-webpack5': '10.2.0', - '@storybook/server-webpack5': '10.2.0', - '@storybook/svelte-vite': '10.2.0', - '@storybook/sveltekit': '10.2.0', - '@storybook/vue3-vite': '10.2.0', - '@storybook/web-components-vite': '10.2.0', - sb: '10.2.0', - '@storybook/cli': '10.2.0', - '@storybook/codemod': '10.2.0', - '@storybook/core-webpack': '10.2.0', - 'create-storybook': '10.2.0', - '@storybook/csf-plugin': '10.2.0', - 'eslint-plugin-storybook': '10.2.0', - '@storybook/react-dom-shim': '10.2.0', - '@storybook/preset-create-react-app': '10.2.0', - '@storybook/preset-react-webpack': '10.2.0', - '@storybook/preset-server-webpack': '10.2.0', - '@storybook/html': '10.2.0', - '@storybook/preact': '10.2.0', - '@storybook/react': '10.2.0', - '@storybook/server': '10.2.0', - '@storybook/svelte': '10.2.0', - '@storybook/vue3': '10.2.0', - '@storybook/web-components': '10.2.0', + '@storybook/addon-a11y': '10.2.9', + '@storybook/addon-docs': '10.2.9', + '@storybook/addon-links': '10.2.9', + '@storybook/addon-onboarding': '10.2.9', + 'storybook-addon-pseudo-states': '10.2.9', + '@storybook/addon-themes': '10.2.9', + '@storybook/addon-vitest': '10.2.9', + '@storybook/builder-vite': '10.2.9', + '@storybook/builder-webpack5': '10.2.9', + storybook: '10.2.9', + '@storybook/angular': '10.2.9', + '@storybook/ember': '10.2.9', + '@storybook/html-vite': '10.2.9', + '@storybook/nextjs': '10.2.9', + '@storybook/nextjs-vite': '10.2.9', + '@storybook/preact-vite': '10.2.9', + '@storybook/react-native-web-vite': '10.2.9', + '@storybook/react-vite': '10.2.9', + '@storybook/react-webpack5': '10.2.9', + '@storybook/server-webpack5': '10.2.9', + '@storybook/svelte-vite': '10.2.9', + '@storybook/sveltekit': '10.2.9', + '@storybook/vue3-vite': '10.2.9', + '@storybook/web-components-vite': '10.2.9', + sb: '10.2.9', + '@storybook/cli': '10.2.9', + '@storybook/codemod': '10.2.9', + '@storybook/core-webpack': '10.2.9', + 'create-storybook': '10.2.9', + '@storybook/csf-plugin': '10.2.9', + 'eslint-plugin-storybook': '10.2.9', + '@storybook/react-dom-shim': '10.2.9', + '@storybook/preset-create-react-app': '10.2.9', + '@storybook/preset-react-webpack': '10.2.9', + '@storybook/preset-server-webpack': '10.2.9', + '@storybook/html': '10.2.9', + '@storybook/preact': '10.2.9', + '@storybook/react': '10.2.9', + '@storybook/server': '10.2.9', + '@storybook/svelte': '10.2.9', + '@storybook/vue3': '10.2.9', + '@storybook/web-components': '10.2.9', }; diff --git a/code/core/src/components/components/Select/Select.stories.tsx b/code/core/src/components/components/Select/Select.stories.tsx index 4861d26ae5d1..a6abd2ce3db1 100644 --- a/code/core/src/components/components/Select/Select.stories.tsx +++ b/code/core/src/components/components/Select/Select.stories.tsx @@ -1479,3 +1479,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'); + }); + }, +}); diff --git a/code/core/src/components/components/Select/Select.tsx b/code/core/src/components/components/Select/Select.tsx index 0844cb7ed982..e50c192aedba 100644 --- a/code/core/src/components/components/Select/Select.tsx +++ b/code/core/src/components/components/Select/Select.tsx @@ -78,6 +78,13 @@ export interface SelectProps extends Omit< onSelect?: (option: Value) => void; onDeselect?: (option: Value) => void; onChange?: (selected: Value[]) => void; + /** + * Legacy option for ToolbarMenuSelect. Do not use in new code. Controls whether to show the + * selected option's title. + * + * @default true + */ + showSelectedOptionTitle?: boolean; } function valueToId(parentId: string, { value }: InternalOption | ResetOption): string { @@ -208,6 +215,7 @@ export const Select = forwardRef( onChange, tooltip, ariaLabel, + showSelectedOptionTitle = true, ...props }, ref @@ -522,7 +530,7 @@ export const Select = forwardRef( {!multiSelect && ( <> {icon} - {selectedOptions[0]?.title ?? children} + {(showSelectedOptionTitle && selectedOptions[0]?.title) || children} )} diff --git a/code/core/src/core-events/index.ts b/code/core/src/core-events/index.ts index a7a47a66eb2a..36d75f3505f1 100644 --- a/code/core/src/core-events/index.ts +++ b/code/core/src/core-events/index.ts @@ -96,6 +96,10 @@ enum events { OPEN_IN_EDITOR_RESPONSE = 'openInEditorResponse', // Emitted when the manager UI sets up a focus trap MANAGER_INERT_ATTRIBUTE_CHANGED = 'managerInertAttributeChanged', + + SHARE_STORY_LINK = 'shareStoryLink', + SHARE_ISOLATE_MODE = 'shareIsolateMode', + SHARE_POPOVER_OPENED = 'sharePopoverOpened', } // Enables: `import Events from ...` @@ -167,6 +171,9 @@ export const { OPEN_IN_EDITOR_REQUEST, OPEN_IN_EDITOR_RESPONSE, MANAGER_INERT_ATTRIBUTE_CHANGED, + SHARE_STORY_LINK, + SHARE_ISOLATE_MODE, + SHARE_POPOVER_OPENED, } = events; export * from './data/create-new-story'; diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 36f40b823c94..072266501fc2 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -34,7 +34,7 @@ import { initCreateNewStoryChannel } from '../server-channel/create-new-story-ch import { initFileSearchChannel } from '../server-channel/file-search-channel'; import { initGhostStoriesChannel } from '../server-channel/ghost-stories-channel'; import { initOpenInEditorChannel } from '../server-channel/open-in-editor-channel'; -import { initPreviewInitializedChannel } from '../server-channel/preview-initialized-channel'; +import { initTelemetryChannel } from '../server-channel/telemetry-channel'; import { initializeChecklist } from '../utils/checklist'; import { defaultFavicon, defaultStaticDirs } from '../utils/constants'; import { initializeSaveStory } from '../utils/save-story/save-story'; @@ -256,7 +256,7 @@ export const experimental_serverChannel = async ( ) => { const coreOptions = await options.presets.apply('core'); - initializeChecklist(); + initializeChecklist(coreOptions?.disableTelemetry); initializeWhatsNew(channel, options, coreOptions); initializeSaveStory(channel, options, coreOptions); @@ -264,7 +264,7 @@ export const experimental_serverChannel = async ( initCreateNewStoryChannel(channel, options, coreOptions); initGhostStoriesChannel(channel, options, coreOptions); initOpenInEditorChannel(channel, options, coreOptions); - initPreviewInitializedChannel(channel, options, coreOptions); + initTelemetryChannel(channel, options); return channel; }; diff --git a/code/core/src/core-server/server-channel/preview-initialized-channel.test.ts b/code/core/src/core-server/server-channel/telemetry-channel.test.ts similarity index 97% rename from code/core/src/core-server/server-channel/preview-initialized-channel.test.ts rename to code/core/src/core-server/server-channel/telemetry-channel.test.ts index 0055849979c0..591380e9cf19 100644 --- a/code/core/src/core-server/server-channel/preview-initialized-channel.test.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { makePayload } from './preview-initialized-channel'; +import { makePayload } from './telemetry-channel'; describe('makePayload', () => { beforeEach(() => { diff --git a/code/core/src/core-server/server-channel/preview-initialized-channel.ts b/code/core/src/core-server/server-channel/telemetry-channel.ts similarity index 60% rename from code/core/src/core-server/server-channel/preview-initialized-channel.ts rename to code/core/src/core-server/server-channel/telemetry-channel.ts index 20b139c349ec..e4b2820611ea 100644 --- a/code/core/src/core-server/server-channel/preview-initialized-channel.ts +++ b/code/core/src/core-server/server-channel/telemetry-channel.ts @@ -1,9 +1,14 @@ import type { Channel } from 'storybook/internal/channels'; -import { PREVIEW_INITIALIZED } from 'storybook/internal/core-events'; +import { + PREVIEW_INITIALIZED, + SHARE_ISOLATE_MODE, + SHARE_POPOVER_OPENED, + SHARE_STORY_LINK, +} from 'storybook/internal/core-events'; import { type InitPayload, telemetry } from 'storybook/internal/telemetry'; import { type CacheEntry, getLastEvents } from 'storybook/internal/telemetry'; import { getSessionId } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options } from 'storybook/internal/types'; +import type { Options } from 'storybook/internal/types'; export const makePayload = ( userAgent: string, @@ -24,13 +29,9 @@ export const makePayload = ( return payload; }; -export function initPreviewInitializedChannel( - channel: Channel, - options: Options, - _coreConfig: CoreConfig -) { - channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => { - if (!options.disableTelemetry) { +export function initTelemetryChannel(channel: Channel, options: Options) { + if (!options.disableTelemetry) { + channel.on(PREVIEW_INITIALIZED, async ({ userAgent }) => { try { const sessionId = await getSessionId(); const lastEvents = await getLastEvents(); @@ -40,9 +41,16 @@ export function initPreviewInitializedChannel( const payload = makePayload(userAgent, lastInit, sessionId); telemetry('preview-first-load', payload); } - } catch (e) { - // do nothing - } - } - }); + } catch {} + }); + channel.on(SHARE_POPOVER_OPENED, async () => { + telemetry('share', { action: 'popover-opened' }); + }); + channel.on(SHARE_STORY_LINK, async () => { + telemetry('share', { action: 'story-link-copied' }); + }); + channel.on(SHARE_ISOLATE_MODE, async () => { + telemetry('share', { action: 'isolate-mode-opened' }); + }); + } } diff --git a/code/core/src/core-server/utils/checklist.ts b/code/core/src/core-server/utils/checklist.ts index 44049e9d6cc2..8463f21e9da6 100644 --- a/code/core/src/core-server/utils/checklist.ts +++ b/code/core/src/core-server/utils/checklist.ts @@ -15,7 +15,7 @@ import { UNIVERSAL_CHECKLIST_STORE_OPTIONS, } from '../../shared/checklist-store'; -export async function initializeChecklist() { +export async function initializeChecklist(disableTelemetry = false) { try { const store = experimental_UniversalStore.create({ ...UNIVERSAL_CHECKLIST_STORE_OPTIONS, @@ -85,6 +85,15 @@ export async function initializeChecklist() { saveProjectState({ items: projectValues as StoreState['items'] }); saveUserState({ items: userValues, widget: state.widget }); + if (disableTelemetry) { + return; + } + + // Skip telemetry when loading from persistence (first transition to loaded: true) + if (!previousState.loaded) { + return; + } + // Gather items that have changed state const { mutedItems, statusItems } = entries.reduce( (acc, [item, { mutedAt, status }]) => { diff --git a/code/core/src/csf-tools/CsfFile.test.ts b/code/core/src/csf-tools/CsfFile.test.ts index b6fec897d9b9..ab2ea4236bc2 100644 --- a/code/core/src/csf-tools/CsfFile.test.ts +++ b/code/core/src/csf-tools/CsfFile.test.ts @@ -2970,11 +2970,13 @@ describe('CsfFile', () => { }); it('bad preview import', () => { + // Only throws when the variable is named "preview" to avoid false positives + // from libraries like Zod that have their own .meta() methods expect(() => parse( dedent` - import { config } from '#.storybook/bad-preview' - const meta = config.meta({ component: 'foo' }); + import { preview } from '#.storybook/bad-preview' + const meta = preview.meta({ component: 'foo' }); export const A = meta.story({}) ` ) @@ -3047,6 +3049,61 @@ describe('CsfFile', () => { More info: https://storybook.js.org/docs/writing-stories?ref=error#default-export] `); }); + + it('ignores unrelated .meta() calls on imported variables (e.g., Zod v4)', () => { + // This should NOT throw - mySchema.meta() is not a CSF Factory call + // See: https://github.com/storybookjs/storybook/issues/33654 + const parsed = loadCsf( + dedent` + import { mySchema } from './schemas'; + + const validatedSchema = mySchema.meta({ description: 'Value' }); + + export default { + title: 'Example', + component: () => null, + }; + + export const Default = {}; + `, + { makeTitle } + ).parse(); + + expect(parsed._meta).toMatchInlineSnapshot(` + title: Example + component: () => null + `); + expect(Object.keys(parsed._stories)).toEqual(['Default']); + }); + + it('ignores chained .meta() calls from libraries like Zod', () => { + // More complex Zod-like patterns should also work + const parsed = loadCsf( + dedent` + import { z } from 'zod'; + import { mySchema } from './schemas'; + + const workingSchema = z.object({ + name: z.string().meta({ description: 'Name' }), + }); + + const failingSchema = z.object({ + value: mySchema.meta({ description: 'Value' }), + }); + + export default { + title: 'Example', + component: () => null, + }; + + export const Default = {}; + `, + { makeTitle } + ).parse(); + + expect(parsed._meta?.title).toBe('Example'); + expect(Object.keys(parsed._stories)).toEqual(['Default']); + }); }); }); }); diff --git a/code/core/src/csf-tools/CsfFile.ts b/code/core/src/csf-tools/CsfFile.ts index dbff965d8070..4ecbdbd72c35 100644 --- a/code/core/src/csf-tools/CsfFile.ts +++ b/code/core/src/csf-tools/CsfFile.ts @@ -858,7 +858,10 @@ export class CsfFile { : callee.property.name; const metaNode = node.arguments[0] as t.ObjectExpression; self._parseMeta(metaNode, self._ast.program); - } else { + } else if (rootObject.name === 'preview') { + // Only throw if the variable is named "preview" - this indicates + // the user is trying to use CSF Factories but with a wrong import path. + // Other .meta() calls (e.g., Zod v4's .meta()) are silently ignored. throw new BadMetaError( 'meta() factory must be imported from .storybook/preview configuration', configParent, diff --git a/code/core/src/manager-api/modules/refs.ts b/code/core/src/manager-api/modules/refs.ts index 586453443c05..040b89d9588c 100644 --- a/code/core/src/manager-api/modules/refs.ts +++ b/code/core/src/manager-api/modules/refs.ts @@ -81,10 +81,8 @@ export const getSourceType = (source: string, refId?: string) => { const { origin: localOrigin, pathname: localPathname } = location; const { origin: sourceOrigin, pathname: sourcePathname } = new URL(source); - const localFull = `${localOrigin + localPathname}`.replace('/iframe.html', '').replace(/\/$/, ''); - const sourceFull = `${sourceOrigin + sourcePathname}` - .replace('/iframe.html', '') - .replace(/\/$/, ''); + const localFull = `${localOrigin + localPathname}`.replace(/\/[^\/]*$/, ''); + const sourceFull = `${sourceOrigin + sourcePathname}`.replace(/\/[^\/]*$/, ''); if (localFull === sourceFull) { return ['local', sourceFull]; @@ -118,6 +116,17 @@ async function handleRequest( throw new Error('Unexpected boolean response'); } if (!response.ok) { + // Check for 401 responses that may contain loginUrl + if (response.status === 401) { + try { + const json = await response.json(); + if (json.loginUrl) { + return { loginUrl: json.loginUrl }; + } + } catch { + // Fall through to error handling if JSON parsing fails + } + } throw new Error(`Unexpected response not OK: ${response.statusText}`); } diff --git a/code/core/src/manager-api/modules/url.ts b/code/core/src/manager-api/modules/url.ts index 83633a00de4e..b7f37e179e59 100644 --- a/code/core/src/manager-api/modules/url.ts +++ b/code/core/src/manager-api/modules/url.ts @@ -247,13 +247,14 @@ export const init: ModuleFn = (moduleArgs) => { throw new Error(`Invalid refId: ${refId}`); } - const originAddress = global.window.location.origin + location.pathname; + const pathname = location.pathname || '/'; + const originAddress = global.window.location.origin + pathname; const networkAddress = global.STORYBOOK_NETWORK_ADDRESS ?? originAddress; const managerBase = - base === 'origin' ? originAddress : base === 'network' ? networkAddress : location.pathname; + base === 'origin' ? originAddress : base === 'network' ? networkAddress : pathname; const previewBase = refId ? refs[refId].url + '/iframe.html' - : global.PREVIEW_URL || `${managerBase}iframe.html`; + : global.PREVIEW_URL || `${managerBase.replace(/\/[^/]*$/, '/')}iframe.html`; const refParam = refId ? `&refId=${encodeURIComponent(refId)}` : ''; const { args = '', globals = '', ...otherParams } = queryParams; diff --git a/code/core/src/manager-api/tests/refs.test.ts b/code/core/src/manager-api/tests/refs.test.ts index bc337d3a7497..d3ec49e7cf7b 100644 --- a/code/core/src/manager-api/tests/refs.test.ts +++ b/code/core/src/manager-api/tests/refs.test.ts @@ -73,6 +73,7 @@ function createMockStore(initialState: Partial = {}) { interface ResponseResult { ok?: boolean; + status?: number; err?: Error; response?: () => never | object | Promise; } @@ -86,13 +87,14 @@ type ResponseKeys = | 'metadata'; function respond(result: ResponseResult): Promise { - const { err, ok, response } = result; + const { err, ok, status, response } = result; if (err) { return Promise.reject(err); } return Promise.resolve({ ok: ok ?? !!response, + status: status ?? (ok ? 200 : 500), json: response, } as Response); } @@ -784,6 +786,52 @@ describe('Refs API', () => { `); }); + it('checks refs (auth with 401)', async () => { + // given + const { api } = initRefs({ provider, store } as any, { runCheck: false }); + + setupResponses({ + indexPrivate: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + storiesPrivate: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + metadata: { + ok: false, + status: 401, + response: async () => ({ loginUrl: 'https://example.com/login' }), + }, + }); + + await api.checkRef({ + id: 'fake', + url: 'https://example.com', + title: 'Fake', + }); + + expect(store.setState.mock.calls[0][0]).toMatchInlineSnapshot(` + { + "refs": { + "fake": { + "filteredIndex": undefined, + "id": "fake", + "index": undefined, + "internal_index": undefined, + "loginUrl": "https://example.com/login", + "title": "Fake", + "type": "auto-inject", + "url": "https://example.com", + }, + }, + } + `); + }); + it('checks refs (basic-auth)', async () => { // given const { api } = initRefs({ provider, store } as any, { runCheck: false }); diff --git a/code/core/src/manager-api/tests/url.test.js b/code/core/src/manager-api/tests/url.test.js index a32e062cbd31..aedbc591d13e 100644 --- a/code/core/src/manager-api/tests/url.test.js +++ b/code/core/src/manager-api/tests/url.test.js @@ -469,5 +469,21 @@ describe('getStoryHrefs', () => { const { managerHref, previewHref } = api.getStoryHrefs('test--story'); expect(managerHref).toEqual('/?path=/story/test--story'); expect(previewHref).toEqual('https://custom.preview.url/?id=test--story&viewMode=story'); + delete global.PREVIEW_URL; + }); + + it('correctly links from /index.html', () => { + const { api, state } = initURL({ + store, + provider: { channel: new EventEmitter() }, + state: { location: { pathname: '/index.html', search: '' } }, + navigate: vi.fn(), + fullAPI: { getCurrentStoryData: () => ({ id: 'test--story' }) }, + }); + store.setState(state); + + const { managerHref, previewHref } = api.getStoryHrefs('test--story'); + expect(managerHref).toEqual('/index.html?path=/story/test--story'); + expect(previewHref).toEqual('/iframe.html?id=test--story&viewMode=story'); }); }); diff --git a/code/core/src/manager-api/version.ts b/code/core/src/manager-api/version.ts index 80ce536b3c01..abc11654b05e 100644 --- a/code/core/src/manager-api/version.ts +++ b/code/core/src/manager-api/version.ts @@ -1 +1 @@ -export const version = '10.2.0'; +export const version = '10.2.9'; diff --git a/code/core/src/manager/components/preview/Viewport.tsx b/code/core/src/manager/components/preview/Viewport.tsx index 9c0fa7f46975..78d0e5910878 100644 --- a/code/core/src/manager/components/preview/Viewport.tsx +++ b/code/core/src/manager/components/preview/Viewport.tsx @@ -359,7 +359,7 @@ export const Viewport = ({ style={{ height: `${(1 / scale) * 100}%`, width: `${(1 / scale) * 100}%`, - transform: `scale(${scale})`, + transform: scale !== 1 ? `scale(${scale})` : 'none', transformOrigin: 'top left', }} > diff --git a/code/core/src/manager/components/preview/tools/share.stories.tsx b/code/core/src/manager/components/preview/tools/share.stories.tsx index 2a7622bde711..0153d555d0bf 100644 --- a/code/core/src/manager/components/preview/tools/share.stories.tsx +++ b/code/core/src/manager/components/preview/tools/share.stories.tsx @@ -5,7 +5,7 @@ import { global } from '@storybook/global'; import type { StoryObj } from '@storybook/react-vite'; import { ManagerContext } from 'storybook/manager-api'; -import { expect, screen, waitFor } from 'storybook/test'; +import { expect, fn, screen, waitFor } from 'storybook/test'; import { shareTool } from './share'; @@ -15,6 +15,7 @@ const managerContext = { refId: undefined, }, api: { + emit: fn().mockName('api::emit'), getShortcutKeys: () => ({ copyStoryLink: ['alt', 'shift', 'l'], openInIsolation: ['alt', 'shift', 'i'], diff --git a/code/core/src/manager/components/preview/tools/share.tsx b/code/core/src/manager/components/preview/tools/share.tsx index 6c9863df3647..caa46f7c78d4 100644 --- a/code/core/src/manager/components/preview/tools/share.tsx +++ b/code/core/src/manager/components/preview/tools/share.tsx @@ -1,6 +1,11 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Button, PopoverProvider, TooltipLinkList } from 'storybook/internal/components'; +import { + SHARE_ISOLATE_MODE, + SHARE_POPOVER_OPENED, + SHARE_STORY_LINK, +} from 'storybook/internal/core-events'; import type { Addon_BaseType } from 'storybook/internal/types'; import { global } from '@storybook/global'; @@ -71,6 +76,10 @@ const ShareMenu = React.memo(function ShareMenu({ const copyStoryLink = shortcutKeys?.copyStoryLink; const openInIsolation = shortcutKeys?.openInIsolation; + useEffect(() => { + api.emit(SHARE_POPOVER_OPENED); + }, [api]); + const links = useMemo(() => { const copyTitle = copied ? 'Copied!' : 'Copy story link'; const originHrefs = api.getStoryHrefs(storyId, { base: 'origin', refId }); @@ -84,6 +93,7 @@ const ShareMenu = React.memo(function ShareMenu({ icon: , right: enableShortcuts ? : null, onClick: () => { + api.emit(SHARE_STORY_LINK, originHrefs.managerHref); copy(originHrefs.managerHref); setCopied(true); setTimeout(() => setCopied(false), 2000); @@ -94,6 +104,9 @@ const ShareMenu = React.memo(function ShareMenu({ title: 'Open in isolation mode', icon: , right: enableShortcuts ? : null, + onClick: () => { + api.emit(SHARE_ISOLATE_MODE, originHrefs.previewHref); + }, href: originHrefs.previewHref, target: '_blank', rel: 'noopener noreferrer', diff --git a/code/core/src/manager/components/preview/tools/zoom.stories.tsx b/code/core/src/manager/components/preview/tools/zoom.stories.tsx index 4c4309a6ab68..75a5eec411ca 100644 --- a/code/core/src/manager/components/preview/tools/zoom.stories.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.stories.tsx @@ -8,7 +8,7 @@ import preview from '../../../../../../.storybook/preview'; import { Zoom } from './zoom'; const openDialog = async (context: StoryContext) => { - const zoom = await context.canvas.findByRole('button', { name: 'Change zoom level' }); + const zoom = await context.canvas.findByRole('switch', { name: 'Change zoom level' }); await context.userEvent.click(zoom); return screen.findByRole('dialog'); }; diff --git a/code/core/src/manager/components/preview/tools/zoom.tsx b/code/core/src/manager/components/preview/tools/zoom.tsx index d6dfc5307929..8ae440d7f092 100644 --- a/code/core/src/manager/components/preview/tools/zoom.tsx +++ b/code/core/src/manager/components/preview/tools/zoom.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from 'react'; import React, { Component, createContext, memo, useCallback, useEffect, useRef } from 'react'; -import { ActionList, Button, PopoverProvider } from 'storybook/internal/components'; +import { ActionList, PopoverProvider, ToggleButton } from 'storybook/internal/components'; import type { Addon_BaseType } from 'storybook/internal/types'; import { UndoIcon, ZoomIcon } from '@storybook/icons'; @@ -15,7 +15,7 @@ import { NumericInput } from '../NumericInput'; const ZOOM_LEVELS = [0.25, 0.5, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 2, 3, 4, 8] as const; const INITIAL_ZOOM_LEVEL = 1; -const ZoomButton = styled(Button)({ +const ZoomButton = styled(ToggleButton)({ minWidth: 48, }); @@ -148,7 +148,7 @@ export const Zoom = memo<{ padding="small" variant="ghost" ariaLabel="Change zoom level" - active={value !== INITIAL_ZOOM_LEVEL} + pressed={value !== INITIAL_ZOOM_LEVEL} > {Math.round(value * 100)}% diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 59703619f1de..e2b984eda3eb 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -615,6 +615,9 @@ export default { 'SET_WHATS_NEW_CACHE', 'SHARED_STATE_CHANGED', 'SHARED_STATE_SET', + 'SHARE_ISOLATE_MODE', + 'SHARE_POPOVER_OPENED', + 'SHARE_STORY_LINK', 'STORIES_COLLAPSE_ALL', 'STORIES_EXPAND_ALL', 'STORY_ARGS_UPDATED', diff --git a/code/core/src/mocking-utils/index.ts b/code/core/src/mocking-utils/index.ts index 5d418381446e..f6ef78ec4af5 100644 --- a/code/core/src/mocking-utils/index.ts +++ b/code/core/src/mocking-utils/index.ts @@ -3,3 +3,4 @@ export * from './extract'; export * from './resolve'; export * from './esmWalker'; export * from './runtime'; +export * from './redirect'; diff --git a/code/core/assets/server/mocker-runtime.template.js b/code/core/src/mocking-utils/mocker-runtime.js similarity index 100% rename from code/core/assets/server/mocker-runtime.template.js rename to code/core/src/mocking-utils/mocker-runtime.js diff --git a/code/core/src/mocking-utils/redirect.ts b/code/core/src/mocking-utils/redirect.ts new file mode 100644 index 000000000000..fdeef615419c --- /dev/null +++ b/code/core/src/mocking-utils/redirect.ts @@ -0,0 +1,3 @@ +// Re-export findMockRedirect from @vitest/mocker/redirect +// This allows builders to use it without depending on @vitest/mocker directly +export { findMockRedirect } from '@vitest/mocker/redirect'; diff --git a/code/core/src/mocking-utils/runtime.ts b/code/core/src/mocking-utils/runtime.ts index eca3c51629f4..be8b131ba6f0 100644 --- a/code/core/src/mocking-utils/runtime.ts +++ b/code/core/src/mocking-utils/runtime.ts @@ -1,28 +1,13 @@ -import { resolvePackageDir } from 'storybook/internal/common'; - -import { buildSync } from 'esbuild'; -import { join } from 'pathe'; - -const runtimeTemplatePath = join( - resolvePackageDir('storybook'), - 'assets', - 'server', - 'mocker-runtime.template.js' -); - -export function getMockerRuntime() { - // Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.) - // into a single, self-contained string of code. - const bundleResult = buildSync({ - entryPoints: [runtimeTemplatePath], - bundle: true, - write: false, // Return the result in memory instead of writing to disk - format: 'esm', - target: 'es2020', - external: ['msw/browser', 'msw/core/http'], - }); - - const runtimeScriptContent = bundleResult.outputFiles[0].text; - - return runtimeScriptContent; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Returns the bundled mocker runtime script content. This is used by builders (webpack5, vite, + * etc.) to inject the mocker runtime into the preview iframe. + */ +export function getMockerRuntime(): string { + return readFileSync( + fileURLToPath(import.meta.resolve('storybook/internal/mocking-utils/mocker-runtime')), + 'utf-8' + ); } 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) { diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index d163f611864d..9da582ca418b 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -482,22 +482,6 @@ export class AddonVitestPostinstallFailedAddonA11yError extends StorybookError { } } -export class AddonVitestPostinstallExistingSetupFileError extends StorybookError { - constructor(public data: { filePath: string }) { - super({ - name: 'AddonVitestPostinstallExistingSetupFileError', - category: Category.CLI_INIT, - isHandledError: true, - code: 7, - documentation: `https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup-advanced`, - message: dedent` - Found an existing Vitest setup file: ${data.filePath} - Please refer to the documentation to complete the setup manually. - `, - }); - } -} - export class AddonVitestPostinstallWorkspaceUpdateError extends StorybookError { constructor(public data: { filePath: string }) { super({ diff --git a/code/core/src/telemetry/detect-agent.test.ts b/code/core/src/telemetry/detect-agent.test.ts new file mode 100644 index 000000000000..077ef55c23b2 --- /dev/null +++ b/code/core/src/telemetry/detect-agent.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { detectAgent } from './detect-agent'; + +describe('detectAgent', () => { + it('detects amp via AGENT=amp (highest precedence)', () => { + expect( + detectAgent({ + stdoutIsTTY: true, + env: { + AGENT: 'amp', + CLAUDECODE: '1', + GEMINI_CLI: '1', + CODEX_SANDBOX: '1', + CURSOR_AGENT: '1', + }, + }) + ).toEqual({ name: 'amp' }); + + expect( + detectAgent({ + stdoutIsTTY: true, + env: { + CLAUDECODE: '1', + GEMINI_CLI: '1', + CODEX_SANDBOX: '1', + CURSOR_AGENT: '1', + AGENT: 'something', + }, + }) + ).toEqual({ name: 'claude-code' }); + }); + + it('detects Gemini CLI via GEMINI_CLI', () => { + expect(detectAgent({ stdoutIsTTY: true, env: { GEMINI_CLI: '1' } })).toEqual({ + name: 'gemini-cli', + }); + }); + + it('detects OpenAI Codex via CODEX_SANDBOX', () => { + expect(detectAgent({ stdoutIsTTY: true, env: { CODEX_SANDBOX: '1' } })).toEqual({ + name: 'codex', + }); + }); + + it('detects Cursor Agent via CURSOR_AGENT (even if AGENT is also set)', () => { + expect( + detectAgent({ stdoutIsTTY: true, env: { CURSOR_AGENT: '1', AGENT: 'something' } }) + ).toEqual({ + name: 'cursor', + }); + }); + + it('treats generic AGENT as unknown', () => { + expect(detectAgent({ stdoutIsTTY: true, env: { AGENT: 'some-agent' } })).toEqual({ + name: 'unknown', + }); + }); + + it('does not use heuristics when stdout is a TTY', () => { + expect(detectAgent({ stdoutIsTTY: true, env: { TERM: 'dumb' } })).toEqual(undefined); + expect(detectAgent({ stdoutIsTTY: true, env: { GIT_PAGER: 'cat' } })).toEqual(undefined); + }); + + it('detects unknown agent via TERM=dumb when stdout is not a TTY', () => { + expect(detectAgent({ stdoutIsTTY: false, env: { TERM: 'dumb' } })).toEqual({ + name: 'unknown', + }); + }); + + it('detects unknown agent via GIT_PAGER=cat when stdout is not a TTY', () => { + expect(detectAgent({ stdoutIsTTY: false, env: { GIT_PAGER: 'cat' } })).toEqual({ + name: 'unknown', + }); + }); + + it('returns isAgent=false when there are no signals', () => { + expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined); + }); + + it('applies heuristics even when CI is set (no CI special-casing)', () => { + expect( + detectAgent({ + stdoutIsTTY: false, + env: { CI: 'true', TERM: 'dumb' }, + }) + ).toEqual({ name: 'unknown' }); + }); + + it('still detects explicit agents in CI', () => { + expect(detectAgent({ stdoutIsTTY: false, env: { CI: 'true', CODEX_SANDBOX: '1' } })).toEqual({ + name: 'codex', + }); + }); +}); diff --git a/code/core/src/telemetry/detect-agent.ts b/code/core/src/telemetry/detect-agent.ts new file mode 100644 index 000000000000..f4cd4d11d4e4 --- /dev/null +++ b/code/core/src/telemetry/detect-agent.ts @@ -0,0 +1,90 @@ +export type KnownAgentName = + | 'claude-code' + | 'gemini-cli' + | 'cursor' + | 'codex' + | 'opencode' + | 'amp' + | 'unknown'; + +export type AgentInfo = { + name: KnownAgentName; +}; + +export type AgentDetection = AgentInfo | undefined; + +type DetectAgentOptions = { + stdoutIsTTY: boolean; + env: NodeJS.ProcessEnv; +}; + +function detectExplicitAgent(env: NodeJS.ProcessEnv): AgentInfo | undefined { + // Amp + if (env.AGENT === 'amp') { + return { + name: 'amp', + }; + } + + // Claude Code + if (env.CLAUDECODE) { + return { + name: 'claude-code', + }; + } + + // Gemini CLI + if (env.GEMINI_CLI) { + return { + name: 'gemini-cli', + }; + } + + // OpenAI Codex + if (env.CODEX_SANDBOX) { + return { + name: 'codex', + }; + } + + // Cursor Agent (proposed / best-effort; Cursor often sets VSCode env vars too) + if (env.CURSOR_AGENT) { + return { + name: 'cursor', + }; + } + + // Generic "AGENT" marker (unknown implementation) + if (env.AGENT) { + return { name: 'unknown' }; + } + + return undefined; +} + +/** Detect whether Storybook CLI is likely being invoked by an AI agent. */ +export const detectAgent = (options: DetectAgentOptions): AgentDetection => { + const env = options.env; + + // 1) Explicit agent variables (strong signal; allow even in CI/TTY) + const explicit = detectExplicitAgent(env); + if (explicit) { + return explicit; + } + + const stdoutIsTTY = options.stdoutIsTTY; + + // 2) Behavioral / fingerprint heuristics (exclude CI to reduce false positives) + if (stdoutIsTTY) { + return undefined; + } + + const isDumbTerm = env.TERM === 'dumb'; + const hasAgentPager = env.GIT_PAGER === 'cat'; + + if (isDumbTerm || hasAgentPager) { + return { name: 'unknown' }; + } + + return undefined; +}; diff --git a/code/core/src/telemetry/storybook-metadata.ts b/code/core/src/telemetry/storybook-metadata.ts index 689e0ca31fd8..79f14e2e69a7 100644 --- a/code/core/src/telemetry/storybook-metadata.ts +++ b/code/core/src/telemetry/storybook-metadata.ts @@ -41,6 +41,7 @@ export const metaFrameworks = { '@tanstack/react-router': 'tanstack-react', '@react-router/dev': 'react-router', '@remix-run/dev': 'remix', + expo: 'expo', } as Record; export const sanitizeAddonName = (name: string) => { diff --git a/code/core/src/telemetry/telemetry.ts b/code/core/src/telemetry/telemetry.ts index 064596a84a75..b84ae26b47ac 100644 --- a/code/core/src/telemetry/telemetry.ts +++ b/code/core/src/telemetry/telemetry.ts @@ -11,6 +11,7 @@ import { nanoid } from 'nanoid'; import { version } from '../../package.json'; import { resolvePackageDir } from '../shared/utils/module'; import { getAnonymousProjectId } from './anonymous-id'; +import { detectAgent } from './detect-agent'; import { set as saveToCache } from './event-cache'; import { fetch } from './fetch'; import { getSessionId } from './session-id'; @@ -49,9 +50,12 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string} // context info sent with all events, provided // by the app. currently: // - cliVersion +const inCI = isCI(); +const agentDetection = detectAgent({ stdoutIsTTY: process.stdout.isTTY, env: process.env }); const globalContext = { - inCI: isCI(), + inCI, isTTY: process.stdout.isTTY, + agent: agentDetection, platform: getOperatingSystem(), nodeVersion: process.versions.node, storybookVersion: getVersionNumber(), diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 8569b8bacbc4..31a8427d43a6 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -2,6 +2,7 @@ import type { StorybookConfig, TypescriptOptions } from 'storybook/internal/type import type { DetectResult } from 'package-manager-detector'; +import type { AgentInfo } from './detect-agent'; import type { KnownPackagesList } from './get-known-packages'; import type { MonorepoType } from './get-monorepo-type'; @@ -18,6 +19,7 @@ export type EventType = | 'scaffolded-empty' | 'browser' | 'canceled' + | 'exit' | 'error' | 'error-metadata' | 'version-update' @@ -41,6 +43,7 @@ export type EventType = | 'migrate' | 'preview-first-load' | 'doctor' + | 'share' | 'ghost-stories'; export interface Dependency { version: string | undefined; @@ -56,6 +59,8 @@ export type StorybookMetadata = { storybookVersionSpecifier: string; generatedAt?: number; userSince?: number; + /** If we can identify the agent, report it; otherwise `unknown` when detected heuristically. */ + agent?: AgentInfo; language: 'typescript' | 'javascript'; framework?: { name?: string; diff --git a/code/core/src/toolbar/components/ToolbarMenuSelect.tsx b/code/core/src/toolbar/components/ToolbarMenuSelect.tsx index 757712a90120..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, shortcuts }, + toolbar: { + icon: _icon, + items, + title: _title, + preventDynamicIcon, + dynamicTitle = true, + shortcuts, + }, }) => { const api = useStorybookApi(); const [globals, updateGlobals, storyGlobals] = useGlobals(); @@ -132,6 +139,7 @@ export const ToolbarMenuSelect: FC = ({ onReset={resetItem ? () => updateGlobals({ [id]: resetItem?.value }) : undefined} onSelect={(selected) => updateGlobals({ [id]: selected })} icon={icon && } + showSelectedOptionTitle={dynamicTitle} > {title} diff --git a/code/e2e-tests/preview-api.spec.ts b/code/e2e-tests/preview-api.spec.ts index 5022f3e003b4..befdd6bab7a8 100644 --- a/code/e2e-tests/preview-api.spec.ts +++ b/code/e2e-tests/preview-api.spec.ts @@ -68,7 +68,7 @@ test.describe('preview-api', () => { await expect(labelControl).toBeVisible(); await labelControl.fill(''); - await labelControl.type('Changed arg', { delay: 50 }); + await labelControl.pressSequentially('Changed arg', { delay: 50 }); await labelControl.blur(); await expect(root.getByText('Loaded. Changed arg')).toBeVisible(); diff --git a/code/frameworks/angular/build-schema.json b/code/frameworks/angular/build-schema.json index d3963edc7215..aa8da2b6f738 100644 --- a/code/frameworks/angular/build-schema.json +++ b/code/frameworks/angular/build-schema.json @@ -31,7 +31,7 @@ }, "loglevel": { "type": "string", - "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", "pattern": "(trace|debug|info|warn|error|silent)" }, "logfile": { diff --git a/code/frameworks/angular/package.json b/code/frameworks/angular/package.json index 9e4907e68c7d..553d48330840 100644 --- a/code/frameworks/angular/package.json +++ b/code/frameworks/angular/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/angular", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Angular: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/angular/start-schema.json b/code/frameworks/angular/start-schema.json index ac81c01d6ab0..7b67578ac332 100644 --- a/code/frameworks/angular/start-schema.json +++ b/code/frameworks/angular/start-schema.json @@ -147,7 +147,7 @@ }, "loglevel": { "type": "string", - "description": "Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].", + "description": "Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].", "pattern": "(trace|debug|info|warn|error|silent)" }, "logfile": { diff --git a/code/frameworks/ember/package.json b/code/frameworks/ember/package.json index b9b2e2ddee24..cc45300e57aa 100644 --- a/code/frameworks/ember/package.json +++ b/code/frameworks/ember/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/ember", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Ember: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/html-vite/package.json b/code/frameworks/html-vite/package.json index 09715319177b..5c63c844874b 100644 --- a/code/frameworks/html-vite/package.json +++ b/code/frameworks/html-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/html-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for HTML and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/nextjs-vite/package.json b/code/frameworks/nextjs-vite/package.json index e7e1b96b226c..95c8668bee72 100644 --- a/code/frameworks/nextjs-vite/package.json +++ b/code/frameworks/nextjs-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/nextjs-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Next.js and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/nextjs/build-config.ts b/code/frameworks/nextjs/build-config.ts index e7c8f55e43c4..bcc08973d6d9 100644 --- a/code/frameworks/nextjs/build-config.ts +++ b/code/frameworks/nextjs/build-config.ts @@ -28,6 +28,10 @@ const config: BuildEntries = { exportEntries: ['./navigation.mock'], entryPoint: './src/export-mocks/navigation/index.ts', }, + { + exportEntries: ['./link.mock'], + entryPoint: './src/export-mocks/link/index.tsx', + }, { exportEntries: ['./router.mock'], entryPoint: './src/export-mocks/router/index.ts', diff --git a/code/frameworks/nextjs/package.json b/code/frameworks/nextjs/package.json index a46df7f02c95..53824380995a 100644 --- a/code/frameworks/nextjs/package.json +++ b/code/frameworks/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/nextjs", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Next.js: Develop, document, and test UI components in isolation", "keywords": [ "storybook", @@ -44,6 +44,11 @@ "./image-context": "./dist/image-context.js", "./images/next-image": "./dist/images/next-image.js", "./images/next-legacy-image": "./dist/images/next-legacy-image.js", + "./link.mock": { + "types": "./dist/export-mocks/link/index.d.ts", + "code": "./src/export-mocks/link/index.tsx", + "default": "./dist/export-mocks/link/index.js" + }, "./navigation.mock": { "types": "./dist/export-mocks/navigation/index.d.ts", "default": "./dist/export-mocks/navigation/index.js" diff --git a/code/frameworks/nextjs/src/aliases/webpack.ts b/code/frameworks/nextjs/src/aliases/webpack.ts index 879b28e0d483..a9453814b7be 100644 --- a/code/frameworks/nextjs/src/aliases/webpack.ts +++ b/code/frameworks/nextjs/src/aliases/webpack.ts @@ -14,6 +14,10 @@ export const configureAliases = (baseConfig: WebpackConfig): void => { ...(baseConfig.resolve?.alias ?? {}), '@opentelemetry/api': 'next/dist/compiled/@opentelemetry/api', next: resolvePackageDir('next'), + 'next/dist/shared/lib/app-router-context.shared-runtime': + 'next/dist/shared/lib/app-router-context.shared-runtime', + 'next/dist/shared/lib/app-router-context': + 'next/dist/shared/lib/app-router-context.shared-runtime', }, }; diff --git a/code/frameworks/nextjs/src/export-mocks/link/index.tsx b/code/frameworks/nextjs/src/export-mocks/link/index.tsx new file mode 100644 index 000000000000..2237a67f7cc6 --- /dev/null +++ b/code/frameworks/nextjs/src/export-mocks/link/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { fn } from 'storybook/test'; + +const linkAction = fn().mockName('next/link::Link'); + +const MockLink = React.forwardRef(function MockLink( + { + href, + as: _as, + replace, + scroll, + shallow, + prefetch, + passHref, + legacyBehavior, + locale, + onClick, + children, + ...rest + }, + ref +) { + const hrefString = + typeof href === 'object' + ? `${href.pathname || ''}${href.query ? '?' + new URLSearchParams(href.query).toString() : ''}${href.hash || ''}` + : href; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + onClick?.(e); + linkAction(hrefString, { replace, scroll, shallow, prefetch, locale }); + }; + + return ( + + {children} + + ); +}); + +MockLink.displayName = 'NextLink'; + +export default MockLink; +export { MockLink as Link }; diff --git a/code/frameworks/nextjs/src/export-mocks/webpack.ts b/code/frameworks/nextjs/src/export-mocks/webpack.ts index b5a813535737..9daaecee3a76 100644 --- a/code/frameworks/nextjs/src/export-mocks/webpack.ts +++ b/code/frameworks/nextjs/src/export-mocks/webpack.ts @@ -7,6 +7,7 @@ const mapping = { 'next/navigation': '@storybook/nextjs/navigation.mock', 'next/router': '@storybook/nextjs/router.mock', 'next/cache': '@storybook/nextjs/cache.mock', + 'next/link': '@storybook/nextjs/link.mock', ...getCompatibilityAliases(), }; diff --git a/code/frameworks/preact-vite/package.json b/code/frameworks/preact-vite/package.json index b65e269808ff..2a21537dfd56 100644 --- a/code/frameworks/preact-vite/package.json +++ b/code/frameworks/preact-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/preact-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Preact and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/react-native-web-vite/package.json b/code/frameworks/react-native-web-vite/package.json index 7f6c726a79d9..d57aa70e1263 100644 --- a/code/frameworks/react-native-web-vite/package.json +++ b/code/frameworks/react-native-web-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-native-web-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for React Native Web and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/react-vite/package.json b/code/frameworks/react-vite/package.json index 1718e5fd7283..6e031cb10076 100644 --- a/code/frameworks/react-vite/package.json +++ b/code/frameworks/react-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for React and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", @@ -49,7 +49,7 @@ "!src/**/*" ], "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "workspace:*", "@storybook/react": "workspace:*", diff --git a/code/frameworks/react-webpack5/package.json b/code/frameworks/react-webpack5/package.json index ddc03f0e3fed..47f2e524e611 100644 --- a/code/frameworks/react-webpack5/package.json +++ b/code/frameworks/react-webpack5/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-webpack5", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for React and Webpack: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/server-webpack5/package.json b/code/frameworks/server-webpack5/package.json index cf4bfd641b26..e87b24786131 100644 --- a/code/frameworks/server-webpack5/package.json +++ b/code/frameworks/server-webpack5/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/server-webpack5", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Server: View HTML snippets from a server in isolation with Hot Reloading.", "keywords": [ "storybook", diff --git a/code/frameworks/svelte-vite/package.json b/code/frameworks/svelte-vite/package.json index fed7ea380e74..d6aaa6b49d20 100644 --- a/code/frameworks/svelte-vite/package.json +++ b/code/frameworks/svelte-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/svelte-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Svelte and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/sveltekit/package.json b/code/frameworks/sveltekit/package.json index 6cbf8a7393db..241ec268c50e 100644 --- a/code/frameworks/sveltekit/package.json +++ b/code/frameworks/sveltekit/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/sveltekit", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for SvelteKit: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/vue3-vite/package.json b/code/frameworks/vue3-vite/package.json index a930c11d906f..17664e1d0116 100644 --- a/code/frameworks/vue3-vite/package.json +++ b/code/frameworks/vue3-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/vue3-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Vue3 and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/frameworks/web-components-vite/package.json b/code/frameworks/web-components-vite/package.json index db8975a8ef7b..6d17d653bdc1 100644 --- a/code/frameworks/web-components-vite/package.json +++ b/code/frameworks/web-components-vite/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/web-components-vite", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Web Components and Vite: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/lib/cli-sb/package.json b/code/lib/cli-sb/package.json index ea29e5c21cc2..ee7c352d35cf 100644 --- a/code/lib/cli-sb/package.json +++ b/code/lib/cli-sb/package.json @@ -1,6 +1,6 @@ { "name": "sb", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook CLI: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/lib/cli-storybook/package.json b/code/lib/cli-storybook/package.json index 69e8abcb9f49..f6b4b175c1df 100644 --- a/code/lib/cli-storybook/package.json +++ b/code/lib/cli-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/cli", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook CLI: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/lib/cli-storybook/src/automigrate/index.ts b/code/lib/cli-storybook/src/automigrate/index.ts index 040f427ba5ec..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, @@ -122,6 +119,7 @@ export const automigrate = async ({ isLatest, storiesPaths, hasCsfFactoryPreview, + glob, }: AutofixOptions): Promise<{ fixResults: Record; preCheckFailure?: PreCheckFailure; @@ -146,6 +144,8 @@ export const automigrate = async ({ result: null, storybookVersion, storiesPaths, + yes, + glob, }); return null; @@ -380,6 +380,7 @@ export async function runFixes({ skipInstall, storybookVersion, storiesPaths, + yes, }); logger.log(`βœ… ran ${picocolors.cyan(f.id)} migration`); diff --git a/code/lib/cli-storybook/src/automigrate/multi-project.ts b/code/lib/cli-storybook/src/automigrate/multi-project.ts index 59543089404c..7c331348c63d 100644 --- a/code/lib/cli-storybook/src/automigrate/multi-project.ts +++ b/code/lib/cli-storybook/src/automigrate/multi-project.ts @@ -264,7 +264,7 @@ export async function runAutomigrationsForProjects( selectedAutomigrations: AutomigrationCheckResult[], options: MultiProjectRunAutomigrationOptions ): Promise> { - const { dryRun, skipInstall, automigrations } = options; + const { dryRun, skipInstall, automigrations, yes } = options; const projectResults: Record = {}; const applicableAutomigrations = selectedAutomigrations.filter((am) => @@ -378,6 +378,7 @@ export async function runAutomigrationsForProjects( skipInstall, storybookVersion: project.storybookVersion, storiesPaths: project.storiesPaths, + yes, }; await fix.run(runOptions); diff --git a/code/lib/cli-storybook/src/automigrate/types.ts b/code/lib/cli-storybook/src/automigrate/types.ts index 1856e53c4211..28e4dcc1d183 100644 --- a/code/lib/cli-storybook/src/automigrate/types.ts +++ b/code/lib/cli-storybook/src/automigrate/types.ts @@ -24,6 +24,10 @@ export interface RunOptions { skipInstall?: boolean; storybookVersion: string; storiesPaths: string[]; + /** Skip prompts and use defaults (from --yes flag) */ + yes?: boolean; + /** Glob pattern for story files (for csf-factories codemod) */ + glob?: string; } /** @@ -97,6 +101,8 @@ export interface AutofixOptionsFromCLI { skipInstall?: boolean; hideMigrationSummary?: boolean; skipDoctor?: boolean; + /** Glob pattern for story files (for csf-factories codemod) */ + glob?: string; } export enum FixStatus { diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index cab65aa2eedf..031c540a27c6 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -39,7 +39,10 @@ const handleCommandFailure = try { const logFile = await logTracker.writeToFile(logFilePath); logger.log(`Debug logs are written to: ${logFile}`); - } catch {} + } catch (e) { + logger.error('Error writing debug logs to file'); + logger.error(String(e)); + } logger.outro(''); process.exit(1); }; @@ -247,7 +250,7 @@ command('sandbox [filterValue]') .action((filterValue, options) => { logger.intro(`Creating a Storybook sandbox...`); sandbox({ filterValue, ...options }) - .catch(handleCommandFailure) + .catch(handleCommandFailure(options.logfile)) .finally(() => { logger.outro('Done!'); }); @@ -278,6 +281,7 @@ command('automigrate [fixId]') 'The renderer package for the framework Storybook is using.' ) .option('--skip-doctor', 'Skip doctor check') + .option('--glob ', 'Glob pattern for story files (for csf-factories codemod)') .action(async (fixId, options) => { withTelemetry('automigrate', { cliOptions: options }, async () => { logger.intro(fixId ? `Running ${fixId} automigration` : 'Running automigrations'); diff --git a/code/lib/cli-storybook/src/codemod/csf-factories.ts b/code/lib/cli-storybook/src/codemod/csf-factories.ts index 35e0e1a4ecea..0aad19f84efa 100644 --- a/code/lib/cli-storybook/src/codemod/csf-factories.ts +++ b/code/lib/cli-storybook/src/codemod/csf-factories.ts @@ -19,15 +19,22 @@ async function runStoriesCodemod(options: { packageManager: JsPackageManager; useSubPathImports: boolean; previewConfigPath: string; + yes: boolean | undefined; + glob: string | undefined; }) { - const { dryRun, packageManager, ...codemodOptions } = options; + const { dryRun, packageManager, yes, glob, ...codemodOptions } = options; try { - let globString = '{stories,src}/**/{Button,Header,Page,button,header,page}.stories.*'; - if (!optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX)) { + const inSandbox = optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) ?? false; + let globString = glob ?? '**/*.{stories,story}.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx}'; + + if (!glob && inSandbox) { + // Sandbox uses limited glob for faster testing (unless glob explicitly provided) + globString = '{stories,src}/**/{Button,Header,Page,button,header,page}.stories.*'; + } else if (!glob && !yes) { logger.log('Please enter the glob for your stories to migrate'); globString = await prompt.text({ message: 'glob', - initialValue: '**/*.{stories,story}.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx}', + initialValue: globString, }); } @@ -52,10 +59,21 @@ async function runStoriesCodemod(options: { export const csfFactories: CommandFix = { id: 'csf-factories', promptType: 'command', - async run({ dryRun, mainConfig, mainConfigPath, previewConfigPath, packageManager, configDir }) { - let useSubPathImports = true; - - if (!optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX)) { + async run({ + dryRun, + mainConfig, + mainConfigPath, + previewConfigPath, + packageManager, + configDir, + yes, + glob, + }) { + const inSandbox = optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) ?? false; + // Defaults to false for users and true in sandbox + let useSubPathImports = inSandbox; + + if (!yes && !inSandbox) { // prompt whether the user wants to use imports map logger.logBox(dedent` The CSF Factories format can benefit from using absolute imports of your ${picocolors.cyan(previewConfigPath)} file. We can configure that for you, using subpath imports (a node standard), by adjusting the imports property of your package.json. @@ -96,6 +114,8 @@ export const csfFactories: CommandFix = { packageManager, useSubPathImports, previewConfigPath: previewConfigPath!, + yes, + glob, }); logger.step('Applying codemod on your main config...'); diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts index 48aa77ccff9b..8c22d4fbba96 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.test.ts @@ -58,6 +58,28 @@ describe('main/preview codemod: general parsing functionality', () => { }); `); }); + + it('should preserve leading comments when adding import', async () => { + await expect( + transform(dedent` + // @ts-check + /** @license MIT */ + export default { + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + framework: '@storybook/react-vite', + }; + `) + ).resolves.toMatchInlineSnapshot(` + // @ts-check + /** @license MIT */ + import { defineMain } from '@storybook/react-vite/node'; + + export default defineMain({ + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + framework: '@storybook/react-vite', + }); + `); + }); it('should wrap defineMain call from const declared default export with different type annotations', async () => { const typedVariants = [ 'export default config;', @@ -325,7 +347,7 @@ describe('preview specific functionality', () => { import { type Preview } from '@storybook/react-vite'; export const decorators = [] const preview = { - + parameters: { options: {} } @@ -346,4 +368,42 @@ describe('preview specific functionality', () => { }); `); }); + + it('should add default export when preview only has side-effect imports', async () => { + await expect( + transform(dedent` + import './preview.scss' + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react-vite'; + + import './preview.scss'; + + export default definePreview({}); + `); + }); + + it('should add default export when preview file is empty', async () => { + await expect(transform('')).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react-vite'; + + export default definePreview({}); + `); + }); + + it('should add default export when preview only has multiple side-effect imports', async () => { + await expect( + transform(dedent` + import './preview.scss' + import './global.css' + `) + ).resolves.toMatchInlineSnapshot(` + import { definePreview } from '@storybook/react-vite'; + + import './global.css'; + import './preview.scss'; + + export default definePreview({}); + `); + }); }); diff --git a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts index 42b6c1787e51..c289c247ff51 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory.ts @@ -7,6 +7,7 @@ import picocolors from 'picocolors'; import type { FileInfo } from '../../automigrate/codemod'; import { + addImportToTop, cleanupTypeImports, getConfigProperties, removeExportDeclarations, @@ -165,6 +166,21 @@ export async function configToCsfFactory( // Add the new export default declaration programNode.body.push(t.exportDefaultDeclaration(defineConfigCall)); + } else if (configType === 'preview') { + /** + * Scenario 4: No exports (empty file or only side-effect imports) + * + * ``` + * import './preview.scss'; + * ``` + * + * Transform into: `import './preview.scss'; export default definePreview({})` + * + * This is needed because story files using CSF factories import from preview, so the preview + * file must have a default export. + */ + const defineConfigCall = t.callExpression(t.identifier(methodName), [t.objectExpression([])]); + programNode.body.push(t.exportDefaultDeclaration(defineConfigCall)); } const configImport = t.importDeclaration( @@ -197,7 +213,7 @@ export async function configToCsfFactory( } } else { // if not, add import { defineMain } from '@storybook/framework' - programNode.body.unshift(configImport); + addImportToTop(programNode, configImport); } // Remove type imports – now inferred – from @storybook/* packages diff --git a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts index 0b23e670d09e..46864f464091 100644 --- a/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts +++ b/code/lib/cli-storybook/src/codemod/helpers/csf-factories-utils.ts @@ -119,3 +119,29 @@ export function getConfigProperties( // error TS4058: Return type of exported function has or is using name 'ObjectProperty' from external module "/tmp/storybook/code/core/dist/babel/index" but cannot be named. return properties as any; } + +/** + * Adds an import declaration to the beginning of the program while preserving any leading comments + * (like license headers or @ts-check directives). + * + * When using `programNode.body.unshift()`, the import would be placed before any leading comments + * attached to the first node. This function transfers those comments to the new import so they + * remain at the top of the file. + * + * Note: We use the `comments` property (used by recast for printing) rather than `leadingComments` + * (used by babel internally) to ensure proper output formatting. + */ +export function addImportToTop(programNode: t.Program, importDecl: t.ImportDeclaration): void { + const firstNode = programNode.body[0] as t.Node & { comments?: t.Comment[] }; + + if (firstNode && firstNode.leadingComments && firstNode.leadingComments.length > 0) { + // Transfer leading comments from the first node to the import using 'comments' property + // which is what recast uses for printing (not 'leadingComments') + (importDecl as t.Node & { comments?: t.Comment[] }).comments = firstNode.leadingComments; + // Clear comments from the original first node to avoid duplication + firstNode.leadingComments = []; + firstNode.comments = []; + } + + programNode.body.unshift(importDecl); +} 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 814d69522eb6..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 @@ -45,6 +45,28 @@ describe('stories codemod', () => { `); }); + it('should preserve leading comments when adding import', async () => { + await expect( + transform(dedent` + // @ts-check + /** + * @license MIT + * Copyright 2024 + */ + const meta = { title: 'Component' }; + export default meta; + export const A = {}; + `) + ).resolves.toMatchInlineSnapshot(` + // @ts-check + /** @license MIT Copyright 2024 */ + import preview from '#.storybook/preview'; + + const meta = preview.meta({ title: 'Component' }); + export const A = meta.story(); + `); + }); + it('should transform and wrap inline default exported meta', async () => { await expect( transform(dedent` @@ -275,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 07d9f27ab195..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 @@ -5,7 +5,7 @@ import { logger } from 'storybook/internal/node-logger'; import path from 'path'; import type { FileInfo } from '../../automigrate/codemod'; -import { cleanupTypeImports } from './csf-factories-utils'; +import { addImportToTop, cleanupTypeImports } from './csf-factories-utils'; import { removeUnusedTypes } from './remove-unused-types'; const typesDisallowList = [ @@ -79,7 +79,54 @@ export async function storyToCsfFactory( const sbConfigImportSpecifier = t.importDefaultSpecifier(t.identifier(sbConfigImportName)); + /** + * 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); + } + }); + } + } + if (t.isImportDeclaration(node) && isValidPreviewPath(node.source.value)) { const defaultImportSpecifier = node.specifiers.find((specifier) => t.isImportDefaultSpecifier(specifier) @@ -97,6 +144,9 @@ export async function storyToCsfFactory( const hasMeta = !!csf._meta; + // 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 +230,13 @@ 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 +283,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 +293,136 @@ 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. @@ -324,7 +510,7 @@ export async function storyToCsfFactory( [t.importDefaultSpecifier(t.identifier(sbConfigImportName))], t.stringLiteral(previewPath) ); - programNode.body.unshift(configImport); + addImportToTop(programNode, configImport); } removeUnusedTypes(programNode, csf._ast); diff --git a/code/lib/cli-storybook/src/sandbox.ts b/code/lib/cli-storybook/src/sandbox.ts index 5bfcc854048f..232f67fb728f 100644 --- a/code/lib/cli-storybook/src/sandbox.ts +++ b/code/lib/cli-storybook/src/sandbox.ts @@ -47,8 +47,12 @@ export const sandbox = async ({ const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr, }); - const latestVersion = (await packageManager.latestVersion('storybook'))!; + const latestVersion = (await packageManager.latestVersion('storybook')) ?? '0.0.0'; const nextVersion = (await packageManager.latestVersion('storybook@next')) ?? '0.0.0'; + + logger.debug(`latestVersion: ${latestVersion}`); + logger.debug(`nextVersion: ${nextVersion}`); + const currentVersion = versions.storybook; const isPrerelease = prerelease(currentVersion); const isOutdated = lt(currentVersion, isPrerelease ? nextVersion : latestVersion); @@ -56,6 +60,11 @@ export const sandbox = async ({ const downloadType = !isOutdated && init ? 'after-storybook' : 'before-storybook'; const branch = isPrerelease ? 'next' : 'main'; + logger.debug(`isPrerelease: ${isPrerelease}`); + logger.debug(`isOutdated: ${isOutdated}`); + logger.debug(`downloadType: ${downloadType}`); + logger.debug(`branch: ${branch}`); + const messages = { welcome: `Creating a Storybook ${picocolors.bold(currentVersion)} sandbox..`, notLatest: picocolors.red(dedent` @@ -71,16 +80,18 @@ export const sandbox = async ({ prerelease: picocolors.yellow('This is a pre-release version.'), }; - logger.logBox( - [messages.welcome] - .concat(isOutdated && !isPrerelease ? [messages.notLatest] : []) - .concat(init && (isOutdated || isPrerelease) ? [messages.longInitTime] : []) - .concat(isPrerelease ? [messages.prerelease] : []) - .join('\n'), - { - rounded: true, - } - ); + try { + logger.logBox( + [messages.welcome] + .concat(isOutdated && !isPrerelease ? [messages.notLatest] : []) + .concat(init && (isOutdated || isPrerelease) ? [messages.longInitTime] : []) + .concat(isPrerelease ? [messages.prerelease] : []) + .join('\n'), + { + rounded: true, + } + ); + } catch {} if (!selectedConfig) { const filterRegex = new RegExp(`^${filterValue || ''}`, 'i'); diff --git a/code/lib/codemod/package.json b/code/lib/codemod/package.json index 43266541115d..a6c5fa32931b 100644 --- a/code/lib/codemod/package.json +++ b/code/lib/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/codemod", - "version": "10.2.0", + "version": "10.2.9", "description": "A collection of codemod scripts written with JSCodeshift", "keywords": [ "storybook" diff --git a/code/lib/codemod/src/index.test.ts b/code/lib/codemod/src/index.test.ts new file mode 100644 index 000000000000..482bc947b963 --- /dev/null +++ b/code/lib/codemod/src/index.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { runCodemod } from './index'; + +const logger = { + step: vi.fn(), + log: vi.fn(), +}; + +const csf2Source = `import { Meta, Story } from '@storybook/react-vite' + +import { Chart, ChartProps } from './chart' + +export default { + component: Chart, + title: 'Chart', +} as Meta + +const Template: Story = (args) => + +export const SimpleBar = Template.bind({}) +SimpleBar.args = { name: 'test' } +`; + +describe('runCodemod', () => { + const tempDir = join(tmpdir(), 'storybook-codemod-test'); + + beforeEach(() => { + mkdirSync(tempDir, { recursive: true }); + vi.clearAllMocks(); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * https://github.com/storybookjs/storybook/issues/33639 + * + * Reproduces the bug where csf-2-to-3 silently fails because filenames are incorrectly quoted + * when passed to spawnSync. + * + * Test file from: + * https://github.com/seb-oss/green/blob/b634df24d2dae157300f73b711e569d1755eb138/libs/react-charts/src/lib/chart.stories.tsx + */ + it('should transform CSF2 Template.bind({}) files with csf-2-to-3', async () => { + const storyFile = join(tempDir, 'chart.stories.tsx'); + writeFileSync(storyFile, csf2Source); + + await runCodemod('csf-2-to-3', { + glob: storyFile, + logger, + }); + + const result = readFileSync(storyFile, 'utf-8'); + + // csf-2-to-3 should transform Template.bind({}) to CSF3 object + expect(result).not.toContain('Template.bind({})'); + expect(result).toContain('export const SimpleBar = {'); + }); + + it('should handle filenames with spaces', async () => { + const storyFile = join(tempDir, 'my component.stories.tsx'); + writeFileSync(storyFile, csf2Source); + + await runCodemod('csf-2-to-3', { + glob: storyFile, + logger, + }); + + const result = readFileSync(storyFile, 'utf-8'); + + expect(result).not.toContain('Template.bind({})'); + expect(result).toContain('export const SimpleBar = {'); + }); + + it('should handle glob patterns', async () => { + const storyFile1 = join(tempDir, 'chart.stories.tsx'); + const storyFile2 = join(tempDir, 'button.stories.tsx'); + writeFileSync(storyFile1, csf2Source); + writeFileSync(storyFile2, csf2Source); + + await runCodemod('csf-2-to-3', { + glob: join(tempDir, '*.stories.tsx'), + logger, + }); + + const result1 = readFileSync(storyFile1, 'utf-8'); + const result2 = readFileSync(storyFile2, 'utf-8'); + + expect(result1).not.toContain('Template.bind({})'); + expect(result2).not.toContain('Template.bind({})'); + }); + + it('should handle recursive glob patterns', async () => { + const nestedDir = join(tempDir, 'components', 'charts'); + mkdirSync(nestedDir, { recursive: true }); + + const storyFile1 = join(tempDir, 'root.stories.tsx'); + const storyFile2 = join(nestedDir, 'nested.stories.tsx'); + writeFileSync(storyFile1, csf2Source); + writeFileSync(storyFile2, csf2Source); + + await runCodemod('csf-2-to-3', { + glob: join(tempDir, '**/*.stories.tsx'), + logger, + }); + + const result1 = readFileSync(storyFile1, 'utf-8'); + const result2 = readFileSync(storyFile2, 'utf-8'); + + expect(result1).not.toContain('Template.bind({})'); + expect(result2).not.toContain('Template.bind({})'); + }); +}); diff --git a/code/lib/codemod/src/index.ts b/code/lib/codemod/src/index.ts index 0455f7aeb5f7..22e453c26e75 100644 --- a/code/lib/codemod/src/index.ts +++ b/code/lib/codemod/src/index.ts @@ -6,6 +6,7 @@ import { extname, join } from 'node:path'; import { resolvePackageDir } from 'storybook/internal/common'; import { sync as spawnSync } from 'cross-spawn'; +import { normalize } from 'pathe'; import { glob as tinyglobby } from 'tinyglobby'; import { jscodeshiftToPrettierParser } from './lib/utils'; @@ -60,7 +61,8 @@ export async function runCodemod( } } - const files = await tinyglobby([glob, '!**/node_modules', '!**/dist']); + // Normalize the glob pattern to use forward slashes (required for glob patterns on Windows) + const files = await tinyglobby([normalize(glob), '!**/node_modules', '!**/dist']); const extensions = new Set(files.map((file) => extname(file).slice(1))); const commaSeparatedExtensions = Array.from(extensions).join(','); @@ -86,7 +88,7 @@ export async function runCodemod( '-t', `${TRANSFORM_DIR}/${codemod}.js`, ...parserArgs, - ...files.map((file) => `"${file}"`), + ...files, ], { stdio: 'inherit', diff --git a/code/lib/core-webpack/package.json b/code/lib/core-webpack/package.json index 861884940aa2..2cb1c07bbdb3 100644 --- a/code/lib/core-webpack/package.json +++ b/code/lib/core-webpack/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/core-webpack", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook framework-agnostic API", "keywords": [ "storybook" diff --git a/code/lib/create-storybook/package.json b/code/lib/create-storybook/package.json index 683366ab449f..d991c5eb489e 100644 --- a/code/lib/create-storybook/package.json +++ b/code/lib/create-storybook/package.json @@ -1,6 +1,6 @@ { "name": "create-storybook", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook installer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts index f852a85fa2e7..2df6ed0ca21a 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts @@ -1,164 +1,346 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { type JsPackageManager } from 'storybook/internal/common'; +import type { AddonVitestService } from 'storybook/internal/cli'; +import { type JsPackageManager, PackageManagerName } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; -import { TelemetryService } from '../services/TelemetryService'; -import { VersionService } from '../services/VersionService'; -import { AddonConfigurationCommand } from './AddonConfigurationCommand'; +import addonA11yPostinstall from '../../../../addons/a11y/src/postinstall'; +import addonVitestPostinstall from '../../../../addons/vitest/src/postinstall'; +import type { TelemetryService } from '../services'; +import { AddonConfigurationCommand, executeAddonConfiguration } from './AddonConfigurationCommand'; -vi.mock('storybook/internal/cli', { spy: true }); vi.mock('storybook/internal/node-logger', { spy: true }); -vi.mock('../../../cli-storybook/src/postinstallAddon', { spy: true }); -vi.mock('../services/TelemetryService', { spy: true }); -vi.mock('../services/VersionService', { spy: true }); +vi.mock('storybook/internal/telemetry', { spy: true }); +vi.mock('../../../../addons/a11y/src/postinstall', { spy: true }); +vi.mock('../../../../addons/vitest/src/postinstall', { spy: true }); +vi.mock('../../../cli-storybook/src/postinstallAddon', () => ({ + postinstallAddon: vi.fn().mockResolvedValue(undefined), +})); describe('AddonConfigurationCommand', () => { let command: AddonConfigurationCommand; - const mockPackageManager = { - type: 'npm', - getVersionedPackages: vi.fn(), - executeCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }), - } as Partial as JsPackageManager; - let mockTask: { - success: ReturnType; - error: ReturnType; - message: ReturnType; - group: ReturnType; - }; - let mockPostinstallAddon: ReturnType; - let mockAddonVitestService: ReturnType; - - beforeEach(async () => { - const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); - mockPostinstallAddon = vi.mocked(postinstallAddon); - mockPostinstallAddon.mockResolvedValue(undefined); - - // Mock the AddonVitestService - const { AddonVitestService } = await import('storybook/internal/cli'); - mockAddonVitestService = vi.mocked(AddonVitestService); - const mockInstance = { - installPlaywright: vi.fn().mockResolvedValue({ errors: [] }), - }; - mockAddonVitestService.mockImplementation(() => mockInstance as any); - - vi.mocked(VersionService).mockImplementation(() => ({})); - - vi.mocked(TelemetryService).mockImplementation((disableTelemetry: boolean = false) => { - return { - disableTelemetry, - versionService: new VersionService(), - }; - }); - - const mockAddonVitestServiceInstance = { - installPlaywright: vi.fn().mockResolvedValue({ errors: [] }), - }; - const mockTelemetryServiceInstance = { - trackPlaywrightPromptDecision: vi.fn(), - }; + let mockPackageManager: JsPackageManager; + let mockAddonVitestService: AddonVitestService; + let mockTelemetryService: TelemetryService; + let mockTaskLog: ReturnType; - command = new AddonConfigurationCommand( - mockPackageManager, - { - yes: true, - disableTelemetry: true, - } as any, - mockAddonVitestServiceInstance as any, - mockTelemetryServiceInstance as any - ); + beforeEach(() => { + mockPackageManager = { + type: 'npm', + } as Partial as JsPackageManager; - mockTask = { + mockAddonVitestService = { + installPlaywright: vi.fn().mockResolvedValue({ errors: [], result: 'installed' }), + } as Partial as AddonVitestService; + + mockTelemetryService = { + trackPlaywrightPromptDecision: vi.fn().mockResolvedValue(undefined), + } as Partial as TelemetryService; + + mockTaskLog = { + message: vi.fn(), success: vi.fn(), error: vi.fn(), - message: vi.fn(), - group: vi.fn(), - }; + } as unknown as ReturnType; - vi.mocked(prompt.taskLog).mockReturnValue(mockTask as any); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.error).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(ErrorCollector.addError).mockImplementation(() => {}); + vi.mocked(addonA11yPostinstall).mockResolvedValue(undefined); + vi.mocked(addonVitestPostinstall).mockResolvedValue(undefined); + + command = new AddonConfigurationCommand( + mockPackageManager, + { packageManager: PackageManagerName.NPM, yes: false, disableTelemetry: false }, + mockAddonVitestService, + mockTelemetryService + ); vi.clearAllMocks(); }); describe('execute', () => { - it('should skip configuration when no addons are provided', async () => { - const addons: string[] = []; + it('should return success when no configDir is provided', async () => { + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: undefined, + }); + + expect(result).toEqual({ status: 'success' }); + expect(prompt.taskLog).not.toHaveBeenCalled(); + }); + it('should return success when addons array is empty', async () => { const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: [], configDir: '.storybook', }); - expect(result.status).toBe('success'); + expect(result).toEqual({ status: 'success' }); expect(prompt.taskLog).not.toHaveBeenCalled(); - expect(mockPackageManager.getVersionedPackages).not.toHaveBeenCalled(); }); - it('should configure test addons when test feature is enabled', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + it('should configure vitest addon successfully', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-vitest'], configDir: '.storybook', }); - expect(result.status).toBe('success'); - expect(prompt.taskLog).toHaveBeenCalledWith({ - id: 'configure-addons', - title: 'Configuring addons...', + expect(result).toEqual({ status: 'success' }); + expect(addonVitestPostinstall).toHaveBeenCalledWith({ + packageManager: 'npm', + configDir: '.storybook', + yes: false, + skipInstall: true, + skipDependencyManagement: true, + logger, + prompt, }); + expect(mockAddonVitestService.installPlaywright).toHaveBeenCalledWith({ + yes: false, + useRemotePkg: false, + }); + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('installed'); + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); }); - it('should handle configuration errors gracefully', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; - const error = new Error('Configuration failed'); - - mockPostinstallAddon.mockRejectedValue(error); + it('should configure a11y addon successfully', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-a11y'], configDir: '.storybook', }); - expect(result.status).toBe('failed'); - expect(mockTask.error).toHaveBeenCalledWith( - expect.stringContaining('Failed to configure addons') - ); + expect(result).toEqual({ status: 'success' }); + expect(addonA11yPostinstall).toHaveBeenCalledWith({ + packageManager: 'npm', + configDir: '.storybook', + yes: false, + skipInstall: true, + skipDependencyManagement: true, + logger, + prompt, + }); + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); }); - it('should complete successfully with valid configuration', async () => { - const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + it('should configure generic addon via postinstallAddon', async () => { + const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); const result = await command.execute({ - dependencyInstallationResult: { status: 'success' }, - addons, + addons: ['@storybook/addon-docs'], configDir: '.storybook', }); - expect(result.status).toBe('success'); - expect(mockPostinstallAddon).toHaveBeenCalledTimes(2); - expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-a11y', { + expect(result).toEqual({ status: 'success' }); + expect(postinstallAddon).toHaveBeenCalledWith('@storybook/addon-docs', { packageManager: 'npm', configDir: '.storybook', - yes: true, + yes: false, skipInstall: true, skipDependencyManagement: true, - logger: expect.any(Object), - prompt: expect.any(Object), + logger, + prompt, }); - expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-vitest', { - packageManager: 'npm', + expect(mockTaskLog.success).toHaveBeenCalledWith('Addons configured successfully'); + }); + + it('should configure multiple addons', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest', '@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + expect(addonVitestPostinstall).toHaveBeenCalled(); + expect(addonA11yPostinstall).toHaveBeenCalled(); + expect(mockTaskLog.message).toHaveBeenCalledWith('Configuring @storybook/addon-vitest...'); + expect(mockTaskLog.message).toHaveBeenCalledWith('Configuring @storybook/addon-a11y...'); + }); + + it('should handle addon configuration failure gracefully', async () => { + const error = new Error('Configuration failed'); + vi.mocked(addonVitestPostinstall).mockRejectedValue(error); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(logger.debug).toHaveBeenCalledWith(error); + expect(ErrorCollector.addError).toHaveBeenCalledWith(error); + expect(mockTaskLog.error).toHaveBeenCalledWith('Failed to configure addons'); + }); + + it('should handle partial addon failures', async () => { + const error = new Error('Vitest configuration failed'); + vi.mocked(addonVitestPostinstall).mockRejectedValue(error); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest', '@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(addonA11yPostinstall).toHaveBeenCalled(); + expect(mockTaskLog.error).toHaveBeenCalledWith('Failed to configure addons'); + }); + + it('should handle unexpected errors during execution', async () => { + const unexpectedError = new Error('Unexpected error'); + vi.mocked(prompt.taskLog).mockImplementation(() => { + throw unexpectedError; + }); + + const result = await command.execute({ + addons: ['@storybook/addon-vitest'], configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'failed' }); + expect(logger.error).toHaveBeenCalledWith('Unexpected error during addon configuration:'); + expect(logger.error).toHaveBeenCalledWith(unexpectedError); + }); + + it('should not install Playwright when vitest addon is not configured', async () => { + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(mockAddonVitestService.installPlaywright).not.toHaveBeenCalled(); + expect(mockTelemetryService.trackPlaywrightPromptDecision).not.toHaveBeenCalled(); + }); + + it('should track skipped Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: [], + result: 'skipped', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('skipped'); + }); + + it('should track aborted Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: [], + result: 'aborted', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('aborted'); + }); + + it('should track failed Playwright installation', async () => { + vi.mocked(mockAddonVitestService.installPlaywright).mockResolvedValue({ + errors: ['Installation error'], + result: 'failed', + }); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await command.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(mockTelemetryService.trackPlaywrightPromptDecision).toHaveBeenCalledWith('failed'); + }); + + it('should pass yes option to addon postinstall functions', async () => { + const commandWithYes = new AddonConfigurationCommand( + mockPackageManager, + { packageManager: PackageManagerName.NPM, yes: true, disableTelemetry: false }, + mockAddonVitestService, + mockTelemetryService + ); + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + + await commandWithYes.execute({ + addons: ['@storybook/addon-vitest'], + configDir: '.storybook', + }); + + expect(addonVitestPostinstall).toHaveBeenCalledWith(expect.objectContaining({ yes: true })); + expect(mockAddonVitestService.installPlaywright).toHaveBeenCalledWith({ yes: true, - skipInstall: true, - skipDependencyManagement: true, - logger: expect.any(Object), - prompt: expect.any(Object), + useRemotePkg: false, }); }); }); }); + +describe('executeAddonConfiguration', () => { + let mockPackageManager: JsPackageManager; + let mockTaskLog: ReturnType; + + beforeEach(() => { + mockPackageManager = { + type: 'npm', + } as Partial as JsPackageManager; + + mockTaskLog = { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType; + + vi.mocked(prompt.taskLog).mockReturnValue(mockTaskLog); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.error).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(addonA11yPostinstall).mockResolvedValue(undefined); + vi.mocked(addonVitestPostinstall).mockResolvedValue(undefined); + + vi.clearAllMocks(); + }); + + it('should create command and execute with provided parameters', async () => { + const result = await executeAddonConfiguration({ + packageManager: mockPackageManager, + options: { packageManager: PackageManagerName.NPM, yes: false, disableTelemetry: true }, + addons: [], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + }); + + it('should execute addon configuration through helper function', async () => { + const result = await executeAddonConfiguration({ + packageManager: mockPackageManager, + options: { packageManager: PackageManagerName.NPM, yes: true, disableTelemetry: false }, + addons: ['@storybook/addon-a11y'], + configDir: '.storybook', + }); + + expect(result).toEqual({ status: 'success' }); + expect(addonA11yPostinstall).toHaveBeenCalled(); + }); +}); diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts index 222710d0f186..017fdb166150 100644 --- a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -5,6 +5,8 @@ import { ErrorCollector } from 'storybook/internal/telemetry'; import { dedent } from 'ts-dedent'; +import addonA11yPostinstall from '../../../../addons/a11y/src/postinstall'; +import addonVitestPostinstall from '../../../../addons/vitest/src/postinstall'; import type { CommandOptions } from '../generators/types'; import { TelemetryService } from '../services'; @@ -16,7 +18,6 @@ const ADDON_INSTALLATION_INSTRUCTIONS = { type ExecuteAddonConfigurationParams = { addons: string[]; configDir?: string; - dependencyInstallationResult: { status: 'success' | 'failed' }; }; export type ExecuteAddonConfigurationResult = { @@ -44,16 +45,7 @@ export class AddonConfigurationCommand { async execute({ addons, configDir, - dependencyInstallationResult, }: ExecuteAddonConfigurationParams): Promise { - const areDependenciesInstalled = - dependencyInstallationResult.status === 'success' && !this.commandOptions.skipInstall; - - if (!areDependenciesInstalled && this.getAddonsWithInstructions(addons).length > 0) { - this.logManualAddonInstructions(addons); - return { status: 'failed' }; - } - if (!configDir || addons.length === 0) { return { status: 'success' }; } @@ -64,11 +56,19 @@ export class AddonConfigurationCommand { if (addonResults.has('@storybook/addon-vitest')) { const { result } = await this.addonVitestService.installPlaywright({ yes: this.commandOptions.yes, + useRemotePkg: !!this.commandOptions.skipInstall, }); // Map outcome to telemetry decision await this.telemetryService.trackPlaywrightPromptDecision(result); } + // some addons failed + if (hasFailures) { + this.logManualAddonInstructions( + addons.filter((addon) => addonResults.get(addon)?.result === 'failed') + ); + } + return { status: hasFailures ? 'failed' : 'success' }; } catch (e) { logger.error('Unexpected error during addon configuration:'); @@ -123,7 +123,7 @@ export class AddonConfigurationCommand { try { task.message(`Configuring ${addon}...`); - await postinstallAddon(addon, { + const options = { packageManager: this.packageManager.type, configDir, yes: this.commandOptions.yes, @@ -131,11 +131,20 @@ export class AddonConfigurationCommand { skipDependencyManagement: true, logger, prompt, - }); + }; + + if (addon === '@storybook/addon-vitest') { + await addonVitestPostinstall(options); + } else if (addon === '@storybook/addon-a11y') { + await addonA11yPostinstall(options); + } else { + await postinstallAddon(addon, options); + } task.message(`${addon} configured\n`); addonResults.set(addon, null); } catch (e) { + logger.debug(e); ErrorCollector.addError(e); addonResults.set(addon, e); } diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts index f879edc78773..78c1db4a74ea 100644 --- a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts @@ -1,6 +1,7 @@ import { ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; +import { telemetry } from 'storybook/internal/telemetry'; import type { SupportedLanguage } from 'storybook/internal/types'; import picocolors from 'picocolors'; @@ -91,6 +92,13 @@ We assume that Storybook is already instantiated for your project. Do you still if (force || options.yes) { options.force = true; } else { + if (!options.disableTelemetry) { + await telemetry( + 'exit', + { eventType: 'init', reason: 'existing-installation' }, + { stripMetadata: true, immediate: true } + ); + } process.exit(0); } } diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 96759086aa20..fafee5b90be7 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -98,7 +98,6 @@ export async function doInitiate(options: CommandOptions): Promise< packageManager, addons: extraAddons, configDir, - dependencyInstallationResult, options, }); diff --git a/code/lib/create-storybook/src/scaffold-new-project.ts b/code/lib/create-storybook/src/scaffold-new-project.ts index 5e12c445d030..a91c3061dbec 100644 --- a/code/lib/create-storybook/src/scaffold-new-project.ts +++ b/code/lib/create-storybook/src/scaffold-new-project.ts @@ -135,6 +135,13 @@ export const scaffoldNewProject = async ( } if (projectStrategy === 'other') { + if (!disableTelemetry) { + await telemetry( + 'exit', + { eventType: 'init', reason: 'scaffold-other' }, + { stripMetadata: true, immediate: true } + ); + } logger.warn( 'To install Storybook on another framework, first generate a project with that framework and then rerun this command.' ); diff --git a/code/lib/create-storybook/src/services/VersionService.test.ts b/code/lib/create-storybook/src/services/VersionService.test.ts index 1927fef28334..4f7d06f4e156 100644 --- a/code/lib/create-storybook/src/services/VersionService.test.ts +++ b/code/lib/create-storybook/src/services/VersionService.test.ts @@ -131,6 +131,57 @@ describe('VersionService', () => { expect(integration).toBeUndefined(); }); + + it('should detect create-rsbuild command', () => { + const ancestry = [{ command: 'npx create-rsbuild' }, { command: 'node /usr/local/bin/npm' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('create-rsbuild'); + }); + + it('should detect create rsbuild with version specifier', () => { + const ancestry = [{ command: 'npx create-rsbuild@1.0.0 init' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('create-rsbuild'); + }); + + it('should detect "create rsbuild" with space instead of dash', () => { + const ancestry = [{ command: 'npm create rsbuild -- my-app' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('create-rsbuild'); + }); + + it('should NOT detect creatersbuild without separator', () => { + const ancestry = [{ command: 'npx creatersbuild' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBeUndefined(); + }); + + it('should detect @tanstack/start command', () => { + const ancestry = [{ command: 'npx @tanstack/start@latest create my-app' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('@tanstack/start'); + }); + + it('should detect @tanstack/start in middle of command chain', () => { + const ancestry = [ + { command: 'pnpm @tanstack/start init' }, + { command: 'node /usr/local/bin/pnpm' }, + ]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('@tanstack/start'); + }); }); describe('getVersionInfo', () => { diff --git a/code/lib/create-storybook/src/services/VersionService.ts b/code/lib/create-storybook/src/services/VersionService.ts index 019911ecebda..5a5cef89c58e 100644 --- a/code/lib/create-storybook/src/services/VersionService.ts +++ b/code/lib/create-storybook/src/services/VersionService.ts @@ -43,16 +43,26 @@ export class VersionService { } /** - * Extract CLI integration from process ancestry Detects if Storybook was invoked via sv create or - * sv add commands + * Extract CLI integration from process ancestry Detects if Storybook was invoked via sv create, + * sv add, create-rsbuild, or @tanstack/start commands */ getCliIntegrationFromAncestry( ancestry: ReturnType ): string | undefined { for (const ancestor of ancestry.toReversed()) { - const match = ancestor.command?.match(/(?:^|\s)(sv(?:@[^ ]+)? (?:create|add))/i); - if (match) { - return match[1].toLowerCase().includes('add') ? 'sv add' : 'sv create'; + // Check for sv create/add + const svMatch = ancestor.command?.match(/(?:^|\s)(sv(?:@[^ ]+)? (?:create|add))/i); + if (svMatch) { + return svMatch[1].toLowerCase().includes('add') ? 'sv add' : 'sv create'; + } + // Check for create-rsbuild or create rsbuild + const rsbuildMatch = ancestor.command?.match(/(?:^|\s)create[\s\-]rsbuild/i); + if (rsbuildMatch) { + return 'create-rsbuild'; + } + // Check for @tanstack/start + if (ancestor.command?.includes('@tanstack/start')) { + return '@tanstack/start'; } } return undefined; diff --git a/code/lib/csf-plugin/package.json b/code/lib/csf-plugin/package.json index cb9c3289ae27..0d216729c705 100644 --- a/code/lib/csf-plugin/package.json +++ b/code/lib/csf-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/csf-plugin", - "version": "10.2.0", + "version": "10.2.9", "description": "Enrich CSF files via static analysis", "keywords": [ "storybook" diff --git a/code/lib/eslint-plugin/package.json b/code/lib/eslint-plugin/package.json index 1616c6a3d92e..f07c5e75c946 100644 --- a/code/lib/eslint-plugin/package.json +++ b/code/lib/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-storybook", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook ESLint Plugin: Best practice rules for writing stories", "keywords": [ "eslint", diff --git a/code/lib/react-dom-shim/package.json b/code/lib/react-dom-shim/package.json index faa2570b9f48..f1b12d2d64d2 100644 --- a/code/lib/react-dom-shim/package.json +++ b/code/lib/react-dom-shim/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-dom-shim", - "version": "10.2.0", + "version": "10.2.9", "description": "", "keywords": [ "storybook" diff --git a/code/package.json b/code/package.json index fd4233e74566..a9f9025ff273 100644 --- a/code/package.json +++ b/code/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/code", - "version": "10.2.0", + "version": "10.2.9", "private": true, "description": "Storybook root", "homepage": "https://storybook.js.org/", diff --git a/code/presets/create-react-app/package.json b/code/presets/create-react-app/package.json index 72dbfc805416..539501b66da3 100644 --- a/code/presets/create-react-app/package.json +++ b/code/presets/create-react-app/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/preset-create-react-app", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Create React App preset", "keywords": [ "storybook" diff --git a/code/presets/react-webpack/package.json b/code/presets/react-webpack/package.json index 9e2b2fef0cf5..a69f977069a6 100644 --- a/code/presets/react-webpack/package.json +++ b/code/presets/react-webpack/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/preset-react-webpack", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for React: Develop React Component in isolation with Hot Reloading", "keywords": [ "storybook" diff --git a/code/presets/server-webpack/package.json b/code/presets/server-webpack/package.json index 77bf38ef5b5a..19ba97c85aee 100644 --- a/code/presets/server-webpack/package.json +++ b/code/presets/server-webpack/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/preset-server-webpack", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook for Server: View HTML snippets from a server in isolation with Hot Reloading.", "keywords": [ "storybook" diff --git a/code/renderers/html/package.json b/code/renderers/html/package.json index 719ae8b43071..93fc15b56e1a 100644 --- a/code/renderers/html/package.json +++ b/code/renderers/html/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/html", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook HTML renderer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/renderers/preact/package.json b/code/renderers/preact/package.json index 78da624a2a53..b0c929fa1cef 100644 --- a/code/renderers/preact/package.json +++ b/code/renderers/preact/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/preact", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Preact renderer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/renderers/react/package.json b/code/renderers/react/package.json index 0e78ffe01261..88fe2633fd18 100644 --- a/code/renderers/react/package.json +++ b/code/renderers/react/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook React renderer", "keywords": [ "storybook" diff --git a/code/renderers/server/package.json b/code/renderers/server/package.json index 13e4216b53c5..b3109b5f4998 100644 --- a/code/renderers/server/package.json +++ b/code/renderers/server/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/server", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Server renderer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/renderers/svelte/package.json b/code/renderers/svelte/package.json index aed8c5b62754..7e49dd8729db 100644 --- a/code/renderers/svelte/package.json +++ b/code/renderers/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/svelte", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Svelte renderer: Develop, document, and test UI components in isolation.", "keywords": [ "storybook", diff --git a/code/renderers/vue3/package.json b/code/renderers/vue3/package.json index 3d1abb3cc2db..c60776a7997e 100644 --- a/code/renderers/vue3/package.json +++ b/code/renderers/vue3/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/vue3", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Vue 3 renderer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/code/renderers/web-components/package.json b/code/renderers/web-components/package.json index 4e51ba94262e..6b804e101889 100644 --- a/code/renderers/web-components/package.json +++ b/code/renderers/web-components/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/web-components", - "version": "10.2.0", + "version": "10.2.9", "description": "Storybook Web Components renderer: Develop, document, and test UI components in isolation", "keywords": [ "storybook", diff --git a/docs/_snippets/csf-factories-automigrate-with-config-directory.md b/docs/_snippets/csf-factories-automigrate-with-config-directory.md new file mode 100644 index 000000000000..8bea18c989e6 --- /dev/null +++ b/docs/_snippets/csf-factories-automigrate-with-config-directory.md @@ -0,0 +1,14 @@ +```shell renderer="common" language="js" packageManager="npm" +npx storybook automigrate csf-factories -c apps/admin/.storybook +npx storybook automigrate csf-factories -c apps/website/.storybook +``` + +```shell renderer="common" language="js" packageManager="pnpm" +pnpm dlx storybook automigrate csf-factories -c apps/admin/.storybook +pnpm dlx storybook automigrate csf-factories -c apps/website/.storybook +``` + +```shell renderer="common" language="js" packageManager="yarn" +yarn dlx storybook automigrate csf-factories -c apps/admin/.storybook +yarn dlx storybook automigrate csf-factories -c apps/website/.storybook +``` diff --git a/docs/_snippets/storybook-preview-configure-globaltypes.md b/docs/_snippets/storybook-preview-configure-globaltypes.md index 651c5a63033a..4f976b95f75a 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, }, }, diff --git a/docs/api/cli-options.mdx b/docs/api/cli-options.mdx index f33b476e0f38..ccbd8fa769b4 100644 --- a/docs/api/cli-options.mdx +++ b/docs/api/cli-options.mdx @@ -42,7 +42,7 @@ Options include: | `--exact-port` | Attempts to run Storybook on the exact port number specified.
If the port is already in use, Storybook will exit with an error message.
`storybook dev -p 9009 --exact-port` | | `-h`, `--host [string]` | Host to run Storybook.
`storybook dev -h my-host.com` | | `-c`, `--config-dir [dir-name]` | Storybook configuration directory.
`storybook dev -c .storybook` | -| `--loglevel [level]` | Controls level of logging during build.
Available options: `silly`, `verbose`, `info` (default), `warn`, `error`, `silent`
`storybook dev --loglevel warn` | +| `--loglevel [level]` | Controls level of logging during build.
Available options: `trace`, `debug`, `info` (default), `warn`, `error`, `silent`
`storybook dev --loglevel warn` | | `--https` | Serve Storybook over HTTPS. Note: You must provide your own certificate information.
`storybook dev --https` | | `--ssl-ca` | Provide an SSL certificate authority. (Optional with --https, required if using a self-signed certificate)
`storybook dev --ssl-ca my-certificate` | | `--ssl-cert` | Provide an SSL certificate. (Required with --https)
`storybook dev --ssl-cert my-ssl-certificate` | @@ -85,7 +85,7 @@ Options include: | `-V`, `--version` | Output the version number.
`storybook build -V` | | `-o`, `--output-dir [dir-name]` | Directory where to store built files.
`storybook build -o /my-deployed-storybook` | | `-c`, `--config-dir [dir-name]` | Storybook configuration directory.
`storybook build -c .storybook` | -| `--loglevel [level]` | Controls level of logging during build.
Available options: `silly`, `verbose`, `info` (default), `warn`, `error`, `silent`.
`storybook build --loglevel warn` | +| `--loglevel [level]` | Controls level of logging during build.
Available options: `trace`, `debug`, `info` (default), `warn`, `error`, `silent`.
`storybook build --loglevel warn` | | `--quiet` | Suppress verbose build output.
`storybook build --quiet` | | `--debug` | Outputs more logs in the CLI to assist debugging.
`storybook build --debug` | | `--debug-webpack` | Display final webpack configurations for debugging purposes.
`storybook build --debug-webpack` | diff --git a/docs/api/csf/csf-next.mdx b/docs/api/csf/csf-next.mdx index 3e4331615fe8..2c64dc702b3b 100644 --- a/docs/api/csf/csf-next.mdx +++ b/docs/api/csf/csf-next.mdx @@ -290,6 +290,12 @@ You can automatically upgrade all of your project's stories from CSF 3 to CSF Ne +#### Monorepos + +If your project has multiple Storybook configurations, run the command with the `-c` flag pointing to each config directory: + + + It will run through each of the manual upgrade steps below on all of your story files.
diff --git a/docs/get-started/frameworks/angular.mdx b/docs/get-started/frameworks/angular.mdx index e1f2b411128f..dc525a26b613 100644 --- a/docs/get-started/frameworks/angular.mdx +++ b/docs/get-started/frameworks/angular.mdx @@ -289,7 +289,7 @@ These are common options you may need for the Angular builder: | `"initialPath"` | URL path to be appended when visiting Storybook for the first time.
`"initialPath": "docs/configure-your-project--docs"` | | `"webpackStatsJson"` | Write Webpack Stats JSON to disk.
`"webpackStatsJson": true` | | `"previewUrl"` | Disables the default storybook preview and lets you use your own.
`"previewUrl": "iframe.html"` | -| `"loglevel"` | Controls level of logging during build. Can be one of: [silly, verbose, info (default), warn, error, silent].
`"loglevel": "info"` | +| `"loglevel"` | Controls level of logging during build. Can be one of: [trace, debug, info (default), warn, error, silent].
`"loglevel": "info"` | | `"sourceMap"` | Configure [sourcemaps](https://angular.dev/reference/configs/workspace-config#source-map-configuration.).
`"sourceMap": true` | | `"experimentalZoneless"` | Configure [zoneless change detection](https://angular.dev/guide/zoneless).
`"experimentalZoneless": true` | diff --git a/docs/versions/latest.json b/docs/versions/latest.json index 6abb03c19141..8492f9efa12c 100644 --- a/docs/versions/latest.json +++ b/docs/versions/latest.json @@ -1 +1 @@ -{"version":"10.2.0","info":{"plain":""}} \ No newline at end of file +{"version":"10.2.9","info":{"plain":"- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic!\n- Builder-Vite: Update dependencies react-vite framework - [#33810](https://github.com/storybookjs/storybook/pull/33810), thanks @valentinpalkovic!\n- Builder-Vite: Use relative path for mocker entry in production builds - [#33792](https://github.com/storybookjs/storybook/pull/33792), thanks @DukeDeSouth!\n- Next.js: Fix Link component override in appDirectory configuration - [#31251](https://github.com/storybookjs/storybook/pull/31251), thanks @yatishgoel!"}} \ No newline at end of file diff --git a/docs/versions/next.json b/docs/versions/next.json index e9a84f0afc54..ab12a3d18ed7 100644 --- a/docs/versions/next.json +++ b/docs/versions/next.json @@ -1 +1 @@ -{"version":"10.2.0-beta.5","info":{"plain":"- Addon A11y: Lock vision filter dropdown for stories with `vision` global - [#33599](https://github.com/storybookjs/storybook/pull/33599), thanks @ghengeveld!"}} \ No newline at end of file +{"version":"10.3.0-alpha.6","info":{"plain":"- Addon-Vitest: Improve config file detection in monorepos - [#33814](https://github.com/storybookjs/storybook/pull/33814), thanks @valentinpalkovic!\n- Addon-Vitest: Support Vitest canaries - [#33833](https://github.com/storybookjs/storybook/pull/33833), thanks @valentinpalkovic!\n- Builder-Vite: Update dependencies react-vite framework - [#33810](https://github.com/storybookjs/storybook/pull/33810), thanks @valentinpalkovic!\n- Next.js: Fix Link component override in appDirectory configuration - [#31251](https://github.com/storybookjs/storybook/pull/31251), thanks @yatishgoel!"}} \ No newline at end of file diff --git a/docs/writing-stories/typescript.mdx b/docs/writing-stories/typescript.mdx index 220e985c6b20..a78660f8b960 100644 --- a/docs/writing-stories/typescript.mdx +++ b/docs/writing-stories/typescript.mdx @@ -69,32 +69,57 @@ Sometimes stories need to define args that aren’t included in the component's {/* prettier-ignore-end */} - ## Vue specific tips - Vue has excellent support for TypeScript, and we have done our utmost to take advantage of that in the stories files. For example, consider the following strongly typed Vue 3 single file component (SFC): +## Vue specific tips - ```html - +```html + + + +``` + +You can type check SFC files with `vue-tsc` and get editor support in VSCode by installing the official [Vue extension](https://marketplace.visualstudio.com/items?itemName=Vue.volar). + +This setup will add type support for `*.vue` imports to your `*.stories.ts` files, providing the same type safety and autocomplete features. + +[CSF Next](../api/csf/csf-next.mdx) adds support for [Generic Vue components](https://vuejs.org/api/sfc-script-setup.html#generics) (using `