diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index dd6be062e67a..17743b78315a 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -45,7 +45,8 @@ export type EventType = | 'doctor' | 'share' | 'ghost-stories' - | 'ai-prepare'; + | 'ai-prepare' + | 'ai-prompt-nudge'; export interface Dependency { version: string | undefined; versionSpecifier?: string; diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 8b7e9854eb74..28ad72ca7ad8 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -3,7 +3,6 @@ import { HandledError, JsPackageManagerFactory, PackageManagerName, - isCI, optionalEnvToBoolean, removeAddon as remove, versions, @@ -106,14 +105,10 @@ command('init') .option('-y --yes', 'Answer yes to all prompts') .option('-b --builder ', 'Builder library') .option('-l --linkable', 'Prepare installation for link (contributor helper)') - .option( - '--dev', - 'Launch the development server after completing initialization. Enabled by default (default: true)', - !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX) - ) + .option('--dev', 'Launch the development server after completing initialization') .option( '--no-dev', - 'Complete the initialization of Storybook without launching the Storybook development server' + 'Do not launch the Storybook development server after completing initialization (default)' ); command('add ') diff --git a/code/lib/create-storybook/src/bin/run.ts b/code/lib/create-storybook/src/bin/run.ts index 496bf53ca9af..19875786bf7b 100644 --- a/code/lib/create-storybook/src/bin/run.ts +++ b/code/lib/create-storybook/src/bin/run.ts @@ -1,5 +1,5 @@ import { ProjectType } from 'storybook/internal/cli'; -import { PackageManagerName, isCI, optionalEnvToBoolean } from 'storybook/internal/common'; +import { PackageManagerName, optionalEnvToBoolean } from 'storybook/internal/common'; import { logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext } from 'storybook/internal/telemetry'; import { Feature, SupportedBuilder } from 'storybook/internal/types'; @@ -116,10 +116,8 @@ const createStorybookProgram = program createStorybookProgram .action(async (options) => { - const isNeitherCiNorSandbox = - !isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); options.debug = options.debug ?? false; - options.dev = options.dev ?? isNeitherCiNorSandbox; + options.dev = options.dev ?? false; if (options.features === false) { // Ensure features are treated as empty when --no-features is set diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts index dfb3c87f4465..1a45fd8601a0 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts @@ -7,7 +7,7 @@ import { logger } from 'storybook/internal/node-logger'; import * as find from 'empathic/find'; -import { FinalizationCommand } from './FinalizationCommand.ts'; +import { FinalizationCommand, executeFinalization } from './FinalizationCommand.ts'; vi.mock('node:fs/promises', { spy: true }); vi.mock('storybook/internal/common', { spy: true }); @@ -18,7 +18,11 @@ describe('FinalizationCommand', () => { let command: FinalizationCommand; beforeEach(() => { - command = new FinalizationCommand(undefined); + command = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); vi.mocked(getProjectRoot).mockReturnValue('/test/project'); vi.mocked(logger.step).mockImplementation(() => {}); @@ -107,4 +111,182 @@ describe('FinalizationCommand', () => { expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('ng run my-app:storybook')); }); }); + + describe('agent mode', () => { + it('should show agent-specific message when showAgentFollowUp=true', async () => { + const agentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: true, + showAiInstructions: false, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await agentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); + }); + + it('should show standard success message when showAgentFollowUp=false with AI instructions', async () => { + const agentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: true, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await agentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('Storybook was successfully installed') + ); + // Ensure the agent message is NOT shown + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('is not entirely set up yet'))).toBe(false); + }); + + it('should show standard success message when showAgentFollowUp=false', async () => { + const nonAgentCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await nonAgentCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('Storybook was successfully installed') + ); + // Ensure the agent message is NOT shown + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('is not entirely set up yet'))).toBe(false); + }); + }); + + describe('AI instructions', () => { + it('should show AI instructions when showAiInstructions=true', async () => { + const aiCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: true, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await aiCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('npx storybook ai prepare')); + }); + + it('should NOT show AI instructions when showAiInstructions=false', async () => { + const noAiCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await noAiCommand.execute({}); + + const stepCalls = vi.mocked(logger.step).mock.calls.map((c) => String(c[0])); + expect(stepCalls.some((msg) => msg.includes('To finalize setting up with AI'))).toBe(false); + }); + + it('should show both agent message and AI instructions when both are true', async () => { + const bothCommand = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: true, + showAiInstructions: true, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await bothCommand.execute({}); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + }); + }); + + describe('storybookCommand message', () => { + it('should print "To run Storybook, run" with the command', async () => { + const cmd = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await cmd.execute({ storybookCommand: 'npm run storybook' }); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('To run Storybook, run')); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('npm run storybook')); + }); + + it('should not print storybook command message when storybookCommand is null', async () => { + const cmd = new FinalizationCommand({ + logfile: undefined, + showAgentFollowUp: false, + showAiInstructions: false, + }); + vi.mocked(find.up).mockReturnValue(undefined); + + await cmd.execute({ storybookCommand: null }); + + const logCalls = vi.mocked(logger.log).mock.calls.map((c) => String(c[0])); + expect(logCalls.some((msg) => msg.includes('To run Storybook, run'))).toBe(false); + }); + }); + + describe('executeFinalization helper', () => { + it('should show agent follow-up when showAgentFollowUp=true', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + showAgentFollowUp: true, + showAiInstructions: false, + logfile: undefined, + }); + + // Agent mode should show agent-specific message + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('is not entirely set up yet') + ); + }); + + it('should pass showAiInstructions=true through to the command', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + showAgentFollowUp: false, + showAiInstructions: true, + logfile: undefined, + }); + + expect(logger.step).toHaveBeenCalledWith( + expect.stringContaining('To finalize setting up with AI') + ); + }); + + it('should forward storybookCommand to execute', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await executeFinalization({ + showAgentFollowUp: false, + showAiInstructions: false, + logfile: undefined, + storybookCommand: 'yarn storybook', + }); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('yarn storybook')); + }); + }); }); diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.ts index f0bb3fbd4265..78348f277fef 100644 --- a/code/lib/create-storybook/src/commands/FinalizationCommand.ts +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.ts @@ -7,6 +7,14 @@ import { ErrorCollector } from 'storybook/internal/telemetry'; import * as find from 'empathic/find'; import { dedent } from 'ts-dedent'; +export type FinalizationCommandOptions = { + logfile: string | boolean | undefined; + /** When true, show the agent follow-up message ("run npx storybook ai prepare") instead of the normal success message */ + showAgentFollowUp: boolean; + /** When true, show the "paste this prompt to your AI agent" instructions */ + showAiInstructions: boolean; +}; + type ExecuteFinalizationParams = { storybookCommand?: string | null; }; @@ -22,7 +30,8 @@ type ExecuteFinalizationParams = { * - Show next steps */ export class FinalizationCommand { - constructor(private logfile: string | boolean | undefined) {} + constructor(private options: FinalizationCommandOptions) {} + /** Execute finalization steps */ async execute({ storybookCommand }: ExecuteFinalizationParams): Promise { // Update .gitignore @@ -69,33 +78,52 @@ export class FinalizationCommand { this.printNextSteps(storybookCommand); try { - const logFile = await logTracker.writeToFile(this.logfile); + const logFile = await logTracker.writeToFile(this.options.logfile); logger.warn(`Debug logs are written to: ${logFile}`); } catch {} } /** Print success message with feature summary */ private printSuccessMessage(storybookCommand?: string | null): void { - logger.step(CLI_COLORS.success('Storybook was successfully installed in your project!')); + if (this.options.showAgentFollowUp) { + logger.step( + CLI_COLORS.storybook( + dedent`Storybook is installed but is not entirely set up yet. + To finish setting up, now run \`npx storybook ai prepare\` and follow its instructions precisely.` + ) + ); + } else { + logger.step(CLI_COLORS.success('Storybook was successfully installed in your project!')); + } this.printNextSteps(storybookCommand); } private printNextSteps(storybookCommand?: string | null): void { if (storybookCommand) { - logger.log( - `To run Storybook manually, run ${CLI_COLORS.cta(storybookCommand)}. CTRL+C to stop.` - ); + logger.log(`To run Storybook, run ${CLI_COLORS.cta(storybookCommand)}. CTRL+C to stop.`); } logger.log(dedent` Want to learn more about Storybook? ${CLI_COLORS.cta('https://storybook.js.org/')} Having trouble or want to chat? ${CLI_COLORS.cta('https://discord.gg/storybook/')} `); + + if (this.options.showAiInstructions) { + logger.step(dedent`To finalize setting up with AI, paste this prompt to your AI agent: + + ${CLI_COLORS.storybook(`Run \`npx storybook ai prepare\` and follow its instructions precisely.`)} + `); + } } } + export const executeFinalization = ({ logfile, + showAgentFollowUp, + showAiInstructions, ...params -}: ExecuteFinalizationParams & { logfile: string | boolean | undefined }) => { - return new FinalizationCommand(logfile).execute(params); +}: ExecuteFinalizationParams & FinalizationCommandOptions) => { + return new FinalizationCommand({ logfile, showAgentFollowUp, showAiInstructions }).execute( + params + ); }; diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts index d82fa03080be..f62b63ce47c2 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -1,7 +1,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { AddonVitestService, ProjectType, globalSettings } from 'storybook/internal/cli'; -import type { JsPackageManager } from 'storybook/internal/common'; import { PackageManagerName, isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; import type { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; @@ -10,7 +9,7 @@ import { Feature } from 'storybook/internal/types'; import type { CommandOptions } from '../generators/types.ts'; import { FeatureCompatibilityService } from '../services/FeatureCompatibilityService.ts'; import { TelemetryService } from '../services/TelemetryService.ts'; -import { UserPreferencesCommand } from './UserPreferencesCommand.ts'; +import { UserPreferencesCommand, executeUserPreferences } from './UserPreferencesCommand.ts'; vi.mock('storybook/internal/cli', { spy: true }); vi.mock('storybook/internal/common', { spy: true }); @@ -22,17 +21,23 @@ interface CommandWithPrivates { telemetryService: { trackNewUserCheck: ReturnType; trackInstallType: ReturnType; - }; - featureService: { - validateTestFeatureCompatibility: ReturnType; + trackAiPromptNudge: ReturnType; }; } describe('UserPreferencesCommand', () => { let command: UserPreferencesCommand; - const mockPackageManager = {} as Partial as JsPackageManager; const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + const defaultExecuteOptions = { + framework: null as null, + builder: 'vite' as SupportedBuilder, + renderer: 'react' as SupportedRenderer, + projectType: ProjectType.REACT, + isTestFeatureAvailable: true, + isAiPrepareAvailable: false, + }; + afterAll(() => { if (originalIsTTYDescriptor) { Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); @@ -51,7 +56,7 @@ describe('UserPreferencesCommand', () => { disableTelemetry: true, }; - command = new UserPreferencesCommand(commandOptions, mockPackageManager); + command = new UserPreferencesCommand(commandOptions); // Mock AddonVitestService const mockAddonVitestService = vi.fn().mockImplementation(() => ({ @@ -88,15 +93,11 @@ describe('UserPreferencesCommand', () => { const mockTelemetryService = { trackNewUserCheck: vi.fn(), trackInstallType: vi.fn(), - }; - - const mockFeatureService = { - validateTestFeatureCompatibility: vi.fn().mockResolvedValue({ compatible: true }), + trackAiPromptNudge: vi.fn(), }; // Inject mocked services (command as unknown as CommandWithPrivates).telemetryService = mockTelemetryService; - (command as unknown as CommandWithPrivates).featureService = mockFeatureService; // Mock logger and prompt vi.mocked(logger.intro).mockImplementation(() => {}); @@ -122,10 +123,8 @@ describe('UserPreferencesCommand', () => { describe('execute', () => { it('should return recommended config for new users in non-interactive mode', async () => { const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isTestFeatureAvailable: true, }); expect(result.newUser).toBe(true); @@ -134,17 +133,6 @@ describe('UserPreferencesCommand', () => { expect(result.selectedFeatures).toContain('onboarding'); }); - it('should include AI feature by default in non-interactive mode', async () => { - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(result.selectedFeatures.has(Feature.AI)).toBe(true); - }); - it('should prompt for new user in interactive mode', async () => { // Mock TTY Object.defineProperty(process.stdout, 'isTTY', { @@ -153,14 +141,8 @@ describe('UserPreferencesCommand', () => { }); vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); + const result = await command.execute(defaultExecuteOptions); expect(prompt.select).toHaveBeenCalledWith( expect.objectContaining({ @@ -181,97 +163,53 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.select) .mockResolvedValueOnce(false) // not new user .mockResolvedValueOnce('light'); // minimal install - vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // no AI setup - - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(prompt.select).toHaveBeenCalledTimes(2); - expect(result.newUser).toBe(false); - const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; - expect(telemetryService.trackInstallType).toHaveBeenCalledWith('light'); - }); - - it('should not include test feature in minimal install', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - vi.mocked(prompt.select) - .mockResolvedValueOnce(false) // not new user - .mockResolvedValueOnce('light'); // minimal install - vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // no AI setup - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, - }); + const result = await command.execute(defaultExecuteOptions); expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); expect(result.selectedFeatures.has(Feature.DOCS)).toBe(false); expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(false); }); - it('should validate test feature compatibility in interactive mode', async () => { + it('should remove test feature if isTestFeatureAvailable is false', async () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, }); vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup - const featureService = (command as unknown as CommandWithPrivates).featureService; - vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ - compatible: true, - }); - await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + const result = await command.execute({ + ...defaultExecuteOptions, + isTestFeatureAvailable: false, }); - expect(featureService.validateTestFeatureCompatibility).toHaveBeenCalledWith( - null, - 'vite', - process.cwd() - ); + expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); + expect(result.selectedFeatures.has(Feature.DOCS)).toBe(true); + expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(true); }); + }); - it('should remove test feature if user chooses to continue without it', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, + describe('isTestFeatureAvailable option', () => { + it('should include test feature when isTestFeatureAvailable=true in recommended install', async () => { + const result = await command.execute({ + ...defaultExecuteOptions, + isTestFeatureAvailable: true, }); - vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - const featureService = (command as unknown as CommandWithPrivates).featureService; - vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ - compatible: false, - reasons: ['React version is too old'], - }); - vi.mocked(prompt.confirm) - .mockResolvedValueOnce(true) // continue without test - .mockResolvedValueOnce(true); // AI setup + expect(result.selectedFeatures.has(Feature.TEST)).toBe(true); + }); + it('should NOT include test feature when isTestFeatureAvailable=false in recommended install', async () => { const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isTestFeatureAvailable: false, }); expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); + // Other features should still be present expect(result.selectedFeatures.has(Feature.DOCS)).toBe(true); - expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(true); + expect(result.selectedFeatures.has(Feature.A11Y)).toBe(true); }); }); @@ -286,16 +224,14 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining( - 'Would you like to improve your Storybook setup with AI?' + 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?' ), }) ); @@ -312,10 +248,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(false); @@ -328,10 +262,8 @@ describe('UserPreferencesCommand', () => { }); const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).not.toHaveBeenCalled(); @@ -349,48 +281,25 @@ describe('UserPreferencesCommand', () => { disableTelemetry: true, yes: true, }; - const yesCommand = new UserPreferencesCommand(commandOptions, mockPackageManager); + const yesCommand = new UserPreferencesCommand(commandOptions); // Inject mocked services (yesCommand as unknown as CommandWithPrivates).telemetryService = { trackNewUserCheck: vi.fn(), trackInstallType: vi.fn(), - }; - (yesCommand as unknown as CommandWithPrivates).featureService = { - validateTestFeatureCompatibility: vi.fn().mockResolvedValue({ compatible: true }), + trackAiPromptNudge: vi.fn(), }; const result = await yesCommand.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(prompt.confirm).not.toHaveBeenCalled(); expect(result.selectedFeatures.has(Feature.AI)).toBe(true); }); - it('should not prompt for AI setup when renderer is not react', async () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - configurable: true, - }); - - vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user - - const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'vue3' as SupportedRenderer, - projectType: ProjectType.REACT, - }); - - expect(prompt.confirm).not.toHaveBeenCalled(); - expect(result.selectedFeatures.has(Feature.AI)).toBe(false); - }); - - it('should not prompt for AI setup when builder is not vite', async () => { + it('should not prompt for AI setup when isAiPrepareAvailable is false', async () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true, @@ -399,10 +308,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user const result = await command.execute({ - framework: null, - builder: 'webpack5' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: false, }); expect(prompt.confirm).not.toHaveBeenCalled(); @@ -421,10 +328,8 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(true); @@ -445,15 +350,79 @@ describe('UserPreferencesCommand', () => { vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no const result = await command.execute({ - framework: null, - builder: 'vite' as SupportedBuilder, - renderer: 'react' as SupportedRenderer, - projectType: ProjectType.REACT, + ...defaultExecuteOptions, + isAiPrepareAvailable: true, }); expect(result.selectedFeatures.has(Feature.AI)).toBe(false); expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); expect(result.selectedFeatures.has(Feature.DOCS)).toBe(false); }); + + it('should track ai-prompt-nudge telemetry when user accepts AI setup', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); // AI setup: yes + + await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).toHaveBeenCalledWith({ skipPrompt: false }); + }); + + it('should not track ai-prompt-nudge telemetry when user declines AI setup', async () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + vi.mocked(prompt.confirm).mockResolvedValueOnce(false); // AI setup: no + + await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).not.toHaveBeenCalled(); + }); + + it('should track ai-prompt-nudge telemetry when AI is auto-accepted in non-interactive mode', async () => { + // Non-interactive (no TTY) with AI available — auto-accepts + const result = await command.execute({ + ...defaultExecuteOptions, + isAiPrepareAvailable: true, + }); + + expect(result.selectedFeatures.has(Feature.AI)).toBe(true); + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackAiPromptNudge).toHaveBeenCalledWith({ skipPrompt: true }); + }); + }); + + describe('executeUserPreferences helper', () => { + it('should return a valid result', async () => { + const commandOptions: CommandOptions = { + packageManager: PackageManagerName.NPM, + disableTelemetry: true, + }; + + const result = await executeUserPreferences({ + options: commandOptions, + ...defaultExecuteOptions, + }); + + // Should return a valid result + expect(result.selectedFeatures).toBeDefined(); + expect(result.newUser).toBeDefined(); + }); }); }); diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts index a8b3ad66ffed..f16a6af3c815 100644 --- a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -1,9 +1,13 @@ import type { ProjectType } from 'storybook/internal/cli'; import { globalSettings } from 'storybook/internal/cli'; -import { type JsPackageManager, isCI } from 'storybook/internal/common'; +import { isCI } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; -import type { SupportedFramework } from 'storybook/internal/types'; -import { Feature, SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; +import type { + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -30,6 +34,8 @@ export interface UserPreferencesOptions { builder: SupportedBuilder; renderer: SupportedRenderer; projectType: ProjectType; + isTestFeatureAvailable: boolean; + isAiPrepareAvailable: boolean; } /** @@ -46,8 +52,6 @@ export interface UserPreferencesOptions { export class UserPreferencesCommand { constructor( private readonly commandOptions: CommandOptions, - packageManager: JsPackageManager, - private readonly featureService = new FeatureCompatibilityService(packageManager), private readonly telemetryService = new TelemetryService(commandOptions.disableTelemetry) ) {} @@ -57,11 +61,6 @@ export class UserPreferencesCommand { const isInteractive = process.stdout.isTTY && !isCI(); const skipPrompt = !isInteractive || !!this.commandOptions.yes; - const isTestFeatureAvailable = await this.isTestFeatureAvailable( - options.framework, - options.builder - ); - // Get new user preference const newUser = await this.promptNewUser(skipPrompt); @@ -77,17 +76,18 @@ export class UserPreferencesCommand { // Get install type const installType: InstallType = !newUser && !this.commandOptions.features - ? await this.promptInstallType(skipPrompt, isTestFeatureAvailable) + ? await this.promptInstallType(skipPrompt, options.isTestFeatureAvailable) : 'recommended'; - // Ask about AI setup (only available for React + Vite projects) - const isAiFeatureAvailable = this.isAiFeatureAvailable(options.renderer, options.builder); - const useAiForSetup = isAiFeatureAvailable ? await this.promptAiSetup(skipPrompt) : false; + // Ask about AI setup (only available for compatible projects, e.g. React + Vite) + const useAiForSetup = options.isAiPrepareAvailable + ? await this.promptAiSetup(skipPrompt) + : false; const selectedFeatures = this.determineFeatures( installType, newUser, - isTestFeatureAvailable, + options.isTestFeatureAvailable, options.projectType, useAiForSetup ); @@ -185,18 +185,6 @@ export class UserPreferencesCommand { return installType; } - /** Prompt user about AI-assisted Storybook setup */ - private async promptAiSetup(skipPrompt: boolean): Promise { - if (skipPrompt) { - return true; - } - - return prompt.confirm({ - message: dedent`Would you like to improve your Storybook setup with AI? - We will provide you with a prompt that you can use with your LLM to fully set up Storybook with best practices, tailored to your project.`, - }); - } - /** Determine features based on install type and user status */ private determineFeatures( installType: InstallType, @@ -230,30 +218,28 @@ export class UserPreferencesCommand { return features; } - /** Check if AI feature is available based on renderer and builder */ - private isAiFeatureAvailable(renderer: SupportedRenderer, builder: SupportedBuilder): boolean { - return renderer === SupportedRenderer.REACT && builder === SupportedBuilder.VITE; - } - - /** Validate test feature compatibility and prompt user if issues found */ - private async isTestFeatureAvailable( - framework: SupportedFramework | null, - builder: SupportedBuilder - ): Promise { - const result = await this.featureService.validateTestFeatureCompatibility( - framework, - builder, - process.cwd() - ); + /** Prompt user about AI-assisted Storybook setup */ + private async promptAiSetup(skipPrompt: boolean): Promise { + const useAi = skipPrompt + ? true + : await prompt.confirm({ + message: + 'Would you like to install AI features (MCP addon, skills and prompt suggestions)?', + }); + + if (useAi) { + await this.telemetryService.trackAiPromptNudge({ skipPrompt }); + } - return result.compatible; + return useAi; } } export const executeUserPreferences = ({ options, - packageManager, ...restOptions -}: UserPreferencesOptions & { options: CommandOptions; packageManager: JsPackageManager }) => { - return new UserPreferencesCommand(options, packageManager).execute(restOptions); +}: UserPreferencesOptions & { + options: CommandOptions; +}) => { + return new UserPreferencesCommand(options).execute(restOptions); }; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index a822fe73b2ea..e8ec6c27e546 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -5,8 +5,13 @@ import { executeCommand, } from 'storybook/internal/common'; import { getServerPort, withTelemetry } from 'storybook/internal/core-server'; -import { CLI_COLORS, logTracker, logger } from 'storybook/internal/node-logger'; +import { logTracker, logger } from 'storybook/internal/node-logger'; import { Feature } from 'storybook/internal/types'; +import type { + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; import { executeAddonConfiguration, @@ -23,7 +28,32 @@ import { registerAllGenerators } from './generators/index.ts'; import type { CommandOptions } from './generators/types.ts'; import { FeatureCompatibilityService } from './services/FeatureCompatibilityService.ts'; import { TelemetryService } from './services/TelemetryService.ts'; -import { dedent } from 'ts-dedent'; + +/** Validate test feature compatibility and check AI prepare support */ +async function checkFeatureSupport( + packageManager: JsPackageManager, + framework: SupportedFramework | null, + builder: SupportedBuilder, + renderer: SupportedRenderer +): Promise<{ + isTestFeatureAvailable: boolean; + isAiPrepareAvailable: boolean; +}> { + const featureService = new FeatureCompatibilityService(packageManager); + + const result = await featureService.validateTestFeatureCompatibility( + framework, + builder, + process.cwd() + ); + + const aiPrepare = FeatureCompatibilityService.supportsAIPrepareFeature(renderer, builder); + + return { + isTestFeatureAvailable: result.compatible, + isAiPrepareAvailable: aiPrepare, + }; +} /** * Main entry point for Storybook initialization @@ -67,13 +97,21 @@ export async function doInitiate(options: CommandOptions): Promise< ); // Step 4: Get user preferences and feature selections (with framework/builder for validation) - const { newUser, selectedFeatures } = await executeUserPreferences({ + const { isTestFeatureAvailable, isAiPrepareAvailable } = await checkFeatureSupport( packageManager, + framework, + builder, + renderer + ); + + const { newUser, selectedFeatures } = await executeUserPreferences({ options, framework, builder, renderer, projectType, + isTestFeatureAvailable, + isAiPrepareAvailable, }); // Step 5: Execute generator with dependency collector (now with frameworkInfo) @@ -109,23 +147,14 @@ export async function doInitiate(options: CommandOptions): Promise< }); // Step 8: Print final summary + const hasAiFeature = selectedFeatures.has(Feature.AI); await executeFinalization({ + showAgentFollowUp: !!options.agent && hasAiFeature, + showAiInstructions: hasAiFeature, logfile: options.logfile, storybookCommand, }); - if (selectedFeatures.has(Feature.AI)) { - if (options.agent) { - const { aiPrepare } = await import('../../cli-storybook/src/ai/index.ts'); - await aiPrepare({ configDir: configDir ?? '.storybook' }); - } else { - logger.step( - CLI_COLORS.storybook(dedent`To finalize setting up with AI, paste this prompt to your AI agent: - Run \`npx storybook ai prepare\` and follow its instructions precisely.`) - ); - } - } - // Step 9: Track telemetry await telemetryService.trackInitWithContext(projectType, selectedFeatures, newUser); @@ -173,6 +202,7 @@ export async function initiate(options: CommandOptions): Promise { handleCommandFailure(options.logfile); }); + // Launch dev server only if --dev was explicitly passed if (!options.agent && initiateResult?.shouldRunDev) { await runStorybookDev(initiateResult); } diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts index 71522f775554..3f6145b596d3 100644 --- a/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AddonVitestService, ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; -import { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; import { FeatureCompatibilityService } from './FeatureCompatibilityService.ts'; @@ -44,6 +44,51 @@ describe('FeatureCompatibilityService', () => { }); }); + describe('supportsAIPrepareFeature', () => { + it('should return true for react renderer with vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.REACT, + SupportedBuilder.VITE + ) + ).toBe(true); + }); + + it('should return false for vue3 renderer with vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.VUE3, + SupportedBuilder.VITE + ) + ).toBe(false); + }); + + it('should return false for react renderer with webpack5 builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.REACT, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + }); + + it('should return false for non-react renderer with non-vite builder', () => { + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.ANGULAR, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + + expect( + FeatureCompatibilityService.supportsAIPrepareFeature( + SupportedRenderer.SVELTE, + SupportedBuilder.WEBPACK5 + ) + ).toBe(false); + }); + }); + describe('validateTestFeatureCompatibility', () => { let mockValidateCompatibility: ReturnType; diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts index 032d73bcaecf..7f78a1214027 100644 --- a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts @@ -1,6 +1,7 @@ import { AddonVitestService, ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager } from 'storybook/internal/common'; -import type { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; +import type { SupportedFramework } from 'storybook/internal/types'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; /** Project types that support the onboarding feature */ const ONBOARDING_PROJECT_TYPES: ProjectType[] = [ @@ -32,6 +33,11 @@ export class FeatureCompatibilityService { ); } + /** Check if AI-assisted setup (storybook ai prepare) is supported for this project configuration */ + static supportsAIPrepareFeature(renderer: SupportedRenderer, builder: SupportedBuilder): boolean { + return renderer === SupportedRenderer.REACT && builder === SupportedBuilder.VITE; + } + /** * Validate all compatibility checks for test feature * diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts index c5f864228490..404957f7d3be 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.test.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -71,6 +71,26 @@ describe('TelemetryService', () => { expect(telemetry).toHaveBeenCalledWith('scaffolded-empty', data); }); + + it('should track ai-prompt-nudge event with context when prompt was shown', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: false }); + + expect(telemetry).toHaveBeenCalledWith('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context: { skipPrompt: false }, + }); + }); + + it('should track ai-prompt-nudge event with context when prompt was skipped', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: true }); + + expect(telemetry).toHaveBeenCalledWith('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context: { skipPrompt: true }, + }); + }); }); describe('when telemetry is disabled', () => { @@ -115,6 +135,12 @@ describe('TelemetryService', () => { expect(telemetry).not.toHaveBeenCalled(); }); + + it('should not track ai-prompt-nudge event', async () => { + await telemetryService.trackAiPromptNudge({ skipPrompt: false }); + + expect(telemetry).not.toHaveBeenCalled(); + }); }); describe('trackInitWithContext', () => { diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts index 8ab7668fcdfd..56bc1ba6e9de 100644 --- a/code/lib/create-storybook/src/services/TelemetryService.ts +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -32,6 +32,15 @@ export class TelemetryService { }); } + /** Track when a user accepts the AI setup nudge prompt */ + async trackAiPromptNudge(context: { skipPrompt: boolean }): Promise { + await this.runTelemetryIfEnabled('ai-prompt-nudge', { + id: 'prepare', + origin: 'init', + context, + }); + } + /** Track Playwright prompt decision (install | skip | aborted) */ async trackPlaywrightPromptDecision( result: 'installed' | 'skipped' | 'aborted' | 'failed'