diff --git a/code/core/src/telemetry/get-monorepo-type.test.ts b/code/core/src/shared/utils/get-monorepo-type.test.ts similarity index 89% rename from code/core/src/telemetry/get-monorepo-type.test.ts rename to code/core/src/shared/utils/get-monorepo-type.test.ts index 2539bedec21c..5d665f879171 100644 --- a/code/core/src/telemetry/get-monorepo-type.test.ts +++ b/code/core/src/shared/utils/get-monorepo-type.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { getMonorepoType, monorepoConfigs } from './get-monorepo-type.ts'; -vi.mock('node:fs', async () => import('../../../__mocks__/fs.ts')); +vi.mock('node:fs', async () => import('../../../../__mocks__/fs.ts')); vi.mock('storybook/internal/common', async (importOriginal) => { return { @@ -23,7 +23,7 @@ const checkMonorepoType = ({ monorepoConfigFile, isYarnWorkspace = false }: any) mockFiles[join('root', monorepoConfigFile)] = '{}'; } - vi.mocked(fs as any).__setMockFiles(mockFiles); + vi.mocked(fs as any).__setMockFiles(mockFiles); return getMonorepoType(); }; diff --git a/code/core/src/telemetry/get-monorepo-type.ts b/code/core/src/shared/utils/get-monorepo-type.ts similarity index 100% rename from code/core/src/telemetry/get-monorepo-type.ts rename to code/core/src/shared/utils/get-monorepo-type.ts diff --git a/code/core/src/telemetry/ai-setup-utils.ts b/code/core/src/telemetry/ai-setup-utils.ts index 198a6221492e..79948e004424 100644 --- a/code/core/src/telemetry/ai-setup-utils.ts +++ b/code/core/src/telemetry/ai-setup-utils.ts @@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises'; import { findConfigFile } from 'storybook/internal/common'; import { detectAgent } from './detect-agent.ts'; -import { isTelemetryModuleEnabled, telemetry } from './index.ts'; +import { telemetry } from './index.ts'; import type { EventType } from './types.ts'; import type { IndexEntry, StoryIndex } from 'storybook/internal/types'; diff --git a/code/core/src/telemetry/storybook-metadata.test.ts b/code/core/src/telemetry/storybook-metadata.test.ts index 8429bfbb329e..90ce24afef26 100644 --- a/code/core/src/telemetry/storybook-metadata.test.ts +++ b/code/core/src/telemetry/storybook-metadata.test.ts @@ -18,7 +18,7 @@ import { type Settings, globalSettings } from '../cli/globalSettings.ts'; import { detectAgent } from './detect-agent.ts'; import { getApplicationFileCount } from '../telemetry/get-application-file-count.ts'; import { analyzeEcosystemPackages } from '../telemetry/get-known-packages.ts'; -import { getMonorepoType } from '../telemetry/get-monorepo-type.ts'; +import { getMonorepoType } from '../shared/utils/get-monorepo-type.ts'; import { getPackageManagerInfo } from '../telemetry/get-package-manager-info.ts'; import { getPortableStoriesFileCount } from '../telemetry/get-portable-stories-usage.ts'; import { @@ -37,7 +37,7 @@ vi.mock('./detect-agent.ts', () => ({ detectAgent: vi.fn().mockReturnValue(undefined), })); vi.mock(import('./package-json.ts'), { spy: true }); -vi.mock(import('./get-monorepo-type.ts'), { spy: true }); +vi.mock(import('../shared/utils/get-monorepo-type.ts'), { spy: true }); vi.mock(import('./get-framework-info.ts'), { spy: true }); vi.mock(import('./get-package-manager-info.ts'), { spy: true }); vi.mock(import('./get-portable-stories-usage.ts'), { spy: true }); diff --git a/code/core/src/telemetry/storybook-metadata.ts b/code/core/src/telemetry/storybook-metadata.ts index 3cbe341bb270..bb1a3413272d 100644 --- a/code/core/src/telemetry/storybook-metadata.ts +++ b/code/core/src/telemetry/storybook-metadata.ts @@ -24,7 +24,7 @@ import { getChromaticVersionSpecifier } from './get-chromatic-version.ts'; import { getFrameworkInfo } from './get-framework-info.ts'; import { getHasRouterPackage } from './get-has-router-package.ts'; import { analyzeEcosystemPackages } from './get-known-packages.ts'; -import { getMonorepoType } from './get-monorepo-type.ts'; +import { getMonorepoType } from '../shared/utils/get-monorepo-type.ts'; import { getPackageManagerInfo } from './get-package-manager-info.ts'; import { getPortableStoriesFileCount } from './get-portable-stories-usage.ts'; import { getActualPackageVersion, getActualPackageVersions } from './package-json.ts'; diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 37b0d372a7de..9137526a30bf 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -4,7 +4,7 @@ import type { DetectResult } from 'package-manager-detector'; import type { AgentInfo } from './detect-agent.ts'; import type { KnownPackagesList } from './get-known-packages.ts'; -import type { MonorepoType } from './get-monorepo-type.ts'; +import type { MonorepoType } from '../shared/utils/get-monorepo-type.ts'; export type EventType = | 'boot' diff --git a/code/lib/cli-storybook/src/ai/index.ts b/code/lib/cli-storybook/src/ai/index.ts index 8c0761dec538..f1371c2ce921 100644 --- a/code/lib/cli-storybook/src/ai/index.ts +++ b/code/lib/cli-storybook/src/ai/index.ts @@ -17,7 +17,7 @@ import { SupportedLanguage } from 'storybook/internal/types'; import { ProjectTypeService } from '../../../create-storybook/src/services/ProjectTypeService.ts'; import { getStorybookData } from '../automigrate/helpers/mainConfigFile.ts'; -import { generateMarkdownOutput } from './prompt.ts'; +import { getAiSetupMarkdownOutput } from './setup-prompts/index.ts'; import type { ProjectInfo, AiSetupOptions } from './types.ts'; export async function aiSetup(options: AiSetupOptions): Promise { @@ -84,7 +84,7 @@ export async function aiSetup(options: AiSetupOptions): Promise { return; } - const result = await generateMarkdownOutput(projectInfo); + const result = await getAiSetupMarkdownOutput(projectInfo); const markdownOutput = result.markdown; await telemetry('ai-setup', { diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts index ed780fa3324f..e2e10593349e 100644 --- a/code/lib/cli-storybook/src/ai/setup-prompts/index.ts +++ b/code/lib/cli-storybook/src/ai/setup-prompts/index.ts @@ -1,6 +1,8 @@ -import type { AiPrompt, ProjectInfo } from '../types.ts'; +import dedent from 'ts-dedent'; +import type { ProjectInfo } from '../types.ts'; import * as optimizedTests from './optimized-tests.ts'; +import { getProjectOverview } from '../utils/project-overview.ts'; /** * Main prompt used currently in `npx storybook ai setup` command. If you promote a new prompt to be default, move this to the FORMERLY_USED_PROMPTS object below. @@ -11,10 +13,13 @@ const CURRENTLY_USED_PROMPT: Record string /** * Names of variants registered behind `EVAL_SETUP_PROMPT`. Loaded on demand - * from sibling files so the bundler can code-split them away from the + * from sibling files so the bundler can code|-split them away from the * default-only path that real users hit. */ const FORMERLY_USED_PROMPTS: Record Promise<(projectInfo: ProjectInfo) => string>> = { + monorepo: async () => (await import('./monorepo.ts')).instructions, + 'optimized-tests': async () => (await import('./optimized-tests.ts')).instructions, + 'relaxed-limits': async () => (await import('./relaxed-limits.ts')).instructions, setup: async () => (await import('./setup.ts')).instructions, 'pattern-copy-play': async () => (await import('./pattern-copy-play.ts')).instructions, }; @@ -53,17 +58,23 @@ function resolvePromptName(): PromptName { return DEFAULT_PROMPT_NAME; } -export async function getPrompts(projectInfo: ProjectInfo): Promise<{ prompts: AiPrompt[] }> { +export async function getAiSetupPrompt(projectInfo: ProjectInfo): Promise { const name = resolvePromptName(); const builder = CURRENTLY_USED_PROMPT[name] ?? (await FORMERLY_USED_PROMPTS[name]()); + return builder(projectInfo); +} + +export async function getAiSetupMarkdownOutput(projectInfo: ProjectInfo): Promise<{ + markdown: string; +}> { return { - prompts: [ - { - name, - description: 'Set up Storybook for success', - instructions: builder(projectInfo), - }, - ], + markdown: dedent` + # Storybook Setup + + ${getProjectOverview(projectInfo)} + + ${await getAiSetupPrompt(projectInfo)} + `, }; } diff --git a/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts b/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts new file mode 100644 index 000000000000..f261abe3a684 --- /dev/null +++ b/code/lib/cli-storybook/src/ai/setup-prompts/monorepo.ts @@ -0,0 +1,451 @@ +import { dedent } from 'ts-dedent'; + +import type { ProjectInfo } from '../types.ts'; +import { getMonorepoType } from '../../../../../core/src/shared/utils/get-monorepo-type.ts'; +import { ext } from '../utils/ext.ts'; +import { getDocsMarkdownUrl } from '../utils/docs-markdown-url.ts'; + +function packageManagerRule(packageManagerName: string | undefined): string { + if (packageManagerName) { + return `**Use \`${packageManagerName}\` for every install** (detected from this project's lockfile).`; + } + return '**Detect the package manager once** from the lockfile (`pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, `bun.lockb` → bun, otherwise npm) and use it for every install in this trial.'; +} + +function monorepoRules(): string | undefined { + const monorepoType = getMonorepoType(); + if (monorepoType) { + return `**${monorepoType} monorepo.** Don't initially look for config or existing Storybook content in other packages. Start exploring from config and tooling local to the package where you are asked to set up Storybook. If it uses local monorepo dependencies, build all dependencies found during discovery before writing stories or running tests.`; + } +} + +function readBudgetRules(): string | undefined { + return `**Read budget: ~12 files for discovery.** Before writing any code you may Read at most ~12 files (\`index.html\`, entry, App, providers, routing, root CSS, 2–3 representative pages/components, 1–2 hooks, 1 test). If you need more, summarize and move on.`; +} + +function getPreviewExample(projectInfo: ProjectInfo): string { + const { configDir, language, framework, rendererPackage } = projectInfo; + const tsx = ext(language, true); + const typeImport = framework || rendererPackage || '@storybook/react-vite'; + + if (language === 'js') { + return dedent` + \`\`\`${tsx} + // ${configDir}/preview.${tsx} + import '../src/index.css'; + import MockDate from 'mockdate'; + import { initialize, mswLoader } from 'msw-storybook-addon'; + import { SessionProvider } from '../src/contexts/SessionContext'; + import { mswHandlers } from './msw-handlers'; + + initialize({ onUnhandledRequest: 'bypass' }); + + const preview = { + decorators: [ + (Story) => ( + + + + ), + ], + loaders: [mswLoader], + parameters: { msw: { handlers: mswHandlers } }, + async beforeEach() { + localStorage.setItem('theme', 'dark'); + MockDate.set('2024-04-01T12:00:00Z'); + }, + }; + + export default preview; + \`\`\` + `; + } + + return dedent` + \`\`\`${tsx} + // ${configDir}/preview.${tsx} + import type { Preview } from '${typeImport}'; + import '../src/index.css'; + import MockDate from 'mockdate'; + import { initialize, mswLoader } from 'msw-storybook-addon'; + import { SessionProvider } from '../src/contexts/SessionContext'; + import { mswHandlers } from './msw-handlers'; + + initialize({ onUnhandledRequest: 'bypass' }); + + const preview: Preview = { + decorators: [ + (Story) => ( + + + + ), + ], + loaders: [mswLoader], + parameters: { msw: { handlers: mswHandlers } }, + async beforeEach() { + localStorage.setItem('theme', 'dark'); + MockDate.set('2024-04-01T12:00:00Z'); + }, + }; + + export default preview; + \`\`\` + `; +} + +function getPortalDecoratorExample(projectInfo: ProjectInfo): string { + const { language } = projectInfo; + const tsx = ext(language, true); + + return dedent` + \`\`\`${tsx} + // Add this entry to the \`decorators\` array of your preview config: + (Story) => { + for (const id of ['modal-root', 'drawer-root', 'toast-root']) { + if (!document.getElementById(id)) { + const el = document.createElement('div'); + el.id = id; + document.body.appendChild(el); + } + } + return ; + } + \`\`\` + `; +} + +function getMainConfigExample(projectInfo: ProjectInfo): string { + const { configDir, framework, rendererPackage, language } = projectInfo; + const ts = ext(language, false); + const typeImport = framework || rendererPackage || '@storybook/react'; + + if (language === 'js') { + return dedent` + \`\`\`js + // ${configDir}/main.js + const config = { staticDirs: ['../public'] }; + export default config; + \`\`\` + `; + } + + return dedent` + \`\`\`${ts} + // ${configDir}/main.${ts} + import type { StorybookConfig } from '${typeImport}'; + + const config: StorybookConfig = { staticDirs: ['../public'] }; + export default config; + \`\`\` + `; +} + +function getStoryExample(projectInfo: ProjectInfo): string { + const { language, framework, rendererPackage } = projectInfo; + const tsx = ext(language, true); + const typeImport = framework || rendererPackage || '@storybook/react-vite'; + + if (language === 'js') { + return dedent` + \`\`\`${tsx} + import { expect } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes + }; + + export default meta; + + // Smoke check — one is enough per file + export const Primary = { + args: { children: 'Order now' }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); + }, + }; + + // Variant-only stories: no play needed + export const Clear = { args: { children: 'Cancel', clear: true } }; + export const Large = { args: { children: 'Checkout', large: true } }; + export const WithIcon = { args: { icon: 'cart', 'aria-label': 'food cart' } }; + \`\`\` + `; + } + + return dedent` + \`\`\`${tsx} + import type { Meta, StoryObj } from '${typeImport}'; + import { expect } from 'storybook/test'; + import { Button } from './Button'; + + const meta = { + component: Button, + tags: ['ai-generated', 'needs-work'], // strip 'needs-work' once vitest passes + } satisfies Meta; + + export default meta; + type Story = StoryObj; + + // Smoke check — one is enough per file + export const Primary: Story = { + args: { children: 'Order now' }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('button', { name: /order now/i })).toBeVisible(); + }, + }; + + // Variant-only stories: no play needed + export const Clear: Story = { args: { children: 'Cancel', clear: true } }; + export const Large: Story = { args: { children: 'Checkout', large: true } }; + export const WithIcon: Story = { args: { icon: 'cart', 'aria-label': 'food cart' } }; + \`\`\` + `; +} + +function getInteractionPlayExample(projectInfo: ProjectInfo): string { + const { language } = projectInfo; + const tsx = ext(language, true); + const typeAnnotation = language === 'ts' ? ': Story' : ''; + + return dedent` + \`\`\`${tsx} + export const FilledForm${typeAnnotation} = { + play: async ({ canvas, userEvent }) => { + await userEvent.type(canvas.getByLabelText('email'), 'a@b.com', { delay: 50 }); + await userEvent.click(canvas.getByRole('button', { name: /submit/i })); + await expect(await canvas.findByText(/welcome/i)).toBeVisible(); + }, + }; + \`\`\` + `; +} + +export function instructions(projectInfo: ProjectInfo): string { + const { configDir, language, packageManager, packageManagerName } = projectInfo; + const tsx = ext(language, true); + const ts = ext(language, false); + const docsUrl = (path: string) => getDocsMarkdownUrl(path, projectInfo); + + const mswInstall = packageManager.getInstallCommand( + ['msw', 'msw-storybook-addon', 'mockdate'], + true + ); + + return dedent` + Your goal is to make Storybook fully functional in this project: configure \`${configDir}/preview.${tsx}\` with the right decorators, add MSW for data, and write up to 10 colocated \`*.stories.${tsx}\` files. Add \`play\` functions only where they prove something non-trivial. + + ## Rules of engagement (follow strictly — these are time budgets, not suggestions) + +${[ + `**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: + - List a directory → \`Glob('src/components/*')\` (alt names: \`search_files\`, \`file_search\`), not \`ls src/components\`. + - Search a string → \`Grep('pattern', { path: 'src' })\` (alt names: \`grep_search\`, \`search_files\`), not \`grep -rn ...\` or \`find ... | xargs grep\`. + - Read a file → \`Read('path/to/file')\` (alt names: \`read_file\`), not \`cat\`/\`head\`/\`tail\`. + - Bulk-edit many files → multiple \`Edit\` calls (alt names: \`apply_patch\`, \`replace_in_file\`, \`replace\`), or one \`Edit\` with \`replace_all\` (alt names: \`replace\` with \`allow_multiple\`), not \`sed -i\`.`, + + `**Never read or grep inside \`node_modules\`.** The imports shown in this prompt are correct — don't verify them by introspecting installed packages. If something seems off, re-read this prompt, not \`node_modules\`.`, + monorepoRules(), + readBudgetRules(), + `**Edit > Write.** For any file you've Read, use \`Edit\`. Use \`Write\` only for new files. The project already has a \`${configDir}/preview.${tsx}\` from \`storybook init\` — **Edit** it, do not overwrite.`, + `**Batch the test loop.** Write **all** stories first, then run vitest **once** across everything. No per-file vitest runs until after that first batch run reveals failures.`, + packageManagerRule(packageManagerName), + `**Prefer fixing the shared \`${configDir}/preview.${tsx}\`** over story-local workarounds when multiple stories fail the same way.`, + `**Stop when the success criteria are met** — don't keep polishing.`, +] + .filter(Boolean) + .map((rule, index) => `${index + 1}. ${rule}`) + .join('\n')} + + ## Plan (do not skip steps, but keep each step lean) + + ### Step 1 — Discover the runtime (≤12 reads) + + Identify, in this order, using Glob/Grep first then targeted Reads: + + - \`index.html\` — \`\` tags, inline \`