diff --git a/.github/actions/setup-node-and-install/action.yml b/.github/actions/setup-node-and-install/action.yml index 754d7dcba690..fb7fd0a25248 100644 --- a/.github/actions/setup-node-and-install/action.yml +++ b/.github/actions/setup-node-and-install/action.yml @@ -11,7 +11,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' @@ -23,7 +23,7 @@ runs: # runner-internal token that bypasses `permissions:`, so splitting is the only # reliable way to prevent pull_request_target runs from poisoning the shared cache. - name: Restore cached dependencies - uses: actions/cache/restore@v4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.yarn/berry/cache @@ -45,7 +45,7 @@ runs: - name: Save cached dependencies if: github.event_name != 'pull_request_target' - uses: actions/cache/save@v4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.yarn/berry/cache diff --git a/.github/workflows/generate-sandboxes.yml b/.github/workflows/generate-sandboxes.yml index 5a7a2feca4ef..431edb241922 100644 --- a/.github/workflows/generate-sandboxes.yml +++ b/.github/workflows/generate-sandboxes.yml @@ -76,7 +76,7 @@ jobs: ref: ${{ matrix.branch }} persist-credentials: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e #6.4.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 2cbc6c7fda4d..aa6e37c7b2d7 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -6,14 +6,14 @@ on: pull_request: branches: ['**'] -permissions: {} +permissions: + contents: read + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. jobs: zizmor: name: zizmor latest via PyPI runs-on: ubuntu-latest - permissions: - security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -27,8 +27,6 @@ jobs: - name: Run zizmor 🌈 run: uvx zizmor --format=sarif . > results.sarif - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 diff --git a/AGENTS.md b/AGENTS.md index 8f3fe3c7a53c..2529530c495d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -258,7 +258,7 @@ Do **not** use `/tmp` paths or replace `node:fs/promises` with a full async fact After changing files: -1. Format with `yarn fmt:write` (run from the repo root) +1. **Always** format with `yarn fmt:write`, run from the `code/` directory (`cd code && yarn fmt:write`), once you are done editing. The repo uses `oxfmt`, so hand-written formatting will frequently be wrong — do not skip this step. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7706ff53bb7..fb06847e79c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 10.4.4 + +- Preview: Stop mixed CSF3+4 stories getting core annotations injected twice - [#35094](https://github.com/storybookjs/storybook/pull/35094), thanks @JReinhold! +- Telemetry: Add timeout to event-log POST to prevent build hang - [#35085](https://github.com/storybookjs/storybook/pull/35085), thanks @badams! + +## 10.4.3 + +- Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages - [#34496](https://github.com/storybookjs/storybook/pull/34496), thanks @NYCU-Chung! +- Core: Respect !dev tag on MDX docs in sidebar - [#35031](https://github.com/storybookjs/storybook/pull/35031), thanks @JReinhold! +- React: Add support for resolving subcomponents attached as properties of a parent component - [#34967](https://github.com/storybookjs/storybook/pull/34967), thanks @yatishgoel! +- UI: Prevent docs page scroll reset on HMR re-render - [#35021](https://github.com/storybookjs/storybook/pull/35021), thanks @LongTangGithub! + ## 10.4.2 - Bug: Fix Windows command resolution for non-Node package managers - [#33534](https://github.com/storybookjs/storybook/pull/33534), thanks @copilot-swe-agent! diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 41f183bba3a7..fc15550429b4 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,14 @@ +## 10.5.0-alpha.7 + +- CLI: Add `storybook ai ` MCP passthrough behind `STORYBOOK_FEATURE_AI_CLI` - [#35125](https://github.com/storybookjs/storybook/pull/35125), thanks @kasperpeulen! +- CLI: Add telemetry for the `storybook ai ` passthrough - [#35138](https://github.com/storybookjs/storybook/pull/35138), thanks @kasperpeulen! +- CLI: Bundle the `ai` command in core so it never downloads `@storybook/cli` - [#35147](https://github.com/storybookjs/storybook/pull/35147), thanks @kasperpeulen! +- Docgen: Register service runtime, payload argTypes - [#35108](https://github.com/storybookjs/storybook/pull/35108), thanks @JReinhold! +- Docs: Route ArgTypes and Controls through docgen service when flag enabled - [#35109](https://github.com/storybookjs/storybook/pull/35109), thanks @JReinhold! +- Open Service: Mark module-graph engine commands as internal - [#35144](https://github.com/storybookjs/storybook/pull/35144), thanks @JReinhold! +- Telemetry: Preserve state machine when the module loads more than once - [#35140](https://github.com/storybookjs/storybook/pull/35140), thanks @kasperpeulen! +- Viewport: Warn when legacy `defaultViewport` parameter is used - [#35087](https://github.com/storybookjs/storybook/pull/35087), thanks @yatishgoel! + ## 10.5.0-alpha.6 - Controls: Guard normalizeOptions against array labels and prototype chain lookups - [#34664](https://github.com/storybookjs/storybook/pull/34664), thanks @creazyfrog! diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 370de8019a35..cbe4aee426f2 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -156,7 +156,9 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, - experimentalDocgenServer: true, + // Disabled for now: the docgen service does not yet work in production builds. Keeping it off + // ensures this branch exercises the normal (non-experimental) docgen path without regressions. + experimentalDocgenServer: false, experimentalReactComponentMeta: true, changeDetection: true, }, diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.mdx b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx new file mode 100644 index 000000000000..cee0bd2720af --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx @@ -0,0 +1,73 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ArgTypesStories from './ArgTypes.stories.tsx'; +import * as ExampleStories from '../examples/ArgTypesParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; + +import { ArgTypes } from './ArgTypes'; + + + +# ArgTypes block (MDX) + +Each section below uses the `ArgTypes` block the same way as the matching story in `ArgTypes.stories.tsx`. + +## OfComponent + + + +## OfMeta + + + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfMeta + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx index 63f493e39313..fb9d13bc8f11 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx @@ -1,16 +1,13 @@ -import React from 'react'; - +/** Custom docs page: {@link ./ArgTypes.mdx} (attached via ``). */ import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { within } from 'storybook/test'; - import * as ExampleStories from '../examples/ArgTypesParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; import { ArgTypes } from './ArgTypes'; -const meta: Meta = { +const meta = { title: 'Blocks/ArgTypes', component: ArgTypes, parameters: { @@ -21,7 +18,8 @@ const meta: Meta = { ], docsStyles: true, }, -}; +} satisfies Meta; + export default meta; type Story = StoryObj; @@ -108,7 +106,7 @@ export const Categories: Story = { }; const findSubcomponentTabs = async ( - canvas: ReturnType, + canvas: Parameters>[0]['canvas'], step: PlayFunctionContext['step'] ) => { let subcomponentATab: HTMLElement | null = null; @@ -124,17 +122,18 @@ export const SubcomponentsOfMeta: Story = { args: { of: SubcomponentsExampleStories.default, }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { await findSubcomponentTabs(canvas, step); }, }; export const SubcomponentsOfStory: Story = { - ...SubcomponentsOfMeta, args: { of: SubcomponentsExampleStories.NoParameters, }, + play: async ({ canvas, step }) => { + await findSubcomponentTabs(canvas, step); + }, }; export const SubcomponentsIncludeProp: Story = { @@ -142,8 +141,7 @@ export const SubcomponentsIncludeProp: Story = { of: SubcomponentsExampleStories.NoParameters, include: ['a', 'f'], }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { const { subcomponentBTab } = await findSubcomponentTabs(canvas, step); if (subcomponentBTab) { await (subcomponentBTab as HTMLElement & { click: () => Promise }).click(); diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index 3d0d343c553a..b9512a060b8c 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -1,9 +1,7 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; import type { ModuleExports } from 'storybook/internal/types'; @@ -11,10 +9,16 @@ import type { PropDescriptor } from 'storybook/preview-api'; import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; -import { useOf } from './useOf'; -import { getComponentName } from './utils'; -import { withMdxComponentOverride } from './with-mdx-component-override'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { + extractComponentArgTypes, + extractSubcomponentArgTypes, + useDocgenServiceRows, +} from './argTypesShared'; +import { DocsContext } from './DocsContext.ts'; +import { useOf } from './useOf.ts'; +import { getComponentName } from './utils.ts'; +import { withMdxComponentOverride } from './with-mdx-component-override.tsx'; type ArgTypesParameters = { include?: PropDescriptor; @@ -25,85 +29,158 @@ type ArgTypesParameters = { type ArgTypesProps = ArgTypesParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + +type ResolvedArgTypes = { + parameters: Parameters; + componentId?: string; + storyId?: string; + initialArgs?: Args; + argTypes?: StrictArgTypes; + component?: Renderer['component']; + subcomponents?: Record; + filterProps: ArgTypesParameters; +}; + +function useResolveArgTypes(props: ArgTypesProps): ResolvedArgTypes { + const { of } = props; + if ('of' in props && of === undefined) { + throw new InvalidBlockOfPropError(); } - return extractArgTypes(component) as StrictArgTypes; -} + const context = useContext(DocsContext); + const resolved = useOf(of || 'meta'); -function getArgTypesFromResolved(resolved: ReturnType) { + let resolvedArgTypes: Omit; if (resolved.type === 'component') { const { component, projectAnnotations: { parameters }, } = resolved; - return { + resolvedArgTypes = { + parameters: parameters as Parameters, + // Bare `of={Component}` has no story/meta annotations; the docgen service is addressed by + // component id, recovered from the CSF file that declares this component. + componentId: context.getComponentId(component), argTypes: extractComponentArgTypes(component, parameters as Parameters), + component, + }; + } else if (resolved.type === 'meta') { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = + resolved.preparedMeta; + resolvedArgTypes = { parameters, + componentId: id.split('--')[0], + initialArgs, + argTypes, component, + subcomponents, + }; + } else { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = resolved.story; + resolvedArgTypes = { + parameters, + componentId: id.split('--')[0], + storyId: id, + initialArgs, + argTypes, + component, + subcomponents, }; } - if (resolved.type === 'meta') { - const { - preparedMeta: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; - } + const argTypesParameters = + resolvedArgTypes.parameters?.docs?.argTypes || ({} as ArgTypesParameters); - // In the case of the story, the enhanceArgs argTypeEnhancer has already added the extracted - // arg types from the component to the prepared story. - const { - story: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; + return { + ...resolvedArgTypes, + filterProps: { + include: props.include ?? argTypesParameters.include, + exclude: props.exclude ?? argTypesParameters.exclude, + sort: props.sort ?? argTypesParameters.sort, + }, + }; } -const ArgTypesImpl: FC = (props) => { - const { of } = props; - if ('of' in props && of === undefined) { - throw new InvalidBlockOfPropError(); +function renderArgTypesTables({ + mainName = 'Main', + mainRows, + subcomponentRows, + include, + exclude, + sort, +}: { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; +}) { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + return ; } - const resolved = useOf(of || 'meta'); - const { argTypes, parameters, component, subcomponents } = getArgTypesFromResolved(resolved); - const argTypesParameters = parameters?.docs?.argTypes || ({} as ArgTypesParameters); - const include = props.include ?? argTypesParameters.include; - const exclude = props.exclude ?? argTypesParameters.exclude; - const sort = props.sort ?? argTypesParameters.sort; + const tabs = { + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), + }; + + return ; +} + +const LegacyArgTypes: FC = (props) => { + const { argTypes, parameters, component, subcomponents, filterProps } = useResolveArgTypes(props); - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); + if (!argTypes) { + return null; + } - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + return renderArgTypesTables({ + mainName: getComponentName(component), + mainRows: argTypes, + subcomponentRows: extractSubcomponentArgTypes(subcomponents, parameters), + ...filterProps, + }); +}; - if (!hasSubcomponents) { - return ; +const DocgenServiceArgTypes: FC = (props) => { + const { argTypes, parameters, componentId, storyId, initialArgs, filterProps, component } = + useResolveArgTypes(props); + const serviceRows = useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes: argTypes, + }); + + if (!serviceRows) { + return null; } - const mainComponentName = getComponentName(component) || 'Main'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes( - extractComponentArgTypes(comp, parameters as Parameters), - include, - exclude - ), - sort, - }, - ]) + return renderArgTypesTables({ + mainName: getComponentName(component) ?? serviceRows.serviceComponentName, + mainRows: serviceRows.mainRows, + subcomponentRows: serviceRows.subcomponentRows, + ...filterProps, + }); +}; + +const ArgTypesImpl: FC = (props) => { + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + ); - const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, - }; - return ; }; export const ArgTypes = withMdxComponentOverride('ArgTypes', ArgTypesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Controls.mdx b/code/addons/docs/src/blocks/blocks/Controls.mdx new file mode 100644 index 000000000000..fec7bba1c16e --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/Controls.mdx @@ -0,0 +1,76 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ControlsStories from './Controls.stories.tsx'; +import * as ExampleStories from '../examples/ControlsParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; +import * as EmptyArgTypesStories from '../examples/EmptyArgTypes.stories'; + +import { Controls } from './Controls'; + + + +# Controls block (MDX) + +Each section below uses the `Controls` block the same way as the matching story in `Controls.stories.tsx`. + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + + +## EmptyArgTypes + + + +## MultipleControlsOnSamePage + + + + +## MultipleControlsForSameStoryOnSamePage + + + diff --git a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx index 8ebaaad093fe..4c917d5dded4 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx @@ -1,10 +1,11 @@ +/** Custom docs page: {@link ./Controls.mdx} (attached via ``). */ import React from 'react'; import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import * as ExampleStories from '../examples/ControlsParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; @@ -129,12 +130,25 @@ export const SubcomponentsRetainControlFocus: Story = { args: { of: SubcomponentsExampleStories.NoParameters, }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('bx') ?? canvas.queryByDisplayValue('b'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'b'); + }; + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const input = await canvas.findByDisplayValue('b'); await userEvent.click(input); await userEvent.type(input, 'x'); - await expect(document.activeElement).toBe(input); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); }, }; diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 9c8a97ce8a63..43291824b083 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -1,18 +1,17 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; import React, { useContext } from 'react'; import { useId } from '@react-aria/utils'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; -import type { ModuleExports } from 'storybook/internal/types'; +import type { Args, Globals, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { DocsContextProps, ModuleExports, PreparedStory } from 'storybook/internal/types'; import { filterArgTypes } from 'storybook/preview-api'; import type { PropDescriptor } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { extractSubcomponentArgTypes, useDocgenServiceRows } from './argTypesShared'; import { DocsContext } from './DocsContext'; import { useArgs } from './useArgs'; import { useGlobals } from './useGlobals'; @@ -30,55 +29,79 @@ type ControlsProps = ControlsParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); - } - return extractArgTypes(component) as StrictArgTypes; +type ControlsStoryProps = ControlsProps & { + story: PreparedStory; + context: DocsContextProps; +}; + +type ControlsInteractiveState = { + controlsId: string; + args: Args; + globals: Globals; + updateArgs: ReturnType[1]; + resetArgs: ReturnType[2]; +}; + +type ControlsTablesProps = ControlsInteractiveState & { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; + storyId: string; +}; + +function getControlsFilterProps(story: PreparedStory, props: ControlsProps): ControlsParameters { + const controlsParameters = story.parameters.docs?.controls || ({} as ControlsParameters); + + return { + include: props.include ?? controlsParameters.include, + exclude: props.exclude ?? controlsParameters.exclude, + sort: props.sort ?? controlsParameters.sort, + }; } -const ControlsImpl: FC = (props) => { - const { of } = props; - const context = useContext(DocsContext); - const primaryStory = usePrimaryStory(); +function useControlsInteractiveState( + story: PreparedStory, + context: DocsContextProps +): ControlsInteractiveState { // Disambiguate multiple blocks rendered for the same story on a single page. // React Aria's useId gives a stable id per component instance, with a polyfill for // React versions that lack the built-in useId. const controlsId = useId(); - - const story = of ? context.resolveOf(of, ['story']).story : primaryStory; - - if (!story) { - return null; - } - - const { parameters, argTypes, component, subcomponents } = story; - const controlsParameters = parameters.docs?.controls || ({} as ControlsParameters); - - const include = props.include ?? controlsParameters.include; - const exclude = props.exclude ?? controlsParameters.exclude; - const sort = props.sort ?? controlsParameters.sort; - const [args, updateArgs, resetArgs] = useArgs(story, context); const [globals] = useGlobals(story, context); - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); - - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + return { controlsId, args, globals, updateArgs, resetArgs }; +} - if (!hasSubcomponents) { - if (!(Object.keys(filteredArgTypes).length > 0 || Object.keys(args).length > 0)) { +const ControlsTables: FC = ({ + mainName = 'Story', + mainRows, + subcomponentRows, + include, + exclude, + sort, + storyId, + controlsId, + args, + globals, + updateArgs, + resetArgs, +}) => { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + if (!(Object.keys(filteredMainRows).length > 0 || Object.keys(args).length > 0)) { return null; } + return ( = (props) => { ); } - const mainComponentName = getComponentName(component) || 'Story'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes(extractComponentArgTypes(comp, parameters), include, exclude), - sort, - }, - ]) - ); const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), }; + return ( = (props) => { globals={globals} updateArgs={updateArgs} resetArgs={resetArgs} - storyId={story.id} + storyId={storyId} controlsId={controlsId} /> ); }; +const LegacyControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component, subcomponents } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + + if (!argTypes) { + return null; + } + + return ( + + ); +}; + +const DocgenServiceControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + const serviceRows = useDocgenServiceRows({ + componentId: story.id.split('--')[0], + storyId: story.id, + parameters, + initialArgs: story.initialArgs, + customArgTypes: argTypes, + }); + + if (!serviceRows) { + return null; + } + + return ( + + ); +}; + +const ControlsImpl: FC = (props) => { + const { of } = props; + const context = useContext(DocsContext); + const primaryStory = usePrimaryStory(); + + const story = of ? context.resolveOf(of, ['story']).story : primaryStory; + + if (!story) { + return null; + } + + const storyProps = { ...props, story, context }; + + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + + ); +}; + export const Controls = withMdxComponentOverride('Controls', ControlsImpl); diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index 29f3394e8485..1292e3c544eb 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -1,11 +1,13 @@ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; +import { DocsContext } from './DocsContext'; import { Markdown } from './Markdown'; import type { Of } from './useOf'; import { useOf } from './useOf'; +import { useServiceDocgen } from './useServiceDocgen'; import { withMdxComponentOverride } from './with-mdx-component-override'; export enum DescriptionType { @@ -23,7 +25,10 @@ interface DescriptionProps { of?: Of; } -const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): string | null => { +const getDescriptionFromResolvedOf = ( + resolvedOf: ReturnType, + serviceComponentDescription?: string +): string | null => { switch (resolvedOf.type) { case 'story': { return resolvedOf.story.parameters.docs?.description?.story || null; @@ -38,7 +43,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } case 'component': { @@ -50,7 +57,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters?.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } default: { @@ -61,6 +70,29 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str } }; +/** + * Resolves the component-level description from the `core/docgen` service when + * `experimentalDocgenServer` is enabled. In that mode the renderer no longer injects `__docgenInfo`, + * so `extractComponentDescription` can't read the component's leading comment — the service payload + * carries it instead. Story- and meta-parameter descriptions are unaffected and keep their sources. + */ +const useServiceComponentDescription = ( + resolvedOf: ReturnType +): string | undefined => { + const context = useContext(DocsContext); + + let componentId: string | undefined; + if (globalThis.FEATURES?.experimentalDocgenServer) { + if (resolvedOf.type === 'meta') { + componentId = resolvedOf.preparedMeta.componentId; + } else if (resolvedOf.type === 'component') { + componentId = context.getComponentId(resolvedOf.component); + } + } + + return useServiceDocgen(componentId)?.description || undefined; +}; + const DescriptionImpl: FC = (props) => { const { of } = props; @@ -68,7 +100,8 @@ const DescriptionImpl: FC = (props) => { throw new InvalidBlockOfPropError(); } const resolvedOf = useOf(of || 'meta'); - const markdown = getDescriptionFromResolvedOf(resolvedOf); + const serviceComponentDescription = useServiceComponentDescription(resolvedOf); + const markdown = getDescriptionFromResolvedOf(resolvedOf, serviceComponentDescription); return markdown ? {markdown} : null; }; diff --git a/code/addons/docs/src/blocks/blocks/argTypesShared.ts b/code/addons/docs/src/blocks/blocks/argTypesShared.ts new file mode 100644 index 000000000000..78e0088b7079 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/argTypesShared.ts @@ -0,0 +1,82 @@ +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import { + getServiceSubcomponentArgTypes, + mergeServiceArgTypes, +} from 'storybook/internal/docs-tools'; + +import { ArgsTableError } from '../components'; +import { useServiceDocgen } from './useServiceDocgen'; + +/** Runs the renderer's docgen extractor against a component, throwing when it is unavailable. */ +export function extractComponentArgTypes( + component: Renderer['component'], + parameters: Parameters +): StrictArgTypes { + const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; + if (!extractArgTypes) { + throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + } + return extractArgTypes(component) as StrictArgTypes; +} + +/** Extracts argTypes for each declared subcomponent via the renderer's docgen extractor. */ +export function extractSubcomponentArgTypes( + subcomponents: Record | undefined, + parameters: Parameters +): Record { + return Object.fromEntries( + Object.entries(subcomponents || {}).map(([key, comp]) => [ + key, + extractComponentArgTypes(comp, parameters), + ]) + ); +} + +export type DocgenServiceRows = { + /** The component name reported by the service, used as a fallback table title. */ + serviceComponentName: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; +}; + +/** + * Shared docgen-service recipe for the ArgTypes and Controls blocks behind + * `experimentalDocgenServer`. + * + * Subscribes to the `core/docgen` service for the component's server-extracted argTypes and merges + * them with the locally-prepared `customArgTypes` (the service only carries extracted component + * docgen, so the block works regardless of whether the story rendered). Returns `null` until the + * service payload is available, so callers render nothing while docgen is still resolving. + */ +export function useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes, +}: { + componentId?: string; + storyId?: string; + parameters?: Parameters; + initialArgs?: Args; + customArgTypes?: StrictArgTypes; +}): DocgenServiceRows | null { + const servicePayload = useServiceDocgen(componentId); + + if (!servicePayload || !componentId) { + return null; + } + + return { + serviceComponentName: servicePayload.name, + mainRows: mergeServiceArgTypes({ + payload: servicePayload, + storyId: storyId ?? componentId, + parameters, + initialArgs, + customArgTypes, + }), + subcomponentRows: getServiceSubcomponentArgTypes(servicePayload), + }; +} diff --git a/code/addons/docs/src/blocks/blocks/useServiceDocgen.ts b/code/addons/docs/src/blocks/blocks/useServiceDocgen.ts new file mode 100644 index 000000000000..342b2f363be9 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/useServiceDocgen.ts @@ -0,0 +1,40 @@ +import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react'; + +import type { DocgenPayload } from 'storybook/internal/types'; + +import type { DocgenService } from 'storybook/open-service'; +import { getService } from 'storybook/preview-api'; + +type SnapshotCache = { + id: string | undefined; + value: DocgenPayload | undefined; +}; + +/** Subscribes docs blocks to the preview's local `core/docgen` runtime. */ +export function useServiceDocgen(id: string | undefined): DocgenPayload | undefined { + const snapshotCache = useRef({ id: undefined, value: undefined }); + const service = useMemo(() => { + try { + return getService('core/docgen'); + } catch { + return undefined; + } + }, []); + + const getSnapshot = useCallback(() => { + return snapshotCache.current.id === id ? snapshotCache.current.value : undefined; + }, [id]); + + const subscribe = useCallback( + (listener: () => void) => + service && id + ? service.queries.getDocgen.subscribe({ id }, (value) => { + snapshotCache.current = { id, value }; + listener(); + }) + : () => {}, + [service, id] + ); + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx index 768d6b19dc16..3305688a5e6c 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx @@ -95,6 +95,17 @@ export const RetainControlFocusWithTabs: StoryObj = { /> ); }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('hellox') ?? canvas.queryByDisplayValue('hello'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'hello'); + }; + }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const input = await canvas.findByDisplayValue('hello'); diff --git a/code/addons/docs/src/blocks/controls/Object.stories.tsx b/code/addons/docs/src/blocks/controls/Object.stories.tsx index 50146d080f31..2476f334e35f 100644 --- a/code/addons/docs/src/blocks/controls/Object.stories.tsx +++ b/code/addons/docs/src/blocks/controls/Object.stories.tsx @@ -1,6 +1,8 @@ +import React, { useEffect, useState } from 'react'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { expect, fn, waitFor } from 'storybook/test'; import { ObjectControl } from './Object'; @@ -64,6 +66,34 @@ export const Undefined: Story = { }, }; +export const DelayedObject: Story = { + render: (args) => { + const [value, setValue] = useState(undefined); + + useEffect(() => { + setTimeout(() => { + setValue({ + name: 'Michael', + nested: { someBool: true, someNumber: 22 }, + }); + }, 1_000); + }, []); + + return ; + }, + parameters: { + withRawArg: false, + }, + play: async ({ canvas }) => { + await canvas.findByText('"Michael"'); + await waitFor(() => { + expect( + canvas.queryByRole('textbox', { name: 'Edit object as JSON' }) + ).not.toBeInTheDocument(); + }); + }, +}; + class Person { constructor( public firstName: string, diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index cff13c9cab97..3bc5e1c3580c 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -174,6 +174,7 @@ export const ObjectControl: FC = ({ const data = useMemo(() => value && cloneDeep(value), [value]); const hasData = data !== null && data !== undefined; const [showRaw, setShowRaw] = useState(!hasData); + const hadDataRef = useRef(hasData); const [parseError, setParseError] = useState(null); const readonly = !!argType?.table?.readonly; @@ -197,6 +198,13 @@ export const ObjectControl: FC = ({ setForceVisible(true); }, [onChange, setForceVisible]); + useEffect(() => { + if (!hadDataRef.current && hasData && showRaw && !forceVisible) { + setShowRaw(false); + } + hadDataRef.current = hasData; + }, [forceVisible, hasData, showRaw]); + const htmlElRef = useRef(null); useEffect(() => { if (forceVisible && htmlElRef.current) { diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index b920c2509f3c..499d42bf67b0 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -131,6 +131,7 @@ const localAlias = { 'storybook/actions': join(CORE_ROOT_DIR, 'src', 'actions'), 'storybook/preview-api': join(CORE_ROOT_DIR, 'src', 'preview-api'), 'storybook/manager-api': join(CORE_ROOT_DIR, 'src', 'manager-api'), + 'storybook/open-service': join(CORE_ROOT_DIR, 'src', 'shared', 'open-service'), storybook: join(CORE_ROOT_DIR, 'src'), }; async function generateExportsFile(): Promise { diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index 9ce0413d9bcb..a03cce0ea8e7 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -1,4 +1,11 @@ -import { getEnvConfig, optionalEnvToBoolean, parseList } from 'storybook/internal/common'; +import { + HandledError, + PackageManagerName, + getEnvConfig, + optionalEnvToBoolean, + parseList, +} from 'storybook/internal/common'; +import { withTelemetry } from 'storybook/internal/core-server'; import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; @@ -7,6 +14,8 @@ import leven from 'leven'; import picocolors from 'picocolors'; import { version } from '../../package.json'; +import { aiSetup } from '../cli/ai/index.ts'; +import { isAiCliFeatureEnabled, registerAiMcpPassthrough } from '../cli/ai/mcp/register.ts'; import { build } from '../cli/build.ts'; import { buildIndex as index } from '../cli/buildIndex.ts'; import { dev } from '../cli/dev.ts'; @@ -23,6 +32,7 @@ process.env.STORYBOOK = 'true'; * - `dev`: Start the Storybook development server * - `build`: Build the Storybook static files * - `index`: Generate the Storybook index file + * - `ai`: AI agent helpers (always bundled so agent invocations never download an extra package) * * The dispatch CLI at ./dispatcher.ts routes commands to this core CLI. */ @@ -218,6 +228,53 @@ command('index') }).catch(() => process.exit(1)); }); +// Like `handleCommandFailure`, but curried and surfacing the error, matching the signature the +// `ai` command handlers expect. +const handleAiCommandFailure = + (logFilePath: string | boolean | undefined) => + async (error: unknown): Promise => { + if (!(error instanceof HandledError)) { + logger.error(String(error)); + } + return handleCommandFailure(logFilePath ?? false); + }; + +const aiCommand = command('ai') + .description('AI agent helpers for Storybook') + .option( + '-o, --output ', + 'Write the prompt output to a file instead of printing it to stdout' + ); + +aiCommand + .command('setup') + .description('Generate setup instructions to write stories for real components') + .addOption( + new Option('--package-manager ', 'Force package manager for installing deps').choices( + Object.values(PackageManagerName) + ) + ) + .option('-c, --config-dir ', 'Directory of Storybook configuration') + .action(async (options, cmd) => { + const parentOptions = cmd.parent?.opts() ?? {}; + const runId = Math.random().toString(36); + const mergedOptions = { ...parentOptions, ...options, runId }; + await withTelemetry('ai-setup', { cliOptions: mergedOptions }, async () => { + await aiSetup(mergedOptions); + }).catch(handleAiCommandFailure(mergedOptions.logfile)); + }); + +// Show available subcommands when `storybook ai` is run without arguments +aiCommand.action(() => { + aiCommand.outputHelp(); +}); + +// Experimental `storybook ai ` passthrough to the local Storybook MCP server +// (storybookjs/storybook#35124). Overrides the help-only action above when enabled. +if (isAiCliFeatureEnabled()) { + registerAiMcpPassthrough(program, aiCommand, handleAiCommandFailure); +} + program.on('command:*', ([invalidCmd]) => { let errorMessage = ` Invalid command: ${picocolors.bold(invalidCmd)}.\n See --help for a list of available commands.`; const availableCommands = program.commands.map((cmd) => cmd.name()); diff --git a/code/core/src/bin/dispatcher.ts b/code/core/src/bin/dispatcher.ts index 9cc5900489f0..a84b322f18e7 100644 --- a/code/core/src/bin/dispatcher.ts +++ b/code/core/src/bin/dispatcher.ts @@ -16,8 +16,9 @@ import { resolvePackageDir } from '../shared/utils/module.ts'; * * This function serves as the main entry point for Storybook CLI operations. * - * - Core Storybook commands (dev, build, index) are routed to the core binary at - * storybook/dist/bin/core.js + * - Core Storybook commands (dev, build, index, ai) are routed to the core binary at + * storybook/dist/bin/core.js — `ai` is bundled because agent skills invoke it repeatedly and + * must never wait on an npx download * - Init is routed to the create-storybook package via npx * - External CLI tools (upgrade, doctor, etc.) are routed to @storybook/cli via npx */ @@ -33,7 +34,7 @@ if (!isNodeVersionSupported(major, minor, patch)) { async function run() { const args = process.argv.slice(2); - if (['dev', 'build', 'index'].includes(args[0])) { + if (['dev', 'build', 'index', 'ai'].includes(args[0])) { const coreBin = pathToFileURL(join(resolvePackageDir('storybook'), 'dist/bin/core.js')).href; await import(coreBin); return; diff --git a/code/lib/cli-storybook/src/ai/index.ts b/code/core/src/cli/ai/index.ts similarity index 92% rename from code/lib/cli-storybook/src/ai/index.ts rename to code/core/src/cli/ai/index.ts index bc3fa3c61547..62720866bc4c 100644 --- a/code/lib/cli-storybook/src/ai/index.ts +++ b/code/core/src/cli/ai/index.ts @@ -7,9 +7,8 @@ import { logger } from 'storybook/internal/node-logger'; import { telemetry } from 'storybook/internal/telemetry'; import { SupportedLanguage } from 'storybook/internal/types'; -import { ProjectTypeService } from '../../../create-storybook/src/services/ProjectTypeService.ts'; - -import { getStorybookData } from '../automigrate/helpers/mainConfigFile.ts'; +import { detectLanguage } from '../detectLanguage.ts'; +import { getStorybookData } from '../getStorybookData.ts'; import { getAiSetupMarkdownOutput } from './setup-prompts/index.ts'; import type { ProjectInfo, AiSetupOptions } from './types.ts'; @@ -35,8 +34,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { ? parseMajorVersion(data.versionInstalled) : undefined; - const projectTypeService = new ProjectTypeService(data.packageManager); - const detectedLanguage = await projectTypeService.detectLanguage(); + const detectedLanguage = await detectLanguage(data.packageManager, data.workingDir); const language = detectedLanguage === SupportedLanguage.TYPESCRIPT ? 'ts' : 'js'; const needsUserOnboarding = await cache.get('onboarding-pending', false); diff --git a/code/core/src/cli/ai/mcp/client.test.ts b/code/core/src/cli/ai/mcp/client.test.ts new file mode 100644 index 000000000000..a9702e9f9de0 --- /dev/null +++ b/code/core/src/cli/ai/mcp/client.test.ts @@ -0,0 +1,363 @@ +import { versions } from 'storybook/internal/common'; + +import { describe, expect, it, vi } from 'vitest'; + +import { MCP_CLIENT_INFO, McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +const record: StorybookInstanceRecord = { + schemaVersion: 1, + instanceId: 'i-1', + pid: 1, + cwd: '/projects/foo', + url: 'http://localhost:6006', + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +const jsonResponse = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json', ...headers }, + }); + +const sseResponse = (body: string, status = 200) => + new Response(body, { + status, + headers: { 'Content-Type': 'text/event-stream' }, + }); + +/** + * Every JSON-RPC request is preceded by a best-effort `initialize` handshake POST, so the actual + * request under test is always the last fetch call. + */ +const lastCall = (fetchImpl: typeof fetch) => vi.mocked(fetchImpl).mock.calls.at(-1)!; + +describe('callMcpTool', () => { + it('POSTs a JSON-RPC tools/call request to the endpoint (application/json)', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'whatever', + result: { content: [{ type: 'text', text: 'hello' }] }, + }) + ) as unknown as typeof fetch; + + const result = await callMcpTool( + record, + { name: 'list-all-documentation', arguments: { withStoryIds: true } }, + fetchImpl + ); + + expect(result.content).toEqual([{ type: 'text', text: 'hello' }]); + + const call = lastCall(fetchImpl); + expect(call[0]).toBe('http://localhost:6006/mcp'); + const init = call[1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Accept).toBe('application/json, text/event-stream'); + expect(headers['X-Storybook-MCP-Proxy']).toBe('true'); + expect(init.signal).toBeInstanceOf(AbortSignal); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'list-all-documentation', + arguments: { withStoryIds: true }, + }, + }); + expect(typeof body.id).toBe('string'); + }); + + it('resolves the endpoint path against the instance url without mangling the scheme', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ jsonrpc: '2.0', id: 'whatever', result: { content: [] } }) + ) as unknown as typeof fetch; + + await callMcpTool( + { ...record, url: 'http://127.0.0.1:6007', mcp: { status: 'ready', endpoint: '/mcp' } }, + { name: 'list-all-documentation' }, + fetchImpl + ); + + expect(lastCall(fetchImpl)[0]).toBe('http://127.0.0.1:6007/mcp'); + }); + + it('parses a single-event SSE response (text/event-stream)', async () => { + const sseBody = + 'event: message\n' + + 'data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"hi"}]}}\n' + + '\n'; + const fetchImpl = (async () => sseResponse(sseBody)) as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + }); + + it('joins multi-line SSE data correctly', async () => { + const envelope = { + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'line\nwith newline' }] }, + }; + const dataLines = JSON.stringify(envelope, null, 2) + .split('\n') + .map((l) => `data: ${l}`) + .join('\n'); + const sseBody = `event: message\n${dataLines}\n\n`; + const fetchImpl = (async () => sseResponse(sseBody)) as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(result.content?.[0]).toEqual({ type: 'text', text: 'line\nwith newline' }); + }); + + it('throws on SSE responses that contain no data event', async () => { + const fetchImpl = (async () => sseResponse('event: ping\n\n')) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/SSE response with no data event/); + }); + + it('throws when the record has no mcp.endpoint', async () => { + const noEndpoint: StorybookInstanceRecord = { ...record, mcp: { status: 'ready' } }; + const fetchImpl = vi.fn() as unknown as typeof fetch; + await expect( + callMcpTool(noEndpoint, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/has no server endpoint registered/); + }); + + it('throws when the response is not ok', async () => { + const fetchImpl = (async () => + new Response('boom', { status: 500, statusText: 'Server Error' })) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/responded with 500/); + }); + + it('throws when the response content-type is neither JSON nor SSE', async () => { + const fetchImpl = (async () => + new Response('', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + })) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/unsupported content-type "text\/html"/); + }); + + it('throws an McpJsonRpcError when the JSON-RPC payload carries an error', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'whatever', + error: { code: -32601, message: 'unknown tool' }, + })) as typeof fetch; + const promise = callMcpTool(record, { name: 'nope' }, fetchImpl); + await expect(promise).rejects.toThrow(/Storybook server error -32601: unknown tool/); + await expect(promise).rejects.toBeInstanceOf(McpJsonRpcError); + }); + + it.each([ + ['a primitive result', { jsonrpc: '2.0', id: 1, result: 'hello' }], + ['a null result', { jsonrpc: '2.0', id: 1, result: null }], + [ + 'a content item without a type', + { jsonrpc: '2.0', id: 1, result: { content: [{ text: 'x' }] } }, + ], + ['a malformed error object', { jsonrpc: '2.0', id: 1, error: { code: 'x' } }], + ])('rejects %s as an unexpected response shape', async (_label, body) => { + const fetchImpl = (async () => jsonResponse(body)) as typeof fetch; + await expect( + callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl) + ).rejects.toThrow(/unexpected response shape/); + }); + + it('passes through extra content fields and result keys (loose validation)', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'resource_link', uri: 'http://x' }], + _meta: { 'storybook.dev/foo': 1 }, + }, + })) as typeof fetch; + const result = await callMcpTool(record, { name: 'x' }, fetchImpl); + expect(result.content?.[0]).toMatchObject({ type: 'resource_link', uri: 'http://x' }); + }); +}); + +describe('listMcpTools', () => { + it('POSTs a JSON-RPC tools/list request and returns the tool descriptors', async () => { + const tools = [ + { name: 'get-documentation', description: 'Docs', inputSchema: { properties: {} } }, + { name: 'list-all-documentation' }, + ]; + const fetchImpl = vi.fn(async () => + jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools } }) + ) as unknown as typeof fetch; + + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual(tools); + + const body = JSON.parse(lastCall(fetchImpl)[1]?.body as string); + expect(body).toMatchObject({ method: 'tools/list', params: {} }); + }); + + it('returns [] when the result has no tools array', async () => { + const fetchImpl = (async () => + jsonResponse({ jsonrpc: '2.0', id: 'x', result: {} })) as typeof fetch; + await expect(listMcpTools(record, fetchImpl)).resolves.toEqual([]); + }); + + it('rejects tool descriptors without a name as an unexpected response shape', async () => { + const fetchImpl = (async () => + jsonResponse({ + jsonrpc: '2.0', + id: 'x', + result: { tools: [{ description: 'nameless' }] }, + })) as typeof fetch; + await expect(listMcpTools(record, fetchImpl)).rejects.toThrow(/unexpected response shape/); + }); +}); + +describe('initialize handshake (clientInfo for telemetry segmentation)', () => { + const initializeResponse = (sessionId?: string) => + jsonResponse( + { jsonrpc: '2.0', id: 'init', result: { protocolVersion: '2025-06-18', serverInfo: {} } }, + 200, + sessionId ? { 'mcp-session-id': sessionId } : {} + ); + + const toolResult = () => + jsonResponse({ + jsonrpc: '2.0', + id: 'call', + result: { content: [{ type: 'text', text: 'hi' }] }, + }); + + it('sends initialize with the storybook-cli clientInfo before the actual request', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-1')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + const [initTarget, initInit] = vi.mocked(fetchImpl).mock.calls[0]; + expect(initTarget).toBe('http://localhost:6006/mcp'); + const initBody = JSON.parse((initInit as RequestInit).body as string); + expect(initBody).toMatchObject({ + jsonrpc: '2.0', + method: 'initialize', + params: { + protocolVersion: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + capabilities: {}, + clientInfo: { name: 'storybook-cli', version: versions.storybook }, + }, + }); + expect(MCP_CLIENT_INFO).toEqual({ name: 'storybook-cli', version: versions.storybook }); + }); + + it('threads the session id from the handshake into the actual request', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-42')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers['Mcp-Session-Id']).toBe('session-42'); + }); + + it('proceeds without a session header when the handshake response has no session id', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse(undefined)) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + }); + + it('proceeds without a session header when the handshake request rejects', async () => { + const fetchImpl = vi + .fn() + .mockRejectedValueOnce(new Error('connection refused')) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + const result = await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + }); + + it('ignores the session id of a non-ok handshake response', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response('boom', { status: 500, headers: { 'mcp-session-id': 'session-broken' } }) + ) + .mockResolvedValueOnce(toolResult()) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + + const headers = (lastCall(fetchImpl)[1] as RequestInit).headers as Record; + expect(headers).not.toHaveProperty('Mcp-Session-Id'); + }); + + it('drains the handshake response body before sending the actual request', async () => { + // The server stores the clientInfo while producing the handshake response body, so the + // follow-up request may only be sent after that body has been consumed. + let drained = false; + const initBody = new ReadableStream({ + pull(controller) { + drained = true; + controller.enqueue(new TextEncoder().encode('{}')); + controller.close(); + }, + }); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response(initBody, { + status: 200, + headers: { 'Content-Type': 'application/json', 'mcp-session-id': 'session-1' }, + }) + ) + .mockImplementationOnce(async () => { + expect(drained).toBe(true); + return toolResult(); + }) as unknown as typeof fetch; + + await callMcpTool(record, { name: 'list-all-documentation' }, fetchImpl); + expect(drained).toBe(true); + }); + + it('also performs the handshake for tools/list requests', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(initializeResponse('session-7')) + .mockResolvedValueOnce( + jsonResponse({ jsonrpc: '2.0', id: 'x', result: { tools: [] } }) + ) as unknown as typeof fetch; + + await listMcpTools(record, fetchImpl); + + const initBody = JSON.parse( + (vi.mocked(fetchImpl).mock.calls[0][1] as RequestInit).body as string + ); + expect(initBody.method).toBe('initialize'); + const headers = (vi.mocked(fetchImpl).mock.calls[1][1] as RequestInit).headers as Record< + string, + string + >; + expect(headers['Mcp-Session-Id']).toBe('session-7'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/client.ts b/code/core/src/cli/ai/mcp/client.ts new file mode 100644 index 000000000000..c046dd1e0008 --- /dev/null +++ b/code/core/src/cli/ai/mcp/client.ts @@ -0,0 +1,276 @@ +import { versions } from 'storybook/internal/common'; + +import * as v from 'valibot'; + +import { + type McpToolDescriptor, + McpToolDescriptorSchema, + type StorybookInstanceRecord, + type ToolCallResult, + ToolCallResultSchema, +} from './types.ts'; + +/** + * Marks the request as coming from a trusted local Storybook client. `@storybook/addon-mcp` uses + * this header to skip auth flows meant for remote (composed) Storybooks. + */ +const STORYBOOK_MCP_PROXY_HEADER = 'X-Storybook-MCP-Proxy'; +const STORYBOOK_MCP_PROXY_HEADER_VALUE = 'true'; + +/** + * Upper bound on a single request so a hung server cannot stall the CLI forever. Generous because + * `run-story-tests` on a full suite legitimately runs for minutes. + */ +const REQUEST_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Identifies the CLI on the MCP connection, so `@storybook/addon-mcp`'s server-side `tool:*` + * telemetry can segment CLI-originated calls from agents connected over MCP directly + * (storybookjs/storybook#35131). + */ +export const MCP_CLIENT_INFO = { name: 'storybook-cli', version: versions.storybook }; + +/** Protocol version sent on `initialize`; tmcp (the addon-mcp server library) supports it. */ +const MCP_PROTOCOL_VERSION = '2025-06-18'; + +/** + * The handshake is telemetry garnish sitting on the critical path of every command, so its budget + * must stay small: a local tmcp `initialize` answers in milliseconds, and a few seconds is already + * generous headroom for a busy dev server. On timeout (or any other failure) the command simply + * proceeds without clientInfo. + */ +const INITIALIZE_TIMEOUT_MS = 3 * 1000; + +export type ToolCallParams = { + name: string; + arguments?: Record; +}; + +/** A JSON-RPC level error returned by the Storybook MCP server (e.g. unknown tool). */ +export class McpJsonRpcError extends Error { + constructor( + public readonly code: number, + message: string + ) { + super(`Storybook server error ${code}: ${message}`); + this.name = 'McpJsonRpcError'; + } +} + +const JsonRpcEnvelopeSchema = v.looseObject({ + result: v.optional(v.unknown()), + error: v.optional(v.looseObject({ code: v.number(), message: v.string() })), +}); + +const ToolListResultSchema = v.looseObject({ + tools: v.optional(v.array(McpToolDescriptorSchema)), +}); + +/** Forward an MCP `tools/call` JSON-RPC request to a local Storybook MCP server. */ +export async function callMcpTool( + record: StorybookInstanceRecord, + params: ToolCallParams, + fetchImpl: typeof fetch = fetch +): Promise { + return sendJsonRpcRequest(record, 'tools/call', params, ToolCallResultSchema, fetchImpl); +} + +/** List the tools exposed by a local Storybook MCP server via `tools/list`. */ +export async function listMcpTools( + record: StorybookInstanceRecord, + fetchImpl: typeof fetch = fetch +): Promise { + const result = await sendJsonRpcRequest( + record, + 'tools/list', + {}, + ToolListResultSchema, + fetchImpl + ); + return result.tools ?? []; +} + +const REQUEST_HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + [STORYBOOK_MCP_PROXY_HEADER]: STORYBOOK_MCP_PROXY_HEADER_VALUE, +}; + +/** + * Send a minimal MCP `initialize` request carrying {@link MCP_CLIENT_INFO} and return the session + * id the transport assigned, or null when the handshake fails in any way. + * + * Its only purpose is telemetry segmentation, and the flow is MCP Streamable HTTP spec behavior, + * not a tmcp implementation detail: the server assigns a session id during initialization, + * returns it in the `Mcp-Session-Id` response header, and associates the session's clientInfo + * with later requests echoing that header. Any spec-compliant server (e.g. the official MCP SDK + * transports) behaves the same, so addon-mcp can move off tmcp without breaking this client. + * The handshake is strictly best-effort — when it fails (or a future server ignores sessions), + * the actual command request proceeds without a session and keeps working; only the telemetry + * segmentation is lost, and error reporting stays anchored on the real call. + * + * Sessions are deliberately one-shot: each JSON-RPC request gets its own handshake and the session + * is never reused or closed. A CLI invocation makes one request on the happy path (two on error + * paths that fetch the tool list), so against a localhost server the extra round-trip is + * negligible — not worth threading session state through the call sites. + * + * The response body is drained before returning because the transport produces it only after the + * server has processed the initialize message (and stored the clientInfo); returning on headers + * alone would race the follow-up request against that processing. + */ +async function initializeMcpSession( + target: string, + fetchImpl: typeof fetch +): Promise { + try { + const response = await fetchImpl(target, { + method: 'POST', + headers: REQUEST_HEADERS, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: MCP_CLIENT_INFO, + }, + }), + signal: AbortSignal.timeout(INITIALIZE_TIMEOUT_MS), + }); + const sessionId = response.headers.get('mcp-session-id'); + if (!response.ok || !sessionId) { + return null; + } + await response.text(); + return sessionId; + } catch { + return null; + } +} + +/** + * Send a single JSON-RPC request to the instance's MCP endpoint over HTTP. + * + * This is deliberately NOT a full MCP client: the `initialize` request exists solely to convey + * `clientInfo` for telemetry (see {@link initializeMcpSession}) — there is no protocol-version + * negotiation, capability handling, or session lifecycle beyond this one follow-up request. The + * downstream is always `@storybook/addon-mcp`, whose tmcp HttpTransport serves `tools/*` + * per-request — the same local shortcut `@storybook/mcp-proxy` takes in its proxy-client. If the + * CLI ever needs to talk to arbitrary MCP servers, replace this with a real client instead of + * extending it. + * + * tmcp hardcodes `text/event-stream` for any request with an id, so we accept both content-types + * and parse the SSE envelope when needed. + */ +async function sendJsonRpcRequest( + record: StorybookInstanceRecord, + method: 'tools/call' | 'tools/list', + params: unknown, + resultSchema: v.GenericSchema, + fetchImpl: typeof fetch +): Promise { + const endpoint = record.mcp.endpoint; + if (!endpoint) { + throw new Error(`The Storybook instance at ${record.cwd} has no server endpoint registered`); + } + + const target = new URL(endpoint, record.url).href; + + const sessionId = await initializeMcpSession(target, fetchImpl); + + const response = await fetchImpl(target, { + method: 'POST', + headers: { + ...REQUEST_HEADERS, + ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}), + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: crypto.randomUUID(), + method, + params, + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error( + `The Storybook server at ${target} responded with ${response.status} ${response.statusText}` + ); + } + + const payload = await readJsonRpcResponse(response, target); + + const envelope = v.safeParse(JsonRpcEnvelopeSchema, payload); + if (!envelope.success) { + throw unexpectedShapeError(target); + } + if (envelope.output.error) { + throw new McpJsonRpcError(envelope.output.error.code, envelope.output.error.message); + } + if (envelope.output.result === undefined) { + throw new Error('The Storybook server returned no result'); + } + + const result = v.safeParse(resultSchema, envelope.output.result); + if (!result.success) { + throw unexpectedShapeError(target); + } + return result.output; +} + +function unexpectedShapeError(target: string): Error { + return new Error(`The Storybook server at ${target} returned an unexpected response shape`); +} + +async function readJsonRpcResponse(response: Response, endpoint: string): Promise { + const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); + + if (contentType.includes('application/json')) { + return await response.json(); + } + + if (contentType.includes('text/event-stream')) { + return parseSseEnvelope(await response.text(), endpoint); + } + + throw new Error( + `The Storybook server at ${endpoint} returned unsupported content-type "${contentType}". Expected application/json or text/event-stream.` + ); +} + +/** + * Parse an MCP Streamable HTTP SSE response containing a single JSON-RPC envelope. Format per the + * SSE spec: lines starting with `data:` hold payload bytes; multiple `data:` lines in one event + * are joined with `\n`; the event terminates at the first blank line. We only care about the first + * event because a tools/call or tools/list response is always a single message. + */ +function parseSseEnvelope(body: string, endpoint: string): unknown { + const dataLines: string[] = []; + for (const rawLine of body.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (line.startsWith('data:')) { + const value = line.slice(5); + dataLines.push(value.startsWith(' ') ? value.slice(1) : value); + continue; + } + if (line === '' && dataLines.length > 0) { + break; + } + } + if (dataLines.length === 0) { + throw new Error( + `The Storybook server at ${endpoint} returned an SSE response with no data event` + ); + } + try { + return JSON.parse(dataLines.join('\n')); + } catch (error) { + throw new Error( + `The Storybook server at ${endpoint} returned an SSE event whose data could not be parsed as JSON: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} diff --git a/code/core/src/cli/ai/mcp/intercepts.test.ts b/code/core/src/cli/ai/mcp/intercepts.test.ts new file mode 100644 index 000000000000..df444e6efcd6 --- /dev/null +++ b/code/core/src/cli/ai/mcp/intercepts.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { getInterceptMarkdown } from './intercepts.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +const record = (cwd: string, url: string): StorybookInstanceRecord => ({ + schemaVersion: 1, + instanceId: 'i-1', + pid: 1, + cwd, + url, + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}); + +describe('getInterceptMarkdown', () => { + it('no-instance without candidates tells the agent to start storybook dev', () => { + const markdown = getInterceptMarkdown('no-instance'); + expect(markdown).toContain('Storybook is not running at this cwd'); + expect(markdown).toContain('storybook dev'); + }); + + it('no-instance with candidates lists the running cwds', () => { + const markdown = getInterceptMarkdown('no-instance', { + records: [record('/projects/foo', 'http://localhost:6006')], + }); + expect(markdown).toContain('Running Storybooks:'); + expect(markdown).toContain('- `/projects/foo` (http://localhost:6006)'); + expect(markdown).toContain('--cwd'); + }); + + it('port-mismatch lists the ports running at the cwd', () => { + const markdown = getInterceptMarkdown('port-mismatch', { + port: 9999, + records: [record('/projects/foo', 'http://localhost:6006')], + }); + expect(markdown).toContain('not on port `9999`'); + expect(markdown).toContain('- port `6006`'); + expect(markdown).toContain('omit `--port`'); + }); + + it('addon-missing instructs installing the MCP addon', () => { + const markdown = getInterceptMarkdown('addon-missing'); + expect(markdown).toContain('`@storybook/addon-mcp` addon is missing'); + expect(markdown).toContain('npx storybook add @storybook/addon-mcp'); + }); + + it('mcp-starting asks to wait and retry', () => { + expect(getInterceptMarkdown('mcp-starting')).toContain('still starting up'); + }); + + it('mcp-error points at the Storybook terminal output', () => { + expect(getInterceptMarkdown('mcp-error')).toContain('Inspect the Storybook terminal output'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/intercepts.ts b/code/core/src/cli/ai/mcp/intercepts.ts new file mode 100644 index 000000000000..3390445461c0 --- /dev/null +++ b/code/core/src/cli/ai/mcp/intercepts.ts @@ -0,0 +1,62 @@ +import type { InterceptReason, StorybookInstanceRecord } from './types.ts'; + +/** + * Repair-instruction markdown for agents, mirroring `@storybook/mcp-proxy` (storybookjs/mcp) so + * the CLI and the proxy give the same guidance — keep the two in sync when updating either. + */ +const NO_INSTANCE_EMPTY = `Storybook is not running at this cwd. Start \`storybook dev\` from the project's cwd and retry the command.`; + +const buildNoInstanceWithCandidates = (records: StorybookInstanceRecord[]) => + `No Storybook is running at this cwd. Either start Storybook from the project's cwd, or retry with \`--cwd\` set to one of the running cwds below. + +Running Storybooks: +${records.map((r) => `- \`${r.cwd}\` (${r.url})`).join('\n')}`; + +const buildPortMismatch = (port: number | undefined, records: StorybookInstanceRecord[]) => + `Storybook is running at this cwd, but not on port \`${port ?? 'unknown'}\`. Retry with one of the running ports below, or omit \`--port\` to route by cwd alone. + +Running Storybooks at this cwd: +${records.map((r) => `- port \`${r.port}\` (${r.url}, status: \`${r.mcp.status}\`)`).join('\n')}`; + +const ADDON_MISSING = `Storybook is running but does not provide these commands. The \`@storybook/addon-mcp\` addon is missing. + +Install it: +\`\`\` +npx storybook add @storybook/addon-mcp +\`\`\` + +Restart Storybook, then retry the command.`; + +const MCP_STARTING = `Storybook is running but its command server is still starting up. Wait a moment and retry the command.`; + +const MCP_ERROR = `Storybook is running but its command server reported an error. Inspect the Storybook terminal output, fix the underlying issue, then retry the command.`; + +export type InterceptExtras = { + records?: StorybookInstanceRecord[]; + port?: number; +}; + +export function getInterceptMarkdown( + reason: InterceptReason, + extras: InterceptExtras = {} +): string { + const { records, port } = extras; + switch (reason) { + case 'no-instance': + return records && records.length > 0 + ? buildNoInstanceWithCandidates(records) + : NO_INSTANCE_EMPTY; + case 'port-mismatch': + return buildPortMismatch(port, records ?? []); + case 'addon-missing': + return ADDON_MISSING; + case 'mcp-starting': + return MCP_STARTING; + case 'mcp-error': + return MCP_ERROR; + default: { + const unhandled: never = reason; + throw new Error(`Unhandled intercept reason: ${unhandled as string}`); + } + } +} diff --git a/code/core/src/cli/ai/mcp/register.test.ts b/code/core/src/cli/ai/mcp/register.test.ts new file mode 100644 index 000000000000..1d4f8512a50a --- /dev/null +++ b/code/core/src/cli/ai/mcp/register.test.ts @@ -0,0 +1,503 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { sendTelemetryError, withTelemetry } from 'storybook/internal/core-server'; +import { telemetry } from 'storybook/internal/telemetry'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Command } from 'commander'; + +import { isAiCliFeatureEnabled, registerAiMcpPassthrough } from './register.ts'; +import { buildStorybookCommandsHelp, runAiTool, runAiToolHelp } from './run-tool.ts'; + +vi.mock('./run-tool.ts', { spy: true }); + +vi.mock('node:fs/promises', { spy: true }); + +vi.mock('storybook/internal/core-server', { spy: true }); + +vi.mock('storybook/internal/telemetry', { spy: true }); + +describe('isAiCliFeatureEnabled', () => { + it.each([ + ['1', true], + ['true', true], + ['0', false], + ['false', false], + ['', false], + [undefined, false], + ])('STORYBOOK_FEATURE_AI_CLI=%j → %j', (value, expected) => { + expect(isAiCliFeatureEnabled({ STORYBOOK_FEATURE_AI_CLI: value })).toBe(expected); + }); +}); + +/** + * Replicate the `ai` command tree from `bin/run.ts`: a `setup` subcommand plus a help action. The + * `--disable-telemetry` and `--logfile` options mirror the shared options that the `command()` + * factory in `bin/run.ts` registers on every command, including the env-var default. + */ +function buildProgram({ withPassthrough }: { withPassthrough: boolean }) { + const program = new Command(); + program.exitOverride(); + const setupAction = vi.fn(); + const helpAction = vi.fn(); + const failures: unknown[] = []; + const handleCommandFailure = vi.fn( + (_logFilePath: string | boolean | undefined) => + async (error: unknown): Promise => { + failures.push(error); + return undefined as never; + } + ); + + const aiCommand = program + .command('ai') + .description('AI agent helpers for Storybook') + .option( + '--disable-telemetry', + 'Disable sending telemetry data', + optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) + ) + .option('--logfile [path]', 'Write all debug logs to the specified file') + .option('-o, --output ', 'Write the prompt output to a file') + .exitOverride(); + aiCommand.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + aiCommand.command('setup').action(setupAction); + aiCommand.action(helpAction); + + if (withPassthrough) { + registerAiMcpPassthrough(program, aiCommand, handleCommandFailure); + } + + return { program, aiCommand, setupAction, helpAction, handleCommandFailure, failures }; +} + +function parse(program: Command, argv: string[]) { + return program.parseAsync(['node', 'storybook', ...argv]); +} + +function stdoutText(): string { + return vi + .mocked(process.stdout.write) + .mock.calls.map(([chunk]) => String(chunk)) + .join(''); +} + +/** The payloads of all `ai-command` events fired through the mocked telemetry module. */ +function aiCommandPayloads(): unknown[] { + return vi + .mocked(telemetry) + .mock.calls.filter(([eventType]) => eventType === 'ai-command') + .map(([, payload]) => payload); +} + +beforeEach(() => { + // The CI/dev shell may have the opt-out set; tests control it explicitly via vi.stubEnv. + vi.stubEnv('STORYBOOK_DISABLE_TELEMETRY', undefined); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'ok', + outcome: { kind: 'success' }, + }); + vi.mocked(runAiToolHelp).mockResolvedValue({ + exitCode: 0, + output: 'tool help', + outcome: { kind: 'help' }, + }); + vi.mocked(buildStorybookCommandsHelp).mockResolvedValue( + 'Storybook commands (from the running Storybook):' + ); + vi.mocked(writeFile).mockResolvedValue(undefined); + // Pass-through that mirrors the real contract: run the callback, propagate its rejection. + vi.mocked(withTelemetry).mockImplementation(async (_eventType, _options, run) => run()); + vi.mocked(telemetry).mockResolvedValue(undefined); + vi.mocked(sendTelemetryError).mockResolvedValue(undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(runAiTool).mockReset(); + vi.mocked(runAiToolHelp).mockReset(); + vi.mocked(buildStorybookCommandsHelp).mockReset(); + vi.unstubAllEnvs(); + process.exitCode = undefined; +}); + +describe('without the feature flag (no registration)', () => { + it('keeps `setup` as the only subcommand', () => { + const { aiCommand } = buildProgram({ withPassthrough: false }); + expect(aiCommand.commands.map((c) => c.name())).toEqual(['setup']); + }); + + it('rejects tool names like today (excess arguments)', async () => { + const { program } = buildProgram({ withPassthrough: false }); + await expect(parse(program, ['ai', 'list-all-documentation'])).rejects.toMatchObject({ + code: 'commander.excessArguments', + }); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it('keeps the bare `ai` help action', async () => { + const { program, helpAction } = buildProgram({ withPassthrough: false }); + await parse(program, ['ai']); + expect(helpAction).toHaveBeenCalled(); + expect(buildStorybookCommandsHelp).not.toHaveBeenCalled(); + }); +}); + +describe('with the feature flag (passthrough registered)', () => { + it('forwards `ai ` with pass-through tokens to runAiTool', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'get-documentation', '--id', 'button-docs']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', ['--id', 'button-docs'], { + cwd: undefined, + port: undefined, + json: undefined, + }); + }); + + it('parses --cwd, --port and --json before the tool name as CLI options', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, [ + 'ai', + '--cwd', + '/x', + '--port', + '6006', + '--json', + '{"a":1}', + 'get-documentation', + ]); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', [], { + cwd: '/x', + port: '6006', + json: '{"a":1}', + }); + }); + + it('passes tokens after the tool name through verbatim, even option-like ones', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x', '--cwd', '/y', '--output', 'z']); + expect(runAiTool).toHaveBeenCalledWith('tool-x', ['--cwd', '/y', '--output', 'z'], { + cwd: undefined, + port: undefined, + json: undefined, + }); + }); + + it('writes the result to the file given via --output instead of stdout', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'markdown result', + outcome: { kind: 'success' }, + }); + await parse(program, ['ai', '-o', '/out/result.md', 'tool-x']); + expect(writeFile).toHaveBeenCalledWith('/out/result.md', 'markdown result\n', 'utf-8'); + expect(process.stdout.write).not.toHaveBeenCalledWith('markdown result\n'); + }); + + it('writes the result to stdout', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'markdown result', + outcome: { kind: 'success' }, + }); + await parse(program, ['ai', 'tool-x']); + expect(process.stdout.write).toHaveBeenCalledWith('markdown result\n'); + expect(process.exitCode).toBeUndefined(); + }); + + it('sets a non-zero exit code on failure', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason: 'no-instance' }, + }); + await parse(program, ['ai', 'tool-x']); + expect(process.stdout.write).toHaveBeenCalledWith('repair instructions\n'); + expect(process.exitCode).toBe(1); + }); + + it('still dispatches `ai setup` to the setup subcommand', async () => { + const { program, setupAction } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'setup']); + expect(setupAction).toHaveBeenCalled(); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it.each([[['ai']], [['ai', '--help']], [['ai', '-h']]])( + 'shows commander help plus the tool commands section for %j', + async (argv) => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, argv); + expect(buildStorybookCommandsHelp).toHaveBeenCalledWith({ cwd: undefined, port: undefined }); + const output = stdoutText(); + expect(output).toContain('Usage:'); + expect(output).toContain('setup'); + expect(output).toContain('Storybook commands (from the running Storybook):'); + expect(runAiTool).not.toHaveBeenCalled(); + } + ); + + it('passes --cwd and --port through to the tool commands section', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--cwd', '/x', '--port', '6006', '--help']); + expect(buildStorybookCommandsHelp).toHaveBeenCalledWith({ cwd: '/x', port: '6006' }); + }); + + it('shows single-tool help for `ai --help `', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--help', 'get-documentation']); + expect(runAiToolHelp).toHaveBeenCalledWith('get-documentation', { + cwd: undefined, + port: undefined, + }); + expect(process.stdout.write).toHaveBeenCalledWith('tool help\n'); + expect(runAiTool).not.toHaveBeenCalled(); + }); + + it('passes a --help token after the tool name through to runAiTool', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'get-documentation', '--help']); + expect(runAiTool).toHaveBeenCalledWith('get-documentation', ['--help'], { + cwd: undefined, + port: undefined, + json: undefined, + }); + }); +}); + +describe('ai-command telemetry', () => { + it('wraps the command execution in withTelemetry with the opt-out cli options', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + { + cliOptions: { + disableTelemetry: undefined, + logfile: undefined, + configDir: resolve(process.cwd(), '.storybook'), + }, + // Keeps the no-instance intercept reportable from a cwd without a loadable main config. + fallbackTelemetryState: true, + }, + expect.any(Function) + ); + }); + + it.each([ + ['before the command name', ['ai', '--cwd', '/target/project', 'tool-x']], + ['after the command name', ['ai', 'tool-x', '--cwd', '/target/project']], + ])( + 'resolves the opt-out configDir from the target Storybook when --cwd is passed %s', + async (_position, argv) => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, argv); + // The target project's core.disableTelemetry must apply, not the invoking cwd's. + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', '.storybook'), + }), + }), + expect.any(Function) + ); + } + ); + + it('honors the --cwd target opt-out even when the remaining args are malformed', async () => { + const { program } = buildProgram({ withPassthrough: true }); + // `--json '{bad'` makes full arg parsing fail (invalid-arguments intercept), but the + // target project's core.disableTelemetry must still be the one consulted. + await parse(program, ['ai', 'tool-x', '--cwd', '/target/project', '--json', '{bad']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ + cliOptions: expect.objectContaining({ + configDir: resolve('/target/project', '.storybook'), + }), + }), + expect.any(Function) + ); + }); + + it('fires ai-command with a success payload and no interceptReason', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: true, + duration: expect.any(Number), + }, + // Metadata is collected from the target project, like the opt-out resolution. + { configDir: resolve(process.cwd(), '.storybook') } + ); + expect(aiCommandPayloads()).toHaveLength(1); + expect(aiCommandPayloads()[0]).not.toHaveProperty('interceptReason'); + expect(sendTelemetryError).not.toHaveBeenCalled(); + }); + + it('collects the event metadata from the --cwd target project', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--cwd', '/target/project', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith('ai-command', expect.anything(), { + configDir: resolve('/target/project', '.storybook'), + }); + }); + + it.each([ + 'no-instance', + 'port-mismatch', + 'addon-missing', + 'mcp-starting', + 'mcp-error', + 'invalid-arguments', + 'unknown-command', + ] as const)( + 'fires ai-command with interceptReason %s on intercepted invocations', + async (reason) => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason }, + }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: false, + interceptReason: reason, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(sendTelemetryError).not.toHaveBeenCalled(); + } + ); + + it('routes server-reached errors through the standard sanitized error path', async () => { + const { program } = buildProgram({ withPassthrough: true }); + const error = new Error('connection reset'); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'Failed to reach the Storybook server', + outcome: { kind: 'error', error }, + }); + await parse(program, ['ai', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: false, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(sendTelemetryError).toHaveBeenCalledWith(error, 'ai-command', { + cliOptions: { + disableTelemetry: undefined, + logfile: undefined, + configDir: resolve(process.cwd(), '.storybook'), + }, + }); + }); + + it('still fires ai-command when writing the --output file fails after the command executed', async () => { + const { program, failures } = buildProgram({ withPassthrough: true }); + const writeError = new Error('EACCES: permission denied'); + vi.mocked(writeFile).mockRejectedValue(writeError); + await parse(program, ['ai', '-o', '/readonly/out.md', 'tool-x']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + { + command: 'tool-x', + success: true, + duration: expect.any(Number), + }, + expect.anything() + ); + expect(failures).toEqual([writeError]); + }); + + it('collapses non-command-shaped names to a placeholder (no paths in payloads)', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 1, + output: 'repair instructions', + outcome: { kind: 'intercept', reason: 'no-instance' }, + }); + await parse(program, ['ai', './projects/secret-app']); + expect(telemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ command: '(invalid)' }), + expect.anything() + ); + }); + + it.each([[['ai']], [['ai', '--help']], [['ai', '--help', 'tool-x']]])( + 'does not fire ai-command for the help path %j, but still wraps it in withTelemetry', + async (argv) => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, argv); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.anything(), + expect.any(Function) + ); + expect(aiCommandPayloads()).toHaveLength(0); + } + ); + + it('does not fire ai-command when a --help token after the command name turns the run into a help lookup', async () => { + const { program } = buildProgram({ withPassthrough: true }); + vi.mocked(runAiTool).mockResolvedValue({ + exitCode: 0, + output: 'tool help', + outcome: { kind: 'help' }, + }); + await parse(program, ['ai', 'tool-x', '--help']); + expect(aiCommandPayloads()).toHaveLength(0); + }); + + it('passes --disable-telemetry through to withTelemetry', async () => { + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', '--disable-telemetry', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ cliOptions: expect.objectContaining({ disableTelemetry: true }) }), + expect.any(Function) + ); + }); + + it('defaults --disable-telemetry from STORYBOOK_DISABLE_TELEMETRY (as registered in bin/run.ts)', async () => { + vi.stubEnv('STORYBOOK_DISABLE_TELEMETRY', 'true'); + const { program } = buildProgram({ withPassthrough: true }); + await parse(program, ['ai', 'tool-x']); + expect(withTelemetry).toHaveBeenCalledWith( + 'ai-command', + expect.objectContaining({ cliOptions: expect.objectContaining({ disableTelemetry: true }) }), + expect.any(Function) + ); + }); + + it('hands unexpected failures to the command failure handler with the --logfile value', async () => { + const { program, handleCommandFailure, failures } = buildProgram({ withPassthrough: true }); + const error = new Error('unexpected'); + vi.mocked(runAiTool).mockRejectedValue(error); + await parse(program, ['ai', '--logfile', 'debug.log', 'tool-x']); + expect(handleCommandFailure).toHaveBeenCalledWith('debug.log'); + expect(failures).toEqual([error]); + }); +}); diff --git a/code/core/src/cli/ai/mcp/register.ts b/code/core/src/cli/ai/mcp/register.ts new file mode 100644 index 000000000000..35d678a8b4b2 --- /dev/null +++ b/code/core/src/cli/ai/mcp/register.ts @@ -0,0 +1,194 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { optionalEnvToBoolean } from 'storybook/internal/common'; +import { sendTelemetryError, withTelemetry } from 'storybook/internal/core-server'; +import { logger } from 'storybook/internal/node-logger'; +import { telemetry } from 'storybook/internal/telemetry'; +import type { CLIOptions } from 'storybook/internal/types'; + +import type { Command } from 'commander'; + +import { + type AiCommandOutcome, + type AiToolRunResult, + buildStorybookCommandsHelp, + runAiTool, + runAiToolHelp, +} from './run-tool.ts'; +import { scanCwdToken } from './tool-args.ts'; + +/** + * The `storybook ai ` MCP passthrough is experimental (storybookjs/storybook#35124) and only + * registered when this feature flag is set; without it, `storybook ai` exposes `setup` only. + */ +export function isAiCliFeatureEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return optionalEnvToBoolean(env.STORYBOOK_FEATURE_AI_CLI) === true; +} + +const CWD_DESCRIPTION = + 'Project directory of the target Storybook (defaults to the current working directory)'; +const PORT_DESCRIPTION = + 'Port of the target Storybook, to address one specific instance when several run at the same cwd'; + +type AiPassthroughOptions = { + cwd?: string; + port?: string; + json?: string; + output?: string; + help?: boolean; + /** From the shared command options in `bin/core.ts`; consumed by `withTelemetry`. */ + disableTelemetry?: boolean; + /** From the shared command options in `bin/core.ts`; consumed by the failure handler. */ + logfile?: string | boolean; +}; + +/** `handleAiCommandFailure` from `bin/core.ts`, passed in to avoid an import cycle. */ +export type CommandFailureHandler = ( + logFilePath: string | boolean | undefined +) => (error: unknown) => Promise; + +/** + * Register the passthrough on the `ai` command: a generic `[command] [args...]` argument pair that + * forwards any command to the running Storybook's server (MCP under the hood, but that is an + * implementation detail — user-facing copy says "commands"). `passThroughOptions` hands every + * token after the command name to the command untouched, which requires positional options on the + * program. + * + * Commander's built-in (synchronous) help is replaced with our own `-h, --help` option so the help + * output can include the commands fetched from the running Storybook. + */ +export function registerAiMcpPassthrough( + program: Command, + aiCommand: Command, + handleCommandFailure: CommandFailureHandler +): void { + program.enablePositionalOptions(); + + aiCommand + .helpOption(false) + .usage('[options] [command] [args...]') + .argument('[command]', 'A command provided by the running Storybook') + .argument( + '[args...]', + 'Command arguments as `--key value` flags; values are JSON-parsed when possible' + ) + .option('--cwd ', CWD_DESCRIPTION) + .option('--port ', PORT_DESCRIPTION) + .option( + '--json ', + 'Raw JSON object with the command arguments (escape hatch for complex values)' + ) + .option('-h, --help', 'Show help, including the commands provided by the running Storybook') + .passThroughOptions() + .action( + async (command: string | undefined, commandArgs: string[], options: AiPassthroughOptions) => { + const cliOptions = pickCliOptions(options, commandArgs); + // Like `init`, the fallback keeps telemetry on when no main config is loadable: running + // from a cwd without a Storybook is the `no-instance` intercept this event exists to + // measure. The explicit opt-outs (env var, flag, loadable `core.disableTelemetry`) + // still apply. + return withTelemetry( + 'ai-command', + { cliOptions, fallbackTelemetryState: true }, + async () => { + const target = { cwd: options.cwd, port: options.port }; + if (options.help && command) { + await printResult(await runAiToolHelp(command, target), options.output); + return; + } + if (options.help || !command) { + const commandsSection = await buildStorybookCommandsHelp(target); + process.stdout.write(`${aiCommand.helpInformation()}\n${commandsSection}\n`); + return; + } + const start = Date.now(); + const result = await runAiTool(command, commandArgs, { ...target, json: options.json }); + const duration = Date.now() - start; + try { + await printResult(result, options.output); + } finally { + // The command has executed either way, so a failed `--output` write must not lose + // the event. Reporting after printing keeps a slow telemetry endpoint from ever + // delaying the user's result. + await reportAiCommandTelemetry(command, result.outcome, duration, cliOptions); + } + } + ).catch(handleCommandFailure(options.logfile)); + } + ); +} + +/** + * The cliOptions handed to the telemetry machinery. Only the opt-out tier is forwarded — the + * passthrough's own options (cwd, port, json) may contain paths and are never sent in payloads. + * `configDir` points at the default config location of the *target* Storybook so `withTelemetry` + * resolves `core.disableTelemetry` from the project the command is aimed at (`--cwd` is accepted + * both before and after the command name, and is scanned leniently so even invocations rejected + * as `invalid-arguments` honor the target's opt-out); it is read locally, never sent. + */ +function pickCliOptions(options: AiPassthroughOptions, commandArgs: string[]): CLIOptions { + const targetCwd = scanCwdToken(commandArgs) ?? options.cwd ?? process.cwd(); + return { + disableTelemetry: options.disableTelemetry, + logfile: options.logfile, + configDir: resolve(targetCwd, '.storybook'), + }; +} + +/** + * Command names are a fixed, server-defined vocabulary of short identifiers. Anything else is + * arbitrary agent input (a typo'd path, a stray flag value) that must not be sent verbatim, so it + * is collapsed to a placeholder. The intercept reason still tells the failure class apart. + */ +function sanitizeCommandName(command: string): string { + return /^[\w-]{1,64}$/.test(command) ? command : '(invalid)'; +} + +/** + * Fire the `ai-command` event, once per executed command (storybookjs/storybook#35131). Help + * lookups are excluded so they cannot skew command success rates. Server-side failures + * additionally go through the standard sanitized error path, like errors thrown under + * `withTelemetry`. + */ +async function reportAiCommandTelemetry( + command: string, + outcome: AiCommandOutcome, + duration: number, + cliOptions: CLIOptions +): Promise { + if (outcome.kind === 'help') { + return; + } + await telemetry( + 'ai-command', + { + command: sanitizeCommandName(command), + success: outcome.kind === 'success', + ...(outcome.kind === 'intercept' && { interceptReason: outcome.reason }), + duration, + }, + // Metadata must describe the target project, consistent with the opt-out resolution. + { configDir: cliOptions.configDir } + ); + if (outcome.kind === 'error') { + await sendTelemetryError(outcome.error, 'ai-command', { cliOptions }); + } +} + +/** Print to stdout, or to the file given via the `ai` command's `-o, --output` option. */ +async function printResult( + { output, exitCode }: AiToolRunResult, + outputPath: string | undefined +): Promise { + if (outputPath) { + const resolvedPath = resolve(outputPath); + await writeFile(resolvedPath, `${output}\n`, 'utf-8'); + logger.log(`Output written to ${resolvedPath}`); + } else { + process.stdout.write(`${output}\n`); + } + if (exitCode !== 0) { + process.exitCode = exitCode; + } +} diff --git a/code/core/src/cli/ai/mcp/registry.test.ts b/code/core/src/cli/ai/mcp/registry.test.ts new file mode 100644 index 000000000000..0e0515ceba52 --- /dev/null +++ b/code/core/src/cli/ai/mcp/registry.test.ts @@ -0,0 +1,143 @@ +import { readFile, readdir, rm } from 'node:fs/promises'; + +import { vol } from 'memfs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { readRegistry } from './registry.ts'; + +// Spy-only mock: keep the real `node:fs/promises` module shape, then redirect the calls used by +// the registry reader to `memfs` so disk state stays scoped to `vol`. +vi.mock('node:fs/promises', { spy: true }); + +const REGISTRY_DIR = '/registry'; + +beforeEach(async () => { + const memfs = await vi.importActual('memfs'); + + vi.mocked(readdir).mockImplementation( + memfs.fs.promises.readdir as unknown as typeof import('node:fs/promises').readdir + ); + vi.mocked(readFile).mockImplementation( + memfs.fs.promises.readFile as unknown as typeof import('node:fs/promises').readFile + ); + vi.mocked(rm).mockImplementation( + memfs.fs.promises.rm as unknown as typeof import('node:fs/promises').rm + ); + + // Deterministic PID liveness: in these tests only the current process counts as alive. + vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid !== process.pid) { + const error = new Error('ESRCH') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }); +}); + +afterEach(() => { + vol.reset(); + vi.restoreAllMocks(); +}); + +const aliveRecord = { + schemaVersion: 1, + instanceId: 'alive-uuid', + pid: process.pid, + cwd: '/projects/alive', + url: 'http://localhost:6006', + port: 6006, + storybookVersion: '10.5.0', + startedAt: '2026-05-18T12:00:00.000Z', + updatedAt: '2026-05-18T12:00:03.000Z', + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +describe('readRegistry', () => { + it('returns [] when the registry dir does not exist', async () => { + vol.fromNestedJSON({ '/elsewhere': {} }); + await expect(readRegistry('/registry-does-not-exist')).resolves.toEqual([]); + }); + + it('returns [] when the registry dir is empty', async () => { + vol.fromNestedJSON({ [REGISTRY_DIR]: {} }); + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); + + it('parses valid records and skips dead PIDs, bad schemas, malformed JSON and non-JSON files', async () => { + const dead = { ...aliveRecord, instanceId: 'dead-uuid', pid: 2147483646 }; + const unknownStatus = { + ...aliveRecord, + instanceId: 'bad-uuid', + mcp: { status: 'unrecognised', endpoint: '/mcp' }, + }; + + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'alive.json': JSON.stringify(aliveRecord), + 'dead.json': JSON.stringify(dead), + 'bad-status.json': JSON.stringify(unknownStatus), + 'malformed.json': '{ not json', + 'wrong-shape.json': JSON.stringify({ foo: 'bar' }), + 'ignored.txt': 'should be ignored', + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([aliveRecord]); + }); + + it('removes the registry file of a dead PID', async () => { + const dead = { ...aliveRecord, instanceId: 'dead-uuid', pid: 2147483646 }; + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'dead.json': JSON.stringify(dead) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + expect(vol.toJSON()[`${REGISTRY_DIR}/dead.json`]).toBeUndefined(); + }); + + it('filters records with non-positive PIDs (process-group sentinels)', async () => { + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'zero.json': JSON.stringify({ ...aliveRecord, instanceId: 'zero', pid: 0 }), + 'negative.json': JSON.stringify({ ...aliveRecord, instanceId: 'neg', pid: -1 }), + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); + + it('treats EPERM on the liveness signal as alive (foreign-user process)', async () => { + vi.mocked(process.kill).mockImplementation(() => { + const error = new Error('EPERM') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + }); + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'alive.json': JSON.stringify(aliveRecord) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([aliveRecord]); + }); + + it('accepts records without the optional version and timestamp fields', async () => { + const minimal = { + schemaVersion: 1, + instanceId: 'minimal', + pid: process.pid, + cwd: '/projects/minimal', + url: 'http://localhost:6007', + port: 6007, + mcp: { status: 'starting' }, + }; + vol.fromNestedJSON({ [REGISTRY_DIR]: { 'minimal.json': JSON.stringify(minimal) } }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([minimal]); + }); + + it('rejects out-of-range ports', async () => { + vol.fromNestedJSON({ + [REGISTRY_DIR]: { + 'bad-port.json': JSON.stringify({ ...aliveRecord, port: 65536 }), + }, + }); + + await expect(readRegistry(REGISTRY_DIR)).resolves.toEqual([]); + }); +}); diff --git a/code/core/src/cli/ai/mcp/registry.ts b/code/core/src/cli/ai/mcp/registry.ts new file mode 100644 index 000000000000..9cc702d43ef1 --- /dev/null +++ b/code/core/src/cli/ai/mcp/registry.ts @@ -0,0 +1,82 @@ +import * as fs from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import * as v from 'valibot'; + +import { type StorybookInstanceRecord, StorybookInstanceRecordSchema } from './types.ts'; + +/** + * Must stay in sync with `getDefaultRuntimeInstanceRegistryDir` in + * `code/core/src/core-server/utils/runtime-instance-registry.ts` (the writer side). Duplicated + * here so this reader does not pull the core-server module graph into the CLI's unit tests; the + * path is specified in storybookjs/storybook#34826. + */ +export const DEFAULT_REGISTRY_DIR = join(homedir(), '.storybook', 'instances'); + +/** + * Errno codes for which we degrade to "no instance" rather than throwing. The command is meant to + * fail-soft for environmental issues; a noisy stack trace would be worse UX than the + * missing-instance repair instructions. + */ +const SOFT_REGISTRY_ERRORS = new Set(['ENOENT', 'EACCES', 'EPERM', 'ENOTDIR']); + +/** + * Read all Storybook instance records from `registryDir`. + * + * Each file is expected to be a single JSON object matching {@link StorybookInstanceRecord}. + * Records whose PID is no longer alive are filtered out (and their files removed). Malformed files + * are skipped silently — the command should degrade to "no instance" rather than fail loudly. + */ +export async function readRegistry( + registryDir: string = DEFAULT_REGISTRY_DIR +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(registryDir); + } catch (error) { + if (SOFT_REGISTRY_ERRORS.has((error as NodeJS.ErrnoException).code ?? '')) { + return []; + } + throw error; + } + + const records = await Promise.all( + entries + .filter((name) => name.endsWith('.json')) + .map(async (name) => { + try { + const raw = await fs.readFile(join(registryDir, name), 'utf-8'); + const parsed = v.safeParse(StorybookInstanceRecordSchema, JSON.parse(raw)); + if (!parsed.success) { + return null; + } + if (!isProcessAlive(parsed.output.pid)) { + await fs.rm(join(registryDir, name), { force: true }).catch(() => {}); + return null; + } + return parsed.output; + } catch { + return null; + } + }) + ); + + return records.filter((r): r is StorybookInstanceRecord => r !== null); +} + +/** + * Liveness check by sending signal 0. `EPERM` means the PID exists but we lack permission to + * signal it (foreign user), which still counts as alive. + */ +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} diff --git a/code/core/src/cli/ai/mcp/resolve-instance.test.ts b/code/core/src/cli/ai/mcp/resolve-instance.test.ts new file mode 100644 index 000000000000..79d454a937ec --- /dev/null +++ b/code/core/src/cli/ai/mcp/resolve-instance.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveInstance } from './resolve-instance.ts'; +import type { McpStatus, StorybookInstanceRecord } from './types.ts'; + +let nextInstance = 0; + +function record( + cwd: string, + status: McpStatus = 'ready', + overrides: Partial = {} +): StorybookInstanceRecord { + nextInstance += 1; + return { + schemaVersion: 1, + instanceId: `inst-${nextInstance}`, + pid: 1000 + nextInstance, + cwd, + url: `http://localhost:${6000 + nextInstance}`, + port: 6000 + nextInstance, + mcp: { + status, + endpoint: + status === 'ready' || status === 'error' + ? `http://localhost:${6000 + nextInstance}/mcp` + : undefined, + }, + ...overrides, + }; +} + +describe('resolveInstance', () => { + it('returns no-instance with empty candidates when registry is empty', () => { + const result = resolveInstance([], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'intercept', reason: 'no-instance', records: [], matches: [] }); + }); + + it('returns no-instance with candidates when no record cwd matches', () => { + const a = record('/Users/x/projects/foo'); + const b = record('/Users/x/projects/bar'); + const result = resolveInstance([a, b], '/Users/x/projects/baz'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + expect(result.records).toEqual([a, b]); + } + }); + + it('matches a record by exact normalized cwd', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('normalizes trailing slashes and dot segments before matching', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo/./'); + expect(result).toEqual({ kind: 'instance', record: r, matches: [r] }); + }); + + it('does NOT match a child path of a record cwd (exact only)', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foo/src/Button.tsx'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('does NOT match a sibling string prefix', () => { + const r = record('/Users/x/projects/foo'); + const result = resolveInstance([r], '/Users/x/projects/foobar'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); + + it('tie-breaks on lowest pid when 2+ records share the cwd and none carry a startedAt', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 200 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 100 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(b); + expect(result.matches).toEqual([b, a]); + } + }); + + it('picks the most recently started ready instance when 2+ records share the cwd', () => { + const older = record('/Users/x/projects/foo', 'ready', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newer = record('/Users/x/projects/foo', 'ready', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([older, newer], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(newer); + expect(result.matches).toEqual([newer, older]); + } + }); + + it('treats a record without startedAt as older than one with a startedAt', () => { + const noStamp = record('/Users/x/projects/foo', 'ready', { pid: 100 }); + const stamped = record('/Users/x/projects/foo', 'ready', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([noStamp, stamped], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(stamped); + } + }); + + it('prefers a ready record over a more recently started non-ready one', () => { + const ready = record('/Users/x/projects/foo', 'ready', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerStarting = record('/Users/x/projects/foo', 'starting', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([ready, newerStarting], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(ready); + } + }); + + it('dispatches the most recently started instance status when none are ready', () => { + const olderError = record('/Users/x/projects/foo', 'error', { + pid: 100, + startedAt: '2026-06-09T10:00:00.000Z', + }); + const newerStarting = record('/Users/x/projects/foo', 'starting', { + pid: 200, + startedAt: '2026-06-09T11:00:00.000Z', + }); + const result = resolveInstance([olderError, newerStarting], '/Users/x/projects/foo'); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('mcp-starting'); + } + }); + + it('prefers a ready record over non-ready ones when multiple records share the cwd', () => { + const starting = record('/Users/x/projects/foo', 'starting', { pid: 100 }); + const ready = record('/Users/x/projects/foo', 'ready', { pid: 200 }); + const result = resolveInstance([starting, ready], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(ready); + expect(result.matches).toEqual([starting, ready]); + } + }); + + it('falls back to dispatching the lowest-pid status when no record at the cwd is ready', () => { + const a = record('/Users/x/projects/foo', 'starting', { pid: 200 }); + const b = record('/Users/x/projects/foo', 'error', { pid: 100 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [b, a] }); + }); + + it('dispatches mcp.status=starting as mcp-starting intercept', () => { + const r = record('/p', 'starting'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-starting', matches: [r] }); + }); + + it('dispatches mcp.status=not-installed as addon-missing intercept', () => { + const r = record('/p', 'not-installed'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'addon-missing', matches: [r] }); + }); + + it('dispatches mcp.status=error as mcp-error intercept', () => { + const r = record('/p', 'error'); + const result = resolveInstance([r], '/p'); + expect(result).toEqual({ kind: 'intercept', reason: 'mcp-error', matches: [r] }); + }); + + it('selects the instance matching BOTH cwd and port when a port is supplied', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo', 6007); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(b); + expect(result.matches).toEqual([b]); + } + }); + + it('ignores port when it is not supplied (routes by cwd alone)', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo'); + expect(result.kind).toBe('instance'); + if (result.kind === 'instance') { + expect(result.record).toBe(a); + expect(result.matches).toEqual([a, b]); + } + }); + + it('returns port-mismatch with the cwd instances as candidates when cwd matches but no instance is on the port', () => { + const a = record('/Users/x/projects/foo', 'ready', { pid: 100, port: 6006 }); + const b = record('/Users/x/projects/foo', 'ready', { pid: 200, port: 6007 }); + const result = resolveInstance([a, b], '/Users/x/projects/foo', 9999); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('port-mismatch'); + expect(result.records).toEqual([a, b]); + expect(result.matches).toEqual([]); + } + }); + + it('returns no-instance (not port-mismatch) when the cwd itself does not match', () => { + const a = record('/Users/x/projects/foo', 'ready', { port: 6006 }); + const result = resolveInstance([a], '/Users/x/projects/bar', 6006); + expect(result.kind).toBe('intercept'); + if (result.kind === 'intercept') { + expect(result.reason).toBe('no-instance'); + } + }); +}); diff --git a/code/core/src/cli/ai/mcp/resolve-instance.ts b/code/core/src/cli/ai/mcp/resolve-instance.ts new file mode 100644 index 000000000000..4af476eab3ae --- /dev/null +++ b/code/core/src/cli/ai/mcp/resolve-instance.ts @@ -0,0 +1,132 @@ +import { resolve } from 'node:path'; + +import type { InterceptReason, StorybookInstanceRecord } from './types.ts'; + +export type ResolveResult = + | { + kind: 'instance'; + record: StorybookInstanceRecord; + matches: StorybookInstanceRecord[]; + } + | { + kind: 'intercept'; + reason: InterceptReason; + records?: StorybookInstanceRecord[]; + matches: StorybookInstanceRecord[]; + }; + +/** + * Pick the Storybook instance whose cwd exactly matches `targetCwd` after normalisation. Per + * milestone 2 of storybookjs/storybook#34826: matching is exact-normalized, with no longest-prefix + * or fallback behaviour. + * + * When `targetPort` is supplied (e.g. an agent that launched Storybook on a known port and wants + * to address that exact instance), it further constrains the cwd matches: an instance must match + * BOTH cwd and port. If the cwd matches but no instance there is on `targetPort`, a + * `port-mismatch` intercept is returned with the cwd's instances as candidates so callers can + * surface the running ports. + * + * If at least one record matches, dispatch based on the selected instance's `mcp.status`: + * + * - `ready` → forward the call + * - `starting` → mcp-starting intercept + * - `not-installed` → addon-missing intercept + * - `error` → mcp-error intercept + * + * Zero matches → no-instance intercept (callers may surface running cwds). 2+ matches at the same + * cwd → pick the most recently started instance (latest `startedAt` among `ready` records, else + * latest overall), on the assumption that the freshest instance is the one the agent just started. + * Records without a `startedAt` tie-break on lowest pid for determinism. All matches are returned + * (most-recent first) as `matches` so callers can warn the agent without blocking the call. + */ +export function resolveInstance( + records: StorybookInstanceRecord[], + targetCwd: string, + targetPort?: number +): ResolveResult { + const normalisedTarget = resolve(targetCwd); + const cwdMatches = records.filter((r) => resolve(r.cwd) === normalisedTarget); + const matches = targetPort == null ? cwdMatches : cwdMatches.filter((r) => r.port === targetPort); + + if (matches.length === 0) { + // cwd matched, but no instance there is on the requested port: a distinct, + // more actionable failure than "nothing is running here". + if (targetPort != null && cwdMatches.length > 0) { + return { + kind: 'intercept', + reason: 'port-mismatch', + records: cwdMatches, + matches: [], + }; + } + return { + kind: 'intercept', + reason: 'no-instance', + records, + matches: [], + }; + } + + const sortedMatches = [...matches].sort(byMostRecentlyStarted); + const selected = sortedMatches.find((r) => r.mcp.status === 'ready') ?? sortedMatches[0]; + + switch (selected.mcp.status) { + case 'ready': + return { + kind: 'instance', + record: selected, + matches: sortedMatches, + }; + + case 'starting': + return { + kind: 'intercept', + reason: 'mcp-starting', + matches: sortedMatches, + }; + + case 'not-installed': + return { + kind: 'intercept', + reason: 'addon-missing', + matches: sortedMatches, + }; + + case 'error': + return { + kind: 'intercept', + reason: 'mcp-error', + matches: sortedMatches, + }; + + default: { + const unhandled: never = selected.mcp.status; + throw new Error(`Unhandled MCP status: ${unhandled as string}`); + } + } +} + +/** + * `startedAt` as epoch millis, or `-Infinity` when absent/unparseable so such records sort as the + * oldest (and fall through to the pid tie-break). + */ +function startedAtMs(r: StorybookInstanceRecord): number { + if (!r.startedAt) { + return Number.NEGATIVE_INFINITY; + } + const t = Date.parse(r.startedAt); + return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t; +} + +/** + * Sort comparator: most recently started first, tie-breaking on lowest pid so ordering stays + * deterministic when timestamps are equal or missing. + */ +function byMostRecentlyStarted(a: StorybookInstanceRecord, b: StorybookInstanceRecord): number { + const ta = startedAtMs(a); + const tb = startedAtMs(b); + if (ta !== tb) { + return tb > ta ? 1 : -1; + } + return a.pid - b.pid; +} diff --git a/code/core/src/cli/ai/mcp/run-tool.test.ts b/code/core/src/cli/ai/mcp/run-tool.test.ts new file mode 100644 index 000000000000..cafd00a6e879 --- /dev/null +++ b/code/core/src/cli/ai/mcp/run-tool.test.ts @@ -0,0 +1,391 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import { readRegistry } from './registry.ts'; +import { buildStorybookCommandsHelp, runAiTool, runAiToolHelp } from './run-tool.ts'; +import type { StorybookInstanceRecord } from './types.ts'; + +vi.mock('./registry.ts', { spy: true }); +vi.mock('./client.ts', { spy: true }); + +const record: StorybookInstanceRecord = { + schemaVersion: 1, + instanceId: 'inst-1', + pid: 1, + cwd: '/projects/foo', + url: 'http://localhost:6006', + port: 6006, + mcp: { status: 'ready', endpoint: '/mcp' }, +}; + +beforeEach(() => { + vi.mocked(readRegistry).mockReset().mockResolvedValue([record]); + vi.mocked(callMcpTool) + .mockReset() + .mockResolvedValue({ content: [{ type: 'text', text: 'upstream result' }] }); + vi.mocked(listMcpTools) + .mockReset() + .mockResolvedValue([{ name: 'list-all-documentation', description: 'List docs' }]); +}); + +describe('runAiTool', () => { + it('forwards the call to the matching instance and prints the markdown result', async () => { + const result = await runAiTool('list-all-documentation', ['--withStoryIds', 'true'], { + cwd: '/projects/foo', + }); + + expect(callMcpTool).toHaveBeenCalledWith( + record, + { name: 'list-all-documentation', arguments: { withStoryIds: true } }, + undefined + ); + expect(result).toEqual({ + exitCode: 0, + output: 'upstream result', + outcome: { kind: 'success' }, + }); + }); + + it('defaults the cwd to process.cwd()', async () => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, cwd: process.cwd() }]); + const result = await runAiTool('list-all-documentation', []); + expect(result.exitCode).toBe(0); + }); + + it('merges --json arguments with --key overrides', async () => { + await runAiTool('get-documentation', ['--id', 'override'], { + cwd: '/projects/foo', + json: '{"id":"base","verbose":true}', + }); + + expect(callMcpTool).toHaveBeenCalledWith( + record, + { name: 'get-documentation', arguments: { id: 'override', verbose: true } }, + undefined + ); + }); + + it('returns the arg-parsing error without contacting the registry', async () => { + const result = await runAiTool('get-documentation', ['positional'], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unexpected argument'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'invalid-arguments' }); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it('prints the no-instance repair markdown and exits non-zero when nothing runs at the cwd', async () => { + const result = await runAiTool('get-documentation', [], { cwd: '/projects/other' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('No Storybook is running at this cwd'); + expect(result.output).toContain('/projects/foo'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'no-instance' }); + }); + + it('routes to the instance on the requested --port when several share the cwd', async () => { + const onOtherPort = { ...record, instanceId: 'inst-2', pid: 2, port: 6007 }; + vi.mocked(readRegistry).mockResolvedValue([record, onOtherPort]); + const result = await runAiTool('list-all-documentation', ['--port', '6007'], { + cwd: '/projects/foo', + }); + expect(callMcpTool).toHaveBeenCalledWith(onOtherPort, expect.anything(), undefined); + expect(result.exitCode).toBe(0); + }); + + it('prints the port-mismatch repair markdown when no instance at the cwd is on the port', async () => { + const result = await runAiTool('list-all-documentation', [], { + cwd: '/projects/foo', + port: '9999', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('not on port `9999`'); + expect(result.output).toContain('- port `6006`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'port-mismatch' }); + }); + + it('rejects an invalid --port without contacting the registry', async () => { + const result = await runAiTool('list-all-documentation', ['--port', 'abc'], { + cwd: '/projects/foo', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('`--port` must be a port number'); + expect(readRegistry).not.toHaveBeenCalled(); + }); + + it.each([ + ['starting', 'still starting up', 'mcp-starting'], + ['not-installed', '`@storybook/addon-mcp` addon is missing', 'addon-missing'], + ['error', 'Inspect the Storybook terminal output', 'mcp-error'], + ] as const)('prints the repair markdown for mcp.status=%s', async (status, expected, reason) => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status } }]); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain(expected); + expect(result.outcome).toEqual({ kind: 'intercept', reason }); + }); + + it('prints a placeholder when the tool returns no content', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ content: [] }); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result).toEqual({ + exitCode: 0, + output: '(the command returned no content)', + outcome: { kind: 'success' }, + }); + }); + + it('surfaces a clean error when a ready record is missing its endpoint', async () => { + vi.mocked(callMcpTool).mockRejectedValue( + new Error('The Storybook instance at /projects/foo has no server endpoint registered') + ); + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status: 'ready' } }]); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to reach the Storybook server at (no endpoint)'); + }); + + it('renders non-text content items as JSON blocks', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ + content: [ + { type: 'text', text: 'intro' }, + { type: 'resource_link', uri: 'http://x' }, + ], + }); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.output).toContain('intro'); + expect(result.output).toContain('```json'); + expect(result.output).toContain('"resource_link"'); + }); + + it('lists the available tools when the call fails because the tool is unknown', async () => { + vi.mocked(callMcpTool).mockRejectedValue(new McpJsonRpcError(-32601, 'unknown tool')); + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'unknown-command' }); + }); + + it('lists the available tools when the server reports the unknown tool as an error result', async () => { + // addon-mcp (tmcp) reports unknown tools as an isError result, not a JSON-RPC error. + vi.mocked(callMcpTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Tool no-such-tool not found' }], + isError: true, + }); + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + expect(result.outcome).toEqual({ kind: 'intercept', reason: 'unknown-command' }); + }); + + it('keeps the original error result when the failing tool does exist', async () => { + vi.mocked(callMcpTool).mockResolvedValue({ + content: [{ type: 'text', text: 'tests failed' }], + isError: true, + }); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result).toEqual({ + exitCode: 1, + output: 'tests failed', + outcome: { kind: 'error', error: expect.objectContaining({ name: 'McpToolResultError' }) }, + }); + // Constant message keeps the telemetry error hash aggregatable; the tool's error text only + // travels as `cause` (uploaded path-sanitized, and only with crash-reports consent). + const error = (result.outcome as { error: Error }).error; + expect(error.message).toBe('The Storybook MCP server returned an error result'); + expect(error.cause).toBe('tests failed'); + }); + + it('prints the original JSON-RPC error when the tool exists', async () => { + const error = new McpJsonRpcError(-32602, 'invalid arguments'); + vi.mocked(callMcpTool).mockRejectedValue(error); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook server error -32602: invalid arguments'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('prints the original JSON-RPC error when the tool list cannot be fetched', async () => { + const error = new McpJsonRpcError(-32601, 'unknown tool'); + vi.mocked(callMcpTool).mockRejectedValue(error); + vi.mocked(listMcpTools).mockRejectedValue(new Error('boom')); + const result = await runAiTool('no-such-tool', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook server error -32601: unknown tool'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('surfaces a friendly error when the MCP server is unreachable', async () => { + const error = new Error('connection refused'); + vi.mocked(callMcpTool).mockRejectedValue(error); + const result = await runAiTool('get-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to reach the Storybook server at /mcp'); + expect(result.output).toContain('connection refused'); + expect(result.outcome).toEqual({ kind: 'error', error }); + }); + + it('prepends a warning when multiple instances run at the same cwd', async () => { + const sibling = { ...record, instanceId: 'inst-2', pid: 2, url: 'http://localhost:6007' }; + vi.mocked(readRegistry).mockResolvedValue([record, sibling]); + const result = await runAiTool('list-all-documentation', [], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Multiple Storybook instances'); + expect(result.output).toContain('pid `1`'); + expect(result.output).toContain('pid `2`'); + expect(result.output).toContain('(used)'); + expect(result.output).toContain('upstream result'); + }); +}); + +describe('buildStorybookCommandsHelp', () => { + it('lists each tool with the first line of its description', async () => { + vi.mocked(listMcpTools).mockResolvedValue([ + { + name: 'get-documentation', + description: 'Get docs for a component.\n\nLong details that should not appear.', + }, + { name: 'list-all-documentation' }, + ]); + + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain( + 'Storybook commands (from the Storybook running at http://localhost:6006):' + ); + expect(section).toContain('get-documentation'); + expect(section).toContain('Get docs for a component.'); + expect(section).not.toContain('Long details'); + expect(section).toContain("Run 'storybook ai --help'"); + }); + + it('degrades to a note when no Storybook is running (help must not fail)', async () => { + vi.mocked(readRegistry).mockResolvedValue([]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('Storybook commands: (unavailable'); + expect(section).toContain('storybook dev'); + }); + + it('lists the sibling ports when several instances run at the cwd', async () => { + const older = { ...record, instanceId: 'inst-2', pid: 2, port: 6007 }; + const newest = { ...record, startedAt: '2026-06-10T12:00:00.000Z' }; + vi.mocked(readRegistry).mockResolvedValue([newest, older]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('2 instances are running at this cwd'); + expect(section).toContain('port 6006'); + expect(section).toContain('other ports: 6007'); + expect(section).toContain('`--port`'); + }); + + it('names the port mismatch instead of claiming nothing is running', async () => { + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo', port: '9999' }); + expect(section).toContain('Storybook commands: (unavailable'); + expect(section).toContain('no instance on port `9999`'); + expect(section).toContain('running ports: 6006'); + expect(section).not.toContain('no running Storybook detected'); + }); + + it('says the Storybook is starting up instead of claiming nothing is running', async () => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status: 'starting' } }]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('still starting up'); + }); + + it('points at the missing addon instead of claiming nothing is running', async () => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, mcp: { status: 'not-installed' } }]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('install `@storybook/addon-mcp`'); + }); + + it('shows the Storybook version reported by the running instance', async () => { + vi.mocked(readRegistry).mockResolvedValue([{ ...record, storybookVersion: '10.5.0' }]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain( + 'Storybook commands (from the Storybook running at http://localhost:6006, Storybook 10.5.0):' + ); + }); + + it('degrades to a note when the MCP server is unreachable', async () => { + vi.mocked(listMcpTools).mockRejectedValue(new Error('connection refused')); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('Storybook commands: (unavailable'); + expect(section).toContain('could not be reached'); + }); + + it('degrades to a note when no tools are exposed', async () => { + vi.mocked(listMcpTools).mockResolvedValue([]); + const section = await buildStorybookCommandsHelp({ cwd: '/projects/foo' }); + expect(section).toContain('provides no commands'); + }); +}); + +describe('runAiToolHelp', () => { + it('prints the description and arguments of a single tool', async () => { + vi.mocked(listMcpTools).mockResolvedValue([ + { + name: 'get-documentation', + description: 'Get docs for a component.', + inputSchema: { + properties: { id: { type: 'string', description: 'Documentation id' } }, + required: ['id'], + }, + }, + ]); + + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + expect(result.output).toContain('Get docs for a component.'); + expect(result.output).toContain('- `--id` (string, required): Documentation id'); + expect(result.outcome).toEqual({ kind: 'help' }); + }); + + it('is reachable through runAiTool via a --help token after the tool name', async () => { + vi.mocked(listMcpTools).mockResolvedValue([ + { name: 'get-documentation', description: 'Get docs.' }, + ]); + const result = await runAiTool('get-documentation', ['--help'], { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('Usage: storybook ai get-documentation'); + expect(result.outcome).toEqual({ kind: 'help' }); + expect(callMcpTool).not.toHaveBeenCalled(); + }); + + it('honors a --port token given after the command name on the help path', async () => { + const result = await runAiTool('get-documentation', ['--port', '9999', '--help'], { + cwd: '/projects/foo', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('not on port `9999`'); + }); + + it('lists the available tools for an unknown tool name', async () => { + const result = await runAiToolHelp('no-such-tool', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Unknown command `no-such-tool`'); + expect(result.output).toContain('- `list-all-documentation`'); + }); + + it('prints repair markdown and exits non-zero on intercepts, still classified as help', async () => { + vi.mocked(readRegistry).mockResolvedValue([]); + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Storybook is not running at this cwd'); + expect(result.outcome).toEqual({ kind: 'help' }); + }); + + it('rejects an invalid --port', async () => { + const result = await runAiToolHelp('get-documentation', { + cwd: '/projects/foo', + port: 'abc', + }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('`--port` must be a port number'); + }); + + it('surfaces a friendly error when the MCP server is unreachable', async () => { + vi.mocked(listMcpTools).mockRejectedValue(new Error('connection refused')); + const result = await runAiToolHelp('get-documentation', { cwd: '/projects/foo' }); + expect(result.exitCode).toBe(1); + expect(result.output).toContain('Failed to reach the Storybook server at /mcp'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/run-tool.ts b/code/core/src/cli/ai/mcp/run-tool.ts new file mode 100644 index 000000000000..5fe2b058e3a1 --- /dev/null +++ b/code/core/src/cli/ai/mcp/run-tool.ts @@ -0,0 +1,404 @@ +import { resolve } from 'node:path'; + +import { McpJsonRpcError, callMcpTool, listMcpTools } from './client.ts'; +import { getInterceptMarkdown } from './intercepts.ts'; +import { readRegistry } from './registry.ts'; +import { resolveInstance } from './resolve-instance.ts'; +import { parseToolArgs } from './tool-args.ts'; +import type { + InterceptReason, + McpToolDescriptor, + StorybookInstanceRecord, + ToolCallResult, +} from './types.ts'; + +/** + * Why an invocation failed before any command executed, for the `ai-command` telemetry event + * (storybookjs/storybook#35131). Extends the instance-resolution intercepts with the two CLI-level + * cases: arguments that never parsed, and command names the server does not provide. + */ +export type AiCommandInterceptReason = InterceptReason | 'invalid-arguments' | 'unknown-command'; + +/** + * Telemetry-facing classification of a run. `help` marks lookups via `--help` flags, which are not + * command executions and are excluded from the `ai-command` event so they cannot skew command + * success rates. `error` carries failures from the server side (error results, transport + * failures, timeouts) for the standard sanitized error path. + */ +export type AiCommandOutcome = + | { kind: 'success' } + | { kind: 'help' } + | { kind: 'intercept'; reason: AiCommandInterceptReason } + | { kind: 'error'; error: unknown }; + +export type AiToolRunResult = { exitCode: 0 | 1; output: string; outcome: AiCommandOutcome }; + +/** + * The server executed the command and reported an error result. The message is deliberately + * constant — the result text is arbitrary tool output (often containing project paths), and a + * constant message keeps the telemetry error hash stable and aggregatable. The tool's error text + * travels as `cause` instead, which the standard error path only uploads — path-sanitized — when + * the user opted into crash reports. + */ +class McpToolResultError extends Error { + constructor(options?: ErrorOptions) { + super('The Storybook MCP server returned an error result', options); + this.name = 'McpToolResultError'; + } +} + +/** Injectable dependencies for tests. */ +export type AiToolRunDeps = { + registryDir?: string; + fetchImpl?: typeof fetch; +}; + +export type AiToolOptions = { + /** Project directory of the target Storybook; defaults to `process.cwd()`. */ + cwd?: string; + /** Port of the target Storybook, to address one specific instance when several share the cwd. */ + port?: string; + /** Raw JSON object with tool arguments (escape hatch for complex values). */ + json?: string; +}; + +/** + * Run a single MCP tool against the Storybook running at the target cwd and return its result as + * markdown. Intercept conditions (no running instance, addon missing, ...) return the same + * repair-instruction markdown as `@storybook/mcp-proxy`, with exit code 1. + */ +export async function runAiTool( + toolName: string, + toolArgTokens: string[], + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + const parsed = parseToolArgs(toolArgTokens, { + cwd: options.cwd, + port: options.port, + json: options.json, + }); + if (!parsed.ok) { + return { + exitCode: 1, + output: parsed.error, + outcome: { kind: 'intercept', reason: 'invalid-arguments' }, + }; + } + if (parsed.help) { + return toolHelp(toolName, parsed.cwd, parsed.port, deps); + } + + const resolution = await resolveReadyInstance(parsed.cwd, parsed.port, deps); + if (resolution.kind === 'error') { + return { + exitCode: 1, + output: resolution.output, + outcome: { kind: 'intercept', reason: resolution.reason }, + }; + } + const { record, matches } = resolution; + + try { + const result = await callMcpTool( + record, + { name: toolName, arguments: parsed.args }, + deps.fetchImpl + ); + if (result.isError) { + // addon-mcp reports unknown tools as an error *result* rather than a JSON-RPC error. + const unknownTool = await describeUnknownTool(record, toolName, deps.fetchImpl); + if (unknownTool) { + return { + exitCode: 1, + output: unknownTool, + outcome: { kind: 'intercept', reason: 'unknown-command' }, + }; + } + } + const siblings = matches.filter((r) => r !== record); + const toolOutput = formatToolResult(result); + const sections = [ + ...(siblings.length > 0 ? [formatMultiInstanceWarning(record, siblings)] : []), + toolOutput, + ]; + const output = sections.join('\n\n'); + if (result.isError) { + return { + exitCode: 1, + output, + outcome: { kind: 'error', error: new McpToolResultError({ cause: toolOutput }) }, + }; + } + return { exitCode: 0, output, outcome: { kind: 'success' } }; + } catch (error) { + if (error instanceof McpJsonRpcError) { + const unknownTool = await describeUnknownTool(record, toolName, deps.fetchImpl); + if (unknownTool) { + return { + exitCode: 1, + output: unknownTool, + outcome: { kind: 'intercept', reason: 'unknown-command' }, + }; + } + return { exitCode: 1, output: error.message, outcome: { kind: 'error', error } }; + } + return { + exitCode: 1, + output: formatServerUnreachable(record, error), + outcome: { kind: 'error', error }, + }; + } +} + +/** + * Build the "Storybook commands" help section listing the commands provided by the running + * Storybook, appended to `storybook ai --help`. Help must never fail, so any error degrades to a + * short note explaining why no commands are listed. + */ +export async function buildStorybookCommandsHelp( + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + const unavailable = (note: string) => `Storybook commands: (unavailable — ${note})`; + + const parsed = parseToolArgs([], { cwd: options.cwd, port: options.port }); + if (!parsed.ok) { + return unavailable(parsed.error); + } + + const resolution = await resolveReadyInstance(parsed.cwd, parsed.port, deps); + if (resolution.kind === 'error') { + return unavailable(helpUnavailableNote(resolution, parsed.port)); + } + const { record, matches } = resolution; + + let tools: McpToolDescriptor[]; + try { + tools = await listMcpTools(record, deps.fetchImpl); + } catch { + return unavailable(`the Storybook at ${record.url} could not be reached`); + } + if (tools.length === 0) { + return unavailable(`the Storybook at ${record.url} provides no commands`); + } + + const siblingPorts = matches.filter((r) => r !== record).map((r) => r.port); + const siblingNote = + siblingPorts.length > 0 + ? [ + `(${matches.length} instances are running at this cwd — using the most recently started, port ${record.port}; other ports: ${siblingPorts.join(', ')}. Pass \`--port\` to target a specific one.)`, + ] + : []; + + const width = Math.max(...tools.map((tool) => tool.name.length)) + 2; + const lines = tools.map((tool) => { + const summary = tool.description?.trim().split('\n')[0] ?? ''; + return ` ${tool.name.padEnd(width)}${summary}`; + }); + const version = record.storybookVersion ? `, Storybook ${record.storybookVersion}` : ''; + return [ + `Storybook commands (from the Storybook running at ${record.url}${version}):`, + ...siblingNote, + ...lines, + '', + `Run 'storybook ai --help' for a command's description and arguments.`, + ].join('\n'); +} + +/** One-line reason why the help section cannot list commands, accurate per intercept. */ +function helpUnavailableNote( + error: Extract, + port: number | undefined +): string { + switch (error.reason) { + case 'no-instance': + return 'no running Storybook detected at this cwd; start `storybook dev` to list its commands'; + case 'port-mismatch': + return `no instance on port \`${port}\` at this cwd — running ports: ${error.records + .map((r) => r.port) + .join(', ')}`; + case 'mcp-starting': + return 'the Storybook at this cwd is still starting up; retry in a moment'; + case 'addon-missing': + return 'the running Storybook does not provide commands — install `@storybook/addon-mcp`'; + case 'mcp-error': + return "the running Storybook's command server reported an error"; + default: { + const unhandled: never = error.reason; + throw new Error(`Unhandled intercept reason: ${unhandled as string}`); + } + } +} + +/** Show the description and arguments of a single command (`storybook ai --help`). */ +export async function runAiToolHelp( + toolName: string, + options: AiToolOptions = {}, + deps: AiToolRunDeps = {} +): Promise { + const parsed = parseToolArgs([], { cwd: options.cwd, port: options.port }); + if (!parsed.ok) { + return { exitCode: 1, output: parsed.error, outcome: { kind: 'help' } }; + } + return toolHelp(toolName, parsed.cwd, parsed.port, deps); +} + +/** All paths are help lookups, so every outcome is `help` regardless of success. */ +async function toolHelp( + toolName: string, + cwd: string | undefined, + port: number | undefined, + deps: AiToolRunDeps +): Promise { + const outcome: AiCommandOutcome = { kind: 'help' }; + + const resolution = await resolveReadyInstance(cwd, port, deps); + if (resolution.kind === 'error') { + return { exitCode: 1, output: resolution.output, outcome }; + } + const { record } = resolution; + + let tools: McpToolDescriptor[]; + try { + tools = await listMcpTools(record, deps.fetchImpl); + } catch (error) { + return { exitCode: 1, output: formatServerUnreachable(record, error), outcome }; + } + + const tool = tools.find((candidate) => candidate.name === toolName); + if (!tool) { + return { exitCode: 1, output: formatUnknownTool(toolName, tools, record), outcome }; + } + return { exitCode: 0, output: formatToolHelp(tool), outcome }; +} + +function formatServerUnreachable(record: StorybookInstanceRecord, error: unknown): string { + return `Failed to reach the Storybook server at ${record.mcp.endpoint ?? '(no endpoint)'}: ${ + error instanceof Error ? error.message : String(error) + }`; +} + +type InstanceResolution = + | { kind: 'ok'; record: StorybookInstanceRecord; matches: StorybookInstanceRecord[] } + | { + kind: 'error'; + output: string; + reason: InterceptReason; + records: StorybookInstanceRecord[]; + }; + +/** + * Resolve the running Storybook instance for `cwdInput` via the registry. No version or + * installed checks: the CLI is invoked as `npx storybook`, so the fact that it is executing + * already proves the project has a compatible Storybook. + */ +async function resolveReadyInstance( + cwdInput: string | undefined, + port: number | undefined, + deps: AiToolRunDeps +): Promise { + const cwd = resolve(cwdInput ?? process.cwd()); + + const records = await readRegistry(deps.registryDir); + const resolution = resolveInstance(records, cwd, port); + + if (resolution.kind === 'intercept') { + return { + kind: 'error', + output: getInterceptMarkdown(resolution.reason, { records: resolution.records, port }), + reason: resolution.reason, + records: resolution.records ?? [], + }; + } + + return { kind: 'ok', record: resolution.record, matches: resolution.matches }; +} + +/** + * Build the "unknown tool" error listing the available tools, or null when the tool does exist + * (the JSON-RPC error had another cause) or the tool list cannot be fetched. + */ +async function describeUnknownTool( + record: StorybookInstanceRecord, + toolName: string, + fetchImpl?: typeof fetch +): Promise { + let tools: McpToolDescriptor[]; + try { + tools = await listMcpTools(record, fetchImpl); + } catch { + return null; + } + if (tools.some((tool) => tool.name === toolName)) { + return null; + } + return formatUnknownTool(toolName, tools, record); +} + +function formatUnknownTool( + toolName: string, + tools: McpToolDescriptor[], + record: StorybookInstanceRecord +): string { + return `Unknown command \`${toolName}\`. The Storybook running at ${record.url} provides: + +${tools.map((tool) => `- \`${tool.name}\``).join('\n')} + +Run \`storybook ai --help\` for all commands, or \`storybook ai --help\` for a command's arguments.`; +} + +/** Render a tools/call result as markdown: text content verbatim, other content as JSON blocks. */ +function formatToolResult(result: ToolCallResult): string { + const content = result.content ?? []; + if (content.length === 0) { + return '(the command returned no content)'; + } + return content + .map((item) => + item.type === 'text' && typeof item.text === 'string' + ? item.text + : `\`\`\`json\n${JSON.stringify(item, null, 2)}\n\`\`\`` + ) + .join('\n\n'); +} + +function formatToolHelp(tool: McpToolDescriptor): string { + const lines = [`Usage: storybook ai ${tool.name} [--key value ...]`]; + if (tool.description) { + lines.push('', tool.description.trim()); + } + const properties = Object.entries(tool.inputSchema?.properties ?? {}); + if (properties.length > 0) { + const required = new Set(tool.inputSchema?.required ?? []); + lines.push( + '', + 'Arguments:', + ...properties.map(([name, schema]) => { + const meta = [schema.type, required.has(name) ? 'required' : undefined] + .filter(Boolean) + .join(', '); + const description = schema.description ? `: ${schema.description}` : ''; + return `- \`--${name}\`${meta ? ` (${meta})` : ''}${description}`; + }) + ); + } + return lines.join('\n'); +} + +function formatMultiInstanceWarning( + chosen: StorybookInstanceRecord, + siblings: StorybookInstanceRecord[] +): string { + const all = [chosen, ...siblings]; + const lines = all.map((r) => { + const marker = r === chosen ? ' (used)' : ''; + return `> - pid \`${r.pid}\` at ${r.url} (status: \`${r.mcp.status}\`)${marker}`; + }); + return `> Warning: Multiple Storybook instances are running at this cwd. This call was sent to pid \`${chosen.pid}\`. +> +> Instances at \`${chosen.cwd}\`: +${lines.join('\n')} +> +> If results look unexpected, ask the user whether they want to stop the other instance(s).`; +} diff --git a/code/core/src/cli/ai/mcp/tool-args.test.ts b/code/core/src/cli/ai/mcp/tool-args.test.ts new file mode 100644 index 000000000000..e6e10ef8144e --- /dev/null +++ b/code/core/src/cli/ai/mcp/tool-args.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; + +import { parseToolArgs, scanCwdToken } from './tool-args.ts'; + +function args(tokens: string[], defaults?: { cwd?: string; port?: string; json?: string }) { + const result = parseToolArgs(tokens, defaults); + if (!result.ok) { + throw new Error(`expected ok, got error: ${result.error}`); + } + return result; +} + +function error(tokens: string[], defaults?: { cwd?: string; port?: string; json?: string }) { + const result = parseToolArgs(tokens, defaults); + if (result.ok) { + throw new Error(`expected error, got ok: ${JSON.stringify(result.args)}`); + } + return result.error; +} + +describe('parseToolArgs', () => { + it('returns empty args for no tokens', () => { + expect(args([])).toEqual({ ok: true, cwd: undefined, port: undefined, help: false, args: {} }); + }); + + it('consumes --help and -h as a help request instead of forwarding them', () => { + expect(args(['--help'])).toMatchObject({ help: true, args: {} }); + expect(args(['-h'])).toMatchObject({ help: true, args: {} }); + expect(args(['--id', 'x', '--help'])).toMatchObject({ help: true, args: { id: 'x' } }); + }); + + it('maps `--key value` pairs to tool arguments', () => { + expect(args(['--id', 'button-docs']).args).toEqual({ id: 'button-docs' }); + }); + + it('supports `--key=value`', () => { + expect(args(['--id=button-docs']).args).toEqual({ id: 'button-docs' }); + }); + + describe('JSON-parse coercion', () => { + it('coerces booleans, numbers and null', () => { + expect(args(['--a', 'true', '--b', '42', '--c', 'null']).args).toEqual({ + a: true, + b: 42, + c: null, + }); + }); + + it('coerces JSON arrays and objects', () => { + expect(args(['--ids', '["a","b"]', '--filter', '{"tag":"x"}']).args).toEqual({ + ids: ['a', 'b'], + filter: { tag: 'x' }, + }); + }); + + it('falls back to the raw string when the value is not valid JSON', () => { + expect(args(['--id', 'button-docs', '--path', 'src/Button.tsx']).args).toEqual({ + id: 'button-docs', + path: 'src/Button.tsx', + }); + }); + + it('unquotes explicitly quoted JSON strings', () => { + expect(args(['--id', '"true"']).args).toEqual({ id: 'true' }); + }); + + it('accepts negative numbers as values', () => { + expect(args(['--offset', '-1']).args).toEqual({ offset: -1 }); + }); + }); + + it('treats a bare `--flag` as true', () => { + expect(args(['--withStoryIds']).args).toEqual({ withStoryIds: true }); + expect(args(['--withStoryIds', '--id', 'x']).args).toEqual({ withStoryIds: true, id: 'x' }); + }); + + it('lets the last occurrence of a repeated key win', () => { + expect(args(['--id', 'a', '--id', 'b']).args).toEqual({ id: 'b' }); + }); + + describe('--cwd', () => { + it('consumes --cwd instead of forwarding it', () => { + expect(args(['--cwd', '/projects/foo', '--id', 'x'])).toEqual({ + ok: true, + cwd: '/projects/foo', + port: undefined, + help: false, + args: { id: 'x' }, + }); + }); + + it('uses the commander-parsed default when not repeated in the tokens', () => { + expect(args(['--id', 'x'], { cwd: '/projects/foo' }).cwd).toBe('/projects/foo'); + }); + + it('prefers a --cwd token over the commander-parsed default', () => { + expect(args(['--cwd', '/b'], { cwd: '/a' }).cwd).toBe('/b'); + }); + + it('errors when --cwd has no value', () => { + expect(error(['--cwd'])).toContain('`--cwd` requires a value'); + }); + }); + + describe('--port', () => { + it('consumes --port as a number instead of forwarding it', () => { + expect(args(['--port', '6006', '--id', 'x'])).toEqual({ + ok: true, + cwd: undefined, + port: 6006, + help: false, + args: { id: 'x' }, + }); + }); + + it('uses the commander-parsed default and lets a token override it', () => { + expect(args(['--id', 'x'], { port: '6006' }).port).toBe(6006); + expect(args(['--port', '6007'], { port: '6006' }).port).toBe(6007); + }); + + it('errors on non-numeric or out-of-range ports', () => { + expect(error(['--port', 'abc'])).toContain('`--port` must be a port number'); + expect(error(['--port', '0'])).toContain('`--port` must be a port number'); + expect(error(['--port', '65536'])).toContain('`--port` must be a port number'); + expect(error(['--port', '6006.5'])).toContain('`--port` must be a port number'); + }); + + it('errors when --port has no value', () => { + expect(error(['--port'])).toContain('`--port` requires a value'); + }); + }); + + describe('--json escape hatch', () => { + it('uses the JSON object as the tool arguments', () => { + expect(args(['--json', '{"id":"x","n":1}']).args).toEqual({ id: 'x', n: 1 }); + }); + + it('lets explicit --key flags override --json entries', () => { + expect(args(['--json', '{"id":"x","n":1}', '--id', 'y']).args).toEqual({ id: 'y', n: 1 }); + }); + + it('accepts --json parsed by commander before the tool name', () => { + expect(args(['--id', 'y'], { json: '{"id":"x","n":1}' }).args).toEqual({ id: 'y', n: 1 }); + }); + + it('errors on invalid JSON', () => { + expect(error(['--json', '{nope'])).toContain('`--json` must be valid JSON'); + }); + + it('errors when the JSON is not an object', () => { + expect(error(['--json', '[1,2]'])).toContain('must be a JSON object'); + expect(error(['--json', '"text"'])).toContain('must be a JSON object'); + expect(error(['--json', 'null'])).toContain('must be a JSON object'); + }); + + it('errors when --json has no value', () => { + expect(error(['--json'])).toContain('`--json` requires a value'); + }); + }); + + it('errors on positional tokens', () => { + expect(error(['positional'])).toContain('Unexpected argument `positional`'); + }); + + it('errors on a bare `--` separator', () => { + expect(error(['--'])).toContain('Unexpected argument `--`'); + }); + + it('errors on `--=value`', () => { + expect(error(['--=x'])).toContain('Invalid flag'); + }); +}); + +describe('scanCwdToken', () => { + it('finds `--cwd value` and `--cwd=value`', () => { + expect(scanCwdToken(['--cwd', '/x'])).toBe('/x'); + expect(scanCwdToken(['--cwd=/x'])).toBe('/x'); + }); + + it('returns undefined without a --cwd token or without its value', () => { + expect(scanCwdToken([])).toBeUndefined(); + expect(scanCwdToken(['--id', 'x'])).toBeUndefined(); + expect(scanCwdToken(['--cwd'])).toBeUndefined(); + expect(scanCwdToken(['--cwd', '--id'])).toBeUndefined(); + }); + + it('lets the last occurrence win, matching the full parser', () => { + expect(scanCwdToken(['--cwd', '/a', '--cwd=/b'])).toBe('/b'); + }); + + it('tolerates tokens the full parser rejects', () => { + expect(parseToolArgs(['positional', '--cwd', '/x'])).toMatchObject({ ok: false }); + expect(scanCwdToken(['positional', '--cwd', '/x'])).toBe('/x'); + expect(scanCwdToken(['--cwd', '/x', '--json', '{bad'])).toBe('/x'); + }); +}); diff --git a/code/core/src/cli/ai/mcp/tool-args.ts b/code/core/src/cli/ai/mcp/tool-args.ts new file mode 100644 index 000000000000..85a83f8cfdf0 --- /dev/null +++ b/code/core/src/cli/ai/mcp/tool-args.ts @@ -0,0 +1,155 @@ +export type ParsedToolArgs = + | { + ok: true; + cwd: string | undefined; + port: number | undefined; + help: boolean; + args: Record; + } + | { ok: false; error: string }; + +/** + * Parse the pass-through tokens after `storybook ai ` into MCP tool arguments. + * + * - `--key value` and `--key=value` become tool arguments; values are coerced by attempting + * `JSON.parse`, falling back to the raw string. + * - A bare `--key` (no value) becomes `true`. + * - `--json ''` is an escape hatch providing the raw argument object; explicit `--key` + * flags override its entries. + * - `--cwd `, `--port ` and `--help`/`-h` are consumed by the CLI itself and never + * forwarded to the tool. + * + * `defaults` carries `--cwd`/`--port`/`--json` values that commander already parsed before the + * tool name; the same flags appearing after the tool name take precedence. + */ +export function parseToolArgs( + tokens: string[], + defaults: { cwd?: string; port?: string; json?: string } = {} +): ParsedToolArgs { + let cwd = defaults.cwd; + let rawPort = defaults.port; + let rawJson = defaults.json; + let help = false; + const flagArgs: Record = {}; + + let i = 0; + while (i < tokens.length) { + const token = tokens[i]; + i += 1; + + if (token === '--help' || token === '-h') { + help = true; + continue; + } + + if (!token.startsWith('--') || token === '--') { + return { + ok: false, + error: `Unexpected argument \`${token}\`. Command arguments must be passed as \`--key value\` flags (or via \`--json ''\`).`, + }; + } + + let key = token.slice(2); + let value: string | undefined; + const equalsIndex = key.indexOf('='); + if (equalsIndex !== -1) { + value = key.slice(equalsIndex + 1); + key = key.slice(0, equalsIndex); + } else if (i < tokens.length && !tokens[i].startsWith('--')) { + value = tokens[i]; + i += 1; + } + + if (key === '') { + return { ok: false, error: `Invalid flag \`${token}\`.` }; + } + + if (key === 'cwd') { + if (value === undefined) { + return { ok: false, error: '`--cwd` requires a value.' }; + } + cwd = value; + continue; + } + + if (key === 'port') { + if (value === undefined) { + return { ok: false, error: '`--port` requires a value.' }; + } + rawPort = value; + continue; + } + + if (key === 'json') { + if (value === undefined) { + return { ok: false, error: '`--json` requires a value.' }; + } + rawJson = value; + continue; + } + + flagArgs[key] = value === undefined ? true : coerceValue(value); + } + + let port: number | undefined; + if (rawPort !== undefined) { + port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { + ok: false, + error: `\`--port\` must be a port number (1-65535), got \`${rawPort}\`.`, + }; + } + } + + let jsonArgs: Record = {}; + if (rawJson !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(rawJson); + } catch (error) { + return { + ok: false, + error: `\`--json\` must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { + ok: false, + error: '`--json` must be a JSON object, e.g. \'{"id": "button-docs"}\'.', + }; + } + jsonArgs = parsed as Record; + } + + return { ok: true, cwd, port, help, args: { ...jsonArgs, ...flagArgs } }; +} + +/** + * Extract only the `--cwd` value from pass-through tokens, tolerating tokens that + * {@link parseToolArgs} would reject. Telemetry opt-out resolution must locate the target project + * even when the invocation itself fails as `invalid-arguments` — that intercept still fires an + * event (storybookjs/storybook#35131). Mirrors the full parser's `--cwd` grammar: `--cwd value` + * or `--cwd=value`, last occurrence wins. + */ +export function scanCwdToken(tokens: string[]): string | undefined { + let cwd: string | undefined; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token === '--cwd' && i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) { + cwd = tokens[i + 1]; + i += 1; + } else if (token.startsWith('--cwd=')) { + cwd = token.slice('--cwd='.length); + } + } + return cwd; +} + +function coerceValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} diff --git a/code/core/src/cli/ai/mcp/types.ts b/code/core/src/cli/ai/mcp/types.ts new file mode 100644 index 000000000000..fc6329479128 --- /dev/null +++ b/code/core/src/cli/ai/mcp/types.ts @@ -0,0 +1,82 @@ +/** + * Reader-side types for the `storybook ai ` MCP passthrough, copied from + * `@storybook/mcp-proxy` (storybookjs/mcp) per storybookjs/storybook#35124. The + * writer side lives in `code/core/src/core-server/utils/runtime-instance-registry.ts`; + * this reader is intentionally more lenient (extra statuses, optional fields) so it + * also accepts records written by other Storybook versions and wrappers. + */ +import * as v from 'valibot'; + +/** + * The in-repo writer only emits `not-installed` and `ready`; `starting` and `error` are written by + * external wrappers (e.g. the storybookjs/mcp launch script) and must keep being dispatched here. + */ +export const McpStatusSchema = v.picklist(['not-installed', 'starting', 'ready', 'error']); +export type McpStatus = v.InferOutput; + +/** + * A single Storybook runtime record written under the registry dir (default + * `~/.storybook/instances`). One file per running `storybook dev` instance. + * Spec: storybookjs/storybook#34826. + */ +export const StorybookInstanceRecordSchema = v.object({ + schemaVersion: v.literal(1), + instanceId: v.string(), + pid: v.pipe(v.number(), v.minValue(1), v.integer()), + cwd: v.string(), + url: v.string(), + port: v.pipe(v.number(), v.minValue(1), v.maxValue(65535), v.integer()), + storybookVersion: v.optional(v.string()), + startedAt: v.optional(v.string()), + updatedAt: v.optional(v.string()), + mcp: v.object({ + status: McpStatusSchema, + endpoint: v.optional(v.string()), + }), +}); +export type StorybookInstanceRecord = v.InferOutput; + +export type InterceptReason = + | 'no-instance' + | 'port-mismatch' + | 'addon-missing' + | 'mcp-starting' + | 'mcp-error'; + +/** + * Result of an MCP `tools/call` request, as returned by `@storybook/addon-mcp`. Loose: servers may + * legally attach extra fields (`_meta`, `structuredContent`, image/audio content properties); we + * validate only what the CLI renders and pass the rest through. + */ +export const ToolResultContentItemSchema = v.looseObject({ + type: v.string(), + text: v.optional(v.string()), +}); +export type ToolResultContentItem = v.InferOutput; + +export const ToolCallResultSchema = v.looseObject({ + content: v.optional(v.array(ToolResultContentItemSchema)), + isError: v.optional(v.boolean()), +}); +export type ToolCallResult = v.InferOutput; + +/** A tool descriptor from an MCP `tools/list` response. */ +export const McpToolDescriptorSchema = v.looseObject({ + name: v.string(), + description: v.optional(v.string()), + inputSchema: v.optional( + v.looseObject({ + properties: v.optional( + v.record( + v.string(), + v.looseObject({ + type: v.optional(v.string()), + description: v.optional(v.string()), + }) + ) + ), + required: v.optional(v.array(v.string())), + }) + ), +}); +export type McpToolDescriptor = v.InferOutput; diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts b/code/core/src/cli/ai/setup-prompts/index.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/index.ts rename to code/core/src/cli/ai/setup-prompts/index.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts b/code/core/src/cli/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts rename to code/core/src/cli/ai/setup-prompts/monorepo-optimized-tests-relaxed-limits-no-story-deletion.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts b/code/core/src/cli/ai/setup-prompts/monorepo.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts rename to code/core/src/cli/ai/setup-prompts/monorepo.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts b/code/core/src/cli/ai/setup-prompts/optimized-tests.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/optimized-tests.ts rename to code/core/src/cli/ai/setup-prompts/optimized-tests.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/dod.ts b/code/core/src/cli/ai/setup-prompts/partials/dod.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/dod.ts rename to code/core/src/cli/ai/setup-prompts/partials/dod.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts b/code/core/src/cli/ai/setup-prompts/partials/examples.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/examples.ts rename to code/core/src/cli/ai/setup-prompts/partials/examples.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts b/code/core/src/cli/ai/setup-prompts/partials/rules.ts similarity index 97% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts rename to code/core/src/cli/ai/setup-prompts/partials/rules.ts index 02cd8a5a7fe8..e2ed5f989d0c 100644 --- a/code/lib/cli-storybook/src/ai/setup-prompts/partials/rules.ts +++ b/code/core/src/cli/ai/setup-prompts/partials/rules.ts @@ -1,6 +1,6 @@ import { dedent } from 'ts-dedent'; import type { SetupInstructionsContext } from '../../types.ts'; -import { getMonorepoType } from '../../../../../../core/src/shared/utils/get-monorepo-type.ts'; +import { getMonorepoType } from '../../../../shared/utils/get-monorepo-type.ts'; export function toolsVsShellRule(ctx: SetupInstructionsContext): string { return dedent`**Discover with Glob/Grep/Read, not shell.** Never use \`ls\`, \`find\`, \`cat\`, \`head\`, \`tail\`, shell \`grep\`, \`sed\`, or \`node -e\` for discovery or for editing files in bulk — these are slower per call and violate caching. Substitute bash commands for the specific tool names listed below, or available tools with the closest semantics: diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts b/code/core/src/cli/ai/setup-prompts/partials/steps.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/steps.ts rename to code/core/src/cli/ai/setup-prompts/partials/steps.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/partials/types.ts b/code/core/src/cli/ai/setup-prompts/partials/types.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/partials/types.ts rename to code/core/src/cli/ai/setup-prompts/partials/types.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/pattern-copy-play.ts b/code/core/src/cli/ai/setup-prompts/pattern-copy-play.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/pattern-copy-play.ts rename to code/core/src/cli/ai/setup-prompts/pattern-copy-play.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/relaxed-limits.ts b/code/core/src/cli/ai/setup-prompts/relaxed-limits.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/relaxed-limits.ts rename to code/core/src/cli/ai/setup-prompts/relaxed-limits.ts diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/setup.ts b/code/core/src/cli/ai/setup-prompts/setup.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/setup-prompts/setup.ts rename to code/core/src/cli/ai/setup-prompts/setup.ts diff --git a/code/lib/cli-storybook/src/ai/types.ts b/code/core/src/cli/ai/types.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/types.ts rename to code/core/src/cli/ai/types.ts diff --git a/code/lib/cli-storybook/src/ai/utils/docs-markdown-url.ts b/code/core/src/cli/ai/utils/docs-markdown-url.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/docs-markdown-url.ts rename to code/core/src/cli/ai/utils/docs-markdown-url.ts diff --git a/code/lib/cli-storybook/src/ai/utils/ext.ts b/code/core/src/cli/ai/utils/ext.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/ext.ts rename to code/core/src/cli/ai/utils/ext.ts diff --git a/code/lib/cli-storybook/src/ai/utils/markdown.ts b/code/core/src/cli/ai/utils/markdown.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/markdown.ts rename to code/core/src/cli/ai/utils/markdown.ts diff --git a/code/lib/cli-storybook/src/ai/utils/project-overview.ts b/code/core/src/cli/ai/utils/project-overview.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/project-overview.ts rename to code/core/src/cli/ai/utils/project-overview.ts diff --git a/code/lib/cli-storybook/src/ai/utils/type-import-source.ts b/code/core/src/cli/ai/utils/type-import-source.ts similarity index 100% rename from code/lib/cli-storybook/src/ai/utils/type-import-source.ts rename to code/core/src/cli/ai/utils/type-import-source.ts diff --git a/code/core/src/cli/detectLanguage.test.ts b/code/core/src/cli/detectLanguage.test.ts new file mode 100644 index 000000000000..3c3e06353a7a --- /dev/null +++ b/code/core/src/cli/detectLanguage.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import * as memfs from 'memfs'; +import { vol } from 'memfs'; + +import { detectLanguage } from './detectLanguage.ts'; + +vi.mock('node:fs', { spy: true }); + +const packageManager = (dependencies: Record) => + ({ + getAllDependencies: () => dependencies, + getModulePackageJSON: async (pkg: string) => + dependencies[pkg] ? { version: dependencies[pkg] } : null, + }) as unknown as JsPackageManager; + +describe('detectLanguage', () => { + beforeEach(async () => { + vol.reset(); + const fs = await import('node:fs'); + vi.mocked(fs.existsSync).mockImplementation(memfs.fs.existsSync as typeof fs.existsSync); + }); + + it('scans the given workingDir, not the process cwd', async () => { + vol.fromNestedJSON({ '/project/tsconfig.json': '{}' }); + + await expect(detectLanguage(packageManager({}), '/project')).resolves.toBe( + SupportedLanguage.TYPESCRIPT + ); + await expect(detectLanguage(packageManager({}), '/elsewhere')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); + + it('treats a jsconfig.json in the workingDir as JavaScript even with a typescript dependency', async () => { + vol.fromNestedJSON({ '/project/jsconfig.json': '{}' }); + + await expect(detectLanguage(packageManager({ typescript: '5.6.0' }), '/project')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); + + it('detects TypeScript from a compatible direct dependency without config files', async () => { + await expect(detectLanguage(packageManager({ typescript: '5.6.0' }), '/project')).resolves.toBe( + SupportedLanguage.TYPESCRIPT + ); + }); + + it('falls back to JavaScript when the typescript dependency is incompatible', async () => { + await expect(detectLanguage(packageManager({ typescript: '4.0.0' }), '/project')).resolves.toBe( + SupportedLanguage.JAVASCRIPT + ); + }); +}); diff --git a/code/core/src/cli/detectLanguage.ts b/code/core/src/cli/detectLanguage.ts new file mode 100644 index 000000000000..a822f0fab4c7 --- /dev/null +++ b/code/core/src/cli/detectLanguage.ts @@ -0,0 +1,105 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import semver from 'semver'; + +/** + * Detect whether the project should be treated as TypeScript or JavaScript. The js/tsconfig lookup + * happens in `workingDir`, which defaults to the process cwd for callers (like `storybook init`) + * that already run from the project root. + */ +export async function detectLanguage( + packageManager: JsPackageManager, + workingDir: string = process.cwd() +): Promise { + let language = SupportedLanguage.JAVASCRIPT; + + if (existsSync(join(workingDir, 'jsconfig.json'))) { + return language; + } + + const isTypescriptDirectDependency = !!packageManager.getAllDependencies().typescript; + + if (isTypescriptDirectDependency) { + const incompatibleReasons = await detectIncompatiblePackageVersions(packageManager); + if (incompatibleReasons.length === 0) { + language = SupportedLanguage.TYPESCRIPT; + } + } else { + // No direct dependency on TypeScript, but could be a transitive dependency + // This is eg the case for Nuxt projects, which support a recent version of TypeScript + // Check for tsconfig.json (https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) + if (existsSync(join(workingDir, 'tsconfig.json'))) { + language = SupportedLanguage.TYPESCRIPT; + } + } + + return language; +} + +/** Check installed tooling versions for TypeScript compatibility constraints */ +export async function detectIncompatiblePackageVersions( + packageManager: JsPackageManager +): Promise { + const getModulePackageJSONVersion = async (pkg: string) => { + return (await packageManager.getModulePackageJSON(pkg))?.version ?? null; + }; + + const [ + typescriptVersion, + prettierVersion, + babelPluginTransformTypescriptVersion, + typescriptEslintParserVersion, + eslintPluginStorybookVersion, + ] = await Promise.all([ + getModulePackageJSONVersion('typescript'), + getModulePackageJSONVersion('prettier'), + getModulePackageJSONVersion('@babel/plugin-transform-typescript'), + getModulePackageJSONVersion('@typescript-eslint/parser'), + getModulePackageJSONVersion('eslint-plugin-storybook'), + ]); + + const satisfies = (version: string | null, range: string) => { + if (!version) { + return false; + } + return semver.satisfies(version, range, { includePrerelease: true }); + }; + + const incompatibleReasons: string[] = []; + + if (typescriptVersion && !satisfies(typescriptVersion, '>=4.9.0')) { + incompatibleReasons.push(`typescript ${typescriptVersion} is below 4.9.0`); + } + if (prettierVersion && !semver.gte(prettierVersion, '2.8.0')) { + incompatibleReasons.push(`prettier ${prettierVersion} is below 2.8.0`); + } + if ( + babelPluginTransformTypescriptVersion && + !satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0') + ) { + incompatibleReasons.push( + `@babel/plugin-transform-typescript ${babelPluginTransformTypescriptVersion} is below 7.20.0` + ); + } + if (typescriptEslintParserVersion && !satisfies(typescriptEslintParserVersion, '>=5.44.0')) { + incompatibleReasons.push( + `@typescript-eslint/parser ${typescriptEslintParserVersion} is below 5.44.0` + ); + } + // Treat Storybook canary/prerelease versions (e.g. 0.0.0-pr-*) as compatible + if ( + eslintPluginStorybookVersion && + !eslintPluginStorybookVersion.startsWith('0.0.0-') && + !satisfies(eslintPluginStorybookVersion, '>=0.6.8') + ) { + incompatibleReasons.push( + `eslint-plugin-storybook ${eslintPluginStorybookVersion} is below 0.6.8` + ); + } + + return incompatibleReasons; +} diff --git a/code/core/src/cli/getStorybookData.test.ts b/code/core/src/cli/getStorybookData.test.ts new file mode 100644 index 000000000000..758789723bc7 --- /dev/null +++ b/code/core/src/cli/getStorybookData.test.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { getWorkingDir } from './getStorybookData.ts'; + +describe('getWorkingDir', () => { + it.each([ + ['.', process.cwd()], + ['.storybook', process.cwd()], + ['./.storybook', process.cwd()], + ['packages/foo/.storybook', resolve(process.cwd(), 'packages/foo')], + ['./apps/web/.storybook', resolve(process.cwd(), 'apps/web')], + ])('resolves relative configDir %j to %j', (configDir, expected) => { + expect(getWorkingDir(configDir)).toBe(expected); + }); + + it('uses the parent directory for absolute config dirs', () => { + const configDir = resolve('/projects/foo/.storybook'); + expect(getWorkingDir(configDir)).toBe(dirname(configDir)); + }); +}); diff --git a/code/core/src/cli/getStorybookData.ts b/code/core/src/cli/getStorybookData.ts new file mode 100644 index 000000000000..38f126dcc303 --- /dev/null +++ b/code/core/src/cli/getStorybookData.ts @@ -0,0 +1,94 @@ +import { dirname, isAbsolute, resolve } from 'node:path'; + +import type { PackageManagerName } from 'storybook/internal/common'; +import { JsPackageManagerFactory, getStorybookInfo } from 'storybook/internal/common'; +import { getStoriesPathsFromConfig } from 'storybook/internal/core-server'; +import { isCsfFactoryPreview, readConfig } from 'storybook/internal/csf-tools'; +import { logger } from 'storybook/internal/node-logger'; + +/** + * The project directory the config dir lives in, used to resolve story globs and to locate + * `tsconfig.json`/`jsconfig.json` for language detection. Taking `dirname` before resolving (not + * after) keeps `--config-dir .` anchored to the project root instead of its parent. + */ +export function getWorkingDir(configDir: string): string { + return isAbsolute(configDir) ? dirname(configDir) : resolve(process.cwd(), dirname(configDir)); +} + +/** + * Gathers the project metadata CLI commands need from the target Storybook: config, framework, + * package manager, installed version, and story paths. The canonical collector — `automigrate`, + * `doctor`, `add`, and `ai setup` all consume it. + */ +export const getStorybookData = async ({ + configDir: userDefinedConfigDir, + packageManagerName, +}: { + configDir?: string; + packageManagerName?: PackageManagerName; +}) => { + logger.debug('Getting Storybook info...'); + const { + mainConfig, + mainConfigPath, + configDir: configDirFromScript, + previewConfigPath, + versionSpecifier, + frameworkPackage, + rendererPackage, + renderer, + builderPackage, + addons, + } = await getStorybookInfo( + userDefinedConfigDir, + userDefinedConfigDir ? dirname(userDefinedConfigDir) : undefined + ); + + const configDir = userDefinedConfigDir || configDirFromScript || '.storybook'; + + const workingDir = getWorkingDir(configDir); + + logger.debug('Getting stories paths...'); + const storiesPaths = await getStoriesPathsFromConfig({ + stories: mainConfig.stories, + configDir, + workingDir, + }); + + logger.debug('Getting package manager...'); + const packageManager = JsPackageManagerFactory.getPackageManager({ + force: packageManagerName, + configDir, + storiesPaths, + }); + + logger.debug('Getting Storybook version...'); + const versionInstalled = (await packageManager.getModulePackageJSON('storybook'))?.version; + + logger.debug('Detecting CSF factory usage...'); + const hasCsfFactoryPreview = previewConfigPath + ? isCsfFactoryPreview(await readConfig(previewConfigPath)) + : false; + + return { + configDir, + workingDir, + mainConfig, + /** The version specifier of Storybook from the user's package.json */ + versionSpecifier, + /** The version of Storybook installed in the user's project */ + versionInstalled, + mainConfigPath, + previewConfigPath, + packageManager, + storiesPaths, + hasCsfFactoryPreview, + frameworkPackage, + rendererPackage, + renderer, + builderPackage, + addons, + }; +}; + +export type GetStorybookData = typeof getStorybookData; diff --git a/code/core/src/cli/index.ts b/code/core/src/cli/index.ts index a7054c44fce5..d8f611ca2edd 100644 --- a/code/core/src/cli/index.ts +++ b/code/core/src/cli/index.ts @@ -7,3 +7,5 @@ export * from './NpmOptions.ts'; export * from './eslintPlugin.ts'; export * from './globalSettings.ts'; export * from './AddonVitestService.ts'; +export * from './detectLanguage.ts'; +export * from './getStorybookData.ts'; diff --git a/code/core/src/controls/components/ControlsPanel.stories.tsx b/code/core/src/controls/components/ControlsPanel.stories.tsx index 40cf2ccfc95f..b28d4d9b49a8 100644 --- a/code/core/src/controls/components/ControlsPanel.stories.tsx +++ b/code/core/src/controls/components/ControlsPanel.stories.tsx @@ -16,6 +16,17 @@ const storyData = { initialArgs: { label: 'Submit' }, argTypes: { label: { name: 'label', control: { type: 'text' } } }, }; +const serviceStoryData = { + type: 'story', + prepared: true, + id: 'example-button--primary', + args: { variant: 'primary' }, + initialArgs: { variant: 'primary' }, + // Custom argTypes reach the panel via the STORY_PREPARED channel (read through `useArgTypes`), + // and are merged with the service's extracted component docgen. + argTypes: { variant: { name: 'variant', control: { type: 'radio' } } }, + parameters: { __isArgsStory: true }, +}; // Reproduces the #34553 condition: the story comes from a composed ref, so the host's // global `previewInitialized` stays false while the ref's own flag is true. @@ -40,6 +51,47 @@ const managerContext: any = { }, }; +const serviceManagerContext: any = { + ...managerContext, + state: { + ...managerContext.state, + path: serviceStoryData.id, + previewInitialized: true, + }, + api: { + ...managerContext.api, + getCurrentStoryData: fn(() => serviceStoryData).mockName('api::getCurrentStoryData'), + }, +}; + +const serviceGetDocgen = Object.assign( + fn((_input?: { id: string }) => ({ + id: 'example-button', + name: 'Button', + path: './Button.stories.tsx', + jsDocTags: {}, + stories: [], + argTypes: { + variant: { + name: 'variant', + description: 'Visual style', + type: { name: 'enum', value: ['primary', 'secondary'] }, + }, + }, + })).mockName('docgenService::getDocgen'), + { + subscribe: fn((_input: { id: string }, callback: (value: unknown) => void) => { + callback(serviceGetDocgen(_input)); + return fn(); + }).mockName('docgenService::getDocgen.subscribe'), + loaded: fn((input: { id: string }) => Promise.resolve(serviceGetDocgen(input))).mockName( + 'docgenService::getDocgen.loaded' + ), + } +); + +const docgenService: any = { queries: { getDocgen: serviceGetDocgen } }; + const meta = { component: ControlsPanel, args: { saveStory: fn(), createStory: fn() }, @@ -63,3 +115,16 @@ export const RefStoryControlsLoad: Story = { await expect(await canvas.findByText('label')).toBeInTheDocument(); }, }; + +export const ServiceDocgenControlsLoad: Story = { + args: { docgenService }, + decorators: [ + (storyFn) => ( + {storyFn()} + ), + ], + play: async ({ canvas }) => { + await expect(await canvas.findByRole('radio', { name: 'primary' })).toBeInTheDocument(); + await expect(serviceGetDocgen).toHaveBeenCalledWith({ id: 'example-button' }); + }, +}; diff --git a/code/core/src/controls/components/ControlsPanel.tsx b/code/core/src/controls/components/ControlsPanel.tsx index b8aaf7299e62..5a2b7323a3d7 100644 --- a/code/core/src/controls/components/ControlsPanel.tsx +++ b/code/core/src/controls/components/ControlsPanel.tsx @@ -1,18 +1,21 @@ import React, { useEffect, useMemo, useState } from 'react'; -import type { ArgTypes } from 'storybook/internal/types'; +import type { ArgTypes, DocgenPayload } from 'storybook/internal/types'; import { global } from '@storybook/global'; import { dequal as deepEqual } from 'dequal'; +import { mergeServiceArgTypes } from '../../docs-tools/argTypes/docgenServiceArgTypes.ts'; import { useArgTypes, useArgs, useGlobals, useParameter, + useServiceQuery, useStorybookApi, useStorybookState, } from 'storybook/manager-api'; +import type { DocgenService } from 'storybook/open-service'; import { styled } from 'storybook/theming'; import { @@ -51,48 +54,44 @@ interface ControlsParameters { interface ControlsPanelProps { saveStory: () => Promise; createStory: (storyName: string) => Promise; + docgenService?: DocgenService; } -export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => { - const api = useStorybookApi(); - const [isLoading, setIsLoading] = useState(true); +function applyPresetColors(rows: ArgTypes, presetColors: PresetColor[] | undefined) { + const withPresetColors = Object.entries(rows).reduce((acc, [key, arg]) => { + const control = arg?.control; + + if (typeof control !== 'object' || control?.type !== 'color' || control?.presetColors) { + acc[key] = arg; + } else { + acc[key] = { ...arg, control: { ...control, presetColors } }; + } + return acc; + }, {} as ArgTypes); + + return withPresetColors; +} + +function ControlsPanelTable({ + rows, + isLoading, + saveStory, + createStory, +}: ControlsPanelProps & { rows: ArgTypes; isLoading: boolean }) { const [args, updateArgs, resetArgs, initialArgs] = useArgs(); const [globals] = useGlobals(); - const rows = useArgTypes(); const { expanded, sort, presetColors, disableSaveFromUI = false, } = useParameter(PARAM_KEY, {}); - const { path, refs, previewInitialized } = useStorybookState(); + const { path } = useStorybookState(); + const api = useStorybookApi(); const storyData = api.getCurrentStoryData(); - // Stories from a composed ref track their own `previewInitialized` flag; the host's - // global flag stays false for them, so read the ref's flag when on a ref story (#34553). - const isPreviewInitialized = storyData?.refId - ? !!refs[storyData.refId]?.previewInitialized - : previewInitialized; - - // If the story is prepared, then show the args table and reset the loading state - useEffect(() => { - if (isPreviewInitialized) { - setIsLoading(false); - } - }, [isPreviewInitialized]); - const hasControls = Object.values(rows).some((arg) => arg?.control); - - const withPresetColors = Object.entries(rows).reduce((acc, [key, arg]) => { - const control = arg?.control; - - if (typeof control !== 'object' || control?.type !== 'color' || control?.presetColors) { - acc[key] = arg; - } else { - acc[key] = { ...arg, control: { ...control, presetColors } }; - } - return acc; - }, {} as ArgTypes); + const withPresetColors = applyPresetColors(rows, presetColors); const hasUpdatedArgs = useMemo( () => !!args && !!initialArgs && !deepEqual(clean(args), clean(initialArgs)), @@ -124,4 +123,94 @@ export const ControlsPanel = ({ saveStory, createStory }: ControlsPanelProps) => {showSaveFromUI && } ); +} + +function LegacyControlsPanel(props: ControlsPanelProps) { + const [isLoading, setIsLoading] = useState(true); + const rows = useArgTypes(); + const { refs, previewInitialized } = useStorybookState(); + const api = useStorybookApi(); + const storyData = api.getCurrentStoryData(); + + // Stories from a composed ref track their own `previewInitialized` flag; the host's + // global flag stays false for them, so read the ref's flag when on a ref story (#34553). + const isPreviewInitialized = storyData?.refId + ? !!refs[storyData.refId]?.previewInitialized + : previewInitialized; + + // If the story is prepared, then show the args table and reset the loading state + useEffect(() => { + if (isPreviewInitialized) { + setIsLoading(false); + } + }, [isPreviewInitialized]); + + return ; +} + +function ServiceControlsPanel({ + docgenService, + ...props +}: ControlsPanelProps & { docgenService: DocgenService }) { + const api = useStorybookApi(); + const storyData = api.getCurrentStoryData(); + const [, , , initialArgs] = useArgs(); + const [docgenReady, setDocgenReady] = useState(false); + // Custom argTypes (project + meta + story, already inferred) for the selected story arrive over + // the channel via STORY_PREPARED — the same source the legacy panel reads. The service only needs + // to contribute server-extracted component props. + const customArgTypes = useArgTypes(); + const id = storyData.id.split('--')[0]; + // `useServiceQuery` mis-infers its types for services with more than one query (it unifies across + // queries, breaking both the argument and the result). Cast until the hook's generics are fixed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const docgenPayload = useServiceQuery(docgenService as any, 'getDocgen', { id }) as + | DocgenPayload + | undefined; + const isStoryPrepared = storyData.type === 'story' ? storyData.prepared : true; + + useEffect(() => { + let active = true; + setDocgenReady(false); + + docgenService.queries.getDocgen + .loaded({ id }) + .catch(() => undefined) + .finally(() => { + if (active) { + setDocgenReady(true); + } + }); + + return () => { + active = false; + }; + }, [docgenService, id]); + + // The manager Controls panel only ever shows the main component's rows; subcomponent tabs are a + // docs-blocks-only feature, so this intentionally ignores `payload.subcomponents` to match the + // legacy panel's behavior. + const rows = useMemo( + () => + docgenPayload + ? mergeServiceArgTypes({ + payload: docgenPayload, + storyId: storyData.id, + parameters: storyData.parameters, + initialArgs, + customArgTypes, + }) + : customArgTypes, + [docgenPayload, initialArgs, storyData.id, storyData.parameters, customArgTypes] + ); + + return ; +} + +export const ControlsPanel = ({ docgenService, ...props }: ControlsPanelProps) => { + if (docgenService) { + return ; + } + + return ; }; diff --git a/code/core/src/controls/manager.tsx b/code/core/src/controls/manager.tsx index 5b48e2e39298..5cd1be63336e 100644 --- a/code/core/src/controls/manager.tsx +++ b/code/core/src/controls/manager.tsx @@ -12,9 +12,11 @@ import type { Args } from 'storybook/internal/csf'; import { FailedIcon, PassedIcon } from '@storybook/icons'; import { dequal as deepEqual } from 'dequal'; -import { addons, experimental_requestResponse, types } from 'storybook/manager-api'; +import { addons, experimental_requestResponse, getService, types } from 'storybook/manager-api'; import { color } from 'storybook/theming'; +import type { DocgenService } from 'storybook/open-service'; + import { ControlsPanel } from './components/ControlsPanel.tsx'; import { Title } from './components/Title.tsx'; import { ADDON_ID, PARAM_KEY } from './constants.ts'; @@ -24,6 +26,9 @@ import { stringifyArgs } from './stringifyArgs.tsx'; export default addons.register(ADDON_ID, (api) => { if (globalThis?.FEATURES?.controls) { const channel = addons.getChannel(); + const docgenService = globalThis.FEATURES?.experimentalDocgenServer + ? getService('core/docgen') + : undefined; const saveStory = async () => { const data = api.getCurrentStoryData(); @@ -124,7 +129,11 @@ export default addons.register(ADDON_ID, (api) => { } return ( - + ); }, diff --git a/code/core/src/core-server/index.ts b/code/core/src/core-server/index.ts index 0702b3e227d1..5fc17e61a730 100644 --- a/code/core/src/core-server/index.ts +++ b/code/core/src/core-server/index.ts @@ -9,6 +9,7 @@ export * from './withTelemetry.ts'; export { default as build } from './standalone.ts'; export { mapStaticDir } from './utils/server-statics.ts'; export { StoryIndexGenerator } from './utils/StoryIndexGenerator.ts'; +export { getStoriesPathsFromConfig } from './utils/get-stories-paths-from-config.ts'; export { generateStoryFile } from './utils/generate-story.ts'; export type { GenerateStoryResult, GenerateStoryOptions } from './utils/generate-story.ts'; export type { ComponentArgTypesData } from './utils/get-dummy-args-from-argtypes.ts'; diff --git a/code/core/src/core-server/presets/common-manager.ts b/code/core/src/core-server/presets/common-manager.ts index 024a34422a7c..524abd84f641 100644 --- a/code/core/src/core-server/presets/common-manager.ts +++ b/code/core/src/core-server/presets/common-manager.ts @@ -1,6 +1,7 @@ /* these imports are in the exact order in which the panels need to be registered */ // THE ORDER OF THESE IMPORTS MATTERS! IT DEFINES THE ORDER OF PANELS AND TOOLS! +import docgenManager from '../../shared/open-service/services/docgen/manager.tsx'; import controlsManager from '../../controls/manager.tsx'; import actionsManager from '../../actions/manager.tsx'; import componentTestingManager from '../../component-testing/manager.tsx'; @@ -10,6 +11,7 @@ import outlineManager from '../../outline/manager.tsx'; import viewportManager from '../../viewport/manager.tsx'; export default [ + docgenManager, measureManager, actionsManager, backgroundsManager, diff --git a/code/core/src/core-server/utils/get-stories-paths-from-config.ts b/code/core/src/core-server/utils/get-stories-paths-from-config.ts new file mode 100644 index 000000000000..0f7c4d60fe5c --- /dev/null +++ b/code/core/src/core-server/utils/get-stories-paths-from-config.ts @@ -0,0 +1,43 @@ +import { normalizeStories } from 'storybook/internal/common'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; + +import { StoryIndexGenerator } from './StoryIndexGenerator.ts'; + +/** + * Resolves story file paths from a main config's `stories` field without evaluating story files. + * + * @example + * + * ```typescript + * const storiesPaths = await getStoriesPathsFromConfig({ + * stories: ['src\/**\/*.stories.tsx'], + * configDir: '/path/to/.storybook', + * workingDir: '/path/to/project', + * }); + * ``` + */ +export const getStoriesPathsFromConfig = async ({ + stories, + configDir, + workingDir, +}: { + stories: StorybookConfigRaw['stories']; + configDir: string; + workingDir: string; +}) => { + if (stories.length === 0) { + return []; + } + + const normalizedStories = normalizeStories(stories, { configDir, workingDir }); + + const matchingStoryFiles = await StoryIndexGenerator.findMatchingFilesForSpecifiers( + normalizedStories, + workingDir, + true + ); + + return matchingStoryFiles.flatMap(([specifier, cache]) => + StoryIndexGenerator.storyFileNames(new Map([[specifier, cache]])) + ); +}; diff --git a/code/core/src/core-server/withTelemetry.test.ts b/code/core/src/core-server/withTelemetry.test.ts index 07b41201c709..a698a8e447d7 100644 --- a/code/core/src/core-server/withTelemetry.test.ts +++ b/code/core/src/core-server/withTelemetry.test.ts @@ -161,6 +161,41 @@ describe('withTelemetry', () => { exitSpy.mockRestore(); }); + it('treats ai-command interruption errors as canceled telemetry', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + const run = vi.fn(async () => { + const error = new Error('The operation was aborted'); + Object.assign(error, { name: 'AbortError', code: 'ABORT_ERR' }); + throw error; + }); + + await expect(withTelemetry('ai-command', { cliOptions }, run)).resolves.toBeUndefined(); + + expect(telemetry).toHaveBeenNthCalledWith( + 2, + 'canceled', + { eventType: 'ai-command' }, + { stripMetadata: true, immediate: true } + ); + expect(exitSpy).toHaveBeenCalledWith(0); + + exitSpy.mockRestore(); + }); + + it('does not treat dev interruption errors as canceled telemetry', async () => { + const run = vi.fn(async () => { + const error = new Error('The operation was aborted'); + Object.assign(error, { name: 'AbortError', code: 'ABORT_ERR' }); + throw error; + }); + + await expect(withTelemetry('dev', { cliOptions, printError: vi.fn() }, run)).rejects.toThrow( + 'The operation was aborted' + ); + + expect(telemetry).not.toHaveBeenCalledWith('canceled', expect.anything(), expect.anything()); + }); + it('treats wrapped init interruption failures as canceled telemetry', async () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); const run = vi.fn(async () => { diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index 3f459c4acc45..38085a14bbe4 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -212,6 +212,12 @@ function isInterruptionError(error: unknown): boolean { ); } +/** + * Commands that report a `canceled` event when the user interrupts them with Ctrl+C. Other + * commands simply die on SIGINT without telemetry. + */ +const CANCELLATION_TRACKED_EVENTS: EventType[] = ['init', 'ai-command']; + export async function withTelemetry( eventType: EventType, options: TelemetryOptions, @@ -230,7 +236,8 @@ export async function withTelemetry( process.exit(0); } - if (eventType === 'init') { + const trackCancellation = CANCELLATION_TRACKED_EVENTS.includes(eventType); + if (trackCancellation) { // We catch Ctrl+C user interactions to be able to detect a cancel event process.on('SIGINT', cancelTelemetry); } @@ -251,7 +258,7 @@ export async function withTelemetry( return undefined; } - if (eventType === 'init' && isInterruptionError(error)) { + if (trackCancellation && isInterruptionError(error)) { await cancelTelemetry(); return undefined; } diff --git a/code/core/src/csf/core-annotations.ts b/code/core/src/csf/core-annotations.ts index 6e58a1a2bec3..ae5c32c5442d 100644 --- a/code/core/src/csf/core-annotations.ts +++ b/code/core/src/csf/core-annotations.ts @@ -8,6 +8,7 @@ import ghostStoriesAnnotations from '../core-server/utils/ghost-stories/test-ann import highlightAnnotations, { type HighlightTypes } from '../highlight/preview.ts'; import measureAnnotations, { type MeasureTypes } from '../measure/preview.ts'; import outlineAnnotations, { type OutlineTypes } from '../outline/preview.ts'; +import docgenAnnotations from '../shared/open-service/services/docgen/preview.ts'; import testAnnotations, { type TestTypes } from '../test/preview.ts'; import viewportAnnotations, { type ViewportTypes } from '../viewport/preview.ts'; @@ -86,5 +87,7 @@ export function getCoreAnnotations() { (testAnnotations.default ?? testAnnotations)(), // @ts-expect-error CJS fallback (ghostStoriesAnnotations.default ?? ghostStoriesAnnotations)(), + // @ts-expect-error CJS fallback + (docgenAnnotations.default ?? docgenAnnotations)(), ]; } diff --git a/code/core/src/docs-tools/argTypes/docgenServiceArgTypes.ts b/code/core/src/docs-tools/argTypes/docgenServiceArgTypes.ts new file mode 100644 index 000000000000..7466214e0829 --- /dev/null +++ b/code/core/src/docs-tools/argTypes/docgenServiceArgTypes.ts @@ -0,0 +1,66 @@ +import type { + ArgTypes, + Args, + DocgenPayload, + Parameters, + StrictArgTypes, + StoryId, +} from 'storybook/internal/types'; + +import { inferArgTypes } from '../../preview-api/modules/store/inferArgTypes.ts'; +import { inferControls } from '../../preview-api/modules/store/inferControls.ts'; +import { combineParameters } from '../../preview-api/modules/store/parameters.ts'; + +/** + * Builds the Controls/ArgTypes table shape from server docgen and custom argTypes. + * + * The server payload contributes component prop extraction (`payload.argTypes`); custom argTypes + * (project + meta + story annotations, already inferred by `prepareStory`) are layered on top via + * `customArgTypes`. Callers source those from the prepared meta/story they already hold — the docs + * blocks resolve it locally through `useOf` (as `StrictArgTypes`), the manager Controls panel reads + * it from the `STORY_PREPARED` channel via `useArgTypes` (as the looser `ArgTypes`); both are + * accepted here and normalized by the inference passes below. + * + * `inferArgTypes` fills in rows for args that only exist in `initialArgs`, and `inferControls` + * assigns the default control widget from each final argType's type/options — mirroring the + * second-pass enhancers that run in `prepareStory`. + */ +export function mergeServiceArgTypes({ + payload, + storyId, + parameters, + initialArgs, + customArgTypes, +}: { + payload: DocgenPayload; + storyId: StoryId; + /** May be undefined when the manager renders before the preview reports `storyPrepared`. */ + parameters?: Parameters; + initialArgs?: Args; + customArgTypes?: ArgTypes; +}): StrictArgTypes { + const merged = combineParameters(payload.argTypes ?? {}, customArgTypes ?? {}) as StrictArgTypes; + + const withInferredTypes = inferArgTypes({ + id: storyId, + argTypes: merged, + initialArgs: initialArgs ?? {}, + }); + + return inferControls({ + argTypes: withInferredTypes, + // The manager can render this before the preview reports `storyPrepared`, so `parameters` may be + // undefined; `inferControls` reads `parameters.__isArgsStory` and would throw. An empty object + // makes it a no-op until prepared parameters arrive and trigger a re-render. + parameters: parameters ?? {}, + }); +} + +/** Returns subcomponent argTypes that were converted by the renderer provider at write time. */ +export function getServiceSubcomponentArgTypes(payload: DocgenPayload) { + return Object.fromEntries( + Object.entries(payload.subcomponents ?? {}).flatMap(([name, subcomponent]) => + subcomponent.argTypes ? [[name, subcomponent.argTypes]] : [] + ) + ) as Record; +} diff --git a/code/core/src/docs-tools/argTypes/index.ts b/code/core/src/docs-tools/argTypes/index.ts index 0bb0dda12ebe..55a7db49dbc5 100644 --- a/code/core/src/docs-tools/argTypes/index.ts +++ b/code/core/src/docs-tools/argTypes/index.ts @@ -6,3 +6,4 @@ export * from './types.ts'; export * from './utils.ts'; export * from './enhanceArgTypes.ts'; +export * from './docgenServiceArgTypes.ts'; diff --git a/code/core/src/manager-api/index.mock.ts b/code/core/src/manager-api/index.mock.ts index c91c66eb0b2b..517751dcfadb 100644 --- a/code/core/src/manager-api/index.mock.ts +++ b/code/core/src/manager-api/index.mock.ts @@ -24,6 +24,7 @@ export { /** Real open-service surface — not mocked; used by the internal sync-test demo. */ export { + getService, registerService, useServiceCommand, useServiceQuery, diff --git a/code/core/src/manager-api/index.ts b/code/core/src/manager-api/index.ts index 9b29151e0049..f3dfeaceaf59 100644 --- a/code/core/src/manager-api/index.ts +++ b/code/core/src/manager-api/index.ts @@ -27,6 +27,7 @@ export { Tag } from '../shared/constants/tags.ts'; /** OPEN SERVICE API (manager relay hub + React hooks; types on `storybook/open-service`) */ export { + getService, registerService, useServiceCommand, useServiceQuery, diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index abc09be167c7..69e069ff4ccf 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -335,6 +335,7 @@ export default { 'experimental_useStatusStore', 'experimental_useTestProviderStore', 'experimental_useUniversalStore', + 'getService', 'internal_checklistStore', 'internal_fullStatusStore', 'internal_fullTestProviderStore', diff --git a/code/core/src/preview-api/index.ts b/code/core/src/preview-api/index.ts index 96a8a3bd102d..a19700926bab 100644 --- a/code/core/src/preview-api/index.ts +++ b/code/core/src/preview-api/index.ts @@ -89,7 +89,7 @@ export { export type { SelectionStore, View } from './preview-web.ts'; /** OPEN SERVICE API (preview leaf — register only; types on `storybook/open-service`) */ -export { registerService } from '../shared/open-service/preview.ts'; +export { getService, registerService } from '../shared/open-service/preview.ts'; export { clearChannel, ensureChannel, diff --git a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts index e22f2e8dcdde..8eaccc11175b 100644 --- a/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts +++ b/code/core/src/preview-api/modules/preview-web/docs-context/DocsContext.ts @@ -286,6 +286,15 @@ export class DocsContext implements DocsContextProps return this.componentStoriesValue; }; + getComponentId = (component: Renderer['component']) => { + for (const csfFile of new Set(this.exportsToCSFFile.values())) { + if (csfFile.meta.component === component) { + return csfFile.meta.id; + } + } + return undefined; + }; + componentStoriesFromCSFFile = (csfFile: CSFFile) => { return this.store.componentStoriesFromCSFFile({ csfFile }); }; diff --git a/code/core/src/preview-api/modules/store/StoryStore.test.ts b/code/core/src/preview-api/modules/store/StoryStore.test.ts index ecee7b4a27e1..f4507af894c6 100644 --- a/code/core/src/preview-api/modules/store/StoryStore.test.ts +++ b/code/core/src/preview-api/modules/store/StoryStore.test.ts @@ -34,8 +34,8 @@ vi.mock('@storybook/global', async (importOriginal) => ({ vi.mock('storybook/internal/client-logger'); const componentOneExports = { - default: { title: 'Component One' }, - a: { args: { foo: 'a' } }, + default: { title: 'Component One', argTypes: { foo: { description: 'from meta' } } }, + a: { args: { foo: 'a' }, argTypes: { foo: { control: 'text' } } }, b: { args: { foo: 'b' } }, }; const componentTwoExports = { @@ -346,6 +346,11 @@ describe('StoryStore', () => { }, }, "foo": { + "control": { + "disable": false, + "type": "text", + }, + "description": "from meta", "name": "foo", "type": { "name": "string", @@ -539,6 +544,11 @@ describe('StoryStore', () => { }, }, "foo": { + "control": { + "disable": false, + "type": "text", + }, + "description": "from meta", "name": "foo", "type": { "name": "string", @@ -605,6 +615,7 @@ describe('StoryStore', () => { }, }, "foo": { + "description": "from meta", "name": "foo", "type": { "name": "string", diff --git a/code/core/src/preview-api/modules/store/inferArgTypes.ts b/code/core/src/preview-api/modules/store/inferArgTypes.ts index dfc49a41b916..f4d5d873e792 100644 --- a/code/core/src/preview-api/modules/store/inferArgTypes.ts +++ b/code/core/src/preview-api/modules/store/inferArgTypes.ts @@ -1,11 +1,22 @@ import { logger } from 'storybook/internal/client-logger'; -import type { ArgTypesEnhancer, Renderer, SBType } from 'storybook/internal/types'; +import type { + Renderer, + SBType, + StoryContextForEnhancers, + StrictArgTypes, +} from 'storybook/internal/types'; import { mapValues } from 'es-toolkit/object'; import { dedent } from 'ts-dedent'; import { combineParameters } from './parameters.ts'; +/** The fields {@link inferArgTypes} reads; the full enhancer context is structurally assignable. */ +export type InferArgTypesContext = Pick< + StoryContextForEnhancers, + 'id' | 'argTypes' | 'initialArgs' +>; + const inferType = ( value: any, name: string, @@ -64,7 +75,12 @@ const inferType = ( return { name: 'object', value: {} }; }; -export const inferArgTypes: ArgTypesEnhancer = (context) => { +// Narrower param than `ArgTypesEnhancer` so it stays directly callable with a minimal context +// (e.g. `mergeServiceArgTypes`), while a full enhancer context remains structurally assignable — +// so it can still be registered in the `argTypesEnhancers` array. +export const inferArgTypes: ((context: InferArgTypesContext) => StrictArgTypes) & { + secondPass?: boolean; +} = (context) => { const { id, argTypes: userArgTypes = {}, initialArgs = {} } = context; const cache = new Map(); const argTypes = mapValues(initialArgs, (arg, key) => ({ @@ -74,7 +90,7 @@ export const inferArgTypes: ArgTypesEnhancer = (context) => { const userArgTypesNames = mapValues(userArgTypes, (argType, key) => ({ name: key, })); - return combineParameters(argTypes, userArgTypesNames, userArgTypes); + return combineParameters(argTypes, userArgTypesNames, userArgTypes) as StrictArgTypes; }; inferArgTypes.secondPass = true; diff --git a/code/core/src/preview-api/modules/store/inferControls.ts b/code/core/src/preview-api/modules/store/inferControls.ts index d3e961dca186..8239ecf5602a 100644 --- a/code/core/src/preview-api/modules/store/inferControls.ts +++ b/code/core/src/preview-api/modules/store/inferControls.ts @@ -1,8 +1,9 @@ import { logger } from 'storybook/internal/client-logger'; import type { - ArgTypesEnhancer, Renderer, SBEnumType, + StoryContextForEnhancers, + StrictArgTypes, StrictInputType, } from 'storybook/internal/types'; @@ -16,6 +17,12 @@ export type ControlsMatchers = { color: RegExp; }; +/** The fields {@link inferControls} reads; the full enhancer context is structurally assignable. */ +export type InferControlsContext = Pick< + StoryContextForEnhancers, + 'argTypes' | 'parameters' +>; + const inferControl = (argType: StrictInputType, name: string, matchers: ControlsMatchers): any => { const { type, options } = argType; if (!type) { @@ -63,7 +70,12 @@ const inferControl = (argType: StrictInputType, name: string, matchers: Controls } }; -export const inferControls: ArgTypesEnhancer = (context) => { +// Narrower param than `ArgTypesEnhancer` so it stays directly callable with a minimal context +// (e.g. `mergeServiceArgTypes`), while a full enhancer context remains structurally assignable — +// so it can still be registered in the `argTypesEnhancers` array. +export const inferControls: ((context: InferControlsContext) => StrictArgTypes) & { + secondPass?: boolean; +} = (context) => { const { argTypes, parameters: { __isArgsStory, controls: { include = null, exclude = null, matchers = {} } = {} }, @@ -78,7 +90,7 @@ export const inferControls: ArgTypesEnhancer = (context) => { return argType?.type && inferControl(argType, name.toString(), matchers); }); - return combineParameters(withControls, filteredArgTypes); + return combineParameters(withControls, filteredArgTypes) as StrictArgTypes; }; inferControls.secondPass = true; diff --git a/code/core/src/shared/open-service/index.ts b/code/core/src/shared/open-service/index.ts index b9c8a932b795..a476243fdfee 100644 --- a/code/core/src/shared/open-service/index.ts +++ b/code/core/src/shared/open-service/index.ts @@ -7,6 +7,9 @@ */ export { defineService } from './service-definition.ts'; +export type { DocgenService } from './services/docgen/definition.ts'; +export type { DocgenPayload } from './services/docgen/types.ts'; + export type { AnyServiceDefinition, Command, diff --git a/code/core/src/shared/open-service/manager.ts b/code/core/src/shared/open-service/manager.ts index e382f661cbe6..08e33b15fe71 100644 --- a/code/core/src/shared/open-service/manager.ts +++ b/code/core/src/shared/open-service/manager.ts @@ -19,7 +19,7 @@ * ``` */ -import { registerService as registerServiceCore } from './service-registry.ts'; +import { getService, registerService as registerServiceCore } from './service-registry.ts'; import type { Commands, Queries, @@ -31,6 +31,7 @@ import type { export { useServiceCommand } from './use-service-command.ts'; export { useServiceQuery } from './use-service-query.ts'; +export { getService }; /** * Registers a service in the manager and returns its runtime surface. diff --git a/code/core/src/shared/open-service/preview.ts b/code/core/src/shared/open-service/preview.ts index 6aa353168865..4d39b4c47a39 100644 --- a/code/core/src/shared/open-service/preview.ts +++ b/code/core/src/shared/open-service/preview.ts @@ -21,7 +21,7 @@ * ``` */ -import { registerService as registerServiceCore } from './service-registry.ts'; +import { getService, registerService as registerServiceCore } from './service-registry.ts'; import type { Commands, Queries, @@ -31,6 +31,8 @@ import type { ServiceRegistryApi, } from './types.ts'; +export { getService }; + /** * Registers a service in the preview and returns its runtime surface. * diff --git a/code/core/src/shared/open-service/services/docgen/definition.ts b/code/core/src/shared/open-service/services/docgen/definition.ts index e4cef536178d..b2c17c641350 100644 --- a/code/core/src/shared/open-service/services/docgen/definition.ts +++ b/code/core/src/shared/open-service/services/docgen/definition.ts @@ -1,3 +1,5 @@ +import type { StrictArgTypes } from 'storybook/internal/types'; + import * as v from 'valibot'; import { defineService } from 'storybook/open-service'; @@ -6,8 +8,14 @@ import type { DocgenPayload } from './types.ts'; import { docgenQueryStaticPath } from './paths.ts'; const docgenInputSchema = v.object({ id: v.string() }); +// Typed as `StrictArgTypes` (a static Storybook construct) but validated loosely: spelling out the +// full recursive valibot shape is deferred, so this only checks "is a plain object" at runtime +// while keeping the payload's `argTypes` typed for consumers. +const argTypesSchema = v.custom( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value) +); -export type DocgenServiceState = { +type DocgenServiceState = { /** Extracted docgen keyed by component id. Populated by the `extractDocgen` command. */ components: Record; }; @@ -36,6 +44,7 @@ const docgenEntryBaseFields = { summary: v.optional(v.string()), import: v.optional(v.string()), jsDocTags: docgenJsDocTagsSchema, + argTypes: v.optional(argTypesSchema), error: v.optional(docgenErrorSchema), }; @@ -65,6 +74,11 @@ const docgenOutputSchema = v.optional(docgenPayloadSchema); /** * Definition for the `core/docgen` open service. * + * The service carries only provider-extracted docgen (component name, description, props, JSDoc + * tags, and the renderer-converted argTypes). Story/meta/project custom argTypes are NOT stored + * here — consumers layer those in from their own sources (the docs blocks resolve the prepared + * meta/story locally; the manager Controls panel reads them from the `STORY_PREPARED` channel). + * * The query is a thin synchronous read of `state.components[id]` — it returns undefined when * nothing has been extracted yet rather than throwing, matching the open-service convention for * sync reads. The real work — story index lookup, provider invocation, error handling — lives in @@ -84,8 +98,7 @@ export const docgenServiceDef = defineService({ description: 'Returns the docgen payload for one component id, or undefined when not loaded.', input: docgenInputSchema, output: docgenOutputSchema, - handler: (input, ctx) => - input.id in ctx.self.state.components ? ctx.self.state.components[input.id] : undefined, + handler: (input, ctx) => ctx.self.state.components[input.id], load: async (input, ctx) => { await ctx.self.commands.extractDocgen(input); }, diff --git a/code/core/src/shared/open-service/services/docgen/manager.tsx b/code/core/src/shared/open-service/services/docgen/manager.tsx new file mode 100644 index 000000000000..20edf9dde6ca --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/manager.tsx @@ -0,0 +1,12 @@ +import { addons } from 'storybook/manager-api'; + +import { registerService } from '../../manager.ts'; +import { docgenServiceDef } from './definition.ts'; + +const ADDON_ID = 'core/docgen'; + +export default addons.register(ADDON_ID, () => { + if (globalThis.FEATURES?.experimentalDocgenServer) { + registerService(docgenServiceDef); + } +}); diff --git a/code/core/src/shared/open-service/services/docgen/preview.ts b/code/core/src/shared/open-service/services/docgen/preview.ts new file mode 100644 index 000000000000..0813314a5180 --- /dev/null +++ b/code/core/src/shared/open-service/services/docgen/preview.ts @@ -0,0 +1,17 @@ +import { definePreviewAddon } from 'storybook/internal/csf'; + +import type { ServiceInstanceOf } from 'storybook/open-service'; + +import { registerService } from '../../preview.ts'; +import { docgenServiceDef } from './definition.ts'; + +export type DocgenService = ServiceInstanceOf; + +export default () => + definePreviewAddon({ + beforeAll: () => { + if (globalThis.FEATURES?.experimentalDocgenServer) { + registerService(docgenServiceDef); + } + }, + }); diff --git a/code/core/src/shared/open-service/services/docgen/types.ts b/code/core/src/shared/open-service/services/docgen/types.ts index bc3ab70756a2..bbbf8f4bb16a 100644 --- a/code/core/src/shared/open-service/services/docgen/types.ts +++ b/code/core/src/shared/open-service/services/docgen/types.ts @@ -1,3 +1,4 @@ +import type { StrictArgTypes } from '../../../../types/modules/csf.ts'; import type { Options } from '../../../../types/modules/core-common.ts'; import type { IndexEntry } from '../../../../types/modules/indexer.ts'; @@ -48,6 +49,8 @@ export interface DocgenPayload { import?: string; summary?: string; jsDocTags: DocgenJsDocTags; + /** Renderer-converted argTypes derived from integration-specific docgen data at write time. */ + argTypes?: StrictArgTypes; stories: DocgenStory[]; subcomponents?: Record; error?: DocgenError; @@ -62,6 +65,8 @@ export interface DocgenSubcomponent { summary?: string; import?: string; jsDocTags: DocgenJsDocTags; + /** Renderer-converted argTypes derived from integration-specific docgen data at write time. */ + argTypes?: StrictArgTypes; error?: DocgenError; [key: string]: unknown; } diff --git a/code/core/src/shared/open-service/services/module-graph/definition.ts b/code/core/src/shared/open-service/services/module-graph/definition.ts index 856358f5a87f..b6bfb3f4e753 100644 --- a/code/core/src/shared/open-service/services/module-graph/definition.ts +++ b/code/core/src/shared/open-service/services/module-graph/definition.ts @@ -129,7 +129,7 @@ export const moduleGraphServiceDef = defineService({ input: noInputSchema, output: moduleGraphStatusSchema, load: async (_input, ctx) => { - await ctx.self.commands.waitForSettledEngine(undefined); + await ctx.self.commands._waitForSettledEngine(undefined); }, handler: (_input, ctx) => ctx.self.state.status, }, @@ -192,9 +192,10 @@ export const moduleGraphServiceDef = defineService({ }, }, commands: { - applyGraphSnapshot: { + _applyGraphSnapshot: { + internal: true, description: - 'Internal use only: replaces the reverse index after the initial graph build. Called by the graph engine, not by external consumers.', + 'Replaces the reverse index after the initial graph build. Called by the graph engine, not by external consumers.', input: v.object({ storiesByFile: v.pipe( storiesByFileSchema, @@ -221,9 +222,10 @@ export const moduleGraphServiceDef = defineService({ }); }, }, - applyGraphUpdate: { + _applyGraphUpdate: { + internal: true, description: - 'Internal use only: replaces the reverse index after an incremental patch and bumps versions for affected story files. Called by the graph engine, not by external consumers.', + 'Replaces the reverse index after an incremental patch and bumps versions for affected story files. Called by the graph engine, not by external consumers.', input: v.object({ storiesByFile: v.pipe( storiesByFileSchema, @@ -255,9 +257,10 @@ export const moduleGraphServiceDef = defineService({ }); }, }, - bumpGraphRevision: { + _bumpGraphRevision: { + internal: true, description: - 'Internal use only: bumps the graph revision when the story index invalidates without an immediate graph snapshot/update.', + 'Bumps the graph revision when the story index invalidates without an immediate graph snapshot/update.', input: noInputSchema, output: v.void(), handler: async (_input, ctx) => { @@ -267,9 +270,10 @@ export const moduleGraphServiceDef = defineService({ }); }, }, - setStatus: { + _setStatus: { + internal: true, description: - 'Internal use only: sets the module graph lifecycle status after engine startup, failure, or adapter availability changes.', + 'Sets the module graph lifecycle status after engine startup, failure, or adapter availability changes.', input: moduleGraphStatusSchema, output: v.void(), handler: async (input, ctx) => { @@ -278,9 +282,10 @@ export const moduleGraphServiceDef = defineService({ }); }, }, - waitForSettledEngine: { + _waitForSettledEngine: { + internal: true, description: - 'Internal use only: waits for the module graph engine to finish its current build or patch cycle. Handler is supplied at server registration.', + 'Waits for the module graph engine to finish its current build or patch cycle. Handler is supplied at server registration.', input: noInputSchema, output: v.void(), }, diff --git a/code/core/src/shared/open-service/services/module-graph/module-graph.test-helpers.ts b/code/core/src/shared/open-service/services/module-graph/module-graph.test-helpers.ts index 5d328f306d36..18cc4837d241 100644 --- a/code/core/src/shared/open-service/services/module-graph/module-graph.test-helpers.ts +++ b/code/core/src/shared/open-service/services/module-graph/module-graph.test-helpers.ts @@ -133,7 +133,7 @@ export function registerTestModuleGraphService(workingDir = process.cwd()) { }, { commands: { - waitForSettledEngine: { + _waitForSettledEngine: { handler: async () => undefined, }, }, diff --git a/code/core/src/shared/open-service/services/module-graph/server.test.ts b/code/core/src/shared/open-service/services/module-graph/server.test.ts index f2c07629f3fb..695cba2fb94b 100644 --- a/code/core/src/shared/open-service/services/module-graph/server.test.ts +++ b/code/core/src/shared/open-service/services/module-graph/server.test.ts @@ -40,11 +40,11 @@ describe('module-graph open service', () => { }); }); - describe('applyGraphSnapshot command', () => { + describe('_applyGraphSnapshot command', () => { it('marks the service ready and stores the reverse index without advancing the revision', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, }, @@ -65,7 +65,7 @@ describe('module-graph open service', () => { it('seeds every known story to revision 0 for scoped reads', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, @@ -81,10 +81,10 @@ describe('module-graph open service', () => { it('replaces (not merges) the reverse index on a subsequent snapshot', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/A.tsx': { './src/A.stories.tsx': 0 } }, }); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/B.tsx': { './src/B.stories.tsx': 0 } }, }); @@ -100,7 +100,7 @@ describe('module-graph open service', () => { it('marks the graph failed with a serializable error', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.setStatus({ + await runtime.commands._setStatus({ value: 'error', error: { message: 'graph build blew up', name: 'ModuleGraphFailureError' }, }); @@ -114,7 +114,7 @@ describe('module-graph open service', () => { it('marks the graph unavailable with a reason and optional error', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.setStatus({ + await runtime.commands._setStatus({ value: 'unavailable', reason: 'builder does not support change detection', error: { message: 'adapter missing' }, @@ -131,7 +131,7 @@ describe('module-graph open service', () => { describe('getStoriesForFiles query', () => { it('returns one result array per input file, positionally', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { @@ -157,7 +157,7 @@ describe('module-graph open service', () => { it('accepts absolute, relative-with-dot, and relative-without-dot input paths', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); @@ -174,7 +174,7 @@ describe('module-graph open service', () => { it('accepts Windows-style absolute and relative input paths', async () => { const runtime = registerBareModuleGraph('C:\\repo'); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); @@ -195,14 +195,14 @@ describe('module-graph open service', () => { }); }); - describe('applyGraphUpdate command', () => { + describe('_applyGraphUpdate command', () => { it('replaces the reverse index, bumps the revision, and records latest changed stories', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Icon.tsx': { './src/Button.stories.tsx': 2 }, @@ -223,14 +223,14 @@ describe('module-graph open service', () => { it('stamps each bumped story with the new revision and leaves untouched stories at 0', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, }, }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, @@ -248,11 +248,11 @@ describe('module-graph open service', () => { it('replaces latest story changes with the newest revision payload', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./a.stories.tsx', './b.stories.tsx'], }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./a.stories.tsx'], }); @@ -266,11 +266,11 @@ describe('module-graph open service', () => { it('does not advance the revision for an out-of-graph change (no bumped stories)', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, bumpedStoryFiles: [], }); @@ -292,7 +292,7 @@ describe('module-graph open service', () => { storyFiles: [], }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./src/Button.stories.tsx', './src/Card.stories.tsx'], }); @@ -306,11 +306,11 @@ describe('module-graph open service', () => { it('replaces the previous change set when a newer update bumps different stories', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./a.stories.tsx', './b.stories.tsx'], }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./c.stories.tsx'], }); @@ -324,11 +324,11 @@ describe('module-graph open service', () => { it('preserves the prior change set when an update bumps no stories', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./src/Button.stories.tsx'], }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, bumpedStoryFiles: [], }); @@ -342,11 +342,11 @@ describe('module-graph open service', () => { it('clears story files after a snapshot without resetting the graph revision', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./src/Button.stories.tsx'], }); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); @@ -356,14 +356,14 @@ describe('module-graph open service', () => { }); }); - it('clears story files but keeps the current revision after bumpGraphRevision', async () => { + it('clears story files but keeps the current revision after _bumpGraphRevision', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./src/Button.stories.tsx'], }); - await runtime.commands.bumpGraphRevision(undefined); + await runtime.commands._bumpGraphRevision(undefined); expect(runtime.queries.getLatestStoryChanges(undefined)).toEqual({ revision: 2, @@ -378,11 +378,11 @@ describe('module-graph open service', () => { seen.push(value); }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./a.stories.tsx'], }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./b.stories.tsx'], }); @@ -397,10 +397,10 @@ describe('module-graph open service', () => { describe('getGraphRevision query scopes', () => { it('returns 0 for an empty watch list and ignores unknown stories', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, bumpedStoryFiles: ['./src/Button.stories.tsx'], }); @@ -417,10 +417,10 @@ describe('module-graph open service', () => { it('accepts absolute and relative scope paths', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, bumpedStoryFiles: ['./src/Button.stories.tsx'], }); @@ -436,11 +436,11 @@ describe('module-graph open service', () => { it('advances watch-all but not scoped reads on a bare revision bump', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 } }, }); - await runtime.commands.bumpGraphRevision(undefined); + await runtime.commands._bumpGraphRevision(undefined); // Index reconciliation advances the global (watch-all) revision... expect(runtime.queries.getGraphRevision(undefined)).toBe(1); @@ -459,8 +459,8 @@ describe('module-graph open service', () => { seen.push(revision); }); - await runtime.commands.applyGraphSnapshot({ storiesByFile: {} }); - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: {} }); + await runtime.commands._applyGraphUpdate({ storiesByFile: {}, bumpedStoryFiles: ['./a.stories.tsx'], }); @@ -471,7 +471,7 @@ describe('module-graph open service', () => { it('notifies a scoped subscriber only when its story is bumped', async () => { const runtime = registerBareModuleGraph(); - await runtime.commands.applyGraphSnapshot({ + await runtime.commands._applyGraphSnapshot({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, @@ -487,7 +487,7 @@ describe('module-graph open service', () => { ); // Bump an unrelated story: the Button-scoped subscriber must not advance. - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, @@ -495,7 +495,7 @@ describe('module-graph open service', () => { bumpedStoryFiles: ['./src/Card.stories.tsx'], }); // Now bump Button itself. - await runtime.commands.applyGraphUpdate({ + await runtime.commands._applyGraphUpdate({ storiesByFile: { './src/Button.tsx': { './src/Button.stories.tsx': 1 }, './src/Card.tsx': { './src/Card.stories.tsx': 1 }, diff --git a/code/core/src/shared/open-service/services/module-graph/server.ts b/code/core/src/shared/open-service/services/module-graph/server.ts index 92106784f011..ee4e647de253 100644 --- a/code/core/src/shared/open-service/services/module-graph/server.ts +++ b/code/core/src/shared/open-service/services/module-graph/server.ts @@ -59,7 +59,7 @@ export function registerModuleGraphService(options: RegisterModuleGraphServiceOp }, { commands: { - waitForSettledEngine: { + _waitForSettledEngine: { handler: async () => { await engine!.whenSettled(); }, @@ -73,19 +73,19 @@ export function registerModuleGraphService(options: RegisterModuleGraphServiceOp workingDir, presets: options.presets, onSnapshot: (storiesByFile) => { - void runtime.commands.applyGraphSnapshot({ storiesByFile }); + void runtime.commands._applyGraphSnapshot({ storiesByFile }); }, onUpdate: ({ storiesByFile, bumpedStoryFiles }) => { - void runtime.commands.applyGraphUpdate({ storiesByFile, bumpedStoryFiles }); + void runtime.commands._applyGraphUpdate({ storiesByFile, bumpedStoryFiles }); }, onStoryIndexInvalidated: () => { - void runtime.commands.bumpGraphRevision(undefined); + void runtime.commands._bumpGraphRevision(undefined); }, onError: (error) => { - void runtime.commands.setStatus({ value: 'error', error: errorToErrorLike(error) }); + void runtime.commands._setStatus({ value: 'error', error: errorToErrorLike(error) }); }, onUnavailable: (reason, error) => { - void runtime.commands.setStatus({ + void runtime.commands._setStatus({ value: 'unavailable', reason, ...(error ? { error: errorToErrorLike(error) } : {}), @@ -99,7 +99,7 @@ export function registerModuleGraphService(options: RegisterModuleGraphServiceOp void changeDetectionAdapterPromise.then((adapter) => { if (!adapter) { - void runtime.commands.setStatus({ + void runtime.commands._setStatus({ value: 'unavailable', reason: 'builder does not support change detection', }); diff --git a/code/core/src/shared/open-service/use-service-query.ts b/code/core/src/shared/open-service/use-service-query.ts index 71e1729c9cb7..5d1caa202f32 100644 --- a/code/core/src/shared/open-service/use-service-query.ts +++ b/code/core/src/shared/open-service/use-service-query.ts @@ -6,9 +6,9 @@ * * Re-renders only when the specific query result changes by value. Signal-level dedup * inside the service runtime ensures that a load which rewrites a deeply-equal payload does - * not re-fire the subscription; `isEqual` in `getSnapshot` provides an additional + * not re-fire the subscription; `isEqual` in the subscription callback provides an additional * referential-stability layer at the React boundary so the component sees a stable object - * reference across snapshot reads that return the same logical value. + * reference across updates that return the same logical value. * * Object inputs are compared with deep equality when deciding whether to re-subscribe, so inline * literals at the call site are safe. @@ -88,21 +88,11 @@ export function useServiceQuery( [queryFn, subscriptionKey] ); - // Read directly from the service to get the freshest synchronous value, but compare with - // the previously stored snapshot so React sees a stable reference when the value is - // deeply equal. This prevents unnecessary re-renders when `getSnapshot` is called outside - // of a subscriber notification (e.g. on React's concurrent-mode bailout checks). + // React may call getSnapshot multiple times during render/bailout checks, so it must be a pure + // ref read. Calling the service query here would be observable for queries with load hooks. const getSnapshot = React.useCallback((): TOutput => { - const value = queryFn(subscriptionKey.input); - const previous = snapshotRef.current as TOutput; + return snapshotRef.current as TOutput; + }, []); - if (isEqual(value, previous)) { - return previous; - } - - snapshotRef.current = value; - return value; - }, [queryFn, subscriptionKey]); - - return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return React.useSyncExternalStore(subscribe, getSnapshot); } diff --git a/code/core/src/telemetry/index.test.ts b/code/core/src/telemetry/index.test.ts index 126dffe5033b..5ba0d2fe3eb1 100644 --- a/code/core/src/telemetry/index.test.ts +++ b/code/core/src/telemetry/index.test.ts @@ -23,6 +23,10 @@ vi.mock('./telemetry.ts', () => ({ beforeEach(async () => { vi.resetModules(); + // The state machine globals deliberately survive module re-loads, so reset them explicitly + delete (globalThis as any).SB_TELEMETRY_STATE; + delete (globalThis as any).SB_TELEMETRY_QUEUE; + delete (globalThis as any).PAYLOAD_ERROR_HANDLER; telemetryModule = await import('./index.ts'); }, 30_000); @@ -173,6 +177,74 @@ describe('telemetry state machine', () => { }); }); +describe('second module load (e.g. addon resolving its own copy of the storybook package)', () => { + it('preserves resolved state when the module loads a second time', async () => { + await telemetryModule.setTelemetryEnabled(true); + + vi.resetModules(); + const secondInstance = await import('./index.ts'); + + expect(secondInstance.isTelemetryStateResolved()).toBe(true); + expect(secondInstance.isTelemetryModuleEnabled()).toBe(true); + }); + + it('sends events from a second module instance after the first one resolved state', async () => { + await telemetryModule.setTelemetryEnabled(true); + + vi.resetModules(); + const secondInstance = await import('./index.ts'); + const { sendTelemetry } = await import('./telemetry.ts'); + + await secondInstance.telemetry('dev', { foo: 'bar' }, { stripMetadata: true }); + + expect(sendTelemetry).toHaveBeenCalledTimes(1); + }); + + it('flushes events queued by the first instance when a second instance resolves state', async () => { + await telemetryModule.telemetry('boot', { eventType: 'dev' }, { stripMetadata: true }); + + vi.resetModules(); + const secondInstance = await import('./index.ts'); + const { sendTelemetry } = await import('./telemetry.ts'); + + await secondInstance.setTelemetryEnabled(true); + + expect(sendTelemetry).toHaveBeenCalledTimes(1); + expect(sendTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'boot', payload: { eventType: 'dev' } }), + expect.anything() + ); + }); + + it('preserves disabled state when the module loads a second time', async () => { + await telemetryModule.setTelemetryEnabled(false); + + vi.resetModules(); + const secondInstance = await import('./index.ts'); + const { sendTelemetry } = await import('./telemetry.ts'); + + await secondInstance.telemetry('dev', { foo: 'bar' }); + + expect(secondInstance.isTelemetryStateResolved()).toBe(true); + expect(secondInstance.isTelemetryModuleEnabled()).toBe(false); + expect(sendTelemetry).not.toHaveBeenCalled(); + }); + + it('keeps a payload error handler registered through the first instance', async () => { + const errorHandler = vi.fn().mockResolvedValue(undefined); + await telemetryModule.setTelemetryEnabled(true); + telemetryModule.onPayloadError(errorHandler); + + vi.resetModules(); + const secondInstance = await import('./index.ts'); + + const testError = new Error('payload failed'); + await secondInstance.telemetry('dev', async () => ({ error: testError })); + + expect(errorHandler).toHaveBeenCalledWith(testError, 'dev'); + }); +}); + describe('payload error handler (onPayloadError)', () => { it('calls registered handler when payload factory returns { error }', async () => { const { sendTelemetry } = await import('./telemetry.ts'); diff --git a/code/core/src/telemetry/index.ts b/code/core/src/telemetry/index.ts index 0f17d84dd6ee..f5caf0b7ac4e 100644 --- a/code/core/src/telemetry/index.ts +++ b/code/core/src/telemetry/index.ts @@ -48,16 +48,28 @@ export const isExampleStoryId = (storyId: string) => type TelemetryState = undefined | 'enabled' | 'disabled'; -globalThis.SB_TELEMETRY_STATE = undefined as TelemetryState; // Start in uninitialized state until we know whether telemetry is enabled or disabled based on presets and CLI options. In the meantime, events are queued. - -type QueuedEvent = { +export type QueuedEvent = { eventType: EventType; payload: PayloadInput; options: Partial; timestamp: number; }; -let _queue: QueuedEvent[] = []; +// State and queue live on globalThis and are only initialized when absent, because this module +// can load more than once in the same process (e.g. an addon resolving its own copy of the +// storybook package, or dual CJS/ESM loading). An unconditional assignment would reset +// already-resolved state back to uninitialized, silently queueing all subsequent events forever. +// The `in` check (rather than `=== undefined`) is load-bearing: the uninitialized state is +// literally `undefined`, so only key presence distinguishes "seeded" from "never loaded". +if (!('SB_TELEMETRY_STATE' in globalThis)) { + // Start in uninitialized state until we know whether telemetry is enabled or disabled based on + // presets and CLI options. In the meantime, events are queued. + globalThis.SB_TELEMETRY_STATE = undefined as TelemetryState; +} + +if (!('SB_TELEMETRY_QUEUE' in globalThis)) { + globalThis.SB_TELEMETRY_QUEUE = []; +} const isPayloadFactory = (payload: PayloadInput): payload is PayloadFactory => typeof payload === 'function'; @@ -75,8 +87,8 @@ export async function setTelemetryEnabled(enabled: boolean) { if (enabled && previousState === undefined) { // Flush the queue - const pending = _queue; - _queue = []; + const pending = globalThis.SB_TELEMETRY_QUEUE; + globalThis.SB_TELEMETRY_QUEUE = []; for (const event of pending) { try { await _processAndSend(event.eventType, event.payload, { @@ -90,7 +102,7 @@ export async function setTelemetryEnabled(enabled: boolean) { } } else { // Clear the queue (disabled, or already resolved) - _queue = []; + globalThis.SB_TELEMETRY_QUEUE = []; } } @@ -111,8 +123,13 @@ export function isTelemetryStateResolved() { * Registered by withTelemetry() to delegate to sendTelemetryError with full context * (presets, cache, error levels, sub-errors). */ -type PayloadErrorHandler = (error: Error, eventType: EventType) => Promise; -globalThis.PAYLOAD_ERROR_HANDLER = undefined as PayloadErrorHandler | undefined; +export type PayloadErrorHandler = (error: Error, eventType: EventType) => Promise; + +// Guarded for the same reason as SB_TELEMETRY_STATE above: a second load of this module must not +// clear a handler registered through the first one. +if (!('PAYLOAD_ERROR_HANDLER' in globalThis)) { + globalThis.PAYLOAD_ERROR_HANDLER = undefined as PayloadErrorHandler | undefined; +} /** * Register a handler for payload factory errors. When a telemetry payload factory @@ -205,7 +222,7 @@ export const telemetry = async ( } if (globalThis.SB_TELEMETRY_STATE === undefined && !options.force) { - _queue.push({ eventType, payload, options, timestamp: Date.now() }); + globalThis.SB_TELEMETRY_QUEUE.push({ eventType, payload, options, timestamp: Date.now() }); return; } diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 6deb54bb41b0..4435d38dd214 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -46,6 +46,7 @@ export type EventType = | 'share' | 'ghost-stories' | 'sidebar-filter' + | 'ai-command' | 'ai-init-opt-in' | 'ai-prompt-nudge' | 'ai-setup' diff --git a/code/core/src/types/modules/docs.ts b/code/core/src/types/modules/docs.ts index 84db04e266a9..fdc156968777 100644 --- a/code/core/src/types/modules/docs.ts +++ b/code/core/src/types/modules/docs.ts @@ -81,6 +81,15 @@ export interface DocsContextProps { /** Syncronously find all stories of the component referenced by the CSF file. */ componentStories: () => PreparedStory[]; + /** + * Resolve the component id (the CSF title id) for a component object referenced by a docs entry. + * + * Returns the id of the first referenced CSF file whose `meta.component` is the given component, + * or `undefined` when no referenced CSF file declares it. Used by blocks like `` to key service lookups that are addressed by component id. + */ + getComponentId: (component: TRenderer['component']) => string | undefined; + /** Syncronously find all stories by CSF file. */ componentStoriesFromCSFFile: (csfFile: CSFFile) => PreparedStory[]; diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts index 373feef0b51a..ab28fd28239e 100644 --- a/code/core/src/typings.d.ts +++ b/code/core/src/typings.d.ts @@ -17,7 +17,8 @@ declare var STORYBOOK_RENDERER: import('./types/modules/renderers').SupportedRen declare var STORYBOOK_HOOKS_CONTEXT: any; declare var STORYBOOK_CURRENT_TASK_LOG: undefined | null | Array; declare var SB_TELEMETRY_STATE: 'enabled' | 'disabled' | undefined; -declare var PAYLOAD_ERROR_HANDLER: PayloadErrorHandler | undefined; +declare var SB_TELEMETRY_QUEUE: Array; +declare var PAYLOAD_ERROR_HANDLER: import('./telemetry').PayloadErrorHandler | undefined; declare var STORYBOOK_LAST_EVENTS: Record< import('./telemetry').EventType, diff --git a/code/core/src/viewport/useViewport.ts b/code/core/src/viewport/useViewport.ts index 4d0c5fa4b0f8..90f27792780a 100644 --- a/code/core/src/viewport/useViewport.ts +++ b/code/core/src/viewport/useViewport.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { deprecate } from 'storybook/internal/client-logger'; import type { Globals } from 'storybook/internal/csf'; import { useGlobals, useParameter, useStorybookApi } from 'storybook/manager-api'; @@ -157,6 +158,17 @@ export const useViewport = () => { const parameter = useParameter(PARAM_KEY); const [globals, updateGlobals, storyGlobals, userGlobals] = useGlobals(); + useEffect(() => { + if (parameter && 'defaultViewport' in parameter) { + const value = (parameter as { defaultViewport?: unknown }).defaultViewport; + deprecate( + `The \`viewport.defaultViewport\` parameter was removed in Storybook 10. ` + + `Use \`globals: { viewport: ${JSON.stringify(value)} }\` instead, ` + + `or run \`npx storybook automigrate\` to update your code automatically.` + ); + } + }, [parameter]); + const { options = MINIMAL_VIEWPORTS, disable = false } = parameter || {}; const { name, type, width, height, value, option, isCustom, isDefault, isLocked, isRotated } = parseGlobals( diff --git a/code/frameworks/react-vite/src/preset.ts b/code/frameworks/react-vite/src/preset.ts index 1f752d510376..c1252949d1d0 100644 --- a/code/frameworks/react-vite/src/preset.ts +++ b/code/frameworks/react-vite/src/preset.ts @@ -8,6 +8,14 @@ export const core: PresetProperty<'core'> = { }; export const viteFinal: NonNullable = async (config, { presets }) => { + const features = await presets.apply('features', {}); + + if (features?.experimentalDocgenServer) { + // The docgen service extracts React metadata on the server. Keep the preview bundle free of + // build-time `__docgenInfo` injection so custom argTypes remain docgen-free. + return config; + } + const plugins = [...(config?.plugins ?? [])]; // Add docgen plugin diff --git a/code/lib/cli-storybook/package.json b/code/lib/cli-storybook/package.json index 2e91d0b1b5e5..85b00077aece 100644 --- a/code/lib/cli-storybook/package.json +++ b/code/lib/cli-storybook/package.json @@ -54,13 +54,15 @@ "envinfo": "^7.14.0", "globby": "^14.1.0", "leven": "^4.0.0", + "memfs": "^4.11.1", "p-limit": "^7.2.0", "picocolors": "^1.1.0", "semver": "^7.7.3", "slash": "^5.0.0", "tiny-invariant": "^1.3.3", "tinyclip": "^0.1.12", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "valibot": "^1.4.0" }, "publishConfig": { "access": "public" diff --git a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts index e9995c6e5cce..d72448d76f2a 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts @@ -1,27 +1,18 @@ -import { dirname, isAbsolute, join, normalize } from 'node:path'; +import { normalize } from 'node:path'; import { - JsPackageManagerFactory, builderPackages, extractFrameworkPackageName, frameworkPackages, - getStorybookInfo, } from 'storybook/internal/common'; -import type { PackageManagerName } from 'storybook/internal/common'; import { frameworkToRenderer } from 'storybook/internal/common'; import type { ConfigFile } from 'storybook/internal/csf-tools'; -import { - isCsfFactoryPreview, - readConfig, - writeConfig as writeConfigFile, -} from 'storybook/internal/csf-tools'; +import { readConfig, writeConfig as writeConfigFile } from 'storybook/internal/csf-tools'; import { logger } from 'storybook/internal/node-logger'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import picocolors from 'picocolors'; -import { getStoriesPathsFromConfig } from '../../util.ts'; - /** * Given a Storybook configuration object, retrieves the package name or file path of the framework. * @@ -103,81 +94,7 @@ export const getFrameworkOptions = ( : (mainConfig?.framework?.options ?? null); }; -export const getStorybookData = async ({ - configDir: userDefinedConfigDir, - packageManagerName, -}: { - configDir?: string; - packageManagerName?: PackageManagerName; - cache?: boolean; -}) => { - logger.debug('Getting Storybook info...'); - const { - mainConfig, - mainConfigPath: mainConfigPath, - configDir: configDirFromScript, - previewConfigPath, - versionSpecifier, - frameworkPackage, - rendererPackage, - renderer, - builderPackage, - addons, - } = await getStorybookInfo( - userDefinedConfigDir, - userDefinedConfigDir ? dirname(userDefinedConfigDir) : undefined - ); - - const configDir = userDefinedConfigDir || configDirFromScript || '.storybook'; - - logger.debug('Loading main config...'); - - const workingDir = isAbsolute(configDir) - ? dirname(configDir) - : dirname(join(process.cwd(), configDir)); - - logger.debug('Getting stories paths...'); - const storiesPaths = await getStoriesPathsFromConfig({ - stories: mainConfig.stories, - configDir, - workingDir, - }); - - logger.debug('Getting package manager...'); - const packageManager = JsPackageManagerFactory.getPackageManager({ - force: packageManagerName, - configDir, - storiesPaths, - }); - - logger.debug('Getting Storybook version...'); - const versionInstalled = (await packageManager.getModulePackageJSON('storybook'))?.version; - - logger.debug('Detecting CSF factory usage...'); - const hasCsfFactoryPreview = previewConfigPath - ? isCsfFactoryPreview(await readConfig(previewConfigPath)) - : false; - - return { - configDir, - mainConfig, - /** The version specifier of Storybook from the user's package.json */ - versionSpecifier, - /** The version of Storybook installed in the user's project */ - versionInstalled, - mainConfigPath, - previewConfigPath, - packageManager, - storiesPaths, - hasCsfFactoryPreview, - frameworkPackage, - rendererPackage, - renderer, - builderPackage, - addons, - }; -}; -export type GetStorybookData = typeof getStorybookData; +export { getStorybookData, type GetStorybookData } from 'storybook/internal/cli'; /** * A helper function to safely read and write the main config file. At the end of the callback, diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index b784848b26aa..ca7205a7e18d 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -23,7 +23,6 @@ import { doctor } from '../doctor/index.ts'; import { link } from '../link.ts'; import { migrate } from '../migrate.ts'; import { sandbox } from '../sandbox.ts'; -import { aiSetup } from '../ai/index.ts'; import { type UpgradeOptions, upgrade } from '../upgrade.ts'; addToGlobalContext('cliVersion', versions.storybook); @@ -299,36 +298,6 @@ command('doctor') }).catch(handleCommandFailure(options.logfile)); }); -const aiCommand = command('ai') - .description('AI agent helpers for Storybook') - .option( - '-o, --output ', - 'Write the prompt output to a file instead of printing it to stdout' - ); - -aiCommand - .command('setup') - .description('Generate setup instructions to write stories for real components') - .addOption( - new Option('--package-manager ', 'Force package manager for installing deps').choices( - Object.values(PackageManagerName) - ) - ) - .option('-c, --config-dir ', 'Directory of Storybook configuration') - .action(async (options, cmd) => { - const parentOptions = cmd.parent?.opts() ?? {}; - const runId = Math.random().toString(36); - const mergedOptions = { ...parentOptions, ...options, runId }; - await withTelemetry('ai-setup', { cliOptions: mergedOptions }, async () => { - await aiSetup(mergedOptions); - }).catch(handleCommandFailure(mergedOptions.logfile)); - }); - -// Show available subcommands when `storybook ai` is run without arguments -aiCommand.action(() => { - aiCommand.outputHelp(); -}); - program.on('command:*', ([invalidCmd]) => { let errorMessage = ` Invalid command: ${picocolors.bold(invalidCmd)}.\n See --help for a list of available commands.`; const availableCommands = program.commands.map((cmd) => cmd.name()); diff --git a/code/lib/cli-storybook/src/util.ts b/code/lib/cli-storybook/src/util.ts index 6a74118de8a1..4e59d5fb64f4 100644 --- a/code/lib/cli-storybook/src/util.ts +++ b/code/lib/cli-storybook/src/util.ts @@ -1,7 +1,10 @@ import type { PackageJsonWithDepsAndDevDeps } from 'storybook/internal/common'; -import { HandledError, JsPackageManager, normalizeStories } from 'storybook/internal/common'; +import { HandledError, JsPackageManager } from 'storybook/internal/common'; import { getProjectRoot, isSatelliteAddon, versions } from 'storybook/internal/common'; -import { StoryIndexGenerator, experimental_loadStorybook } from 'storybook/internal/core-server'; +import { + experimental_loadStorybook, + getStoriesPathsFromConfig, +} from 'storybook/internal/core-server'; import { logTracker, logger, prompt } from 'storybook/internal/node-logger'; import { UpgradeStorybookToLowerVersionError, @@ -20,6 +23,8 @@ import type { AutoblockerResult } from './autoblock/types.ts'; import { getStorybookData } from './automigrate/helpers/mainConfigFile.ts'; import { type UpgradeOptions } from './upgrade.ts'; +export { getStoriesPathsFromConfig }; + // ============================================================================ // TYPES AND INTERFACES // ============================================================================ @@ -776,47 +781,3 @@ export const getEvaluatedStoryPaths = async ( workingDir, }); }; - -/** - * Gets story file paths from a Storybook configuration directory - * - * @example - * - * ```typescript - * const storiesPaths = await getStoriesPathsFromConfigWithoutEvaluating({ - * stories: ['src\/**\/*.stories.tsx'], - * configDir: '/path/to/.storybook', - * workingDir: '/path/to/project', - * }); - * ``` - */ -export const getStoriesPathsFromConfig = async ({ - stories, - configDir, - workingDir, -}: { - stories: StorybookConfigRaw['stories']; - configDir: string; - workingDir: string; -}) => { - if (stories.length === 0) { - return []; - } - - const normalizedStories = normalizeStories(stories, { - configDir, - workingDir, - }); - - const matchingStoryFiles = await StoryIndexGenerator.findMatchingFilesForSpecifiers( - normalizedStories, - workingDir, - true - ); - - const storiesPaths = matchingStoryFiles.flatMap(([specifier, cache]) => { - return StoryIndexGenerator.storyFileNames(new Map([[specifier, cache]])); - }); - - return storiesPaths; -}; diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts index d3352e4cba5c..4403f1975dcd 100644 --- a/code/lib/create-storybook/src/services/ProjectTypeService.ts +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -1,12 +1,16 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; -import { ProjectType } from 'storybook/internal/cli'; +import { + ProjectType, + detectIncompatiblePackageVersions, + detectLanguage, +} from 'storybook/internal/cli'; import { HandledError, getProjectRoot } from 'storybook/internal/common'; import type { JsPackageManager, PackageJsonWithMaybeDeps } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { NxProjectDetectedError } from 'storybook/internal/server-errors'; -import { SupportedLanguage } from 'storybook/internal/types'; +import type { SupportedLanguage } from 'storybook/internal/types'; import * as find from 'empathic/find'; import semver from 'semver'; @@ -207,91 +211,12 @@ export class ProjectTypeService { } async detectLanguage(): Promise { - let language = SupportedLanguage.JAVASCRIPT; - - if (existsSync('jsconfig.json')) { - return language; - } - - const isTypescriptDirectDependency = !!this.jsPackageManager.getAllDependencies().typescript; - - if (isTypescriptDirectDependency) { - const incompatibleReasons = await this.detectIncompatiblePackageVersions(); - if (incompatibleReasons.length === 0) { - language = SupportedLanguage.TYPESCRIPT; - } - } else { - // No direct dependency on TypeScript, but could be a transitive dependency - // This is eg the case for Nuxt projects, which support a recent version of TypeScript - // Check for tsconfig.json (https://www.typescriptlang.org/docs/handbook/tsconfig-json.html) - if (existsSync('tsconfig.json')) { - language = SupportedLanguage.TYPESCRIPT; - } - } - - return language; + return detectLanguage(this.jsPackageManager); } /** Check installed tooling versions for TypeScript compatibility constraints */ async detectIncompatiblePackageVersions(): Promise { - const getModulePackageJSONVersion = async (pkg: string) => { - return (await this.jsPackageManager.getModulePackageJSON(pkg))?.version ?? null; - }; - - const [ - typescriptVersion, - prettierVersion, - babelPluginTransformTypescriptVersion, - typescriptEslintParserVersion, - eslintPluginStorybookVersion, - ] = await Promise.all([ - getModulePackageJSONVersion('typescript'), - getModulePackageJSONVersion('prettier'), - getModulePackageJSONVersion('@babel/plugin-transform-typescript'), - getModulePackageJSONVersion('@typescript-eslint/parser'), - getModulePackageJSONVersion('eslint-plugin-storybook'), - ]); - - const satisfies = (version: string | null, range: string) => { - if (!version) { - return false; - } - return semver.satisfies(version, range, { includePrerelease: true }); - }; - - const incompatibleReasons: string[] = []; - - if (typescriptVersion && !satisfies(typescriptVersion, '>=4.9.0')) { - incompatibleReasons.push(`typescript ${typescriptVersion} is below 4.9.0`); - } - if (prettierVersion && !semver.gte(prettierVersion, '2.8.0')) { - incompatibleReasons.push(`prettier ${prettierVersion} is below 2.8.0`); - } - if ( - babelPluginTransformTypescriptVersion && - !satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0') - ) { - incompatibleReasons.push( - `@babel/plugin-transform-typescript ${babelPluginTransformTypescriptVersion} is below 7.20.0` - ); - } - if (typescriptEslintParserVersion && !satisfies(typescriptEslintParserVersion, '>=5.44.0')) { - incompatibleReasons.push( - `@typescript-eslint/parser ${typescriptEslintParserVersion} is below 5.44.0` - ); - } - // Treat Storybook canary/prerelease versions (e.g. 0.0.0-pr-*) as compatible - if ( - eslintPluginStorybookVersion && - !eslintPluginStorybookVersion.startsWith('0.0.0-') && - !satisfies(eslintPluginStorybookVersion, '>=0.6.8') - ) { - incompatibleReasons.push( - `eslint-plugin-storybook ${eslintPluginStorybookVersion} is below 0.6.8` - ); - } - - return incompatibleReasons; + return detectIncompatiblePackageVersions(this.jsPackageManager); } private eqMajor(versionRange: string, major: number) { diff --git a/code/package.json b/code/package.json index f81c886e0e31..c34bd85db00a 100644 --- a/code/package.json +++ b/code/package.json @@ -196,5 +196,6 @@ "Dependency Upgrades" ] ] - } + }, + "deferredNextVersion": "10.5.0-alpha.7" } diff --git a/code/presets/react-webpack/src/framework-preset-react-docs.ts b/code/presets/react-webpack/src/framework-preset-react-docs.ts index cffac3b53db8..c2c0f4879520 100644 --- a/code/presets/react-webpack/src/framework-preset-react-docs.ts +++ b/code/presets/react-webpack/src/framework-preset-react-docs.ts @@ -8,6 +8,14 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async ( config, options ): Promise => { + const features = await options.presets.apply('features', {}); + if (features?.experimentalDocgenServer) { + // The docgen service owns React metadata extraction for this mode. Do not inject + // `Component.__docgenInfo` into the preview bundle, otherwise preview argTypes would include + // docgen data that the UI is now responsible for merging from the service. + return config; + } + const typescriptOptions = await options.presets.apply('typescript', {} as any); const debug = options.loglevel === 'debug'; diff --git a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts index 8c113783b1c8..c285c291a72f 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/ComponentMetaProject.storyExtraction.test.ts @@ -44,7 +44,7 @@ describe('prop extraction via story JSX', () => { parent: { name: 'ButtonProps' }, }, disabled: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, parent: { name: 'ButtonProps' }, }, @@ -247,7 +247,7 @@ describe('prop extraction via story JSX', () => { }, props: { multiple: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, description: 'Whether multiple items can be open', defaultValue: { value: 'true' }, @@ -302,7 +302,7 @@ describe('prop extraction via story JSX', () => { parent: { name: 'PanelProps' }, }, open: { - type: { name: 'boolean | undefined' }, + type: { name: 'boolean' }, required: false, description: 'Whether the panel is open', defaultValue: { value: 'false' }, diff --git a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts index 8c1269e0123e..1c2074c763cf 100644 --- a/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts +++ b/code/renderers/react/src/componentManifest/componentMeta/componentMetaExtractor.ts @@ -698,6 +698,16 @@ function serializeType( (t) => !(t.getFlags() & typescript.TypeFlags.Undefined) ); + // `boolean` is modeled as the union `true | false`. For optional props the implicit + // `| undefined` keeps this a union, so `typeToString` would yield `boolean | undefined` + // (mapped to `other` downstream). Collapse the boolean-literal pair back to `boolean`. + const booleanLiterals = nonUndefinedTypes.filter( + (t) => t.getFlags() & typescript.TypeFlags.BooleanLiteral + ); + if (booleanLiterals.length === 2 && booleanLiterals.length === nonUndefinedTypes.length) { + return { name: 'boolean' }; + } + const literalMembers = nonUndefinedTypes.filter(isLiteralType); if (literalMembers.length > 0 && literalMembers.length === nonUndefinedTypes.length) { diff --git a/code/renderers/react/src/componentManifest/jsdocTags.test.ts b/code/renderers/react/src/componentManifest/jsdocTags.test.ts index 27f830b6bbb5..91b6f7a278dd 100644 --- a/code/renderers/react/src/componentManifest/jsdocTags.test.ts +++ b/code/renderers/react/src/componentManifest/jsdocTags.test.ts @@ -38,3 +38,29 @@ it('should extract @param tag with type', () => { } `); }); + +it('preserves blank lines and newlines in the description so Markdown survives', () => { + const code = dedent` + ## Example button component + + Comes in three sizes: \`small\`, \`medium\`, and \`large\`. + + Can be primary or secondary. + + _This description is written as a comment above the component_ + @summary short summary`; + const { description, tags } = extractJSDocInfo(code); + + expect(description).toBe( + [ + '## Example button component', + '', + 'Comes in three sizes: `small`, `medium`, and `large`.', + '', + 'Can be primary or secondary.', + '', + '_This description is written as a comment above the component_', + ].join('\n') + ); + expect(tags).toEqual({ summary: ['short summary'] }); +}); diff --git a/code/renderers/react/src/componentManifest/jsdocTags.ts b/code/renderers/react/src/componentManifest/jsdocTags.ts index 5d0fd31b7795..c746c46cbdf0 100644 --- a/code/renderers/react/src/componentManifest/jsdocTags.ts +++ b/code/renderers/react/src/componentManifest/jsdocTags.ts @@ -6,10 +6,16 @@ export function extractJSDocInfo(jsdocComment: string) { const lines = jsdocComment.split('\n'); const jsDoc = ['/**', ...lines.map((line) => ` * ${line}`), ' */'].join('\n'); - const parsed = parse(jsDoc); + // `comment-parser` applies one `spacing` mode to the whole block, so we parse twice on purpose. + // `preserve` keeps blank lines and line breaks in the block description so multi-paragraph + // component comments still render as Markdown (matching react-docgen's legacy `__docgenInfo`). + // `compact` collapses each tag value onto a single line, which is the shape tag consumers and + // snapshots already expect — using `preserve` for both would change multi-line tag values too. + const description = parse(jsDoc, { spacing: 'preserve' })[0].description; + const parsed = parse(jsDoc, { spacing: 'compact' }); return { - description: parsed[0].description, + description, tags: Object.fromEntries( Object.entries(groupBy(parsed[0].tags, (it) => it.tag)).map(([key, tags]) => [ key, diff --git a/code/renderers/react/src/docgen/buildDocgen.test.ts b/code/renderers/react/src/docgen/buildDocgen.test.ts index 719e198be1be..6bb50f93f3a9 100644 --- a/code/renderers/react/src/docgen/buildDocgen.test.ts +++ b/code/renderers/react/src/docgen/buildDocgen.test.ts @@ -42,7 +42,7 @@ afterEach(() => { describe('buildDocgenPayload', () => { it( - 'extracts name, description, props, and a snippet for one component', + 'extracts name, description, props, argTypes, and a snippet for one component', { timeout: 30_000 }, async () => { tempDir = createTempDir('docgen-build'); @@ -84,6 +84,14 @@ describe('buildDocgenPayload', () => { const meta = payload!.reactComponentMeta as ComponentDoc | undefined; expect(Object.keys(meta?.props ?? {}).sort()).toEqual(['disabled', 'label']); expect(meta?.props.label.required).toBe(true); + expect(payload!.argTypes?.label).toMatchObject({ + name: 'label', + type: { name: 'string', required: true }, + }); + expect(payload!.argTypes?.disabled).toMatchObject({ + name: 'disabled', + type: { name: 'boolean', required: false }, + }); expect(payload!.stories).toHaveLength(1); expect(payload!.stories?.[0]).toMatchObject({ id: expect.stringMatching(/--primary$/), @@ -155,5 +163,9 @@ describe('buildDocgenPayload', () => { | ComponentDoc | undefined; expect(Object.keys(cardHeaderMeta?.props ?? {})).toContain('level'); + expect(payload!.subcomponents?.CardHeader.argTypes?.level).toMatchObject({ + name: 'level', + type: { name: 'number', required: false }, + }); }); }); diff --git a/code/renderers/react/src/docgen/buildDocgen.ts b/code/renderers/react/src/docgen/buildDocgen.ts index 460a12f80b98..86af74d51905 100644 --- a/code/renderers/react/src/docgen/buildDocgen.ts +++ b/code/renderers/react/src/docgen/buildDocgen.ts @@ -1,16 +1,23 @@ -import type { DocgenPayload, DocgenProviderInput } from 'storybook/internal/types'; +import type { + DocgenPayload, + DocgenProviderInput, + DocgenSubcomponent, + StrictArgTypes, +} from 'storybook/internal/types'; import { getStoryImportPathFromEntry } from 'storybook/internal/common'; import path from 'pathe'; import { buildReactComponentDocgenFromResolved } from '../componentManifest/buildReactComponentDocgen.ts'; import type { ComponentMetaManager } from '../componentManifest/componentMeta/ComponentMetaManager.ts'; +import type { ComponentDoc } from '../componentManifest/componentMeta/componentMetaExtractor.ts'; import type { ComponentRef, StoryRef, TypescriptOptions, } from '../componentManifest/getComponentImports.ts'; import { resolveStoryFileComponents } from '../componentManifest/resolveComponents.ts'; +import { extractArgTypes } from '../extractArgTypes.ts'; export interface BuildDocgenContext { componentMetaManager: ComponentMetaManager; @@ -19,6 +26,47 @@ export interface BuildDocgenContext { resolvePath?: (importPath: string) => string; } +type ReactDocgenPayload = DocgenPayload & { + reactComponentMeta?: ComponentDoc; + subcomponents?: Record; +}; + +/** Converts one RCM `ComponentDoc` into the `StrictArgTypes` shape consumed by args tables. */ +function extractArgTypesFromComponentMeta( + componentMeta: ComponentDoc | undefined +): StrictArgTypes | undefined { + return componentMeta + ? (extractArgTypes({ __docgenInfo: componentMeta }) ?? undefined) + : undefined; +} + +/** + * Adds renderer-converted argTypes to the manifest-shaped React docgen payload. + * + * The service keeps the raw `reactComponentMeta` data for non-UI consumers, but UI consumers should + * read `argTypes` so they do not need to know about React-specific docgen engine output. + */ +function addArgTypesFromComponentMeta(payload: ReactDocgenPayload): DocgenPayload { + const argTypes = extractArgTypesFromComponentMeta(payload.reactComponentMeta); + const subcomponents = payload.subcomponents + ? Object.fromEntries( + Object.entries(payload.subcomponents).map(([name, subcomponent]) => [ + name, + { + ...subcomponent, + argTypes: extractArgTypesFromComponentMeta(subcomponent.reactComponentMeta), + }, + ]) + ) + : undefined; + + return { + ...payload, + ...(argTypes ? { argTypes } : {}), + ...(subcomponents ? { subcomponents } : {}), + }; +} + /** * Build a {@link DocgenPayload} for the component found in one CSF story file. * @@ -82,5 +130,5 @@ export async function buildDocgenPayload( docgenEngine: 'react-component-meta', }); - return componentDocgen; + return addArgTypesFromComponentMeta(componentDocgen); } diff --git a/scripts/build/utils/generate-bundle.ts b/scripts/build/utils/generate-bundle.ts index 780923842825..3566c96aec02 100644 --- a/scripts/build/utils/generate-bundle.ts +++ b/scripts/build/utils/generate-bundle.ts @@ -147,6 +147,7 @@ export async function generateBundle({ 'storybook/measure': './src/measure', 'storybook/actions': './src/actions', 'storybook/viewport': './src/viewport', + 'storybook/open-service': './src/shared/open-service', // The following aliases ensures that the manager has a single version of React, // even if transitive dependencies would depend on other versions. react: resolvePackageDir('react'), diff --git a/scripts/eval/README.md b/scripts/eval/README.md index 0666c00ce196..b48089596de9 100644 --- a/scripts/eval/README.md +++ b/scripts/eval/README.md @@ -295,7 +295,7 @@ The harness hands steps (1) and (2) to the trial agent as its task. Eval starts ### How variant selection works -Prompt variants live in [`code/lib/cli-storybook/src/ai/setup-prompts/`](../../code/lib/cli-storybook/src/ai/setup-prompts/). Each variant is a self-contained `.ts` file that exports an `instructions(projectInfo)` function. The registry in `prompts/index.ts` lists every variant. +Prompt variants live in [`code/core/src/cli/ai/setup-prompts/`](../../code/core/src/cli/ai/setup-prompts/). Each variant is a self-contained `.ts` file that exports an `instructions(projectInfo)` function. The registry in `prompts/index.ts` lists every variant. The eval selects a variant by injecting the `EVAL_SETUP_PROMPT` env var into the agent's spawn environment. When the agent later runs `npx storybook ai setup`, the CLI reads that env var and returns the matching variant. Real users never set this env var, so they always get the default (`pattern-copy-play`). @@ -314,9 +314,9 @@ eval.ts --prompt setup ### Adding a new prompt variant -1. Create `code/lib/cli-storybook/src/ai/setup-prompts/.ts`. Make it fully self-contained — keep its own `getTypeImportSource`, code-example helpers, and any other private utilities so changing one variant can never accidentally change another. Duplication is deliberate here. +1. Create `code/core/src/cli/ai/setup-prompts/.ts`. Make it fully self-contained — keep its own `getTypeImportSource`, code-example helpers, and any other private utilities so changing one variant can never accidentally change another. Duplication is deliberate here. 2. Export an `instructions(projectInfo: ProjectInfo): string` function. -3. Register it in `code/lib/cli-storybook/src/ai/setup-prompts/index.ts` by adding an entry to `CURRENTLY_USED_PROMPT` and moving the existing one to FORMERLY_USED_PROMPTS. +3. Register it in `code/core/src/cli/ai/setup-prompts/index.ts` by adding an entry to `CURRENTLY_USED_PROMPT` and moving the existing one to FORMERLY_USED_PROMPTS. 4. Use it from the eval: `node scripts/eval/eval.ts -p mealdrop --prompt `. To promote a variant to be the default users see, change `DEFAULT_PROMPT_NAME` in the same registry file. diff --git a/scripts/eval/lib/run-trial.ts b/scripts/eval/lib/run-trial.ts index b98a7fbb388e..a0aeb3ab49ce 100644 --- a/scripts/eval/lib/run-trial.ts +++ b/scripts/eval/lib/run-trial.ts @@ -24,7 +24,7 @@ export interface TrialConfig { project: Project; /** Agent, model, and effort level. */ variant: AgentVariant; - /** Prompt variant name — registered in `code/lib/cli-storybook/src/ai/prompts/` (e.g. "pattern-copy-play"). */ + /** Prompt variant name — registered in `code/core/src/cli/ai/setup-prompts/` (e.g. "pattern-copy-play"). */ prompt: string; /** Log agent messages to stdout. */ verbose?: boolean; diff --git a/scripts/eval/lib/utils.ts b/scripts/eval/lib/utils.ts index fbba1b6e2e51..3406d7c02892 100644 --- a/scripts/eval/lib/utils.ts +++ b/scripts/eval/lib/utils.ts @@ -6,7 +6,7 @@ import { x } from 'tinyexec'; import { DEFAULT_PROMPT_NAME, PROMPT_NAMES, -} from '../../../code/lib/cli-storybook/src/ai/setup-prompts/index.ts'; +} from '../../../code/core/src/cli/ai/setup-prompts/index.ts'; import { getAiSetupPrompt } from '../../../code/core/src/shared/utils/ai-prompts.ts'; export interface Logger { diff --git a/scripts/eval/run-batch.ts b/scripts/eval/run-batch.ts index 128427750079..160faf631158 100644 --- a/scripts/eval/run-batch.ts +++ b/scripts/eval/run-batch.ts @@ -560,7 +560,7 @@ const runBatchOptions = { prompt: { type: 'string' as const, description: - 'Prompt variant name (required unless --prompts is set; registered in code/lib/cli-storybook/src/ai/setup-prompts/)', + 'Prompt variant name (required unless --prompts is set; registered in code/core/src/cli/ai/setup-prompts/)', }, prompts: { type: 'string' as const, diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index 7b5e5e37a0b5..3850bcadded1 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -11,7 +11,7 @@ import { join, relative, resolve, sep } from 'path'; // eslint-disable-next-line depend/ban-dependencies import slash from 'slash'; -import { babelParse, types as t } from '../../code/core/src/babel/index.ts'; +import { babelParse, traverse, types as t } from '../../code/core/src/babel/index.ts'; import { JsPackageManagerFactory } from '../../code/core/src/common/js-package-manager/index.ts'; import storybookPackages from '../../code/core/src/common/versions.ts'; import type { ConfigFile } from '../../code/core/src/csf-tools/index.ts'; @@ -92,6 +92,114 @@ async function pathExists(path: string) { } } +const propKey = (p: t.ObjectProperty) => { + if (t.isIdentifier(p.key)) { + return p.key.name; + } + + if (t.isStringLiteral(p.key)) { + return p.key.value; + } + + return null; +}; + +const makeObjectExpression = (path: string[], value: t.Expression): t.Expression => { + if (path.length === 0) { + return value; + } + + const [first, ...rest] = path; + return t.objectExpression([ + t.objectProperty(t.identifier(first), makeObjectExpression(rest, value)), + ]); +}; + +const updateObjectExpression = ( + path: string[], + expr: t.Expression, + existing: t.ObjectExpression +) => { + const [first, ...rest] = path; + const existingField = (existing.properties as t.ObjectProperty[]).find( + (p) => propKey(p) === first + ) as t.ObjectProperty; + + if (!existingField) { + existing.properties.push( + t.objectProperty(t.identifier(first), makeObjectExpression(rest, expr)) + ); + } else if (t.isObjectExpression(existingField.value) && rest.length > 0) { + updateObjectExpression(rest, expr, existingField.value); + } else { + existingField.value = makeObjectExpression(rest, expr); + } +}; + +const findPluginCall = (name: string, ast: t.File): t.CallExpression | undefined => { + let call: t.CallExpression | undefined; + traverse(ast, { + CallExpression: { + enter(path) { + if (call) { + return; + } + + const { callee } = path.node; + if (t.isIdentifier(callee) && callee.name === name) { + call = path.node; + path.stop(); + } + }, + }, + }); + return call; +}; + +function setPluginParam( + config: ConfigFile, + { + pluginName, + paramPos, + paramPath, + paramValue, + }: { + pluginName: string; + paramPos: number; + paramPath: string[]; + paramValue: unknown; + } +) { + const call = findPluginCall(pluginName, config._ast); + if (!call) { + throw new Error(`Could not find a call to the "${pluginName}" plugin in this file.`); + } + + if (paramPos > call.arguments.length) { + throw new Error( + `Cannot set argument ${paramPos} of "${pluginName}" as the call only has ${call.arguments.length} argument(s).` + ); + } + + if (paramPos === call.arguments.length) { + call.arguments.push(t.objectExpression([])); + } + + const param = call.arguments[paramPos]; + if (!t.isObjectExpression(param)) { + throw new Error( + `Expected argument ${paramPos} of "${pluginName}" to be an object, got '${param.type}'.` + ); + } + + const valueNode = config.valueToNode(paramValue); + if (!valueNode) { + throw new Error(`Unexpected value ${JSON.stringify(paramValue)}`); + } + + updateObjectExpression(paramPath, valueNode, param); +} + const logger = console; export const essentialsAddons = [ @@ -180,7 +288,11 @@ export const init: Task['run'] = async ( extra = { type: 'server' }; break; case '@storybook/svelte': - await prepareSvelteSandbox(cwd); + if (template.expected.framework === '@storybook/sveltekit') { + await prepareSvelteKitSandbox(cwd); + } else { + await prepareSvelteSandbox(cwd); + } break; } @@ -475,17 +587,20 @@ export async function setupVitest(details: TemplateDetails, options: PassedOptio let fileContent = await readFile(join(sandboxDir, configFile), 'utf-8'); - // Insert resolve: { preserveSymlinks: true } and optionally server.fs.allow as siblings to plugins - // Handles both defineConfig({ ... }) and defineWorkspace([ ... , { ... }]) - fileContent = fileContent.replace(/(plugins\s*:\s*\[[^\]]*\],?)/, (match) => { - let replacement = `${match}\n resolve: {\n preserveSymlinks: true\n },`; + // Insert resolve: { preserveSymlinks: true } and optionally server.fs.allow as siblings to + // plugins. Handles both defineConfig({ ... }) and defineWorkspace([ ... , { ... }]). Anchored + // on the `plugins:` key (injecting before it) instead of matching the whole array: plugin code + // may contain `]` (e.g. the regex literal in the sveltekit template), which a bracket-counting + // regex like `\[[^\]]*\]` would cut short, splicing the injection into the middle of it. + fileContent = fileContent.replace(/^([ \t]*)plugins\s*:/m, (match, indent) => { + let injected = `${indent}resolve: {\n${indent} preserveSymlinks: true\n${indent}},\n`; // In linked mode, also add server.fs.allow to allow Vite to serve files from the monorepo root if (options.link) { - replacement += `\n server: {\n fs: {\n allow: ['../../..']\n }\n },`; + injected += `${indent}server: {\n${indent} fs: {\n${indent} allow: ['../../..']\n${indent} }\n${indent}},\n`; } - return replacement; + return `${injected}${match}`; }); // search for storybookTest({...}) and place `tags: 'vitest'` into it but tags option doesn't exist yet in the config. Also consider multi line @@ -896,34 +1011,52 @@ async function prepareReactNativeWebSandbox(cwd: string) { } } -async function prepareSvelteSandbox(cwd: string) { - const svelteConfigJsPath = join(cwd, 'svelte.config.js'); - const svelteConfigTsPath = join(cwd, 'svelte.config.ts'); - - // Check which config file exists - const configPath = (await pathExists(svelteConfigTsPath)) - ? svelteConfigTsPath - : (await pathExists(svelteConfigJsPath)) - ? svelteConfigJsPath - : null; +async function getConfigFile(names: string[], cwd: string) { + const firstPath = await findFirstPath(names, { cwd }); - if (!configPath) { - throw new Error( - `No svelte.config.js or svelte.config.ts found in sandbox: ${cwd}, cannot modify config.` - ); + if (!firstPath) { + throw new Error(`No ${names.join(' or ')} found in sandbox: ${cwd}, cannot modify config.`); } + // findFirstPath returns a path relative to `cwd`; resolve it so readConfig + // does not resolve it against the script's own working directory. + return join(cwd, firstPath); +} + +async function prepareSvelteSandbox(cwd: string) { + const configPath = await getConfigFile(['svelte.config.ts', 'svelte.config.js'], cwd); const svelteConfig = await csfReadConfig(configPath); // Enable async components // see https://svelte.dev/docs/svelte/await-expressions svelteConfig.setFieldValue(['compilerOptions', 'experimental', 'async'], true); + await writeConfig(svelteConfig); +} + +async function prepareSvelteKitSandbox(cwd: string) { + const configPath = await getConfigFile(['vite.config.ts', 'vite.config.js'], cwd); + const viteConfig = await csfReadConfig(configPath); + + // Enable async components + // see https://svelte.dev/docs/svelte/await-expressions + setPluginParam(viteConfig, { + pluginName: 'sveltekit', + paramPos: 0, + paramPath: ['compilerOptions', 'experimental', 'async'], + paramValue: true, + }); + // Enable remote functions // see https://svelte.dev/docs/kit/remote-functions - svelteConfig.setFieldValue(['kit', 'experimental', 'remoteFunctions'], true); + setPluginParam(viteConfig, { + pluginName: 'sveltekit', + paramPos: 0, + paramPath: ['experimental', 'remoteFunctions'], + paramValue: true, + }); - await writeConfig(svelteConfig); + await writeConfig(viteConfig); } /** diff --git a/yarn.lock b/yarn.lock index 6635182c9967..9c5289d30a31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8507,6 +8507,7 @@ __metadata: globby: "npm:^14.1.0" jscodeshift: "npm:^0.15.1" leven: "npm:^4.0.0" + memfs: "npm:^4.11.1" p-limit: "npm:^7.2.0" picocolors: "npm:^1.1.0" semver: "npm:^7.7.3" @@ -8516,6 +8517,7 @@ __metadata: tinyclip: "npm:^0.1.12" ts-dedent: "npm:^2.0.0" typescript: "npm:^5.8.3" + valibot: "npm:^1.4.0" bin: cli: ./dist/bin/index.js languageName: unknown