diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index a8a33580f1f1..7a7bc9b4dcfe 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -25,7 +25,7 @@ jobs: - name: check run: yarn task --task check - prettier: + formatting: name: Core Formatting if: github.repository_owner != 'storybookjs' runs-on: ubuntu-latest @@ -39,8 +39,8 @@ jobs: with: install-code-deps: true - - name: prettier - run: cd code && yarn lint:prettier --check . + - name: oxfmt + run: cd code && yarn lint:fmt test: strategy: diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index ff5c96043417..f3a0498e74b8 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest env: - ALL_TASKS: compile,check,knip,test,pretty-docs,lint,sandbox,build,e2e-tests,e2e-tests-dev,test-runner,vitest-integration,check-sandbox,e2e-ui,jest,vitest,playwright-ct,cypress + ALL_TASKS: compile,check,knip,test,lint,fmt,sandbox,build,e2e-tests,e2e-tests-dev,test-runner,vitest-integration,check-sandbox,e2e-ui,jest,vitest,playwright-ct,cypress steps: - uses: actions/checkout@v4 with: diff --git a/.husky/pre-commit b/.husky/pre-commit index 36c4e990898b..fe596ea4aea0 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,3 @@ if [ -z "$SKIP_STORYBOOK_GIT_HOOKS" ]; then - cd code - yarn lint-staged - - cd ../scripts yarn lint-staged fi diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 000000000000..9f5654bdc2e8 --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,11 @@ +import { detectAgent } from 'std-env'; + +const fmtCmd = detectAgent().name ? 'oxfmt' : 'oxfmt --check'; + +export default { + 'code/**/*.{js,jsx,mjs,ts,tsx,html,json}': [fmtCmd, 'yarn --cwd code lint:js:cmd'], + 'scripts/**/*.{html,js,json,jsx,mjs,ts,tsx}': ['yarn --cwd scripts lint:js:cmd'], + 'docs/_snippets/**/*.{js,jsx,mjs,ts,tsx,html,json}': [fmtCmd], + '**/*.ejs': ['yarn --cwd scripts exec ejslint'], + '**/package.json': ['yarn --cwd scripts lint:package'], +}; diff --git a/.nx/workflows/distribution-config.yaml b/.nx/workflows/distribution-config.yaml index d62ec545c179..caf65796918b 100644 --- a/.nx/workflows/distribution-config.yaml +++ b/.nx/workflows/distribution-config.yaml @@ -14,7 +14,6 @@ assignment-rules: - compile - check - lint - - pretty-docs - knip run-on: - agent: linux-js diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 000000000000..fa3458fee14e --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,68 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "printWidth": 100, + "tabWidth": 2, + "bracketSpacing": true, + "trailingComma": "es5", + "singleQuote": true, + "arrowParens": "always", + "sortPackageJson": false, + "ignorePatterns": [ + "*.bundle.js", + "*.js.map", + ".yarn", + ".vscode", + ".nx/cache", + ".nx/workspace-data", + "dist", + "build", + "bench", + "coverage", + "node_modules", + "storybook-static", + "built-storybooks", + "ember-output", + "code/core/assets", + "code/core/report", + "code/core/src/core-server/presets/common-manager.ts", + "code/core/src/core-server/utils/__search-files-tests__", + "code/core/src/core-server/utils/__mockdata__/src/Empty.stories.ts", + "code/lib/codemod/src/transforms/__testfixtures__", + "code/frameworks/angular/template/**", + "code/lib/eslint-plugin", + ".prettierrc", + "test-storybooks", + "*.yml", + "*.yaml", + "*.md", + "*.mdx", + "!docs/_snippets/**" + ], + "overrides": [ + { + "files": ["docs/_snippets/**"], + "options": { + "trailingComma": "all" + } + }, + { + "files": ["*.md", "*.mdx"], + "options": { + "importOrderSeparation": false, + "importOrderSortSpecifiers": false + } + }, + { + "files": ["*.component.html"], + "options": { + "parser": "angular" + } + }, + { + "files": ["**/frameworks/angular/src/**/*.ts", "**/frameworks/angular/template/**/*.ts"], + "options": { + "parser": "babel-ts" + } + } + ] +} diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index f1dbb34bc476..ecf7e7e15cde 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -15,7 +15,7 @@ Current version: 10.2.x (as of March 2026) - **Monorepo Tool**: NX (with `--no-cloud` flag required to avoid NX Cloud login issues) - **Test Runner**: Vitest (primary), Playwright (E2E) - **Linting**: ESLint 8 -- **Formatting**: Prettier 3.7+ +- **Formatting**: Oxfmt - **Bundlers**: Vite 7, Webpack 5, esbuild - **UI Libraries**: React 18, react-aria (use specific submodules, not root imports) - **Build System**: Custom build via `jiti ./scripts/build/build-package.ts` diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 020ac953c668..ca2530047fdd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -3,6 +3,7 @@ "dbaeumer.vscode-eslint", "EditorConfig.EditorConfig", "unifiedjs.vscode-mdx", - "yzhang.markdown-all-in-one" + "yzhang.markdown-all-in-one", + "oxc.oxc-vscode" ] -} +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f2737e362922..7013371e0609 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,30 +1,25 @@ { - "[javascript]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.formatOnSave": true - }, - "[javascriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", - "editor.formatOnSave": true - }, - "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true - }, - "[typescriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true }, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" + "source.fixAll.eslint": "explicit", + "source.fixAll.oxc": "explicit" }, "editor.formatOnSave": true, "editor.tabSize": 2, - "eslint.format.enable": true, "eslint.options": { "cache": true, "cacheLocation": ".cache/eslint", - "extensions": [".js", ".jsx", ".mjs", ".json", ".ts", ".tsx"] + "extensions": [ + ".js", + ".jsx", + ".mjs", + ".json", + ".ts", + ".tsx" + ] }, "eslint.useESLintClass": true, "eslint.validate": [ @@ -35,14 +30,17 @@ "typescript", "typescriptreact" ], - "eslint.workingDirectories": ["./code", "./scripts"], + "eslint.workingDirectories": [ + "./code", + "./scripts" + ], "files.associations": { - "*.js": "javascriptreact" + "*.js": "javascriptreact", + ".oxfmtrc.json": "json" }, "javascript.preferences.importModuleSpecifier": "relative", "javascript.preferences.quoteStyle": "single", "js/ts.implicitProjectConfig.target": "ESNext", - "prettier.ignorePath": "./code/.prettierignore", "storyExplorer.storybookConfigDir": "./code/.storybook", "typescript.format.enable": false, "typescript.preferences.importModuleSpecifier": "relative", @@ -52,4 +50,6 @@ "typescript.tsdk": "./typescript/lib", "vitest.workspaceConfig": "./code/vitest.workspace.ts", "vitest.rootConfig": "./code/vitest.workspace.ts", -} + "oxc.fmt.configPath": ".oxfmtrc.json", + "oxc.enable.oxlint": false, +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4e3450d1431b..7c99c9041a9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,7 +234,7 @@ When writing tests: After changing files: -1. Format with `yarn prettier --write ` +1. Format with `cd code && oxfmt` 2. Lint with `yarn --cwd code lint:js:cmd --fix` or `cd code && yarn lint:js:cmd ` 3. Run relevant tests before submitting a PR @@ -243,6 +243,8 @@ Use Storybook loggers instead of raw `console.*` in normal code paths: - Server-side: `storybook/internal/node-logger` - Client-side: `storybook/internal/client-logger` +The pre-commit hook automatically detects AI agents (via `std-env`) and switches from check-only to write mode, so formatting is auto-fixed when agents commit. + Avoid `console.log`, `console.warn`, and `console.error` unless the file is isolated enough that importing the logger is not reasonable. ## Troubleshooting diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7c7d8530de..bebcb8ad665f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 10.3.4 + +- Addon-a11y: Clear status transition timer on unmount to prevent test flake - [#34203](https://github.com/storybookjs/storybook/pull/34203), thanks @mixelburg! +- Bug: Skip re-processing already transformed config files for CSF factories - [#34273](https://github.com/storybookjs/storybook/pull/34273), thanks @huang-julien! +- Builder-Vite: Use djb2 hash to prevent variable name collisions in builder-vite - [#34274](https://github.com/storybookjs/storybook/pull/34274), thanks @chida09! +- CLI: Prompt for init crash reports - [#34316](https://github.com/storybookjs/storybook/pull/34316), thanks @JReinhold! +- CSF4: Fix duplicate preview loading issue in Vitest - [#34361](https://github.com/storybookjs/storybook/pull/34361), thanks @valentinpalkovic! +- Core: Fix WebSocket connection for StackBlitz/WebContainers - [#34281](https://github.com/storybookjs/storybook/pull/34281), thanks @ghengeveld! +- React-Docgen: Try .tsx fallback when resolving .js ESM imports in docgen resolvers - [#34393](https://github.com/storybookjs/storybook/pull/34393), thanks @mixelburg! +- React-Vite: Upgrade @joshwooding/vite-plugin-react-docgen-typescript to 0.7.0 - [#34335](https://github.com/storybookjs/storybook/pull/34335), thanks @beeswhacks! + ## 10.3.3 - Addon-Vitest: Streamline vite(st) config detection across init and postinstall - [#34193](https://github.com/storybookjs/storybook/pull/34193), thanks @valentinpalkovic! diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5472f281b06e..2580150e27ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ Here's a highlight of notable directories and files: │ ├── package.json │ ├── playwright.config.ts │ ├── presets # Preset packages -│ ├── prettier.config.mjs +│ ├── .oxfmtrc.json │ ├── renderers # Storybook renderers for different frameworks │ ├── sandbox # Sandboxes for Bug Reproductions or experimentation │ ├── tsconfig.json @@ -117,7 +117,7 @@ Here's a highlight of notable directories and files: │ └── writing-tests ├── node_modules ├── package.json # Root of the yarn monorepo -├── prettier.config.mjs +├── .oxfmtrc.json ├── scripts # Build and Helper Scripts ├── test-storybooks │ ├── ember-cli diff --git a/code/.prettierignore b/code/.prettierignore deleted file mode 100644 index 6f24e6a798d8..000000000000 --- a/code/.prettierignore +++ /dev/null @@ -1,16 +0,0 @@ -*.mdx - -.yarn -.vscode -dist -bench -/.nx/cache -core/report - -/.nx/workspace-data - -# This file contains imports with an order that must be preserved. -core/src/core-server/presets/common-manager.ts - -# This file is modified by build/check, causing errors when changed in CI jobs. -lib/eslint-plugin/docs/rules/no-stories-of.md \ No newline at end of file diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 90c6f2f3f328..4047c37d3887 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -1,5 +1,13 @@ import type { FC, PropsWithChildren } from 'react'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { STORY_CHANGED, @@ -140,9 +148,22 @@ export const A11yContextProvider: FC = (props) => { }, [setState, storyId]); const handleToggleHighlight = useCallback(() => { - setState((prev) => ({ ...prev, ui: { ...prev.ui, highlighted: !prev.ui.highlighted } })); + setState((prev) => ({ + ...prev, + ui: { ...prev.ui, highlighted: !prev.ui.highlighted }, + })); }, [setState]); + const statusTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (statusTimerRef.current !== null) { + clearTimeout(statusTimerRef.current); + } + }; + }, []); + const [selectedItems, setSelectedItems] = useState>(() => { const initialValue = new Map(); // Check if the a11ySelection param is a valid format before parsing it @@ -202,7 +223,11 @@ export const A11yContextProvider: FC = (props) => { if (storyId === id) { setState((prev) => ({ ...prev, status: 'ran', results: axeResults })); - setTimeout(() => { + if (statusTimerRef.current !== null) { + clearTimeout(statusTimerRef.current); + } + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null; setState((prev) => { if (prev.status === 'ran') { return { ...prev, status: 'ready' }; diff --git a/code/addons/docs/docs/frameworks/WEB_COMPONENTS.md b/code/addons/docs/docs/frameworks/WEB_COMPONENTS.md index 7edf06fe9989..c5417be88e43 100644 --- a/code/addons/docs/docs/frameworks/WEB_COMPONENTS.md +++ b/code/addons/docs/docs/frameworks/WEB_COMPONENTS.md @@ -14,7 +14,7 @@ ```js import { setCustomElementsManifest } from '@storybook/web-components'; import customElements from '../custom-elements.json'; - + setCustomElementsManifest(customElements); ``` diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 8d3f7c2666a4..98af7d649d54 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -303,14 +303,9 @@ export const storybookTest = async (options?: UserOptions): Promise => finalOptions.includeStories = includeStories; const projectId = oneWayHash(finalOptions.configDir); - const previewOrConfigFile = loadPreviewOrConfigFile({ configDir: finalOptions.configDir }); - const previewConfig = previewOrConfigFile ? await readConfig(previewOrConfigFile) : undefined; - const isCSF4 = previewConfig ? isCsfFactoryPreview(previewConfig) : false; - const areProjectAnnotationRequired = await requiresProjectAnnotations( nonMutableInputConfig.test, - finalOptions, - isCSF4 + finalOptions ); const internalSetupFiles = ( @@ -318,7 +313,6 @@ export const storybookTest = async (options?: UserOptions): Promise => '@storybook/addon-vitest/internal/setup-file', areProjectAnnotationRequired && '@storybook/addon-vitest/internal/setup-file-with-project-annotations', - isCSF4 && previewOrConfigFile, ].filter(Boolean) as string[] ).map((filePath) => fileURLToPath(import.meta.resolve(filePath))); diff --git a/code/addons/vitest/src/vitest-plugin/utils.ts b/code/addons/vitest/src/vitest-plugin/utils.ts index dfa3c74cc0e6..ce3946aa9498 100644 --- a/code/addons/vitest/src/vitest-plugin/utils.ts +++ b/code/addons/vitest/src/vitest-plugin/utils.ts @@ -20,8 +20,7 @@ const logBoxOnce = (message: string) => { export async function requiresProjectAnnotations( testConfig: ViteUserConfig['test'] | undefined, - finalOptions: InternalOptions, - isCSF4: boolean + finalOptions: InternalOptions ) { const setupFiles = Array.isArray(testConfig?.setupFiles) ? testConfig.setupFiles @@ -58,8 +57,6 @@ export async function requiresProjectAnnotations( You can safely remove the "setProjectAnnotations" call from your setup file, or remove the file entirely if you don't have custom code there. `); - return false; - } else if (isCSF4) { return false; } diff --git a/code/builders/builder-vite/input/iframe.html b/code/builders/builder-vite/input/iframe.html index 1637f04eb9e8..b161747bb105 100644 --- a/code/builders/builder-vite/input/iframe.html +++ b/code/builders/builder-vite/input/iframe.html @@ -70,10 +70,10 @@ if (hostname !== 'localhost' && globalThis.CONFIG_TYPE === 'DEVELOPMENT') { const message = `Failed to load the Storybook preview file 'vite-app.js': -It looks like you're visiting the Storybook development server on another hostname than localhost: '${hostname}', but you haven't configured the necessary security features to support this. -Please re-run your Storybook development server with the '--host ${hostname}' flag, or manually configure your Vite allowedHosts configuration with viteFinal. + It looks like you're visiting the Storybook development server on another hostname than localhost: '${hostname}', but you haven't configured the necessary security features to support this. + Please re-run your Storybook development server with the '--host ${hostname}' flag, or manually configure your Vite allowedHosts configuration with viteFinal. -See:`; + See:`; const docs = [ 'https://storybook.js.org/docs/api/cli-options#dev', 'https://storybook.js.org/docs/api/main-config/main-config-vite-final', @@ -85,7 +85,7 @@ `

${message.replaceAll( '\n', '
' - )}

    ${docs.map((doc) => `
  • ${doc}
  • `).join('')}

      `; + )}
        ${docs.map((doc) => `
      • ${doc}
      • `).join('')}

      `; return; } } diff --git a/code/builders/builder-vite/src/codegen-project-annotations.ts b/code/builders/builder-vite/src/codegen-project-annotations.ts index 9d13c9390ec9..1f5b5192145d 100644 --- a/code/builders/builder-vite/src/codegen-project-annotations.ts +++ b/code/builders/builder-vite/src/codegen-project-annotations.ts @@ -105,6 +105,11 @@ export function generateProjectAnnotationsCodeFromPreviews(options: { `.trim(); } +/** djb2 hash — http://www.cse.yorku.ca/~oz/hash.html */ function hash(value: string) { - return value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0); + let acc = 5381; + for (let i = 0; i < value.length; i++) { + acc = ((acc << 5) + acc + value.charCodeAt(i)) >>> 0; + } + return acc; } diff --git a/code/core/package.json b/code/core/package.json index 1a062c57b367..40e0f7e9ea88 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -234,6 +234,7 @@ "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", + "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "recast": "^0.23.5", diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index 0d07dd4468ff..89fe327ea060 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -8,7 +8,7 @@ import { pathToFileURL } from 'node:url'; import { GlobalRegistrator } from '@happy-dom/global-registrator'; import { isNotNil } from 'es-toolkit/predicate'; import * as esbuild from 'esbuild'; -import * as prettier from 'prettier'; +import { format } from 'oxfmt'; import { dedent } from 'ts-dedent'; import { getWorkspace } from '../../../scripts/utils/tools'; @@ -47,16 +47,10 @@ async function temporaryFile({ name, extension }: { name?: string; extension?: s // save this list into ./code/core/src/types/frameworks.ts and export it as a union type. // The name of the type is `SupportedFrameworks`. Add additionally 'qwik' and `solid` to that list. export const generateSourceFiles = async () => { - const prettierConfig = await prettier.resolveConfig(join(CORE_ROOT_DIR, 'src')); - - await Promise.all([ - generateFrameworksFile(prettierConfig), - generateVersionsFile(prettierConfig), - generateExportsFile(prettierConfig), - ]); + await Promise.all([generateFrameworksFile(), generateVersionsFile(), generateExportsFile()]); }; -async function generateVersionsFile(prettierConfig: prettier.Options | null): Promise { +async function generateVersionsFile(): Promise { const destination = join(CORE_ROOT_DIR, 'src', 'common', 'versions.ts'); const workspace = (await getWorkspace()).filter(isNotNil); @@ -72,22 +66,19 @@ async function generateVersionsFile(prettierConfig: prettier.Options | null): Pr }, {}) ); - await writeFile( - destination, - await prettier.format( - dedent` - // auto generated file, do not edit - export default ${versions}; - `, - { - ...prettierConfig, - parser: 'typescript', - } - ) + const { code: formatted } = await format( + 'versions.ts', + dedent` + // auto generated file, do not edit + export default ${versions}; + `, + { singleQuote: true } ); + + await writeFile(destination, formatted); } -async function generateFrameworksFile(prettierConfig: prettier.Options | null): Promise { +async function generateFrameworksFile(): Promise { const thirdPartyFrameworks = [ 'html-rsbuild', 'nuxt', @@ -112,24 +103,21 @@ async function generateFrameworksFile(prettierConfig: prettier.Options | null): const coreFrameworks = readFrameworks.sort().map(formatFramework).join(',\n'); const communityFrameworks = thirdPartyFrameworks.sort().map(formatFramework).join(',\n'); - await writeFile( - destination, - await prettier.format( - dedent` - // auto generated file, do not edit - export enum SupportedFramework { - // CORE - ${coreFrameworks}, - // COMMUNITY - ${communityFrameworks} - } - `, - { - ...prettierConfig, - parser: 'typescript', + const { code: formatted } = await format( + 'frameworks.ts', + dedent` + // auto generated file, do not edit + export enum SupportedFramework { + // CORE + ${coreFrameworks}, + // COMMUNITY + ${communityFrameworks} } - ) + `, + { singleQuote: true } ); + + await writeFile(destination, formatted); } const localAlias = { @@ -142,7 +130,7 @@ const localAlias = { 'storybook/manager-api': join(CORE_ROOT_DIR, 'src', 'manager-api'), storybook: join(CORE_ROOT_DIR, 'src'), }; -async function generateExportsFile(prettierConfig: prettier.Options | null): Promise { +async function generateExportsFile(): Promise { function removeDefault(input: string) { return input !== 'default'; } @@ -178,22 +166,19 @@ async function generateExportsFile(prettierConfig: prettier.Options | null): Pro } } - await writeFile( - destination, - await prettier.format( - dedent` + const { code: formatted } = await format( + 'exports.ts', + dedent` // this file is generated by sourcefiles.ts // this is done to prevent runtime dependencies from making it's way into the build/start script of the manager // the manager builder needs to know which dependencies are 'globalized' in the ui - + export default ${JSON.stringify(data)} as const; `, - { - ...prettierConfig, - parser: 'typescript', - } - ) + { singleQuote: true } ); + + await writeFile(destination, formatted); } generateSourceFiles(); diff --git a/code/core/src/common/utils/__tests-formatter__/withPrettierConfig/.prettierrc b/code/core/src/common/utils/__tests-formatter__/withPrettierConfig/.prettierrc index f9148847022b..e6983783b15b 100644 --- a/code/core/src/common/utils/__tests-formatter__/withPrettierConfig/.prettierrc +++ b/code/core/src/common/utils/__tests-formatter__/withPrettierConfig/.prettierrc @@ -1,6 +1,6 @@ { - "trailingComma": "es5", - "tabWidth": 4, - "semi": true, - "singleQuote": true + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true } diff --git a/code/core/src/common/utils/envs.ts b/code/core/src/common/utils/envs.ts index d62eeb6048be..c87005101d5c 100644 --- a/code/core/src/common/utils/envs.ts +++ b/code/core/src/common/utils/envs.ts @@ -1,3 +1,5 @@ +export { isWebContainer } from '@webcontainer/env'; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - Needed for Angular sandbox running without --no-link option. Do NOT convert to @ts-expect-error! import { nodePathsToArray } from './paths'; diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index bba61a8f6ddd..93233fb497f5 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -5,6 +5,7 @@ import { getConfigInfo, getInterpretedFile, getProjectRoot, + isWebContainer, loadAllPresets, loadMainConfig, resolveAddonName, @@ -236,6 +237,7 @@ export async function buildDevStandalone( token: getWsToken(), host: options.host, allowedHosts, + skipValidation: isWebContainer(), localAddress, networkAddress, }); diff --git a/code/core/src/core-server/utils/__tests__/server-channel.test.ts b/code/core/src/core-server/utils/__tests__/server-channel.test.ts index 9128df924339..3b1e29276615 100644 --- a/code/core/src/core-server/utils/__tests__/server-channel.test.ts +++ b/code/core/src/core-server/utils/__tests__/server-channel.test.ts @@ -16,6 +16,11 @@ const options = { token: mockToken, } as any; +const webContainerOptions = { + ...options, + skipValidation: true, +} as any; + describe('getServerChannel', () => { it('should return a channel', () => { const server = { on: vi.fn() } as any as Server; @@ -303,4 +308,60 @@ describe('ServerChannelTransport', () => { // Socket should not be destroyed for wrong path (just ignored) expect(destroySpy).not.toHaveBeenCalled(); }); + + it('accepts connections without token when validation is disabled', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const handleUpgradeSpy = vi.fn(); + const transport = new ServerChannelTransport(server, webContainerOptions); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + const request = { + url: '/storybook-server-channel', + headers: { + origin: 'http://localhost:6006', + }, + } as any; + const head = Buffer.from(''); + + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); + + it('accepts connections with invalid origin when validation is disabled', () => { + const server = new EventEmitter() as any as Server; + const socket = new EventEmitter() as any; + socket.write = vi.fn(); + socket.destroy = vi.fn(); + const destroySpy = vi.spyOn(socket, 'destroy'); + const handleUpgradeSpy = vi.fn(); + const transport = new ServerChannelTransport(server, webContainerOptions); + + // Mock handleUpgrade to track if it's called + // @ts-expect-error (accessing private property) + transport.socket.handleUpgrade = handleUpgradeSpy; + + const request = { + url: '/storybook-server-channel?token=wrong-token', + headers: { + origin: 'http://malicious-site.com', + }, + } as any; + const head = Buffer.from(''); + + server.listeners('upgrade')[0](request, socket, head); + + expect(socket.write).not.toHaveBeenCalled(); + expect(destroySpy).not.toHaveBeenCalled(); + expect(handleUpgradeSpy).toHaveBeenCalled(); + }); }); diff --git a/code/core/src/core-server/utils/get-new-story-file.test.ts b/code/core/src/core-server/utils/get-new-story-file.test.ts index 806b44a94cb1..b4d8b15dbbd2 100644 --- a/code/core/src/core-server/utils/get-new-story-file.test.ts +++ b/code/core/src/core-server/utils/get-new-story-file.test.ts @@ -50,9 +50,9 @@ describe('get-new-story-file', () => { expect(exportedStoryName).toBe('Default'); expect(storyFileContent).toMatchInlineSnapshot(` - "import type { Meta, StoryObj } from '@storybook/nextjs'; + "import type { Meta, StoryObj } from "@storybook/nextjs"; - import { Page } from './Page'; + import { Page } from "./Page"; const meta = { component: Page, @@ -89,9 +89,9 @@ describe('get-new-story-file', () => { expect(exportedStoryName).toBe('Default'); expect(storyFileContent).toMatchInlineSnapshot(` - "import type { Meta, StoryObj } from '@storybook/react-vite'; + "import type { Meta, StoryObj } from "@storybook/react-vite"; - import { Page } from './Page'; + import { Page } from "./Page"; const meta = { component: Page, @@ -128,7 +128,7 @@ describe('get-new-story-file', () => { expect(exportedStoryName).toBe('Default'); expect(storyFileContent).toMatchInlineSnapshot(` - "import Page from './Page'; + "import Page from "./Page"; const meta = { component: Page, @@ -166,7 +166,7 @@ describe('get-new-story-file', () => { } as unknown as Options ); - expect(storyFileContent).toContain("import { fn } from 'storybook/test';"); + expect(storyFileContent).toContain('import { fn } from "storybook/test";'); expect(storyFileContent).toContain('fn()'); expect(storyFileContent).not.toContain(STORYBOOK_FN_PLACEHOLDER); }); @@ -258,10 +258,10 @@ describe('get-new-story-file', () => { expect(exportedStoryName).toBe('Default'); expect(storyFileContent).toMatchInlineSnapshot(` - "import preview from '#.storybook/preview'; - import { fn } from 'storybook/test'; + "import { fn } from "storybook/test"; + import preview from "#.storybook/preview"; - import { Page } from './Page'; + import { Page } from "./Page"; const meta = preview.meta({ component: Page, @@ -269,7 +269,7 @@ describe('get-new-story-file', () => { export const Default = meta.story({ args: { - label: 'label', + label: "label", answer: 0, onClick: fn(), }, diff --git a/code/core/src/core-server/utils/get-server-channel.ts b/code/core/src/core-server/utils/get-server-channel.ts index 2050bf9d71e7..a338f5e2e298 100644 --- a/code/core/src/core-server/utils/get-server-channel.ts +++ b/code/core/src/core-server/utils/get-server-channel.ts @@ -14,6 +14,7 @@ import { isValidToken } from './validate-token'; type Server = NonNullable[0]>['server']>; type ServerChannelTransportOptions = HostValidationOptions & { + skipValidation?: boolean; token: string; }; @@ -38,14 +39,16 @@ export class ServerChannelTransport { return; } - const originHost = request.headers.origin && new URL(request.headers.origin).host; - if (!isValidHost(originHost, options)) { - throw new Error('Invalid websocket origin'); - } + if (!options.skipValidation) { + const originHost = request.headers.origin && new URL(request.headers.origin).host; + if (!isValidHost(originHost, options)) { + throw new Error('Invalid websocket origin'); + } - const requestToken = url.searchParams.get('token'); - if (!isValidToken(requestToken, options.token)) { - throw new Error('Invalid websocket token'); + const requestToken = url.searchParams.get('token'); + if (!isValidToken(requestToken, options.token)) { + throw new Error('Invalid websocket token'); + } } this.socket.handleUpgrade(request, socket, head, (ws) => { diff --git a/code/core/src/core-server/withTelemetry.test.ts b/code/core/src/core-server/withTelemetry.test.ts index a46488bbe561..af02bfac6936 100644 --- a/code/core/src/core-server/withTelemetry.test.ts +++ b/code/core/src/core-server/withTelemetry.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cache, loadAllPresets } from 'storybook/internal/common'; +import { cache, isCI, loadAllPresets } from 'storybook/internal/common'; import { prompt } from 'storybook/internal/node-logger'; import { ErrorCollector, oneWayHash, telemetry } from 'storybook/internal/telemetry'; @@ -11,6 +11,18 @@ vi.mock('storybook/internal/telemetry', { spy: true }); vi.mock('storybook/internal/node-logger', { spy: true }); const cliOptions = {}; +const originalStdoutIsTTY = process.stdout.isTTY; + +const setStdoutIsTTY = (value: boolean | undefined) => { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }); +}; + +afterEach(() => { + setStdoutIsTTY(originalStdoutIsTTY); +}); describe('withTelemetry', () => { beforeEach(() => { @@ -74,6 +86,52 @@ describe('withTelemetry', () => { ); }); + it('prompts for crash reports when init fails without preset options', async () => { + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(cache.get).mockResolvedValueOnce(undefined); + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); + setStdoutIsTTY(true); + + await expect(async () => + withTelemetry('init', { cliOptions, printError: vi.fn() }, run) + ).rejects.toThrow(error); + + expect(prompt.confirm).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('enableCrashReports', true); + expect(telemetry).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + eventType: 'init', + error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: true }) + ); + }); + + it('does not send full error details when init prompt is rejected', async () => { + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(cache.get).mockResolvedValueOnce(undefined); + vi.mocked(prompt.confirm).mockResolvedValueOnce(false); + setStdoutIsTTY(true); + + await expect(async () => + withTelemetry('init', { cliOptions, printError: vi.fn() }, run) + ).rejects.toThrow(error); + + expect(prompt.confirm).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('enableCrashReports', false); + expect(telemetry).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + eventType: 'init', + error: undefined, + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: false }) + ); + }); + it('does not send error message when cli opt out is passed', async () => { await expect(async () => withTelemetry('dev', { cliOptions: { disableTelemetry: true }, printError: vi.fn() }, run) @@ -383,6 +441,67 @@ describe('sendTelemetryError', () => { }) ); }); + + it('does not prompt for non-blocking init errors without cached consent', async () => { + const options: any = { + cliOptions: {}, + skipPrompt: false, + }; + const mockError = new Error('Init non-blocking error'); + + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(cache.get).mockResolvedValueOnce(undefined); + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); + setStdoutIsTTY(true); + + await sendTelemetryError(mockError, 'init', options, false); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(vi.mocked(cache.set).mock.calls).not.toContainEqual([ + 'enableCrashReports', + expect.anything(), + ]); + expect(telemetry).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + eventType: 'init', + blocking: false, + error: undefined, + isErrorInstance: true, + }), + expect.objectContaining({ + enableCrashReports: false, + immediate: true, + }) + ); + }); + + it('uses cached crash report consent for non-blocking init errors', async () => { + const options: any = { + cliOptions: {}, + skipPrompt: false, + }; + const mockError = new Error('Init non-blocking error'); + + vi.mocked(cache.get).mockResolvedValueOnce(true); + + await sendTelemetryError(mockError, 'init', options, false); + + expect(prompt.confirm).not.toHaveBeenCalled(); + expect(telemetry).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + eventType: 'init', + blocking: false, + error: expect.objectContaining({ message: 'Init non-blocking error', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ + enableCrashReports: true, + immediate: true, + }) + ); + }); }); describe('getErrorLevel', () => { @@ -412,6 +531,7 @@ describe('getErrorLevel', () => { }, presetOptions: undefined, skipPrompt: false, + eventType: 'dev', }; const errorLevel = await getErrorLevel(options); @@ -419,6 +539,52 @@ describe('getErrorLevel', () => { expect(errorLevel).toBe('error'); }); + it('returns "full" for init when presetOptions are not provided and prompt is accepted', async () => { + const options: any = { + cliOptions: { + disableTelemetry: false, + }, + presetOptions: undefined, + skipPrompt: false, + eventType: 'init', + }; + + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(cache.get).mockResolvedValueOnce(undefined); + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); + setStdoutIsTTY(true); + + const errorLevel = await getErrorLevel(options); + + expect(errorLevel).toBe('full'); + expect(loadAllPresets).not.toHaveBeenCalled(); + expect(prompt.confirm).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('enableCrashReports', true); + }); + + it('returns "error" for init when presetOptions are not provided and prompt is rejected', async () => { + const options: any = { + cliOptions: { + disableTelemetry: false, + }, + presetOptions: undefined, + skipPrompt: false, + eventType: 'init', + }; + + vi.mocked(isCI).mockReturnValue(false); + vi.mocked(cache.get).mockResolvedValueOnce(undefined); + vi.mocked(prompt.confirm).mockResolvedValueOnce(false); + setStdoutIsTTY(true); + + const errorLevel = await getErrorLevel(options); + + expect(errorLevel).toBe('error'); + expect(loadAllPresets).not.toHaveBeenCalled(); + expect(prompt.confirm).toHaveBeenCalledTimes(1); + expect(cache.set).toHaveBeenCalledWith('enableCrashReports', false); + }); + it('returns "full" when core.enableCrashReports is true', async () => { const options: any = { cliOptions: { diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index a7e77bd42e83..a8eb4738fcbf 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -16,6 +16,7 @@ type TelemetryOptions = { presetOptions?: Parameters[0]; printError?: (err: any) => void; skipPrompt?: boolean; + eventType?: EventType; }; const promptCrashReports = async () => { @@ -40,29 +41,30 @@ export async function getErrorLevel({ cliOptions, presetOptions, skipPrompt, + eventType, }: TelemetryOptions): Promise { if (cliOptions.disableTelemetry) { return 'none'; } - // If we are running init or similar, we just have to go with true here - if (!presetOptions) { + if (!presetOptions && eventType !== 'init') { return 'error'; } - // should we load the preset? - const presets = await loadAllPresets(presetOptions); + if (presetOptions) { + const presets = await loadAllPresets(presetOptions); - // If the user has chosen to enable/disable crash reports in main.js - // or disabled telemetry, we can return that - const core = await presets.apply('core'); + // If the user has chosen to enable/disable crash reports in main.js + // or disabled telemetry, we can return that + const core = await presets.apply('core'); - if (core?.enableCrashReports !== undefined) { - return core.enableCrashReports ? 'full' : 'error'; - } + if (core?.enableCrashReports !== undefined) { + return core.enableCrashReports ? 'full' : 'error'; + } - if (core?.disableTelemetry) { - return 'none'; + if (core?.disableTelemetry) { + return 'none'; + } } // Deal with typo, remove in future version (7.1?) @@ -96,7 +98,11 @@ export async function sendTelemetryError( try { let errorLevel = 'error'; try { - errorLevel = await getErrorLevel(options); + errorLevel = await getErrorLevel({ + ...options, + eventType, + skipPrompt: options.skipPrompt || (eventType === 'init' && !blocking), + }); } catch (err) { // If this throws, eg. due to main.js breaking, we fall back to 'error' } diff --git a/code/core/src/csf/story.ts b/code/core/src/csf/story.ts index 3351d30e2112..53c04a4e950d 100644 --- a/code/core/src/csf/story.ts +++ b/code/core/src/csf/story.ts @@ -584,8 +584,8 @@ export type StoryAnnotationsOrFn = Meta extends { render?: ArgsStoryFn; - loaders?: (infer Loaders)[] | infer Loaders; - decorators?: (infer Decorators)[] | infer Decorators; + loaders?: (infer Loaders)[] | (infer Loaders); + decorators?: (infer Decorators)[] | (infer Decorators); } ? Simplify< RemoveIndexSignature< diff --git a/code/core/src/manager-api/modules/stories.ts b/code/core/src/manager-api/modules/stories.ts index dfd4e08e7f6c..15803f447faa 100644 --- a/code/core/src/manager-api/modules/stories.ts +++ b/code/core/src/manager-api/modules/stories.ts @@ -70,24 +70,22 @@ const STORY_INDEX_PATH = './index.json'; const TAGS_FILTER = 'tags-filter'; const STATIC_FILTER = 'static-filter'; -export const getDefaultTagsFromPreset = memoize(1)( - ( - presets: TagsOptions - ): { - included: Tag[]; - excluded: Tag[]; - } => { - const presetEntries = Object.entries(presets); - return { - included: presetEntries - .filter(([, option]) => option.defaultFilterSelection === 'include') - .map(([tag]) => tag), - excluded: presetEntries - .filter(([, option]) => option.defaultFilterSelection === 'exclude') - .map(([tag]) => tag), - }; - } -); +export const getDefaultTagsFromPreset = memoize(1)(( + presets: TagsOptions +): { + included: Tag[]; + excluded: Tag[]; +} => { + const presetEntries = Object.entries(presets); + return { + included: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'include') + .map(([tag]) => tag), + excluded: presetEntries + .filter(([, option]) => option.defaultFilterSelection === 'exclude') + .map(([tag]) => tag), + }; +}); const computeStaticFilterFn = (tagPresets: TagsOptions) => { const staticExcludeTags = Object.entries(tagPresets).reduce( diff --git a/code/frameworks/angular/src/client/angular-beta/ComputesTemplateFromComponent.test.ts b/code/frameworks/angular/src/client/angular-beta/ComputesTemplateFromComponent.test.ts index 758ac2d93d72..06832be6a3de 100644 --- a/code/frameworks/angular/src/client/angular-beta/ComputesTemplateFromComponent.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/ComputesTemplateFromComponent.test.ts @@ -47,7 +47,9 @@ describe('angular template decorator', () => { describe('with component without selector', () => { @Component({ - template: `The content`, + template: ` + The content + `, }) class WithoutSelectorComponent {} @@ -319,7 +321,9 @@ describe('angular source decorator', () => { describe('with component without selector', () => { @Component({ - template: `The content`, + template: ` + The content + `, }) class WithoutSelectorComponent {} diff --git a/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts b/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts index 6411cdcb81a2..7492fe452727 100644 --- a/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts +++ b/code/frameworks/angular/src/client/angular-beta/StorybookModule.test.ts @@ -18,12 +18,12 @@ describe('StorybookModule', () => { selector: 'foo', template: `

      {{ input }}

      -

      {{ localPropertyName }}

      -

      {{ setterCallNb }}

      -

      {{ localProperty }}

      -

      {{ localFunction() }}

      -

      -

      +

      {{ localPropertyName }}

      +

      {{ setterCallNb }}

      +

      {{ localProperty }}

      +

      {{ localFunction() }}

      +

      +

      `, }) class FooComponent { @@ -297,7 +297,9 @@ describe('StorybookModule', () => { describe('with component without selector', () => { @Component({ - template: `The content`, + template: ` + The content + `, }) class WithoutSelectorComponent {} @@ -334,7 +336,9 @@ describe('StorybookModule', () => { it('should keep template with an empty value', async () => { @Component({ selector: 'foo', - template: `Should not be displayed`, + template: ` + Should not be displayed + `, }) class FooComponent {} diff --git a/code/frameworks/angular/src/client/csf-factories.test.ts b/code/frameworks/angular/src/client/csf-factories.test.ts index 8e2ab022256b..6fb17d00869a 100644 --- a/code/frameworks/angular/src/client/csf-factories.test.ts +++ b/code/frameworks/angular/src/client/csf-factories.test.ts @@ -10,7 +10,9 @@ import type { Decorator } from './public-types'; @Component({ selector: 'storybook-button', standalone: true, - template: ``, + template: ` + + `, }) class ButtonComponent { @Input() @@ -297,7 +299,9 @@ it('Components without Props can be used', () => { @Component({ selector: 'storybook-simple', standalone: true, - template: `
      Simple
      `, + template: ` +
      Simple
      + `, }) class SimpleComponent {} @@ -322,14 +326,16 @@ it('Signal components can be used', () => { standalone: false, // Needs to be a different name to the CLI template button selector: 'storybook-signal-button', - template: ` `, + template: ` + + `, }) class SignalButtonComponent { /** Is this the principal call to action on the page? */ diff --git a/code/frameworks/angular/src/client/decorateStory.test.ts b/code/frameworks/angular/src/client/decorateStory.test.ts index 112c36eb0cfa..88266b818489 100644 --- a/code/frameworks/angular/src/client/decorateStory.test.ts +++ b/code/frameworks/angular/src/client/decorateStory.test.ts @@ -328,13 +328,17 @@ function makeContext(input: Record): StoryContext`, + template: ` + + `, }) class ParentComponent { @Input() diff --git a/code/frameworks/react-vite/package.json b/code/frameworks/react-vite/package.json index eb2197b00757..7b2a9a877bca 100644 --- a/code/frameworks/react-vite/package.json +++ b/code/frameworks/react-vite/package.json @@ -52,7 +52,7 @@ "!src/**/*" ], "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "workspace:*", "@storybook/react": "workspace:*", diff --git a/code/frameworks/react-vite/src/plugins/docgen-resolver.ts b/code/frameworks/react-vite/src/plugins/docgen-resolver.ts index f4ae37407c09..cd17a5d62c4f 100644 --- a/code/frameworks/react-vite/src/plugins/docgen-resolver.ts +++ b/code/frameworks/react-vite/src/plugins/docgen-resolver.ts @@ -55,9 +55,16 @@ export function defaultLookupModule(filename: string, basedir: string): string { switch (ext) { case '.js': case '.mjs': - case '.cjs': - newFilename = `${filename.slice(0, -2)}ts`; + case '.cjs': { + // Try .ts first, then fall back to .tsx (for React components using ESM-style .js imports) + const base = filename.slice(0, -2); + try { + return resolve.sync(`${base}ts`, { ...resolveOptions, extensions: [] }); + } catch { + newFilename = `${base}tsx`; + } break; + } case '.jsx': newFilename = `${filename.slice(0, -3)}tsx`; diff --git a/code/frameworks/web-components-vite/template/cli/js/Header.js b/code/frameworks/web-components-vite/template/cli/js/Header.js index 19fea63604a8..7b82fad65a5d 100644 --- a/code/frameworks/web-components-vite/template/cli/js/Header.js +++ b/code/frameworks/web-components-vite/template/cli/js/Header.js @@ -26,19 +26,21 @@ export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => html`

      Acme

      - ${user - ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) - : html`${Button({ - size: 'small', - onClick: onLogin, - label: 'Log in', - })} + ${ + user + ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) + : html`${Button({ + size: 'small', + onClick: onLogin, + label: 'Log in', + })} ${Button({ primary: true, size: 'small', onClick: onCreateAccount, label: 'Sign up', - })}`} + })}` + }
      diff --git a/code/frameworks/web-components-vite/template/cli/ts/Header.ts b/code/frameworks/web-components-vite/template/cli/ts/Header.ts index 7c3c8b89375a..b67a3e99007c 100644 --- a/code/frameworks/web-components-vite/template/cli/ts/Header.ts +++ b/code/frameworks/web-components-vite/template/cli/ts/Header.ts @@ -37,19 +37,21 @@ export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps

      Acme

      - ${user - ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) - : html`${Button({ - size: 'small', - onClick: onLogin, - label: 'Log in', - })} + ${ + user + ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) + : html`${Button({ + size: 'small', + onClick: onLogin, + label: 'Log in', + })} ${Button({ primary: true, size: 'small', onClick: onCreateAccount, label: 'Sign up', - })}`} + })}` + }
      diff --git a/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts index 7289e65e8cfb..fb62ae118f91 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts @@ -21,6 +21,7 @@ vi.mock('storybook/internal/common', async (importOriginal) => { ...mod, getAddonNames: vi.fn(), removeAddon: vi.fn(), + formatFileContent: vi.fn((_path: string, content: string) => content), }; }); @@ -205,37 +206,34 @@ describe('transformPreviewFile', () => { it('should add spyOn import and remove addon-console import', async () => { const source = dedent` import "@storybook/addon-console"; - + export default {}; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: function beforeEach() { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: function beforeEach() { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should add console spies to beforeEach function', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { beforeEach: () => { // existing code @@ -243,96 +241,87 @@ describe('transformPreviewFile', () => { }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: () => { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - - `; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: () => { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should create beforeEach function if it does not exist', async () => { const source = dedent` import "@storybook/addon-console"; - + export default {}; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: function beforeEach() { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: function beforeEach() { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should handle arrow function beforeEach with expression body', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { beforeEach: () => someFunction() }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; + const result = await transformPreviewFile(source, '.storybook/preview.ts'); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; export default { beforeEach: () => { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); return someFunction(); - }, - }; - - `; - - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + } + };" + `); }); it('should handle arrow function beforeEach with block body', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { beforeEach: () => { // existing setup @@ -341,35 +330,32 @@ describe('transformPreviewFile', () => { }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: () => { - // existing setup - setupTest(); - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: () => { + // existing setup + setupTest(); + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should handle beforeEach function', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { beforeEach: function() { // existing code @@ -378,35 +364,32 @@ describe('transformPreviewFile', () => { }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: function () { - // existing code - setupSomething(); - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: function() { + // existing code + setupSomething(); + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should handle existing beforeEach function declaration', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { beforeEach: function beforeEach() { // existing setup @@ -415,96 +398,87 @@ describe('transformPreviewFile', () => { }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: function beforeEach() { - // existing setup - initializeTest(); - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: function beforeEach() { + // existing setup + initializeTest(); + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should preserve existing spyOn import if present', async () => { const source = dedent` import { spyOn } from "storybook/test"; import "@storybook/addon-console"; - + export default {}; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - beforeEach: function beforeEach() { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; - -`; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; + + export default { + beforeEach: function beforeEach() { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); it('should create named export for beforeEach when no default export exists', async () => { const source = dedent` import "@storybook/addon-console"; - + export const parameters = {}; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export const parameters = {}; - - export const beforeEach = function beforeEach() { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }; + const result = await transformPreviewFile(source, '.storybook/preview.ts'); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; -`; + export const parameters = {}; - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + export const beforeEach = function beforeEach() { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + };" + `); }); it('should add beforeEach to default export object when no beforeEach exists', async () => { const source = dedent` import "@storybook/addon-console"; - + export default { parameters: { layout: 'centered' @@ -512,30 +486,27 @@ describe('transformPreviewFile', () => { }; `; - const target = dedent` - import { spyOn } from 'storybook/test'; - - export default { - parameters: { - layout: 'centered', - }, - - beforeEach: function beforeEach() { - spyOn(console, 'log').mockName('console.log'); - spyOn(console, 'warn').mockName('console.warn'); - spyOn(console, 'error').mockName('console.error'); - spyOn(console, 'info').mockName('console.info'); - spyOn(console, 'debug').mockName('console.debug'); - spyOn(console, 'trace').mockName('console.trace'); - spyOn(console, 'count').mockName('console.count'); - spyOn(console, 'dir').mockName('console.dir'); - spyOn(console, 'assert').mockName('console.assert'); - }, - }; + const result = await transformPreviewFile(source, '.storybook/preview.ts'); + expect(result).toMatchInlineSnapshot(` + "import { spyOn } from "storybook/test"; - `; + export default { + parameters: { + layout: 'centered' + }, - const result = await transformPreviewFile(source, '.storybook/preview.ts'); - expect(result).toBe(target); + beforeEach: function beforeEach() { + spyOn(console, "log").mockName("console.log"); + spyOn(console, "warn").mockName("console.warn"); + spyOn(console, "error").mockName("console.error"); + spyOn(console, "info").mockName("console.info"); + spyOn(console, "debug").mockName("console.debug"); + spyOn(console, "trace").mockName("console.trace"); + spyOn(console, "count").mockName("console.count"); + spyOn(console, "dir").mockName("console.dir"); + spyOn(console, "assert").mockName("console.assert"); + } + };" + `); }); }); 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 8c22d4fbba96..a72fef8f8096 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 @@ -1,9 +1,13 @@ -import { describe, expect, it } from 'vitest'; - +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { dedent } from 'ts-dedent'; - +import { formatFileContent } from 'storybook/internal/common'; import { configToCsfFactory } from './config-to-csf-factory'; +vi.mock('storybook/internal/common', { spy: true }); + +beforeEach(() => { + vi.mocked(formatFileContent).mockImplementation(async (_path, content) => content); +}); expect.addSnapshotSerializer({ serialize: (val: any) => (typeof val === 'string' ? val : val.toString()), test: () => true, @@ -28,12 +32,11 @@ describe('main/preview codemod: general parsing functionality', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; - + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: ['@storybook/addon-essentials'], - framework: '@storybook/react-vite', + framework: '@storybook/react-vite' }); `); }); @@ -49,7 +52,7 @@ describe('main/preview codemod: general parsing functionality', () => { export default config; `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -72,11 +75,11 @@ describe('main/preview codemod: general parsing functionality', () => { ).resolves.toMatchInlineSnapshot(` // @ts-check /** @license MIT */ - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], - framework: '@storybook/react-vite', + framework: '@storybook/react-vite' }); `); }); @@ -100,7 +103,7 @@ describe('main/preview codemod: general parsing functionality', () => { ${variant} `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], @@ -123,14 +126,12 @@ describe('main/preview codemod: general parsing functionality', () => { export default config; `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ tags: [], - viteFinal: () => { - return config; - }, - framework: '@storybook/react-vite', + viteFinal: () => { return config }, + framework: '@storybook/react-vite' }); `); }); @@ -143,29 +144,25 @@ describe('main/preview codemod: general parsing functionality', () => { export const framework = '@storybook/react-vite'; `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ - stories: () => { - return ['../src/**/*.stories.@(js|jsx|ts|tsx)']; - }, + stories: () => { return ['../src/**/*.stories.@(js|jsx|ts|tsx)'] }, addons: ['@storybook/addon-essentials'], - viteFinal: () => { - return config; - }, - framework: '@storybook/react-vite', + viteFinal: () => { return config }, + framework: '@storybook/react-vite' }); `); }); it('should not add additional imports if there is already one', async () => { const transformed = await transform(dedent` - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; const config = {}; export default config; `); expect( - transformed.match(/import { defineMain } from '@storybook\/react-vite\/node'/g) + transformed.match(/import { defineMain } from "@storybook\/react-vite\/node"/g) ).toHaveLength(1); }); @@ -179,6 +176,31 @@ describe('main/preview codemod: general parsing functionality', () => { expect(transformed).toEqual(original); }); + it('should add missing import when already transformed but import is absent', async () => { + await expect( + transform(dedent` + export default defineMain({}); + `) + ).resolves.toMatchInlineSnapshot(` + import { defineMain } from "@storybook/react-vite/node"; + export default defineMain({}); + `); + }); + + it('should add missing specifier when already transformed but defineMain is not in existing import', async () => { + await expect( + transform(dedent` + import { someHelper } from '@storybook/react-vite/node'; + + export default defineMain({}); + `) + ).resolves.toMatchInlineSnapshot(` + import { someHelper, defineMain } from '@storybook/react-vite/node'; + + export default defineMain({}); + `); + }); + it('should remove legacy main config type imports if unused', async () => { await expect( transform(dedent` @@ -190,10 +212,9 @@ describe('main/preview codemod: general parsing functionality', () => { export default config; `) ).resolves.toMatchInlineSnapshot(` - import { defineMain } from '@storybook/react-vite/node'; - + import { defineMain } from "@storybook/react-vite/node"; export default defineMain({ - stories: [], + stories: [] }); `); }); @@ -214,15 +235,15 @@ describe('main/preview codemod: general parsing functionality', () => { export default config; `) ).resolves.toMatchInlineSnapshot(` - import { type StorybookConfig } from '@storybook/react-vite'; - import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from "@storybook/react-vite/node"; + import { type StorybookConfig } from '@storybook/react-vite' const features: StorybookConfig['features'] = { foo: true, }; export default defineMain({ - stories: [], + stories: [] }); `); }); @@ -245,10 +266,9 @@ describe('preview specific functionality', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; - + import { definePreview } from "@storybook/react-vite"; export default definePreview({ - tags: ['test'], + tags: ['test'] }); `); }); @@ -264,10 +284,9 @@ describe('preview specific functionality', () => { export default preview; `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; - + import { definePreview } from "@storybook/react-vite"; export default definePreview({ - tags: [], + tags: [] }); `); }); @@ -285,12 +304,12 @@ describe('preview specific functionality', () => { export default preview; `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; + import { definePreview } from "@storybook/react-vite"; - export const withStore: Decorator = () => {}; + export const withStore: Decorator = () => {} export default definePreview({ - tags: [], + tags: [] }); `); }); @@ -303,11 +322,10 @@ describe('preview specific functionality', () => { } `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; - + import { definePreview } from "@storybook/react-vite"; export default definePreview({ decorators: [1], - parameters: {}, + parameters: {} }); `); }); @@ -333,10 +351,10 @@ describe('preview specific functionality', () => { export default definePreview({ decorators: [], - + parameters: { - options: {}, - }, + options: {} + } }); `); }); @@ -363,8 +381,8 @@ describe('preview specific functionality', () => { decorators: [], parameters: { - options: {}, - }, + options: {} + } }); `); }); @@ -375,18 +393,15 @@ describe('preview specific functionality', () => { import './preview.scss' `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; - - import './preview.scss'; - + 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'; - + import { definePreview } from "@storybook/react-vite"; export default definePreview({}); `); }); @@ -398,11 +413,9 @@ describe('preview specific functionality', () => { import './global.css' `) ).resolves.toMatchInlineSnapshot(` - import { definePreview } from '@storybook/react-vite'; - - import './global.css'; - import './preview.scss'; - + import { definePreview } from "@storybook/react-vite"; + import './preview.scss' + import './global.css' 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 c289c247ff51..f4668a1e46b8 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 @@ -33,6 +33,47 @@ export async function configToCsfFactory( const defineConfigProps = getConfigProperties(exportDecls, { configType }); const hasNamedExports = defineConfigProps.length > 0; + // Early return if the code is already transformed (default export is already defineMain/definePreview) + const isAlreadyTransformed = programNode.body.some((node) => { + if (!t.isExportDefaultDeclaration(node)) return false; + + // Unwrap TS syntax (e.g. `as`, `satisfies`) around the default export expression + const declaration = + typeof (config as any)._unwrap === 'function' + ? (config as any)._unwrap(node.declaration) + : node.declaration; + + return ( + t.isCallExpression(declaration) && + t.isIdentifier(declaration.callee) && + declaration.callee.name === methodName + ); + }); + + // Check whether the required framework import (e.g. defineMain from '@storybook/react-vite/node') is already present + const expectedImportSource = frameworkPackage + (configType === 'main' ? '/node' : ''); + const hasCorrectImport = programNode.body.some( + (node) => + t.isImportDeclaration(node) && + node.importKind !== 'type' && + node.source.value === expectedImportSource && + node.specifiers.some( + (spec) => + t.isImportSpecifier(spec) && + t.isIdentifier(spec.imported) && + spec.imported.name === methodName + ) + ); + + // For main configs, always return early when already transformed and imports are valid. + // For preview configs, only return early when there are no named exports to merge. + const shouldSkipTransform = + configType === 'main' ? isAlreadyTransformed : isAlreadyTransformed && !hasNamedExports; + + if (shouldSkipTransform && hasCorrectImport) { + return info.source; + } + function findDeclarationNodeIndex(declarationName: string): number { return programNode.body.findIndex( (n) => @@ -52,19 +93,21 @@ export async function configToCsfFactory( ); } - /** - * Scenario 1: Mixed exports - * - * ``` - * export const tags = []; - * export default { - * parameters: {}, - * }; - * ``` - * - * Transform into: `export default defineMain({ tags: [], parameters: {} })` - */ - if (config._exportsObject && hasNamedExports) { + if (shouldSkipTransform) { + // already transformed — skip transformation but still run import fixup below + } else if (config._exportsObject && hasNamedExports) { + /** + * Scenario 1: Mixed exports + * + * ``` + * export const tags = []; + * export default { + * parameters: {}, + * }; + * ``` + * + * Transform into: `export default defineMain({ tags: [], parameters: {} })` + */ // when merging named exports with default exports, add the named exports first in the list config._exportsObject.properties = [...defineConfigProps, ...config._exportsObject.properties]; programNode.body = removeExportDeclarations(programNode, exportDecls); 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 1855be5d9885..522bc45ded87 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 @@ -38,9 +38,8 @@ describe('stories codemod', () => { export const A = {}; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - const meta = preview.meta({ title: 'Component' }); + import preview from "#.storybook/preview"; + const meta = preview.meta({ title: "Component" }); export const A = meta.story(); `); }); @@ -59,10 +58,13 @@ describe('stories codemod', () => { `) ).resolves.toMatchInlineSnapshot(` // @ts-check - /** @license MIT Copyright 2024 */ - import preview from '#.storybook/preview'; + /** + * @license MIT + * Copyright 2024 + */ + import preview from "#.storybook/preview"; - const meta = preview.meta({ title: 'Component' }); + const meta = preview.meta({ title: "Component" }); export const A = meta.story(); `); }); @@ -74,10 +76,10 @@ describe('stories codemod', () => { export const A = {}; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; const meta = preview.meta({ - title: 'Component', + title: "Component", }); export const A = meta.story(); @@ -92,9 +94,8 @@ describe('stories codemod', () => { export const A = {}; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - const componentMeta = preview.meta({ title: 'Component' }); + import preview from "#.storybook/preview"; + const componentMeta = preview.meta({ title: "Component" }); export const A = componentMeta.story(); `); }); @@ -110,9 +111,8 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - const componentMeta = preview.meta({ title: 'Component' }); + import preview from "#.storybook/preview"; + const componentMeta = preview.meta({ title: "Component" }); export const A = componentMeta.story({ args: { primary: true }, render: (args) => , @@ -132,9 +132,8 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import preview, { decorators } from '#.storybook/preview'; - - const componentMeta = preview.meta({ title: 'Component' }); + import preview, { decorators } from "#.storybook/preview"; + const componentMeta = preview.meta({ title: "Component" }); export const A = componentMeta.story({ args: { primary: true }, render: (args) => , @@ -154,9 +153,8 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import previewConfig from '#.storybook/preview'; - - const componentMeta = previewConfig.meta({ title: 'Component' }); + import previewConfig from "#.storybook/preview"; + const componentMeta = previewConfig.meta({ title: "Component" }); export const A = componentMeta.story({ args: { primary: true }, render: (args) => , @@ -176,9 +174,8 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import storybookPreview from '#.storybook/preview'; - - const componentMeta = storybookPreview.meta({ title: 'Component' }); + import storybookPreview from "#.storybook/preview"; + const componentMeta = storybookPreview.meta({ title: "Component" }); const preview = {}; export const A = componentMeta.story({ args: { primary: true }, @@ -212,10 +209,10 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; const meta = preview.meta({ - title: 'Component', + title: "Component", }); const someData = {}; @@ -269,9 +266,8 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - const myMeta = preview.meta({ title: 'Component', args: {} }); + import preview from "#.storybook/preview"; + const myMeta = preview.meta({ title: "Component", args: {} }); const metaProperties = { ...myMeta.input, @@ -324,13 +320,12 @@ describe('stories codemod', () => { }; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import * as BaseStories from './Button.stories'; - import { Primary as ImportedPrimary } from './Card.stories'; + import preview from "#.storybook/preview"; + import * as BaseStories from "./Button.stories"; + import { Primary as ImportedPrimary } from "./Card.stories"; const meta = preview.meta({ - title: 'Component', + title: "Component", }); export const A = meta.story({ @@ -341,7 +336,7 @@ describe('stories codemod', () => { ...BaseStories.Secondary.input, args: { ...BaseStories.Secondary.input.args, - label: 'Custom', + label: "Custom", }, }); @@ -367,7 +362,7 @@ describe('stories codemod', () => { export const D = A.extends({}); `) ).resolves.toMatchInlineSnapshot(` - export default { title: 'Component' }; + export default { title: "Component" }; export const A = {}; export const B = { play: async () => { @@ -443,8 +438,7 @@ describe('stories codemod', () => { ) ) ).resolves.toMatchInlineSnapshot(` - import preview, { extra } from '#.storybook/preview'; - + import preview, { extra } from "#.storybook/preview"; const meta = preview.meta({}); export const A = meta.story(); `); @@ -465,8 +459,7 @@ describe('stories codemod', () => { ) ) ).resolves.toMatchInlineSnapshot(` - import preview, { extra } from '../../preview'; - + import preview, { extra } from "../../preview"; const meta = preview.meta({}); export const A = meta.story(); `); @@ -483,9 +476,8 @@ describe('stories codemod', () => { export const CSF1Story = () =>
      Hello
      ; `) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - const meta = preview.meta({ title: 'Component' }); + import preview from "#.storybook/preview"; + const meta = preview.meta({ title: "Component" }); export const CSF1Story = meta.story(() =>
      Hello
      ); `); }); @@ -504,11 +496,10 @@ describe('stories codemod', () => { `; it('meta satisfies syntax', async () => { await expect(transform(inlineMetaSatisfies)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - import { ComponentProps } from './Component'; - - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -528,11 +519,10 @@ describe('stories codemod', () => { `; it('meta as syntax', async () => { await expect(transform(inlineMetaAs)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - import { ComponentProps } from './Component'; - - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -552,11 +542,10 @@ describe('stories codemod', () => { `; it('meta satisfies syntax', async () => { await expect(transform(metaSatisfies)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import { ComponentProps } from './Component'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -577,11 +566,10 @@ describe('stories codemod', () => { `; it('meta type syntax', async () => { await expect(transform(metaTypeDef)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import { ComponentProps } from './Component'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -602,11 +590,10 @@ describe('stories codemod', () => { `; it('meta as syntax', async () => { await expect(transform(metaAs)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import { ComponentProps } from './Component'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -627,11 +614,10 @@ describe('stories codemod', () => { `; it('story satisfies syntax', async () => { await expect(transform(storySatisfies)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import { ComponentProps } from './Component'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -652,11 +638,10 @@ describe('stories codemod', () => { `; it('story as syntax', async () => { await expect(transform(storyAs)).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; - import { ComponentProps } from './Component'; - - const meta = preview.meta({ title: 'Component', component: Component }); + const meta = preview.meta({ title: "Component", component: Component }); export const A = meta.story({ args: { primary: true }, @@ -691,9 +676,8 @@ describe('stories codemod', () => { export const A: Story = {};` ) ).resolves.toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; - - import { ComponentProps } from './Component'; + import preview from "#.storybook/preview"; + import { ComponentProps } from "./Component"; const meta = preview.meta({}); @@ -724,7 +708,7 @@ describe('stories codemod', () => { expect(result).not.toContain('UnusedAndShouldBeRemoved'); expect(result).toMatchInlineSnapshot(` - import preview from '#.storybook/preview'; + import preview from "#.storybook/preview"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Data = Record; @@ -733,7 +717,7 @@ describe('stories codemod', () => { } const meta = preview.meta({ - title: 'Table', + title: "Table", }); export const A = meta.story({ @@ -762,17 +746,15 @@ describe('stories codemod', () => { export const A = {}; `) ).resolves.toMatchInlineSnapshot(` - import { Meta } from '@storybook/react'; - - import preview from '#.storybook/preview'; - - import { Button } from './Button'; + import preview from "#.storybook/preview"; + import { Meta } from "@storybook/react"; + import { Button } from "./Button"; type ThisShouldNotBeRemoved = Meta; const something: ThisShouldNotBeRemoved = {}; const meta = preview.meta({ - title: 'Button', + title: "Button", }); export const A = meta.story(); diff --git a/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap b/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap index b6690df75b31..a7744d638370 100644 --- a/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap +++ b/code/lib/codemod/src/transforms/__tests__/__snapshots__/upgrade-deprecated-types.test.ts.snap @@ -12,15 +12,16 @@ Rename this local import and try again. exports[`upgrade-deprecated-types > typescript > upgrade imports with local names 1`] = ` import { + StoryFn as Story_, Meta as ComponentMeta_, StoryObj as ComponentStoryObj_, - StoryFn as Story_, -} from '@storybook/react'; - -import { Cat } from './Cat'; +} from "@storybook/react"; +import { Cat } from "./Cat"; -const meta = { title: 'Cat', component: Cat } satisfies ComponentMeta_; -const meta2: ComponentMeta_ = { title: 'Cat', component: Cat }; +const meta = { title: "Cat", component: Cat } satisfies ComponentMeta_< + typeof Cat +>; +const meta2: ComponentMeta_ = { title: "Cat", component: Cat }; export default meta; export const A: Story__ = (args) => ; @@ -28,19 +29,18 @@ export const B: any = (args) => `; + return html` + + `; } } declare global { @@ -102,10 +104,18 @@ describe('Args can be provided in multiple ways', () => { args: { label: 'good' }, }); const Basic = meta.story({ - render: () => html`
      Hello world
      `, + render: () => + html` +
      Hello world
      + `, }); - const CSF1 = meta.story(() => html`
      Hello world
      `); + const CSF1 = meta.story( + () => + html` +
      Hello world
      + ` + ); }); it('❌ Required args need to be provided when the user uses a non-empty render', () => { @@ -118,7 +128,10 @@ describe('Args can be provided in multiple ways', () => { args: { label: 'good', }, - render: (args) => html`
      Hello world
      `, + render: (args) => + html` +
      Hello world
      + `, }); }); }); diff --git a/code/renderers/web-components/src/docs/__testfixtures__/lit-element-demo-card/input.js b/code/renderers/web-components/src/docs/__testfixtures__/lit-element-demo-card/input.js index c2a514d77453..02c95545cb96 100644 --- a/code/renderers/web-components/src/docs/__testfixtures__/lit-element-demo-card/input.js +++ b/code/renderers/web-components/src/docs/__testfixtures__/lit-element-demo-card/input.js @@ -161,9 +161,10 @@ export class DemoWcCard extends LitElement {
      ${this.header}
      - ${this.rows.length === 0 - ? html`` - : html` + ${ + this.rows.length === 0 + ? html`` + : html`
      ${this.rows.map( (row) => html` @@ -172,7 +173,8 @@ export class DemoWcCard extends LitElement { ` )}
      - `} + ` + }
      - ${user - ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) - : html`${Button({ - size: 'small', - onClick: onLogin, - label: 'Log in', - })} + ${ + user + ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) + : html`${Button({ + size: 'small', + onClick: onLogin, + label: 'Log in', + })} ${Button({ primary: true, size: 'small', onClick: onCreateAccount, label: 'Sign up', - })}`} + })}` + }
      diff --git a/code/renderers/web-components/template/cli/ts/Header.ts b/code/renderers/web-components/template/cli/ts/Header.ts index 7c3c8b89375a..b67a3e99007c 100644 --- a/code/renderers/web-components/template/cli/ts/Header.ts +++ b/code/renderers/web-components/template/cli/ts/Header.ts @@ -37,19 +37,21 @@ export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps

      Acme

      - ${user - ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) - : html`${Button({ - size: 'small', - onClick: onLogin, - label: 'Log in', - })} + ${ + user + ? Button({ size: 'small', onClick: onLogout, label: 'Log out' }) + : html`${Button({ + size: 'small', + onClick: onLogin, + label: 'Log in', + })} ${Button({ primary: true, size: 'small', onClick: onCreateAccount, label: 'Sign up', - })}`} + })}` + }
      diff --git a/code/renderers/web-components/template/stories/demo-wc-card/DemoWcCard.js b/code/renderers/web-components/template/stories/demo-wc-card/DemoWcCard.js index 0da2dea93d1b..212a1a7463be 100644 --- a/code/renderers/web-components/template/stories/demo-wc-card/DemoWcCard.js +++ b/code/renderers/web-components/template/stories/demo-wc-card/DemoWcCard.js @@ -67,9 +67,10 @@ export class DemoWcCard extends LitElement {
      ${this.header}
      - ${this.rows.length === 0 - ? html`` - : html` + ${ + this.rows.length === 0 + ? html`` + : html`
      ${this.rows.map( (row) => html` @@ -78,7 +79,8 @@ export class DemoWcCard extends LitElement { ` )}
      - `} + ` + }