diff --git a/.circleci/config.yml b/.circleci/config.yml index f78651b38f1c..2145f39bfaa7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -950,7 +950,7 @@ jobs: mkdir features-1 cd features-1 npm set registry http://localhost:6001 - npx create-storybook --yes --package-manager npm --features dev docs test + npx create-storybook --yes --package-manager npm --features docs test a11y npx vitest environment: IN_STORYBOOK_SANDBOX: true diff --git a/.circleci/src/jobs/test-init-features.yml b/.circleci/src/jobs/test-init-features.yml index aeffad3b0311..d590ee7c78cf 100644 --- a/.circleci/src/jobs/test-init-features.yml +++ b/.circleci/src/jobs/test-init-features.yml @@ -26,7 +26,7 @@ steps: mkdir features-1 cd features-1 npm set registry http://localhost:6001 - npx create-storybook --yes --package-manager npm --features dev docs test + npx create-storybook --yes --package-manager npm --features docs test a11y --loglevel=debug npx vitest environment: IN_STORYBOOK_SANDBOX: true diff --git a/code/addons/a11y/src/postinstall.ts b/code/addons/a11y/src/postinstall.ts index cf57bddb9853..d8cd0bf18b04 100644 --- a/code/addons/a11y/src/postinstall.ts +++ b/code/addons/a11y/src/postinstall.ts @@ -1,32 +1,29 @@ -// eslint-disable-next-line depend/ban-dependencies -import { execa } from 'execa'; +import { JsPackageManagerFactory } from 'storybook/internal/common'; import type { PostinstallOptions } from '../../../lib/cli-storybook/src/add'; -const $ = execa({ - preferLocal: true, - stdio: 'inherit', - // we stream the stderr to the console - reject: false, -}); - export default async function postinstall(options: PostinstallOptions) { - const command = ['storybook', 'automigrate', 'addon-a11y-addon-test']; + const args = ['storybook', 'automigrate', 'addon-a11y-addon-test']; - command.push('--loglevel', 'silent'); - command.push('--skip-doctor'); + args.push('--loglevel', 'silent'); + args.push('--skip-doctor'); if (options.yes) { - command.push('--yes'); + args.push('--yes'); } if (options.packageManager) { - command.push('--package-manager', options.packageManager); + args.push('--package-manager', options.packageManager); } if (options.configDir) { - command.push('--config-dir', `"${options.configDir}"`); + args.push('--config-dir', options.configDir); } - await $`${command.join(' ')}`; + const jsPackageManager = JsPackageManagerFactory.getPackageManager({ + force: options.packageManager, + configDir: options.configDir, + }); + + await jsPackageManager.runPackageCommand({ args }); } diff --git a/code/addons/themes/src/postinstall.ts b/code/addons/themes/src/postinstall.ts index 991529112e7c..3d99b67f3d37 100644 --- a/code/addons/themes/src/postinstall.ts +++ b/code/addons/themes/src/postinstall.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process'; +import { spawnSync } from 'child_process'; const PACKAGE_MANAGER_TO_COMMAND = { npm: 'npx', @@ -12,11 +12,11 @@ const selectPackageManagerCommand = (packageManager: string) => PACKAGE_MANAGER_TO_COMMAND[packageManager as keyof typeof PACKAGE_MANAGER_TO_COMMAND]; export default async function postinstall({ packageManager = 'npm' }) { - const command = selectPackageManagerCommand(packageManager); + const commandString = selectPackageManagerCommand(packageManager); + const [command, ...commandArgs] = commandString.split(' '); - await spawn(`${command} @storybook/auto-config themes`, { + spawnSync(command, [...commandArgs, '@storybook/auto-config', 'themes'], { stdio: 'inherit', cwd: process.cwd(), - shell: true, }); } diff --git a/code/addons/vitest/package.json b/code/addons/vitest/package.json index 063b764f51d7..e90fa30247f6 100644 --- a/code/addons/vitest/package.json +++ b/code/addons/vitest/package.json @@ -73,9 +73,7 @@ }, "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.0", - "prompts": "^2.4.0", - "ts-dedent": "^2.2.0" + "@storybook/icons": "^2.0.0" }, "devDependencies": { "@types/istanbul-lib-report": "^3.0.3", @@ -84,10 +82,8 @@ "@types/semver": "^7", "@vitest/browser-playwright": "^4.0.1", "@vitest/runner": "^4.0.1", - "boxen": "^8.0.1", "empathic": "^2.0.0", "es-toolkit": "^1.36.0", - "execa": "^8.0.1", "istanbul-lib-report": "^3.0.1", "micromatch": "^4.0.8", "pathe": "^1.1.2", diff --git a/code/addons/vitest/src/constants.ts b/code/addons/vitest/src/constants.ts index 05c92bc41a23..1efa660ec4f8 100644 --- a/code/addons/vitest/src/constants.ts +++ b/code/addons/vitest/src/constants.ts @@ -18,19 +18,6 @@ export const DOCUMENTATION_FATAL_ERROR_LINK = `${DOCUMENTATION_LINK}#what-happen export const COVERAGE_DIRECTORY = 'coverage'; -export const SUPPORTED_FRAMEWORKS = [ - '@storybook/nextjs', - '@storybook/nextjs-vite', - '@storybook/react-vite', - '@storybook/preact-vite', - '@storybook/svelte-vite', - '@storybook/vue3-vite', - '@storybook/html-vite', - '@storybook/web-components-vite', - '@storybook/sveltekit', - '@storybook/react-native-web-vite', -]; - export const storeOptions = { id: ADDON_ID, initialState: { diff --git a/code/addons/vitest/src/logger.ts b/code/addons/vitest/src/logger.ts index 388723539236..9846c7e3e7fa 100644 --- a/code/addons/vitest/src/logger.ts +++ b/code/addons/vitest/src/logger.ts @@ -1,7 +1,15 @@ +import { logger } from 'storybook/internal/node-logger'; + import picocolors from 'picocolors'; import { ADDON_ID } from './constants'; export const log = (message: any) => { - console.log(`${picocolors.magenta(ADDON_ID)}: ${message.toString().trim()}`); + logger.log( + `${picocolors.magenta(ADDON_ID)}: ${message + .toString() + // Counteracts the default logging behavior of the clack prompt library + .replaceAll(/(│\n|│ )/g, '') + .trim()}` + ); }; diff --git a/code/addons/vitest/src/node/boot-test-runner.test.ts b/code/addons/vitest/src/node/boot-test-runner.test.ts index 6257cc333553..87eae0cd7577 100644 --- a/code/addons/vitest/src/node/boot-test-runner.test.ts +++ b/code/addons/vitest/src/node/boot-test-runner.test.ts @@ -3,42 +3,55 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel, type ChannelTransport } from 'storybook/internal/channels'; +import { executeNodeCommand } from 'storybook/internal/common'; import type { Options } from 'storybook/internal/types'; -// eslint-disable-next-line depend/ban-dependencies -import { execaNode } from 'execa'; - import { storeOptions } from '../constants'; import { log } from '../logger'; import type { StoreEvent } from '../types'; import type { StoreState } from '../types'; import { killTestRunner, runTestRunner } from './boot-test-runner'; -let stdout: (chunk: any) => void; -let stderr: (chunk: any) => void; -let message: (event: any) => void; +let stdout: (chunk: Buffer | string) => void; +let stderr: (chunk: Buffer | string) => void; +let message: (event: { type: string; args?: unknown[]; payload?: unknown }) => void; const child = vi.hoisted(() => ({ stdout: { - on: vi.fn((event, callback) => { - stdout = callback; + on: vi.fn((event: string, callback: (chunk: Buffer | string) => void) => { + if (event === 'data') { + stdout = callback; + } }), }, stderr: { - on: vi.fn((event, callback) => { - stderr = callback; + on: vi.fn((event: string, callback: (chunk: Buffer | string) => void) => { + if (event === 'data') { + stderr = callback; + } }), }, - on: vi.fn((event, callback) => { - message = callback; - }), + on: vi.fn( + ( + event: string, + callback: (event: { type: string; args?: unknown[]; payload?: unknown }) => void + ) => { + if (event === 'message') { + message = callback; + } + } + ), send: vi.fn(), kill: vi.fn(), })); -vi.mock('execa', () => ({ - execaNode: vi.fn().mockReturnValue(child), -})); +vi.mock('storybook/internal/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + executeNodeCommand: vi.fn().mockReturnValue(child), + }; +}); vi.mock('../logger', () => ({ log: vi.fn(), @@ -47,27 +60,18 @@ vi.mock('../logger', () => ({ vi.mock('../../../../core/src/shared/utils/module', () => ({ importMetaResolve: vi .fn() - .mockImplementation( - (a) => 'file://' + join(__dirname, '..', '..', 'dist', 'node', 'vitest.js') - ), + .mockImplementation(() => 'file://' + join(__dirname, '..', '..', 'dist', 'node', 'vitest.js')), })); -let statusStoreSubscriber = vi.hoisted(() => undefined); -let testProviderStoreSubscriber = vi.hoisted(() => undefined); - vi.mock('storybook/internal/core-server', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, internal_universalStatusStore: { - subscribe: (listener: any) => { - statusStoreSubscriber = listener; - }, + subscribe: vi.fn(() => () => {}), }, internal_universalTestProviderStore: { - subscribe: (listener: any) => { - testProviderStoreSubscriber = listener; - }, + subscribe: vi.fn(() => () => {}), }, }; }); @@ -85,7 +89,12 @@ const transport = { setHandler: vi.fn(), send: vi.fn() } satisfies ChannelTransp const mockChannel = new Channel({ transport }); describe('bootTestRunner', () => { - let mockStore: any; + let mockStore: InstanceType< + typeof import('storybook/internal/core-server').experimental_MockUniversalStore< + StoreState, + StoreEvent + > + >; const mockOptions = { configDir: '.storybook', } as Options; @@ -95,28 +104,38 @@ describe('bootTestRunner', () => { 'storybook/internal/core-server' ); mockStore = new MockUniversalStore(storeOptions); + vi.mocked(executeNodeCommand).mockClear(); + vi.mocked(log).mockClear(); + child.send.mockClear(); }); it('should execute vitest.js', async () => { - runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); - expect(execaNode).toHaveBeenCalledWith(expect.stringMatching(/vitest\.js$/), { - env: { - NODE_ENV: 'test', - TEST: 'true', - VITEST: 'true', - VITEST_CHILD_PROCESS: 'true', - STORYBOOK_CONFIG_DIR: '.storybook', + const promise = runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); + expect(vi.mocked(executeNodeCommand)).toHaveBeenCalledWith({ + scriptPath: expect.stringMatching(/vitest\.js$/), + options: { + env: { + NODE_ENV: 'test', + TEST: 'true', + VITEST: 'true', + VITEST_CHILD_PROCESS: 'true', + STORYBOOK_CONFIG_DIR: '.storybook', + }, + extendEnv: true, }, - extendEnv: true, }); + message({ type: 'ready' }); + await promise; }); it('should log stdout and stderr', async () => { - runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); + const promise = runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); stdout('foo'); stderr('bar'); - expect(log).toHaveBeenCalledWith('foo'); - expect(log).toHaveBeenCalledWith('bar'); + message({ type: 'ready' }); + await promise; + expect(vi.mocked(log)).toHaveBeenCalledWith('foo'); + expect(vi.mocked(log)).toHaveBeenCalledWith('bar'); }); it('should wait for vitest to be ready', async () => { @@ -145,8 +164,9 @@ describe('bootTestRunner', () => { }); it('should forward universal store events', async () => { - runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); + const promise = runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions }); message({ type: 'ready' }); + await promise; mockStore.send({ type: 'TRIGGER_RUN', payload: { triggeredBy: 'global', storyIds: ['foo'] } }); expect(child.send).toHaveBeenCalledWith({ @@ -174,7 +194,7 @@ describe('bootTestRunner', () => { }); it('should resend init event', async () => { - runTestRunner({ + const promise = runTestRunner({ channel: mockChannel, store: mockStore, options: mockOptions, @@ -182,6 +202,7 @@ describe('bootTestRunner', () => { initArgs: ['foo'], }); message({ type: 'ready' }); + await promise; expect(child.send).toHaveBeenCalledWith({ args: ['foo'], from: 'server', diff --git a/code/addons/vitest/src/node/boot-test-runner.ts b/code/addons/vitest/src/node/boot-test-runner.ts index 58c8b8f2e13e..48cc96549744 100644 --- a/code/addons/vitest/src/node/boot-test-runner.ts +++ b/code/addons/vitest/src/node/boot-test-runner.ts @@ -2,14 +2,13 @@ import { type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import type { Channel } from 'storybook/internal/channels'; +import { executeNodeCommand } from 'storybook/internal/common'; import { internal_universalStatusStore, internal_universalTestProviderStore, } from 'storybook/internal/core-server'; import type { EventInfo, Options } from 'storybook/internal/types'; -// eslint-disable-next-line depend/ban-dependencies -import { execaNode } from 'execa'; import { normalize } from 'pathe'; import { importMetaResolve } from '../../../../core/src/shared/utils/module'; @@ -77,15 +76,18 @@ const bootTestRunner = async ({ const startChildProcess = () => new Promise((resolve, reject) => { - child = execaNode(vitestModulePath, { - env: { - VITEST: 'true', - TEST: 'true', - VITEST_CHILD_PROCESS: 'true', - NODE_ENV: process.env.NODE_ENV ?? 'test', - STORYBOOK_CONFIG_DIR: normalize(options.configDir), + child = executeNodeCommand({ + scriptPath: vitestModulePath, + options: { + env: { + VITEST: 'true', + TEST: 'true', + VITEST_CHILD_PROCESS: 'true', + NODE_ENV: process.env.NODE_ENV ?? 'test', + STORYBOOK_CONFIG_DIR: normalize(options.configDir), + }, + extendEnv: true, }, - extendEnv: true, }); stderr = []; diff --git a/code/addons/vitest/src/postinstall-logger.ts b/code/addons/vitest/src/postinstall-logger.ts deleted file mode 100644 index 03bdf56c8710..000000000000 --- a/code/addons/vitest/src/postinstall-logger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { isCI } from 'storybook/internal/common'; -import { colors, logger } from 'storybook/internal/node-logger'; - -const fancy = process.platform !== 'win32' || isCI() || process.env.TERM === 'xterm-256color'; - -export const step = colors.gray('›'); -export const info = colors.blue(fancy ? 'ℹ' : 'i'); -export const success = colors.green(fancy ? '✔' : '√'); -export const warning = colors.orange(fancy ? '⚠' : '‼'); -export const error = colors.red(fancy ? '✖' : '×'); - -type Options = Parameters[1]; - -const baseOptions: Options = { - borderStyle: 'round', - padding: 1, -}; - -export const print = (message: string, options: Options) => { - logger.line(1); - logger.logBox(message, { ...baseOptions, ...options }); -}; - -export const printInfo = (title: string, message: string, options?: Options) => - print(message, { borderColor: 'blue', title, ...options }); - -export const printWarning = (title: string, message: string, options?: Options) => - print(message, { borderColor: 'yellow', title, ...options }); - -export const printError = (title: string, message: string, options?: Options) => - print(message, { borderColor: 'red', title, ...options }); - -export const printSuccess = (title: string, message: string, options?: Options) => - print(message, { borderColor: 'green', title, ...options }); diff --git a/code/addons/vitest/src/postinstall.ts b/code/addons/vitest/src/postinstall.ts index 6bea9c1a0322..5231a1317ddc 100644 --- a/code/addons/vitest/src/postinstall.ts +++ b/code/addons/vitest/src/postinstall.ts @@ -1,88 +1,52 @@ import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; import { writeFile } from 'node:fs/promises'; -import { isAbsolute, posix, sep } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { babelParse, generate, traverse } from 'storybook/internal/babel'; +import { babelParse, generate } from 'storybook/internal/babel'; +import { AddonVitestService } from 'storybook/internal/cli'; import { JsPackageManagerFactory, formatFileContent, - getInterpretedFile, getProjectRoot, - isCI, - loadMainConfig, - scanAndTransformFiles, - transformImportFiles, + getStorybookInfo, } from 'storybook/internal/common'; -import { experimental_loadStorybook } from 'storybook/internal/core-server'; -import { readConfig, writeConfig } from 'storybook/internal/csf-tools'; -import { logger } from 'storybook/internal/node-logger'; +import { CLI_COLORS } from 'storybook/internal/node-logger'; +import { + AddonVitestPostinstallError, + AddonVitestPostinstallPrerequisiteCheckError, +} from 'storybook/internal/server-errors'; +import { SupportedFramework } from 'storybook/internal/types'; import * as find from 'empathic/find'; -import * as pkg from 'empathic/package'; -// eslint-disable-next-line depend/ban-dependencies -import { execa } from 'execa'; import { dirname, relative, resolve } from 'pathe'; -import prompts from 'prompts'; -import { coerce, satisfies } from 'semver'; +import { satisfies } from 'semver'; import { dedent } from 'ts-dedent'; import { type PostinstallOptions } from '../../../lib/cli-storybook/src/add'; -import { DOCUMENTATION_LINK, SUPPORTED_FRAMEWORKS } from './constants'; -import { printError, printInfo, printSuccess, printWarning, step } from './postinstall-logger'; +import { DOCUMENTATION_LINK } from './constants'; import { loadTemplate, updateConfigFile, updateWorkspaceFile } from './updateVitestFile'; -import { getAddonNames } from './utils'; const ADDON_NAME = '@storybook/addon-vitest' as const; const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.cts', '.mts', '.cjs', '.mjs']; const addonA11yName = '@storybook/addon-a11y'; -let hasErrors = false; - -function nameMatches(name: string, pattern: string) { - if (name === pattern) { - return true; - } - - if (name.includes(`${pattern}${sep}`)) { - return true; - } - if (name.includes(`${pattern}${posix.sep}`)) { - return true; - } - - return false; -} - -const logErrors = (...args: Parameters) => { - hasErrors = true; - printError(...args); -}; - -const findFile = (basename: string, extensions = EXTENSIONS) => - find.any( - extensions.map((ext) => basename + ext), - { last: getProjectRoot() } - ); - export default async function postInstall(options: PostinstallOptions) { - printSuccess( - '👋 Howdy!', - dedent` - I'm the installation helper for ${ADDON_NAME} - - Hold on for a moment while I look at your project and get it set up... - ` - ); + const errors: string[] = []; + const { logger, prompt } = options; const packageManager = JsPackageManagerFactory.getPackageManager({ force: options.packageManager, }); + const findFile = (basename: string, extensions = EXTENSIONS) => + find.any( + extensions.map((ext) => basename + ext), + { last: getProjectRoot(), cwd: options.configDir } + ); + const vitestVersionSpecifier = await packageManager.getInstalledVersion('vitest'); - const coercedVitestVersion = vitestVersionSpecifier ? coerce(vitestVersionSpecifier) : null; + logger.debug(`Vitest version specifier: ${vitestVersionSpecifier}`); const isVitest3_2To4 = vitestVersionSpecifier ? satisfies(vitestVersionSpecifier, '>=3.2.0 <4.0.0') : false; @@ -90,243 +54,103 @@ export default async function postInstall(options: PostinstallOptions) { ? satisfies(vitestVersionSpecifier, '>=4.0.0') : true; - const info = await getStorybookInfo(options); + const info = await getStorybookInfo(options.configDir); const allDeps = packageManager.getAllDependencies(); // only install these dependencies if they are not already installed - const dependencies = [ - 'vitest', - 'playwright', - isVitest4OrNewer ? '@vitest/browser-playwright' : '@vitest/browser', - ]; - - const uniqueDependencies = dependencies.filter((p) => !allDeps[p]); - - const mainJsPath = getInterpretedFile(resolve(options.configDir, 'main')) as string; - const config = await readConfig(mainJsPath); - - const hasCustomWebpackConfig = !!config.getFieldNode(['webpackFinal']); - - const isInteractive = process.stdout.isTTY && !isCI(); - - if (nameMatches(info.frameworkPackageName, '@storybook/nextjs') && !hasCustomWebpackConfig) { - const out = - options.yes || !isInteractive - ? { migrateToNextjsVite: !!options.yes } - : await prompts({ - type: 'confirm', - name: 'migrateToNextjsVite', - message: dedent` - The addon requires the use of @storybook/nextjs-vite to work with Next.js. - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#install-and-set-up - - Do you want to migrate? - `, - initial: true, - }); - - if (out.migrateToNextjsVite) { - await packageManager.addDependencies({ type: 'devDependencies', skipInstall: true }, [ - '@storybook/nextjs-vite', - ]); - - await packageManager.removeDependencies(['@storybook/nextjs']); - - traverse(config._ast, { - StringLiteral(path) { - if (path.node.value === '@storybook/nextjs') { - path.node.value = '@storybook/nextjs-vite'; - } - }, - }); - - await writeConfig(config, mainJsPath); - - info.frameworkPackageName = '@storybook/nextjs-vite'; - info.builderPackageName = '@storybook/builder-vite'; - await scanAndTransformFiles({ - promptMessage: - 'Enter a glob to scan for all @storybook/nextjs imports to substitute with @storybook/nextjs-vite:', - force: options.yes, - dryRun: false, - transformFn: (files, options, dryRun) => transformImportFiles(files, options, dryRun), - transformOptions: { - '@storybook/nextjs': '@storybook/nextjs-vite', - }, - }); - } - } - - const annotationsImport = SUPPORTED_FRAMEWORKS.find((f) => - nameMatches(info.frameworkPackageName, f) - ) - ? info.frameworkPackageName === '@storybook/nextjs' - ? '@storybook/nextjs-vite' - : info.frameworkPackageName - : null; - - const isRendererSupported = !!annotationsImport; + const addonVitestService = new AddonVitestService(); - const prerequisiteCheck = async () => { - const reasons = []; - - if (hasCustomWebpackConfig) { - reasons.push('• The addon can not be used with a custom Webpack configuration.'); - } + // Use AddonVitestService for compatibility validation + const compatibilityResult = await addonVitestService.validateCompatibility({ + packageManager, + framework: info.framework, + builder: info.builder, + }); - if ( - !nameMatches(info.frameworkPackageName, '@storybook/nextjs') && - !nameMatches(info.builderPackageName, '@storybook/builder-vite') - ) { - reasons.push( - '• The addon can only be used with a Vite-based Storybook framework or Next.js.' - ); - } + let result: string | null = null; + if (!compatibilityResult.compatible && compatibilityResult.reasons) { + const reasons = compatibilityResult.reasons.map((r) => `• ${CLI_COLORS.error(r)}`); + reasons.unshift(dedent` + Automated setup failed + The following packages have incompatibilities that prevent automated setup: + `); + reasons.push( + dedent` + You can fix these issues and rerun the command to reinstall. If you wish to roll back the installation, remove ${ADDON_NAME} from the "addons" array + in your main Storybook config file and remove the dependency from your package.json file. - if (!isRendererSupported) { - reasons.push(dedent` - • The addon cannot yet be used with ${info.frameworkPackageName} - `); - } + Please check the documentation for more information about its requirements and installation: + https://storybook.js.org/docs/next/${DOCUMENTATION_LINK} + ` + ); - if (coercedVitestVersion && !satisfies(coercedVitestVersion, '>=3.0.0')) { - reasons.push(dedent` - • The addon requires Vitest 3.0.0 or higher. You are currently using ${vitestVersionSpecifier}. - Please update all of your Vitest dependencies and try again. - `); - } + result = reasons.map((r) => r.trim()).join('\n\n'); + } - const mswVersionSpecifier = await packageManager.getInstalledVersion('msw'); - const coercedMswVersion = mswVersionSpecifier ? coerce(mswVersionSpecifier) : null; + if (result) { + logger.error(result); + throw new AddonVitestPostinstallPrerequisiteCheckError({ + reasons: compatibilityResult.reasons!, + }); + } - if (coercedMswVersion && !satisfies(coercedMswVersion, '>=2.0.0')) { - reasons.push(dedent` - • The addon uses Vitest behind the scenes, which supports only version 2 and above of MSW. However, we have detected version ${coercedMswVersion.version} in this project. - Please update the 'msw' package and try again. - `); - } + // Skip all dependency management when flag is set (called from init command) + if (!options.skipDependencyManagement) { + // Use AddonVitestService for dependency collection + const versionedDependencies = await addonVitestService.collectDependencies(packageManager); - if (nameMatches(info.frameworkPackageName, '@storybook/nextjs')) { - const nextVersion = await packageManager.getInstalledVersion('next'); - if (!nextVersion) { - reasons.push(dedent` - • You are using @storybook/nextjs without having "next" installed. - Please install "next" or use a different Storybook framework integration and try again. - `); + // Print informational messages for Next.js + if (info.framework === SupportedFramework.NEXTJS) { + const allDeps = packageManager.getAllDependencies(); + if (!allDeps['@storybook/nextjs-vite']) { + // TODO: Tell people to migrate first to nextjs-vite } } - if (reasons.length > 0) { - reasons.unshift( - `@storybook/addon-vitest's automated setup failed due to the following package incompatibilities:` - ); - reasons.push('--------------------------------'); - reasons.push( + // Print informational message for coverage reporter + const v8Version = await packageManager.getInstalledVersion('@vitest/coverage-v8'); + const istanbulVersion = await packageManager.getInstalledVersion('@vitest/coverage-istanbul'); + if (!v8Version && !istanbulVersion) { + logger.step( dedent` - You can fix these issues and rerun the command to reinstall. If you wish to roll back the installation, remove ${ADDON_NAME} from the "addons" array - in your main Storybook config file and remove the dependency from your package.json file. + You don't seem to have a coverage reporter installed. Vitest needs either V8 or Istanbul to generate coverage reports. + + Adding "@vitest/coverage-v8" to enable coverage reporting. + Read more about Vitest coverage providers at https://vitest.dev/guide/coverage.html#coverage-providers ` ); - - if (!isRendererSupported) { - reasons.push( - dedent` - Please check the documentation for more information about its requirements and installation: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK} - ` - ); - } else { - reasons.push( - dedent` - Fear not, however, you can follow the manual installation process instead at: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup - ` - ); - } - - return reasons.map((r) => r.trim()).join('\n\n'); } - return null; - }; - - const result = await prerequisiteCheck(); - - if (result) { - logErrors('⛔️ Sorry!', result); - logger.line(1); - return; - } - - if (info.frameworkPackageName === '@storybook/nextjs') { - printInfo( - '🍿 Just so you know...', - dedent` - It looks like you're using Next.js. + if (versionedDependencies.length > 0) { + logger.step('Adding dependencies to your package.json'); + logger.log(' ' + versionedDependencies.join(', ')); - Adding "@storybook/nextjs-vite/vite-plugin" so you can use it with Vitest. - - More info about the plugin at https://github.com/storybookjs/vite-plugin-storybook-nextjs - ` - ); - try { - const storybookVersion = await packageManager.getInstalledVersion('storybook'); - uniqueDependencies.push(`@storybook/nextjs-vite@^${storybookVersion}`); - } catch (e) { - console.error('Failed to install @storybook/nextjs-vite. Please install it manually'); + await packageManager.addDependencies( + { type: 'devDependencies', skipInstall: true }, + versionedDependencies + ); } - } - - const v8Version = await packageManager.getInstalledVersion('@vitest/coverage-v8'); - const istanbulVersion = await packageManager.getInstalledVersion('@vitest/coverage-istanbul'); - if (!v8Version && !istanbulVersion) { - printInfo( - '🙈 Let me cover this for you', - dedent` - You don't seem to have a coverage reporter installed. Vitest needs either V8 or Istanbul to generate coverage reports. - Adding "@vitest/coverage-v8" to enable coverage reporting. - Read more about Vitest coverage providers at https://vitest.dev/guide/coverage.html#coverage-providers - ` - ); - uniqueDependencies.push(`@vitest/coverage-v8`); // Version specifier is added below - } - - const versionedDependencies = uniqueDependencies.map((p) => { - if (p.includes('vitest')) { - return vitestVersionSpecifier ? `${p}@${vitestVersionSpecifier}` : p; + if (!options.skipInstall) { + await packageManager.installDependencies(); } - - return p; - }); - - if (versionedDependencies.length > 0) { - await packageManager.addDependencies( - { type: 'devDependencies', skipInstall: true }, - versionedDependencies - ); - logger.line(1); - logger.plain(`${step} Installing dependencies:`); - logger.plain(' ' + versionedDependencies.join(', ')); } - await packageManager.installDependencies(); - - logger.line(1); - - if (options.skipInstall) { - logger.plain('Skipping Playwright installation, please run this command manually:'); - logger.plain(' npx playwright install chromium --with-deps'); - } else { - logger.plain(`${step} Configuring Playwright with Chromium (this might take some time):`); - logger.plain(' npx playwright install chromium --with-deps'); - try { - await packageManager.executeCommand({ - command: 'npx', - args: ['playwright', 'install', 'chromium', '--with-deps'], - }); - } catch (e) { - console.error('Failed to install Playwright. Please install it manually'); + // Install Playwright browser binaries using AddonVitestService + if (!options.skipDependencyManagement) { + if (!options.skipInstall) { + const { errors: playwrightErrors } = await addonVitestService.installPlaywright( + packageManager, + { + yes: options.yes, + } + ); + errors.push(...playwrightErrors); + } else { + logger.warn(dedent` + Playwright browser binaries installation skipped. Please run the following command manually later: + ${CLI_COLORS.cta('npx playwright install chromium --with-deps')} + `); } } @@ -334,48 +158,47 @@ export default async function postInstall(options: PostinstallOptions) { allDeps.typescript || findFile('tsconfig', [...EXTENSIONS, '.json']) ? 'ts' : 'js'; const vitestSetupFile = resolve(options.configDir, `vitest.setup.${fileExtension}`); + if (existsSync(vitestSetupFile)) { - logErrors( - '🚨 Oh no!', - dedent` - Found an existing Vitest setup file: - ${vitestSetupFile} + const errorMessage = dedent` + Found an existing Vitest setup file: + ${vitestSetupFile} + Please refer to the documentation to complete the setup manually: + https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup + `; + logger.line(); + logger.error(`${errorMessage}\n`); + errors.push('Found existing Vitest setup file'); + } else { + logger.step(`Creating a Vitest setup file for Storybook:`); + logger.log(`${vitestSetupFile}\n`); - Please refer to the documentation to complete the setup manually: - https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup - ` + const previewExists = EXTENSIONS.map((ext) => resolve(options.configDir, `preview${ext}`)).some( + existsSync ); - logger.line(1); - return; - } - logger.line(1); - logger.plain(`${step} Creating a Vitest setup file for Storybook:`); - logger.plain(` ${vitestSetupFile}`); + const annotationsImport = info.frameworkPackage; - const previewExists = EXTENSIONS.map((ext) => resolve(options.configDir, `preview${ext}`)).some( - existsSync - ); + const imports = [`import { setProjectAnnotations } from '${annotationsImport}';`]; - const imports = [`import { setProjectAnnotations } from '${annotationsImport}';`]; + const projectAnnotations = []; - const projectAnnotations = []; - - if (previewExists) { - imports.push(`import * as projectAnnotations from './preview';`); - projectAnnotations.push('projectAnnotations'); - } + if (previewExists) { + imports.push(`import * as projectAnnotations from './preview';`); + projectAnnotations.push('projectAnnotations'); + } - await writeFile( - vitestSetupFile, - dedent` + await writeFile( + vitestSetupFile, + dedent` ${imports.join('\n')} // This is an important step to apply the right configuration when testing your stories. // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations setProjectAnnotations([${projectAnnotations.join(', ')}]); ` - ); + ); + } const vitestWorkspaceFile = findFile('vitest.workspace', ['.ts', '.js', '.json']); const viteConfigFile = findFile('vite.config'); @@ -417,15 +240,14 @@ export default async function postInstall(options: PostinstallOptions) { const updated = updateWorkspaceFile(source, target); if (updated) { - logger.line(1); - logger.plain(`${step} Updating your Vitest workspace file:`); - logger.plain(` ${vitestWorkspaceFile}`); + logger.step(`Updating your Vitest workspace file...`); + + logger.log(`${vitestWorkspaceFile}`); const formattedContent = await formatFileContent(vitestWorkspaceFile, generate(target).code); await writeFile(vitestWorkspaceFile, formattedContent); } else { - logErrors( - '🚨 Oh no!', + logger.error( dedent` Could not update existing Vitest workspace file: ${vitestWorkspaceFile} @@ -437,8 +259,7 @@ export default async function postInstall(options: PostinstallOptions) { https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup ` ); - logger.line(1); - return; + errors.push('Unable to update existing Vitest workspace file'); } } // If there's an existing Vite/Vitest config with workspaces, we update it to include the Storybook Addon Vitest plugin. @@ -463,9 +284,8 @@ export default async function postInstall(options: PostinstallOptions) { } if (target && updated) { - logger.line(1); - logger.plain(`${step} Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); - logger.plain(` ${rootConfig}`); + logger.step(`Updating your ${vitestConfigFile ? 'Vitest' : 'Vite'} config file:`); + logger.log(` ${rootConfig}`); const formattedContent = await formatFileContent(rootConfig, generate(target).code); // Only add triple slash reference to vite.config files, not vitest.config files @@ -478,29 +298,27 @@ export default async function postInstall(options: PostinstallOptions) { : formattedContent ); } else { - logErrors( - '🚨 Oh no!', - dedent` + logger.error(dedent` We were unable to update your existing ${vitestConfigFile ? 'Vitest' : 'Vite'} config file. Please refer to the documentation to complete the setup manually: https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup - ` - ); + `); + errors.push('Unable to update existing Vitest config file'); } } // If there's no existing Vitest/Vite config, we create a new Vitest config file. else { - const newConfigFile = resolve(`vitest.config.${fileExtension}`); + const parentDir = dirname(options.configDir); + const newConfigFile = resolve(parentDir, `vitest.config.${fileExtension}`); const configTemplate = await loadTemplate(getTemplateName(), { CONFIG_DIR: options.configDir, SETUP_FILE: relative(dirname(newConfigFile), vitestSetupFile), }); - logger.line(1); - logger.plain(`${step} Creating a Vitest config file:`); - logger.plain(` ${newConfigFile}`); + logger.step(`Creating a Vitest config file:`); + logger.log(`${newConfigFile}`); const formattedContent = await formatFileContent(newConfigFile, configTemplate); await writeFile(newConfigFile, formattedContent); @@ -510,125 +328,72 @@ export default async function postInstall(options: PostinstallOptions) { if (a11yAddon) { try { - logger.plain(`${step} Setting up ${addonA11yName} for @storybook/addon-vitest:`); - - await execa( + const command = [ 'storybook', - [ - 'automigrate', - 'addon-a11y-addon-test', - '--loglevel', - 'silent', - '--yes', - '--skip-doctor', - ...(options.packageManager ? ['--package-manager', options.packageManager] : []), - ...(options.skipInstall ? ['--skip-install'] : []), - ...(options.configDir !== '.storybook' ? ['--config-dir', `"${options.configDir}"`] : []), - ], + 'automigrate', + 'addon-a11y-addon-test', + '--loglevel', + 'silent', + '--yes', + '--skip-doctor', + ]; + + if (options.packageManager) { + command.push('--package-manager', options.packageManager); + } + + if (options.skipInstall) { + command.push('--skip-install'); + } + + if (options.configDir !== '.storybook') { + command.push('--config-dir', options.configDir); + } + + await prompt.executeTask( + // TODO: Remove stdio: 'ignore' once we have a way to log the output of the command properly + () => packageManager.runPackageCommand({ args: command, stdio: 'ignore' }), { - stdio: 'inherit', + intro: 'Setting up a11y addon for @storybook/addon-vitest', + error: 'Failed to setup a11y addon for @storybook/addon-vitest', + success: 'a11y addon setup successfully', } ); } catch (e: unknown) { - logErrors( - '🚨 Oh no!', - dedent` - We have detected that you have ${addonA11yName} installed but could not automatically set it up for @storybook/addon-vitest: - - ${e instanceof Error ? e.message : String(e)} - + logger.error(dedent` + Could not automatically set up ${addonA11yName} for @storybook/addon-vitest. Please refer to the documentation to complete the setup manually: https://storybook.js.org/docs/writing-tests/accessibility-testing#test-addon-integration - ` + `); + errors.push( + "The @storybook/addon-a11y couldn't be set up for the Vitest addon" + + (e instanceof Error ? e.stack : String(e)) ); } } const runCommand = rootConfig ? `npx vitest --project=storybook` : `npx vitest`; - if (!hasErrors) { - printSuccess( - '🎉 All done!', - dedent` + if (errors.length === 0) { + logger.step(CLI_COLORS.success('@storybook/addon-vitest setup completed successfully')); + logger.log(dedent` @storybook/addon-vitest is now configured and you're ready to run your tests! - Here are a couple of tips to get you started: - • You can run tests with "${runCommand}" - • When using the Vitest extension in your editor, all of your stories will be shown as tests! - + + • You can run tests with "${CLI_COLORS.cta(runCommand)}" + • Vitest IDE extension shows all stories as tests in your editor! + Check the documentation for more information about its features and options at: https://storybook.js.org/docs/next/${DOCUMENTATION_LINK} - ` - ); + `); } else { - printWarning( - '⚠️ Done, but with errors!', + logger.warn( dedent` - @storybook/addon-vitest was installed successfully, but there were some errors during the setup process. - - Please refer to the documentation to complete the setup manually and check the errors above: + Done, but with errors! + @storybook/addon-vitest was installed successfully, but there were some errors during the setup process. Please refer to the documentation to complete the setup manually and check the errors above: https://storybook.js.org/docs/next/${DOCUMENTATION_LINK}#manual-setup ` ); + throw new AddonVitestPostinstallError({ errors }); } - - logger.line(1); -} - -async function getPackageNameFromPath(input: string): Promise { - const path = input.startsWith('file://') ? fileURLToPath(input) : input; - if (!isAbsolute(path)) { - return path; - } - - const packageJsonPath = pkg.up({ cwd: path }); - if (!packageJsonPath) { - throw new Error(`Could not find package.json in path: ${path}`); - } - - const { default: packageJson } = await import(pathToFileURL(packageJsonPath).href, { - with: { type: 'json' }, - }); - return packageJson.name; -} - -async function getStorybookInfo({ configDir, packageManager: pkgMgr }: PostinstallOptions) { - const packageManager = JsPackageManagerFactory.getPackageManager({ force: pkgMgr, configDir }); - const { packageJson } = packageManager.primaryPackageJson; - - const config = await loadMainConfig({ configDir }); - - const { presets } = await experimental_loadStorybook({ - configDir, - packageJson, - }); - - const framework = await presets.apply('framework', {}); - const core = await presets.apply('core', {}); - - const { builder, renderer } = core; - if (!builder) { - throw new Error('Could not detect your Storybook builder.'); - } - - const frameworkPackageName = await getPackageNameFromPath( - typeof framework === 'string' ? framework : framework.name - ); - - const builderPackageName = await getPackageNameFromPath( - typeof builder === 'string' ? builder : builder.name - ); - - let rendererPackageName: string | undefined; - - if (renderer) { - rendererPackageName = await getPackageNameFromPath(renderer); - } - - return { - frameworkPackageName, - builderPackageName, - rendererPackageName, - addons: getAddonNames(config), - }; } diff --git a/code/addons/vitest/src/utils.ts b/code/addons/vitest/src/utils.ts index 4ccc6d92ffb0..befbb6f45d17 100644 --- a/code/addons/vitest/src/utils.ts +++ b/code/addons/vitest/src/utils.ts @@ -1,23 +1,5 @@ -import type { StorybookConfig } from 'storybook/internal/types'; - import type { ErrorLike } from './types'; -export function getAddonNames(mainConfig: StorybookConfig): string[] { - const addons = mainConfig.addons || []; - const addonList = addons.map((addon) => { - let name = ''; - if (typeof addon === 'string') { - name = addon; - } else if (typeof addon === 'object') { - name = addon.name; - } - - return name; - }); - - return addonList.filter((item): item is NonNullable => item != null); -} - export function errorToErrorLike(error: Error): ErrorLike { return { message: error.message, diff --git a/code/addons/vitest/src/vitest-plugin/global-setup.ts b/code/addons/vitest/src/vitest-plugin/global-setup.ts index 098810f87b91..2d11a8afbff5 100644 --- a/code/addons/vitest/src/vitest-plugin/global-setup.ts +++ b/code/addons/vitest/src/vitest-plugin/global-setup.ts @@ -47,7 +47,6 @@ const startStorybookIfNotRunning = async () => { storybookProcess = spawn(storybookScript, [], { stdio: process.env.DEBUG === 'storybook' ? 'pipe' : 'ignore', cwd: process.cwd(), - shell: true, }); storybookProcess.on('error', (error) => { diff --git a/code/addons/vitest/src/vitest-plugin/index.ts b/code/addons/vitest/src/vitest-plugin/index.ts index 4d4981797291..c3e28d4ed781 100644 --- a/code/addons/vitest/src/vitest-plugin/index.ts +++ b/code/addons/vitest/src/vitest-plugin/index.ts @@ -24,7 +24,7 @@ import { oneWayHash } from 'storybook/internal/telemetry'; import type { Presets } from 'storybook/internal/types'; import { match } from 'micromatch'; -import { dirname, join, normalize, relative, resolve, sep } from 'pathe'; +import { join, normalize, relative, resolve, sep } from 'pathe'; import picocolors from 'picocolors'; import sirv from 'sirv'; import { dedent } from 'ts-dedent'; diff --git a/code/builders/builder-vite/build-config.ts b/code/builders/builder-vite/build-config.ts index 60918f8f8eb0..f6bc2c790c0f 100644 --- a/code/builders/builder-vite/build-config.ts +++ b/code/builders/builder-vite/build-config.ts @@ -7,6 +7,11 @@ const config: BuildEntries = { exportEntries: ['.'], entryPoint: './src/index.ts', }, + { + exportEntries: ['./preset'], + entryPoint: './src/preset.ts', + dts: false, + }, ], }, extraOutputs: { diff --git a/code/builders/builder-vite/package.json b/code/builders/builder-vite/package.json index 72168b2a9711..b62b14c3abd9 100644 --- a/code/builders/builder-vite/package.json +++ b/code/builders/builder-vite/package.json @@ -33,7 +33,8 @@ "default": "./dist/index.js" }, "./input/iframe.html": "./input/iframe.html", - "./package.json": "./package.json" + "./package.json": "./package.json", + "./preset": "./dist/preset.js" }, "files": [ "dist/**/*", @@ -41,6 +42,7 @@ "README.md", "*.js", "*.d.ts", + "preset.js", "!src/**/*" ], "scripts": { @@ -49,6 +51,7 @@ }, "dependencies": { "@storybook/csf-plugin": "workspace:*", + "@vitest/mocker": "3.2.4", "ts-dedent": "^2.0.0" }, "devDependencies": { diff --git a/code/builders/builder-vite/preset.js b/code/builders/builder-vite/preset.js new file mode 100644 index 000000000000..4bd63d324002 --- /dev/null +++ b/code/builders/builder-vite/preset.js @@ -0,0 +1 @@ +export * from './dist/preset.js'; diff --git a/code/builders/builder-vite/src/build.ts b/code/builders/builder-vite/src/build.ts index 9782081c0465..db5eb1eb17c0 100644 --- a/code/builders/builder-vite/src/build.ts +++ b/code/builders/builder-vite/src/build.ts @@ -5,6 +5,7 @@ import { dedent } from 'ts-dedent'; import type { InlineConfig } from 'vite'; import { sanitizeEnvVars } from './envs'; +import { createViteLogger } from './logger'; import type { WebpackStatsPlugin } from './plugins'; import { hasVitePlugins } from './utils/has-vite-plugins'; import { withoutVitePlugins } from './utils/without-vite-plugins'; @@ -64,6 +65,8 @@ export async function build(options: Options) { finalConfig.plugins = await withoutVitePlugins(finalConfig.plugins, [turbosnapPluginName]); } + finalConfig.customLogger ??= await createViteLogger(); + await viteBuild(await sanitizeEnvVars(options, finalConfig)); const statsPlugin = findPlugin( diff --git a/code/builders/builder-vite/src/index.ts b/code/builders/builder-vite/src/index.ts index ff91de398583..f447e76b0419 100644 --- a/code/builders/builder-vite/src/index.ts +++ b/code/builders/builder-vite/src/index.ts @@ -63,3 +63,5 @@ export const start: ViteBuilder['start'] = async ({ export const build: ViteBuilder['build'] = async ({ options }) => { return viteBuild(options as Options); }; + +export const corePresets = [import.meta.resolve('./preset.js')]; diff --git a/code/builders/builder-vite/src/logger.ts b/code/builders/builder-vite/src/logger.ts new file mode 100644 index 000000000000..470ba7d243b2 --- /dev/null +++ b/code/builders/builder-vite/src/logger.ts @@ -0,0 +1,26 @@ +import { logger } from 'storybook/internal/node-logger'; + +import picocolors from 'picocolors'; + +const seenWarnings = new Set(); + +export async function createViteLogger() { + const { createLogger } = await import('vite'); + + const customViteLogger = createLogger(); + const logWithPrefix = (fn: (msg: string) => void) => (msg: string) => + fn(`${picocolors.bgYellow('Vite')} ${msg}`); + + customViteLogger.error = logWithPrefix(logger.error); + customViteLogger.warn = logWithPrefix(logger.warn); + customViteLogger.warnOnce = (msg) => { + if (seenWarnings.has(msg)) { + return; + } + seenWarnings.add(msg); + logWithPrefix(logger.warn)(msg); + }; + customViteLogger.info = logWithPrefix((msg) => logger.log(msg, { spacing: 0 })); + + return customViteLogger; +} diff --git a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts similarity index 97% rename from code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts rename to code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts index ec468cf21d3e..8937c9a33bce 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-inject-mocker/plugin.ts @@ -2,12 +2,12 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolvePackageDir } from 'storybook/internal/common'; + import { exactRegex } from '@rolldown/pluginutils'; import { dedent } from 'ts-dedent'; import type { ResolvedConfig, ViteDevServer } from 'vite'; -import { resolvePackageDir } from '../../../../shared/utils/module'; - const entryPath = '/vite-inject-mocker-entry.js'; const entryCode = dedent` diff --git a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts similarity index 96% rename from code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts rename to code/builders/builder-vite/src/plugins/vite-mock/plugin.ts index 57d06762d2a9..23861a121259 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-mock/plugin.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/plugin.ts @@ -1,18 +1,19 @@ import { readFileSync } from 'node:fs'; +import { + babelParser, + extractMockCalls, + getAutomockCode, + getRealPath, + rewriteSbMockImportCalls, +} from 'storybook/internal/mocking-utils'; import { logger } from 'storybook/internal/node-logger'; import type { CoreConfig } from 'storybook/internal/types'; +import { findMockRedirect } from '@vitest/mocker/redirect'; import { normalize } from 'pathe'; import type { Plugin, ResolvedConfig } from 'vite'; -import { getAutomockCode } from '../../../mocking-utils/automock'; -import { - babelParser, - extractMockCalls, - rewriteSbMockImportCalls, -} from '../../../mocking-utils/extract'; -import { getRealPath } from '../../../mocking-utils/resolve'; import { type MockCall, getCleanId, invalidateAllRelatedModules } from './utils'; export interface MockPluginOptions { @@ -55,7 +56,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { }, buildStart() { - mockCalls = extractMockCalls(options, babelParser, viteConfig.root); + mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect); }, configureServer(server) { @@ -64,7 +65,7 @@ export function viteMockPlugin(options: MockPluginOptions): Plugin[] { // Store the old mocks before updating const oldMockCalls = mockCalls; // Re-extract mocks to get the latest list - mockCalls = extractMockCalls(options, babelParser, viteConfig.root); + mockCalls = extractMockCalls(options, babelParser, viteConfig.root, findMockRedirect); // Invalidate the preview file const previewMod = server.moduleGraph.getModuleById(options.previewConfigPath); diff --git a/code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts b/code/builders/builder-vite/src/plugins/vite-mock/utils.ts similarity index 96% rename from code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts rename to code/builders/builder-vite/src/plugins/vite-mock/utils.ts index d3d1de0d4bcf..6f582b9570cd 100644 --- a/code/core/src/core-server/presets/vitePlugins/vite-mock/utils.ts +++ b/code/builders/builder-vite/src/plugins/vite-mock/utils.ts @@ -1,4 +1,3 @@ -import { realpathSync } from 'fs'; import type { ViteDevServer } from 'vite'; /** diff --git a/code/builders/builder-vite/src/preset.ts b/code/builders/builder-vite/src/preset.ts new file mode 100644 index 000000000000..b309970c3314 --- /dev/null +++ b/code/builders/builder-vite/src/preset.ts @@ -0,0 +1,34 @@ +import { findConfigFile } from 'storybook/internal/common'; +import type { Options } from 'storybook/internal/types'; + +import type { UserConfig } from 'vite'; + +import { viteInjectMockerRuntime } from './plugins/vite-inject-mocker/plugin'; +import { viteMockPlugin } from './plugins/vite-mock/plugin'; + +// This preset defines currently mocking plugins for Vite +// It is defined as a viteFinal preset so that @storybook/addon-vitest can use it as well and that it doesn't have to be duplicated in addon-vitest. +// The main vite configuration is defined in `./vite-config.ts`. +export async function viteFinal(existing: UserConfig, options: Options) { + const previewConfigPath = findConfigFile('preview', options.configDir); + + // If there's no preview file, there's nothing to mock. + if (!previewConfigPath) { + return existing; + } + + const coreOptions = await options.presets.apply('core'); + + return { + ...existing, + plugins: [ + ...(existing.plugins ?? []), + ...(previewConfigPath + ? [ + viteInjectMockerRuntime({ previewConfigPath }), + viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), + ] + : []), + ], + }; +} diff --git a/code/builders/builder-vite/src/vite-config.ts b/code/builders/builder-vite/src/vite-config.ts index 7a4bd5b7d089..9cb4e86042f2 100644 --- a/code/builders/builder-vite/src/vite-config.ts +++ b/code/builders/builder-vite/src/vite-config.ts @@ -2,7 +2,6 @@ import { resolve } from 'node:path'; import { getBuilderOptions, - getFrameworkName, isPreservingSymlinks, resolvePathInStorybookCache, } from 'storybook/internal/common'; @@ -59,6 +58,8 @@ export async function commonConfig( const { config: { build: buildProperty = undefined, ...userConfig } = {} } = (await loadConfigFromFile(configEnv, viteConfigPath, projectRoot)) ?? {}; + // This is the main Vite config that is used by Storybook. + // Some shared vite plugins are defined in the `./preset.ts` file so that it can be shared between the @storybook/builder-vite and @storybook/addon-vitest package. const sbConfig: InlineConfig = { configFile: false, cacheDir: resolvePathInStorybookCache('sb-vite', options.cacheKey), diff --git a/code/builders/builder-vite/src/vite-server.ts b/code/builders/builder-vite/src/vite-server.ts index 344e8d047db7..30e712c5a0cf 100644 --- a/code/builders/builder-vite/src/vite-server.ts +++ b/code/builders/builder-vite/src/vite-server.ts @@ -6,6 +6,7 @@ import { dedent } from 'ts-dedent'; import type { InlineConfig, ServerOptions } from 'vite'; import { sanitizeEnvVars } from './envs'; +import { createViteLogger } from './logger'; import { getOptimizeDeps } from './optimizeDeps'; import { commonConfig } from './vite-config'; @@ -48,5 +49,7 @@ export async function createViteServer(options: Options, devServer: Server) { const finalConfig = await presets.apply('viteFinal', config, options); const { createServer } = await import('vite'); + + finalConfig.customLogger ??= await createViteLogger(); return createServer(await sanitizeEnvVars(options, finalConfig)); } diff --git a/code/builders/builder-webpack5/build-config.ts b/code/builders/builder-webpack5/build-config.ts index cd2d4bb30ccf..64d45a5ebec4 100644 --- a/code/builders/builder-webpack5/build-config.ts +++ b/code/builders/builder-webpack5/build-config.ts @@ -22,6 +22,16 @@ const config: BuildEntries = { entryPoint: './src/loaders/export-order-loader.ts', dts: false, }, + { + exportEntries: ['./loaders/storybook-mock-transform-loader'], + entryPoint: './src/loaders/storybook-mock-transform-loader.ts', + dts: false, + }, + { + exportEntries: ['./loaders/webpack-automock-loader'], + entryPoint: './src/loaders/webpack-automock-loader.ts', + dts: false, + }, ], }, extraOutputs: { diff --git a/code/builders/builder-webpack5/package.json b/code/builders/builder-webpack5/package.json index aac7563052ad..cac1f7450b81 100644 --- a/code/builders/builder-webpack5/package.json +++ b/code/builders/builder-webpack5/package.json @@ -32,6 +32,8 @@ "default": "./dist/index.js" }, "./loaders/export-order-loader": "./dist/loaders/export-order-loader.js", + "./loaders/storybook-mock-transform-loader": "./dist/loaders/storybook-mock-transform-loader.js", + "./loaders/webpack-automock-loader": "./dist/loaders/webpack-automock-loader.js", "./package.json": "./package.json", "./presets/custom-webpack-preset": "./dist/presets/custom-webpack-preset.js", "./presets/preview-preset": "./dist/presets/preview-preset.js", @@ -54,6 +56,7 @@ }, "dependencies": { "@storybook/core-webpack": "workspace:*", + "@vitest/mocker": "3.2.4", "case-sensitive-paths-webpack-plugin": "^2.4.0", "cjs-module-lexer": "^1.2.3", "css-loader": "^7.1.2", diff --git a/code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts b/code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts similarity index 91% rename from code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts rename to code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts index 2da83caa09e7..ebc0aa6c402b 100644 --- a/code/core/src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts +++ b/code/builders/builder-webpack5/src/loaders/storybook-mock-transform-loader.ts @@ -1,9 +1,8 @@ +import { rewriteSbMockImportCalls } from 'storybook/internal/mocking-utils'; import { logger } from 'storybook/internal/node-logger'; import type { LoaderDefinition } from 'webpack'; -import { rewriteSbMockImportCalls } from '../../../mocking-utils/extract'; - /** * A Webpack loader that normalize sb.mock(import(...)) calls to sb.mock(...) * diff --git a/code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts b/code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts similarity index 91% rename from code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts rename to code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts index 980be6eb4018..8fa9d7dfb952 100644 --- a/code/core/src/core-server/presets/webpack/loaders/webpack-automock-loader.ts +++ b/code/builders/builder-webpack5/src/loaders/webpack-automock-loader.ts @@ -1,7 +1,6 @@ -import type { LoaderContext } from 'webpack'; +import { babelParser, getAutomockCode } from 'storybook/internal/mocking-utils'; -import { getAutomockCode } from '../../../mocking-utils/automock'; -import { babelParser } from '../../../mocking-utils/extract'; +import type { LoaderContext } from 'webpack'; /** Defines the options that can be passed to the webpack-automock-loader. */ interface AutomockLoaderOptions { diff --git a/code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts b/code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts similarity index 72% rename from code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts rename to code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts index efcccf2f4f95..da843d1cd782 100644 --- a/code/core/src/core-server/presets/webpack/plugins/webpack-inject-mocker-runtime-plugin.ts +++ b/code/builders/builder-webpack5/src/plugins/webpack-inject-mocker-runtime-plugin.ts @@ -1,13 +1,10 @@ -import { join } from 'node:path'; +import { getMockerRuntime } from 'storybook/internal/mocking-utils'; -import { buildSync } from 'esbuild'; // HtmlWebpackPlugin is a standard part of Storybook's Webpack setup. // We can assume it's available as a dependency. import type HtmlWebpackPlugin from 'html-webpack-plugin'; import type { Compiler } from 'webpack'; -import { resolvePackageDir } from '../../../../shared/utils/module'; - const PLUGIN_NAME = 'WebpackInjectMockerRuntimePlugin'; /** @@ -55,26 +52,7 @@ export class WebpackInjectMockerRuntimePlugin { PLUGIN_NAME, (data, cb) => { try { - // The runtime template is the same for both dev and build in the final implementation, - // as all mocking logic is handled at build time or by the dev server's transform. - const runtimeTemplatePath = join( - resolvePackageDir('storybook'), - 'assets', - 'server', - 'mocker-runtime.template.js' - ); - // Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.) - // into a single, self-contained string of code. - const bundleResult = buildSync({ - entryPoints: [runtimeTemplatePath], - bundle: true, - write: false, // Return the result in memory instead of writing to disk - format: 'esm', - target: 'es2020', - external: ['msw/browser', 'msw/core/http'], - }); - - const runtimeScriptContent = bundleResult.outputFiles[0].text; + const runtimeScriptContent = getMockerRuntime(); const runtimeAssetName = 'mocker-runtime-injected.js'; // Use the documented `emitAsset` method to add the pre-bundled runtime script diff --git a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts similarity index 94% rename from code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts rename to code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts index 6bb87d340fc1..7a3f4b8f2dfe 100644 --- a/code/core/src/core-server/presets/webpack/plugins/webpack-mock-plugin.ts +++ b/code/builders/builder-webpack5/src/plugins/webpack-mock-plugin.ts @@ -1,17 +1,16 @@ -import { createRequire } from 'node:module'; import { dirname, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Compiler } from 'webpack'; - -import { babelParser, extractMockCalls } from '../../../mocking-utils/extract'; import { + babelParser, + extractMockCalls, getIsExternal, resolveExternalModule, resolveWithExtensions, -} from '../../../mocking-utils/resolve'; +} from 'storybook/internal/mocking-utils'; -const require = createRequire(import.meta.url); +import { findMockRedirect } from '@vitest/mocker/redirect'; +import type { Compiler } from 'webpack'; // --- Type Definitions --- @@ -131,7 +130,8 @@ export class WebpackMockPlugin { const mocks = extractMockCalls( { previewConfigPath, configDir: dirname(previewConfigPath) }, babelParser, - compiler.context + compiler.context, + findMockRedirect ); // 2. Resolve each mock call to its absolute path and replacement resource. @@ -148,7 +148,7 @@ export class WebpackMockPlugin { } else { // No `__mocks__` file found. Use our custom loader to automock the module. const loaderPath = fileURLToPath( - import.meta.resolve('storybook/webpack/loaders/webpack-automock-loader') + import.meta.resolve('@storybook/builder-webpack5/loaders/webpack-automock-loader') ); replacementResource = `${loaderPath}?spy=${mock.spy}!${absolutePath}`; } diff --git a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts index dc573abf3fa1..20947f59e5a9 100644 --- a/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts +++ b/code/builders/builder-webpack5/src/presets/custom-webpack-preset.ts @@ -1,3 +1,6 @@ +import { fileURLToPath } from 'node:url'; + +import { findConfigFile } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import type { Options, PresetProperty } from 'storybook/internal/types'; @@ -6,6 +9,8 @@ import { loadCustomWebpackConfig } from '@storybook/core-webpack'; import webpackModule from 'webpack'; import type { Configuration } from 'webpack'; +import { WebpackInjectMockerRuntimePlugin } from '../plugins/webpack-inject-mocker-runtime-plugin'; +import { WebpackMockPlugin } from '../plugins/webpack-mock-plugin'; import { createDefaultWebpackConfig } from '../preview/base-webpack.config'; export const swc: PresetProperty<'swc'> = (config: Record): Record => { @@ -26,6 +31,38 @@ export const swc: PresetProperty<'swc'> = (config: Record): Record< }; }; +export async function webpackFinal(config: Configuration, options: Options) { + const previewConfigPath = findConfigFile('preview', options.configDir); + + // If there's no preview file, there's nothing to mock. + if (!previewConfigPath) { + return config; + } + + config.plugins = config.plugins || []; + + // 1. Add the loader to normalize sb.mock(import(...)) calls. + config.module!.rules!.push({ + test: /preview\.(t|j)sx?$/, + use: [ + { + loader: fileURLToPath( + import.meta.resolve('@storybook/builder-webpack5/loaders/storybook-mock-transform-loader') + ), + }, + ], + }); + + // 2. Add the plugin to handle module replacement based on sb.mock() calls. + // This plugin scans the preview file and sets up rules to swap modules. + config.plugins.push(new WebpackMockPlugin({ previewConfigPath })); + + // 3. Add the plugin to inject the mocker runtime script into the HTML. + // This ensures the `sb` object is available before any other code runs. + config.plugins.push(new WebpackInjectMockerRuntimePlugin()); + return config; +} + export async function webpack(config: Configuration, options: Options) { const { configDir, configType, presets } = options; @@ -43,11 +80,11 @@ export async function webpack(config: Configuration, options: Options) { const customConfig = await loadCustomWebpackConfig(configDir); if (typeof customConfig === 'function') { - logger.info('=> Loading custom Webpack config (full-control mode).'); + logger.info('Loading custom Webpack config (full-control mode).'); return customConfig({ config: finalDefaultConfig, mode: configType }); } - logger.info('=> Using default Webpack5 setup'); + logger.info('Using default Webpack5 setup'); return finalDefaultConfig; } diff --git a/code/builders/builder-webpack5/src/preview/base-webpack.config.ts b/code/builders/builder-webpack5/src/preview/base-webpack.config.ts index 4c6c57da3557..9a2e4d9aeea2 100644 --- a/code/builders/builder-webpack5/src/preview/base-webpack.config.ts +++ b/code/builders/builder-webpack5/src/preview/base-webpack.config.ts @@ -25,7 +25,7 @@ export async function createDefaultWebpackConfig( let cssLoaders = {}; if (!hasPostcssAddon) { - logger.info(`=> Using implicit CSS loaders`); + logger.info(`Using implicit CSS loaders`); cssLoaders = { test: /\.css$/, sideEffects: true, @@ -49,6 +49,12 @@ export async function createDefaultWebpackConfig( return { ...storybookBaseConfig, + // TODO: Implement the clearing functionality of StyledConsoleLogger so that we can use it for webpack + // The issue currently is that the status line is not cleared when the webpack compiler is run, + // which causes the status line to be printed multiple times. + // infrastructureLogging: { + // console: new StyledConsoleLogger({ prefix: 'Webpack', color: 'bgBlue' }), + // }, module: { ...storybookBaseConfig.module, rules: [ diff --git a/code/core/build-config.ts b/code/core/build-config.ts index 20552490d75c..3912a937d994 100644 --- a/code/core/build-config.ts +++ b/code/core/build-config.ts @@ -77,14 +77,8 @@ const config: BuildEntries = { exportEntries: ['./internal/cli'], }, { - entryPoint: './src/core-server/presets/webpack/loaders/webpack-automock-loader.ts', - exportEntries: ['./webpack/loaders/webpack-automock-loader'], - dts: false, - }, - { - entryPoint: './src/core-server/presets/webpack/loaders/storybook-mock-transform-loader.ts', - exportEntries: ['./webpack/loaders/storybook-mock-transform-loader'], - dts: false, + exportEntries: ['./internal/mocking-utils'], + entryPoint: './src/mocking-utils/index.ts', }, ], browser: [ diff --git a/code/core/package.json b/code/core/package.json index 9c2f17ebe6a7..19b7ff9ba3d0 100644 --- a/code/core/package.json +++ b/code/core/package.json @@ -118,6 +118,10 @@ "default": "./dist/manager/globals.js" }, "./internal/manager/globals-runtime": "./dist/manager/globals-runtime.js", + "./internal/mocking-utils": { + "types": "./dist/mocking-utils/index.d.ts", + "default": "./dist/mocking-utils/index.js" + }, "./internal/node-logger": { "types": "./dist/node-logger/index.d.ts", "default": "./dist/node-logger/index.js" @@ -175,9 +179,7 @@ "./viewport": { "types": "./dist/viewport/index.d.ts", "default": "./dist/viewport/index.js" - }, - "./webpack/loaders/storybook-mock-transform-loader": "./dist/core-server/presets/webpack/loaders/storybook-mock-transform-loader.js", - "./webpack/loaders/webpack-automock-loader": "./dist/core-server/presets/webpack/loaders/webpack-automock-loader.js" + } }, "bin": "./dist/bin/dispatcher.js", "files": [ @@ -200,7 +202,6 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", "@vitest/spy": "3.2.4", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "recast": "^0.23.5", @@ -215,7 +216,7 @@ "@babel/parser": "^7.26.9", "@babel/traverse": "^7.26.9", "@babel/types": "^7.26.8", - "@clack/prompts": "^1.0.0-alpha.0", + "@clack/prompts": "1.0.0-alpha.6", "@devtools-ds/object-inspector": "^1.1.2", "@discoveryjs/json-ext": "^0.5.3", "@emotion/cache": "^11.14.0", @@ -260,12 +261,10 @@ "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", "ansi-to-html": "^0.7.2", - "boxen": "^8.0.1", "browser-dtector": "^3.4.0", "bundle-require": "^5.1.0", "camelcase": "^8.0.0", "chai": "^5.1.1", - "cli-table3": "^0.6.1", "commander": "^14.0.1", "comment-parser": "^1.4.1", "copy-to-clipboard": "^3.3.1", @@ -309,7 +308,6 @@ "polka": "^1.0.0-next.28", "prettier": "^3.5.3", "pretty-hrtime": "^1.0.3", - "prompts": "^2.4.0", "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-aria-components": "patch:react-aria-components@npm%3A1.12.2#~/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch", diff --git a/code/core/scripts/generate-source-files.ts b/code/core/scripts/generate-source-files.ts index 04d7fdb7f4cd..0d07dd4468ff 100644 --- a/code/core/scripts/generate-source-files.ts +++ b/code/core/scripts/generate-source-files.ts @@ -88,23 +88,41 @@ async function generateVersionsFile(prettierConfig: prettier.Options | null): Pr } async function generateFrameworksFile(prettierConfig: prettier.Options | null): Promise { - const thirdPartyFrameworks = ['qwik', 'solid', 'nuxt', 'react-rsbuild', 'vue3-rsbuild']; + const thirdPartyFrameworks = [ + 'html-rsbuild', + 'nuxt', + 'qwik', + 'react-rsbuild', + 'solid', + 'vue3-rsbuild', + 'web-components-rsbuild', + ]; const destination = join(CORE_ROOT_DIR, 'src', 'types', 'modules', 'frameworks.ts'); const frameworksDirectory = join(CODE_DIR, 'frameworks'); const readFrameworks = (await readdir(frameworksDirectory)).filter((framework) => existsSync(join(frameworksDirectory, framework, 'package.json')) ); - const frameworks = [...readFrameworks.sort(), ...thirdPartyFrameworks] - .map((framework) => `'${framework}'`) - .join(' | '); + + const formatFramework = (framework: string) => { + const typedName = framework.replace(/-/g, '_').toUpperCase(); + return `${typedName} = '${framework}'`; + }; + + const coreFrameworks = readFrameworks.sort().map(formatFramework).join(',\n'); + const communityFrameworks = thirdPartyFrameworks.sort().map(formatFramework).join(',\n'); await writeFile( destination, await prettier.format( dedent` // auto generated file, do not edit - export type SupportedFrameworks = ${frameworks}; + export enum SupportedFramework { + // CORE + ${coreFrameworks}, + // COMMUNITY + ${communityFrameworks} + } `, { ...prettierConfig, diff --git a/code/core/src/bin/core.ts b/code/core/src/bin/core.ts index effa6ec1d5a1..d0c41c6974b5 100644 --- a/code/core/src/bin/core.ts +++ b/code/core/src/bin/core.ts @@ -25,6 +25,14 @@ addToGlobalContext('cliVersion', version); * * The dispatch CLI at ./dispatcher.ts routes commands to this core CLI. */ + +const handleCommandFailure = async (logFilePath: string | boolean): Promise => { + const logFile = await logTracker.writeToFile(logFilePath); + logger.log(`Storybook debug logs can be found at: ${logFile}`); + logger.outro('Storybook exited with an error'); + process.exit(1); +}; + const command = (name: string) => program .command(name) @@ -36,7 +44,10 @@ const command = (name: string) => .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') .option('--loglevel ', 'Define log level', 'info') - .option('--write-logs', 'Write all debug logs to a file at the end of the run') + .option( + '--logfile [path]', + 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' + ) .hook('preAction', async (self) => { try { const options = self.opts(); @@ -44,7 +55,7 @@ const command = (name: string) => logger.setLogLevel(options.loglevel); } - if (options.writeLogs) { + if (options.logfile) { logTracker.enableLogWriting(); } @@ -53,9 +64,9 @@ const command = (name: string) => logger.error('Error loading global settings:\n' + String(e)); } }) - .hook('postAction', async () => { + .hook('postAction', async ({ getOptionValue }) => { if (logTracker.shouldWriteLogsToFile) { - const logFile = await logTracker.writeToFile(); + const logFile = await logTracker.writeToFile(getOptionValue('logfile')); logger.outro(`Storybook debug logs can be found at: ${logFile}`); } }); @@ -103,9 +114,7 @@ command('dev') with: { type: 'json' }, }); - logger.log( - picocolors.bold(`${packageJson.name} v${packageJson.version}`) + picocolors.reset('\n') - ); + logger.intro(`${packageJson.name} v${packageJson.version}`); // The key is the field created in `options` variable for // each command line argument. Value is the env variable. @@ -121,7 +130,9 @@ command('dev') options.port = parseInt(`${options.port}`, 10); } - await dev({ ...options, packageJson }).catch(() => process.exit(1)); + await dev({ ...options, packageJson }).catch(() => { + handleCommandFailure(options.logfile); + }); }); command('build') @@ -150,7 +161,7 @@ command('build') with: { type: 'json' }, }); - logger.log(picocolors.bold(`${packageJson.name} v${packageJson.version}\n`)); + logger.intro(`Building ${packageJson.name} v${packageJson.version}`); // The key is the field created in `options` variable for // each command line argument. Value is the env variable. @@ -164,7 +175,12 @@ command('build') ...options, packageJson, test: !!options.test || optionalEnvToBoolean(process.env.SB_TESTBUILD), - }).catch(() => process.exit(1)); + }).catch(() => { + logger.outro('Storybook exited with an error'); + process.exit(1); + }); + + logger.outro('Storybook build completed successfully'); }); command('index') diff --git a/code/core/src/bin/dispatcher.ts b/code/core/src/bin/dispatcher.ts index 71cc19f5e076..9dd3787480df 100644 --- a/code/core/src/bin/dispatcher.ts +++ b/code/core/src/bin/dispatcher.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; import { pathToFileURL } from 'node:url'; +import { executeCommand, executeNodeCommand } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; import { join } from 'pathe'; @@ -53,26 +53,34 @@ async function run() { args, } as const); - let command; try { const { default: targetCliPackageJson } = await import(`${targetCli.pkg}/package.json`, { with: { type: 'json' }, }); if (targetCliPackageJson.version === versions[targetCli.pkg]) { - command = [ - 'node', - `"${join(resolvePackageDir(targetCli.pkg), 'dist/bin/index.js')}"`, - ...targetCli.args, - ]; + const child = executeNodeCommand({ + scriptPath: join(resolvePackageDir(targetCli.pkg), 'dist/bin/index.js'), + args: targetCli.args, + options: { + stdio: 'inherit', + }, + }); + child.on('exit', (code) => { + process.exit(code ?? 1); + }); + return; } - } catch (e) { + } catch { // the package couldn't be imported, use npx to install and run it instead } - command ??= ['npx', '--yes', `${targetCli.pkg}@${versions[targetCli.pkg]}`, ...targetCli.args]; - const child = spawn(command[0], command.slice(1), { stdio: 'inherit', shell: true }); + const child = executeCommand({ + command: 'npx', + args: ['--yes', `${targetCli.pkg}@${versions[targetCli.pkg]}`, ...targetCli.args], + stdio: 'inherit', + }); child.on('exit', (code) => { - process.exit(code); + process.exit(code ?? 1); }); } diff --git a/code/core/src/bin/loader.ts b/code/core/src/bin/loader.ts index 05ed8864e017..37ae944fb492 100644 --- a/code/core/src/bin/loader.ts +++ b/code/core/src/bin/loader.ts @@ -38,10 +38,7 @@ export function resolveWithExtension(importPath: string, currentFilePath: string } deprecate(dedent`One or more extensionless imports detected: "${importPath}" in file "${currentFilePath}". - For maximum compatibility, you should add an explicit file extension to this import. - Storybook will attempt to resolve it automatically, but this may change in the future. - If adding the extension results in an error from TypeScript, we recommend setting moduleResolution to "bundler" in tsconfig.json - or alternatively look into the allowImportingTsExtensions option.`); + For maximum compatibility, you should add an explicit file extension to this import. Storybook will attempt to resolve it automatically, but this may change in the future. If adding the extension results in an error from TypeScript, we recommend setting moduleResolution to "bundler" in tsconfig.json or alternatively look into the allowImportingTsExtensions option.`); // Resolve the import path relative to the current file const currentDir = path.dirname(currentFilePath); diff --git a/code/core/src/builder-manager/index.ts b/code/core/src/builder-manager/index.ts index d67a290035dd..7daba1650222 100644 --- a/code/core/src/builder-manager/index.ts +++ b/code/core/src/builder-manager/index.ts @@ -141,7 +141,7 @@ const starter: StarterFunction = async function* starterGeneratorFn({ router, }) { if (!options.quiet) { - logger.info('=> Starting manager..'); + logger.info('Starting...'); } const { @@ -258,7 +258,7 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime, if (!options.outputDir) { throw new Error('outputDir is required'); } - logger.info('=> Building manager..'); + logger.step('Building manager..'); const { config, customHead, @@ -320,7 +320,7 @@ const builder: BuilderFunction = async function* builderGeneratorFn({ startTime, await Promise.all([writeFile(join(options.outputDir, 'index.html'), html), managerFiles]); - logger.trace({ message: '=> Manager built', time: process.hrtime(startTime) }); + logger.trace({ message: 'Manager built', time: process.hrtime(startTime) }); return { toJson: () => ({}), diff --git a/code/core/src/builder-manager/utils/framework.ts b/code/core/src/builder-manager/utils/framework.ts index d980b1c5d65f..7a06fded0ee3 100644 --- a/code/core/src/builder-manager/utils/framework.ts +++ b/code/core/src/builder-manager/utils/framework.ts @@ -1,9 +1,6 @@ import { sep } from 'node:path'; -import { - extractProperRendererNameFromFramework, - getFrameworkName, -} from 'storybook/internal/common'; +import { extractRenderer, getFrameworkName } from 'storybook/internal/common'; import type { Options } from 'storybook/internal/types'; interface PropertyObject { @@ -36,11 +33,10 @@ export const buildFrameworkGlobalsFromOptions = async (options: Options) => { const { builder } = await options.presets.apply('core'); const frameworkName = await getFrameworkName(options); - const rendererName = await extractProperRendererNameFromFramework(frameworkName); + const rendererName = await extractRenderer(frameworkName); if (rendererName) { - globals.STORYBOOK_RENDERER = - (await extractProperRendererNameFromFramework(frameworkName)) ?? undefined; + globals.STORYBOOK_RENDERER = rendererName ?? undefined; } const resolvedPreviewBuilder = pluckNameFromConfigProperty(builder); diff --git a/code/core/src/cli/AddonVitestService.test.ts b/code/core/src/cli/AddonVitestService.test.ts new file mode 100644 index 000000000000..841d23e4d622 --- /dev/null +++ b/code/core/src/cli/AddonVitestService.test.ts @@ -0,0 +1,609 @@ +import * as fs from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { getProjectRoot } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; + +import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; + +import { SupportedBuilder, SupportedFramework } from '../types'; +import { AddonVitestService } from './AddonVitestService'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('storybook/internal/common', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('empathic/find', { spy: true }); + +describe('AddonVitestService', () => { + let service: AddonVitestService; + let mockPackageManager: JsPackageManager; + + beforeEach(() => { + vi.clearAllMocks(); + service = new AddonVitestService(); + vi.mocked(getProjectRoot).mockReturnValue('/test/project'); + + mockPackageManager = { + getAllDependencies: vi.fn(), + getInstalledVersion: vi.fn(), + runPackageCommand: vi.fn(), + } as Partial as JsPackageManager; + + // Setup default mocks for logger and prompt + vi.mocked(logger.info).mockImplementation(() => {}); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(prompt.executeTask).mockResolvedValue(undefined); + vi.mocked(prompt.executeTaskWithSpinner).mockResolvedValue(undefined); + vi.mocked(prompt.confirm).mockResolvedValue(true); + }); + + describe('collectDeps', () => { + beforeEach(() => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion).mockResolvedValue(null); + }); + + it('should collect base packages when not installed', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // vitest version check + .mockResolvedValueOnce(null) // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps).toContain('vitest'); + // When vitest version is null, defaults to vitest 4+ behavior + expect(deps).toContain('@vitest/browser-playwright'); + expect(deps).toContain('playwright'); + expect(deps).toContain('@vitest/coverage-v8'); + }); + + it('should not include base packages if already installed', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + vitest: '3.0.0', + '@vitest/browser': '3.0.0', + playwright: '1.0.0', + }); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest version + .mockResolvedValueOnce('3.0.0') // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps).not.toContain('vitest'); + expect(deps).not.toContain('@vitest/browser'); + expect(deps).not.toContain('playwright'); + }); + + // Note: collectDependencies doesn't add framework-specific packages + // It only collects base vitest packages + it('should collect base packages without framework-specific additions', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // vitest version check + .mockResolvedValueOnce(null) // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + // Should only contain base packages, not framework-specific ones + expect(deps).toContain('vitest'); + // When vitest version is null, defaults to vitest 4+ behavior + expect(deps).toContain('@vitest/browser-playwright'); + expect(deps).toContain('playwright'); + expect(deps).toContain('@vitest/coverage-v8'); + expect(deps.every((d) => !d.includes('nextjs-vite'))).toBe(true); + }); + + it('should not add @storybook/nextjs-vite for non-Next.js frameworks', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // vitest version + .mockResolvedValueOnce(null) // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps.every((d) => !d.includes('nextjs-vite'))).toBe(true); + }); + + it('should not add coverage reporter if v8 already installed', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // vitest version + .mockResolvedValueOnce('3.0.0') // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps.every((d) => !d.includes('coverage'))).toBe(true); + }); + + it('skips coverage if istanbul', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // @vitest/coverage-v8 + .mockResolvedValueOnce('3.0.0') // @vitest/coverage-istanbul + .mockResolvedValueOnce(null); // vitest version + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps.every((d) => !d.includes('coverage'))).toBe(true); + }); + + it('applies version', async () => { + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.2.0') // vitest version check + .mockResolvedValueOnce(null) // @vitest/coverage-v8 + .mockResolvedValueOnce(null); // @vitest/coverage-istanbul + + const deps = await service.collectDependencies(mockPackageManager); + + expect(deps).toContain('vitest@3.2.0'); + // Version 3.2.0 < 4.0.0, so uses @vitest/browser + expect(deps).toContain('@vitest/browser@3.2.0'); + expect(deps).toContain('@vitest/coverage-v8@3.2.0'); + expect(deps).toContain('playwright'); // no version for playwright + }); + }); + + describe('validatePackageVersions', () => { + it('should return compatible when vitest >=3.0.0', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(true); + expect(result.reasons).toBeUndefined(); + }); + + it('should return compatible when vitest >=4.0.0', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('4.0.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(true); + expect(result.reasons).toBeUndefined(); + }); + + it('should return incompatible when vitest <3.0.0', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('2.5.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.some((r) => r.includes('Vitest 3.0.0 or higher'))).toBe(true); + }); + + it('should return compatible when msw >=2.0.0', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce('2.0.0'); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(true); + }); + + it('should return incompatible when msw <2.0.0', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce('1.9.0'); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.some((r) => r.includes('MSW'))).toBe(true); + }); + + it('should return compatible when msw not installed', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(true); + }); + + it('should return compatible when vitest is not installed', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce(null) // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(true); + }); + + it('should handle multiple validation failures', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('2.0.0') // vitest <3.0.0 + .mockResolvedValueOnce('1.0.0'); // msw <2.0.0 + + const result = await service.validatePackageVersions(mockPackageManager); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.length).toBe(2); + }); + }); + + describe('validateCompatibility', () => { + beforeEach(() => { + vi.mocked(mockPackageManager.getInstalledVersion).mockResolvedValue('3.0.0'); + vi.mocked(find.any).mockReturnValue(undefined); + }); + + it('should return compatible for valid Vite-based framework', async () => { + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.REACT_VITE, + builder: SupportedBuilder.VITE, + }); + + expect(result.compatible).toBe(true); + }); + + it('should return compatible for react-vite with Vite builder', async () => { + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.REACT_VITE, + builder: SupportedBuilder.VITE, + }); + + expect(result.compatible).toBe(true); + }); + + it('should return incompatible for non-Vite builder (except Next.js)', async () => { + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.REACT_WEBPACK5, + builder: SupportedBuilder.WEBPACK5, + }); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('Non-Vite builder'))).toBe(true); + }); + + it('should return incompatible for Next.js with webpack builder', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.NEXTJS, + builder: SupportedBuilder.WEBPACK5, + }); + + // Test addon requires Vite builder, even for Next.js + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('Non-Vite builder'))).toBe(true); + }); + + it('should return incompatible for unsupported framework', async () => { + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.ANGULAR, + builder: SupportedBuilder.VITE, + }); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('cannot yet be used'))).toBe(true); + }); + + // Note: validateCompatibility currently doesn't validate Next.js installation + // It only validates builder, framework support, package versions, and config files + it('should return compatible for Next.js framework with valid setup', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('3.0.0') // vitest + .mockResolvedValueOnce(null); // msw + + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.NEXTJS_VITE, + builder: SupportedBuilder.VITE, + }); + + // NEXTJS_VITE framework is in SUPPORTED_FRAMEWORKS and Vite builder is compatible + expect(result.compatible).toBe(true); + }); + + it('should validate config files when configDir provided', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.json'); + + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.REACT_VITE, + builder: SupportedBuilder.VITE, + projectRoot: '.storybook', + }); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('JSON workspace'))).toBe(true); + }); + + it('should skip config file validation when no configDir provided', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.json'); + + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.REACT_VITE, + builder: SupportedBuilder.VITE, + }); + + expect(result.compatible).toBe(true); + expect(find.any).not.toHaveBeenCalled(); + }); + + it('should accumulate multiple validation failures', async () => { + vi.mocked(mockPackageManager.getInstalledVersion) + .mockResolvedValueOnce('2.0.0') // vitest <3.0.0 + .mockResolvedValueOnce('1.0.0'); // msw <2.0.0 + + const result = await service.validateCompatibility({ + packageManager: mockPackageManager, + framework: SupportedFramework.ANGULAR, + builder: SupportedBuilder.WEBPACK5, + }); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.length).toBeGreaterThan(2); + }); + }); + + describe('installPlaywright', () => { + beforeEach(() => { + // Mock the logger methods used in installPlaywright + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(logger.warn).mockImplementation(() => {}); + }); + + it('should install Playwright successfully', async () => { + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockResolvedValue(undefined); + + const { errors } = await service.installPlaywright(mockPackageManager); + + expect(errors).toEqual([]); + expect(prompt.confirm).toHaveBeenCalledWith({ + message: 'Do you want to install Playwright with Chromium now?', + initialValue: true, + }); + expect(prompt.executeTaskWithSpinner).toHaveBeenCalledWith(expect.any(Function), { + id: 'playwright-installation', + intro: 'Installing Playwright browser binaries (Press "c" to abort)', + error: expect.stringContaining('An error occurred'), + success: 'Playwright browser binaries installed successfully', + abortable: true, + }); + }); + + it('should execute playwright install command', async () => { + type ChildProcessFactory = (signal?: AbortSignal) => ExecaChildProcess; + let commandFactory: ChildProcessFactory | ChildProcessFactory[]; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockImplementation( + async (factory: ChildProcessFactory | ChildProcessFactory[]) => { + commandFactory = Array.isArray(factory) ? factory[0] : factory; + // Simulate the child process completion + commandFactory(); + } + ); + + await service.installPlaywright(mockPackageManager); + + expect(mockPackageManager.runPackageCommand).toHaveBeenCalledWith({ + args: ['playwright', 'install', 'chromium', '--with-deps'], + signal: undefined, + stdio: ['inherit', 'pipe', 'pipe'], + }); + }); + + it('should capture error stack when installation fails', async () => { + const error = new Error('Installation failed'); + error.stack = 'Error stack trace'; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockRejectedValue(error); + + const { errors } = await service.installPlaywright(mockPackageManager); + + expect(errors).toEqual(['Error stack trace']); + }); + + it('should capture error message when installation fails without stack', async () => { + const error = new Error('Installation failed'); + error.stack = undefined; + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockRejectedValue(error); + + const { errors } = await service.installPlaywright(mockPackageManager); + + expect(errors).toEqual(['Installation failed']); + }); + + it('should convert non-Error exceptions to string', async () => { + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockRejectedValue('String error'); + + const { errors } = await service.installPlaywright(mockPackageManager); + + expect(errors).toEqual(['String error']); + }); + + it('should skip installation when user declines', async () => { + vi.mocked(prompt.confirm).mockResolvedValue(false); + + const { errors } = await service.installPlaywright(mockPackageManager); + + expect(errors).toEqual([]); + expect(prompt.executeTaskWithSpinner).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith('Playwright installation skipped'); + }); + + it('should not skip installation by default', async () => { + vi.mocked(prompt.confirm).mockResolvedValue(true); + vi.mocked(prompt.executeTaskWithSpinner).mockResolvedValue(undefined); + + await service.installPlaywright(mockPackageManager); + + expect(prompt.confirm).toHaveBeenCalled(); + expect(prompt.executeTaskWithSpinner).toHaveBeenCalled(); + }); + }); + + describe('validateConfigFiles', () => { + beforeEach(() => { + vi.mocked(find.any).mockReset(); + vi.mocked(find.any).mockReturnValue(undefined); + }); + + it('should return compatible when no config files found', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + }); + + it('should reject JSON workspace files', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.json'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.some((r) => r.includes('JSON workspace'))).toBe(true); + }); + + it('should validate non-JSON workspace files', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.ts'); + vi.mocked(fs.readFile).mockResolvedValue('export default ["project1", "project2"]'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + expect(fs.readFile).toHaveBeenCalledWith('vitest.workspace.ts', 'utf8'); + }); + + it('should reject invalid workspace config', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.ts'); + vi.mocked(fs.readFile).mockResolvedValue('export default "invalid"'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('invalid workspace'))).toBe(true); + }); + + it('should reject CommonJS config files (.cts)', async () => { + vi.mocked(find.any).mockReset(); + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.cts'); // config + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.length).toBeGreaterThan(0); + expect(result.reasons!.some((r) => r.includes('CommonJS config'))).toBe(true); + }); + + it('should reject CommonJS config files (.cjs)', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.cjs'); // config + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('CommonJS config'))).toBe(true); + }); + + it('should validate non-CommonJS config files', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue('export default defineConfig({ test: {} })'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + }); + + it('should reject invalid vitest config', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue('export default {}'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons!.some((r) => r.includes('invalid Vitest config'))).toBe(true); + }); + + it('should validate defineWorkspace expression', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.ts'); + vi.mocked(fs.readFile).mockResolvedValue('export default defineWorkspace(["project1"])'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + }); + + it('should validate workspace config with object expressions', async () => { + vi.mocked(find.any).mockReturnValueOnce('vitest.workspace.ts'); + vi.mocked(fs.readFile).mockResolvedValue('export default [{ test: {} }, "project"]'); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + }); + + it('should validate config with workspace array in test', async () => { + vi.mocked(find.any) + .mockReturnValueOnce(undefined) // workspace + .mockReturnValueOnce('vitest.config.ts'); // config + vi.mocked(fs.readFile).mockResolvedValue( + 'export default defineConfig({ test: { workspace: [] } })' + ); + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(true); + }); + + it('should accumulate multiple config validation errors', async () => { + vi.mocked(find.any).mockReset(); + vi.mocked(find.any) + .mockReturnValueOnce('vitest.workspace.json') // workspace JSON + .mockReturnValueOnce('vitest.config.cjs'); // config CJS + + const result = await service.validateConfigFiles('.storybook'); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons!.length).toBe(2); + }); + }); +}); diff --git a/code/core/src/cli/AddonVitestService.ts b/code/core/src/cli/AddonVitestService.ts new file mode 100644 index 000000000000..1d72f92f7357 --- /dev/null +++ b/code/core/src/cli/AddonVitestService.ts @@ -0,0 +1,375 @@ +import fs from 'node:fs/promises'; + +import * as babel from 'storybook/internal/babel'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { getProjectRoot } from 'storybook/internal/common'; +import { CLI_COLORS } from 'storybook/internal/node-logger'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; + +import type { CallExpression } from '@babel/types'; +import * as find from 'empathic/find'; +import { coerce, satisfies } from 'semver'; +import { dedent } from 'ts-dedent'; + +import { SupportedBuilder, SupportedFramework } from '../types'; + +type Result = { + compatible: boolean; + reasons?: string[]; +}; + +export interface AddonVitestCompatibilityOptions { + packageManager: JsPackageManager; + builder?: SupportedBuilder; + framework?: SupportedFramework | null; + projectRoot?: string; +} + +/** + * Centralized service for @storybook/addon-vitest dependency collection and compatibility + * validation + * + * This service consolidates logic from: + * + * - Code/addons/vitest/src/postinstall.ts + * - Code/lib/create-storybook/src/addon-dependencies/addon-vitest.ts + * - Code/lib/create-storybook/src/services/FeatureCompatibilityService.ts + */ +export class AddonVitestService { + readonly supportedFrameworks: SupportedFramework[] = [ + SupportedFramework.HTML_VITE, + SupportedFramework.NEXTJS_VITE, + SupportedFramework.PREACT_VITE, + SupportedFramework.REACT_NATIVE_WEB_VITE, + SupportedFramework.REACT_VITE, + SupportedFramework.SVELTE_VITE, + SupportedFramework.SVELTEKIT, + SupportedFramework.VUE3_VITE, + SupportedFramework.WEB_COMPONENTS_VITE, + ]; + /** + * Collect all dependencies needed for @storybook/addon-vitest + * + * Returns versioned package strings ready for installation: + * + * - Base packages: vitest, @vitest/browser, playwright + * - Next.js specific: @storybook/nextjs-vite + * - Coverage reporter: @vitest/coverage-v8 + */ + async collectDependencies(packageManager: JsPackageManager): Promise { + const allDeps = packageManager.getAllDependencies(); + const dependencies: string[] = []; + + // Get vitest version for proper version specifiers + const vitestVersionSpecifier = await packageManager.getInstalledVersion('vitest'); + + const isVitest4OrNewer = vitestVersionSpecifier + ? satisfies(vitestVersionSpecifier, '>=4.0.0') + : true; + + // only install these dependencies if they are not already installed + const basePackages = [ + 'vitest', + 'playwright', + isVitest4OrNewer ? '@vitest/browser-playwright' : '@vitest/browser', + ]; + + // Only install these dependencies if they are not already installed + for (const pkg of basePackages) { + if (!allDeps[pkg]) { + dependencies.push(pkg); + } + } + + // Check for coverage reporters + const v8Version = await packageManager.getInstalledVersion('@vitest/coverage-v8'); + const istanbulVersion = await packageManager.getInstalledVersion('@vitest/coverage-istanbul'); + + if (!v8Version && !istanbulVersion) { + dependencies.push('@vitest/coverage-v8'); + } + + // Apply version specifiers to vitest-related packages + const versionedDependencies = dependencies.map((pkg) => { + if (pkg.includes('vitest') && vitestVersionSpecifier) { + return `${pkg}@${vitestVersionSpecifier}`; + } + return pkg; + }); + + return versionedDependencies; + } + + /** + * Install Playwright browser binaries for @storybook/addon-vitest + * + * Installs Chromium with dependencies via `npx playwright install chromium --with-deps` + * + * @param packageManager - The package manager to use for installation + * @param prompt - The prompt instance for displaying progress + * @param logger - The logger instance for displaying messages + * @param options - Installation options + * @returns Array of error messages if installation fails + */ + async installPlaywright( + packageManager: JsPackageManager, + options: { yes?: boolean } = {} + ): Promise<{ errors: string[] }> { + const errors: string[] = []; + + const playwrightCommand = ['playwright', 'install', 'chromium', '--with-deps']; + + try { + const shouldBeInstalled = options.yes + ? true + : await (async () => { + logger.log(dedent` + Playwright browser binaries are necessary for @storybook/addon-vitest. The download can take some time. If you don't want to wait, you can skip the installation and run the following command manually later: + ${CLI_COLORS.cta(`npx ${playwrightCommand.join(' ')}`)} + `); + return prompt.confirm({ + message: 'Do you want to install Playwright with Chromium now?', + initialValue: true, + }); + })(); + + if (shouldBeInstalled) { + await prompt.executeTaskWithSpinner( + (signal) => + packageManager.runPackageCommand({ + args: playwrightCommand, + stdio: ['inherit', 'pipe', 'pipe'], + signal, + }), + { + id: 'playwright-installation', + intro: 'Installing Playwright browser binaries (Press "c" to abort)', + error: `An error occurred while installing Playwright browser binaries. Please run the following command later: npx ${playwrightCommand.join(' ')}`, + success: 'Playwright browser binaries installed successfully', + abortable: true, + } + ); + } else { + logger.warn('Playwright installation skipped'); + } + } catch (e) { + ErrorCollector.addError(e); + if (e instanceof Error) { + errors.push(e.stack ?? e.message); + } else { + errors.push(String(e)); + } + } + + return { errors }; + } + + /** + * Validate full compatibility for @storybook/addon-vitest + * + * Checks: + * + * - Webpack configuration compatibility + * - Builder compatibility (Vite or Next.js) + * - Renderer/framework support + * - Vitest version (>=3.0.0) + * - MSW version (>=2.0.0 if installed) + * - Next.js installation (if using @storybook/nextjs) + * - Vitest config files (if configDir provided) + */ + async validateCompatibility(options: AddonVitestCompatibilityOptions): Promise { + const reasons: string[] = []; + + // Check builder compatibility + if (options.builder !== SupportedBuilder.VITE) { + reasons.push('Non-Vite builder is not supported'); + } + + // Check renderer/framework support + const isFrameworkSupported = this.supportedFrameworks.some( + (framework) => options.framework === framework + ); + + if (!isFrameworkSupported) { + reasons.push(`Test feature cannot yet be used with ${options.framework}`); + } + + // Check package versions + const packageVersionResult = await this.validatePackageVersions(options.packageManager); + if (!packageVersionResult.compatible && packageVersionResult.reasons) { + reasons.push(...packageVersionResult.reasons); + } + + // Check vitest config files if configDir provided + if (options.projectRoot) { + const configResult = await this.validateConfigFiles(options.projectRoot); + if (!configResult.compatible && configResult.reasons) { + reasons.push(...configResult.reasons); + } + } + + return reasons.length > 0 ? { compatible: false, reasons } : { compatible: true }; + } + + /** + * Validate package versions for addon-vitest compatibility Public method to allow early + * validation before framework detection + */ + async validatePackageVersions(packageManager: JsPackageManager): Promise { + const reasons: string[] = []; + + // Check Vitest version (>=3.0.0 - stricter requirement from postinstall) + const vitestVersionSpecifier = await packageManager.getInstalledVersion('vitest'); + const coercedVitestVersion = vitestVersionSpecifier ? coerce(vitestVersionSpecifier) : null; + + if (coercedVitestVersion && !satisfies(coercedVitestVersion, '>=3.0.0')) { + reasons.push( + `The addon requires Vitest 3.0.0 or higher. You are currently using ${vitestVersionSpecifier}.` + ); + } + + // Check MSW version (>=2.0.0 if installed) + const mswVersionSpecifier = await packageManager.getInstalledVersion('msw'); + const coercedMswVersion = mswVersionSpecifier ? coerce(mswVersionSpecifier) : null; + + if (coercedMswVersion && !satisfies(coercedMswVersion, '>=2.0.0')) { + reasons.push( + `The addon uses Vitest behind the scenes, which supports only version 2 and above of MSW. However, we have detected version ${coercedMswVersion.version} in this project.` + ); + } + + return reasons.length > 0 ? { compatible: false, reasons } : { compatible: true }; + } + + /** + * Validate vitest config files for addon compatibility + * + * Public method that can be used by both postinstall and create-storybook flows + */ + async validateConfigFiles(directory: string): Promise { + const reasons: string[] = []; + const projectRoot = getProjectRoot(); + + // Check workspace files + const vitestWorkspaceFile = find.any( + ['ts', 'js', 'json'].flatMap((ex) => [`vitest.workspace.${ex}`, `vitest.projects.${ex}`]), + { cwd: directory, last: projectRoot } + ); + + if (vitestWorkspaceFile?.endsWith('.json')) { + reasons.push(`Cannot auto-update JSON workspace file: ${vitestWorkspaceFile}`); + } else if (vitestWorkspaceFile) { + const fileContents = await fs.readFile(vitestWorkspaceFile, 'utf8'); + if (!this.isValidWorkspaceConfigFile(fileContents)) { + reasons.push(`Found an invalid workspace config file: ${vitestWorkspaceFile}`); + } + } + + // Check config files + const vitestConfigFile = find.any( + ['ts', 'js', 'tsx', 'jsx', 'cts', 'cjs', 'mts', 'mjs'].map((ex) => `vitest.config.${ex}`), + { cwd: directory, last: projectRoot } + ); + + if (vitestConfigFile?.endsWith('.cts') || vitestConfigFile?.endsWith('.cjs')) { + reasons.push(`Cannot auto-update CommonJS config file: ${vitestConfigFile}`); + } else if (vitestConfigFile) { + const configContent = await fs.readFile(vitestConfigFile, 'utf8'); + if (!this.isValidVitestConfig(configContent)) { + reasons.push(`Found an invalid Vitest config file: ${vitestConfigFile}`); + } + } + + return reasons.length > 0 ? { compatible: false, reasons } : { compatible: true }; + } + + // Private helper methods for Vitest config validation + + /** Validate workspace config file structure */ + private isValidWorkspaceConfigFile(fileContents: string): boolean { + let isValid = false; + const parsedFile = babel.babelParse(fileContents); + + babel.traverse(parsedFile, { + ExportDefaultDeclaration: (path: any) => { + const declaration = path.node.declaration; + isValid = + this.isWorkspaceConfigArray(declaration) || this.isDefineWorkspaceExpression(declaration); + }, + }); + + return isValid; + } + + /** Validate Vitest config file structure */ + private isValidVitestConfig(configContent: string): boolean { + const parsedConfig = babel.babelParse(configContent); + let isValidVitestConfig = false; + + babel.traverse(parsedConfig, { + ExportDefaultDeclaration: (path: any) => { + if (this.isDefineConfigExpression(path.node.declaration)) { + isValidVitestConfig = this.isSafeToExtendWorkspace( + path.node.declaration as CallExpression + ); + } else if (this.isMergeConfigExpression(path.node.declaration)) { + // the config could be anywhere in the mergeConfig call, so we need to check each argument + const mergeCall = path.node.declaration as CallExpression; + isValidVitestConfig = mergeCall.arguments.some((arg) => + this.isSafeToExtendWorkspace(arg as CallExpression) + ); + } + }, + }); + + return isValidVitestConfig; + } + + private isWorkspaceConfigArray(node: any): boolean { + return ( + babel.types.isArrayExpression(node) && + node?.elements.every( + (el: any) => babel.types.isStringLiteral(el) || babel.types.isObjectExpression(el) + ) + ); + } + + private isDefineWorkspaceExpression(node: any): boolean { + return ( + babel.types.isCallExpression(node) && + node.callee && + (node.callee as any)?.name === 'defineWorkspace' && + this.isWorkspaceConfigArray(node.arguments?.[0]) + ); + } + + private isDefineConfigExpression(node: any): boolean { + return ( + babel.types.isCallExpression(node) && + (node.callee as any)?.name === 'defineConfig' && + babel.types.isObjectExpression(node.arguments?.[0]) + ); + } + + private isMergeConfigExpression(path: babel.types.Node): boolean { + return babel.types.isCallExpression(path) && (path.callee as any)?.name === 'mergeConfig'; + } + + private isSafeToExtendWorkspace(node: CallExpression): boolean { + return ( + babel.types.isCallExpression(node) && + node.arguments.length > 0 && + babel.types.isObjectExpression(node.arguments?.[0]) && + node.arguments[0]?.properties.every( + (p: any) => + p.key?.name !== 'test' || + (babel.types.isObjectExpression(p.value) && + p.value.properties.every( + ({ key, value }: any) => + key?.name !== 'workspace' || babel.types.isArrayExpression(value) + )) + ) + ); + } +} diff --git a/code/core/src/cli/angular/helpers.ts b/code/core/src/cli/angular/helpers.ts index 8c85756901e5..7c7301a69af0 100644 --- a/code/core/src/cli/angular/helpers.ts +++ b/code/core/src/cli/angular/helpers.ts @@ -1,43 +1,11 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { logger } from 'storybook/internal/node-logger'; +import { prompt } from 'storybook/internal/node-logger'; import { MissingAngularJsonError } from 'storybook/internal/server-errors'; -import boxen from 'boxen'; -import prompts from 'prompts'; -import { dedent } from 'ts-dedent'; - export const ANGULAR_JSON_PATH = 'angular.json'; -export const compoDocPreviewPrefix = dedent` - import { setCompodocJson } from "@storybook/addon-docs/angular"; - import docJson from "../documentation.json"; - setCompodocJson(docJson); -`.trimStart(); - -export const promptForCompoDocs = async (): Promise => { - logger.plain( - // Create a text which explains the user why compodoc is necessary - boxen( - dedent` - Compodoc is a great tool to generate documentation for your Angular projects. - Storybook can use the documentation generated by Compodoc to extract argument definitions - and JSDOC comments to display them in the Storybook UI. We highly recommend using Compodoc for - your Angular projects to get the best experience out of Storybook. - `, - { title: 'Compodoc', borderStyle: 'round', padding: 1, borderColor: '#F1618C' } - ) - ); - const { useCompoDoc } = await prompts({ - type: 'confirm', - name: 'useCompoDoc', - message: 'Do you want to use Compodoc for documentation?', - }); - - return useCompoDoc; -}; - export class AngularJSON { json: { projects: Record }>; @@ -88,17 +56,13 @@ export class AngularJSON { async getProjectName() { if (this.projectsWithoutStorybook.length > 1) { - const { projectName } = await prompts({ - type: 'select', - name: 'projectName', + return prompt.select({ message: 'For which project do you want to generate Storybook configuration?', - choices: this.projectsWithoutStorybook.map((name) => ({ - title: name, + options: this.projectsWithoutStorybook.map((name) => ({ + label: name, value: name, })), }); - - return projectName; } return this.projectsWithoutStorybook[0]; diff --git a/code/core/src/cli/detect.test.ts b/code/core/src/cli/detect.test.ts deleted file mode 100644 index 157ea39920f9..000000000000 --- a/code/core/src/cli/detect.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { existsSync } from 'node:fs'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { JsPackageManager, PackageJsonWithMaybeDeps } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; - -import { detect, detectFrameworkPreset, detectLanguage } from './detect'; -import { ProjectType, SupportedLanguage } from './project_types'; - -vi.mock('./helpers', () => ({ - isNxProject: vi.fn(), -})); - -vi.mock(import('fs'), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(), - }; -}); - -vi.mock('storybook/internal/node-logger'); -vi.mock('empathic/find'); - -const MOCK_FRAMEWORK_FILES: { - name: string; - files: Record<'package.json', PackageJsonWithMaybeDeps> | Record; -}[] = [ - { - name: ProjectType.VUE3, - files: { - 'package.json': { - dependencies: { - vue: '^3.0.0', - }, - }, - }, - }, - { - name: ProjectType.NUXT, - files: { - 'package.json': { - dependencies: { - nuxt: '^3.11.2', - }, - }, - }, - }, - { - name: ProjectType.NUXT, - files: { - 'package.json': { - dependencies: { - // Nuxt projects may have Vue 3 as an explicit dependency - nuxt: '^3.11.2', - vue: '^3.0.0', - }, - }, - }, - }, - { - name: ProjectType.VUE3, - files: { - 'package.json': { - dependencies: { - // Testing the `next` tag too - vue: 'next', - }, - }, - }, - }, - { - name: ProjectType.EMBER, - files: { - 'package.json': { - devDependencies: { - 'ember-cli': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.REACT_PROJECT, - files: { - 'package.json': { - peerDependencies: { - react: '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.QWIK, - files: { - 'package.json': { - devDependencies: { - '@builder.io/qwik': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.REACT_NATIVE, - files: { - 'package.json': { - dependencies: { - 'react-native': '1.0.0', - }, - devDependencies: { - 'react-native-scripts': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.REACT_SCRIPTS, - files: { - 'package.json': { - devDependencies: { - 'react-scripts': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.WEBPACK_REACT, - files: { - 'package.json': { - dependencies: { - react: '1.0.0', - }, - devDependencies: { - webpack: '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.REACT, - files: { - 'package.json': { - dependencies: { - react: '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.NEXTJS, - files: { - 'package.json': { - dependencies: { - next: '^9.0.0', - }, - }, - }, - }, - { - name: ProjectType.ANGULAR, - files: { - 'package.json': { - dependencies: { - '@angular/core': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.WEB_COMPONENTS, - files: { - 'package.json': { - dependencies: { - 'lit-element': '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.WEB_COMPONENTS, - files: { - 'package.json': { - dependencies: { - 'lit-html': '1.4.1', - }, - }, - }, - }, - { - name: ProjectType.WEB_COMPONENTS, - files: { - 'package.json': { - dependencies: { - 'lit-html': '2.0.0-rc.3', - }, - }, - }, - }, - { - name: ProjectType.WEB_COMPONENTS, - files: { - 'package.json': { - dependencies: { - lit: '2.0.0-rc.2', - }, - }, - }, - }, - { - name: ProjectType.PREACT, - files: { - 'package.json': { - dependencies: { - preact: '1.0.0', - }, - }, - }, - }, - { - name: ProjectType.SVELTE, - files: { - 'package.json': { - dependencies: { - svelte: '1.0.0', - }, - }, - }, - }, -]; - -describe('Detect', () => { - it(`should return type HTML if html option is passed`, async () => { - const packageManager = { - primaryPackageJson: { - packageJson: { - dependencies: {}, - devDependencies: {}, - peerDependencies: {}, - }, - packageJsonPath: 'some/path', - operationDir: 'some/path', - }, - getAllDependencies: () => ({}), - getModulePackageJSON: () => Promise.resolve(null), - } as Partial; - - await expect(detect(packageManager as any, { html: true })).resolves.toBe(ProjectType.HTML); - }); - - it(`should return language javascript if the TS dependency is present but less than minimum supported`, async () => { - vi.mocked(logger.warn).mockClear(); - - const packageManager = { - getAllDependencies: () => ({ - typescript: '1.0.0', - }), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '1.0.0', - }); - default: - return null; - } - }, - } as Partial; - - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.JAVASCRIPT); - expect(logger.warn).toHaveBeenCalledWith( - 'Detected TypeScript < 4.9 or incompatible tooling, populating with JavaScript examples' - ); - }); - - it(`should return language javascript if the TS dependency is <4.9`, async () => { - const packageManager = { - getAllDependencies: () => ({ - typescript: '4.8.0', - }), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '4.8.0', - }); - default: - return null; - } - }, - } as Partial; - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.JAVASCRIPT); - }); - - it(`should return language typescript-4-9 if the dependency is >TS4.9`, async () => { - const packageManager = { - getAllDependencies: () => ({ - typescript: '4.9.1', - }), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '4.9.1', - }); - default: - return null; - } - }, - } as Partial; - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.TYPESCRIPT); - }); - - it(`should return language typescript if the dependency is =TS4.9`, async () => { - const packageManager = { - getAllDependencies: () => ({ - typescript: '4.9.0', - }), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '4.9.0', - }); - default: - return null; - } - }, - } as Partial; - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.TYPESCRIPT); - }); - - it(`should return language JavaScript if the dependency is =TS4.9beta`, async () => { - const packageManager = { - getAllDependencies: () => ({ - typescript: '4.9.0-beta', - }), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '4.9.0-beta', - }); - default: - return null; - } - }, - } as Partial; - - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.JAVASCRIPT); - }); - - it(`should return language javascript by default`, async () => { - const packageManager = { - getAllDependencies: () => ({}), - getModulePackageJSON: () => Promise.resolve(null), - } as Partial; - - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.JAVASCRIPT); - }); - - it(`should return language Javascript even when Typescript is detected in the node_modules but not listed as a direct dependency`, async () => { - const packageManager = { - getAllDependencies: () => ({}), - getModulePackageJSON: (packageName: string) => { - switch (packageName) { - case 'typescript': - return Promise.resolve({ - version: '4.9.0', - }); - default: - return null; - } - }, - } as Partial; - - await expect(detectLanguage(packageManager as any)).resolves.toBe(SupportedLanguage.JAVASCRIPT); - }); - - describe('detectFrameworkPreset should return', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - MOCK_FRAMEWORK_FILES.forEach((structure) => { - it(`${structure.name}`, () => { - vi.mocked(existsSync).mockImplementation((filePath) => { - return typeof filePath === 'string' && Object.keys(structure.files).includes(filePath); - }); - - const result = detectFrameworkPreset( - structure.files['package.json'] as PackageJsonWithMaybeDeps - ); - - expect(result).toBe(structure.name); - }); - }); - - it(`UNDETECTED for unknown frameworks`, () => { - const result = detectFrameworkPreset(); - expect(result).toBe(ProjectType.UNDETECTED); - }); - - // TODO: The mocking in this test causes tests after it to fail - it('REACT_SCRIPTS for custom react scripts config', () => { - const forkedReactScriptsConfig = { - '/node_modules/.bin/react-scripts': 'file content', - }; - - vi.mocked(existsSync).mockImplementation((filePath) => { - return ( - typeof filePath === 'string' && Object.keys(forkedReactScriptsConfig).includes(filePath) - ); - }); - - const result = detectFrameworkPreset(); - expect(result).toBe(ProjectType.REACT_SCRIPTS); - }); - }); -}); diff --git a/code/core/src/cli/detect.ts b/code/core/src/cli/detect.ts index f09049383add..1c4d3be9caec 100644 --- a/code/core/src/cli/detect.ts +++ b/code/core/src/cli/detect.ts @@ -1,251 +1,6 @@ -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import type { JsPackageManager, PackageJsonWithMaybeDeps } from 'storybook/internal/common'; -import { HandledError, commandLog, getProjectRoot } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; - import * as find from 'empathic/find'; -import prompts from 'prompts'; -import semver from 'semver'; - -import { isNxProject } from './helpers'; -import type { TemplateConfiguration, TemplateMatcher } from './project_types'; -import { - CoreBuilder, - ProjectType, - SupportedLanguage, - supportedTemplates, - unsupportedTemplate, -} from './project_types'; - -const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; -const webpackConfigFiles = ['webpack.config.js']; - -const hasDependency = ( - packageJson: PackageJsonWithMaybeDeps, - name: string, - matcher?: (version: string) => boolean -) => { - const version = packageJson.dependencies?.[name] || packageJson.devDependencies?.[name]; - if (version && typeof matcher === 'function') { - return matcher(version); - } - return !!version; -}; - -const hasPeerDependency = ( - packageJson: PackageJsonWithMaybeDeps, - name: string, - matcher?: (version: string) => boolean -) => { - const version = packageJson.peerDependencies?.[name]; - if (version && typeof matcher === 'function') { - return matcher(version); - } - return !!version; -}; - -type SearchTuple = [string, ((version: string) => boolean) | undefined]; - -const getFrameworkPreset = ( - packageJson: PackageJsonWithMaybeDeps, - framework: TemplateConfiguration -): ProjectType | null => { - const matcher: TemplateMatcher = { - dependencies: [false], - peerDependencies: [false], - files: [false], - }; - - const { preset, files, dependencies, peerDependencies, matcherFunction } = framework; - - let dependencySearches = [] as SearchTuple[]; - if (Array.isArray(dependencies)) { - dependencySearches = dependencies.map((name) => [name, undefined]); - } else if (typeof dependencies === 'object') { - dependencySearches = Object.entries(dependencies); - } - - // Must check the length so the `[false]` isn't overwritten if `{ dependencies: [] }` - if (dependencySearches.length > 0) { - matcher.dependencies = dependencySearches.map(([name, matchFn]) => - hasDependency(packageJson, name, matchFn) - ); - } - - let peerDependencySearches = [] as SearchTuple[]; - if (Array.isArray(peerDependencies)) { - peerDependencySearches = peerDependencies.map((name) => [name, undefined]); - } else if (typeof peerDependencies === 'object') { - peerDependencySearches = Object.entries(peerDependencies); - } - - // Must check the length so the `[false]` isn't overwritten if `{ peerDependencies: [] }` - if (peerDependencySearches.length > 0) { - matcher.peerDependencies = peerDependencySearches.map(([name, matchFn]) => - hasPeerDependency(packageJson, name, matchFn) - ); - } - - if (Array.isArray(files) && files.length > 0) { - matcher.files = files.map((name) => existsSync(name)); - } - - return matcherFunction(matcher) ? preset : null; -}; - -export function detectFrameworkPreset( - packageJson = {} as PackageJsonWithMaybeDeps -): ProjectType | null { - const result = [...supportedTemplates, unsupportedTemplate].find((framework) => { - return getFrameworkPreset(packageJson, framework) !== null; - }); - - return result ? result.preset : ProjectType.UNDETECTED; -} - -/** - * Attempts to detect which builder to use, by searching for a vite config file or webpack - * installation. If neither are found it will choose the default builder based on the project type. - * - * @returns CoreBuilder - */ -export async function detectBuilder(packageManager: JsPackageManager, projectType: ProjectType) { - const viteConfig = find.any(viteConfigFiles, { last: getProjectRoot() }); - const webpackConfig = find.any(webpackConfigFiles, { last: getProjectRoot() }); - const dependencies = packageManager.getAllDependencies(); - - if (viteConfig || (dependencies.vite && dependencies.webpack === undefined)) { - commandLog('Detected Vite project. Setting builder to Vite')(); - return CoreBuilder.Vite; - } - - // REWORK - if ( - webpackConfig || - ((dependencies.webpack || dependencies['@nuxt/webpack-builder']) && - dependencies.vite !== undefined) - ) { - commandLog('Detected webpack project. Setting builder to webpack')(); - return CoreBuilder.Webpack5; - } - - // Fallback to Vite or Webpack based on project type - switch (projectType) { - case ProjectType.REACT_NATIVE_AND_RNW: - case ProjectType.REACT_NATIVE_WEB: - return CoreBuilder.Vite; - case ProjectType.REACT_SCRIPTS: - case ProjectType.ANGULAR: - case ProjectType.REACT_NATIVE: // technically react native doesn't use webpack, we just want to set something - case ProjectType.NEXTJS: - case ProjectType.EMBER: - return CoreBuilder.Webpack5; - case ProjectType.NUXT: - return CoreBuilder.Vite; - default: - const { builder } = await prompts( - { - type: 'select', - name: 'builder', - message: - '\nWe were not able to detect the right builder for your project. Please select one:', - choices: [ - { title: 'Vite', value: CoreBuilder.Vite }, - { title: 'Webpack 5', value: CoreBuilder.Webpack5 }, - ], - }, - { - onCancel: () => { - throw new HandledError('Canceled by the user'); - }, - } - ); - - return builder; - } -} - -export function isStorybookInstantiated(configDir = resolve(process.cwd(), '.storybook')) { - return existsSync(configDir); -} // TODO: Remove in SB11 export async function detectPnp() { return !!find.any(['.pnp.js', '.pnp.cjs']); } - -export async function detectLanguage(packageManager: JsPackageManager) { - let language = SupportedLanguage.JAVASCRIPT; - - if (existsSync('jsconfig.json')) { - return language; - } - - const isTypescriptDirectDependency = !!packageManager.getAllDependencies().typescript; - - 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'), - ]); - - if (isTypescriptDirectDependency && typescriptVersion) { - if ( - semver.gte(typescriptVersion, '4.9.0') && - (!prettierVersion || semver.gte(prettierVersion, '2.8.0')) && - (!babelPluginTransformTypescriptVersion || - semver.gte(babelPluginTransformTypescriptVersion, '7.20.0')) && - (!typescriptEslintParserVersion || semver.gte(typescriptEslintParserVersion, '5.44.0')) && - (!eslintPluginStorybookVersion || semver.gte(eslintPluginStorybookVersion, '0.6.8')) - ) { - language = SupportedLanguage.TYPESCRIPT; - } else { - logger.warn( - 'Detected TypeScript < 4.9 or incompatible tooling, populating with JavaScript examples' - ); - } - } 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; -} - -export async function detect( - packageManager: JsPackageManager, - options: { force?: boolean; html?: boolean } = {} -) { - try { - if (await isNxProject()) { - return ProjectType.NX; - } - - if (options.html) { - return ProjectType.HTML; - } - - const { packageJson } = packageManager.primaryPackageJson; - return detectFrameworkPreset(packageJson); - } catch (e) { - return ProjectType.UNDETECTED; - } -} diff --git a/code/core/src/cli/dev.ts b/code/core/src/cli/dev.ts index 87e637c408c4..1b59ba9e4223 100644 --- a/code/core/src/cli/dev.ts +++ b/code/core/src/cli/dev.ts @@ -13,15 +13,14 @@ function printError(error: any) { if ((error as any).error) { logger.error((error as any).error); } else if ((error as any).stats && (error as any).stats.compilation.errors) { - (error as any).stats.compilation.errors.forEach((e: any) => logger.plain(e)); + (error as any).stats.compilation.errors.forEach((e: any) => logger.log(e)); } else { logger.error(error as any); } } else if (error.compilation?.errors) { - error.compilation.errors.forEach((e: any) => logger.plain(e)); + error.compilation.errors.forEach((e: any) => logger.log(e)); } - logger.line(); logger.warn( error.close ? dedent` @@ -33,7 +32,6 @@ function printError(error: any) { You may need to refresh the browser. ` ); - logger.line(); } export const dev = async (cliOptions: CLIOptions) => { diff --git a/code/core/src/cli/dirs.ts b/code/core/src/cli/dirs.ts index 3529582b63be..f4b48ce76291 100644 --- a/code/core/src/cli/dirs.ts +++ b/code/core/src/cli/dirs.ts @@ -6,14 +6,36 @@ import { createGunzip } from 'node:zlib'; import { temporaryDirectory, versions } from 'storybook/internal/common'; import type { JsPackageManager } from 'storybook/internal/common'; -import type { SupportedFrameworks, SupportedRenderers } from 'storybook/internal/types'; +import { SupportedFramework, type SupportedRenderer } from 'storybook/internal/types'; import getNpmTarballUrlDefault from 'get-npm-tarball-url'; import { unpackTar } from 'modern-tar/fs'; import invariant from 'tiny-invariant'; import { resolvePackageDir } from '../shared/utils/module'; -import { externalFrameworks } from './project_types'; + +type ExternalFramework = { + name: SupportedFramework; + packageName?: string; + frameworks?: string[]; + renderer?: string; +}; + +const externalFrameworks: ExternalFramework[] = [ + { name: SupportedFramework.QWIK, packageName: 'storybook-framework-qwik' }, + { + name: SupportedFramework.SOLID, + packageName: 'storybook-solidjs-vite', + frameworks: ['storybook-solidjs-vite'], + renderer: 'storybook-solidjs-vite', + }, + { + name: SupportedFramework.NUXT, + packageName: '@storybook-vue/nuxt', + frameworks: ['@storybook-vue/nuxt'], + renderer: '@storybook/vue3', + }, +]; const resolveUsingBranchInstall = async (packageManager: JsPackageManager, request: string) => { const tempDirectory = await temporaryDirectory(); @@ -47,7 +69,7 @@ const resolveUsingBranchInstall = async (packageManager: JsPackageManager, reque export async function getRendererDir( packageManager: JsPackageManager, - renderer: SupportedFrameworks | SupportedRenderers + renderer: SupportedFramework | SupportedRenderer ) { const externalFramework = externalFrameworks.find((framework) => framework.name === renderer); const frameworkPackageName = diff --git a/code/core/src/cli/helpers.test.ts b/code/core/src/cli/helpers.test.ts index ca8e6f7c8638..ffccebb4f8d3 100644 --- a/code/core/src/cli/helpers.test.ts +++ b/code/core/src/cli/helpers.test.ts @@ -4,13 +4,12 @@ import fsp from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { JsPackageManager } from 'storybook/internal/common'; -import type { SupportedRenderers } from 'storybook/internal/types'; +import { Feature, SupportedLanguage, SupportedRenderer } from 'storybook/internal/types'; import { sep } from 'path'; import { IS_WINDOWS } from '../../../vitest.helpers'; import * as helpers from './helpers'; -import { SupportedLanguage } from './project_types'; const normalizePath = (path: string) => (IS_WINDOWS ? path.replace(/\//g, sep) : path); @@ -162,11 +161,11 @@ describe('Helpers', () => { filePath === normalizePath('@storybook/react/template/cli') ); await helpers.copyTemplateFiles({ - templateLocation: 'react', + templateLocation: SupportedRenderer.REACT, language, packageManager: packageManagerMock, commonAssetsDir: normalizePath('create-storybook/rendererAssets/common'), - features: ['dev', 'docs', 'test'], + features: new Set([Feature.DOCS, Feature.TEST]), }); expect(fsp.cp).toHaveBeenNthCalledWith( @@ -186,10 +185,10 @@ describe('Helpers', () => { return filePath === normalizePath('@storybook/react/template/cli') || filePath === './src'; }); await helpers.copyTemplateFiles({ - templateLocation: 'react', + templateLocation: SupportedRenderer.REACT, language: SupportedLanguage.JAVASCRIPT, packageManager: packageManagerMock, - features: ['dev', 'docs', 'test'], + features: new Set([Feature.DOCS, Feature.TEST]), }); expect(fsp.cp).toHaveBeenCalledWith(expect.anything(), './src/stories', expect.anything()); }); @@ -199,23 +198,23 @@ describe('Helpers', () => { return filePath === normalizePath('@storybook/react/template/cli'); }); await helpers.copyTemplateFiles({ - templateLocation: 'react', + templateLocation: SupportedRenderer.REACT, language: SupportedLanguage.JAVASCRIPT, packageManager: packageManagerMock, - features: ['dev', 'docs', 'test'], + features: new Set([Feature.DOCS, Feature.TEST]), }); expect(fsp.cp).toHaveBeenCalledWith(expect.anything(), './stories', expect.anything()); }); it(`should throw an error for unsupported renderer`, async () => { - const renderer = 'unknown renderer' as SupportedRenderers; + const renderer = 'unknown renderer' as unknown as SupportedRenderer; const expectedMessage = `Unsupported renderer: ${renderer}`; await expect( helpers.copyTemplateFiles({ templateLocation: renderer, language: SupportedLanguage.JAVASCRIPT, packageManager: packageManagerMock, - features: ['dev', 'docs', 'test'], + features: new Set([Feature.DOCS, Feature.TEST]), }) ).rejects.toThrowError(expectedMessage); }); diff --git a/code/core/src/cli/helpers.ts b/code/core/src/cli/helpers.ts index d529523fb352..29d8c31a47d4 100644 --- a/code/core/src/cli/helpers.ts +++ b/code/core/src/cli/helpers.ts @@ -6,19 +6,21 @@ import { type JsPackageManager, type PackageJson, frameworkToRenderer, - getProjectRoot, } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; -import type { SupportedFrameworks, SupportedRenderers } from 'storybook/internal/types'; +import { + type SupportedFramework, + SupportedLanguage, + type SupportedRenderer, +} from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; -import * as find from 'empathic/find'; import picocolors from 'picocolors'; import { coerce, satisfies } from 'semver'; import stripJsonComments from 'strip-json-comments'; import invariant from 'tiny-invariant'; import { getRendererDir } from './dirs'; -import { CommunityBuilder, CoreBuilder, SupportedLanguage } from './project_types'; export function readFileAsJson(jsonPath: string, allowComments?: boolean) { const filePath = resolve(jsonPath); @@ -129,37 +131,11 @@ export function copyTemplate(templateRoot: string, destination = '.') { type CopyTemplateFilesOptions = { packageManager: JsPackageManager; - templateLocation: SupportedFrameworks | SupportedRenderers; + templateLocation: SupportedFramework | SupportedRenderer; language: SupportedLanguage; commonAssetsDir?: string; destination?: string; - features: string[]; -}; - -export const frameworkToDefaultBuilder: Record< - SupportedFrameworks, - CoreBuilder | CommunityBuilder -> = { - angular: CoreBuilder.Webpack5, - ember: CoreBuilder.Webpack5, - 'html-vite': CoreBuilder.Vite, - nextjs: CoreBuilder.Webpack5, - nuxt: CoreBuilder.Vite, - 'nextjs-vite': CoreBuilder.Vite, - 'preact-vite': CoreBuilder.Vite, - qwik: CoreBuilder.Vite, - 'react-native-web-vite': CoreBuilder.Vite, - 'react-vite': CoreBuilder.Vite, - 'react-webpack5': CoreBuilder.Webpack5, - 'server-webpack5': CoreBuilder.Webpack5, - solid: CoreBuilder.Vite, - 'svelte-vite': CoreBuilder.Vite, - sveltekit: CoreBuilder.Vite, - 'vue3-vite': CoreBuilder.Vite, - 'web-components-vite': CoreBuilder.Vite, - // Only to pass type checking, will never be used - 'react-rsbuild': CommunityBuilder.Rsbuild, - 'vue3-rsbuild': CommunityBuilder.Rsbuild, + features: Set; }; /** @@ -229,19 +205,15 @@ export async function copyTemplateFiles({ }; const destinationPath = destination ?? (await cliStoriesTargetPath()); - const filter = (file: string) => features.includes('docs') || !file.endsWith('.mdx'); + const filter = (file: string) => features.has(Feature.DOCS) || !file.endsWith('.mdx'); if (commonAssetsDir) { await cp(commonAssetsDir, destinationPath, { recursive: true, filter }); } await cp(await templatePath(), destinationPath, { recursive: true, filter }); - if (commonAssetsDir && features.includes('docs')) { - let rendererType = frameworkToRenderer[templateLocation] || 'react'; + if (commonAssetsDir && features.has(Feature.DOCS)) { + const rendererType = frameworkToRenderer[templateLocation] || 'react'; - // This is only used for docs links and the docs site uses `vue` for both `vue` & `vue3` renderers - if (rendererType === 'vue3') { - rendererType = 'vue'; - } await adjustTemplate(join(destinationPath, 'Configure.mdx'), { renderer: rendererType }); } } @@ -258,10 +230,6 @@ export async function adjustTemplate(templatePath: string, templateData: Record< await writeFile(templatePath, template); } -export async function isNxProject() { - return find.up('nx.json', { last: getProjectRoot() }); -} - export function coerceSemver(version: string) { const coercedSemver = coerce(version); invariant(coercedSemver != null, `Could not coerce ${version} into a semver.`); diff --git a/code/core/src/cli/index.ts b/code/core/src/cli/index.ts index 568fd163e727..617abb5c60d7 100644 --- a/code/core/src/cli/index.ts +++ b/code/core/src/cli/index.ts @@ -2,7 +2,8 @@ export * from './detect'; export * from './helpers'; export * from './angular/helpers'; export * from './dirs'; -export * from './project_types'; +export * from './projectTypes'; export * from './NpmOptions'; export * from './eslintPlugin'; export * from './globalSettings'; +export * from './AddonVitestService'; diff --git a/code/core/src/cli/projectTypes.ts b/code/core/src/cli/projectTypes.ts new file mode 100644 index 000000000000..b91f16460c86 --- /dev/null +++ b/code/core/src/cli/projectTypes.ts @@ -0,0 +1,24 @@ +export enum ProjectType { + ANGULAR = 'angular', + EMBER = 'ember', + HTML = 'html', + NEXTJS = 'nextjs', + NUXT = 'nuxt', + NX = 'nx', + PREACT = 'preact', + QWIK = 'qwik', + REACT = 'react', + REACT_NATIVE = 'react_native', + REACT_NATIVE_AND_RNW = 'react_native_and_rnw', + REACT_NATIVE_WEB = 'react_native_web', + REACT_PROJECT = 'react_project', + REACT_SCRIPTS = 'react_scripts', + SERVER = 'server', + SOLID = 'solid', + SVELTE = 'svelte', + SVELTEKIT = 'sveltekit', + UNDETECTED = 'undetected', + UNSUPPORTED = 'unsupported', + VUE3 = 'vue3', + WEB_COMPONENTS = 'web_components', +} diff --git a/code/core/src/cli/project_types.test.ts b/code/core/src/cli/project_types.test.ts deleted file mode 100644 index c5b6e4adc82f..000000000000 --- a/code/core/src/cli/project_types.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { SUPPORTED_RENDERERS, installableProjectTypes } from './project_types'; - -describe('installableProjectTypes should have an entry for the supported framework', () => { - SUPPORTED_RENDERERS.forEach((framework) => { - it(`${framework}`, () => { - expect(installableProjectTypes.includes(framework.replace(/-/g, '_'))).toBe(true); - }); - }); -}); diff --git a/code/core/src/cli/project_types.ts b/code/core/src/cli/project_types.ts deleted file mode 100644 index 7a75f0c9f327..000000000000 --- a/code/core/src/cli/project_types.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { SupportedFrameworks, SupportedRenderers } from 'storybook/internal/types'; - -import { minVersion, validRange } from 'semver'; - -function eqMajor(versionRange: string, major: number) { - // Uses validRange to avoid a throw from minVersion if an invalid range gets passed - if (validRange(versionRange)) { - return minVersion(versionRange)?.major === major; - } - return false; -} - -/** A list of all frameworks that are supported, but use a package outside the storybook monorepo */ -export type ExternalFramework = { - name: SupportedFrameworks; - packageName?: string; - frameworks?: string[]; - renderer?: string; -}; - -export const externalFrameworks: ExternalFramework[] = [ - { name: 'qwik', packageName: 'storybook-framework-qwik' }, - { - name: 'solid', - packageName: 'storybook-solidjs-vite', - frameworks: ['storybook-solidjs-vite'], - renderer: 'storybook-solidjs-vite', - }, - { - name: 'nuxt', - packageName: '@storybook-vue/nuxt', - frameworks: ['@storybook-vue/nuxt'], - renderer: '@storybook/vue3', - }, -]; - -export const SUPPORTED_RENDERERS: SupportedRenderers[] = [ - 'react', - 'react-native', - 'vue3', - 'angular', - 'ember', - 'preact', - 'svelte', - 'qwik', - 'solid', -]; - -export enum ProjectType { - UNDETECTED = 'UNDETECTED', - UNSUPPORTED = 'UNSUPPORTED', - REACT = 'REACT', - REACT_SCRIPTS = 'REACT_SCRIPTS', - REACT_NATIVE = 'REACT_NATIVE', - REACT_NATIVE_WEB = 'REACT_NATIVE_WEB', - REACT_NATIVE_AND_RNW = 'REACT_NATIVE_AND_RNW', - REACT_PROJECT = 'REACT_PROJECT', - WEBPACK_REACT = 'WEBPACK_REACT', - NEXTJS = 'NEXTJS', - VUE3 = 'VUE3', - NUXT = 'NUXT', - ANGULAR = 'ANGULAR', - EMBER = 'EMBER', - WEB_COMPONENTS = 'WEB_COMPONENTS', - HTML = 'HTML', - QWIK = 'QWIK', - PREACT = 'PREACT', - SVELTE = 'SVELTE', - SVELTEKIT = 'SVELTEKIT', - SERVER = 'SERVER', - NX = 'NX', - SOLID = 'SOLID', -} - -export enum CoreBuilder { - Webpack5 = 'webpack5', - Vite = 'vite', -} - -export enum CoreWebpackCompilers { - Babel = 'babel', - SWC = 'swc', -} - -export enum CommunityBuilder { - Rsbuild = 'rsbuild', -} - -export const compilerNameToCoreCompiler: Record = { - '@storybook/addon-webpack5-compiler-babel': CoreWebpackCompilers.Babel, - '@storybook/addon-webpack5-compiler-swc': CoreWebpackCompilers.SWC, -}; - -export const builderNameToCoreBuilder: Record = { - '@storybook/builder-webpack5': CoreBuilder.Webpack5, - '@storybook/builder-vite': CoreBuilder.Vite, -}; - -// The `& {}` bit allows for auto-complete, see: https://github.com/microsoft/TypeScript/issues/29729 -export type Builder = CoreBuilder | (string & {}); - -export enum SupportedLanguage { - JAVASCRIPT = 'javascript', - TYPESCRIPT = 'typescript', -} - -export type TemplateMatcher = { - files?: boolean[]; - dependencies?: boolean[]; - peerDependencies?: boolean[]; -}; - -export type TemplateConfiguration = { - preset: ProjectType; - /** Will be checked both against dependencies and devDependencies */ - dependencies?: string[] | { [dependency: string]: (version: string) => boolean }; - peerDependencies?: string[] | { [dependency: string]: (version: string) => boolean }; - files?: string[]; - matcherFunction: (matcher: TemplateMatcher) => boolean; -}; - -/** - * Configuration to match a storybook preset template. - * - * This has to be an array sorted in order of specificity/priority. Reason: both REACT and - * WEBPACK_REACT have react as dependency, therefore WEBPACK_REACT has to come first, as it's more - * specific. - */ -export const supportedTemplates: TemplateConfiguration[] = [ - { - preset: ProjectType.NUXT, - dependencies: ['nuxt'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.VUE3, - dependencies: { - // This Vue template works with Vue 3 - vue: (versionRange) => versionRange === 'next' || eqMajor(versionRange, 3), - }, - matcherFunction: ({ dependencies }) => { - return dependencies?.some(Boolean) ?? false; - }, - }, - { - preset: ProjectType.EMBER, - dependencies: ['ember-cli'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.NEXTJS, - dependencies: ['next'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.QWIK, - dependencies: ['@builder.io/qwik'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.REACT_PROJECT, - peerDependencies: ['react'], - matcherFunction: ({ peerDependencies }) => { - return peerDependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.REACT_NATIVE, - dependencies: ['react-native', 'react-native-scripts'], - matcherFunction: ({ dependencies }) => { - return dependencies?.some(Boolean) ?? false; - }, - }, - { - preset: ProjectType.REACT_SCRIPTS, - // For projects using a custom/forked `react-scripts` package. - files: ['/node_modules/.bin/react-scripts'], - // For standard CRA projects - dependencies: ['react-scripts'], - matcherFunction: ({ dependencies, files }) => { - return (dependencies?.every(Boolean) || files?.every(Boolean)) ?? false; - }, - }, - { - preset: ProjectType.ANGULAR, - dependencies: ['@angular/core'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.WEB_COMPONENTS, - dependencies: ['lit-element', 'lit-html', 'lit'], - matcherFunction: ({ dependencies }) => { - return dependencies?.some(Boolean) ?? false; - }, - }, - { - preset: ProjectType.PREACT, - dependencies: ['preact'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - // TODO: This only works because it is before the SVELTE template. could be more explicit - preset: ProjectType.SVELTEKIT, - dependencies: ['@sveltejs/kit'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.SVELTE, - dependencies: ['svelte'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.SOLID, - dependencies: ['solid-js'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - // DO NOT MOVE ANY TEMPLATES BELOW THIS LINE - // React is part of every Template, after Storybook is initialized once - { - preset: ProjectType.WEBPACK_REACT, - dependencies: ['react', 'webpack'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, - { - preset: ProjectType.REACT, - dependencies: ['react'], - matcherFunction: ({ dependencies }) => { - return dependencies?.every(Boolean) ?? true; - }, - }, -]; - -// A TemplateConfiguration that matches unsupported frameworks -// Framework matchers can be added to this object to give -// users an "Unsupported framework" message -export const unsupportedTemplate: TemplateConfiguration = { - preset: ProjectType.UNSUPPORTED, - dependencies: {}, - matcherFunction: ({ dependencies }) => { - return dependencies?.some(Boolean) ?? false; - }, -}; - -const notInstallableProjectTypes: ProjectType[] = [ - ProjectType.UNDETECTED, - ProjectType.UNSUPPORTED, - ProjectType.NX, -]; - -export const installableProjectTypes = Object.values(ProjectType) - .filter((type) => !notInstallableProjectTypes.includes(type)) - .map((type) => type.toLowerCase()); diff --git a/code/core/src/common/index.ts b/code/core/src/common/index.ts index af3093d20c02..928d44c89e5e 100644 --- a/code/core/src/common/index.ts +++ b/code/core/src/common/index.ts @@ -9,7 +9,7 @@ export * from './utils/cli'; export * from './utils/check-addon-order'; export * from './utils/envs'; export * from './utils/common-glob-options'; -export * from './utils/framework-to-renderer'; +export * from './utils/framework'; export * from './utils/get-builder-options'; export * from './utils/get-framework-name'; export * from './utils/get-renderer-name'; @@ -24,7 +24,6 @@ export * from './utils/interpret-require'; export * from './utils/load-main-config'; export * from './utils/load-manager-or-addons-file'; export * from './utils/load-preview-or-config-file'; -export * from './utils/log'; export * from './utils/log-config'; export * from './utils/normalize-stories'; export * from './utils/paths'; @@ -39,13 +38,16 @@ export * from './utils/satisfies'; export * from './utils/formatter'; export * from './utils/get-story-id'; export * from './utils/posix'; -export * from './utils/get-addon-names'; export * from './utils/sync-main-preview-addons'; +export * from './utils/setup-addon-in-config'; +export * from './utils/wrap-getAbsolutePath-utils'; export * from './js-package-manager'; export * from './utils/scan-and-transform-files'; export * from './utils/transform-imports'; export * from '../shared/utils/module'; +export * from './utils/get-addon-names'; export * from './utils/utils'; +export * from './utils/command'; export { versions }; diff --git a/code/core/src/common/js-package-manager/BUNProxy.ts b/code/core/src/common/js-package-manager/BUNProxy.ts index 8a8b9dcbd6fc..3884c175db0b 100644 --- a/code/core/src/common/js-package-manager/BUNProxy.ts +++ b/code/core/src/common/js-package-manager/BUNProxy.ts @@ -1,15 +1,18 @@ -import { existsSync, readFileSync } from 'node:fs'; -import { platform } from 'node:os'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { logger } from 'storybook/internal/node-logger'; +import { logger, prompt } from 'storybook/internal/node-logger'; import { FindPackageVersionsError } from 'storybook/internal/server-errors'; import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; import sort from 'semver/functions/sort.js'; +import type { ExecuteCommandOptions } from '../utils/command'; +import { executeCommand } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; -import { JsPackageManager } from './JsPackageManager'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; @@ -64,12 +67,12 @@ const NPM_ERROR_CODES = { }; export class BUNProxy extends JsPackageManager { - readonly type = 'bun'; + readonly type = PackageManagerName.BUN; installArgs: string[] | undefined; async initPackageJson() { - return this.executeCommand({ command: 'bun', args: ['init'] }); + return executeCommand({ command: 'bun', args: ['init'] }); } getRunStorybookCommand(): string { @@ -103,31 +106,21 @@ export class BUNProxy extends JsPackageManager { return this.installArgs; } - public runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ): string { - return this.executeCommandSync({ - command: 'bun', - args: ['run', command, ...args], - cwd, - stdio, - }); - } - public runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommand({ - command: 'bun', - args: ['run', command, ...args], - cwd, - stdio, + options: Omit & { args: string[] } + ): ExecaChildProcess { + // The following command is unsafe to use with `bun run` + // because it will always favour a equally script named in the package.json instead of the installed binary. + // so running `bun storybook automigrate` will run the + // `storybook` script (dev) instead of the `storybook`. binary. + // return executeCommand({ + // command: 'bun', + // args: ['run', ...args], + // ...options, + // }); + return executeCommand({ + command: 'bunx', + ...options, }); } @@ -137,15 +130,21 @@ export class BUNProxy extends JsPackageManager { cwd?: string, stdio?: 'inherit' | 'pipe' | 'ignore' ) { - return this.executeCommand({ command: 'bun', args: [command, ...args], cwd, stdio }); + return executeCommand({ + command: 'bun', + args: [command, ...args], + cwd: cwd ?? this.cwd, + stdio, + }); } public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { const exec = async ({ packageDepth }: { packageDepth: number }) => { - const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; - return this.executeCommand({ + return executeCommand({ command: 'npm', - args: ['ls', '--json', `--depth=${packageDepth}`, pipeToNull], + args: ['ls', '--json', `--depth=${packageDepth}`], + cwd: this.cwd, + stdio: ['ignore', 'pipe', 'ignore'], env: { FORCE_COLOR: 'false', }, @@ -170,7 +169,9 @@ export class BUNProxy extends JsPackageManager { return this.mapDependencies(parsedOutput, pattern); } catch (err) { - logger.debug(`An issue occurred while trying to find dependencies metadata using npm.`); + logger.debug( + `An issue occurred while trying to find dependencies metadata using npm: ${err}` + ); return undefined; } } @@ -186,17 +187,18 @@ export class BUNProxy extends JsPackageManager { } protected runInstall(options?: { force?: boolean }) { - return this.executeCommand({ + return executeCommand({ command: 'bun', args: ['install', ...this.getInstallArgs(), ...(options?.force ? ['--force'] : [])], - stdio: 'inherit', cwd: this.cwd, + stdio: prompt.getPreferredStdio(), }); } public async getRegistryURL() { - const process = this.executeCommand({ + const process = executeCommand({ command: 'npm', + cwd: this.cwd, // "npm config" commands are not allowed in workspaces per default // https://github.com/npm/cli/issues/6099#issuecomment-1847584792 args: ['config', 'get', 'registry', '-ws=false', '-iwr'], @@ -213,7 +215,7 @@ export class BUNProxy extends JsPackageManager { args = ['-D', ...args]; } - return this.executeCommand({ + return executeCommand({ command: 'bun', args: ['add', ...args, ...this.getInstallArgs()], stdio: 'pipe', @@ -227,8 +229,9 @@ export class BUNProxy extends JsPackageManager { ): Promise { const args = fetchAllVersions ? ['versions', '--json'] : ['version']; try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'npm', + cwd: this.cwd, args: ['info', packageName, ...args], }); const result = await process; diff --git a/code/core/src/common/js-package-manager/JsPackageManager.test.ts b/code/core/src/common/js-package-manager/JsPackageManager.test.ts index 7c0b0c5e12aa..3156f14cf1ec 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.test.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.test.ts @@ -2,22 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { JsPackageManager } from './JsPackageManager'; +const mockVersions = vi.hoisted(() => ({ + '@storybook/react': '8.3.0', +})); + vi.mock('../versions', () => ({ - default: { - '@storybook/react': '8.3.0', - }, + default: mockVersions, })); describe('JsPackageManager', () => { let jsPackageManager: JsPackageManager; - let mockLatestVersion: ReturnType; + let mockLatestVersion: ReturnType; beforeEach(() => { - mockLatestVersion = vi.fn(); - // @ts-expect-error Ignore abstract class error jsPackageManager = new JsPackageManager(); - jsPackageManager.latestVersion = mockLatestVersion; + // @ts-expect-error latestVersion is a method that exists on the instance + mockLatestVersion = vi.spyOn(jsPackageManager, 'latestVersion'); vi.clearAllMocks(); }); diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index ed4bdd86157b..fdf735b29b75 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -1,33 +1,34 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, normalize, resolve } from 'node:path'; import { logger, prompt } from 'storybook/internal/node-logger'; import * as find from 'empathic/find'; // eslint-disable-next-line depend/ban-dependencies -import { type CommonOptions, type ExecaChildProcess, execa, execaCommandSync } from 'execa'; +import { type ExecaChildProcess } from 'execa'; // eslint-disable-next-line depend/ban-dependencies import { globSync } from 'glob'; import picocolors from 'picocolors'; -import { gt, satisfies } from 'semver'; +import { coerce, gt, satisfies } from 'semver'; import invariant from 'tiny-invariant'; import { HandledError } from '../utils/HandledError'; +import type { ExecuteCommandOptions } from '../utils/command'; import { findFilesUp, getProjectRoot } from '../utils/paths'; import storybookPackagesVersions from '../versions'; import type { PackageJson, PackageJsonWithDepsAndDevDeps } from './PackageJson'; import type { InstallationMetadata } from './types'; -export type PackageManagerName = 'npm' | 'yarn1' | 'yarn2' | 'pnpm' | 'bun'; +export enum PackageManagerName { + NPM = 'npm', + YARN1 = 'yarn', + YARN2 = 'yarn2', + PNPM = 'pnpm', + BUN = 'bun', +} type StorybookPackage = keyof typeof storybookPackagesVersions; -export const COMMON_ENV_VARS = { - COREPACK_ENABLE_STRICT: '0', - COREPACK_ENABLE_AUTO_PIN: '0', - NO_UPDATE_NOTIFIER: 'true', -}; - /** * Extract package name and version from input * @@ -83,6 +84,9 @@ export abstract class JsPackageManager { /** Cache for installed version results to avoid repeated file system calls. */ static readonly installedVersionCache = new Map(); + /** Cache for package.json files to avoid repeated file system calls. */ + static readonly packageJsonCache = new Map(); + constructor(options?: JsPackageManagerOptions) { this.cwd = options?.cwd || process.cwd(); this.instanceDir = options?.configDir @@ -99,11 +103,6 @@ export abstract class JsPackageManager { /** Runs arbitrary package scripts. */ abstract getRunCommand(command: string): string; - /** - * Run a command from a local or remote. Fetches a package from the registry without installing it - * as a dependency, hotloads it, and runs whatever default command binary it exposes. - */ - abstract getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string; /** Get the package.json file for a given module. */ abstract getModulePackageJSON(packageName: string): Promise; @@ -135,10 +134,10 @@ export abstract class JsPackageManager { } async installDependencies(options?: { force?: boolean }) { - await prompt.executeTask(() => this.runInstall(options), { + await prompt.executeTaskWithSpinner(() => this.runInstall(options), { id: 'install-dependencies', intro: 'Installing dependencies...', - error: 'An error occurred while installing dependencies.', + error: 'Installation of dependencies failed!', success: 'Dependencies installed', }); @@ -148,9 +147,9 @@ export abstract class JsPackageManager { async dedupeDependencies(options?: { force?: boolean }) { await prompt.executeTask( - () => this.runInternalCommand('dedupe', [...(options?.force ? ['--force'] : [])], this.cwd), + (_signal) => + this.runInternalCommand('dedupe', [...(options?.force ? ['--force'] : [])], this.cwd), { - id: 'dedupe-dependencies', intro: 'Deduplicating dependencies...', error: 'An error occurred while deduplicating dependencies.', success: 'Dependencies deduplicated', @@ -163,15 +162,34 @@ export abstract class JsPackageManager { /** Read the `package.json` file available in the provided directory */ static getPackageJson(packageJsonPath: string): PackageJsonWithDepsAndDevDeps { - const jsonContent = readFileSync(packageJsonPath, 'utf8'); + // Normalize path to absolute for consistent cache keys + // Always use resolve() to ensure consistent format on Windows + // (handles drive letter casing and path separator differences) + // resolve() normalizes absolute paths too, ensuring consistent cache keys + const absolutePath = normalize(resolve(packageJsonPath)); + + // Check cache first + const cached = JsPackageManager.packageJsonCache.get(absolutePath); + if (cached) { + logger.debug(`Using cached package.json for ${absolutePath}...`); + return cached; + } + + // Read from disk if not in cache + const jsonContent = readFileSync(absolutePath, 'utf8'); const packageJSON = JSON.parse(jsonContent); - return { + const result: PackageJsonWithDepsAndDevDeps = { ...packageJSON, - dependencies: { ...packageJSON.dependencies }, - devDependencies: { ...packageJSON.devDependencies }, - peerDependencies: { ...packageJSON.peerDependencies }, + dependencies: { ...(packageJSON.dependencies || {}) }, + devDependencies: { ...(packageJSON.devDependencies || {}) }, + peerDependencies: { ...(packageJSON.peerDependencies || {}) }, }; + + // Store in cache + JsPackageManager.packageJsonCache.set(absolutePath, result); + + return result; } writePackageJson(packageJson: PackageJson, directory = this.cwd) { @@ -185,8 +203,19 @@ export abstract class JsPackageManager { } }); + const packageJsonPath = normalize(resolve(directory, 'package.json')); const content = `${JSON.stringify(packageJsonToWrite, null, 2)}\n`; - writeFileSync(resolve(directory, 'package.json'), content, 'utf8'); + writeFileSync(packageJsonPath, content, 'utf8'); + + // Update cache with the written content + // Ensure dependencies and devDependencies exist (even if empty) to match PackageJsonWithDepsAndDevDeps type + const cachedPackageJson: PackageJsonWithDepsAndDevDeps = { + ...packageJsonToWrite, + dependencies: { ...(packageJsonToWrite.dependencies || {}) }, + devDependencies: { ...(packageJsonToWrite.devDependencies || {}) }, + peerDependencies: { ...(packageJsonToWrite.peerDependencies || {}) }, + }; + JsPackageManager.packageJsonCache.set(packageJsonPath, cachedPackageJson); } getAllDependencies() { @@ -271,8 +300,8 @@ export abstract class JsPackageManager { return result; } catch (e: any) { - logger.error('\nAn error occurred while installing dependencies:'); - logger.log(e.message); + logger.error('\nAn error occurred while adding dependencies to your package.json:'); + logger.log(String(e)); throw new HandledError(e); } } @@ -572,17 +601,8 @@ export abstract class JsPackageManager { stdio?: 'inherit' | 'pipe' | 'ignore' ): ExecaChildProcess; public abstract runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'inherit' | 'pipe' | 'ignore' + options: Omit & { args: string[] } ): ExecaChildProcess; - public abstract runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'inherit' | 'pipe' | 'ignore' - ): string; public abstract findInstallations(pattern?: string[]): Promise; public abstract findInstallations( pattern?: string[], @@ -590,87 +610,6 @@ export abstract class JsPackageManager { ): Promise; public abstract parseErrorFromLogs(logs?: string): string; - public executeCommandSync({ - command, - args = [], - stdio, - cwd, - ignoreError = false, - env, - ...execaOptions - }: CommonOptions<'utf8'> & { - command: string; - args: string[]; - cwd?: string; - ignoreError?: boolean; - }): string { - try { - const commandResult = execaCommandSync([command, ...args].join(' '), { - cwd: cwd ?? this.cwd, - stdio: stdio ?? 'pipe', - shell: true, - cleanup: true, - env: { - ...COMMON_ENV_VARS, - ...env, - }, - ...execaOptions, - }); - - return commandResult.stdout ?? ''; - } catch (err) { - if (ignoreError !== true) { - throw err; - } - return ''; - } - } - - /** - * Execute a command asynchronously and return the execa process. This allows you to hook into - * stdout/stderr streams and monitor the process. - * - * @example Const process = packageManager.executeCommand({ command: 'npm', args: ['install'] }); - * process.stdout?.on('data', (data) => console.log(data.toString())); const result = await - * process; - */ - public executeCommand({ - command, - args = [], - stdio, - cwd, - ignoreError = false, - env, - ...execaOptions - }: CommonOptions<'utf8'> & { - command: string; - args: string[]; - cwd?: string; - ignoreError?: boolean; - }): ExecaChildProcess { - const execaProcess = execa([command, ...args].join(' '), { - cwd: cwd ?? this.cwd, - stdio: stdio ?? 'pipe', - encoding: 'utf8', - shell: true, - cleanup: true, - env: { - ...COMMON_ENV_VARS, - ...env, - }, - ...execaOptions, - }); - - // If ignoreError is true, catch and suppress errors - if (ignoreError) { - execaProcess.catch((err) => { - // Silently ignore errors when ignoreError is true - }); - } - - return execaProcess; - } - // TODO: Remove pnp compatibility code in SB11 /** Returns the installed (within node_modules or pnp zip) version of a specified package */ public async getInstalledVersion(packageName: string): Promise { @@ -696,10 +635,13 @@ export abstract class JsPackageManager { const version = Object.entries(installations.dependencies)[0]?.[1]?.[0].version || null; + const coercedVersion = coerce(version, { includePrerelease: true })?.toString() ?? version; + + logger.debug(`Installed version for ${packageName}: ${coercedVersion}`); // Cache the result - JsPackageManager.installedVersionCache.set(cacheKey, version); + JsPackageManager.installedVersionCache.set(cacheKey, coercedVersion); - return version; + return coercedVersion; } catch (e) { JsPackageManager.installedVersionCache.set(cacheKey, null); return null; @@ -716,6 +658,7 @@ export abstract class JsPackageManager { * the dependency. */ public getDependencyVersion(dependency: string): string | null { + logger.debug(`Getting dependency version for ${dependency}...`); const dependencyVersion = this.packageJsonPaths .map((path) => { const packageJson = JsPackageManager.getPackageJson(path); @@ -814,6 +757,7 @@ export abstract class JsPackageManager { } static getPackageJsonInfo(packageJsonPath: string): PackageJsonInfo { + logger.debug(`Getting package.json info for ${packageJsonPath}...`); const operationDir = dirname(packageJsonPath); return { packageJsonPath, diff --git a/code/core/src/common/js-package-manager/JsPackageManagerFactory.test.ts b/code/core/src/common/js-package-manager/JsPackageManagerFactory.test.ts index 6616d15b1ad2..50070bfbfc0e 100644 --- a/code/core/src/common/js-package-manager/JsPackageManagerFactory.test.ts +++ b/code/core/src/common/js-package-manager/JsPackageManagerFactory.test.ts @@ -2,9 +2,10 @@ import { join } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { sync as spawnSync } from 'cross-spawn'; import * as find from 'empathic/find'; +import { PackageManagerName } from '.'; +import { executeCommandSync } from '../utils/command'; import { BUNProxy } from './BUNProxy'; import { JsPackageManagerFactory } from './JsPackageManagerFactory'; import { NPMProxy } from './NPMProxy'; @@ -12,8 +13,8 @@ import { PNPMProxy } from './PNPMProxy'; import { Yarn1Proxy } from './Yarn1Proxy'; import { Yarn2Proxy } from './Yarn2Proxy'; -vi.mock('cross-spawn'); -const spawnSyncMock = vi.mocked(spawnSync); +vi.mock('../utils/command', { spy: true }); +const executeCommandSyncMock = vi.mocked(executeCommandSync); vi.mock('empathic/find'); const findMock = vi.mocked(find); @@ -23,16 +24,18 @@ describe('CLASS: JsPackageManagerFactory', () => { JsPackageManagerFactory.clearCache(); findMock.up.mockReturnValue(undefined); findMock.any.mockReturnValue(undefined); - spawnSyncMock.mockReturnValue({ status: 1 } as any); + executeCommandSyncMock.mockImplementation(() => { + throw new Error('Command not found'); + }); delete process.env.npm_config_user_agent; }); describe('METHOD: getPackageManager', () => { describe('NPM proxy', () => { it('FORCE: it should return a NPM proxy when `force` option is `npm`', () => { - expect(JsPackageManagerFactory.getPackageManager({ force: 'npm' })).toBeInstanceOf( - NPMProxy - ); + expect( + JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.NPM }) + ).toBeInstanceOf(NPMProxy); }); it('USER AGENT: it should infer npm from the user agent', () => { @@ -41,32 +44,21 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('ALL EXIST: when all package managers are ok, but only a `package-lock.json` file is found', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); // There is only a package-lock.json @@ -83,9 +75,9 @@ describe('CLASS: JsPackageManagerFactory', () => { describe('PNPM proxy', () => { it('FORCE: it should return a PNPM proxy when `force` option is `pnpm`', () => { - expect(JsPackageManagerFactory.getPackageManager({ force: 'pnpm' })).toBeInstanceOf( - PNPMProxy - ); + expect( + JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.PNPM }) + ).toBeInstanceOf(PNPMProxy); }); it('USER AGENT: it should infer pnpm from the user agent', () => { @@ -94,32 +86,21 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('ALL EXIST: when all package managers are ok, but only a `pnpm-lock.yaml` file is found', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); // There is only a pnpm-lock.yaml @@ -139,32 +120,21 @@ describe('CLASS: JsPackageManagerFactory', () => { (await vi.importActual('empathic/find')).up ); - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); const fixture = join(__dirname, 'fixtures', 'pnpm-workspace', 'package'); expect(JsPackageManagerFactory.getPackageManager({}, fixture)).toBeInstanceOf(PNPMProxy); @@ -173,9 +143,9 @@ describe('CLASS: JsPackageManagerFactory', () => { describe('Yarn 1 proxy', () => { it('FORCE: it should return a Yarn1 proxy when `force` option is `yarn1`', () => { - expect(JsPackageManagerFactory.getPackageManager({ force: 'yarn1' })).toBeInstanceOf( - Yarn1Proxy - ); + expect( + JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.YARN1 }) + ).toBeInstanceOf(Yarn1Proxy); }); it('USER AGENT: it should infer yarn1 from the user agent', () => { @@ -184,30 +154,21 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('when Yarn command is ok and a yarn.lock file is found', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ko - if (command === 'npm --version') { - return { - status: 1, - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + throw new Error('Command not found'); } // PNPM is ko - if (command === 'pnpm --version') { - return { - status: 1, - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + throw new Error('Command not found'); } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); // there is a yarn.lock file @@ -222,32 +183,21 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('when Yarn command is ok, Yarn version is <2, NPM and PNPM are ok, there is a `yarn.lock` file', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); // There is a yarn.lock @@ -267,32 +217,21 @@ describe('CLASS: JsPackageManagerFactory', () => { (await vi.importActual('empathic/find')).up ); - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '1.22.4', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '1.22.4'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); const fixture = join(__dirname, 'fixtures', 'multiple-lockfiles'); expect(JsPackageManagerFactory.getPackageManager({}, fixture)).toBeInstanceOf(Yarn1Proxy); @@ -301,9 +240,9 @@ describe('CLASS: JsPackageManagerFactory', () => { describe('Yarn 2 proxy', () => { it('FORCE: it should return a Yarn2 proxy when `force` option is `yarn2`', () => { - expect(JsPackageManagerFactory.getPackageManager({ force: 'yarn2' })).toBeInstanceOf( - Yarn2Proxy - ); + expect( + JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.YARN2 }) + ).toBeInstanceOf(Yarn2Proxy); }); it('USER AGENT: it should infer yarn2 from the user agent', () => { @@ -312,30 +251,21 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('ONLY YARN 2: when Yarn command is ok, Yarn version is >=2, NPM is ko, PNPM is ko, and a yarn.lock file is found', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '2.0.0-rc.33', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '2.0.0-rc.33'; } // NPM is ko - if (command === 'npm --version') { - return { - status: 1, - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + throw new Error('Command not found'); } // PNPM is ko - if (command === 'pnpm --version') { - return { - status: 1, - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + throw new Error('Command not found'); } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); findMock.up.mockImplementation((filename) => { @@ -349,39 +279,62 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('when Yarn command is ok, Yarn version is >=2, NPM and PNPM are ok, there is a `yarn.lock` file', () => { - spawnSyncMock.mockImplementation((command) => { + executeCommandSyncMock.mockImplementation((options) => { // Yarn is ok - if (command === 'yarn --version') { - return { - status: 0, - output: '2.0.0-rc.33', - }; + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '2.0.0-rc.33'; } // NPM is ok - if (command === 'npm --version') { - return { - status: 0, - output: '6.5.12', - }; + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; } // PNPM is ok - if (command === 'pnpm --version') { - return { - status: 0, - output: '7.9.5', - }; + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; + } + // Unknown package manager is ko + throw new Error('Command not found'); + }); + + // There is a yarn.lock + findMock.up.mockImplementation((filename) => { + if (typeof filename === 'string' && filename === 'yarn.lock') { + return '/Users/johndoe/Documents/yarn.lock'; } + return undefined; + }); + + expect(JsPackageManagerFactory.getPackageManager()).toBeInstanceOf(Yarn2Proxy); + }); + }); + + describe('BUN proxy', () => { + it('FORCE: it should return a BUN proxy when `force` option is `bun`', () => { + expect( + JsPackageManagerFactory.getPackageManager({ force: PackageManagerName.BUN }) + ).toBeInstanceOf(BUNProxy); + }); - if (command === 'bun --version') { - return { - status: 0, - output: '1.0.0', - }; + it('when Bun command is ok, NPM and PNPM are ok, there is a `bun.lockb` file', () => { + executeCommandSyncMock.mockImplementation((options) => { + // Bun is ok + if (options.command === 'bun' && options.args?.[0] === '--version') { + return '1.0.0'; + } + // Yarn is ok + if (options.command === 'yarn' && options.args?.[0] === '--version') { + return '2.0.0-rc.33'; + } + // NPM is ok + if (options.command === 'npm' && options.args?.[0] === '--version') { + return '6.5.12'; + } + // PNPM is ok + if (options.command === 'pnpm' && options.args?.[0] === '--version') { + return '7.9.5'; } // Unknown package manager is ko - return { - status: 1, - } as any; + throw new Error('Command not found'); }); // There is a bun.lockb @@ -397,7 +350,10 @@ describe('CLASS: JsPackageManagerFactory', () => { }); it('throws an error if Yarn, NPM, and PNPM are not found', () => { - spawnSyncMock.mockReturnValue({ status: 1 } as any); + executeCommandSyncMock.mockImplementation(() => { + throw new Error('Command not found'); + }); + findMock.up.mockReturnValue(undefined); expect(() => JsPackageManagerFactory.getPackageManager()).toThrow(); }); }); diff --git a/code/core/src/common/js-package-manager/JsPackageManagerFactory.ts b/code/core/src/common/js-package-manager/JsPackageManagerFactory.ts index d7917cb9909d..ce7243d28027 100644 --- a/code/core/src/common/js-package-manager/JsPackageManagerFactory.ts +++ b/code/core/src/common/js-package-manager/JsPackageManagerFactory.ts @@ -1,12 +1,12 @@ import { basename, parse, relative } from 'node:path'; -import { sync as spawnSync } from 'cross-spawn'; import * as find from 'empathic/find'; +import { executeCommandSync } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; import { BUNProxy } from './BUNProxy'; -import type { JsPackageManager, PackageManagerName } from './JsPackageManager'; -import { COMMON_ENV_VARS } from './JsPackageManager'; +import type { JsPackageManager } from './JsPackageManager'; +import { PackageManagerName } from './JsPackageManager'; import { NPMProxy } from './NPMProxy'; import { PNPMProxy } from './PNPMProxy'; import { Yarn1Proxy } from './Yarn1Proxy'; @@ -87,24 +87,24 @@ export class JsPackageManagerFactory { const yarnVersion = getYarnVersion(cwd); if (yarnVersion && closestLockfile === YARN_LOCKFILE) { - return yarnVersion === 1 ? 'yarn1' : 'yarn2'; + return yarnVersion === 1 ? PackageManagerName.YARN1 : PackageManagerName.YARN2; } if (hasPNPM(cwd) && closestLockfile === PNPM_LOCKFILE) { - return 'pnpm'; + return PackageManagerName.PNPM; } const isNPMCommandOk = hasNPM(cwd); if (isNPMCommandOk && closestLockfile === NPM_LOCKFILE) { - return 'npm'; + return PackageManagerName.NPM; } if ( hasBun(cwd) && (closestLockfile === BUN_LOCKFILE || closestLockfile === BUN_LOCKFILE_BINARY) ) { - return 'bun'; + return PackageManagerName.BUN; } // Option 2: If the user is running a command via npx/pnpx/yarn create/etc, we infer the package manager from the command @@ -116,7 +116,7 @@ export class JsPackageManagerFactory { // Default fallback, whenever users try to use something different than NPM, PNPM, Yarn, // but still have NPM installed if (isNPMCommandOk) { - return 'npm'; + return PackageManagerName.NPM; } throw new Error('Unable to find a usable package manager within NPM, PNPM, Yarn and Yarn 2'); @@ -145,23 +145,27 @@ export class JsPackageManagerFactory { // Option 1: If the user has provided a forcing flag, we use it if (force && force in this.PROXY_MAP) { - const packageManager = new this.PROXY_MAP[force]({ cwd, configDir, storiesPaths }); - this.cache.set(cacheKey, packageManager); - return packageManager; + const packageManager = new this.PROXY_MAP[force]({ + cwd, + configDir, + storiesPaths, + }); + this.cache.set(cacheKey, packageManager as unknown as JsPackageManager); + return packageManager as unknown as JsPackageManager; } // Option 2: Detect package managers based on some heuristics const packageManagerType = this.getPackageManagerType(cwd); const packageManager = new this.PROXY_MAP[packageManagerType]({ cwd, configDir, storiesPaths }); - this.cache.set(cacheKey, packageManager); - return packageManager; + this.cache.set(cacheKey, packageManager as unknown as JsPackageManager); + return packageManager as unknown as JsPackageManager; } /** Look up map of package manager proxies by name */ private static PROXY_MAP: Record = { npm: NPMProxy, pnpm: PNPMProxy, - yarn1: Yarn1Proxy, + yarn: Yarn1Proxy, yarn2: Yarn2Proxy, bun: BUNProxy, }; @@ -178,15 +182,17 @@ export class JsPackageManagerFactory { const [pkgMgrName, pkgMgrVersion] = packageSpec.split('/'); if (pkgMgrName === 'pnpm') { - return 'pnpm'; + return PackageManagerName.PNPM; } if (pkgMgrName === 'npm') { - return 'npm'; + return PackageManagerName.NPM; } if (pkgMgrName === 'yarn') { - return `yarn${pkgMgrVersion?.startsWith('1.') ? '1' : '2'}`; + return pkgMgrVersion?.startsWith('1.') + ? PackageManagerName.YARN1 + : PackageManagerName.YARN2; } } @@ -195,56 +201,60 @@ export class JsPackageManagerFactory { } function hasNPM(cwd?: string) { - const npmVersionCommand = spawnSync('npm --version', { - cwd, - shell: true, - env: { - ...process.env, - ...COMMON_ENV_VARS, - }, - }); - return npmVersionCommand.status === 0; + try { + executeCommandSync({ + command: 'npm', + args: ['--version'], + cwd, + env: process.env, + }); + return true; + } catch (err) { + return false; + } } function hasBun(cwd?: string) { - const pnpmVersionCommand = spawnSync('bun --version', { - cwd, - shell: true, - env: { - ...process.env, - ...COMMON_ENV_VARS, - }, - }); - return pnpmVersionCommand.status === 0; + try { + executeCommandSync({ + command: 'bun', + args: ['--version'], + cwd, + env: process.env, + }); + return true; + } catch (err) { + return false; + } } function hasPNPM(cwd?: string) { - const pnpmVersionCommand = spawnSync('pnpm --version', { - cwd, - shell: true, - env: { - ...process.env, - ...COMMON_ENV_VARS, - }, - }); - return pnpmVersionCommand.status === 0; + try { + executeCommandSync({ + command: 'pnpm', + args: ['--version'], + cwd, + env: process.env, + }); + + return true; + } catch (err) { + return false; + } } function getYarnVersion(cwd?: string): 1 | 2 | undefined { - const yarnVersionCommand = spawnSync('yarn --version', { - cwd, - shell: true, - env: { - ...process.env, - ...COMMON_ENV_VARS, - }, - }); - - if (yarnVersionCommand.status !== 0) { + try { + const yarnVersion = executeCommandSync({ + command: 'yarn', + args: ['--version'], + cwd, + env: { + ...process.env, + }, + }); + return /^1\.+/.test(yarnVersion.trim()) ? 1 : 2; + } catch (err) { return undefined; } - - const yarnVersion = yarnVersionCommand.output.toString().replace(/,/g, '').replace(/"/g, ''); - - return /^1\.+/.test(yarnVersion) ? 1 : 2; } diff --git a/code/core/src/common/js-package-manager/NPMProxy.test.ts b/code/core/src/common/js-package-manager/NPMProxy.test.ts index cff5121f2c29..c5394a9ecb5a 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.test.ts @@ -2,9 +2,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { prompt } from 'storybook/internal/node-logger'; +import { executeCommand } from '../utils/command'; import { JsPackageManager } from './JsPackageManager'; import { NPMProxy } from './NPMProxy'; +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: vi.fn(), + getPreferredStdio: vi.fn(() => 'inherit'), + }, + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock(import('../utils/command'), { spy: true }); + +const mockedExecuteCommand = vi.mocked(executeCommand); + describe('NPM Proxy', () => { let npmProxy: NPMProxy; @@ -22,12 +39,12 @@ describe('NPM Proxy', () => { describe('npm6', () => { it('should run `npm install`', async () => { // sort of un-mock part of the function so executeCommand (also mocked) is called - vi.mocked(prompt.executeTask).mockImplementationOnce(async (fn: any) => { + vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (fn: any) => { await Promise.resolve(fn()); }); - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '6.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '6.0.0', + } as any); await npmProxy.installDependencies(); @@ -39,12 +56,12 @@ describe('NPM Proxy', () => { describe('npm7', () => { it('should run `npm install`', async () => { // sort of un-mock part of the function so executeCommand (also mocked) is called - vi.mocked(prompt.executeTask).mockImplementationOnce(async (fn: any) => { + vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (fn: any) => { await Promise.resolve(fn()); }); - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.1.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '7.1.0', + } as any); await npmProxy.installDependencies(); @@ -58,32 +75,36 @@ describe('NPM Proxy', () => { describe('runScript', () => { describe('npm6', () => { it('should execute script `npm exec -- compodoc -e json -d .`', () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '6.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '6.0.0', + } as any); - npmProxy.runPackageCommand('compodoc', ['-e', 'json', '-d', '.']); + npmProxy.runPackageCommand({ + args: ['compodoc', '-e', 'json', '-d', '.'], + }); expect(executeCommandSpy).toHaveBeenCalledWith( expect.objectContaining({ - command: 'npm', - args: ['exec', '--', 'compodoc', '-e', 'json', '-d', '.'], + command: 'npx', + args: ['compodoc', '-e', 'json', '-d', '.'], }) ); }); }); describe('npm7', () => { it('should execute script `npm run compodoc -- -e json -d .`', () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.1.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '7.1.0', + } as any); - npmProxy.runPackageCommand('compodoc', ['-e', 'json', '-d', '.']); + npmProxy.runPackageCommand({ + args: ['compodoc', '-e', 'json', '-d', '.'], + }); expect(executeCommandSpy).toHaveBeenCalledWith( expect.objectContaining({ - command: 'npm', - args: ['exec', '--', 'compodoc', '-e', 'json', '-d', '.'], + command: 'npx', + args: ['compodoc', '-e', 'json', '-d', '.'], }) ); }); @@ -93,9 +114,9 @@ describe('NPM Proxy', () => { describe('addDependencies', () => { describe('npm6', () => { it('with devDep it should run `npm install -D storybook`', async () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '6.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '6.0.0', + } as any); await npmProxy.addDependencies({ type: 'devDependencies' }, ['storybook']); @@ -109,9 +130,9 @@ describe('NPM Proxy', () => { }); describe('npm7', () => { it('with devDep it should run `npm install -D storybook`', async () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '7.0.0', + } as any); await npmProxy.addDependencies({ type: 'devDependencies' }, ['storybook']); @@ -128,9 +149,9 @@ describe('NPM Proxy', () => { describe('removeDependencies', () => { describe('skipInstall', () => { it('should only change package.json without running install', async () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '7.0.0', + } as any); vi.spyOn(npmProxy, 'packageJsonPaths', 'get').mockImplementation(() => ['package.json']); @@ -163,9 +184,7 @@ describe('NPM Proxy', () => { describe('latestVersion', () => { it('without constraint it returns the latest version', async () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '5.3.19' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); const version = await npmProxy.latestVersion('storybook'); @@ -179,9 +198,9 @@ describe('NPM Proxy', () => { }); it('with constraint it returns the latest version satisfying the constraint', async () => { - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '["4.25.3","5.3.19","6.0.0-beta.23"]' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '["4.25.3","5.3.19","6.0.0-beta.23"]', + } as any); const version = await npmProxy.latestVersion('storybook', '5.X'); @@ -195,7 +214,7 @@ describe('NPM Proxy', () => { }); it('with constraint it throws an error if command output is not a valid JSON', async () => { - vi.spyOn(npmProxy, 'executeCommand').mockResolvedValue({ stdout: 'NOT A JSON' } as any); + mockedExecuteCommand.mockResolvedValue({ stdout: 'NOT A JSON' } as any); await expect(npmProxy.latestVersion('storybook', '5.X')).resolves.toBe(null); }); @@ -204,9 +223,7 @@ describe('NPM Proxy', () => { describe('getVersion', () => { it('with a Storybook package listed in versions.json it returns the version', async () => { const storybookAngularVersion = (await import('../versions')).default['@storybook/angular']; - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '5.3.19' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); const version = await npmProxy.getVersion('@storybook/angular'); @@ -221,9 +238,9 @@ describe('NPM Proxy', () => { it('with a Storybook package not listed in versions.json it returns the latest version', async () => { const packageVersion = '5.3.19'; - const executeCommandSpy = vi - .spyOn(npmProxy, 'executeCommand') - .mockResolvedValue({ stdout: `${packageVersion}` } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: `${packageVersion}`, + } as any); const version = await npmProxy.getVersion('@storybook/react-native'); @@ -271,7 +288,7 @@ describe('NPM Proxy', () => { describe('mapDependencies', () => { it('should display duplicated dependencies based on npm output', async () => { // npm ls --depth 10 --json - vi.spyOn(npmProxy, 'executeCommand').mockResolvedValue({ + mockedExecuteCommand.mockResolvedValue({ stdout: ` { "dependencies": { diff --git a/code/core/src/common/js-package-manager/NPMProxy.ts b/code/core/src/common/js-package-manager/NPMProxy.ts index cad78dfc2471..7444b64f7aea 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.ts @@ -6,10 +6,14 @@ import { logger, prompt } from 'storybook/internal/node-logger'; import { FindPackageVersionsError } from 'storybook/internal/server-errors'; import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; import sort from 'semver/functions/sort.js'; +import type { ExecuteCommandOptions } from '../utils/command'; +import { executeCommand } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; -import { JsPackageManager } from './JsPackageManager'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; @@ -64,7 +68,7 @@ const NPM_ERROR_CODES = { }; export class NPMProxy extends JsPackageManager { - readonly type = 'npm'; + readonly type = PackageManagerName.NPM; installArgs: string[] | undefined; @@ -72,10 +76,6 @@ export class NPMProxy extends JsPackageManager { return `npm run ${command}`; } - getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string { - return `npx ${pkg}${specifier ? `@${specifier}` : ''} ${args.join(' ')}`; - } - async getModulePackageJSON(packageName: string): Promise { const wantedPath = join('node_modules', packageName, 'package.json'); const packageJsonPath = find.up(wantedPath, { cwd: this.cwd, last: getProjectRoot() }); @@ -95,31 +95,12 @@ export class NPMProxy extends JsPackageManager { return this.installArgs; } - public runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ): string { - return this.executeCommandSync({ - command: 'npm', - args: ['exec', '--', command, ...args], - cwd, - stdio, - }); - } - public runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommand({ - command: 'npm', - args: ['exec', '--', command, ...args], - cwd, - stdio, + options: Omit & { args: string[] } + ): ExecaChildProcess { + return executeCommand({ + command: 'npx', + ...options, }); } @@ -129,10 +110,10 @@ export class NPMProxy extends JsPackageManager { cwd?: string, stdio?: 'inherit' | 'pipe' | 'ignore' ) { - return this.executeCommand({ + return executeCommand({ command: 'npm', args: [command, ...args], - cwd, + cwd: cwd ?? this.cwd, stdio, }); } @@ -140,7 +121,7 @@ export class NPMProxy extends JsPackageManager { public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { const exec = ({ packageDepth }: { packageDepth: number }) => { const pipeToNull = platform() === 'win32' ? '2>NUL' : '2>/dev/null'; - return this.executeCommand({ + return executeCommand({ command: 'npm', args: ['ls', '--json', `--depth=${packageDepth}`, pipeToNull], env: { @@ -166,7 +147,9 @@ export class NPMProxy extends JsPackageManager { return this.mapDependencies(parsedOutput, pattern); } catch (err) { - logger.debug(`An issue occurred while trying to find dependencies metadata using npm.`); + logger.debug( + `An issue occurred while trying to find dependencies metadata using npm: ${err}` + ); return undefined; } } @@ -182,7 +165,7 @@ export class NPMProxy extends JsPackageManager { } protected runInstall(options?: { force?: boolean }) { - return this.executeCommand({ + return executeCommand({ command: 'npm', args: ['install', ...this.getInstallArgs(), ...(options?.force ? ['--force'] : [])], cwd: this.cwd, @@ -191,7 +174,7 @@ export class NPMProxy extends JsPackageManager { } public async getRegistryURL() { - const process = this.executeCommand({ + const process = executeCommand({ command: 'npm', // "npm config" commands are not allowed in workspaces per default // https://github.com/npm/cli/issues/6099#issuecomment-1847584792 @@ -209,7 +192,7 @@ export class NPMProxy extends JsPackageManager { args = ['-D', ...args]; } - return this.executeCommand({ + return executeCommand({ command: 'npm', args: ['install', ...args, ...this.getInstallArgs()], stdio: prompt.getPreferredStdio(), @@ -223,7 +206,7 @@ export class NPMProxy extends JsPackageManager { ): Promise { const args = fetchAllVersions ? ['versions', '--json'] : ['version']; try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'npm', args: ['info', packageName, ...args], }); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.test.ts b/code/core/src/common/js-package-manager/PNPMProxy.test.ts index 5c194a69c2df..6926096dffaf 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.test.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.test.ts @@ -2,9 +2,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { prompt } from 'storybook/internal/node-logger'; +import { executeCommand } from '../utils/command'; import { JsPackageManager } from './JsPackageManager'; import { PNPMProxy } from './PNPMProxy'; +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: vi.fn(), + getPreferredStdio: vi.fn(() => 'inherit'), + }, + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock(import('../utils/command'), { spy: true }); +const mockedExecuteCommand = vi.mocked(executeCommand); + describe('PNPM Proxy', () => { let pnpmProxy: PNPMProxy; @@ -21,12 +37,10 @@ describe('PNPM Proxy', () => { describe('installDependencies', () => { it('should run `pnpm install`', async () => { // sort of un-mock part of the function so executeCommand (also mocked) is called - vi.mocked(prompt.executeTask).mockImplementationOnce(async (fn: any) => { + vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (fn: any) => { await Promise.resolve(fn()); }); - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.1.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '7.1.0' } as any); await pnpmProxy.installDependencies(); @@ -38,11 +52,9 @@ describe('PNPM Proxy', () => { describe('runScript', () => { it('should execute script `pnpm exec compodoc -- -e json -d .`', () => { - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.1.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '7.1.0' } as any); - pnpmProxy.runPackageCommand('compodoc', ['-e', 'json', '-d', '.']); + pnpmProxy.runPackageCommand({ args: ['compodoc', '-e', 'json', '-d', '.'] }); expect(executeCommandSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -55,9 +67,7 @@ describe('PNPM Proxy', () => { describe('addDependencies', () => { it('with devDep it should run `pnpm add -D storybook`', async () => { - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '6.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '6.0.0' } as any); await pnpmProxy.addDependencies({ type: 'devDependencies' }, ['storybook']); @@ -72,9 +82,7 @@ describe('PNPM Proxy', () => { describe('removeDependencies', () => { it('should only change package.json without running install', async () => { - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '7.0.0' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '7.0.0' } as any); const writePackageSpy = vi.spyOn(pnpmProxy, 'writePackageJson').mockImplementation(vi.fn()); vi.spyOn(JsPackageManager, 'getPackageJson').mockImplementation((args) => { @@ -104,9 +112,7 @@ describe('PNPM Proxy', () => { describe('latestVersion', () => { it('without constraint it returns the latest version', async () => { - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '5.3.19' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); const version = await pnpmProxy.latestVersion('storybook'); @@ -120,9 +126,9 @@ describe('PNPM Proxy', () => { }); it('with constraint it returns the latest version satisfying the constraint', async () => { - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '["4.25.3","5.3.19","6.0.0-beta.23"]' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: '["4.25.3","5.3.19","6.0.0-beta.23"]', + } as any); const version = await pnpmProxy.latestVersion('storybook', '5.X'); @@ -136,7 +142,7 @@ describe('PNPM Proxy', () => { }); it('with constraint it throws an error if command output is not a valid JSON', async () => { - vi.spyOn(pnpmProxy, 'executeCommand').mockResolvedValue({ stdout: 'NOT A JSON' } as any); + mockedExecuteCommand.mockResolvedValue({ stdout: 'NOT A JSON' } as any); await expect(pnpmProxy.latestVersion('storybook', '5.X')).resolves.toBe(null); }); @@ -145,9 +151,7 @@ describe('PNPM Proxy', () => { describe('getVersion', () => { it('with a Storybook package listed in versions.json it returns the version', async () => { const storybookAngularVersion = (await import('../versions')).default['@storybook/angular']; - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: '5.3.19' } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '5.3.19' } as any); const version = await pnpmProxy.getVersion('@storybook/angular'); @@ -162,9 +166,9 @@ describe('PNPM Proxy', () => { it('with a Storybook package not listed in versions.json it returns the latest version', async () => { const packageVersion = '5.3.19'; - const executeCommandSpy = vi - .spyOn(pnpmProxy, 'executeCommand') - .mockResolvedValue({ stdout: `${packageVersion}` } as any); + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ + stdout: `${packageVersion}`, + } as any); const version = await pnpmProxy.getVersion('@storybook/react-native'); @@ -216,7 +220,7 @@ describe('PNPM Proxy', () => { describe('mapDependencies', () => { it('should display duplicated dependencies based on pnpm output', async () => { // pnpm list "@storybook/*" "storybook" --depth 10 --json - vi.spyOn(pnpmProxy, 'executeCommand').mockResolvedValue({ + mockedExecuteCommand.mockResolvedValue({ stdout: ` [ { diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index b6c37b27f33a..4eb2122c1f83 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -6,9 +6,13 @@ import { prompt } from 'storybook/internal/node-logger'; import { FindPackageVersionsError } from 'storybook/internal/server-errors'; import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; +import type { ExecuteCommandOptions } from '../utils/command'; +import { executeCommand } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; -import { JsPackageManager } from './JsPackageManager'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; @@ -34,7 +38,7 @@ export type PnpmListOutput = PnpmListItem[]; const PNPM_ERROR_REGEX = /(ELIFECYCLE|ERR_PNPM_[A-Z_]+)\s+(.*)/i; export class PNPMProxy extends JsPackageManager { - readonly type = 'pnpm'; + readonly type = PackageManagerName.PNPM; installArgs: string[] | undefined; @@ -49,12 +53,9 @@ export class PNPMProxy extends JsPackageManager { return `pnpm run ${command}`; } - getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string { - return `pnpm dlx ${pkg}${specifier ? `@${specifier}` : ''} ${args.join(' ')}`; - } - async getPnpmVersion(): Promise { - const result = await this.executeCommand({ + const result = await executeCommand({ + cwd: this.cwd, command: 'pnpm', args: ['--version'], }); @@ -72,31 +73,14 @@ export class PNPMProxy extends JsPackageManager { return this.installArgs; } - public runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ): string { - return this.executeCommandSync({ - command: 'pnpm', - args: ['exec', command, ...args], - cwd, - stdio, - }); - } - - public runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommand({ + public runPackageCommand({ + args, + ...options + }: Omit & { args: string[] }): ExecaChildProcess { + return executeCommand({ command: 'pnpm', - args: ['exec', command, ...args], - cwd, - stdio, + args: ['exec', ...args], + ...options, }); } @@ -106,16 +90,16 @@ export class PNPMProxy extends JsPackageManager { cwd?: string, stdio?: 'inherit' | 'pipe' | 'ignore' ) { - return this.executeCommand({ + return executeCommand({ command: 'pnpm', args: [command, ...args], - cwd, + cwd: cwd ?? this.cwd, stdio, }); } public async getRegistryURL() { - const childProcess = await this.executeCommand({ + const childProcess = await executeCommand({ command: 'pnpm', args: ['config', 'get', 'registry'], }); @@ -125,7 +109,7 @@ export class PNPMProxy extends JsPackageManager { public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { try { - const childProcess = await this.executeCommand({ + const childProcess = await executeCommand({ command: 'pnpm', args: ['list', pattern.map((p) => `"${p}"`).join(' '), '--json', `--depth=${depth}`], env: { @@ -193,7 +177,7 @@ export class PNPMProxy extends JsPackageManager { } protected runInstall(options?: { force?: boolean }) { - return this.executeCommand({ + return executeCommand({ command: 'pnpm', args: ['install', ...this.getInstallArgs(), ...(options?.force ? ['--force'] : [])], stdio: prompt.getPreferredStdio(), @@ -210,7 +194,7 @@ export class PNPMProxy extends JsPackageManager { const commandArgs = ['add', ...args, ...this.getInstallArgs()]; - return this.executeCommand({ + return executeCommand({ command: 'pnpm', args: commandArgs, stdio: prompt.getPreferredStdio(), @@ -225,7 +209,7 @@ export class PNPMProxy extends JsPackageManager { const args = fetchAllVersions ? ['versions', '--json'] : ['version']; try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'pnpm', args: ['info', packageName, ...args], }); diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts index edeb7b932a44..cdce47483712 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.test.ts @@ -4,9 +4,25 @@ import { prompt } from 'storybook/internal/node-logger'; import { dedent } from 'ts-dedent'; -import { JsPackageManager } from './JsPackageManager'; +import { executeCommand } from '../utils/command'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import { Yarn1Proxy } from './Yarn1Proxy'; +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: vi.fn(), + getPreferredStdio: vi.fn(() => 'inherit'), + }, + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock(import('../utils/command'), { spy: true }); +const mockedExecuteCommand = vi.mocked(executeCommand); + vi.mock('node:process', async (importOriginal) => { const original: any = await importOriginal(); return { @@ -31,18 +47,18 @@ describe('Yarn 1 Proxy', () => { }); it('type should be yarn1', () => { - expect(yarn1Proxy.type).toEqual('yarn1'); + expect(yarn1Proxy.type).toEqual(PackageManagerName.YARN1); }); describe('installDependencies', () => { it('should run `yarn`', async () => { // sort of un-mock part of the function so executeCommand (also mocked) is called - vi.mocked(prompt.executeTask).mockImplementationOnce(async (fn: any) => { + vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (fn: any) => { await Promise.resolve(fn()); }); - const executeCommandSpy = vi - .spyOn(yarn1Proxy, 'executeCommand') - .mockReturnValue(Promise.resolve({ stdout: '' }) as any); + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( + Promise.resolve({ stdout: '' }) as any + ); await yarn1Proxy.installDependencies(); @@ -57,16 +73,16 @@ describe('Yarn 1 Proxy', () => { describe('runScript', () => { it('should execute script `yarn compodoc -- -e json -d .`', () => { - const executeCommandSpy = vi - .spyOn(yarn1Proxy, 'executeCommand') - .mockReturnValue(Promise.resolve({ stdout: '7.1.0' }) as any); + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( + Promise.resolve({ stdout: '7.1.0' }) as any + ); - yarn1Proxy.runPackageCommand('compodoc', ['-e', 'json', '-d', '.']); + yarn1Proxy.runPackageCommand({ args: ['compodoc', '-e', 'json', '-d', '.'] }); expect(executeCommandSpy).toHaveBeenLastCalledWith( expect.objectContaining({ command: 'yarn', - args: ['exec', 'compodoc', '-e', 'json', '-d', '.'], + args: ['exec', 'compodoc', '--', '-e', 'json', '-d', '.'], }) ); }); @@ -74,9 +90,9 @@ describe('Yarn 1 Proxy', () => { describe('addDependencies', () => { it('with devDep it should run `yarn install -D --ignore-workspace-root-check storybook`', async () => { - const executeCommandSpy = vi - .spyOn(yarn1Proxy, 'executeCommand') - .mockReturnValue(Promise.resolve({ stdout: '' }) as any); + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( + Promise.resolve({ stdout: '' }) as any + ); await yarn1Proxy.addDependencies({ type: 'devDependencies' }, ['storybook']); @@ -91,9 +107,9 @@ describe('Yarn 1 Proxy', () => { describe('removeDependencies', () => { it('skipInstall should only change package.json without running install', async () => { - const executeCommandSpy = vi - .spyOn(yarn1Proxy, 'executeCommand') - .mockReturnValue(Promise.resolve({ stdout: '7.0.0' }) as any); + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( + Promise.resolve({ stdout: '7.0.0' }) as any + ); const writePackageSpy = vi.spyOn(yarn1Proxy, 'writePackageJson').mockImplementation(vi.fn()); vi.spyOn(JsPackageManager, 'getPackageJson').mockImplementation((args) => { @@ -123,9 +139,9 @@ describe('Yarn 1 Proxy', () => { describe('latestVersion', () => { it('without constraint it returns the latest version', async () => { - const executeCommandSpy = vi - .spyOn(yarn1Proxy, 'executeCommand') - .mockReturnValue(Promise.resolve({ stdout: '{"type":"inspect","data":"5.3.19"}' }) as any); + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( + Promise.resolve({ stdout: '{"type":"inspect","data":"5.3.19"}' }) as any + ); const version = await yarn1Proxy.latestVersion('storybook'); @@ -139,7 +155,7 @@ describe('Yarn 1 Proxy', () => { }); it('with constraint it returns the latest version satisfying the constraint', async () => { - const executeCommandSpy = vi.spyOn(yarn1Proxy, 'executeCommand').mockReturnValue( + const executeCommandSpy = mockedExecuteCommand.mockReturnValue( Promise.resolve({ stdout: '{"type":"inspect","data":["4.25.3","5.3.19","6.0.0-beta.23"]}', }) as any @@ -157,9 +173,7 @@ describe('Yarn 1 Proxy', () => { }); it('throws an error if command output is not a valid JSON', async () => { - vi.spyOn(yarn1Proxy, 'executeCommand').mockReturnValue( - Promise.resolve({ stdout: 'NOT A JSON' }) as any - ); + mockedExecuteCommand.mockReturnValue(Promise.resolve({ stdout: 'NOT A JSON' }) as any); await expect(yarn1Proxy.latestVersion('storybook')).resolves.toBe(null); }); @@ -199,7 +213,7 @@ describe('Yarn 1 Proxy', () => { describe('mapDependencies', () => { it('should display duplicated dependencies based on yarn output', async () => { // yarn list --pattern "@storybook/*" "@storybook/react" --recursive --json - vi.spyOn(yarn1Proxy, 'executeCommand').mockResolvedValueOnce({ + mockedExecuteCommand.mockResolvedValueOnce({ stdout: ` { "type": "tree", diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.ts index c03c173b61c0..a13d7a13a9ed 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.ts @@ -6,9 +6,13 @@ import { prompt } from 'storybook/internal/node-logger'; import { FindPackageVersionsError } from 'storybook/internal/server-errors'; import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; +import type { ExecuteCommandOptions } from '../utils/command'; +import { executeCommand } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; -import { JsPackageManager } from './JsPackageManager'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; import { parsePackageData } from './util'; @@ -31,7 +35,7 @@ export type Yarn1ListOutput = { const YARN1_ERROR_REGEX = /^error\s(.*)$/gm; export class Yarn1Proxy extends JsPackageManager { - readonly type = 'yarn1'; + readonly type = PackageManagerName.YARN1; installArgs: string[] | undefined; @@ -46,40 +50,30 @@ export class Yarn1Proxy extends JsPackageManager { return `yarn ${command}`; } - getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string { - return `npx ${pkg}${specifier ? `@${specifier}` : ''} ${args.join(' ')}`; - } - - public runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ): string { - return this.executeCommandSync({ + public runPackageCommand({ + args, + ...options + }: Omit & { args: string[] }): ExecaChildProcess { + const [command, ...rest] = args; + return executeCommand({ command: `yarn`, - args: ['exec', command, ...args], - cwd, - stdio, + args: ['exec', command, '--', ...rest], + ...options, }); } - public runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommand({ command: `yarn`, args: ['exec', command, ...args], cwd, stdio }); - } - public runInternalCommand( command: string, args: string[], cwd?: string, stdio?: 'inherit' | 'pipe' | 'ignore' ) { - return this.executeCommand({ command: `yarn`, args: [command, ...args], cwd, stdio }); + return executeCommand({ + command: `yarn`, + args: [command, ...args], + cwd: cwd ?? this.cwd, + stdio, + }); } public async getModulePackageJSON(packageName: string): Promise { @@ -94,7 +88,7 @@ export class Yarn1Proxy extends JsPackageManager { } public async getRegistryURL() { - const childProcess = await this.executeCommand({ + const childProcess = await executeCommand({ command: 'yarn', args: ['config', 'get', 'registry'], }); @@ -110,7 +104,7 @@ export class Yarn1Proxy extends JsPackageManager { } try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'yarn', args: yarnArgs.concat(pattern), env: { @@ -138,7 +132,7 @@ export class Yarn1Proxy extends JsPackageManager { } protected runInstall(options?: { force?: boolean }) { - return this.executeCommand({ + return executeCommand({ command: 'yarn', args: ['install', ...this.getInstallArgs(), ...(options?.force ? ['--force'] : [])], stdio: prompt.getPreferredStdio(), @@ -153,7 +147,7 @@ export class Yarn1Proxy extends JsPackageManager { args = ['-D', ...args]; } - return this.executeCommand({ + return executeCommand({ command: 'yarn', args: ['add', ...this.getInstallArgs(), ...args], stdio: prompt.getPreferredStdio(), @@ -167,7 +161,7 @@ export class Yarn1Proxy extends JsPackageManager { ): Promise { const args = [fetchAllVersions ? 'versions' : 'version', '--json']; try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'yarn', args: ['info', packageName, ...args], }); diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts index 5afe867b2099..529b131c69cb 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.test.ts @@ -2,9 +2,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { prompt } from 'storybook/internal/node-logger'; +import { executeCommand } from '../utils/command'; import { JsPackageManager } from './JsPackageManager'; import { Yarn2Proxy } from './Yarn2Proxy'; +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + executeTaskWithSpinner: vi.fn(), + getPreferredStdio: vi.fn(() => 'inherit'), + }, + logger: { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../utils/command', { spy: true }); +const mockedExecuteCommand = vi.mocked(executeCommand); + describe('Yarn 2 Proxy', () => { let yarn2Proxy: Yarn2Proxy; @@ -12,7 +28,6 @@ describe('Yarn 2 Proxy', () => { yarn2Proxy = new Yarn2Proxy(); JsPackageManager.clearLatestVersionCache(); vi.spyOn(yarn2Proxy, 'writePackageJson').mockImplementation(vi.fn()); - vi.spyOn(yarn2Proxy, 'executeCommand').mockClear(); }); it('type should be yarn2', () => { @@ -22,10 +37,10 @@ describe('Yarn 2 Proxy', () => { describe('installDependencies', () => { it('should run `yarn`', async () => { // sort of un-mock part of the function so executeCommand (also mocked) is called - vi.mocked(prompt.executeTask).mockImplementationOnce(async (fn: any) => { + vi.mocked(prompt.executeTaskWithSpinner).mockImplementationOnce(async (fn: any) => { await Promise.resolve(fn()); }); - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '', } as any); @@ -39,11 +54,11 @@ describe('Yarn 2 Proxy', () => { describe('runScript', () => { it('should execute script `yarn compodoc -- -e json -d .`', async () => { - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '7.1.0', } as any); - await yarn2Proxy.runPackageCommand('compodoc', ['-e', 'json', '-d', '.']); + await yarn2Proxy.runPackageCommand({ args: ['compodoc', '-e', 'json', '-d', '.'] }); expect(executeCommandSpy).toHaveBeenLastCalledWith( expect.objectContaining({ @@ -56,7 +71,7 @@ describe('Yarn 2 Proxy', () => { describe('addDependencies', () => { it('with devDep it should run `yarn install -D storybook`', async () => { - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '', } as any); @@ -73,7 +88,7 @@ describe('Yarn 2 Proxy', () => { describe('removeDependencies', () => { it('skipInstall should only change package.json without running install', async () => { - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '7.0.0', } as any); const writePackageSpy = vi.spyOn(yarn2Proxy, 'writePackageJson').mockImplementation(vi.fn()); @@ -105,7 +120,7 @@ describe('Yarn 2 Proxy', () => { describe('latestVersion', () => { it('without constraint it returns the latest version', async () => { - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '{"name":"storybook","version":"5.3.19"}', } as any); @@ -121,7 +136,7 @@ describe('Yarn 2 Proxy', () => { }); it('with constraint it returns the latest version satisfying the constraint', async () => { - const executeCommandSpy = vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + const executeCommandSpy = mockedExecuteCommand.mockResolvedValue({ stdout: '{"name":"storybook","versions":["4.25.3","5.3.19","6.0.0-beta.23"]}', } as any); @@ -137,7 +152,7 @@ describe('Yarn 2 Proxy', () => { }); it('throws an error if command output is not a valid JSON', async () => { - vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + mockedExecuteCommand.mockResolvedValue({ stdout: 'NOT A JSON', } as any); @@ -180,7 +195,7 @@ describe('Yarn 2 Proxy', () => { describe('mapDependencies', () => { it('should display duplicated dependencies based on yarn2 output', async () => { // yarn info --name-only --recursive "@storybook/*" "storybook" - vi.spyOn(yarn2Proxy, 'executeCommand').mockResolvedValue({ + mockedExecuteCommand.mockResolvedValue({ stdout: ` "unrelated-and-should-be-filtered@npm:1.0.0" "@storybook/global@npm:5.0.0" diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index 14fe5bc75cf3..a8d5975cec0a 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -8,9 +8,14 @@ import { FindPackageVersionsError } from 'storybook/internal/server-errors'; import { PosixFS, VirtualFS, ZipOpenFS } from '@yarnpkg/fslib'; import { getLibzipSync } from '@yarnpkg/libzip'; import * as find from 'empathic/find'; +// eslint-disable-next-line depend/ban-dependencies +import type { ExecaChildProcess } from 'execa'; +import { logger } from '../../node-logger'; +import type { ExecuteCommandOptions } from '../utils/command'; +import { executeCommand } from '../utils/command'; import { getProjectRoot } from '../utils/paths'; -import { JsPackageManager } from './JsPackageManager'; +import { JsPackageManager, PackageManagerName } from './JsPackageManager'; import type { PackageJson } from './PackageJson'; import type { InstallationMetadata, PackageMetadata } from './types'; import { parsePackageData } from './util'; @@ -74,7 +79,7 @@ const CRITICAL_YARN2_ERROR_CODES = { // This encompasses Yarn Berry (v2+) export class Yarn2Proxy extends JsPackageManager { - readonly type = 'yarn2'; + readonly type = PackageManagerName.YARN2; installArgs: string[] | undefined; @@ -89,40 +94,29 @@ export class Yarn2Proxy extends JsPackageManager { return `yarn ${command}`; } - getRemoteRunCommand(pkg: string, args: string[], specifier?: string): string { - return `yarn dlx ${pkg}${specifier ? `@${specifier}` : ''} ${args.join(' ')}`; - } - - public runPackageCommandSync( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommandSync({ + public runPackageCommand({ + args, + ...options + }: Omit & { args: string[] }): ExecaChildProcess { + return executeCommand({ command: 'yarn', - args: ['exec', command, ...args], - cwd, - stdio, + args: ['exec', ...args], + ...options, }); } - public runPackageCommand( - command: string, - args: string[], - cwd?: string, - stdio?: 'pipe' | 'inherit' - ) { - return this.executeCommand({ command: 'yarn', args: ['exec', command, ...args], cwd, stdio }); - } - public runInternalCommand( command: string, args: string[], cwd?: string, stdio?: 'inherit' | 'pipe' | 'ignore' ) { - return this.executeCommand({ command: 'yarn', args: [command, ...args], cwd, stdio }); + return executeCommand({ + command: 'yarn', + args: [command, ...args], + cwd: cwd ?? this.cwd, + stdio, + }); } public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { @@ -133,7 +127,7 @@ export class Yarn2Proxy extends JsPackageManager { } try { - const childProcess = await this.executeCommand({ + const childProcess = await executeCommand({ command: 'yarn', args: yarnArgs.concat(pattern), env: { @@ -143,6 +137,8 @@ export class Yarn2Proxy extends JsPackageManager { }); const commandResult = childProcess.stdout ?? ''; + logger.debug(`Installation found for ${pattern.join(', ')}: ${commandResult}`); + return this.mapDependencies(commandResult, pattern); } catch (e) { return undefined; @@ -222,10 +218,11 @@ export class Yarn2Proxy extends JsPackageManager { } protected runInstall() { - return this.executeCommand({ + return executeCommand({ command: 'yarn', args: ['install', ...this.getInstallArgs()], cwd: this.cwd, + stdio: prompt.getPreferredStdio(), }); } @@ -236,7 +233,7 @@ export class Yarn2Proxy extends JsPackageManager { args = ['-D', ...args]; } - return this.executeCommand({ + return executeCommand({ command: 'yarn', args: ['add', ...this.getInstallArgs(), ...args], stdio: prompt.getPreferredStdio(), @@ -245,7 +242,7 @@ export class Yarn2Proxy extends JsPackageManager { } public async getRegistryURL() { - const process = this.executeCommand({ + const process = executeCommand({ command: 'yarn', args: ['config', 'get', 'npmRegistryServer'], }); @@ -261,7 +258,7 @@ export class Yarn2Proxy extends JsPackageManager { const field = fetchAllVersions ? 'versions' : 'version'; const args = ['--fields', field, '--json']; try { - const process = this.executeCommand({ + const process = executeCommand({ command: 'yarn', args: ['npm', 'info', packageName, ...args], }); @@ -286,6 +283,7 @@ export class Yarn2Proxy extends JsPackageManager { const duplicatedDependencies: Record = {}; lines.forEach((packageName) => { + logger.debug(`Processing package ${packageName}`); if ( !packageName || !pattern.some((p) => new RegExp(`${p.replace(/\*/g, '.*')}`).test(packageName)) @@ -294,6 +292,7 @@ export class Yarn2Proxy extends JsPackageManager { } const { name, value } = parsePackageData(packageName.replaceAll(`"`, '')); + logger.debug(`Package ${name} found with version ${value.version}`); if (!existingVersions[name]?.includes(value.version)) { if (acc[name]) { acc[name].push(value); diff --git a/code/core/src/common/utils/cli.ts b/code/core/src/common/utils/cli.ts index d36c99d78d97..70f5cb76b8bb 100644 --- a/code/core/src/common/utils/cli.ts +++ b/code/core/src/common/utils/cli.ts @@ -115,7 +115,7 @@ export function getEnvConfig(program: Record, configEnv: Record & { + command: string; + args?: string[]; + cwd?: string; + ignoreError?: boolean; + env?: Record; +}; + +function getExecaOptions({ stdio, cwd, env, ...execaOptions }: ExecuteCommandOptions) { + return { + cwd, + stdio: stdio ?? prompt.getPreferredStdio(), + encoding: 'utf8' as const, + cleanup: true, + env: { + ...COMMON_ENV_VARS, + ...env, + }, + ...execaOptions, + }; +} + +export function executeCommand(options: ExecuteCommandOptions): ExecaChildProcess { + const { command, args = [], ignoreError = false } = options; + logger.debug(`Executing command: ${command} ${args.join(' ')}`); + const execaProcess = execa(resolveCommand(command), args, getExecaOptions(options)); + + if (ignoreError) { + execaProcess.catch(() => { + // Silently ignore errors when ignoreError is true + }); + } + + return execaProcess; +} + +export function executeCommandSync(options: ExecuteCommandOptions): string { + const { command, args = [], ignoreError = false } = options; + try { + const commandResult = execaCommandSync( + [resolveCommand(command), ...args].join(' '), + getExecaOptions(options) + ); + return commandResult.stdout ?? ''; + } catch (err) { + if (!ignoreError) { + throw err; + } + return ''; + } +} + +export function executeNodeCommand({ + scriptPath, + args, + options, +}: { + scriptPath: string; + args?: string[]; + options?: NodeOptions; +}): ExecaChildProcess { + return execaNode(scriptPath, args, { + ...options, + }); +} + +/** + * Resolve the actual executable name for a given command on the current platform. + * + * Why this exists: + * + * - Many Node-based CLIs (npm, npx, pnpm, yarn, vite, eslint, anything in node_modules/.bin) do NOT + * ship as real executables on Windows. + * - Instead, they install *.cmd and *.ps1 “shim” files. + * - When using execa/child_process with `shell: false` (our default), Node WILL NOT resolve these + * shims. -> calling execa("npx") throws ENOENT on Windows. + * + * This helper normalizes command names so they can be spawned cross-platform without using `shell: + * true`. + * + * Rules: + * + * - If on Windows: + * + * - For known shim-based commands, append `.cmd` (e.g., "npx" → "npx.cmd"). + * - For everything else, return the name unchanged. + * - On non-Windows, return command unchanged. + * + * Open for extension: + * + * - Add new commands to `WINDOWS_SHIM_COMMANDS` as needed. + * - If Storybook adds new internal commands later, extend the list. + * + * @param {string} command - The executable name passed into executeCommand. + * @returns {string} - The normalized executable name safe for passing to execa. + */ +function resolveCommand(command: string): string { + // Commands known to require .cmd on Windows (node-based & shim-installed) + const WINDOWS_SHIM_COMMANDS = new Set([ + 'npm', + 'npx', + 'pnpm', + 'yarn', + 'ng', + // Anything installed via node_modules/.bin (vite, eslint, prettier, etc) + // can be added here as needed. Do NOT list native executables. + ]); + + if (process.platform !== 'win32') { + return command; + } + + if (WINDOWS_SHIM_COMMANDS.has(command)) { + return `${command}.cmd`; + } + + return command; +} diff --git a/code/core/src/common/utils/framework-to-renderer.ts b/code/core/src/common/utils/framework-to-renderer.ts deleted file mode 100644 index c5cbddda6d6f..000000000000 --- a/code/core/src/common/utils/framework-to-renderer.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { SupportedRenderers } from 'storybook/internal/types'; -import type { SupportedFrameworks } from 'storybook/internal/types'; - -export const frameworkToRenderer: Record< - SupportedFrameworks | SupportedRenderers, - SupportedRenderers | 'vue' -> = { - // frameworks - angular: 'angular', - ember: 'ember', - 'html-vite': 'html', - nextjs: 'react', - 'nextjs-vite': 'react', - 'preact-vite': 'preact', - qwik: 'qwik', - 'react-vite': 'react', - 'react-webpack5': 'react', - 'server-webpack5': 'server', - solid: 'solid', - 'svelte-vite': 'svelte', - sveltekit: 'svelte', - 'vue3-vite': 'vue3', - nuxt: 'vue3', - 'web-components-vite': 'web-components', - 'react-rsbuild': 'react', - 'vue3-rsbuild': 'vue3', - // renderers - html: 'html', - preact: 'preact', - 'react-native': 'react-native', - 'react-native-web-vite': 'react', - react: 'react', - server: 'server', - svelte: 'svelte', - vue3: 'vue3', - 'web-components': 'web-components', -}; diff --git a/code/core/src/common/utils/framework.ts b/code/core/src/common/utils/framework.ts new file mode 100644 index 000000000000..cb5e08434af6 --- /dev/null +++ b/code/core/src/common/utils/framework.ts @@ -0,0 +1,64 @@ +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; + +export const frameworkToRenderer: Record< + SupportedFramework | SupportedRenderer, + SupportedRenderer +> = { + // frameworks + [SupportedFramework.ANGULAR]: SupportedRenderer.ANGULAR, + [SupportedFramework.EMBER]: SupportedRenderer.EMBER, + [SupportedFramework.HTML_VITE]: SupportedRenderer.HTML, + [SupportedFramework.NEXTJS]: SupportedRenderer.REACT, + [SupportedFramework.NEXTJS_VITE]: SupportedRenderer.REACT, + [SupportedFramework.PREACT_VITE]: SupportedRenderer.PREACT, + [SupportedFramework.QWIK]: SupportedRenderer.QWIK, + [SupportedFramework.REACT_VITE]: SupportedRenderer.REACT, + [SupportedFramework.REACT_WEBPACK5]: SupportedRenderer.REACT, + [SupportedFramework.SERVER_WEBPACK5]: SupportedRenderer.SERVER, + [SupportedFramework.SOLID]: SupportedRenderer.SOLID, + [SupportedFramework.SVELTE_VITE]: SupportedRenderer.SVELTE, + [SupportedFramework.SVELTEKIT]: SupportedRenderer.SVELTE, + [SupportedFramework.VUE3_VITE]: SupportedRenderer.VUE3, + [SupportedFramework.WEB_COMPONENTS_VITE]: SupportedRenderer.WEB_COMPONENTS, + [SupportedFramework.REACT_RSBUILD]: SupportedRenderer.REACT, + [SupportedFramework.VUE3_RSBUILD]: SupportedRenderer.VUE3, + [SupportedFramework.HTML_RSBUILD]: SupportedRenderer.HTML, + [SupportedFramework.WEB_COMPONENTS_RSBUILD]: SupportedRenderer.WEB_COMPONENTS, + [SupportedFramework.REACT_NATIVE_WEB_VITE]: SupportedRenderer.REACT, + [SupportedFramework.NUXT]: SupportedRenderer.VUE3, + + // renderers + [SupportedRenderer.HTML]: SupportedRenderer.HTML, + [SupportedRenderer.PREACT]: SupportedRenderer.PREACT, + [SupportedRenderer.REACT_NATIVE]: SupportedRenderer.REACT_NATIVE, + [SupportedRenderer.REACT]: SupportedRenderer.REACT, + [SupportedRenderer.SERVER]: SupportedRenderer.SERVER, + [SupportedRenderer.SVELTE]: SupportedRenderer.SVELTE, + [SupportedRenderer.VUE3]: SupportedRenderer.VUE3, + [SupportedRenderer.WEB_COMPONENTS]: SupportedRenderer.WEB_COMPONENTS, +}; + +export const frameworkToBuilder: Record = { + // frameworks + [SupportedFramework.ANGULAR]: SupportedBuilder.WEBPACK5, + [SupportedFramework.EMBER]: SupportedBuilder.WEBPACK5, + [SupportedFramework.HTML_VITE]: SupportedBuilder.VITE, + [SupportedFramework.NEXTJS]: SupportedBuilder.WEBPACK5, + [SupportedFramework.NEXTJS_VITE]: SupportedBuilder.VITE, + [SupportedFramework.PREACT_VITE]: SupportedBuilder.VITE, + [SupportedFramework.REACT_NATIVE_WEB_VITE]: SupportedBuilder.VITE, + [SupportedFramework.REACT_VITE]: SupportedBuilder.VITE, + [SupportedFramework.REACT_WEBPACK5]: SupportedBuilder.WEBPACK5, + [SupportedFramework.SERVER_WEBPACK5]: SupportedBuilder.WEBPACK5, + [SupportedFramework.SVELTE_VITE]: SupportedBuilder.VITE, + [SupportedFramework.SVELTEKIT]: SupportedBuilder.VITE, + [SupportedFramework.VUE3_VITE]: SupportedBuilder.VITE, + [SupportedFramework.WEB_COMPONENTS_VITE]: SupportedBuilder.VITE, + [SupportedFramework.QWIK]: SupportedBuilder.VITE, + [SupportedFramework.SOLID]: SupportedBuilder.VITE, + [SupportedFramework.NUXT]: SupportedBuilder.VITE, + [SupportedFramework.REACT_RSBUILD]: SupportedBuilder.RSBUILD, + [SupportedFramework.VUE3_RSBUILD]: SupportedBuilder.RSBUILD, + [SupportedFramework.HTML_RSBUILD]: SupportedBuilder.RSBUILD, + [SupportedFramework.WEB_COMPONENTS_RSBUILD]: SupportedBuilder.RSBUILD, +}; diff --git a/code/core/src/common/utils/get-framework-name.test.ts b/code/core/src/common/utils/get-framework-name.test.ts index b0dba5027e01..4d610d0f7ff7 100644 --- a/code/core/src/common/utils/get-framework-name.test.ts +++ b/code/core/src/common/utils/get-framework-name.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { extractProperFrameworkName } from './get-framework-name'; +import { extractFrameworkPackageName } from './get-framework-name'; describe('get-framework-name', () => { describe('extractProperFrameworkName', () => { it('should extract the proper framework name from the given framework field', () => { - expect(extractProperFrameworkName('@storybook/angular')).toBe('@storybook/angular'); - expect(extractProperFrameworkName('/path/to/@storybook/angular')).toBe('@storybook/angular'); - expect(extractProperFrameworkName('\\path\\to\\@storybook\\angular')).toBe( + expect(extractFrameworkPackageName('@storybook/angular')).toBe('@storybook/angular'); + expect(extractFrameworkPackageName('/path/to/@storybook/angular')).toBe('@storybook/angular'); + expect(extractFrameworkPackageName('\\path\\to\\@storybook\\angular')).toBe( '@storybook/angular' ); }); it('should return the given framework name if it is a third-party framework', () => { - expect(extractProperFrameworkName('@third-party/framework')).toBe('@third-party/framework'); + expect(extractFrameworkPackageName('@third-party/framework')).toBe('@third-party/framework'); }); }); }); diff --git a/code/core/src/common/utils/get-framework-name.ts b/code/core/src/common/utils/get-framework-name.ts index ba9d52c8d54e..9c5af3a4a660 100644 --- a/code/core/src/common/utils/get-framework-name.ts +++ b/code/core/src/common/utils/get-framework-name.ts @@ -27,11 +27,11 @@ export async function getFrameworkName(options: Options) { * @example * * ```ts - * ExtractProperFrameworkName('/path/to/@storybook/angular'); // => '@storybook/angular' - * extractProperFrameworkName('@third-party/framework'); // => '@third-party/framework' + * extractFrameworkPackageName('/path/to/@storybook/angular'); // => '@storybook/angular' + * extractFrameworkPackageName('@third-party/framework'); // => '@third-party/framework' * ``` */ -export const extractProperFrameworkName = (framework: string) => { +export const extractFrameworkPackageName = (framework: string) => { const normalizedPath = normalizePath(framework); const frameworkName = Object.keys(frameworkPackages).find((pkg) => normalizedPath.endsWith(pkg)); diff --git a/code/core/src/common/utils/get-renderer-name.test.ts b/code/core/src/common/utils/get-renderer-name.test.ts index 5ae7fef96f35..78b1f35b799e 100644 --- a/code/core/src/common/utils/get-renderer-name.test.ts +++ b/code/core/src/common/utils/get-renderer-name.test.ts @@ -1,16 +1,16 @@ import { describe, expect, test } from 'vitest'; -import { extractProperRendererNameFromFramework } from './get-renderer-name'; +import { extractRenderer } from './get-renderer-name'; describe('get-renderer-name', () => { describe('extractProperRendererNameFromFramework', () => { test('should return the renderer name for a known framework', async () => { - const renderer = await extractProperRendererNameFromFramework('@storybook/react-vite'); + const renderer = await extractRenderer('@storybook/react-vite'); expect(renderer).toEqual('react'); }); test('should return null for an unknown framework', async () => { - const renderer = await extractProperRendererNameFromFramework('@third-party/framework'); + const renderer = await extractRenderer('@third-party/framework'); expect(renderer).toBeNull(); }); }); diff --git a/code/core/src/common/utils/get-renderer-name.ts b/code/core/src/common/utils/get-renderer-name.ts index 8dab8e11e554..af039d9d5abc 100644 --- a/code/core/src/common/utils/get-renderer-name.ts +++ b/code/core/src/common/utils/get-renderer-name.ts @@ -1,7 +1,7 @@ import type { Options } from 'storybook/internal/types'; -import { frameworkToRenderer } from './framework-to-renderer'; -import { extractProperFrameworkName, getFrameworkName } from './get-framework-name'; +import { frameworkToRenderer } from './framework'; +import { extractFrameworkPackageName, getFrameworkName } from './get-framework-name'; import { frameworkPackages } from './get-storybook-info'; /** @@ -26,16 +26,16 @@ export async function getRendererName(options: Options) { * @example * * ```ts - * extractProperRendererNameFromFramework('@storybook/react'); // => 'react' - * extractProperRendererNameFromFramework('@storybook/angular'); // => 'angular' - * extractProperRendererNameFromFramework('@third-party/framework'); // => null + * extractRenderer('@storybook/react'); // => 'react' + * extractRenderer('@storybook/angular'); // => 'angular' + * extractRenderer('@third-party/framework'); // => null * ``` * * @param frameworkName The name of the framework. * @returns The name of the renderer. */ -export async function extractProperRendererNameFromFramework(frameworkName: string) { - const extractedFrameworkName = extractProperFrameworkName(frameworkName); +export async function extractRenderer(frameworkName: string) { + const extractedFrameworkName = extractFrameworkPackageName(frameworkName); const framework = frameworkPackages[extractedFrameworkName]; if (!framework) { diff --git a/code/core/src/common/utils/get-storybook-info.ts b/code/core/src/common/utils/get-storybook-info.ts index 3cf0639721e5..ad83e36cf00f 100644 --- a/code/core/src/common/utils/get-storybook-info.ts +++ b/code/core/src/common/utils/get-storybook-info.ts @@ -1,59 +1,80 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import type { SupportedFrameworks } from 'storybook/internal/types'; -import type { CoreCommon_StorybookInfo, PackageJson } from 'storybook/internal/types'; +import { CoreWebpackCompiler, SupportedFramework } from 'storybook/internal/types'; +import type { + CoreCommon_StorybookInfo, + PackageJson, + StorybookConfigRaw, +} from 'storybook/internal/types'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; + +import invariant from 'tiny-invariant'; import { JsPackageManager } from '../js-package-manager/JsPackageManager'; +import { frameworkToBuilder } from './framework'; +import { getAddonNames } from './get-addon-names'; +import { extractFrameworkPackageName } from './get-framework-name'; +import { extractRenderer } from './get-renderer-name'; import { getStorybookConfiguration } from './get-storybook-configuration'; - -export const rendererPackages: Record = { - '@storybook/react': 'react', - '@storybook/vue3': 'vue3', - '@storybook/angular': 'angular', - '@storybook/html': 'html', - '@storybook/web-components': 'web-components', - '@storybook/polymer': 'polymer', - '@storybook/ember': 'ember', - '@storybook/svelte': 'svelte', - '@storybook/preact': 'preact', - '@storybook/server': 'server', +import { loadMainConfig } from './load-main-config'; + +export const rendererPackages: Record = { + '@storybook/react': SupportedRenderer.REACT, + '@storybook/vue3': SupportedRenderer.VUE3, + '@storybook/angular': SupportedRenderer.ANGULAR, + '@storybook/html': SupportedRenderer.HTML, + '@storybook/web-components': SupportedRenderer.WEB_COMPONENTS, + '@storybook/ember': SupportedRenderer.EMBER, + '@storybook/svelte': SupportedRenderer.SVELTE, + '@storybook/preact': SupportedRenderer.PREACT, + '@storybook/server': SupportedRenderer.SERVER, // community (outside of monorepo) - 'storybook-framework-qwik': 'qwik', - 'storybook-solidjs-vite': 'solid', + 'storybook-framework-qwik': SupportedRenderer.QWIK, + 'storybook-solidjs-vite': SupportedRenderer.SOLID, +}; - /** @deprecated This is deprecated. */ - '@storybook/vue': 'vue', +export const frameworkPackages: Record = { + '@storybook/angular': SupportedFramework.ANGULAR, + '@storybook/ember': SupportedFramework.EMBER, + '@storybook/html-vite': SupportedFramework.HTML_VITE, + '@storybook/nextjs': SupportedFramework.NEXTJS, + '@storybook/preact-vite': SupportedFramework.PREACT_VITE, + '@storybook/react-vite': SupportedFramework.REACT_VITE, + '@storybook/react-webpack5': SupportedFramework.REACT_WEBPACK5, + '@storybook/server-webpack5': SupportedFramework.SERVER_WEBPACK5, + '@storybook/svelte-vite': SupportedFramework.SVELTE_VITE, + '@storybook/sveltekit': SupportedFramework.SVELTEKIT, + '@storybook/vue3-vite': SupportedFramework.VUE3_VITE, + '@storybook/nextjs-vite': SupportedFramework.NEXTJS_VITE, + '@storybook/react-native-web-vite': SupportedFramework.REACT_NATIVE_WEB_VITE, + '@storybook/web-components-vite': SupportedFramework.WEB_COMPONENTS_VITE, + // community (outside of monorepo) + 'storybook-framework-qwik': SupportedFramework.QWIK, + 'storybook-solidjs-vite': SupportedFramework.SOLID, + 'storybook-react-rsbuild': SupportedFramework.REACT_RSBUILD, + 'storybook-vue3-rsbuild': SupportedFramework.VUE3_RSBUILD, + 'storybook-web-components-rsbuild': SupportedFramework.WEB_COMPONENTS_RSBUILD, + 'storybook-html-rsbuild': SupportedFramework.HTML_RSBUILD, + '@storybook-vue/nuxt': SupportedFramework.NUXT, }; -export const frameworkPackages: Record = { - '@storybook/angular': 'angular', - '@storybook/ember': 'ember', - '@storybook/html-vite': 'html-vite', - '@storybook/nextjs': 'nextjs', - '@storybook/preact-vite': 'preact-vite', - '@storybook/react-vite': 'react-vite', - '@storybook/react-webpack5': 'react-webpack5', - '@storybook/server-webpack5': 'server-webpack5', - '@storybook/svelte-vite': 'svelte-vite', - '@storybook/sveltekit': 'sveltekit', - '@storybook/vue3-vite': 'vue3-vite', - '@storybook/nextjs-vite': 'nextjs-vite', - '@storybook/react-native-web-vite': 'react-native-web-vite', - '@storybook/web-components-vite': 'web-components-vite', +export const builderPackages: Record = { + '@storybook/builder-webpack5': SupportedBuilder.WEBPACK5, + '@storybook/builder-vite': SupportedBuilder.VITE, // community (outside of monorepo) - 'storybook-framework-qwik': 'qwik', - 'storybook-solidjs-vite': 'solid', - 'storybook-react-rsbuild': 'react-rsbuild', - 'storybook-vue3-rsbuild': 'vue3-rsbuild', + 'storybook-builder-rsbuild': SupportedBuilder.RSBUILD, }; -export const builderPackages = ['@storybook/builder-webpack5', '@storybook/builder-vite']; +export const compilerPackages: Record = { + '@storybook/addon-webpack5-compiler-babel': CoreWebpackCompiler.Babel, + '@storybook/addon-webpack5-compiler-swc': CoreWebpackCompiler.SWC, +}; const findDependency = ( { dependencies, devDependencies, peerDependencies }: PackageJson, - predicate: (entry: [string, string | undefined]) => string + predicate: (entry: [string, string | undefined]) => boolean ) => [ Object.entries(dependencies || {}).find(predicate), @@ -61,27 +82,21 @@ const findDependency = ( Object.entries(peerDependencies || {}).find(predicate), ] as const; -const getRendererInfo = (configDir: string) => { +const getStorybookVersionSpecifier = (configDir: string) => { const packageJsonPaths = JsPackageManager.listAllPackageJsonPaths(dirname(configDir)); for (const packageJsonPath of packageJsonPaths) { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); // Pull the viewlayer from dependencies in package.json - const [dep, devDep, peerDep] = findDependency(packageJson, ([key]) => rendererPackages[key]); + const [dep, devDep, peerDep] = findDependency(packageJson, ([key]) => key === 'storybook'); const [pkg, version] = dep || devDep || peerDep || []; if (pkg && version) { - return { - version, - frameworkPackage: pkg, - }; + return version; } } - return { - version: undefined, - frameworkPackage: undefined, - }; + return undefined; }; const validConfigExtensions = ['ts', 'js', 'tsx', 'jsx', 'mjs', 'cjs']; @@ -120,12 +135,62 @@ export const getConfigInfo = (configDir?: string) => { }; }; -export const getStorybookInfo = (configDir = '.storybook') => { - const rendererInfo = getRendererInfo(configDir); +export const getStorybookInfo = async ( + configDir = '.storybook', + cwd?: string +): Promise => { const configInfo = getConfigInfo(configDir); + const mainConfig = (await loadMainConfig({ + configDir: configInfo.configDir, + cwd, + })) as StorybookConfigRaw; + + invariant(mainConfig, `Unable to find or evaluate ${configInfo.mainConfigPath}`); + + const frameworkValue = mainConfig.framework; + const frameworkField = typeof frameworkValue === 'string' ? frameworkValue : frameworkValue?.name; + const addons = getAddonNames(mainConfig); + const version = getStorybookVersionSpecifier(configDir); + + if (!frameworkField) { + return { + ...configInfo, + version, + addons, + mainConfig, + mainConfigPath: configInfo.mainConfigPath ?? undefined, + previewConfigPath: configInfo.previewConfigPath ?? undefined, + managerConfigPath: configInfo.managerConfigPath ?? undefined, + }; + } + + const frameworkPackage = extractFrameworkPackageName(frameworkField); + + const framework = frameworkPackages[frameworkPackage]; + const renderer = await extractRenderer(frameworkPackage); + const builder = frameworkToBuilder[framework]; + + const rendererPackage = Object.entries(rendererPackages).find( + ([, value]) => value === renderer + )?.[0]; + + const builderPackage = Object.entries(builderPackages).find( + ([, value]) => value === builder + )?.[0]; return { - ...rendererInfo, ...configInfo, - } as CoreCommon_StorybookInfo; + addons, + mainConfig, + framework, + version, + renderer: renderer ?? undefined, + builder: builder ?? undefined, + frameworkPackage, + rendererPackage, + builderPackage, + mainConfigPath: configInfo.mainConfigPath ?? undefined, + previewConfigPath: configInfo.previewConfigPath ?? undefined, + managerConfigPath: configInfo.managerConfigPath ?? undefined, + }; }; diff --git a/code/core/src/common/utils/load-manager-or-addons-file.ts b/code/core/src/common/utils/load-manager-or-addons-file.ts index cb9d20261bd0..0a53025e56f8 100644 --- a/code/core/src/common/utils/load-manager-or-addons-file.ts +++ b/code/core/src/common/utils/load-manager-or-addons-file.ts @@ -11,7 +11,7 @@ export function loadManagerOrAddonsFile({ configDir }: { configDir: string }) { const storybookCustomManagerPath = getInterpretedFile(resolve(configDir, 'manager')); if (storybookCustomAddonsPath || storybookCustomManagerPath) { - logger.info('=> Loading custom manager config'); + logger.step('Loading custom manager config'); } if (storybookCustomAddonsPath && storybookCustomManagerPath) { diff --git a/code/core/src/common/utils/log.ts b/code/core/src/common/utils/log.ts deleted file mode 100644 index 3b3b2f8e7322..000000000000 --- a/code/core/src/common/utils/log.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { logger } from 'storybook/internal/node-logger'; - -import picocolors from 'picocolors'; - -export const commandLog = (message: string) => { - process.stdout.write(picocolors.cyan(' • ') + message); - - // Need `void` to be able to use this function in a then of a Promise - return (errorMessage?: string | void, errorInfo?: string) => { - if (errorMessage) { - process.stdout.write(`. ${picocolors.red('✖')}\n`); - logger.error(`\n ${picocolors.red(errorMessage)}`); - - if (!errorInfo) { - return; - } - - const newErrorInfo = errorInfo - .split('\n') - .map((line) => ` ${picocolors.dim(line)}`) - .join('\n'); - logger.error(`${newErrorInfo}\n`); - return; - } - - process.stdout.write(`. ${picocolors.green('✓')}\n`); - }; -}; - -export function paddedLog(message: string) { - const newMessage = message - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); - - logger.log(newMessage); -} - -export function getChars(char: string, amount: number) { - let line = ''; - for (let lc = 0; lc < amount; lc += 1) { - line += char; - } - - return line; -} - -export function codeLog(codeLines: string[], leftPadAmount?: number) { - let maxLength = 0; - const newLines = codeLines.map((line) => { - maxLength = line.length > maxLength ? line.length : maxLength; - return line; - }); - - const finalResult = newLines - .map((line) => { - const rightPadAmount = maxLength - line.length; - let newLine = line + getChars(' ', rightPadAmount); - newLine = getChars(' ', leftPadAmount || 2) + picocolors.inverse(` ${newLine} `); - return newLine; - }) - .join('\n'); - - logger.log(finalResult); -} diff --git a/code/core/src/common/utils/scan-and-transform-files.test.ts b/code/core/src/common/utils/scan-and-transform-files.test.ts index c5366ff54a6b..7ce57f9a57fd 100644 --- a/code/core/src/common/utils/scan-and-transform-files.test.ts +++ b/code/core/src/common/utils/scan-and-transform-files.test.ts @@ -6,14 +6,10 @@ import { scanAndTransformFiles } from './scan-and-transform-files'; // Mock dependencies const mocks = vi.hoisted(() => { return { - prompts: vi.fn(), commonGlobOptions: vi.fn(), - }; -}); - -vi.mock('prompts', () => { - return { - default: mocks.prompts, + promptText: vi.fn(), + globby: vi.fn(), + loggerLog: vi.fn(), }; }); @@ -21,7 +17,18 @@ vi.mock('./common-glob-options', () => ({ commonGlobOptions: mocks.commonGlobOptions, })); -vi.mock('globby', () => ({ globby: vi.fn() })); +vi.mock('storybook/internal/node-logger', () => ({ + logger: { + log: mocks.loggerLog, + }, + prompt: { + text: mocks.promptText, + }, +})); + +vi.mock('globby', () => ({ + globby: mocks.globby, +})); describe('scanAndTransformFiles', () => { const mockTransformFn = vi.fn(); @@ -36,14 +43,9 @@ describe('scanAndTransformFiles', () => { vi.spyOn(paths, 'getProjectRoot').mockReturnValue('/mock/project/root'); // Setup mock implementations - mocks.prompts.mockResolvedValue({ glob: '**/*.{js,ts}' }); - - // Setup globby mock - vi.doMock('globby', async () => { - return { - globby: vi.fn().mockResolvedValue(mockFiles), - }; - }); + mocks.promptText.mockResolvedValue('**/*.{js,ts}'); + mocks.commonGlobOptions.mockReturnValue({ cwd: '/mock/project/root' }); + mocks.globby.mockResolvedValue(mockFiles); // Setup transform function mock mockTransformFn.mockResolvedValue(mockErrors); @@ -57,12 +59,10 @@ describe('scanAndTransformFiles', () => { transformOptions: mockTransformOptions, }); - // Verify prompts was called with the right arguments - expect(mocks.prompts).toHaveBeenCalledWith({ - type: 'text', - name: 'glob', + // Verify prompt.text was called with the right arguments + expect(mocks.promptText).toHaveBeenCalledWith({ message: 'Enter a custom glob pattern to scan (or press enter to use default):', - initial: '**/*.{mjs,cjs,js,jsx,ts,tsx,mdx}', + initialValue: '**/*.{mjs,cjs,js,jsx,ts,tsx,mdx}', }); // Verify commonGlobOptions was called @@ -85,12 +85,10 @@ describe('scanAndTransformFiles', () => { transformOptions: mockTransformOptions, }); - // Verify prompts was called with the custom options - expect(mocks.prompts).toHaveBeenCalledWith({ - type: 'text', - name: 'glob', + // Verify prompt.text was called with the custom options + expect(mocks.promptText).toHaveBeenCalledWith({ message: 'Custom prompt message', - initial: '**/*.custom', + initialValue: '**/*.custom', }); }); diff --git a/code/core/src/common/utils/scan-and-transform-files.ts b/code/core/src/common/utils/scan-and-transform-files.ts index 88313f1f7020..2d9568dd3b12 100644 --- a/code/core/src/common/utils/scan-and-transform-files.ts +++ b/code/core/src/common/utils/scan-and-transform-files.ts @@ -1,4 +1,4 @@ -import prompts from 'prompts'; +import { logger, prompt } from 'storybook/internal/node-logger'; import { commonGlobOptions } from './common-glob-options'; import { getProjectRoot } from './paths'; @@ -30,16 +30,14 @@ export async function scanAndTransformFiles>({ transformOptions: T; }): Promise> { // Ask for glob pattern - const { glob } = force - ? { glob: defaultGlob } - : await prompts({ - type: 'text', - name: 'glob', + const glob = force + ? defaultGlob + : await prompt.text({ message: promptMessage, - initial: defaultGlob, + initialValue: defaultGlob, }); - console.log('Scanning for affected files...'); + logger.log('Scanning for affected files...'); // eslint-disable-next-line depend/ban-dependencies const globby = (await import('globby')).globby; @@ -52,7 +50,7 @@ export async function scanAndTransformFiles>({ absolute: true, }); - console.log(`Scanning ${sourceFiles.length} files...`); + logger.log(`Scanning ${sourceFiles.length} files...`); // Transform the files using the provided transform function return transformFn(sourceFiles, transformOptions, dryRun); diff --git a/code/core/src/common/utils/setup-addon-in-config.test.ts b/code/core/src/common/utils/setup-addon-in-config.test.ts new file mode 100644 index 000000000000..484994425caf --- /dev/null +++ b/code/core/src/common/utils/setup-addon-in-config.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ConfigFile } from 'storybook/internal/csf-tools'; +import * as csfTools from 'storybook/internal/csf-tools'; +import type { StorybookConfigRaw } from 'storybook/internal/types'; + +import * as loadMainConfigModule from './load-main-config'; +import { setupAddonInConfig } from './setup-addon-in-config'; +import * as syncModule from './sync-main-preview-addons'; +import * as wrapUtils from './wrap-getAbsolutePath-utils'; + +vi.mock('storybook/internal/csf-tools', { spy: true }); +vi.mock('./sync-main-preview-addons', { spy: true }); +vi.mock('./wrap-getAbsolutePath-utils', { spy: true }); +vi.mock('./load-main-config', { spy: true }); + +describe('setupAddonInConfig', () => { + let mockMain: ConfigFile; + let mockMainConfig: StorybookConfigRaw; + + beforeEach(() => { + vi.clearAllMocks(); + + mockMain = { + getFieldNode: vi.fn(), + valueToNode: vi.fn(), + appendNodeToArray: vi.fn(), + appendValueToArray: vi.fn(), + } as any; + + mockMainConfig = { + addons: [], + } as any; + + vi.mocked(csfTools.writeConfig).mockResolvedValue(); + vi.mocked(syncModule.syncStorybookAddons).mockResolvedValue(); + vi.mocked(loadMainConfigModule.loadMainConfig).mockResolvedValue(mockMainConfig); + }); + + it('should add addon to main config when no getAbsolutePath wrapper exists', async () => { + vi.mocked(mockMain.getFieldNode).mockReturnValue({} as any); + vi.mocked(wrapUtils.getAbsolutePathWrapperName).mockReturnValue(null); + + await setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: '.storybook/preview.ts', + configDir: '.storybook', + }); + + expect(mockMain.appendValueToArray).toHaveBeenCalledWith(['addons'], '@storybook/addon-docs'); + expect(mockMain.appendNodeToArray).not.toHaveBeenCalled(); + expect(wrapUtils.wrapValueWithGetAbsolutePathWrapper).not.toHaveBeenCalled(); + expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); + expect(loadMainConfigModule.loadMainConfig).toHaveBeenCalledWith({ + configDir: '.storybook', + skipCache: true, + }); + expect(syncModule.syncStorybookAddons).toHaveBeenCalledWith( + mockMainConfig, + '.storybook/preview.ts', + '.storybook' + ); + }); + + it('should add addon with getAbsolutePath wrapper when wrapper exists', async () => { + const mockAddonNode = { type: 'StringLiteral' } as any; + + vi.mocked(mockMain.getFieldNode).mockReturnValue({} as any); + vi.mocked(mockMain.valueToNode).mockReturnValue(mockAddonNode); + vi.mocked(wrapUtils.getAbsolutePathWrapperName).mockReturnValue('getAbsolutePath'); + vi.mocked(wrapUtils.wrapValueWithGetAbsolutePathWrapper).mockImplementation(() => {}); + + await setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: '.storybook/preview.ts', + configDir: '.storybook', + }); + + expect(mockMain.valueToNode).toHaveBeenCalledWith('@storybook/addon-docs'); + expect(mockMain.appendNodeToArray).toHaveBeenCalledWith(['addons'], mockAddonNode); + expect(wrapUtils.wrapValueWithGetAbsolutePathWrapper).toHaveBeenCalledWith( + mockMain, + mockAddonNode + ); + expect(mockMain.appendValueToArray).not.toHaveBeenCalled(); + expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); + expect(loadMainConfigModule.loadMainConfig).toHaveBeenCalledWith({ + configDir: '.storybook', + skipCache: true, + }); + expect(syncModule.syncStorybookAddons).toHaveBeenCalledWith( + mockMainConfig, + '.storybook/preview.ts', + '.storybook' + ); + }); + + it('should write config even when addon field does not exist', async () => { + vi.mocked(mockMain.getFieldNode).mockReturnValue(undefined); + + await setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: '.storybook/preview.ts', + configDir: '.storybook', + }); + + expect(mockMain.appendValueToArray).toHaveBeenCalledWith(['addons'], '@storybook/addon-docs'); + expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); + }); + + it('should handle sync errors gracefully', async () => { + vi.mocked(mockMain.getFieldNode).mockReturnValue(undefined); + vi.mocked(syncModule.syncStorybookAddons).mockRejectedValue(new Error('Sync failed')); + + await expect( + setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: '.storybook/preview.ts', + configDir: '.storybook', + }) + ).resolves.not.toThrow(); + + expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); + expect(syncModule.syncStorybookAddons).toHaveBeenCalled(); + }); + + it('should handle undefined previewConfigPath', async () => { + vi.mocked(mockMain.getFieldNode).mockReturnValue(undefined); + + await setupAddonInConfig({ + addonName: '@storybook/addon-docs', + mainConfigCSFFile: mockMain, + previewConfigPath: undefined, + configDir: '.storybook', + }); + + expect(mockMain.appendValueToArray).toHaveBeenCalledWith(['addons'], '@storybook/addon-docs'); + expect(csfTools.writeConfig).toHaveBeenCalledWith(mockMain); + // syncStorybookAddons will be called with undefined, which is fine + }); +}); diff --git a/code/core/src/common/utils/setup-addon-in-config.ts b/code/core/src/common/utils/setup-addon-in-config.ts new file mode 100644 index 000000000000..e234806c1055 --- /dev/null +++ b/code/core/src/common/utils/setup-addon-in-config.ts @@ -0,0 +1,51 @@ +import type { ConfigFile } from 'storybook/internal/csf-tools'; +import { writeConfig } from 'storybook/internal/csf-tools'; + +import { loadMainConfig } from './load-main-config'; +import { syncStorybookAddons } from './sync-main-preview-addons'; +import { + getAbsolutePathWrapperName, + wrapValueWithGetAbsolutePathWrapper, +} from './wrap-getAbsolutePath-utils'; + +export interface SetupAddonInConfigOptions { + addonName: string; + mainConfigCSFFile: ConfigFile; + previewConfigPath: string | undefined; + configDir: string; +} + +/** + * Setup an addon in the Storybook configuration by adding it to the addons array in main config and + * syncing it with preview config. + * + * @param options Configuration options for setting up the addon + */ +export async function setupAddonInConfig({ + addonName, + previewConfigPath, + configDir, + mainConfigCSFFile, +}: SetupAddonInConfigOptions): Promise { + const mainConfigAddons = mainConfigCSFFile.getFieldNode(['addons']); + if (mainConfigAddons && getAbsolutePathWrapperName(mainConfigCSFFile) !== null) { + const addonNode = mainConfigCSFFile.valueToNode(addonName); + mainConfigCSFFile.appendNodeToArray(['addons'], addonNode as any); + wrapValueWithGetAbsolutePathWrapper(mainConfigCSFFile, addonNode as any); + } else { + mainConfigCSFFile.appendValueToArray(['addons'], addonName); + } + + await writeConfig(mainConfigCSFFile); + + // TODO: remove try/catch once CSF factories is shipped, for now gracefully handle any error + try { + const newMainConfig = await loadMainConfig({ configDir, skipCache: true }); + + if (previewConfigPath) { + await syncStorybookAddons(newMainConfig, previewConfigPath, configDir); + } + } catch (e) { + // + } +} diff --git a/code/core/src/common/utils/sync-main-preview-addons.test.ts b/code/core/src/common/utils/sync-main-preview-addons.test.ts index 5bd3c6eff7d9..0d8651c7f6bd 100644 --- a/code/core/src/common/utils/sync-main-preview-addons.test.ts +++ b/code/core/src/common/utils/sync-main-preview-addons.test.ts @@ -7,7 +7,7 @@ import type { StorybookConfigRaw } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; import { getAddonAnnotations } from './get-addon-annotations'; -import { getSyncedStorybookAddons } from './sync-main-preview-addons'; +import { syncPreviewAddonsWithMainConfig } from './sync-main-preview-addons'; vi.mock('./get-addon-annotations'); @@ -16,7 +16,7 @@ expect.addSnapshotSerializer({ test: () => true, }); -describe('getSyncedStorybookAddons', () => { +describe('syncPreviewAddonsWithMainConfig', () => { const mainConfig: StorybookConfigRaw = { stories: [], addons: ['custom-addon', '@storybook/addon-a11y'], @@ -38,7 +38,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); + const result = await syncPreviewAddonsWithMainConfig(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; import * as myAddonAnnotations from "custom-addon/preview"; @@ -68,7 +68,7 @@ describe('getSyncedStorybookAddons', () => { }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); + const result = await syncPreviewAddonsWithMainConfig(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import addonA11yAnnotations from "@storybook/addon-a11y"; import * as myAddonAnnotations from "custom-addon/preview"; @@ -94,7 +94,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); + const result = await syncPreviewAddonsWithMainConfig(mainConfig, preview, configDir); expect(printConfig(result).code).toMatchInlineSnapshot(` import * as addonA11yAnnotations from "@storybook/addon-a11y/preview"; import { definePreview } from "@storybook/react/preview"; @@ -124,7 +124,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); + const result = await syncPreviewAddonsWithMainConfig(mainConfig, preview, configDir); const transformedCode = normalizeLineBreaks(printConfig(result).code); expect(transformedCode).toMatch(originalCode); @@ -146,7 +146,7 @@ describe('getSyncedStorybookAddons', () => { return { importName: 'addonA11yAnnotations', importPath: '@storybook/addon-a11y/preview' }; }); - const result = await getSyncedStorybookAddons(mainConfig, preview, configDir); + const result = await syncPreviewAddonsWithMainConfig(mainConfig, preview, configDir); const transformedCode = normalizeLineBreaks(printConfig(result).code); expect(transformedCode).toMatch(originalCode); @@ -160,7 +160,7 @@ describe('getSyncedStorybookAddons', () => { `; const preview = loadConfig(originalCode).parse(); - const result = await getSyncedStorybookAddons( + const result = await syncPreviewAddonsWithMainConfig( { addons: [], stories: [], diff --git a/code/core/src/common/utils/sync-main-preview-addons.ts b/code/core/src/common/utils/sync-main-preview-addons.ts index ca14d2c6a6d8..a06ab24251c0 100644 --- a/code/core/src/common/utils/sync-main-preview-addons.ts +++ b/code/core/src/common/utils/sync-main-preview-addons.ts @@ -18,13 +18,17 @@ export async function syncStorybookAddons( previewConfigPath: string, configDir: string ) { - const previewConfig = await readConfig(previewConfigPath!); - const modifiedConfig = await getSyncedStorybookAddons(mainConfig, previewConfig, configDir); + const previewConfig = await readConfig(previewConfigPath); + const modifiedConfig = await syncPreviewAddonsWithMainConfig( + mainConfig, + previewConfig, + configDir + ); await writeConfig(modifiedConfig); } -export async function getSyncedStorybookAddons( +export async function syncPreviewAddonsWithMainConfig( mainConfig: StorybookConfig, previewConfig: ConfigFile, configDir: string diff --git a/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts b/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts new file mode 100644 index 000000000000..e4ffc1144d4f --- /dev/null +++ b/code/core/src/common/utils/wrap-getAbsolutePath-utils.ts @@ -0,0 +1,215 @@ +import { types as t } from 'storybook/internal/babel'; +import type { ConfigFile } from 'storybook/internal/csf-tools'; + +const PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'getAbsolutePath'; +const ALTERNATIVE_GET_ABSOLUTE_PATH_WRAPPER_NAME = 'wrapForPnp'; + +/** + * Checks if the following node declarations exists in the main config file. + * + * @example + * + * ```ts + * const = () => {}; + * function () {} + * ``` + */ +export function doesVariableOrFunctionDeclarationExist(node: t.Node, name: string) { + return ( + (t.isVariableDeclaration(node) && + node.declarations.length === 1 && + t.isVariableDeclarator(node.declarations[0]) && + t.isIdentifier(node.declarations[0].id) && + node.declarations[0].id?.name === name) || + (t.isFunctionDeclaration(node) && t.isIdentifier(node.id) && node.id.name === name) + ); +} + +/** + * Wrap a value with getAbsolutePath wrapper. + * + * @example + * + * ```ts + * // Before + * { + * framework: '@storybook/react-vite'; + * } + * + * // After + * { + * framework: getAbsolutePath('@storybook/react-vite'); + * } + * ``` + */ +function getReferenceToGetAbsolutePathWrapper(config: ConfigFile, value: string) { + return t.callExpression( + t.identifier(getAbsolutePathWrapperName(config) ?? PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME), + [t.stringLiteral(value)] + ); +} + +/** + * Returns the name of the getAbsolutePath wrapper function if it exists in the main config file. + * + * @returns Name of the getAbsolutePath wrapper function (e.g. `getAbsolutePath`). + */ +export function getAbsolutePathWrapperName(config: ConfigFile) { + const declarationName = config + .getBodyDeclarations() + .flatMap((node) => + doesVariableOrFunctionDeclarationExist(node, ALTERNATIVE_GET_ABSOLUTE_PATH_WRAPPER_NAME) + ? [ALTERNATIVE_GET_ABSOLUTE_PATH_WRAPPER_NAME] + : doesVariableOrFunctionDeclarationExist(node, PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME) + ? [PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME] + : [] + ); + + if (declarationName.length) { + return declarationName[0]; + } + + return null; +} + +/** Check if the node needs to be wrapped with getAbsolutePath wrapper. */ +export function isGetAbsolutePathWrapperNecessary( + node: t.Node, + cb: (node: t.StringLiteral | t.ObjectProperty | t.ArrayExpression) => void = () => {} +) { + if (t.isStringLiteral(node)) { + // value will be converted from StringLiteral to CallExpression. + cb(node); + return true; + } + + if (t.isObjectExpression(node)) { + const nameProperty = node.properties.find( + (property) => + t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === 'name' + ) as t.ObjectProperty; + + if (nameProperty && t.isStringLiteral(nameProperty.value)) { + cb(nameProperty); + return true; + } + } + + if ( + t.isArrayExpression(node) && + node.elements.some((element) => element && isGetAbsolutePathWrapperNecessary(element)) + ) { + cb(node); + return true; + } + + return false; +} + +/** + * Get all fields that need to be wrapped with getAbsolutePath wrapper. + * + * @returns Array of fields that need to be wrapped with getAbsolutePath wrapper. + */ +export function getFieldsForGetAbsolutePathWrapper(config: ConfigFile): t.Node[] { + const frameworkNode = config.getFieldNode(['framework']); + const builderNode = config.getFieldNode(['core', 'builder']); + const rendererNode = config.getFieldNode(['core', 'renderer']); + const addons = config.getFieldNode(['addons']); + + const fieldsWithRequireWrapper = [ + ...(frameworkNode ? [frameworkNode] : []), + ...(builderNode ? [builderNode] : []), + ...(rendererNode ? [rendererNode] : []), + ...(addons && t.isArrayExpression(addons) ? [addons] : []), + ]; + + return fieldsWithRequireWrapper; +} + +/** + * Returns AST for the following function + * + * @example + * + * ```ts + * function getAbsolutePath(value) { + * return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); + * } + * ``` + */ +export function getAbsolutePathWrapperAsCallExpression( + isConfigTypescript: boolean +): t.FunctionDeclaration { + const functionDeclaration = { + ...t.functionDeclaration( + t.identifier(PREFERRED_GET_ABSOLUTE_PATH_WRAPPER_NAME), + [ + { + ...t.identifier('value'), + ...(isConfigTypescript + ? { typeAnnotation: t.tsTypeAnnotation(t.tSStringKeyword()) } + : {}), + }, + ], + t.blockStatement([ + t.returnStatement( + t.callExpression(t.identifier('dirname'), [ + t.callExpression(t.identifier('fileURLToPath'), [ + t.callExpression( + t.memberExpression( + t.metaProperty(t.identifier('import'), t.identifier('meta')), + t.identifier('resolve') + ), + [ + t.templateLiteral( + [ + t.templateElement({ raw: '' }), + t.templateElement({ raw: '/package.json' }, true), + ], + [t.identifier('value')] + ), + ] + ), + ]), + ]) + ), + ]) + ), + ...(isConfigTypescript ? { returnType: t.tSTypeAnnotation(t.tsAnyKeyword()) } : {}), + }; + + t.addComment( + functionDeclaration, + 'leading', + '*\n * This function is used to resolve the absolute path of a package.\n * It is needed in projects that use Yarn PnP or are set up within a monorepo.\n' + ); + + return functionDeclaration; +} + +export function wrapValueWithGetAbsolutePathWrapper(config: ConfigFile, node: t.Node) { + isGetAbsolutePathWrapperNecessary(node, (n) => { + if (t.isStringLiteral(n)) { + const wrapperNode = getReferenceToGetAbsolutePathWrapper(config, n.value); + Object.keys(n).forEach((k) => { + delete n[k as keyof typeof n]; + }); + Object.keys(wrapperNode).forEach((k) => { + (n as any)[k] = wrapperNode[k as keyof typeof wrapperNode]; + }); + } + + if (t.isObjectProperty(n) && t.isStringLiteral(n.value)) { + n.value = getReferenceToGetAbsolutePathWrapper(config, n.value.value) as any; + } + + if (t.isArrayExpression(n)) { + n.elements.forEach((element) => { + if (element && isGetAbsolutePathWrapperNecessary(element)) { + wrapValueWithGetAbsolutePathWrapper(config, element); + } + }); + } + }); +} diff --git a/code/core/src/core-server/build-dev.ts b/code/core/src/core-server/build-dev.ts index 3aea0bc12953..1d605a5dc1e0 100644 --- a/code/core/src/core-server/build-dev.ts +++ b/code/core/src/core-server/build-dev.ts @@ -12,7 +12,7 @@ import { validateFrameworkName, versions, } from 'storybook/internal/common'; -import { deprecate, logger } from 'storybook/internal/node-logger'; +import { deprecate, logger, prompt } from 'storybook/internal/node-logger'; import { MissingBuilderError, NoStatsForViteDevError } from 'storybook/internal/server-errors'; import { oneWayHash, telemetry } from 'storybook/internal/telemetry'; import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook/internal/types'; @@ -20,7 +20,6 @@ import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook import { global } from '@storybook/global'; import { join, relative, resolve } from 'pathe'; -import prompts from 'prompts'; import invariant from 'tiny-invariant'; import { dedent } from 'ts-dedent'; @@ -68,11 +67,12 @@ export async function buildDevStandalone( ]); if (!options.ci && !options.smokeTest && options.port != null && port !== options.port) { - const { shouldChangePort } = await prompts({ - type: 'confirm', - initial: true, - name: 'shouldChangePort', - message: `Port ${options.port} is not available. Would you like to run Storybook on port ${port} instead?`, + const shouldChangePort = await prompt.confirm({ + message: dedent` + Port ${options.port} is not available. + Would you like to run Storybook on port ${port} instead? + `, + initialValue: true, }); if (!shouldChangePort) { process.exit(1); diff --git a/code/core/src/core-server/build-static.ts b/code/core/src/core-server/build-static.ts index c4d844ca8d8e..ee7486c39178 100644 --- a/code/core/src/core-server/build-static.ts +++ b/code/core/src/core-server/build-static.ts @@ -42,9 +42,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption options.outputDir = resolve(options.outputDir); options.configDir = resolve(options.configDir); - logger.info( - `=> Cleaning outputDir: ${picocolors.cyan(relative(process.cwd(), options.outputDir))}` - ); + logger.step(`Cleaning outputDir: ${picocolors.cyan(relative(process.cwd(), options.outputDir))}`); if (options.outputDir === '/') { throw new Error("Won't remove directory '/'. Check your outputDir!"); } @@ -70,7 +68,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption 'storybook/internal/core-server/presets/common-override-preset' ); - logger.info('=> Loading presets'); + logger.step('Loading presets'); let presets = await loadAllPresets({ corePresets: [commonPreset, ...corePresets], overridePresets: [commonOverridePreset], @@ -203,9 +201,9 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption } if (options.ignorePreview) { - logger.info(`=> Not building preview`); + logger.info(`Not building preview`); } else { - logger.info('=> Building preview..'); + logger.info('Building preview..'); } const startTime = process.hrtime(); @@ -219,7 +217,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption options: fullOptions, }) .then(async (previewStats) => { - logger.trace({ message: '=> Preview built', time: process.hrtime(startTime) }); + logger.trace({ message: 'Preview built', time: process.hrtime(startTime) }); const statsOption = options.webpackStatsJson || options.statsJson; if (statsOption) { @@ -228,7 +226,7 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption } }) .catch((error) => { - logger.error('=> Failed to build the preview'); + logger.error('Failed to build the preview'); process.exitCode = 1; throw error; }), @@ -256,5 +254,5 @@ export async function buildStaticStandalone(options: BuildStaticStandaloneOption ); } - logger.info(`=> Output directory: ${options.outputDir}`); + logger.step(`Output directory: ${options.outputDir}`); } diff --git a/code/core/src/core-server/dev-server.ts b/code/core/src/core-server/dev-server.ts index cd3c5d02f1b8..1a67dedd3f92 100644 --- a/code/core/src/core-server/dev-server.ts +++ b/code/core/src/core-server/dev-server.ts @@ -94,9 +94,7 @@ export async function storybookDevServer(options: Options) { await Promise.resolve(); if (!options.ignorePreview) { - if (!options.quiet) { - logger.info('=> Starting preview..'); - } + logger.debug('Starting preview..'); previewResult = await previewBuilder .start({ startTime: process.hrtime(), @@ -106,7 +104,7 @@ export async function storybookDevServer(options: Options) { channel: serverChannel, }) .catch(async (e: any) => { - logger.error('=> Failed to build the preview'); + logger.error('Failed to build the preview'); process.exitCode = 1; await managerBuilder?.bail().catch(); diff --git a/code/core/src/core-server/load.ts b/code/core/src/core-server/load.ts index 0217913fd4d8..8ac8ca7d19f3 100644 --- a/code/core/src/core-server/load.ts +++ b/code/core/src/core-server/load.ts @@ -10,7 +10,7 @@ import type { BuilderOptions, CLIOptions, LoadOptions, Options } from 'storybook import { global } from '@storybook/global'; -import { join, relative, resolve } from 'pathe'; +import { dirname, join, relative, resolve } from 'pathe'; import { resolvePackageDir } from '../shared/utils/module'; @@ -57,9 +57,15 @@ export async function loadStorybook( isCritical: true, }); - const { renderer } = await presets.apply('core', {}); + const { renderer, builder } = await presets.apply('core', {}); const resolvedRenderer = renderer && resolveAddonName(options.configDir, renderer, options); + const builderName = typeof builder === 'string' ? builder : builder?.name; + + if (builderName) { + corePresets.push(join(dirname(builderName), 'preset.js')); + } + // Load second pass: all presets are applied in order presets = await loadAllPresets({ diff --git a/code/core/src/core-server/presets/common-preset.ts b/code/core/src/core-server/presets/common-preset.ts index 63c11d689faa..976dad85adca 100644 --- a/code/core/src/core-server/presets/common-preset.ts +++ b/code/core/src/core-server/presets/common-preset.ts @@ -1,13 +1,11 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; import type { Channel } from 'storybook/internal/channels'; import { optionalEnvToBoolean } from 'storybook/internal/common'; import { JsPackageManagerFactory, type RemoveAddonOptions, - findConfigFile, getDirectoryFromWorkingDir, getPreviewBodyTemplate, getPreviewHeadTemplate, @@ -288,73 +286,3 @@ export const managerEntries = async (existing: any) => { ...(existing || []), ]; }; - -export const viteFinal = async ( - existing: import('vite').UserConfig, - options: Options -): Promise => { - const previewConfigPath = findConfigFile('preview', options.configDir); - - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return existing; - } - - const { viteInjectMockerRuntime } = await import('./vitePlugins/vite-inject-mocker/plugin'); - const { viteMockPlugin } = await import('./vitePlugins/vite-mock/plugin'); - const coreOptions = await options.presets.apply('core'); - - return { - ...existing, - plugins: [ - ...(existing.plugins ?? []), - ...(previewConfigPath - ? [ - viteInjectMockerRuntime({ previewConfigPath }), - viteMockPlugin({ previewConfigPath, coreOptions, configDir: options.configDir }), - ] - : []), - ], - }; -}; - -export const webpackFinal = async ( - config: import('webpack').Configuration, - options: Options -): Promise => { - const previewConfigPath = findConfigFile('preview', options.configDir); - - // If there's no preview file, there's nothing to mock. - if (!previewConfigPath) { - return config; - } - - const { WebpackMockPlugin } = await import('./webpack/plugins/webpack-mock-plugin'); - const { WebpackInjectMockerRuntimePlugin } = await import( - './webpack/plugins/webpack-inject-mocker-runtime-plugin' - ); - - config.plugins = config.plugins || []; - - // 1. Add the loader to normalize sb.mock(import(...)) calls. - config.module!.rules!.push({ - test: /preview\.(t|j)sx?$/, - use: [ - { - loader: fileURLToPath( - import.meta.resolve('storybook/webpack/loaders/storybook-mock-transform-loader') - ), - }, - ], - }); - - // 2. Add the plugin to handle module replacement based on sb.mock() calls. - // This plugin scans the preview file and sets up rules to swap modules. - config.plugins.push(new WebpackMockPlugin({ previewConfigPath })); - - // 3. Add the plugin to inject the mocker runtime script into the HTML. - // This ensures the `sb` object is available before any other code runs. - config.plugins.push(new WebpackInjectMockerRuntimePlugin()); - - return config; -}; diff --git a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts b/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts deleted file mode 100644 index cff1849840b7..000000000000 --- a/code/core/src/core-server/presets/vitePlugins/vite-inject-mocker/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const __STORYBOOK_GLOBAL_THIS_ACCESSOR__ = '__vitest_mocker__'; diff --git a/code/core/src/core-server/server-channel/file-search-channel.test.ts b/code/core/src/core-server/server-channel/file-search-channel.test.ts index ac9f15c195a1..22c77a1e06bf 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.test.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.test.ts @@ -13,6 +13,7 @@ import { FILE_COMPONENT_SEARCH_RESPONSE, } from 'storybook/internal/core-events'; +import { SupportedRenderer } from '../../types'; import { searchFiles } from '../utils/search-files'; import { initFileSearchChannel } from './file-search-channel'; @@ -25,7 +26,7 @@ vi.mock('storybook/internal/common'); beforeEach(() => { vi.restoreAllMocks(); vi.mocked(common.getFrameworkName).mockResolvedValue('@storybook/react'); - vi.mocked(common.extractProperRendererNameFromFramework).mockResolvedValue('react'); + vi.mocked(common.extractRenderer).mockResolvedValue(SupportedRenderer.REACT); vi.spyOn(common, 'getProjectRoot').mockReturnValue( require('path').join(__dirname, '..', 'utils', '__search-files-tests__') ); diff --git a/code/core/src/core-server/server-channel/file-search-channel.ts b/code/core/src/core-server/server-channel/file-search-channel.ts index 239c8e77b8fa..f539556d935b 100644 --- a/code/core/src/core-server/server-channel/file-search-channel.ts +++ b/code/core/src/core-server/server-channel/file-search-channel.ts @@ -2,11 +2,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { Channel } from 'storybook/internal/channels'; -import { - extractProperRendererNameFromFramework, - getFrameworkName, - getProjectRoot, -} from 'storybook/internal/common'; +import { extractRenderer, getFrameworkName, getProjectRoot } from 'storybook/internal/common'; import type { FileComponentSearchRequestPayload, FileComponentSearchResponsePayload, @@ -18,7 +14,7 @@ import { FILE_COMPONENT_SEARCH_RESPONSE, } from 'storybook/internal/core-events'; import { telemetry } from 'storybook/internal/telemetry'; -import type { CoreConfig, Options, SupportedRenderers } from 'storybook/internal/types'; +import type { CoreConfig, Options, SupportedRenderer } from 'storybook/internal/types'; import { doesStoryFileExist, getStoryMetadata } from '../utils/get-new-story-file'; import { getParser } from '../utils/parser'; @@ -41,9 +37,7 @@ export async function initFileSearchChannel( const frameworkName = await getFrameworkName(options); - const rendererName = (await extractProperRendererNameFromFramework( - frameworkName - )) as SupportedRenderers; + const rendererName = (await extractRenderer(frameworkName)) as SupportedRenderer; const files = await searchFiles({ searchQuery, diff --git a/code/core/src/core-server/utils/copy-all-static-files.ts b/code/core/src/core-server/utils/copy-all-static-files.ts index e75ae0af0a8e..301a695b0132 100644 --- a/code/core/src/core-server/utils/copy-all-static-files.ts +++ b/code/core/src/core-server/utils/copy-all-static-files.ts @@ -20,7 +20,7 @@ export async function copyAllStaticFiles(staticDirs: any[] | undefined, outputDi if (!staticDir.includes('node_modules')) { const from = picocolors.cyan(print(staticDir)); const to = picocolors.cyan(print(targetDir)); - logger.info(`=> Copying static files: ${from} => ${to}`); + logger.info(`Copying static files: ${from} => ${to}`); } // Storybook's own files should not be overwritten, so we skip such files if we find them @@ -65,7 +65,7 @@ export async function copyAllStaticFilesRelativeToMain( const skipPaths = ['index.html', 'iframe.html'].map((f) => join(outputDir, f)); if (!from.includes('node_modules')) { logger.info( - `=> Copying static files: ${picocolors.cyan(print(from))} at ${picocolors.cyan(print(targetPath))}` + `Copying static files: ${picocolors.cyan(print(from))} at ${picocolors.cyan(print(targetPath))}` ); } await cp(from, targetPath, { diff --git a/code/core/src/core-server/utils/get-new-story-file.ts b/code/core/src/core-server/utils/get-new-story-file.ts index ebcb4e9a8f6c..18d06ba415d0 100644 --- a/code/core/src/core-server/utils/get-new-story-file.ts +++ b/code/core/src/core-server/utils/get-new-story-file.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { basename, dirname, extname, join } from 'node:path'; import { - extractProperFrameworkName, + extractFrameworkPackageName, findConfigFile, getFrameworkName, getProjectRoot, @@ -27,7 +27,7 @@ export async function getNewStoryFile( options: Options ) { const frameworkPackageName = await getFrameworkName(options); - const sanitizedFrameworkPackageName = extractProperFrameworkName(frameworkPackageName); + const sanitizedFrameworkPackageName = extractFrameworkPackageName(frameworkPackageName); const base = basename(componentFilePath); const extension = extname(componentFilePath); diff --git a/code/core/src/core-server/utils/output-startup-information.ts b/code/core/src/core-server/utils/output-startup-information.ts index 8d8973a0d247..f86cf5d050cd 100644 --- a/code/core/src/core-server/utils/output-startup-information.ts +++ b/code/core/src/core-server/utils/output-startup-information.ts @@ -1,8 +1,6 @@ -import { colors } from 'storybook/internal/node-logger'; +import { CLI_COLORS, logger } from 'storybook/internal/node-logger'; import type { VersionCheck } from 'storybook/internal/types'; -import boxen from 'boxen'; -import Table from 'cli-table3'; import picocolors from 'picocolors'; import prettyTime from 'pretty-hrtime'; import { dedent } from 'ts-dedent'; @@ -23,34 +21,22 @@ export function outputStartupInformation(options: { const updateMessage = createUpdateMessage(updateInfo, version); - const serveMessage = new Table({ - chars: { - top: '', - 'top-mid': '', - 'top-left': '', - 'top-right': '', - bottom: '', - 'bottom-mid': '', - 'bottom-left': '', - 'bottom-right': '', - left: '', - 'left-mid': '', - mid: '', - 'mid-mid': '', - right: '', - 'right-mid': '', - middle: '', - }, - // @ts-expect-error (Converted from ts-ignore) - paddingLeft: 0, - paddingRight: 0, - paddingTop: 0, - paddingBottom: 0, - }); - - serveMessage.push( - ['Local:', picocolors.cyan(address)], - ['On your network:', picocolors.cyan(networkAddress)] + const serverMessages = [ + `- Local: ${address}`, + `- On your network: ${networkAddress}`, + ]; + + logger.logBox( + dedent` + Storybook ready! + + ${serverMessages.join('\n')}${updateMessage ? `\n\n${updateMessage}` : ''} + `, + { + formatBorder: CLI_COLORS.storybook, + contentPadding: 3, + rounded: true, + } ); const timeStatement = [ @@ -60,17 +46,5 @@ export function outputStartupInformation(options: { .filter(Boolean) .join(' and '); - console.log( - boxen( - dedent` - ${colors.green( - `Storybook ${picocolors.bold(version)} for ${picocolors.bold(name)} started` - )} - ${picocolors.gray(timeStatement)} - - ${serveMessage.toString()}${updateMessage ? `\n\n${updateMessage}` : ''} - `, - { borderStyle: 'round', padding: 1, borderColor: '#F1618C' } as any - ) - ); + logger.info(timeStatement); } diff --git a/code/core/src/core-server/utils/output-stats.ts b/code/core/src/core-server/utils/output-stats.ts index 2255e9048abf..1c0dd01fa74b 100644 --- a/code/core/src/core-server/utils/output-stats.ts +++ b/code/core/src/core-server/utils/output-stats.ts @@ -10,11 +10,11 @@ import picocolors from 'picocolors'; export async function outputStats(directory: string, previewStats?: any, managerStats?: any) { if (previewStats) { const filePath = await writeStats(directory, 'preview', previewStats as Stats); - logger.info(`=> preview stats written to ${picocolors.cyan(filePath)}`); + logger.info(`Preview stats written to ${picocolors.cyan(filePath)}`); } if (managerStats) { const filePath = await writeStats(directory, 'manager', managerStats as Stats); - logger.info(`=> manager stats written to ${picocolors.cyan(filePath)}`); + logger.info(`Manager stats written to ${picocolors.cyan(filePath)}`); } } diff --git a/code/core/src/core-server/utils/parser/index.ts b/code/core/src/core-server/utils/parser/index.ts index 61feeb8790e3..c5c117ebb867 100644 --- a/code/core/src/core-server/utils/parser/index.ts +++ b/code/core/src/core-server/utils/parser/index.ts @@ -1,4 +1,4 @@ -import type { SupportedRenderers } from 'storybook/internal/types'; +import type { SupportedRenderer } from 'storybook/internal/types'; import { GenericParser } from './generic-parser'; import type { Parser } from './types'; @@ -9,7 +9,7 @@ import type { Parser } from './types'; * @param renderer The renderer to get the parser for * @returns The parser for the renderer */ -export function getParser(renderer: SupportedRenderers | null): Parser { +export function getParser(renderer: SupportedRenderer | null): Parser { switch (renderer) { default: return new GenericParser(); diff --git a/code/core/src/core-server/utils/server-statics.ts b/code/core/src/core-server/utils/server-statics.ts index c7b567bc0593..966fdd2f5789 100644 --- a/code/core/src/core-server/utils/server-statics.ts +++ b/code/core/src/core-server/utils/server-statics.ts @@ -2,10 +2,15 @@ import { existsSync, statSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, posix, resolve, sep, win32 } from 'node:path'; -import { getDirectoryFromWorkingDir, resolvePathInStorybookCache } from 'storybook/internal/common'; -import { logger, once } from 'storybook/internal/node-logger'; +import { + getDirectoryFromWorkingDir, + getProjectRoot, + resolvePathInStorybookCache, +} from 'storybook/internal/common'; +import { CLI_COLORS, logger, once } from 'storybook/internal/node-logger'; import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; +import { relative } from 'pathe'; import picocolors from 'picocolors'; import type { Polka } from 'polka'; import sirv from 'sirv'; @@ -113,8 +118,9 @@ export async function useStatics(app: Polka, options: Options): Promise { // Don't log for internal static dirs if (!targetEndpoint.startsWith('/sb-') && !staticDir.startsWith(cacheDir)) { - logger.info( - `=> Serving static files from ${picocolors.cyan(staticDir)} at ${picocolors.cyan(targetEndpoint)}` + const relativeStaticDir = relative(getProjectRoot(), staticDir); + logger.debug( + `Serving static files from ${CLI_COLORS.info(relativeStaticDir)} at ${CLI_COLORS.info(targetEndpoint)}` ); } diff --git a/code/core/src/core-server/withTelemetry.test.ts b/code/core/src/core-server/withTelemetry.test.ts index 27843b3f73fa..b642feae1c2b 100644 --- a/code/core/src/core-server/withTelemetry.test.ts +++ b/code/core/src/core-server/withTelemetry.test.ts @@ -1,19 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cache, loadAllPresets } from 'storybook/internal/common'; -import { oneWayHash, telemetry } from 'storybook/internal/telemetry'; - -import prompts from 'prompts'; +import { prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector, oneWayHash, telemetry } from 'storybook/internal/telemetry'; import { getErrorLevel, sendTelemetryError, withTelemetry } from './withTelemetry'; -vi.mock('prompts'); -vi.mock('storybook/internal/common'); -vi.mock('storybook/internal/telemetry'); +vi.mock('storybook/internal/common', { spy: true }); +vi.mock('storybook/internal/telemetry', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); const cliOptions = {}; describe('withTelemetry', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(ErrorCollector.getErrors).mockReturnValue([]); + }); it('works in happy path', async () => { const run = vi.fn(); @@ -61,7 +64,11 @@ describe('withTelemetry', () => { expect(telemetry).toHaveBeenCalledTimes(2); expect(telemetry).toHaveBeenCalledWith( 'error', - expect.objectContaining({ eventType: 'dev', error }), + expect.objectContaining({ + eventType: 'dev', + error: undefined, // error is only included when errorLevel === 'full' + isErrorInstance: true, + }), expect.objectContaining({}) ); }); @@ -115,8 +122,12 @@ describe('withTelemetry', () => { expect(telemetry).toHaveBeenCalledTimes(2); expect(telemetry).toHaveBeenCalledWith( 'error', - expect.objectContaining({ eventType: 'dev', error }), - expect.objectContaining({}) + expect.objectContaining({ + eventType: 'dev', + error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: true }) ); }); @@ -157,8 +168,12 @@ describe('withTelemetry', () => { expect(telemetry).toHaveBeenCalledTimes(2); expect(telemetry).toHaveBeenCalledWith( 'error', - expect.objectContaining({ eventType: 'dev', error }), - expect.objectContaining({}) + expect.objectContaining({ + eventType: 'dev', + error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: true }) ); }); @@ -201,8 +216,12 @@ describe('withTelemetry', () => { expect(telemetry).toHaveBeenCalledTimes(2); expect(telemetry).toHaveBeenCalledWith( 'error', - expect.objectContaining({ eventType: 'dev', error }), - expect.objectContaining({}) + expect.objectContaining({ + eventType: 'dev', + error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: true }) ); }); @@ -211,7 +230,7 @@ describe('withTelemetry', () => { apply: async () => ({}) as any, }); vi.mocked(cache.get).mockResolvedValueOnce(undefined); - vi.mocked(prompts).mockResolvedValueOnce({ enableCrashReports: false }); + vi.mocked(prompt.confirm).mockResolvedValueOnce(false); await expect(async () => withTelemetry( @@ -234,7 +253,7 @@ describe('withTelemetry', () => { apply: async () => ({}) as any, }); vi.mocked(cache.get).mockResolvedValueOnce(undefined); - vi.mocked(prompts).mockResolvedValueOnce({ enableCrashReports: true }); + vi.mocked(prompt.confirm).mockResolvedValueOnce(true); await expect(async () => withTelemetry( @@ -247,8 +266,12 @@ describe('withTelemetry', () => { expect(telemetry).toHaveBeenCalledTimes(2); expect(telemetry).toHaveBeenCalledWith( 'error', - expect.objectContaining({ eventType: 'dev', error }), - expect.objectContaining({}) + expect.objectContaining({ + eventType: 'dev', + error: expect.objectContaining({ message: 'An Error!', name: 'Error' }), + isErrorInstance: true, + }), + expect.objectContaining({ enableCrashReports: true }) ); }); @@ -276,6 +299,11 @@ describe('withTelemetry', () => { }); describe('sendTelemetryError', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(ErrorCollector.getErrors).mockReturnValue([]); + }); + it('handles error instances and sends telemetry', async () => { const options: any = { cliOptions: {}, @@ -291,12 +319,16 @@ describe('sendTelemetryError', () => { expect(telemetry).toHaveBeenCalledWith( 'error', expect.objectContaining({ - error: mockError, + error: undefined, // error is only included when errorLevel === 'full' eventType, isErrorInstance: true, errorHash: 'some-hash', + name: 'Error', }), - expect.any(Object) + expect.objectContaining({ + enableCrashReports: false, + immediate: true, + }) ); }); @@ -313,12 +345,15 @@ describe('sendTelemetryError', () => { expect(telemetry).toHaveBeenCalledWith( 'error', expect.objectContaining({ - error: mockError, + error: undefined, // error is only included when errorLevel === 'full' eventType, isErrorInstance: false, errorHash: 'NO_MESSAGE', }), - expect.any(Object) + expect.objectContaining({ + enableCrashReports: false, + immediate: true, + }) ); }); @@ -335,12 +370,16 @@ describe('sendTelemetryError', () => { expect(telemetry).toHaveBeenCalledWith( 'error', expect.objectContaining({ - error: mockError, + error: undefined, // error is only included when errorLevel === 'full' eventType, isErrorInstance: true, errorHash: 'EMPTY_MESSAGE', + name: 'Error', }), - expect.any(Object) + expect.objectContaining({ + enableCrashReports: false, + immediate: true, + }) ); }); }); @@ -348,6 +387,7 @@ describe('sendTelemetryError', () => { describe('getErrorLevel', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(ErrorCollector.getErrors).mockReturnValue([]); }); it('returns "none" when cliOptions.disableTelemetry is true', async () => { @@ -364,7 +404,7 @@ describe('getErrorLevel', () => { expect(errorLevel).toBe('none'); }); - it('returns "full" when presetOptions is not provided', async () => { + it('returns "error" when presetOptions is not provided', async () => { const options: any = { cliOptions: { disableTelemetry: false, @@ -375,7 +415,7 @@ describe('getErrorLevel', () => { const errorLevel = await getErrorLevel(options); - expect(errorLevel).toBe('full'); + expect(errorLevel).toBe('error'); }); it('returns "full" when core.enableCrashReports is true', async () => { diff --git a/code/core/src/core-server/withTelemetry.ts b/code/core/src/core-server/withTelemetry.ts index 914ec185fb7b..2e0ce5726da8 100644 --- a/code/core/src/core-server/withTelemetry.ts +++ b/code/core/src/core-server/withTelemetry.ts @@ -1,10 +1,17 @@ import { HandledError, cache, isCI, loadAllPresets } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; -import { getPrecedingUpgrade, oneWayHash, telemetry } from 'storybook/internal/telemetry'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { + ErrorCollector, + getPrecedingUpgrade, + oneWayHash, + telemetry, +} from 'storybook/internal/telemetry'; import type { EventType } from 'storybook/internal/telemetry'; import type { CLIOptions } from 'storybook/internal/types'; -import prompts from 'prompts'; +import { dedent } from 'ts-dedent'; + +import { StorybookError } from '../storybook-error'; type TelemetryOptions = { cliOptions: CLIOptions; @@ -18,11 +25,10 @@ const promptCrashReports = async () => { return undefined; } - const { enableCrashReports } = await prompts({ - type: 'confirm', - name: 'enableCrashReports', - message: `Would you like to help improve Storybook by sending anonymous crash reports?`, - initial: true, + const enableCrashReports = await prompt.confirm({ + message: + 'Would you like to send anonymous crash reports to improve Storybook and fix bugs faster?', + initialValue: true, }); await cache.set('enableCrashReports', enableCrashReports); @@ -43,7 +49,7 @@ export async function getErrorLevel({ // If we are running init or similar, we just have to go with true here if (!presetOptions) { - return 'full'; + return 'error'; } // should we load the preset? @@ -85,7 +91,8 @@ export async function getErrorLevel({ export async function sendTelemetryError( _error: unknown, eventType: EventType, - options: TelemetryOptions + options: TelemetryOptions, + blocking = true ) { try { let errorLevel = 'error'; @@ -114,6 +121,7 @@ export async function sendTelemetryError( name, category, eventType, + blocking, precedingUpgrade, error: errorLevel === 'full' ? error : undefined, errorHash, @@ -132,14 +140,16 @@ export async function sendTelemetryError( } } +export function isTelemetryEnabled(options: TelemetryOptions) { + return !(options.cliOptions.disableTelemetry || options.cliOptions.test === true); +} + export async function withTelemetry( eventType: EventType, options: TelemetryOptions, run: () => Promise ): Promise { - const enableTelemetry = !( - options.cliOptions.disableTelemetry || options.cliOptions.test === true - ); + const enableTelemetry = isTelemetryEnabled(options); let canceled = false; @@ -168,7 +178,10 @@ export async function withTelemetry( return undefined; } - if (!(error instanceof HandledError)) { + const isHandledError = + error instanceof HandledError || (error instanceof StorybookError && error.isHandledError); + + if (!isHandledError) { const { printError = logger.error } = options; printError(error); } @@ -179,6 +192,12 @@ export async function withTelemetry( throw error; } finally { - process.off('SIGINT', cancelTelemetry); + if (enableTelemetry) { + const errors = ErrorCollector.getErrors(); + for (const error of errors) { + await sendTelemetryError(error, eventType, options, false); + } + process.off('SIGINT', cancelTelemetry); + } } } diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 5a193d06cd24..4c5f1505a183 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -649,5 +649,13 @@ export default { 'stringifyQuery', 'useNavigate', ], - 'storybook/internal/types': ['Addon_TypesEnum'], + 'storybook/internal/types': [ + 'Addon_TypesEnum', + 'CoreWebpackCompiler', + 'Feature', + 'SupportedBuilder', + 'SupportedFramework', + 'SupportedLanguage', + 'SupportedRenderer', + ], } as const; diff --git a/code/core/src/core-server/mocking-utils/automock.ts b/code/core/src/mocking-utils/automock.ts similarity index 97% rename from code/core/src/core-server/mocking-utils/automock.ts rename to code/core/src/mocking-utils/automock.ts index b1df00327a51..aac315198c81 100644 --- a/code/core/src/core-server/mocking-utils/automock.ts +++ b/code/core/src/mocking-utils/automock.ts @@ -8,11 +8,12 @@ import type { } from 'estree'; import MagicString from 'magic-string'; -import { __STORYBOOK_GLOBAL_THIS_ACCESSOR__ } from '../presets/vitePlugins/vite-inject-mocker/constants'; import { type Positioned, getArbitraryModuleIdentifier } from './esmWalker'; type ParseFn = (code: string) => Program; +export const __STORYBOOK_GLOBAL_THIS_ACCESSOR__ = '__vitest_mocker__'; + export function getAutomockCode(originalCode: string, isSpy: boolean, parse: ParseFn) { const mocked = automockModule(originalCode, isSpy ? 'autospy' : 'automock', parse, { globalThisAccessor: JSON.stringify(__STORYBOOK_GLOBAL_THIS_ACCESSOR__), @@ -49,7 +50,8 @@ export function automockModule( parse: (code: string) => any, options: any = {} ): MagicString { - const globalThisAccessor = options.globalThisAccessor || '"__vitest_mocker__"'; + const globalThisAccessor = + options.globalThisAccessor || JSON.stringify(__STORYBOOK_GLOBAL_THIS_ACCESSOR__); const ast = parse(code) as Program; const m = new MagicString(code); diff --git a/code/core/src/core-server/mocking-utils/esmWalker.ts b/code/core/src/mocking-utils/esmWalker.ts similarity index 100% rename from code/core/src/core-server/mocking-utils/esmWalker.ts rename to code/core/src/mocking-utils/esmWalker.ts diff --git a/code/core/src/core-server/mocking-utils/extract.test.ts b/code/core/src/mocking-utils/extract.test.ts similarity index 84% rename from code/core/src/core-server/mocking-utils/extract.test.ts rename to code/core/src/mocking-utils/extract.test.ts index 8c0b8a8c4a32..be33590d426c 100644 --- a/code/core/src/core-server/mocking-utils/extract.test.ts +++ b/code/core/src/mocking-utils/extract.test.ts @@ -16,17 +16,22 @@ vi.mock('fs', async () => { vi.mock('./resolve', async () => { return { - resolveMock: vi.fn((path) => { - if (path === './bar/baz.js') { - return { absolutePath: '/abs/path/bar/baz.js', redirectPath: null }; + resolveMock: vi.fn((path, root, importer, findMockRedirect) => { + const result = + path === './bar/baz.js' + ? { absolutePath: '/abs/path/bar/baz.js', redirectPath: null } + : path === './bar/baz.utils' + ? { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null } + : path === './bar/baz.utils.ts' + ? { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null } + : { absolutePath: '/abs/path', redirectPath: null }; + + if (findMockRedirect) { + const redirectPath = findMockRedirect(root, result.absolutePath, null); + return { ...result, redirectPath }; } - if (path === './bar/baz.utils') { - return { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null }; - } - if (path === './bar/baz.utils.ts') { - return { absolutePath: '/abs/path/bar/baz.utils.ts', redirectPath: null }; - } - return { absolutePath: '/abs/path', redirectPath: null }; + + return result; }), }; }); @@ -50,17 +55,21 @@ describe('extractMockCalls', () => { const root = '/project'; const coreOptions = { disableTelemetry: true }; + const findMockRedirect = vi.fn(() => null); + const extractMockCalls = (previewContent: string) => { vi.mocked(readFileSync).mockReturnValue(previewContent); return extractModule.extractMockCalls( { previewConfigPath, configDir, coreOptions }, parser, - root + root, + findMockRedirect ); }; beforeEach(() => { vi.clearAllMocks(); + findMockRedirect.mockReturnValue(null); }); it('returns empty array if readFileSync throws', () => { @@ -84,7 +93,12 @@ describe('extractMockCalls', () => { spy: true, }, ]); - expect(resolveModule.resolveMock).toHaveBeenCalledWith('foo', root, previewConfigPath); + expect(resolveModule.resolveMock).toHaveBeenCalledWith( + 'foo', + root, + previewConfigPath, + findMockRedirect + ); }); it('handles no sb.mock calls in preview file', () => { diff --git a/code/core/src/core-server/mocking-utils/extract.ts b/code/core/src/mocking-utils/extract.ts similarity index 94% rename from code/core/src/core-server/mocking-utils/extract.ts rename to code/core/src/mocking-utils/extract.ts index 967a117c57ad..cc809760c564 100644 --- a/code/core/src/core-server/mocking-utils/extract.ts +++ b/code/core/src/mocking-utils/extract.ts @@ -91,7 +91,12 @@ export function extractMockCalls( jsx?: boolean; } ) => t.Node, - root: string + root: string, + findMockRedirect: ( + root: string, + absolutePath: string, + externalPath: string | null + ) => string | null ): MockCall[] { try { const previewConfigCode = readFileSync(options.previewConfigPath, 'utf-8'); @@ -155,7 +160,12 @@ export function extractMockCalls( node.arguments[1].type === 'ObjectExpression' && hasSpyTrue(node.arguments[1]); - const { absolutePath, redirectPath } = resolveMock(path, root, options.previewConfigPath); + const { absolutePath, redirectPath } = resolveMock( + path, + root, + options.previewConfigPath, + findMockRedirect + ); const pathWithoutExtension = path.replace(/\.[^/.]+$/, ''); const basenameAbsolutePath = basename(absolutePath); @@ -186,7 +196,7 @@ export function extractMockCalls( } return mocks; } catch (error) { - logger.debug('Error extracting mock calls', error); + logger.debug('Error extracting mock calls: ' + String(error)); return []; } } diff --git a/code/core/src/mocking-utils/index.ts b/code/core/src/mocking-utils/index.ts new file mode 100644 index 000000000000..5d418381446e --- /dev/null +++ b/code/core/src/mocking-utils/index.ts @@ -0,0 +1,5 @@ +export * from './automock'; +export * from './extract'; +export * from './resolve'; +export * from './esmWalker'; +export * from './runtime'; diff --git a/code/core/src/core-server/mocking-utils/resolve.ts b/code/core/src/mocking-utils/resolve.ts similarity index 95% rename from code/core/src/core-server/mocking-utils/resolve.ts rename to code/core/src/mocking-utils/resolve.ts index 3c52b03eaec5..35c7ff5b56ea 100644 --- a/code/core/src/core-server/mocking-utils/resolve.ts +++ b/code/core/src/mocking-utils/resolve.ts @@ -1,7 +1,6 @@ import { readFileSync, realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; -import { findMockRedirect } from '@vitest/mocker/redirect'; import { dirname, isAbsolute, join, resolve } from 'pathe'; import { exports as resolveExports } from 'resolve.exports'; @@ -72,7 +71,16 @@ export function getIsExternal(path: string, importer: string) { * @param root The project's root directory. * @param importer The absolute path of the file containing the mock call (the preview file). */ -export function resolveMock(path: string, root: string, importer: string) { +export function resolveMock( + path: string, + root: string, + importer: string, + findMockRedirect: ( + root: string, + absolutePath: string, + externalPath: string | null + ) => string | null +) { const isExternal = getIsExternal(path, root); const externalPath = isExternal ? path : null; diff --git a/code/core/src/mocking-utils/runtime.ts b/code/core/src/mocking-utils/runtime.ts new file mode 100644 index 000000000000..eca3c51629f4 --- /dev/null +++ b/code/core/src/mocking-utils/runtime.ts @@ -0,0 +1,28 @@ +import { resolvePackageDir } from 'storybook/internal/common'; + +import { buildSync } from 'esbuild'; +import { join } from 'pathe'; + +const runtimeTemplatePath = join( + resolvePackageDir('storybook'), + 'assets', + 'server', + 'mocker-runtime.template.js' +); + +export function getMockerRuntime() { + // Use esbuild to bundle the runtime script and its dependencies (`@vitest/mocker`, etc.) + // into a single, self-contained string of code. + const bundleResult = buildSync({ + entryPoints: [runtimeTemplatePath], + bundle: true, + write: false, // Return the result in memory instead of writing to disk + format: 'esm', + target: 'es2020', + external: ['msw/browser', 'msw/core/http'], + }); + + const runtimeScriptContent = bundleResult.outputFiles[0].text; + + return runtimeScriptContent; +} diff --git a/code/core/src/node-logger/index.test.ts b/code/core/src/node-logger/index.test.ts index 5badb27bfff6..397a13d4c794 100644 --- a/code/core/src/node-logger/index.test.ts +++ b/code/core/src/node-logger/index.test.ts @@ -9,6 +9,9 @@ vi.mock('./logger/logger', () => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + setLogLevel: vi.fn(), })); const loggerMock = vi.mocked(loggerRaw); @@ -33,14 +36,13 @@ vi.mock('npmlog', () => ({ }, })); +vi.mock('./prompts/prompt-config', () => ({ + isClackEnabled: vi.fn(() => false), +})); + // describe('node-logger', () => { - it('should have an info method', () => { - const message = 'information'; - logger.info(message); - expect(npmlog.info).toHaveBeenCalledWith('', message); - }); it('should have a warn method', () => { const message = 'warning message'; logger.warn(message); diff --git a/code/core/src/node-logger/index.ts b/code/core/src/node-logger/index.ts index ef6b083e01c9..0184a017c2e9 100644 --- a/code/core/src/node-logger/index.ts +++ b/code/core/src/node-logger/index.ts @@ -3,13 +3,13 @@ import npmLog from 'npmlog'; import prettyTime from 'pretty-hrtime'; import * as newLogger from './logger/logger'; -import { isClackEnabled } from './prompts/prompt-config'; export { prompt } from './prompts'; export { logTracker } from './logger/log-tracker'; export type { SpinnerInstance, TaskLogInstance } from './prompts/prompt-provider-base'; export { protectUrls, createHyperlink } from './wrap-utils'; export { CLI_COLORS } from './logger/colors'; +export { ConsoleLogger, StyledConsoleLogger } from './logger/console'; // The default is stderr, which can cause some tools (like rush.js) to think // there are issues with the build: https://github.com/storybookjs/storybook/issues/14621 @@ -49,10 +49,9 @@ export const colors = { export const logger = { ...newLogger, verbose: (message: string): void => newLogger.debug(message), - info: (message: string): void => - isClackEnabled() ? newLogger.info(message) : npmLog.info('', message), - plain: (message: string): void => newLogger.log(message), + line: (count = 1): void => newLogger.log(`${Array(count - 1).fill('\n')}`), + /** For non-critical issues or warnings */ warn: (message: string): void => newLogger.warn(message), trace: ({ message, time }: { message: string; time: [number, number] }): void => newLogger.debug(`${message} (${colors.purple(prettyTime(time))})`), diff --git a/code/core/src/node-logger/logger/colors.ts b/code/core/src/node-logger/logger/colors.ts index f37133e5c4d8..03d47d47b146 100644 --- a/code/core/src/node-logger/logger/colors.ts +++ b/code/core/src/node-logger/logger/colors.ts @@ -4,8 +4,10 @@ export const CLI_COLORS = { success: picocolors.green, error: picocolors.red, warning: picocolors.yellow, - info: picocolors.blue, + info: process.platform === 'win32' ? picocolors.cyan : picocolors.blue, debug: picocolors.gray, // Only color a link if it is the primary call to action, otherwise links shouldn't be colored cta: picocolors.cyan, + muted: picocolors.dim, + storybook: (text: string) => `\x1b[38;2;255;71;133m${text}\x1b[39m`, }; diff --git a/code/core/src/node-logger/logger/console.ts b/code/core/src/node-logger/logger/console.ts new file mode 100644 index 000000000000..e2a74cd075d7 --- /dev/null +++ b/code/core/src/node-logger/logger/console.ts @@ -0,0 +1,340 @@ +import picocolors from 'picocolors'; + +import { debug, error, log, warn } from './logger'; + +interface ConsoleLoggerOptions { + prefix: string; + color: + | 'bgBlack' + | 'bgRed' + | 'bgGreen' + | 'bgYellow' + | 'bgBlue' + | 'bgMagenta' + | 'bgCyan' + | 'bgWhite' + | 'bgBlackBright' + | 'bgRedBright' + | 'bgGreenBright' + | 'bgYellowBright' + | 'bgBlueBright' + | 'bgMagentaBright' + | 'bgCyanBright' + | 'bgWhiteBright'; +} + +class ConsoleLogger implements Console { + Console = ConsoleLogger; + + protected timers = new Map(); + protected counters = new Map(); + protected lastStatusLine: string | null = null; + protected statusLineCount = 0; + + // These will be overridden by child classes + protected get prefix(): string { + return ''; + } + + protected get color(): (text: string) => string { + return (text: string) => text; + } + + protected formatMessage(...data: any[]): string { + const message = data.join(' '); + return this.prefix ? `${this.color(this.prefix)} ${message}` : message; + } + + assert(condition?: boolean, ...data: any[]): void { + if (!condition) { + error(this.formatMessage('Assertion failed:', ...data)); + } + } + + // Needs some proper implementation + // Take a look at https://github.com/webpack/webpack/blob/5f898719ae47b89bee3c126bf5d2e0081ea8c91f/lib/node/nodeConsole.js#L4 + // for some inspiration + // status(...data: any[]): void { + // const message = this.formatMessage(...data); + + // // If we have a previous status line, we need to clear it + // if (this.lastStatusLine !== null) { + // this.clearStatus(); + // } + + // // Write the status message directly to stdout without adding newlines + // process.stdout.write(message); + + // // Update tracking variables + // this.lastStatusLine = message; + // this.statusLineCount = 1; // For now, assume single line status messages + + // // If the message contains newlines, count them + // const newlineCount = (message.match(/\n/g) || []).length; + // this.statusLineCount = newlineCount + 1; + // } + + // /** Clears the current status line if one exists */ + // clearStatus(): void { + // if (this.lastStatusLine !== null) { + // // Move cursor to the beginning of the current line + // process.stdout.write('\r'); + + // // Clear the current line + // process.stdout.clearLine(1); + + // // Reset tracking variables + // this.lastStatusLine = null; + // this.statusLineCount = 0; + // } + // } + + /** + * The **`console.clear()`** static method clears the console if possible. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) + */ + clear(): void { + // Clear the console by logging a clear sequence + console.clear(); + } + + /** + * The **`console.count()`** static method logs the number of times that this particular call to + * `count()` has been called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) + */ + count(label?: string): void { + const key = label || 'default'; + const currentCount = (this.counters.get(key) || 0) + 1; + this.counters.set(key, currentCount); + log(this.formatMessage(`${key}: ${currentCount}`)); + } + + /** + * The **`console.countReset()`** static method resets counter used with console/count_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) + */ + countReset(label?: string): void { + const key = label || 'default'; + this.counters.delete(key); + } + + /** + * The **`console.debug()`** static method outputs a message to the console at the 'debug' log + * level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) + */ + debug(...data: any[]): void { + process.stdout.write('\n'); // Add newline after clearing status + debug(this.formatMessage(...data)); + } + + /** + * The **`console.dir()`** static method displays a list of the properties of the specified + * JavaScript object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) + */ + dir(item?: any, options?: any): void { + // TODO: Implement this with our own logger + console.dir(item, options); + } + + /** + * The **`console.dirxml()`** static method displays an interactive tree of the descendant + * elements of the specified XML/HTML element. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) + */ + dirxml(...data: any[]): void { + // TODO: Implement this with our own logger + console.dirxml(...data); + } + + /** + * The **`console.error()`** static method outputs a message to the console at the 'error' log + * level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) + */ + error(...data: any[]): void { + process.stdout.write('\n'); // Add newline after clearing status + error(this.formatMessage(...data)); + } + + /** + * The **`console.group()`** static method creates a new inline group in the Web console log, + * causing any subsequent console messages to be indented by an additional level, until + * console/groupEnd_static is called. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) + */ + group(...data: any[]): void { + // TODO: Implement this with our own logger + console.group(...data); + } + + /** + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) + */ + groupCollapsed(...data: any[]): void { + // TODO: Implement this with our own logger + console.groupCollapsed(...data); + } + + /** + * The **`console.groupEnd()`** static method exits the current inline group in the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) + */ + groupEnd(): void { + // TODO: Implement this with our own logger + console.groupEnd(); + } + + /** + * The **`console.info()`** static method outputs a message to the console at the 'info' log + * level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) + */ + info(...data: any[]): void { + process.stdout.write('\n'); // Add newline after clearing status + // "info" logger shouldn't be used in the console logger, because info should be reserved for important messages + log(this.formatMessage(...data)); + } + + /** + * The **`console.log()`** static method outputs a message to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) + */ + log(...data: any[]): void { + process.stdout.write('\n'); // Add newline after clearing status + log(this.formatMessage(...data)); + } + + /** + * The **`console.table()`** static method displays tabular data as a table. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) + */ + table(tabularData?: any, properties?: string[]): void { + // TODO: Implement this with our own logger + console.table(tabularData, properties); + } + + /** + * The **`console.time()`** static method starts a timer you can use to track how long an + * operation takes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) + */ + time(label?: string): void { + const key = label || 'default'; + // TODO: Implement this with our own logger + this.timers.set(key, Date.now()); + } + + /** + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling + * console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) + */ + timeEnd(label?: string): void { + const key = label || 'default'; + const startTime = this.timers.get(key); + if (startTime) { + const duration = Date.now() - startTime; + log(this.formatMessage(`${key}: ${duration}ms`)); + this.timers.delete(key); + } else { + warn(this.formatMessage(`Timer '${key}' does not exist`)); + } + } + + /** + * The **`console.timeLog()`** static method logs the current value of a timer that was previously + * started by calling console/time_static. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) + */ + timeLog(label?: string, ...data: any[]): void { + const key = label || 'default'; + const startTime = this.timers.get(key); + if (startTime) { + const duration = Date.now() - startTime; + log(this.formatMessage(`${key}: ${duration}ms`, ...data)); + } else { + warn(this.formatMessage(`Timer '${key}' does not exist`)); + } + } + + timeStamp(label?: string): void { + const timestamp = new Date().toISOString(); + log(this.formatMessage(`[${timestamp}]${label ? ` ${label}` : ''}`)); + } + + /** + * The **`console.trace()`** static method outputs a stack trace to the console. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) + */ + trace(...data: any[]): void { + // TODO: Implement this with our own logger + console.trace(...data); + } + + /** + * The **`console.warn()`** static method outputs a warning message to the console at the + * 'warning' log level. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) + */ + warn(...data: any[]): void { + process.stdout.write('\n'); // Add newline after clearing status + warn(this.formatMessage(...data)); + } + + profile(label?: string): void { + // TODO: Implement this with our own logger + console.profile(label); + log(this.formatMessage(`Profile started: ${label}`)); + } + + profileEnd(label?: string): void { + // TODO: Implement this with our own logger + console.profileEnd(label); + log(this.formatMessage(`Profile ended: ${label}`)); + } +} + +// Extended ConsoleLogger with prefix and color functionality +class StyledConsoleLogger extends ConsoleLogger { + private _prefix: string; + private _color: ConsoleLoggerOptions['color']; + + constructor(options: ConsoleLoggerOptions) { + super(); + this._prefix = options.prefix || ''; + this._color = options.color; + } + + // Override the getter methods from parent class + protected get prefix(): string { + return this._prefix; + } + + protected get color() { + return picocolors[this._color]; + } +} + +export { ConsoleLogger, StyledConsoleLogger }; diff --git a/code/core/src/node-logger/logger/index.ts b/code/core/src/node-logger/logger/index.ts index 372eb556c5b5..09b301dc1cb8 100644 --- a/code/core/src/node-logger/logger/index.ts +++ b/code/core/src/node-logger/logger/index.ts @@ -1,3 +1,4 @@ export * from './logger'; export * from './log-tracker'; export * from './colors'; +export * from './console'; diff --git a/code/core/src/node-logger/logger/log-tracker.ts b/code/core/src/node-logger/logger/log-tracker.ts index c38f76c3ee93..457625db7f3c 100644 --- a/code/core/src/node-logger/logger/log-tracker.ts +++ b/code/core/src/node-logger/logger/log-tracker.ts @@ -3,7 +3,6 @@ import path, { join } from 'node:path'; import { isCI } from 'storybook/internal/common'; -import { cleanLog } from '../../../../lib/cli-storybook/src/automigrate/helpers/cleanLog'; import type { LogLevel } from './logger'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -16,6 +15,7 @@ export interface LogEntry { } const DEBUG_LOG_FILE_NAME = 'debug-storybook.log'; +const DEFAULT_LOG_FILE_PATH = join(process.cwd(), DEBUG_LOG_FILE_NAME); /** * Tracks and manages logs for Storybook CLI operations. Provides functionality to collect, store @@ -24,19 +24,13 @@ const DEBUG_LOG_FILE_NAME = 'debug-storybook.log'; class LogTracker { /** Array to store log entries */ #logs: LogEntry[] = []; - /** Path where log file will be written */ - #logFilePath = ''; /** * Flag indicating if logs should be written to file it is enabled either by users providing the - * `--write-logs` flag to a CLI command or when we explicitly enable it by calling + * `--logfile` flag to a CLI command or when we explicitly enable it by calling * `logTracker.enableLogWriting()` e.g. in automigrate or doctor command when there are issues */ #shouldWriteLogsToFile = false; - constructor() { - this.#logFilePath = join(process.cwd(), DEBUG_LOG_FILE_NAME); - } - /** Enables writing logs to file. */ enableLogWriting(): void { this.#shouldWriteLogsToFile = true; @@ -47,11 +41,6 @@ class LogTracker { return this.#shouldWriteLogsToFile; } - /** Returns the configured log file path. */ - get logFilePath(): string { - return this.#logFilePath; - } - /** Returns a copy of all stored logs. */ get logs(): LogEntry[] { return [...this.#logs]; @@ -68,7 +57,7 @@ class LogTracker { this.#logs.push({ timestamp: new Date(), level, - message: cleanLog(message), + message, metadata, }); } @@ -85,7 +74,9 @@ class LogTracker { * @returns The path where logs were written, by default is debug-storybook.log in current working * directory */ - async writeToFile(filePath: string = this.#logFilePath): Promise { + async writeToFile(filePath: string | boolean | undefined): Promise { + const logFilePath = typeof filePath === 'string' ? filePath : DEFAULT_LOG_FILE_PATH; + const logContent = this.#logs .map((log) => { const timestamp = @@ -96,10 +87,10 @@ class LogTracker { }) .join('\n'); - await fs.writeFile(filePath, logContent, 'utf-8'); + await fs.writeFile(logFilePath, logContent, 'utf-8'); this.#logs = []; - return isCI() ? filePath : path.relative(process.cwd(), filePath); + return isCI() ? logFilePath : path.relative(process.cwd(), logFilePath); } } diff --git a/code/core/src/node-logger/logger/logger.ts b/code/core/src/node-logger/logger/logger.ts index 41dc5def53fd..a6faf8bb95a8 100644 --- a/code/core/src/node-logger/logger/logger.ts +++ b/code/core/src/node-logger/logger/logger.ts @@ -1,20 +1,33 @@ import * as clack from '@clack/prompts'; -import boxen from 'boxen'; import { isClackEnabled } from '../prompts/prompt-config'; -import { currentTaskLog } from '../prompts/prompt-provider-clack'; +import { getCurrentTaskLog } from '../prompts/prompt-provider-clack'; import { wrapTextForClack } from '../wrap-utils'; import { CLI_COLORS } from './colors'; import { logTracker } from './log-tracker'; const createLogFunction = - (clackFn: (message: string) => void, consoleFn: (...args: any[]) => void) => () => + any>( + clackFn: T, + consoleFn: (...args: Parameters) => void, + cliColors?: (typeof CLI_COLORS)[keyof typeof CLI_COLORS] + ) => + () => isClackEnabled() - ? (message: string) => { + ? (...args: Parameters) => { + const [message, ...rest] = args; + const currentTaskLog = getCurrentTaskLog(); if (currentTaskLog) { - currentTaskLog.message(message); + currentTaskLog.message( + cliColors && typeof message === 'string' ? cliColors(message) : message + ); } else { - clackFn(wrapTextForClack(message)); + // If first parameter is a string, wrap; otherwise pass as-is + if (typeof message === 'string') { + (clackFn as T)(wrapTextForClack(message), ...rest); + } else { + (clackFn as T)(message, ...rest); + } } } : consoleFn; @@ -22,8 +35,8 @@ const createLogFunction = const LOG_FUNCTIONS = { log: createLogFunction(clack.log.message, console.log), info: createLogFunction(clack.log.info, console.log), - warn: createLogFunction(clack.log.warn, console.warn), - error: createLogFunction(clack.log.error, console.error), + warn: createLogFunction(clack.log.warn, console.warn, CLI_COLORS.warning), + error: createLogFunction(clack.log.error, console.error, CLI_COLORS.error), intro: createLogFunction(clack.intro, console.log), outro: createLogFunction(clack.outro, console.log), step: createLogFunction(clack.log.step, console.log), @@ -96,26 +109,30 @@ const formatLogMessage = (args: any[]): string => { }; // Higher-level abstraction for creating logging functions -function createLogger( +function createLogger void>( level: LogLevel | 'prompt', - logFn: (message: string) => void, + logFn: T, prefix?: string ) { - return function logFunction(...args: any[]) { - const message = formatLogMessage(args); - logTracker.addLog(level, message); + return function logFunction(...args: Parameters) { + const [message, ...rest] = args; + const msg = formatLogMessage([message]); + logTracker.addLog(level, msg); if (level === 'prompt') { level = 'info'; } if (shouldLog(level)) { - const formattedMessage = prefix ? `${prefix} ${message}` : message; - logFn(formattedMessage); + const formattedMessage = prefix ? `${prefix} ${msg}` : message; + logFn(formattedMessage, ...rest); // in practice, logFn typically expects a string } }; } -// Create all logging functions using the factory +/** + * For detailed information useful for debugging, which is hidden by default and only appears in log + * files or when the log level is set to debug + */ export const debug = createLogger( 'debug', function logFunction(message) { @@ -127,64 +144,61 @@ export const debug = createLogger( '[DEBUG]' ); -export const log = createLogger('info', (...args) => { - return LOG_FUNCTIONS.log()(...args); -}); -export const info = createLogger('info', (...args) => { - return LOG_FUNCTIONS.info()(...args); -}); -export const warn = createLogger('warn', (...args) => { - return LOG_FUNCTIONS.warn()(...args); -}); -export const error = createLogger('error', (...args) => { - return LOG_FUNCTIONS.error()(...args); -}); - -type BoxenOptions = { - borderStyle?: 'round' | 'none'; - padding?: number; +type LogFunctionArgs any> = Parameters>; + +/** For general information that should always be visible to the user */ +export const log = createLogger('info', (...args: LogFunctionArgs) => + LOG_FUNCTIONS.log()(...args) +); +/** For general information that should catch the user's attention */ +export const info = createLogger('info', (...args: LogFunctionArgs) => + LOG_FUNCTIONS.info()(...args) +); +export const warn = createLogger('warn', (...args: LogFunctionArgs) => + LOG_FUNCTIONS.warn()(...args) +); +export const error = createLogger('error', (...args: LogFunctionArgs) => + LOG_FUNCTIONS.error()(...args) +); + +type BoxOptions = { title?: string; - titleAlignment?: 'left' | 'center' | 'right'; - borderColor?: string; - backgroundColor?: string; -}; +} & clack.BoxOptions; -export const logBox = (message: string, options?: BoxenOptions) => { +export const logBox = (message: string, { title, ...options }: BoxOptions = {}) => { if (shouldLog('info')) { logTracker.addLog('info', message); if (isClackEnabled()) { - if (options?.title) { - log(options.title); - } - log(message); + clack.box(message, title, { + ...options, + width: options.width ?? 'auto', + }); } else { - console.log( - boxen(message, { - borderStyle: 'round', - padding: 1, - borderColor: '#F1618C', // pink - ...options, - }) - ); + console.log(message); } } }; export const intro = (message: string) => { logTracker.addLog('info', message); - console.log('\n'); - LOG_FUNCTIONS.intro()(message); + if (shouldLog('info')) { + console.log(''); + LOG_FUNCTIONS.intro()(message); + } }; export const outro = (message: string) => { logTracker.addLog('info', message); - LOG_FUNCTIONS.outro()(message); - console.log('\n'); + if (shouldLog('info')) { + LOG_FUNCTIONS.outro()(message); + } }; export const step = (message: string) => { logTracker.addLog('info', message); - LOG_FUNCTIONS.step()(message); + if (shouldLog('info')) { + LOG_FUNCTIONS.step()(message); + } }; export const SYMBOLS = { diff --git a/code/core/src/node-logger/prompts/prompt-config.ts b/code/core/src/node-logger/prompts/prompt-config.ts index c79cc8152974..971f743cd0de 100644 --- a/code/core/src/node-logger/prompts/prompt-config.ts +++ b/code/core/src/node-logger/prompts/prompt-config.ts @@ -1,18 +1,13 @@ -import { optionalEnvToBoolean } from '../../common/utils/envs'; import type { PromptProvider } from './prompt-provider-base'; import { ClackPromptProvider } from './prompt-provider-clack'; -import { PromptsPromptProvider } from './prompt-provider-prompts'; -type PromptLibrary = 'clack' | 'prompts'; +type PromptLibrary = 'clack'; const PROVIDERS = { clack: new ClackPromptProvider(), - prompts: new PromptsPromptProvider(), } as const; -let currentPromptLibrary: PromptLibrary = optionalEnvToBoolean(process.env.USE_CLACK) - ? 'clack' - : 'prompts'; +let currentPromptLibrary: PromptLibrary = 'clack'; export const setPromptLibrary = (library: PromptLibrary): void => { currentPromptLibrary = library; @@ -30,10 +25,6 @@ export const isClackEnabled = (): boolean => { return currentPromptLibrary === 'clack'; }; -export const isPromptsEnabled = (): boolean => { - return currentPromptLibrary === 'prompts'; -}; - /** * Returns the preferred stdio for the current prompt library. * diff --git a/code/core/src/node-logger/prompts/prompt-functions.ts b/code/core/src/node-logger/prompts/prompt-functions.ts index b1f559b31a58..30182d843900 100644 --- a/code/core/src/node-logger/prompts/prompt-functions.ts +++ b/code/core/src/node-logger/prompts/prompt-functions.ts @@ -1,3 +1,5 @@ +import { logger } from '../../client-logger'; +import { shouldLog } from '../logger'; import { wrapTextForClack, wrapTextForClackHint } from '../wrap-utils'; import { getPromptProvider } from './prompt-config'; import type { @@ -13,7 +15,6 @@ import type { TaskLogOptions, TextPromptOptions, } from './prompt-provider-base'; -import { asyncLocalStorage } from './storage'; // Re-export types for convenience export type { @@ -34,6 +35,10 @@ let activeSpinner: SpinnerInstance | null = null; let activeTaskLog: TaskLogInstance | null = null; let originalConsoleLog: typeof console.log | null = null; +const isInteractiveTerminal = () => { + return process.stdout.isTTY && process.stdin.isTTY && !process.env.CI; +}; + // Console.log patching functions const patchConsoleLog = () => { if (!originalConsoleLog) { @@ -44,9 +49,13 @@ const patchConsoleLog = () => { .join(' '); if (activeTaskLog) { - activeTaskLog.message(message); + if (shouldLog('info')) { + activeTaskLog.message(message); + } } else if (activeSpinner) { - activeSpinner.message(message); + if (shouldLog('info')) { + activeSpinner.message(message); + } } else { originalConsoleLog!(...args); } @@ -92,7 +101,7 @@ export const multiselect = async ( options: options.options.map((opt) => ({ ...opt, hint: opt.hint - ? wrapTextForClackHint(opt.hint, undefined, opt.label || String(opt.value)) + ? wrapTextForClackHint(opt.hint, undefined, opt.label || String(opt.value), 0) : undefined, })), }, @@ -101,51 +110,121 @@ export const multiselect = async ( }; export const spinner = (options: SpinnerOptions): SpinnerInstance => { - const spinnerInstance = getPromptProvider().spinner(options); - - // Wrap the spinner methods to handle console.log patching - const wrappedSpinner: SpinnerInstance = { - start: (message?: string) => { - activeSpinner = wrappedSpinner; - patchConsoleLog(); - spinnerInstance.start(message); - }, - stop: (message?: string) => { - activeSpinner = null; - restoreConsoleLog(); - spinnerInstance.stop(message); - }, - message: (text: string) => { - spinnerInstance.message(text); - }, - }; + if (isInteractiveTerminal()) { + const spinnerInstance = getPromptProvider().spinner(options); + + // Wrap the spinner methods to handle console.log patching + const wrappedSpinner: SpinnerInstance = { + start: (message?: string) => { + activeSpinner = wrappedSpinner; + patchConsoleLog(); + if (shouldLog('info')) { + spinnerInstance.start(message); + } + }, + stop: (message?: string) => { + activeSpinner = null; + restoreConsoleLog(); + if (shouldLog('info')) { + spinnerInstance.stop(message); + } + }, + message: (text: string) => { + if (shouldLog('info')) { + spinnerInstance.message(text); + } + }, + }; - return wrappedSpinner; + return wrappedSpinner; + } else { + const maybeLog = shouldLog('info') ? logger.log : (_: string) => {}; + + return { + start: (message) => { + if (message) { + maybeLog(message); + } + }, + stop: (message) => { + if (message) { + maybeLog(message); + } + }, + message: (message) => { + maybeLog(message); + }, + }; + } }; export const taskLog = (options: TaskLogOptions): TaskLogInstance => { - const task = getPromptProvider().taskLog(options); - - // Wrap the task log methods to handle console.log patching - const wrappedTaskLog: TaskLogInstance = { - message: (message: string) => { - task.message(wrapTextForClack(message)); - }, - success: (message: string, options?: { showLog?: boolean }) => { - activeTaskLog = null; - restoreConsoleLog(); - task.success(message, options); - }, - error: (message: string) => { - activeTaskLog = null; - restoreConsoleLog(); - task.error(message); - }, - }; - - // Activate console.log patching when task log is created - activeTaskLog = wrappedTaskLog; - patchConsoleLog(); + if (isInteractiveTerminal() && shouldLog('info')) { + const task = getPromptProvider().taskLog(options); + + // Wrap the task log methods to handle console.log patching + const wrappedTaskLog: TaskLogInstance = { + message: (message: string) => { + task.message(wrapTextForClack(message)); + }, + success: (message: string, options?: { showLog?: boolean }) => { + activeTaskLog = null; + restoreConsoleLog(); + task.success(message, options); + }, + error: (message: string) => { + activeTaskLog = null; + restoreConsoleLog(); + task.error(message); + }, + group: function (title: string) { + this.message(`\n${title}\n`); + return { + message: (message: string) => { + task.message(wrapTextForClack(message)); + }, + success: (message: string) => { + task.success(message); + }, + error: (message: string) => { + task.error(message); + }, + }; + }, + }; - return wrappedTaskLog; + // Activate console.log patching when task log is created + activeTaskLog = wrappedTaskLog; + patchConsoleLog(); + + return wrappedTaskLog; + } else { + const maybeLog = shouldLog('info') ? logger.log : (_: string) => {}; + + return { + message: (message: string) => { + maybeLog(message); + }, + success: (message: string) => { + maybeLog(message); + }, + error: (message: string) => { + maybeLog(message); + }, + group: (title: string) => { + maybeLog(`\n${title}\n`); + return { + message: (message: string) => { + maybeLog(message); + }, + success: (message: string) => { + maybeLog(message); + }, + error: (message: string) => { + maybeLog(message); + }, + }; + }, + }; + } }; diff --git a/code/core/src/node-logger/prompts/prompt-provider-base.ts b/code/core/src/node-logger/prompts/prompt-provider-base.ts index 9cbb511b7a8a..b9b2bd9b54c9 100644 --- a/code/core/src/node-logger/prompts/prompt-provider-base.ts +++ b/code/core/src/node-logger/prompts/prompt-provider-base.ts @@ -53,6 +53,11 @@ export interface TaskLogInstance { message: (text: string) => void; success: (message: string, options?: { showLog?: boolean }) => void; error: (message: string) => void; + group: (title: string) => { + message: (text: string, options?: any) => void; + success: (message: string) => void; + error: (message: string) => void; + }; } export interface SpinnerOptions { diff --git a/code/core/src/node-logger/prompts/prompt-provider-clack.ts b/code/core/src/node-logger/prompts/prompt-provider-clack.ts index 4a527fe48ce1..38880c47dfca 100644 --- a/code/core/src/node-logger/prompts/prompt-provider-clack.ts +++ b/code/core/src/node-logger/prompts/prompt-provider-clack.ts @@ -1,6 +1,7 @@ import * as clack from '@clack/prompts'; import { logTracker } from '../logger/log-tracker'; +import { wrapTextForClackHint } from '../wrap-utils'; import type { ConfirmPromptOptions, MultiSelectPromptOptions, @@ -14,7 +15,26 @@ import type { } from './prompt-provider-base'; import { PromptProvider } from './prompt-provider-base'; -export let currentTaskLog: ReturnType | null = null; +export const getCurrentTaskLog = (): ReturnType | null => { + if (globalThis.STORYBOOK_CURRENT_TASK_LOG) { + return globalThis.STORYBOOK_CURRENT_TASK_LOG[globalThis.STORYBOOK_CURRENT_TASK_LOG.length - 1]; + } else { + return null; + } +}; + +const setCurrentTaskLog = (taskLog: any) => { + globalThis.STORYBOOK_CURRENT_TASK_LOG = [ + ...(globalThis.STORYBOOK_CURRENT_TASK_LOG || []), + taskLog, + ]; +}; + +const clearCurrentTaskLog = () => { + if (globalThis.STORYBOOK_CURRENT_TASK_LOG) { + globalThis.STORYBOOK_CURRENT_TASK_LOG.pop(); + } +}; export class ClackPromptProvider extends PromptProvider { private handleCancel(result: unknown | symbol, promptOptions?: PromptOptions) { @@ -36,14 +56,20 @@ export class ClackPromptProvider extends PromptProvider { } async confirm(options: ConfirmPromptOptions, promptOptions?: PromptOptions): Promise { - const result = await clack.confirm(options); + const result = await clack.confirm({ + ...options, + message: wrapTextForClackHint(options.message, undefined, undefined, 2), + }); this.handleCancel(result, promptOptions); logTracker.addLog('prompt', options.message, { choice: result }); return Boolean(result); } async select(options: SelectPromptOptions, promptOptions?: PromptOptions): Promise { - const result = await clack.select(options); + const result = await clack.select({ + ...options, + message: wrapTextForClackHint(options.message, undefined, undefined, 2), + }); this.handleCancel(result, promptOptions); logTracker.addLog('prompt', options.message, { choice: result }); return result as T; @@ -83,11 +109,14 @@ export class ClackPromptProvider extends PromptProvider { } taskLog(options: TaskLogOptions): TaskLogInstance { - const task = clack.taskLog(options); + const isCurrentTaskActive = !!getCurrentTaskLog(); + const task = getCurrentTaskLog() || clack.taskLog(options); const taskId = `${options.id}-task`; logTracker.addLog('info', `${taskId}-start: ${options.title}`); - currentTaskLog = task; + if (!isCurrentTaskActive) { + setCurrentTaskLog(task); + } return { message: (message) => { @@ -97,12 +126,34 @@ export class ClackPromptProvider extends PromptProvider { error: (message) => { logTracker.addLog('error', `${taskId}-error: ${message}`); task.error(message, { showLog: true }); - currentTaskLog = null; + clearCurrentTaskLog(); }, success: (message, options) => { logTracker.addLog('info', `${taskId}-success: ${message}`); - task.success(message, options); - currentTaskLog = null; + if (!isCurrentTaskActive) { + task.success(message, options); + } + clearCurrentTaskLog(); + }, + group(title) { + logTracker.addLog('info', `${taskId}-group: ${title}`); + const group = task.group(title); + + setCurrentTaskLog(group); + + return { + message: (message) => { + group.message(message); + }, + success: (message) => { + group.success(message); + clearCurrentTaskLog(); + }, + error: (message) => { + group.error(message); + clearCurrentTaskLog(); + }, + }; }, }; } diff --git a/code/core/src/node-logger/prompts/prompt-provider-prompts.ts b/code/core/src/node-logger/prompts/prompt-provider-prompts.ts deleted file mode 100644 index 819de4fbc686..000000000000 --- a/code/core/src/node-logger/prompts/prompt-provider-prompts.ts +++ /dev/null @@ -1,176 +0,0 @@ -import prompts from 'prompts'; - -import { logger } from '..'; -import { logTracker } from '../logger/log-tracker'; -import type { - ConfirmPromptOptions, - MultiSelectPromptOptions, - PromptOptions, - SelectPromptOptions, - SpinnerInstance, - SpinnerOptions, - TaskLogInstance, - TaskLogOptions, - TextPromptOptions, -} from './prompt-provider-base'; -import { PromptProvider } from './prompt-provider-base'; - -export class PromptsPromptProvider extends PromptProvider { - private getBaseOptions(promptOptions?: PromptOptions) { - return { - onCancel: () => { - if (promptOptions?.onCancel) { - promptOptions.onCancel(); - } else { - logger.info('Operation canceled.'); - process.exit(0); - } - }, - }; - } - - async text(options: TextPromptOptions, promptOptions?: PromptOptions): Promise { - const validate = options.validate - ? (value: string) => { - const result = options.validate!(value); - if (result instanceof Error) { - return result.message; - } - if (typeof result === 'string') { - return result; - } - return true; - } - : undefined; - - const result = await prompts( - { - type: 'text', - name: 'value', - message: options.message, - initial: options.initialValue, - validate, - }, - { ...this.getBaseOptions(promptOptions) } - ); - - logTracker.addLog('prompt', options.message, { choice: result.value }); - return result.value; - } - - async confirm(options: ConfirmPromptOptions, promptOptions?: PromptOptions): Promise { - const result = await prompts( - { - type: 'confirm', - name: 'value', - message: options.message, - initial: options.initialValue, - active: options.active, - inactive: options.inactive, - }, - { ...this.getBaseOptions(promptOptions) } - ); - - logTracker.addLog('prompt', options.message, { choice: result.value }); - return result.value; - } - - async select(options: SelectPromptOptions, promptOptions?: PromptOptions): Promise { - const result = await prompts( - { - type: 'select', - name: 'value', - message: options.message, - choices: options.options.map((opt) => ({ - title: opt.label || String(opt.value), - value: opt.value, - description: opt.hint, - selected: opt.value === options.initialValue, - })), - }, - { ...this.getBaseOptions(promptOptions) } - ); - - logTracker.addLog('prompt', options.message, { choice: result.value }); - return result.value as T; - } - - async multiselect( - options: MultiSelectPromptOptions, - promptOptions?: PromptOptions - ): Promise { - const result = await prompts( - { - type: 'multiselect', - name: 'value', - message: options.message, - choices: options.options.map((opt) => ({ - title: opt.label || String(opt.value), - value: opt.value, - description: opt.hint, - selected: options.initialValues?.includes(opt.value), - })), - min: options.required ? 1 : 0, - }, - { ...this.getBaseOptions(promptOptions) } - ); - - logTracker.addLog('prompt', options.message, { choice: result.value }); - return result.value as T[]; - } - - spinner(options: SpinnerOptions): SpinnerInstance { - // Simple spinner implementation using process.stdout.write since prompts doesn't have a built-in spinner - let interval: NodeJS.Timeout; - const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - let i = 0; - const spinnerId = `${options.id}-spinner`; - - return { - start: (message?: string) => { - logTracker.addLog('info', `${spinnerId}-start: ${message}`); - process.stdout.write('\x1b[?25l'); // Hide cursor - interval = setInterval(() => { - process.stdout.write(`\r${chars[i]} ${message || 'Loading...'}`); - i = (i + 1) % chars.length; - }, 100); - }, - stop: (message?: string) => { - logTracker.addLog('info', `${spinnerId}-stop: ${message}`); - clearInterval(interval); - process.stdout.write('\x1b[?25h'); // Show cursor - if (message) { - process.stdout.write(`\r✓ ${message}\n`); - } else { - process.stdout.write('\r\x1b[K'); // Clear line - } - }, - message: (text: string) => { - logTracker.addLog('info', `${spinnerId}: ${text}`); - process.stdout.write(`\r${text}`); - }, - }; - } - - taskLog(options: TaskLogOptions): TaskLogInstance { - // Simple logs because prompts doesn't allow for clearing lines - logger.info(`${options.title}\n`); - const taskId = `${options.id}-task`; - logTracker.addLog('info', `${taskId}-start: ${options.title}`); - - return { - message: (text: string) => { - logger.info(text); - logTracker.addLog('info', `${taskId}: ${text}`); - }, - success: (message: string) => { - logger.info(message); - logTracker.addLog('info', `${taskId}-success: ${message}`); - }, - error: (message: string) => { - logger.error(message); - logTracker.addLog('error', `${taskId}-error: ${message}`); - }, - }; - } -} diff --git a/code/core/src/node-logger/tasks.ts b/code/core/src/node-logger/tasks.ts index 28afc19d618a..cbd5b67569e5 100644 --- a/code/core/src/node-logger/tasks.ts +++ b/code/core/src/node-logger/tasks.ts @@ -1,30 +1,78 @@ // eslint-disable-next-line depend/ban-dependencies import type { ExecaChildProcess } from 'execa'; +import { CLI_COLORS, log } from './logger'; import { logTracker } from './logger/log-tracker'; -import { spinner, taskLog } from './prompts/prompt-functions'; +import { spinner } from './prompts/prompt-functions'; + +type ChildProcessFactory = (signal?: AbortSignal) => ExecaChildProcess; + +interface SetupAbortControllerResult { + abortController: AbortController; + cleanup: () => void; +} + +function setupAbortController(): SetupAbortControllerResult { + const abortController = new AbortController(); + let isRawMode = false; + const wasRawMode = process.stdin.isRaw; + + const onKeyPress = (chunk: Buffer) => { + const key = chunk.toString(); + if (key === 'c' || key === 'C') { + abortController.abort(); + } + }; + + const cleanup = () => { + if (isRawMode) { + process.stdin.setRawMode(wasRawMode ?? false); + process.stdin.removeListener('data', onKeyPress); + if (!wasRawMode) { + process.stdin.pause(); + } + } + }; + + // Set up stdin in raw mode to capture single keypresses + if (process.stdin.isTTY) { + try { + isRawMode = true; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', onKeyPress); + } catch { + isRawMode = false; + } + } + + return { abortController, cleanup }; +} /** * Given a function that returns a child process or array of functions that return child processes, * this function will execute them sequentially and display the output in a task log. */ export const executeTask = async ( - childProcessFactories: (() => ExecaChildProcess) | (() => ExecaChildProcess)[], + childProcessFactories: ChildProcessFactory | ChildProcessFactory[], { - id, intro, error, success, - limitLines = 4, - }: { id: string; intro: string; error: string; success: string; limitLines?: number } + abortable = false, + }: { intro: string; error: string; success: string; abortable?: boolean } ) => { logTracker.addLog('info', intro); - const task = taskLog({ - id, - title: intro, - retainLog: false, - limit: limitLines, - }); + log(intro); + + let abortController: AbortController | undefined; + let cleanup: (() => void) | undefined; + + if (abortable) { + const result = setupAbortController(); + abortController = result.abortController; + cleanup = result.cleanup; + } const factories = Array.isArray(childProcessFactories) ? childProcessFactories @@ -32,29 +80,57 @@ export const executeTask = async ( try { for (const factory of factories) { - const childProcess = factory(); + const childProcess = factory(abortController?.signal); childProcess.stdout?.on('data', (data: Buffer) => { const message = data.toString().trim(); logTracker.addLog('info', message); - task.message(message); + log(message); }); await childProcess; } logTracker.addLog('info', success); - task.success(success); - } catch (err) { + log(CLI_COLORS.success(success)); + } catch (err: any) { + const isAborted = + abortController?.signal.aborted || + err.message?.includes('Command was killed with SIGINT') || + err.message?.includes('The operation was aborted'); + + if (isAborted) { + logTracker.addLog('info', `${intro} aborted`); + log(CLI_COLORS.error(`${intro} aborted`)); + return; + } const errorMessage = err instanceof Error ? (err.stack ?? err.message) : String(err); logTracker.addLog('error', error, { error: errorMessage }); - task.error(error); + log(CLI_COLORS.error(String((err as any).message ?? err))); throw err; + } finally { + cleanup?.(); } }; export const executeTaskWithSpinner = async ( - childProcessFactories: (() => ExecaChildProcess) | (() => ExecaChildProcess)[], - { id, intro, error, success }: { id: string; intro: string; error: string; success: string } + childProcessFactories: ChildProcessFactory | ChildProcessFactory[], + { + id, + intro, + error, + success, + abortable = false, + }: { id: string; intro: string; error: string; success: string; abortable?: boolean } ) => { logTracker.addLog('info', intro); + + let abortController: AbortController | undefined; + let cleanup: (() => void) | undefined; + + if (abortable) { + const result = setupAbortController(); + abortController = result.abortController; + cleanup = result.cleanup; + } + const task = spinner({ id }); task.start(intro); @@ -64,7 +140,7 @@ export const executeTaskWithSpinner = async ( try { for (const factory of factories) { - const childProcess = factory(); + const childProcess = factory(abortController?.signal); childProcess.stdout?.on('data', (data: Buffer) => { const message = data.toString().trim().slice(0, 25); logTracker.addLog('info', `${intro}: ${data.toString()}`); @@ -74,9 +150,22 @@ export const executeTaskWithSpinner = async ( } logTracker.addLog('info', success); task.stop(success); - } catch (err) { - logTracker.addLog('error', error, { error: err }); - task.stop(error); + } catch (err: any) { + const isAborted = + abortController?.signal.aborted || + err.message?.includes('Command was killed with SIGINT') || + err.message?.includes('The operation was aborted'); + + if (isAborted) { + logTracker.addLog('info', `${intro} aborted`); + task.stop(CLI_COLORS.warning(`${intro} aborted`)); + return; + } + const errorMessage = err instanceof Error ? (err.stack ?? err.message) : String(err); + logTracker.addLog('error', error, { error: errorMessage }); + task.stop(CLI_COLORS.error(error)); throw err; + } finally { + cleanup?.(); } }; diff --git a/code/core/src/node-logger/wrap-utils.test.ts b/code/core/src/node-logger/wrap-utils.test.ts index 5ca742d5b28d..0b280e69909c 100644 --- a/code/core/src/node-logger/wrap-utils.test.ts +++ b/code/core/src/node-logger/wrap-utils.test.ts @@ -80,6 +80,9 @@ describe('wrap-utils', () => { describe('wrapTextForClack', () => { beforeEach(() => { + // Note: execaSync mock is not actually used by the implementation + // which reads process.env directly, but kept for compatibility + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(execaSync).mockImplementation((cmd: string, args: any) => { if (args && args[0] === '$TERM_PROGRAM') { return { @@ -93,7 +96,7 @@ describe('wrap-utils', () => { } return { stdout: '', - }; + } as any; }); }); @@ -340,7 +343,9 @@ describe('wrap-utils', () => { describe('protectUrls', () => { beforeEach(() => { - // Mock execaSync for supportsHyperlinks detection + // Note: execaSync mock is not actually used by the implementation + // which reads process.env directly, but kept for compatibility + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(execaSync).mockImplementation((cmd: string, args: any) => { if (args && args[0] === '$TERM_PROGRAM') { return { @@ -354,7 +359,7 @@ describe('wrap-utils', () => { } return { stdout: '', - }; + } as any; }); }); @@ -498,23 +503,28 @@ describe('wrap-utils', () => { }); it('should not modify text when terminal does not support hyperlinks', () => { - // Mock execaSync to return unsupported terminal - vi.mocked(execaSync).mockImplementation((cmd, args: any) => { - if (args && args[0] === '$TERM_PROGRAM') { - return { - stdout: 'Apple_Terminal', - } as any; - } - return { - stdout: '', - }; - }); + // Mock process.env to return unsupported terminal + const originalEnv = process.env.TERM_PROGRAM; + const originalVersion = process.env.TERM_PROGRAM_VERSION; + + process.env.TERM_PROGRAM = 'Apple_Terminal'; + delete process.env.TERM_PROGRAM_VERSION; const text = 'Visit https://example.com for info'; const result = protectUrls(text); expect(result).toBe(text); expect(result).not.toContain('\u001b]8;;'); + + // Restore original env + if (originalEnv) { + process.env.TERM_PROGRAM = originalEnv; + } else { + delete process.env.TERM_PROGRAM; + } + if (originalVersion) { + process.env.TERM_PROGRAM_VERSION = originalVersion; + } }); it('should handle complex URLs with ports and authentication', () => { diff --git a/code/core/src/node-logger/wrap-utils.ts b/code/core/src/node-logger/wrap-utils.ts index d4f84f6e5b96..8382c58bb2d4 100644 --- a/code/core/src/node-logger/wrap-utils.ts +++ b/code/core/src/node-logger/wrap-utils.ts @@ -1,6 +1,4 @@ import { S_BAR } from '@clack/prompts'; -// eslint-disable-next-line depend/ban-dependencies -import { execaSync } from 'execa'; import { cyan, dim, reset } from 'picocolors'; import wrapAnsi from 'wrap-ansi'; @@ -32,7 +30,7 @@ function getVisibleLength(str: string): number { } function getEnvFromTerminal(key: string): string { - return execaSync('echo', [`$${key}`], { shell: true }).stdout.trim(); + return (process.env[key] || '').trim(); } /** @@ -62,8 +60,8 @@ function supportsHyperlinks(): boolean { // Most other modern terminals support hyperlinks return true; } - } catch (error) { - // If we can't execute shell commands, fall back to conservative default + } catch { + // If we can't access environment variables, fall back to conservative default return false; } } @@ -218,7 +216,13 @@ export { getTerminalWidth, supportsHyperlinks }; * Specialized wrapper for hint text that adds stroke characters (│) to continuation lines to * maintain visual consistency with clack's multiselect prompts */ -export function wrapTextForClackHint(text: string, width?: number, label?: string): string { +export function wrapTextForClackHint( + text: string, + width?: number, + label?: string, + // Total chars before hint text starts: "│ " + "◼ " + _indentSpaces = 3 + 1 +): string { const terminalWidth = width || getTerminalWidth(); // Calculate the space taken by the label @@ -235,7 +239,7 @@ export function wrapTextForClackHint(text: string, width?: number, label?: strin // For continuation lines, we only need to account for the indentation // Format: "│ continuation text..." - const indentSpaces = 3 + 1; // Total chars before hint text starts: "│ " + "◼ " + const indentSpaces = _indentSpaces; const continuationLineWidth = getOptimalWidth(Math.max(terminalWidth - indentSpaces, 30)); // First, try wrapping with the continuation line width for optimal wrapping @@ -288,7 +292,7 @@ export function wrapTextForClackHint(text: string, width?: number, label?: strin } // Use reset + cyan to counteract clack's dimming effect on the vertical line - const indentation = reset(cyan(S_BAR)) + ' '.repeat(indentSpaces); + const indentation = indentSpaces > 0 ? reset(cyan(S_BAR)) + ' '.repeat(indentSpaces) : ''; // Add proper indentation to all lines except the first one return finalLines diff --git a/code/core/src/server-errors.ts b/code/core/src/server-errors.ts index e5ee2fe5e685..a258eec27461 100644 --- a/code/core/src/server-errors.ts +++ b/code/core/src/server-errors.ts @@ -455,6 +455,31 @@ export class GenerateNewProjectOnInitError extends StorybookError { } } +export class AddonVitestPostinstallPrerequisiteCheckError extends StorybookError { + constructor(public data: { reasons: string[] }) { + super({ + name: 'AddonVitestPostinstallPrerequisiteCheckError', + category: Category.CLI_INIT, + isHandledError: true, + code: 4, + documentation: '', + message: 'The prerequisite check for the Vitest addon failed.', + }); + } +} + +export class AddonVitestPostinstallError extends StorybookError { + constructor(public data: { errors: string[] }) { + super({ + name: 'AddonVitestPostinstallError', + category: Category.CLI_INIT, + isHandledError: true, + code: 5, + message: 'The Vitest addon setup failed.', + }); + } +} + export class UpgradeStorybookToLowerVersionError extends StorybookError { constructor(public data: { beforeVersion: string; currentVersion: string }) { super({ @@ -580,3 +605,16 @@ export class CommonJsConfigNotSupportedError extends StorybookError { }); } } + +export class AutomigrateError extends StorybookError { + constructor(public data: { errors: Array }) { + super({ + name: 'AutomigrateError', + category: Category.CLI_AUTOMIGRATE, + code: 2, + message: dedent` + An error occurred while running the automigrate command. + `, + }); + } +} diff --git a/code/core/src/shared/utils/module.ts b/code/core/src/shared/utils/module.ts index e4aa51f84e35..7183241abdf0 100644 --- a/code/core/src/shared/utils/module.ts +++ b/code/core/src/shared/utils/module.ts @@ -29,7 +29,12 @@ export const resolvePackageDir = ( pkg: Parameters[0], parent?: Parameters[0] ) => { - return dirname(fileURLToPath(importMetaResolve(join(pkg, 'package.json'), parent))); + try { + return dirname(fileURLToPath(importMetaResolve(join(pkg, 'package.json'), parent))); + } catch { + // Necessary fallback for Bun runtime + return dirname(fileURLToPath(importMetaResolve(join(pkg, 'package.json')))); + } }; let isTypescriptLoaderRegistered = false; diff --git a/code/core/src/storybook-error.ts b/code/core/src/storybook-error.ts index b502eb212329..ffb703bd2574 100644 --- a/code/core/src/storybook-error.ts +++ b/code/core/src/storybook-error.ts @@ -50,6 +50,12 @@ export abstract class StorybookError extends Error { /** Flag used to easily determine if the error originates from Storybook. */ readonly fromStorybook: true = true as const; + /** + * Flag used to determine if the error is handled by us and should therefore not be shown to the + * user. + */ + public isHandledError = false; + get fullErrorCode() { return parseErrorCode({ code: this.code, category: this.category }); } @@ -70,12 +76,14 @@ export abstract class StorybookError extends Error { code: number; message: string; documentation?: boolean | string | string[]; + isHandledError?: boolean; name: string; }) { super(StorybookError.getFullMessage(props)); this.category = props.category; this.documentation = props.documentation ?? false; this.code = props.code; + this.isHandledError = props.isHandledError ?? false; this.name = props.name; } diff --git a/code/core/src/telemetry/error-collector.test.ts b/code/core/src/telemetry/error-collector.test.ts new file mode 100644 index 000000000000..0a2de673677f --- /dev/null +++ b/code/core/src/telemetry/error-collector.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { ErrorCollector } from './error-collector'; + +describe('ErrorCollector', () => { + it('should collect errors', () => { + const error = new Error('Test error'); + ErrorCollector.addError(error); + + expect(ErrorCollector.getErrors()).toEqual([error]); + }); +}); diff --git a/code/core/src/telemetry/error-collector.ts b/code/core/src/telemetry/error-collector.ts new file mode 100644 index 000000000000..7c2b89fe17f5 --- /dev/null +++ b/code/core/src/telemetry/error-collector.ts @@ -0,0 +1,33 @@ +/** + * Service for collecting errors during Storybook initialization. + * + * This singleton class exists to accumulate non-fatal errors that occur during the Storybook's + * processes. Instead of immediately reporting errors to telemetry (which could interrupt the + * process), errors are collected here and then batch-reported at the end of initialization via the + * telemetry system. + * + * This allows Storybook to continue e.g. initialization even when non-critical errors occur, + * ensuring a better user experience while still capturing all errors for telemetry and debugging + * purposes. + */ +export class ErrorCollector { + private static instance: ErrorCollector; + private errors: unknown[] = []; + + private constructor() {} + + public static getInstance(): ErrorCollector { + if (!ErrorCollector.instance) { + ErrorCollector.instance = new ErrorCollector(); + } + return ErrorCollector.instance; + } + + public static addError(error: unknown) { + this.getInstance().errors.push(error); + } + + public static getErrors() { + return this.getInstance().errors; + } +} diff --git a/code/core/src/telemetry/exec-command-count-lines.test.ts b/code/core/src/telemetry/exec-command-count-lines.test.ts index eacfe9f72952..ac68fca650fa 100644 --- a/code/core/src/telemetry/exec-command-count-lines.test.ts +++ b/code/core/src/telemetry/exec-command-count-lines.test.ts @@ -1,18 +1,18 @@ import type { Transform } from 'node:stream'; import { PassThrough } from 'node:stream'; -import { beforeEach, describe, expect, it, vitest } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; // eslint-disable-next-line depend/ban-dependencies -import { execaCommand as rawExecaCommand } from 'execa'; +import { execa as rawExeca } from 'execa'; import { execCommandCountLines } from './exec-command-count-lines'; -vitest.mock('execa'); +vi.mock('execa', { spy: true }); -const execaCommand = vitest.mocked(rawExecaCommand); +const execa = vi.mocked(rawExeca); beforeEach(() => { - execaCommand.mockReset(); + execa.mockReset(); }); type ExecaStreamer = typeof Promise & { @@ -22,9 +22,9 @@ type ExecaStreamer = typeof Promise & { function createExecaStreamer() { let resolver: () => void; - const promiseLike: ExecaStreamer = new Promise((aResolver, aRejecter) => { + const promiseLike = new Promise((aResolver) => { resolver = aResolver; - }) as any; + }) as unknown as ExecaStreamer; promiseLike.stdout = new PassThrough(); // @ts-expect-error technically it is invalid to use resolver "before" it is assigned (but not really) @@ -35,9 +35,9 @@ function createExecaStreamer() { describe('execCommandCountLines', () => { it('counts lines, many', async () => { const streamer = createExecaStreamer(); - execaCommand.mockReturnValue(streamer as any); + execa.mockReturnValue(streamer as unknown as ReturnType); - const promise = execCommandCountLines('some command'); + const promise = execCommandCountLines('some command', []); streamer.stdout.write('First line\n'); streamer.stdout.write('Second line\n'); @@ -48,9 +48,9 @@ describe('execCommandCountLines', () => { it('counts lines, one', async () => { const streamer = createExecaStreamer(); - execaCommand.mockReturnValue(streamer as any); + execa.mockReturnValue(streamer as unknown as ReturnType); - const promise = execCommandCountLines('some command'); + const promise = execCommandCountLines('some command', []); streamer.stdout.write('First line\n'); streamer.kill(); @@ -60,9 +60,9 @@ describe('execCommandCountLines', () => { it('counts lines, none', async () => { const streamer = createExecaStreamer(); - execaCommand.mockReturnValue(streamer as any); + execa.mockReturnValue(streamer as unknown as ReturnType); - const promise = execCommandCountLines('some command'); + const promise = execCommandCountLines('some command', []); streamer.kill(); diff --git a/code/core/src/telemetry/exec-command-count-lines.ts b/code/core/src/telemetry/exec-command-count-lines.ts index fdc4547ce464..3ca85bb1244a 100644 --- a/code/core/src/telemetry/exec-command-count-lines.ts +++ b/code/core/src/telemetry/exec-command-count-lines.ts @@ -1,7 +1,7 @@ import { createInterface } from 'node:readline'; // eslint-disable-next-line depend/ban-dependencies -import { execaCommand } from 'execa'; +import { execa } from 'execa'; /** * Execute a command in the local terminal and count the lines in the result @@ -12,9 +12,10 @@ import { execaCommand } from 'execa'; */ export async function execCommandCountLines( command: string, - options?: Parameters[1] + args: string[], + options?: Parameters[1] ) { - const process = execaCommand(command, { shell: true, buffer: false, ...options }); + const process = execa(command, args, { buffer: false, ...options }); if (!process.stdout) { // eslint-disable-next-line local-rules/no-uncategorized-errors throw new Error('Unexpected missing stdout'); diff --git a/code/core/src/telemetry/get-application-file-count.ts b/code/core/src/telemetry/get-application-file-count.ts index 4f4807ddff00..abb3b6b53458 100644 --- a/code/core/src/telemetry/get-application-file-count.ts +++ b/code/core/src/telemetry/get-application-file-count.ts @@ -14,12 +14,11 @@ export const getApplicationFilesCountUncached = async (basePath: string) => { ]); const globs = bothCasesNameMatches.flatMap((match) => - extensions.map((extension) => `"${basePath}${sep}*${match}*.${extension}"`) + extensions.map((extension) => `${basePath}${sep}*${match}*.${extension}`) ); try { - const command = `git ls-files -- ${globs.join(' ')}`; - return await execCommandCountLines(command); + return await execCommandCountLines('git', ['ls-files', '--', ...globs]); } catch { return undefined; } diff --git a/code/core/src/telemetry/get-portable-stories-usage.ts b/code/core/src/telemetry/get-portable-stories-usage.ts index 0831b484ab69..a0e9f82b6db6 100644 --- a/code/core/src/telemetry/get-portable-stories-usage.ts +++ b/code/core/src/telemetry/get-portable-stories-usage.ts @@ -3,8 +3,12 @@ import { runTelemetryOperation } from './run-telemetry-operation'; export const getPortableStoriesFileCountUncached = async (path?: string) => { try { - const command = `git grep -l composeStor` + (path ? ` -- ${path}` : ''); - return await execCommandCountLines(command); + return await execCommandCountLines('git', [ + 'grep', + '-l', + 'composeStor', + ...(path ? ['--', path] : []), + ]); } catch (err: any) { // exit code 1 if no matches are found return err.exitCode === 1 ? 0 : undefined; diff --git a/code/core/src/telemetry/index.ts b/code/core/src/telemetry/index.ts index 617b2db2b7e6..71224cdedde1 100644 --- a/code/core/src/telemetry/index.ts +++ b/code/core/src/telemetry/index.ts @@ -14,6 +14,8 @@ export * from './types'; export * from './sanitize'; +export * from './error-collector'; + export { getPrecedingUpgrade } from './event-cache'; export { addToGlobalContext } from './telemetry'; @@ -59,7 +61,7 @@ export const telemetry = async ( if (!payload.error || options?.enableCrashReports) { if (process.env?.STORYBOOK_TELEMETRY_DEBUG) { - logger.info('\n[telemetry]'); + logger.info('[telemetry]'); logger.info(JSON.stringify(telemetryData, null, 2)); } await sendTelemetry(telemetryData, options); diff --git a/code/core/src/telemetry/notify.ts b/code/core/src/telemetry/notify.ts index 988d853b0b98..1bbf2be8d958 100644 --- a/code/core/src/telemetry/notify.ts +++ b/code/core/src/telemetry/notify.ts @@ -1,7 +1,7 @@ import { cache } from 'storybook/internal/common'; -import { CLI_COLORS, logger } from 'storybook/internal/node-logger'; +import { logger } from 'storybook/internal/node-logger'; -import picocolors from 'picocolors'; +import { dedent } from 'ts-dedent'; const TELEMETRY_KEY_NOTIFY_DATE = 'telemetry-notification-date'; @@ -17,12 +17,10 @@ export const notify = async () => { cache.set(TELEMETRY_KEY_NOTIFY_DATE, Date.now()); - logger.log( - `${CLI_COLORS.info('Attention:')} Storybook now collects completely anonymous telemetry regarding usage. This information is used to shape Storybook's roadmap and prioritize features.` + logger.info( + dedent` + Attention: Storybook now collects completely anonymous telemetry regarding usage. This information is used to shape Storybook's roadmap and prioritize features. You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL: + https://storybook.js.org/telemetry + ` ); - logger.log( - `You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:` - ); - logger.log(picocolors.cyan('https://storybook.js.org/telemetry')); - logger.log(''); }; diff --git a/code/core/src/telemetry/storybook-metadata.test.ts b/code/core/src/telemetry/storybook-metadata.test.ts index 59232111d1c1..6bc4fe11002f 100644 --- a/code/core/src/telemetry/storybook-metadata.test.ts +++ b/code/core/src/telemetry/storybook-metadata.test.ts @@ -4,7 +4,13 @@ import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getStorybookInfo, isCI } from 'storybook/internal/common'; -import type { PackageJson, StorybookConfig } from 'storybook/internal/types'; +import { + type PackageJson, + type StorybookConfig, + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; import { detect } from 'package-manager-detector'; @@ -35,12 +41,17 @@ const mainJsMock: StorybookConfig = { }; beforeEach(() => { - vi.mocked(getStorybookInfo).mockImplementation(() => ({ - version: '9.0.0', - framework: 'react', - frameworkPackage: '@storybook/react', - renderer: 'react', - rendererPackage: '@storybook/react', + vi.mocked(getStorybookInfo).mockImplementation(async () => ({ + framework: SupportedFramework.REACT_VITE, + renderer: SupportedRenderer.REACT, + builder: SupportedBuilder.VITE, + addons: [], + mainConfig: { + stories: [], + }, + mainConfigPath: '', + previewConfigPath: '', + managerConfigPath: '', })); vi.mocked(detect).mockImplementation(async () => ({ diff --git a/code/core/src/telemetry/storybook-metadata.ts b/code/core/src/telemetry/storybook-metadata.ts index 5c301d80fef2..c6f5db760e31 100644 --- a/code/core/src/telemetry/storybook-metadata.ts +++ b/code/core/src/telemetry/storybook-metadata.ts @@ -241,7 +241,7 @@ export const computeStorybookMetadata = async ({ portableStoriesFileCount, applicationFileCount, storybookVersion: version, - storybookVersionSpecifier: storybookInfo.version, + storybookVersionSpecifier: storybookInfo.version ?? '', language, storybookPackages, addons, diff --git a/code/core/src/telemetry/types.ts b/code/core/src/telemetry/types.ts index 69e27f4f794d..c6ea3cd99354 100644 --- a/code/core/src/telemetry/types.ts +++ b/code/core/src/telemetry/types.ts @@ -6,6 +6,7 @@ import type { MonorepoType } from './get-monorepo-type'; export type EventType = | 'boot' + | 'add' | 'dev' | 'build' | 'index' @@ -33,7 +34,10 @@ export type EventType = | 'addon-onboarding' | 'onboarding-survey' | 'mocking' - | 'preview-first-load'; + | 'automigrate' + | 'migrate' + | 'preview-first-load' + | 'doctor'; export interface Dependency { version: string | undefined; versionSpecifier?: string; diff --git a/code/core/src/types/index.ts b/code/core/src/types/index.ts index 9800d3ddd4a7..b2f088c464db 100644 --- a/code/core/src/types/index.ts +++ b/code/core/src/types/index.ts @@ -16,3 +16,7 @@ export * from './modules/renderers'; export * from './modules/status'; export * from './modules/test-provider'; export * from './modules/universal-store'; +export * from './modules/webpack'; +export * from './modules/builders'; +export * from './modules/features'; +export * from './modules/languages'; diff --git a/code/core/src/types/modules/builders.ts b/code/core/src/types/modules/builders.ts new file mode 100644 index 000000000000..29f2ffbb0883 --- /dev/null +++ b/code/core/src/types/modules/builders.ts @@ -0,0 +1,7 @@ +export enum SupportedBuilder { + // CORE + WEBPACK5 = 'webpack5', + VITE = 'vite', + // COMMUNITY + RSBUILD = 'rsbuild', +} diff --git a/code/core/src/types/modules/core-common.ts b/code/core/src/types/modules/core-common.ts index 2b7d75a0fe9a..1f4406111ada 100644 --- a/code/core/src/types/modules/core-common.ts +++ b/code/core/src/types/modules/core-common.ts @@ -8,7 +8,10 @@ import type { Server as NetServer } from 'net'; import type { Options as TelejsonOptions } from 'telejson'; import type { PackageJson as PackageJsonFromTypeFest } from 'type-fest'; +import type { SupportedBuilder } from './builders'; +import type { SupportedFramework } from './frameworks'; import type { Indexer, StoriesEntry } from './indexer'; +import type { SupportedRenderer } from './renderers'; /** ⚠️ This file contains internal WIP types they MUST NOT be exported outside this package for now! */ @@ -680,15 +683,16 @@ export type CoreCommon_AddonEntry = string | CoreCommon_OptionsEntry; export type CoreCommon_AddonInfo = { name: string; inEssentials: boolean }; export interface CoreCommon_StorybookInfo { - version: string; - // FIXME: these are renderers for now, - // need to update with framework OR fix - // the calling code - framework: string; - frameworkPackage: string; - renderer: string; - rendererPackage: string; + addons: string[]; + version?: string; + framework?: SupportedFramework; + renderer?: SupportedRenderer; + builder?: SupportedBuilder; + rendererPackage?: string; + frameworkPackage?: string; + builderPackage?: string; configDir?: string; + mainConfig: StorybookConfigRaw; mainConfigPath?: string; previewConfigPath?: string; managerConfigPath?: string; diff --git a/code/core/src/types/modules/features.ts b/code/core/src/types/modules/features.ts new file mode 100644 index 000000000000..3c289d2e5b0b --- /dev/null +++ b/code/core/src/types/modules/features.ts @@ -0,0 +1,6 @@ +export enum Feature { + DOCS = 'docs', + TEST = 'test', + ONBOARDING = 'onboarding', + A11Y = 'a11y', +} diff --git a/code/core/src/types/modules/frameworks.ts b/code/core/src/types/modules/frameworks.ts index e7bb460ca573..e48771ceb2e1 100644 --- a/code/core/src/types/modules/frameworks.ts +++ b/code/core/src/types/modules/frameworks.ts @@ -1,21 +1,26 @@ // auto generated file, do not edit -export type SupportedFrameworks = - | 'angular' - | 'ember' - | 'html-vite' - | 'nextjs' - | 'nextjs-vite' - | 'preact-vite' - | 'react-native-web-vite' - | 'react-vite' - | 'react-webpack5' - | 'server-webpack5' - | 'svelte-vite' - | 'sveltekit' - | 'vue3-vite' - | 'web-components-vite' - | 'qwik' - | 'solid' - | 'nuxt' - | 'react-rsbuild' - | 'vue3-rsbuild'; +export enum SupportedFramework { + // CORE + ANGULAR = 'angular', + EMBER = 'ember', + HTML_VITE = 'html-vite', + NEXTJS = 'nextjs', + NEXTJS_VITE = 'nextjs-vite', + PREACT_VITE = 'preact-vite', + REACT_NATIVE_WEB_VITE = 'react-native-web-vite', + REACT_VITE = 'react-vite', + REACT_WEBPACK5 = 'react-webpack5', + SERVER_WEBPACK5 = 'server-webpack5', + SVELTE_VITE = 'svelte-vite', + SVELTEKIT = 'sveltekit', + VUE3_VITE = 'vue3-vite', + WEB_COMPONENTS_VITE = 'web-components-vite', + // COMMUNITY + HTML_RSBUILD = 'html-rsbuild', + NUXT = 'nuxt', + QWIK = 'qwik', + REACT_RSBUILD = 'react-rsbuild', + SOLID = 'solid', + VUE3_RSBUILD = 'vue3-rsbuild', + WEB_COMPONENTS_RSBUILD = 'web-components-rsbuild', +} diff --git a/code/core/src/types/modules/languages.ts b/code/core/src/types/modules/languages.ts new file mode 100644 index 000000000000..be0fdf93e7ca --- /dev/null +++ b/code/core/src/types/modules/languages.ts @@ -0,0 +1,4 @@ +export enum SupportedLanguage { + JAVASCRIPT = 'javascript', + TYPESCRIPT = 'typescript', +} diff --git a/code/core/src/types/modules/renderers.ts b/code/core/src/types/modules/renderers.ts index e6fd0f650bf3..a776f6fb7040 100644 --- a/code/core/src/types/modules/renderers.ts +++ b/code/core/src/types/modules/renderers.ts @@ -1,15 +1,15 @@ -// Should match @storybook/ -export type SupportedRenderers = - | 'react' - | 'react-native' - | 'vue3' - | 'angular' - | 'ember' - | 'preact' - | 'svelte' - | 'qwik' - | 'html' - | 'web-components' - | 'server' - | 'solid' - | 'nuxt'; +export enum SupportedRenderer { + REACT = 'react', + REACT_NATIVE = 'react-native', + VUE3 = 'vue3', + ANGULAR = 'angular', + EMBER = 'ember', + PREACT = 'preact', + SVELTE = 'svelte', + QWIK = 'qwik', + HTML = 'html', + WEB_COMPONENTS = 'web-components', + SERVER = 'server', + SOLID = 'solid', + NUXT = 'nuxt', +} diff --git a/code/core/src/types/modules/webpack.ts b/code/core/src/types/modules/webpack.ts new file mode 100644 index 000000000000..2f8dcdfe6a93 --- /dev/null +++ b/code/core/src/types/modules/webpack.ts @@ -0,0 +1,4 @@ +export enum CoreWebpackCompiler { + Babel = 'babel', + SWC = 'swc', +} diff --git a/code/core/src/typings.d.ts b/code/core/src/typings.d.ts index cba197e782fe..9ae586492116 100644 --- a/code/core/src/typings.d.ts +++ b/code/core/src/typings.d.ts @@ -11,6 +11,7 @@ declare var STORYBOOK_BUILDER: string | undefined; declare var STORYBOOK_FRAMEWORK: string | undefined; declare var STORYBOOK_HOOKS_CONTEXT: any; declare var STORYBOOK_RENDERER: string | undefined; +declare var STORYBOOK_CURRENT_TASK_LOG: undefined | null | Array; declare var __STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER__: any; declare var __STORYBOOK_ADDON_INTERACTIONS_INSTRUMENTER_STATE__: any; diff --git a/code/frameworks/angular/src/builders/build-storybook/index.ts b/code/frameworks/angular/src/builders/build-storybook/index.ts index ca08aef2ddac..dfc9deb1b438 100644 --- a/code/frameworks/angular/src/builders/build-storybook/index.ts +++ b/code/frameworks/angular/src/builders/build-storybook/index.ts @@ -4,6 +4,7 @@ import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/commo import { buildStaticStandalone, withTelemetry } from 'storybook/internal/core-server'; import { addToGlobalContext } from 'storybook/internal/telemetry'; import type { CLIOptions } from 'storybook/internal/types'; +import { logger } from 'storybook/internal/node-logger'; import type { BuilderContext, @@ -190,7 +191,12 @@ function runInstance(options: StandaloneBuildOptions) { presetOptions: { ...options, corePresets: [], overridePresets: [] }, printError: printErrorDetails, }, - () => buildStaticStandalone(options) + async () => { + logger.intro('Building storybook'); + const result = await buildStaticStandalone(options); + logger.outro('Storybook build completed successfully'); + return result; + } ) ).pipe(catchError((error: any) => throwError(errorSummary(error)))); } diff --git a/code/frameworks/angular/src/builders/start-storybook/index.ts b/code/frameworks/angular/src/builders/start-storybook/index.ts index 2fd75f075984..8695d87662b0 100644 --- a/code/frameworks/angular/src/builders/start-storybook/index.ts +++ b/code/frameworks/angular/src/builders/start-storybook/index.ts @@ -4,6 +4,7 @@ import { getEnvConfig, getProjectRoot, versions } from 'storybook/internal/commo import { buildDevStandalone, withTelemetry } from 'storybook/internal/core-server'; import { addToGlobalContext } from 'storybook/internal/telemetry'; import type { CLIOptions } from 'storybook/internal/types'; +import { logger } from 'storybook/internal/node-logger'; import type { BuilderContext, @@ -215,7 +216,10 @@ function runInstance(options: StandaloneOptions) { presetOptions: { ...options, corePresets: [], overridePresets: [] }, printError: printErrorDetails, }, - () => buildDevStandalone(options) + () => { + logger.intro('Starting storybook'); + return buildDevStandalone(options); + } ) .then(({ port }) => observer.next(port)) .catch((error) => { diff --git a/code/frameworks/angular/src/builders/utils/error-handler.ts b/code/frameworks/angular/src/builders/utils/error-handler.ts index af0aaddbfbba..44b75d4e55da 100644 --- a/code/frameworks/angular/src/builders/utils/error-handler.ts +++ b/code/frameworks/angular/src/builders/utils/error-handler.ts @@ -11,15 +11,13 @@ export const printErrorDetails = (error: any): void => { if ((error as any).error) { logger.error((error as any).error); } else if ((error as any).stats && (error as any).stats.compilation.errors) { - (error as any).stats.compilation.errors.forEach((e: any) => logger.plain(e)); + (error as any).stats.compilation.errors.forEach((e: any) => logger.log(e)); } else { logger.error(error as any); } } else if (error.compilation?.errors) { - error.compilation.errors.forEach((e: any) => logger.plain(e)); + error.compilation.errors.forEach((e: any) => logger.log(e)); } - - logger.line(); }; export const errorSummary = (error: any): string => { diff --git a/code/frameworks/angular/src/builders/utils/run-compodoc.spec.ts b/code/frameworks/angular/src/builders/utils/run-compodoc.spec.ts index 65b7fd73eff6..ebcf27f4c8e3 100644 --- a/code/frameworks/angular/src/builders/utils/run-compodoc.spec.ts +++ b/code/frameworks/angular/src/builders/utils/run-compodoc.spec.ts @@ -6,12 +6,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { runCompodoc } from './run-compodoc'; -const mockRunScript = vi.fn(); +const mockRunScript = vi.fn().mockResolvedValue({ stdout: '' }); vi.mock('storybook/internal/common', () => ({ JsPackageManagerFactory: { getPackageManager: () => ({ - runPackageCommandSync: mockRunScript, + runPackageCommand: mockRunScript, }), }, })); @@ -47,12 +47,10 @@ describe('runCompodoc', () => { .pipe(take(1)) .subscribe(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], - 'path/to/project', - 'inherit' - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); }); it('should run compodoc with tsconfig from compodocArgs', async () => { @@ -66,12 +64,10 @@ describe('runCompodoc', () => { .pipe(take(1)) .subscribe(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-d', 'path/to/project', '-p', 'path/to/tsconfig.stories.json'], - 'path/to/project', - 'inherit' - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-d', 'path/to/project', '-p', 'path/to/tsconfig.stories.json'], + cwd: 'path/to/project', + }); }); it('should run compodoc with default output folder.', async () => { @@ -85,12 +81,10 @@ describe('runCompodoc', () => { .pipe(take(1)) .subscribe(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], - 'path/to/project', - 'inherit' - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/project'], + cwd: 'path/to/project', + }); }); it('should run with custom output folder specified with --output compodocArgs', async () => { @@ -104,12 +98,10 @@ describe('runCompodoc', () => { .pipe(take(1)) .subscribe(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', 'path/to/tsconfig.json', '--output', 'path/to/customFolder'], - 'path/to/project', - 'inherit' - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '--output', 'path/to/customFolder'], + cwd: 'path/to/project', + }); }); it('should run with custom output folder specified with -d compodocArgs', async () => { @@ -123,11 +115,9 @@ describe('runCompodoc', () => { .pipe(take(1)) .subscribe(); - expect(mockRunScript).toHaveBeenCalledWith( - 'compodoc', - ['-p', 'path/to/tsconfig.json', '-d', 'path/to/customFolder'], - 'path/to/project', - 'inherit' - ); + expect(mockRunScript).toHaveBeenCalledWith({ + args: ['compodoc', '-p', 'path/to/tsconfig.json', '-d', 'path/to/customFolder'], + cwd: 'path/to/project', + }); }); }); diff --git a/code/frameworks/angular/src/builders/utils/run-compodoc.ts b/code/frameworks/angular/src/builders/utils/run-compodoc.ts index 623f84016844..fd0a6353306a 100644 --- a/code/frameworks/angular/src/builders/utils/run-compodoc.ts +++ b/code/frameworks/angular/src/builders/utils/run-compodoc.ts @@ -22,6 +22,7 @@ export const runCompodoc = ( return new Observable((observer) => { const tsConfigPath = toRelativePath(tsconfig); const finalCompodocArgs = [ + 'compodoc', ...(hasTsConfigArg(compodocArgs) ? [] : ['-p', tsConfigPath]), ...(hasOutputArg(compodocArgs) ? [] : ['-d', `${context.workspaceRoot || '.'}`]), ...compodocArgs, @@ -30,16 +31,16 @@ export const runCompodoc = ( const packageManager = JsPackageManagerFactory.getPackageManager(); try { - const stdout = packageManager.runPackageCommandSync( - 'compodoc', - finalCompodocArgs, - context.workspaceRoot, - 'inherit' - ); - - context.logger.info(stdout); - observer.next(); - observer.complete(); + packageManager + .runPackageCommand({ + args: finalCompodocArgs, + cwd: context.workspaceRoot, + }) + .then((result) => { + context.logger.info(result.stdout); + observer.next(); + observer.complete(); + }); } catch (e) { context.logger.error(e); observer.error(); diff --git a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts index 89cdf948e543..d702777f2b86 100644 --- a/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts +++ b/code/frameworks/angular/src/server/framework-preset-angular-cli.test.ts @@ -95,7 +95,7 @@ describe('framework-preset-angular-cli', () => { expect(mockedTargetFromTargetString).toHaveBeenCalledWith('test-project:build:development'); expect(mockedLogger.info).toHaveBeenCalledWith( - '=> Using angular browser target options from "test-project:build:development"' + 'Using angular browser target options from "test-project:build:development"' ); expect(mockBuilderContext.getTargetOptions).toHaveBeenCalledWith(mockTarget); }); @@ -145,7 +145,7 @@ describe('framework-preset-angular-cli', () => { expect(result.tsConfig).toBe('/custom/tsconfig.json'); expect(mockedLogger.info).toHaveBeenCalledWith( - '=> Using angular project with "tsConfig:/custom/tsconfig.json"' + 'Using angular project with "tsConfig:../../custom/tsconfig.json"' ); }); @@ -223,7 +223,7 @@ describe('framework-preset-angular-cli', () => { const result = await getBuilderOptions(options, mockBuilderContext); expect(mockedLogger.info).toHaveBeenCalledWith( - '=> Using angular browser target options from "test-project:build"' + 'Using angular browser target options from "test-project:build"' ); }); @@ -243,7 +243,7 @@ describe('framework-preset-angular-cli', () => { const result = await getBuilderOptions(options, mockBuilderContext); expect(mockedLogger.info).toHaveBeenCalledWith( - '=> Using angular browser target options from "test-project:build:production"' + 'Using angular browser target options from "test-project:build:production"' ); }); diff --git a/code/frameworks/angular/src/server/framework-preset-angular-cli.ts b/code/frameworks/angular/src/server/framework-preset-angular-cli.ts index 3dd0643c02bc..62ff798d118d 100644 --- a/code/frameworks/angular/src/server/framework-preset-angular-cli.ts +++ b/code/frameworks/angular/src/server/framework-preset-angular-cli.ts @@ -12,10 +12,11 @@ import type webpack from 'webpack'; import { getWebpackConfig as getCustomWebpackConfig } from './angular-cli-webpack'; import type { PresetOptions } from './preset-options'; import { getProjectRoot, resolvePackageDir } from 'storybook/internal/common'; +import { relative } from 'pathe'; export async function webpackFinal(baseConfig: webpack.Configuration, options: PresetOptions) { if (!resolvePackageDir('@angular-devkit/build-angular')) { - logger.info('=> Using base config because "@angular-devkit/build-angular" is not installed'); + logger.info('Using base config because "@angular-devkit/build-angular" is not installed'); return baseConfig; } @@ -122,7 +123,7 @@ export async function getBuilderOptions(options: PresetOptions, builderContext: const browserTarget = targetFromTargetString(options.angularBrowserTarget); logger.info( - `=> Using angular browser target options from "${browserTarget.project}:${ + `Using angular browser target options from "${browserTarget.project}:${ browserTarget.target }${browserTarget.configuration ? `:${browserTarget.configuration}` : ''}"` ); @@ -148,7 +149,9 @@ export async function getBuilderOptions(options: PresetOptions, builderContext: options.tsConfig ?? find.up('tsconfig.json', { cwd: options.configDir, last: getProjectRoot() }) ?? browserTargetOptions.tsConfig; - logger.info(`=> Using angular project with "tsConfig:${builderOptions.tsConfig}"`); + logger.info( + `Using angular project with "tsConfig:${relative(getProjectRoot(), builderOptions.tsConfig as string)}"` + ); builderOptions.experimentalZoneless = options.angularBuilderOptions?.experimentalZoneless; diff --git a/code/frameworks/nextjs/src/preset.ts b/code/frameworks/nextjs/src/preset.ts index 86da3fba457a..53cf944db194 100644 --- a/code/frameworks/nextjs/src/preset.ts +++ b/code/frameworks/nextjs/src/preset.ts @@ -191,10 +191,10 @@ export const webpackFinal: StorybookConfig['webpackFinal'] = async (baseConfig, } if (useSWC) { - logger.info('=> Using SWC as compiler'); + logger.info('Using SWC as compiler'); await configureSWCLoader(baseConfig, options, nextConfig); } else { - logger.info('=> Using Babel as compiler'); + logger.info('Using Babel as compiler'); await configureBabelLoader(baseConfig, options, nextConfig); } diff --git a/code/frameworks/react-vite/src/plugins/react-docgen.ts b/code/frameworks/react-vite/src/plugins/react-docgen.ts index bbac579c379f..5878684e90d4 100644 --- a/code/frameworks/react-vite/src/plugins/react-docgen.ts +++ b/code/frameworks/react-vite/src/plugins/react-docgen.ts @@ -50,6 +50,7 @@ export async function reactDocgen({ let matchPath: TsconfigPaths.MatchPath | undefined; if (tsconfig.resultType === 'success') { + logger.debug('Using tsconfig paths for react-docgen'); matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ 'browser', 'module', diff --git a/code/lib/cli-storybook/package.json b/code/lib/cli-storybook/package.json index 0a7c99b26e74..6d65b819faf5 100644 --- a/code/lib/cli-storybook/package.json +++ b/code/lib/cli-storybook/package.json @@ -45,7 +45,6 @@ "@types/semver": "^7.3.4", "commander": "^14.0.1", "create-storybook": "workspace:*", - "giget": "^2.0.0", "jscodeshift": "^0.15.1", "storybook": "workspace:*", "ts-dedent": "^2.0.0" @@ -53,17 +52,14 @@ "devDependencies": { "@types/cross-spawn": "^6.0.6", "@types/prompts": "^2.0.9", - "boxen": "^8.0.1", "comment-json": "^4.2.5", "cross-spawn": "^7.0.6", "empathic": "^2.0.0", "envinfo": "^7.14.0", - "execa": "^9.6.0", "globby": "^14.0.1", "leven": "^4.0.0", "p-limit": "^6.2.0", "picocolors": "^1.1.0", - "prompts": "^2.4.0", "semver": "^7.7.2", "slash": "^5.0.0", "tiny-invariant": "^1.3.3", diff --git a/code/lib/cli-storybook/src/add.test.ts b/code/lib/cli-storybook/src/add.test.ts index 1bd09a624feb..d9a00130ee64 100644 --- a/code/lib/cli-storybook/src/add.test.ts +++ b/code/lib/cli-storybook/src/add.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { logger } from 'storybook/internal/node-logger'; + +import { PackageManagerName } from '../../../core/src/common'; import { add, getVersionSpecifier } from './add'; const MockedConfig = vi.hoisted(() => { @@ -66,9 +69,6 @@ vi.mock('storybook/internal/csf-tools', () => { vi.mock('./postinstallAddon', () => { return MockedPostInstall; }); -vi.mock('./automigrate/fixes/wrap-getAbsolutePath-utils', () => { - return MockWrapGetAbsolutePathUtils; -}); vi.mock('./automigrate/helpers/mainConfigFile', () => { return MockedMainConfigFileHelper; }); @@ -81,7 +81,10 @@ vi.mock('storybook/internal/common', () => { JsPackageManagerFactory: { getPackageManager: vi.fn(() => MockedPackageManager), }, - syncStorybookAddons: vi.fn(), + setupAddonInConfig: vi.fn(), + getAbsolutePathWrapperName: MockWrapGetAbsolutePathUtils.getAbsolutePathWrapperName, + wrapValueWithGetAbsolutePathWrapper: + MockWrapGetAbsolutePathUtils.wrapValueWithGetAbsolutePathWrapper, getCoercedStorybookVersion: vi.fn(() => '8.0.0'), versions: { storybook: '8.0.0', @@ -128,14 +131,7 @@ describe('add', () => { ]; test.each(testData)('$input', async ({ input, expected }) => { - const [packageName] = getVersionSpecifier(input); - - await add(input, { packageManager: 'npm', skipPostinstall: true }, MockedConsole); - - expect(MockedConfig.appendValueToArray).toHaveBeenCalledWith( - expect.arrayContaining(['addons']), - packageName - ); + await add(input, { packageManager: PackageManagerName.NPM, skipPostinstall: true }); expect(MockedPackageManager.addDependencies).toHaveBeenCalledWith( { type: 'devDependencies', writeOutputToFile: false }, @@ -148,58 +144,30 @@ describe('add (extra)', () => { beforeEach(() => { vi.clearAllMocks(); }); - test('should not add a "wrap getAbsolutePath" to the addon when not needed', async () => { - MockedConfig.getFieldNode.mockReturnValue({}); - MockWrapGetAbsolutePathUtils.getAbsolutePathWrapperName.mockReturnValue(null); - await add( - '@storybook/addon-docs', - { packageManager: 'npm', skipPostinstall: true }, - MockedConsole - ); - - expect(MockWrapGetAbsolutePathUtils.wrapValueWithGetAbsolutePathWrapper).not.toHaveBeenCalled(); - expect(MockedConfig.appendValueToArray).toHaveBeenCalled(); - expect(MockedConfig.appendNodeToArray).not.toHaveBeenCalled(); - }); - test('should add a "wrap getAbsolutePath" to the addon when applicable', async () => { - MockedConfig.getFieldNode.mockReturnValue({}); - MockWrapGetAbsolutePathUtils.getAbsolutePathWrapperName.mockReturnValue('getAbsolutePath'); - await add( - '@storybook/addon-docs', - { packageManager: 'npm', skipPostinstall: true }, - MockedConsole - ); - - expect(MockWrapGetAbsolutePathUtils.wrapValueWithGetAbsolutePathWrapper).toHaveBeenCalled(); - expect(MockedConfig.appendValueToArray).not.toHaveBeenCalled(); - expect(MockedConfig.appendNodeToArray).toHaveBeenCalled(); - }); test('not warning when installing the correct version of storybook', async () => { - await add( - '@storybook/addon-docs', - { packageManager: 'npm', skipPostinstall: true }, - MockedConsole - ); + await add('@storybook/addon-docs', { + packageManager: PackageManagerName.NPM, + skipPostinstall: true, + }); - expect(MockedConsole.warn).not.toHaveBeenCalledWith( + expect(logger.warn).not.toHaveBeenCalledWith( expect.stringContaining(`is not the same as the version of Storybook you are using.`) ); }); test('not warning when installing unrelated package', async () => { - await add('aa', { packageManager: 'npm', skipPostinstall: true }, MockedConsole); + await add('aa', { packageManager: PackageManagerName.NPM, skipPostinstall: true }); - expect(MockedConsole.warn).not.toHaveBeenCalledWith( + expect(logger.warn).not.toHaveBeenCalledWith( expect.stringContaining(`is not the same as the version of Storybook you are using.`) ); }); test('warning when installing a core addon mismatching version of storybook', async () => { - await add( - '@storybook/addon-docs@2.0.0', - { packageManager: 'npm', skipPostinstall: true }, - MockedConsole - ); + await add('@storybook/addon-docs@2.0.0', { + packageManager: PackageManagerName.NPM, + skipPostinstall: true, + }); - expect(MockedConsole.warn).toHaveBeenCalledWith( + expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining( `The version of @storybook/addon-docs (2.0.0) you are installing is not the same as the version of Storybook you are using (8.0.0). This may lead to unexpected behavior.` ) @@ -207,15 +175,16 @@ describe('add (extra)', () => { }); test('postInstall', async () => { - await add( - '@storybook/addon-docs', - { packageManager: 'npm', skipPostinstall: false }, - MockedConsole - ); + await add('@storybook/addon-docs', { + packageManager: PackageManagerName.NPM, + skipPostinstall: false, + }); expect(MockedPostInstall.postinstallAddon).toHaveBeenCalledWith('@storybook/addon-docs', { packageManager: 'npm', configDir: '.storybook', + logger: expect.any(Object), + prompt: expect.any(Object), }); }); }); diff --git a/code/lib/cli-storybook/src/add.ts b/code/lib/cli-storybook/src/add.ts index 95ca4722a0bd..e1b7abc19678 100644 --- a/code/lib/cli-storybook/src/add.ts +++ b/code/lib/cli-storybook/src/add.ts @@ -1,28 +1,27 @@ -import { - type PackageManagerName, - loadMainConfig, - syncStorybookAddons, - versions, -} from 'storybook/internal/common'; -import { readConfig, writeConfig } from 'storybook/internal/csf-tools'; +import { type PackageManagerName, setupAddonInConfig, versions } from 'storybook/internal/common'; +import { readConfig } from 'storybook/internal/csf-tools'; +import { logger as nodeLogger } from 'storybook/internal/node-logger'; import { prompt } from 'storybook/internal/node-logger'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import SemVer from 'semver'; import { dedent } from 'ts-dedent'; -import { - getAbsolutePathWrapperName, - wrapValueWithGetAbsolutePathWrapper, -} from './automigrate/fixes/wrap-getAbsolutePath-utils'; import { getStorybookData } from './automigrate/helpers/mainConfigFile'; import { postinstallAddon } from './postinstallAddon'; export interface PostinstallOptions { packageManager: PackageManagerName; configDir: string; + logger: typeof nodeLogger; + prompt: typeof prompt; yes?: boolean; skipInstall?: boolean; + /** + * Skip all dependency management (collecting, adding to package.json, installing). Used when the + * caller (e.g., init command) has already handled dependencies. + */ + skipDependencyManagement?: boolean; } /** @@ -85,7 +84,7 @@ export async function add( yes, skipInstall, }: CLIOptions, - logger = console + logger = nodeLogger ) { const [addonName, inputVersion] = getVersionSpecifier(addon); @@ -166,24 +165,12 @@ export async function add( if (shouldAddToMain) { logger.log(`Adding '${addon}' to the "addons" field in ${mainConfigPath}`); - const mainConfigAddons = main.getFieldNode(['addons']); - if (mainConfigAddons && getAbsolutePathWrapperName(main) !== null) { - const addonNode = main.valueToNode(addonName); - main.appendNodeToArray(['addons'], addonNode as any); - wrapValueWithGetAbsolutePathWrapper(main, addonNode as any); - } else { - main.appendValueToArray(['addons'], addonName); - } - - await writeConfig(main); - } - - // TODO: remove try/catch once CSF factories is shipped, for now gracefully handle any error - try { - const newMainConfig = await loadMainConfig({ configDir, skipCache: true }); - await syncStorybookAddons(newMainConfig, previewConfigPath!, configDir); - } catch (e) { - // + await setupAddonInConfig({ + addonName, + mainConfigCSFFile: main, + previewConfigPath, + configDir, + }); } if (!skipPostinstall && isCoreAddon(addonName)) { @@ -191,6 +178,8 @@ export async function add( packageManager: packageManager.type, configDir, yes, + logger, + prompt, skipInstall, }); } diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts index 9795c2cfe32f..3fa0c62c81e3 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAddonNames } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import path from 'path'; @@ -46,8 +45,6 @@ vi.mock('picocolors', async (importOriginal) => { }; }); -const loggerMock = vi.mocked(logger); - describe('addonA11yAddonTest', () => { const configDir = '/path/to/config'; const mainConfig = {} as any; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.ts index 859bbdff30a5..9464ad5b2da7 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-a11y-addon-test.ts @@ -1,4 +1,4 @@ -import { formatFileContent, getAddonNames } from 'storybook/internal/common'; +import { formatFileContent, frameworkPackages, getAddonNames } from 'storybook/internal/common'; import { formatConfig, loadConfig } from 'storybook/internal/csf-tools'; import { existsSync, readFileSync, writeFileSync } from 'fs'; @@ -8,7 +8,6 @@ import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; // Relative path import to avoid dependency to storybook/test -import { SUPPORTED_FRAMEWORKS } from '../../../../../addons/vitest/src/constants'; import { getFrameworkPackageName } from '../helpers/mainConfigFile'; import type { Fix } from '../types'; @@ -53,7 +52,9 @@ export const addonA11yAddonTest: Fix = { const hasA11yAddon = !!addons.find((addon) => addon.includes('@storybook/addon-a11y')); const hasTestAddon = !!addons.find((addon) => addon.includes('@storybook/addon-vitest')); - if (!SUPPORTED_FRAMEWORKS.find((framework) => frameworkPackageName?.includes(framework))) { + if ( + !Object.keys(frameworkPackages).find((framework) => frameworkPackageName?.includes(framework)) + ) { return null; } diff --git a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts index 01cab31471a3..07facfd32494 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/addon-globals-api.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { printCsf } from 'storybook/internal/csf-tools'; // Import common to mock -import dedent from 'ts-dedent'; +import { dedent } from 'ts-dedent'; // Import FixResult type import { addonGlobalsApi, transformStoryFile } from './addon-globals-api'; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/index.ts b/code/lib/cli-storybook/src/automigrate/fixes/index.ts index 3b8f0d98045c..159da1a65cbe 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/index.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/index.ts @@ -11,6 +11,7 @@ import { eslintPlugin } from './eslint-plugin'; import { fixFauxEsmRequire } from './fix-faux-esm-require'; import { initialGlobals } from './initial-globals'; import { migrateAddonConsole } from './migrate-addon-console'; +import { nextjsToNextjsVite } from './nextjs-to-nextjs-vite'; import { removeAddonInteractions } from './remove-addon-interactions'; import { removeDocsAutodocs } from './remove-docs-autodocs'; import { removeEssentials } from './remove-essentials'; @@ -33,6 +34,7 @@ export const allFixes: Fix[] = [ addonExperimentalTest, rnstorybookConfig, migrateAddonConsole, + nextjsToNextjsVite, removeAddonInteractions, rendererToFramework, removeEssentials, diff --git a/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts index 25c93460dccc..304ef6dc00ea 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/migrate-addon-console.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getAddonNames, removeAddon } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; -import dedent from 'ts-dedent'; +import { dedent } from 'ts-dedent'; import type { RunOptions } from '../types'; import { diff --git a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts new file mode 100644 index 000000000000..0bfb0521d2e3 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.test.ts @@ -0,0 +1,224 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; + +import type { CheckOptions } from '.'; +import { nextjsToNextjsVite } from './nextjs-to-nextjs-vite'; + +// Mock dependencies +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('storybook/internal/node-logger', () => ({ + logger: { + step: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('storybook/internal/common', () => ({ + transformImportFiles: vi.fn().mockResolvedValue([]), +})); + +vi.mock('globby', () => ({ + globby: vi.fn().mockResolvedValue([]), +})); + +const mockReadFile = vi.mocked(readFile); +const mockWriteFile = vi.mocked(writeFile); + +describe('nextjs-to-nextjs-vite', () => { + const mockPackageManager = { + getAllDependencies: vi.fn(), + packageJsonPaths: ['/project/package.json'], + removeDependencies: vi.fn().mockResolvedValue(undefined), + addDependencies: vi.fn().mockResolvedValue(undefined), + } as unknown as JsPackageManager; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockPackageManager.removeDependencies).mockResolvedValue(undefined); + vi.mocked(mockPackageManager.addDependencies).mockResolvedValue(undefined); + }); + + describe('check function', () => { + it('should return null if @storybook/nextjs is not installed', async () => { + mockPackageManager.getAllDependencies = vi.fn().mockReturnValue({ + '@storybook/react': '^9.0.0', + }); + + const result = await nextjsToNextjsVite.check({ + packageManager: mockPackageManager, + } as CheckOptions); + expect(result).toBeNull(); + }); + + it('should return migration options if @storybook/nextjs is installed', async () => { + mockPackageManager.getAllDependencies = vi.fn().mockReturnValue({ + '@storybook/nextjs': '^9.0.0', + '@storybook/react': '^9.0.0', + }); + + mockReadFile.mockResolvedValue( + JSON.stringify({ + dependencies: { + '@storybook/nextjs': '^9.0.0', + }, + }) + ); + + const result = await nextjsToNextjsVite.check({ + packageManager: mockPackageManager, + } as CheckOptions); + + expect(result).toEqual({ + hasNextjsPackage: true, + packageJsonFiles: ['/project/package.json'], + }); + }); + + it('should handle invalid package.json files gracefully', async () => { + mockPackageManager.getAllDependencies = vi.fn().mockReturnValue({ + '@storybook/nextjs': '^9.0.0', + }); + + mockReadFile.mockRejectedValue(new Error('Invalid JSON')); + + const result = await nextjsToNextjsVite.check({ + packageManager: mockPackageManager, + } as CheckOptions); + + expect(result).toEqual({ + hasNextjsPackage: true, + packageJsonFiles: [], + }); + }); + }); + + describe('prompt function', () => { + it('should return a descriptive prompt message', () => { + const prompt = nextjsToNextjsVite.prompt(); + + expect(prompt).toContain('@storybook/nextjs'); + expect(prompt).toContain('@storybook/nextjs-vite'); + }); + }); + + describe('run function', () => { + it('should handle null result gracefully', async () => { + await expect( + nextjsToNextjsVite.run!({ + result: null, + dryRun: false, + packageManager: mockPackageManager, + mainConfigPath: '/project/.storybook/main.js', + storiesPaths: ['**/*.stories.*'], + configDir: '.storybook', + } as any) + ).resolves.toBeUndefined(); + }); + + it('should transform package.json files', async () => { + const result = { + hasNextjsPackage: true, + packageJsonFiles: ['/project/package.json'], + }; + + mockReadFile.mockResolvedValue( + JSON.stringify({ + dependencies: { + '@storybook/nextjs': '^9.0.0', + '@storybook/react': '^9.0.0', + }, + }) + ); + + await nextjsToNextjsVite.run!({ + result, + dryRun: false, + packageManager: mockPackageManager, + mainConfigPath: '/project/.storybook/main.js', + storiesPaths: ['**/*.stories.*'], + configDir: '.storybook', + storybookVersion: '9.0.0', + } as any); + + expect(mockPackageManager.removeDependencies).toHaveBeenCalledWith(['@storybook/nextjs']); + expect(mockPackageManager.addDependencies).toHaveBeenCalledWith( + { type: 'devDependencies', skipInstall: true }, + ['@storybook/nextjs-vite@9.0.0'] + ); + }); + + it('should transform main config file', async () => { + const result = { + hasNextjsPackage: true, + packageJsonFiles: [], + }; + + mockReadFile.mockResolvedValue(` + export default { + framework: '@storybook/nextjs', + addons: ['@storybook/addon-essentials'], + }; + `); + + await nextjsToNextjsVite.run!({ + result, + dryRun: false, + packageManager: mockPackageManager, + mainConfigPath: '/project/.storybook/main.js', + storiesPaths: ['**/*.stories.*'], + configDir: '.storybook', + storybookVersion: '9.0.0', + } as any); + + expect(mockPackageManager.removeDependencies).toHaveBeenCalledWith(['@storybook/nextjs']); + expect(mockPackageManager.addDependencies).toHaveBeenCalledWith( + { type: 'devDependencies', skipInstall: true }, + ['@storybook/nextjs-vite@9.0.0'] + ); + expect(mockWriteFile).toHaveBeenCalledWith( + '/project/.storybook/main.js', + expect.stringContaining('@storybook/nextjs-vite') + ); + }); + + it('should handle dry run mode', async () => { + const result = { + hasNextjsPackage: true, + packageJsonFiles: ['/project/package.json'], + }; + + mockReadFile.mockResolvedValue( + JSON.stringify({ + dependencies: { + '@storybook/nextjs': '^9.0.0', + }, + }) + ); + + await nextjsToNextjsVite.run!({ + result, + dryRun: true, + packageManager: mockPackageManager, + mainConfigPath: '/project/.storybook/main.js', + storiesPaths: ['**/*.stories.*'], + configDir: '.storybook', + storybookVersion: '9.0.0', + } as any); + + // In dry run mode, package.json updates should be skipped + expect(mockPackageManager.removeDependencies).not.toHaveBeenCalled(); + expect(mockPackageManager.addDependencies).not.toHaveBeenCalled(); + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts new file mode 100644 index 000000000000..94337d986731 --- /dev/null +++ b/code/lib/cli-storybook/src/automigrate/fixes/nextjs-to-nextjs-vite.ts @@ -0,0 +1,141 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import { transformImportFiles } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; + +import type { Fix } from '../types'; + +interface NextjsToNextjsViteOptions { + hasNextjsPackage: boolean; + packageJsonFiles: string[]; +} + +const transformMainConfig = async (mainConfigPath: string, dryRun: boolean): Promise => { + try { + const content = await readFile(mainConfigPath, 'utf-8'); + + // Check if the file contains @storybook/nextjs references + if (!content.includes('@storybook/nextjs')) { + return false; + } + + // Replace @storybook/nextjs with @storybook/nextjs-vite in the content + const transformedContent = content.replace(/@storybook\/nextjs/g, '@storybook/nextjs-vite'); + + if (transformedContent !== content && !dryRun) { + await writeFile(mainConfigPath, transformedContent); + } + + return transformedContent !== content; + } catch (error) { + logger.error(`Failed to update main config at ${mainConfigPath}: ${error}`); + return false; + } +}; + +export const nextjsToNextjsVite: Fix = { + id: 'nextjs-to-nextjs-vite', + link: 'https://storybook.js.org/docs/get-started/frameworks/nextjs-vite', + defaultSelected: false, + + async check({ packageManager }): Promise { + const allDeps = packageManager.getAllDependencies(); + + // Check if @storybook/nextjs is present + if (!allDeps['@storybook/nextjs']) { + return null; + } + + // Find package.json files that contain @storybook/nextjs + const packageJsonFiles: string[] = []; + + for (const packageJsonPath of packageManager.packageJsonPaths) { + try { + const content = await readFile(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(content); + + const hasNextjs = Object.keys({ + ...(packageJson.dependencies || {}), + ...(packageJson.devDependencies || {}), + }).includes('@storybook/nextjs'); + + if (hasNextjs) { + packageJsonFiles.push(packageJsonPath); + } + } catch { + // Skip invalid package.json files + continue; + } + } + + return { + hasNextjsPackage: true, + packageJsonFiles, + }; + }, + + prompt() { + return 'Migrate from @storybook/nextjs to @storybook/nextjs-vite (Vite framework)'; + }, + + async run({ + result, + dryRun = false, + mainConfigPath, + storiesPaths, + configDir, + packageManager, + storybookVersion, + }) { + if (!result) { + return; + } + + logger.step('Migrating from @storybook/nextjs to @storybook/nextjs-vite...'); + + // Update package.json files + if (dryRun) { + logger.debug('Dry run: Skipping package.json updates.'); + } else { + logger.debug('Updating package.json files...'); + await packageManager.removeDependencies(['@storybook/nextjs']); + await packageManager.addDependencies({ type: 'devDependencies', skipInstall: true }, [ + `@storybook/nextjs-vite@${storybookVersion}`, + ]); + } + + // Update main config file + if (mainConfigPath) { + logger.debug('Updating main config file...'); + await transformMainConfig(mainConfigPath, dryRun); + } + + // Scan and transform import statements in source files + logger.debug('Scanning and updating import statements...'); + + // eslint-disable-next-line depend/ban-dependencies + const { globby } = await import('globby'); + const configFiles = await globby([`${configDir}/**/*`]); + const allFiles = [...storiesPaths, ...configFiles].filter(Boolean) as string[]; + + const transformErrors = await transformImportFiles( + allFiles, + { + '@storybook/nextjs': '@storybook/nextjs-vite', + }, + !!dryRun + ); + + if (transformErrors.length > 0) { + logger.warn(`Encountered ${transformErrors.length} errors during file transformation:`); + transformErrors.forEach(({ file, error }) => { + logger.warn(` - ${file}: ${error.message}`); + }); + } + + logger.step('Migration completed successfully!'); + logger.log( + `For more information, see: https://storybook.js.org/docs/nextjs/get-started/nextjs-vite` + ); + }, +}; diff --git a/code/lib/cli-storybook/src/automigrate/fixes/remove-essentials.test.ts b/code/lib/cli-storybook/src/automigrate/fixes/remove-essentials.test.ts index f6e09b201335..23c1345a1402 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/remove-essentials.test.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/remove-essentials.test.ts @@ -44,10 +44,6 @@ vi.mock('../../add', () => ({ add: vi.fn(), })); -vi.mock('prompts', () => ({ - default: vi.fn().mockResolvedValue({ glob: '**/*.{mjs,cjs,js,jsx,ts,tsx,mdx}' }), -})); - vi.mock('globby', () => ({ globby: vi.fn().mockResolvedValue(['/fake/project/root/src/stories/Button.stories.tsx']), })); diff --git a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.ts b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.ts index ad550d953fba..c346375085f7 100644 --- a/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.ts +++ b/code/lib/cli-storybook/src/automigrate/fixes/wrap-getAbsolutePath.ts @@ -1,4 +1,11 @@ import { detectPnp } from 'storybook/internal/cli'; +import { + getAbsolutePathWrapperAsCallExpression, + getAbsolutePathWrapperName, + getFieldsForGetAbsolutePathWrapper, + isGetAbsolutePathWrapperNecessary, + wrapValueWithGetAbsolutePathWrapper, +} from 'storybook/internal/common'; import { readConfig } from 'storybook/internal/csf-tools'; import { CommonJsConfigNotSupportedError } from 'storybook/internal/server-errors'; @@ -6,13 +13,6 @@ import { dedent } from 'ts-dedent'; import { updateMainConfig } from '../helpers/mainConfigFile'; import type { Fix } from '../types'; -import { - getAbsolutePathWrapperAsCallExpression, - getAbsolutePathWrapperName, - getFieldsForGetAbsolutePathWrapper, - isGetAbsolutePathWrapperNecessary, - wrapValueWithGetAbsolutePathWrapper, -} from './wrap-getAbsolutePath-utils'; export interface WrapGetAbsolutePathRunOptions { storybookVersion: string; diff --git a/code/lib/cli-storybook/src/automigrate/helpers/cleanLog.ts b/code/lib/cli-storybook/src/automigrate/helpers/cleanLog.ts deleted file mode 100644 index 2f8a7b7ed856..000000000000 --- a/code/lib/cli-storybook/src/automigrate/helpers/cleanLog.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { EOL } from 'node:os'; - -// copied from https://github.com/chalk/ansi-regex -// the package is ESM only so not compatible with jest -export const ansiRegex = ({ onlyFirst = false } = {}) => { - const pattern = [ - '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', - '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', - ].join('|'); - - return new RegExp(pattern, onlyFirst ? undefined : 'g'); -}; - -export const cleanLog = (str: string) => - str - // remove picocolors ANSI colors - .replace(ansiRegex(), '') - // fix boxen output - .replace(/╮│/g, '╮\n│') - .replace(/││/g, '│\n│') - .replace(/│╰/g, '│\n╰') - .replace(/⚠️ {2}failed to check/g, `${EOL}⚠️ failed to check`); diff --git a/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.test.ts b/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.test.ts deleted file mode 100644 index 1c4f22ff5bc7..000000000000 --- a/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { logger as loggerRaw } from 'storybook/internal/node-logger'; - -import { FixStatus } from '../types'; -import { logMigrationSummary } from './logMigrationSummary'; - -vi.mock('picocolors', () => ({ - default: { - yellow: (str: string) => str, - cyan: (str: string) => str, - bold: (str: string) => str, - green: (str: string) => str, - red: (str: string) => str, - }, -})); - -const loggerMock = vi.mocked(loggerRaw); - -// necessary for windows and unix output to match in the assertions -const normalizeLineBreaks = (str: string) => str.replace(/\r\n|\r|\n/g, '\n').trim(); - -describe('logMigrationSummary', () => { - const fixResults = { - 'foo-package': FixStatus.SUCCEEDED, - 'bar-package': FixStatus.MANUAL_SUCCEEDED, - 'baz-package': FixStatus.CHECK_FAILED, - 'qux-package': FixStatus.FAILED, - 'quux-package': FixStatus.UNNECESSARY, - }; - - const fixSummary = { - succeeded: ['foo-package'], - failed: { 'baz-package': 'Some error message' }, - manual: ['bar-package'], - skipped: ['quux-package'], - }; - - it('renders a summary with a "no migrations" message if all migrations were unnecessary', () => { - logMigrationSummary({ - fixResults: { 'foo-package': FixStatus.UNNECESSARY }, - fixSummary: { - succeeded: [], - failed: {}, - manual: [], - skipped: [], - }, - }); - - expect(loggerMock.logBox.mock.calls[0][1]).toEqual( - expect.objectContaining({ - title: 'No migrations were applicable to your project', - }) - ); - }); - - it('renders a summary with a "check failed" message if at least one migration completely failed', () => { - logMigrationSummary({ - fixResults: { - 'foo-package': FixStatus.SUCCEEDED, - 'bar-package': FixStatus.MANUAL_SUCCEEDED, - 'baz-package': FixStatus.FAILED, - }, - fixSummary: { - succeeded: [], - failed: { 'baz-package': 'Some error message' }, - manual: ['bar-package'], - skipped: [], - }, - }); - - expect(loggerMock.logBox.mock.calls[0][1]).toEqual( - expect.objectContaining({ - title: 'Migration check ran with failures', - }) - ); - }); - - it('renders a summary with successful, manual, failed, and skipped migrations', () => { - logMigrationSummary({ - fixResults, - fixSummary, - }); - - expect(loggerMock.logBox.mock.calls[0][1]).toEqual( - expect.objectContaining({ - title: 'Migration check ran with failures', - }) - ); - expect(normalizeLineBreaks(loggerMock.logBox.mock.calls[0][0])).toMatchInlineSnapshot(` - "Successful migrations: - - foo-package - - Failed migrations: - - baz-package: - Some error message - - Manual migrations: - - bar-package - - Skipped migrations: - - quux-package - - ───────────────────────────────────────────────── - - If you'd like to run the migrations again, you can do so by running 'npx storybook automigrate' - - The automigrations try to migrate common patterns in your project, but might not contain everything needed to migrate to the latest version of Storybook. - - Please check the changelog and migration guide for manual migrations and more information: https://storybook.js.org/docs/releases/migration-guide?ref=upgrade - And reach out on Discord if you need help: https://discord.gg/storybook" - `); - }); - - it('renders a summary with a warning if there are duplicated dependencies outside the allow list', () => { - logMigrationSummary({ - fixResults: {}, - fixSummary: { succeeded: [], failed: {}, manual: [], skipped: [] }, - }); - - expect(loggerMock.logBox.mock.calls[0][1]).toEqual( - expect.objectContaining({ - title: 'No migrations were applicable to your project', - }) - ); - expect(normalizeLineBreaks(loggerMock.logBox.mock.calls[0][0])).toMatchInlineSnapshot(` - "If you'd like to run the migrations again, you can do so by running 'npx storybook automigrate' - - The automigrations try to migrate common patterns in your project, but might not contain everything needed to migrate to the latest version of Storybook. - - Please check the changelog and migration guide for manual migrations and more information: https://storybook.js.org/docs/releases/migration-guide?ref=upgrade - And reach out on Discord if you need help: https://discord.gg/storybook" - `); - }); - - it('renders a basic summary if there are no duplicated dependencies or migrations', () => { - logMigrationSummary({ - fixResults: {}, - fixSummary: { succeeded: [], failed: {}, manual: [], skipped: [] }, - }); - - expect(loggerMock.logBox.mock.calls[0][1]).toEqual( - expect.objectContaining({ - title: 'No migrations were applicable to your project', - }) - ); - expect(normalizeLineBreaks(loggerMock.logBox.mock.calls[0][0])).toMatchInlineSnapshot(` - "If you'd like to run the migrations again, you can do so by running 'npx storybook automigrate' - - The automigrations try to migrate common patterns in your project, but might not contain everything needed to migrate to the latest version of Storybook. - - Please check the changelog and migration guide for manual migrations and more information: https://storybook.js.org/docs/releases/migration-guide?ref=upgrade - And reach out on Discord if you need help: https://discord.gg/storybook" - `); - }); -}); diff --git a/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.ts b/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.ts index 527b15db41ad..65a95802a299 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/logMigrationSummary.ts @@ -1,4 +1,4 @@ -import { logger } from 'storybook/internal/node-logger'; +import { CLI_COLORS, logger } from 'storybook/internal/node-logger'; import picocolors from 'picocolors'; import { dedent } from 'ts-dedent'; @@ -56,16 +56,14 @@ export function logMigrationSummary({ const messages = []; messages.push(getGlossaryMessages(fixSummary, fixResults).join(messageDivider)); - messages.push(dedent`If you'd like to run the migrations again, you can do so by running '${picocolors.cyan( - 'npx storybook automigrate' - )}' + messages.push(dedent`If you'd like to run the migrations again, you can do so by running + ${picocolors.cyan('npx storybook automigrate')} The automigrations try to migrate common patterns in your project, but might not contain everything needed to migrate to the latest version of Storybook. - Please check the changelog and migration guide for manual migrations and more information: ${picocolors.yellow( - 'https://storybook.js.org/docs/releases/migration-guide?ref=upgrade' - )} - And reach out on Discord if you need help: ${picocolors.yellow('https://discord.gg/storybook')} + Please check the changelog and migration guide for manual migrations and more information: + https://storybook.js.org/docs/releases/migration-guide?ref=upgrade + And reach out on Discord if you need help: https://discord.gg/storybook `); const hasNoFixes = Object.values(fixResults).every((r) => r === FixStatus.UNNECESSARY); @@ -73,14 +71,13 @@ export function logMigrationSummary({ (r) => r === FixStatus.FAILED || r === FixStatus.CHECK_FAILED ); - const title = hasNoFixes - ? 'No migrations were applicable to your project' - : hasFailures - ? 'Migration check ran with failures' - : 'Migration check ran successfully'; + if (hasNoFixes) { + logger.warn('No migrations were applicable to your project'); + } else if (hasFailures) { + logger.error('Migration check ran with failures'); + } else { + logger.step(CLI_COLORS.success('Migration check ran successfully')); + } - return logger.logBox(messages.filter(Boolean).join(segmentDivider), { - title, - borderColor: hasFailures ? 'red' : 'green', - }); + logger.log(messages.filter(Boolean).join(segmentDivider)); } diff --git a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.test.ts b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.test.ts index 4dfc15e19cd7..e23dff24abde 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.test.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.test.ts @@ -1,14 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - containsDirnameUsage, - containsPatternUsage, - getBuilderPackageName, - getFrameworkPackageName, - getRendererName, - getRendererPackageNameFromFramework, - hasDirnameDefined, -} from './mainConfigFile'; +import { getBuilderPackageName, getFrameworkPackageName, getRendererName } from './mainConfigFile'; describe('getBuilderPackageName', () => { it('should return null when mainConfig is undefined or null', () => { @@ -192,141 +184,3 @@ describe('getRendererName', () => { expect(rendererName).toBeUndefined(); }); }); - -describe('getRendererPackageNameFromFramework', () => { - it('should return null when given no package name', () => { - // @ts-expect-error (Argument of type 'undefined' is not assignable) - const packageName = getRendererPackageNameFromFramework(undefined); - expect(packageName).toBeNull(); - }); - - it('should return the frameworkPackageName if it exists in rendererPackages', () => { - const frameworkPackageName = '@storybook/angular'; - const packageName = getRendererPackageNameFromFramework(frameworkPackageName); - expect(packageName).toBe(frameworkPackageName); - }); - - it('should return the corresponding key of rendererPackages if the value is the same as the frameworkPackageName', () => { - const frameworkPackageName = 'vue3'; - const expectedPackageName = '@storybook/vue3'; - const packageName = getRendererPackageNameFromFramework(frameworkPackageName); - expect(packageName).toBe(expectedPackageName); - }); - - it('should return null if a frameworkPackageName is known but not available in rendererPackages', () => { - const frameworkPackageName = '@storybook/unknown'; - const packageName = getRendererPackageNameFromFramework(frameworkPackageName); - expect(packageName).toBeNull(); - }); -}); - -describe('containsPatternUsage', () => { - it('should detect __dirname usage with hardcoded regex', () => { - const content = ` - const path = require('path'); - const configPath = path.join(__dirname, 'config.js'); - `; - expect(containsPatternUsage(content, /\b__dirname\b/)).toBe(true); - }); - - it('should not detect __dirname in comments', () => { - const content = ` - // This is __dirname in a comment - const path = require('path'); - `; - expect(containsPatternUsage(content, /\b__dirname\b/)).toBe(false); - }); - - it('should not detect __dirname in strings', () => { - const content = ` - const message = "This is __dirname in a string"; - const path = require('path'); - `; - expect(containsPatternUsage(content, /\b__dirname\b/)).toBe(false); - }); - - it('should return false when __dirname is not used', () => { - const content = ` - const path = require('path'); - const configPath = path.join(process.cwd(), 'config.js'); - `; - expect(containsPatternUsage(content, /\b__dirname\b/)).toBe(false); - }); - - it('should work with other regex patterns', () => { - const content = ` - const path = require('path'); - const configPath = path.join(__filename, 'config.js'); - `; - expect(containsPatternUsage(content, /\b__filename\b/)).toBe(true); - expect(containsPatternUsage(content, /\b__dirname\b/)).toBe(false); - }); -}); - -describe('containsDirnameUsage', () => { - it('should detect __dirname usage', () => { - const content = ` - const path = require('path'); - const configPath = path.join(__dirname, 'config.js'); - `; - expect(containsDirnameUsage(content)).toBe(true); - }); - - it('should not detect __dirname in comments', () => { - const content = ` - // This is __dirname in a comment - const path = require('path'); - `; - expect(containsDirnameUsage(content)).toBe(false); - }); - - it('should not detect __dirname in strings', () => { - const content = ` - const message = "This is __dirname in a string"; - const path = require('path'); - `; - expect(containsDirnameUsage(content)).toBe(false); - }); - - it('should return false when __dirname is not used', () => { - const content = ` - const path = require('path'); - const configPath = path.join(process.cwd(), 'config.js'); - `; - expect(containsDirnameUsage(content)).toBe(false); - }); -}); - -describe('hasDirnameDefined', () => { - it('should detect const __dirname definition', () => { - const content = ` - const __dirname = dirname(__filename); - const path = require('path'); - `; - expect(hasDirnameDefined(content)).toBe(true); - }); - - it('should detect let __dirname definition', () => { - const content = ` - let __dirname = dirname(__filename); - const path = require('path'); - `; - expect(hasDirnameDefined(content)).toBe(true); - }); - - it('should detect var __dirname definition', () => { - const content = ` - var __dirname = dirname(__filename); - const path = require('path'); - `; - expect(hasDirnameDefined(content)).toBe(true); - }); - - it('should return false when __dirname is not defined', () => { - const content = ` - const path = require('path'); - const configPath = path.join(__dirname, 'config.js'); - `; - expect(hasDirnameDefined(content)).toBe(false); - }); -}); diff --git a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts index 343cdccf1129..94e6269fa8bd 100644 --- a/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts +++ b/code/lib/cli-storybook/src/automigrate/helpers/mainConfigFile.ts @@ -3,11 +3,9 @@ import { dirname, isAbsolute, join, normalize } from 'node:path'; import { JsPackageManagerFactory, builderPackages, - extractProperFrameworkName, + extractFrameworkPackageName, frameworkPackages, getStorybookInfo, - loadMainConfig, - rendererPackages, } from 'storybook/internal/common'; import type { PackageManagerName } from 'storybook/internal/common'; import { frameworkToRenderer, getCoercedStorybookVersion } from 'storybook/internal/common'; @@ -17,7 +15,6 @@ import { logger } from 'storybook/internal/node-logger'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import picocolors from 'picocolors'; -import { dedent } from 'ts-dedent'; import { getStoriesPathsFromConfig } from '../../util'; @@ -35,7 +32,7 @@ export const getFrameworkPackageName = (mainConfig?: StorybookConfigRaw) => { return null; } - return extractProperFrameworkName(packageNameOrPath); + return extractFrameworkPackageName(packageNameOrPath); }; /** @@ -83,7 +80,9 @@ export const getBuilderPackageName = (mainConfig?: StorybookConfigRaw) => { const normalizedPath = normalize(packageNameOrPath).replace(new RegExp(/\\/, 'g'), '/'); - return builderPackages.find((pkg) => normalizedPath.endsWith(pkg)) || packageNameOrPath; + return ( + Object.keys(builderPackages).find((pkg) => normalizedPath.endsWith(pkg)) || packageNameOrPath + ); }; /** @@ -100,30 +99,6 @@ export const getFrameworkOptions = ( : (mainConfig?.framework?.options ?? null); }; -/** - * Returns a renderer package name given a framework package name. - * - * @param frameworkPackageName - The package name of the framework to lookup. - * @returns - The corresponding package name in `rendererPackages`. If not found, returns null. - */ -export const getRendererPackageNameFromFramework = (frameworkPackageName: string) => { - if (frameworkPackageName) { - if (Object.keys(rendererPackages).includes(frameworkPackageName)) { - // at some point in 6.4 we introduced a framework field, but filled with a renderer package - return frameworkPackageName; - } - - if (Object.values(rendererPackages).includes(frameworkPackageName)) { - // for scenarios where the value is e.g. "react" instead of "@storybook/react" - return Object.keys(rendererPackages).find( - (k) => rendererPackages[k] === frameworkPackageName - ); - } - } - - return null; -}; - export const getStorybookData = async ({ configDir: userDefinedConfigDir, cwd, @@ -136,23 +111,15 @@ export const getStorybookData = async ({ }) => { logger.debug('Getting Storybook info...'); const { + mainConfig, mainConfigPath: mainConfigPath, - version: storybookVersionSpecifier, configDir: configDirFromScript, previewConfigPath, - } = await getStorybookInfo(userDefinedConfigDir); + } = await getStorybookInfo(userDefinedConfigDir, cwd); const configDir = userDefinedConfigDir || configDirFromScript || '.storybook'; logger.debug('Loading main config...'); - let mainConfig: StorybookConfigRaw; - try { - mainConfig = (await loadMainConfig({ configDir, cwd })) as StorybookConfigRaw; - } catch (err) { - throw new Error( - dedent`Unable to find or evaluate ${picocolors.blue(mainConfigPath)}: ${String(err)}` - ); - } const workingDir = isAbsolute(configDir) ? dirname(configDir) @@ -178,7 +145,6 @@ export const getStorybookData = async ({ return { configDir, mainConfig, - storybookVersionSpecifier, storybookVersion, mainConfigPath, previewConfigPath, diff --git a/code/lib/cli-storybook/src/automigrate/index.test.ts b/code/lib/cli-storybook/src/automigrate/index.test.ts index 4bbf7b180ffc..88b46248c5ea 100644 --- a/code/lib/cli-storybook/src/automigrate/index.test.ts +++ b/code/lib/cli-storybook/src/automigrate/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { JsPackageManager, PackageJson } from 'storybook/internal/common'; +import { prompt } from 'storybook/internal/node-logger'; import * as mainConfigFile from './helpers/mainConfigFile'; import { doAutomigrate, runFixes } from './index'; @@ -15,6 +16,38 @@ const prompt1Message = 'prompt1Message'; vi.spyOn(console, 'error').mockImplementation(console.log); vi.spyOn(mainConfigFile, 'getStorybookData').mockImplementation(getStorybookData); +vi.mock('storybook/internal/node-logger', () => ({ + logger: { + logBox: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + step: vi.fn(), + }, + prompt: { + confirm: vi.fn(), + taskLog: vi.fn(() => ({ + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + })), + }, + logTracker: { + enableLogWriting: vi.fn(), + }, + CLI_COLORS: { + success: vi.fn((text: string) => text), + error: vi.fn((text: string) => text), + warning: vi.fn((text: string) => text), + info: vi.fn((text: string) => text), + debug: vi.fn((text: string) => text), + cta: vi.fn((text: string) => text), + dimmed: vi.fn((text: string) => text), + }, +})); + const fixes: Fix[] = [ { id: 'fix-1', @@ -44,17 +77,7 @@ vi.mock('storybook/internal/common', async (importOriginal) => ({ loadMainConfig: coreCommonMock.loadMainConfig, })); -const promptMocks = vi.hoisted(() => { - return { - default: vi.fn(), - }; -}); - -vi.mock('prompts', () => { - return { - default: promptMocks.default, - }; -}); +// Remove the old prompt mock - now handled in the node-logger mock class PackageManager implements Partial { async getModulePackageJSON( @@ -130,6 +153,7 @@ describe('runFixes', () => { }; }); check1.mockResolvedValue({ some: 'result' }); + vi.mocked(prompt.confirm).mockResolvedValue(true); }); afterEach(() => { @@ -137,8 +161,6 @@ describe('runFixes', () => { }); it('should be necessary to run fix-1 from SB 6.5.15 to 7.0.0', async () => { - promptMocks.default.mockResolvedValue({ shouldContinue: true }); - const { fixResults } = await runFixWrapper({ beforeVersion, storybookVersion: '7.0.0' }); expect(fixResults).toEqual({ @@ -173,7 +195,9 @@ describe('runFixes', () => { const result = runAutomigrateWrapper({ beforeVersion, storybookVersion: '7.0.0' }); - await expect(result).rejects.toThrow('Some migrations failed'); + await expect(result).rejects.toThrow( + 'An error occurred while running the automigrate command.' + ); expect(run1).not.toHaveBeenCalled(); }); }); diff --git a/code/lib/cli-storybook/src/automigrate/index.ts b/code/lib/cli-storybook/src/automigrate/index.ts index 76216837d2a1..937c2402ad3e 100644 --- a/code/lib/cli-storybook/src/automigrate/index.ts +++ b/code/lib/cli-storybook/src/automigrate/index.ts @@ -1,5 +1,6 @@ import { type JsPackageManager } from 'storybook/internal/common'; import { logTracker, logger, prompt } from 'storybook/internal/node-logger'; +import { AutomigrateError } from 'storybook/internal/server-errors'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import picocolors from 'picocolors'; @@ -81,7 +82,7 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { (r) => r === FixStatus.SUCCEEDED || r === FixStatus.MANUAL_SUCCEEDED ); - if (hasAppliedFixes) { + if (hasAppliedFixes && !options.skipInstall) { packageManager.installDependencies(); } @@ -90,7 +91,14 @@ export const doAutomigrate = async (options: AutofixOptionsFromCLI) => { } if (hasFailures(outcome?.fixResults)) { - throw new Error('Some migrations failed'); + const failedMigrations = Object.entries(outcome?.fixResults ?? {}) + .filter(([, status]) => status === FixStatus.FAILED || status === FixStatus.CHECK_FAILED) + .map(([id, status]) => { + const statusLabel = status === FixStatus.CHECK_FAILED ? 'check failed' : 'failed'; + return `${picocolors.cyan(id)} (${statusLabel})`; + }); + + throw new AutomigrateError({ errors: failedMigrations }); } }; @@ -125,7 +133,7 @@ export const automigrate = async ({ // if an on-command migration is triggered, run it and bail const commandFix = commandFixes.find((f) => f.id === fixId); if (commandFix) { - logger.log(`🔎 Running migration ${picocolors.magenta(fixId)}..`); + logger.step(`Running migration ${picocolors.magenta(fixId)}..`); await commandFix.run({ mainConfigPath, @@ -164,7 +172,7 @@ export const automigrate = async ({ return null; } - logger.log('🔎 checking possible migrations..'); + logger.step('Checking possible migrations..'); const { fixResults, fixSummary, preCheckFailure } = await runFixes({ fixes, @@ -189,12 +197,10 @@ export const automigrate = async ({ } if (!hideMigrationSummary) { - logger.log(''); logMigrationSummary({ fixResults, fixSummary, }); - logger.log(''); } return { fixResults, preCheckFailure }; @@ -303,7 +309,6 @@ export async function runFixes({ fixResults[f.id] = FixStatus.MANUAL_SUCCEEDED; fixSummary.manual.push(f.id); - logger.log(''); const shouldContinue = await prompt.confirm( { message: @@ -324,17 +329,19 @@ export async function runFixes({ break; } } else if (promptType === 'auto') { - const shouldRun = await prompt.confirm( - { - message: `Do you want to run the '${picocolors.cyan(f.id)}' migration on your project?`, - initialValue: f.defaultSelected ?? true, - }, - { - onCancel: () => { - throw new Error(); - }, - } - ); + const shouldRun = yes + ? true + : await prompt.confirm( + { + message: `Do you want to run the '${picocolors.cyan(f.id)}' migration on your project?`, + initialValue: f.defaultSelected ?? true, + }, + { + onCancel: () => { + throw new Error(); + }, + } + ); runAnswer = { fix: shouldRun }; } else if (promptType === 'notification') { const shouldContinue = await prompt.confirm( diff --git a/code/lib/cli-storybook/src/automigrate/multi-project.test.ts b/code/lib/cli-storybook/src/automigrate/multi-project.test.ts index cd3e731dae7f..7c24485b7790 100644 --- a/code/lib/cli-storybook/src/automigrate/multi-project.test.ts +++ b/code/lib/cli-storybook/src/automigrate/multi-project.test.ts @@ -30,6 +30,11 @@ const taskLogMock = { message: vi.fn(), success: vi.fn(), error: vi.fn(), + group: vi.fn().mockReturnValue({ + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }), }; describe('multi-project automigrations', () => { diff --git a/code/lib/cli-storybook/src/automigrate/multi-project.ts b/code/lib/cli-storybook/src/automigrate/multi-project.ts index a6256e84771d..c5b4c55be1ef 100644 --- a/code/lib/cli-storybook/src/automigrate/multi-project.ts +++ b/code/lib/cli-storybook/src/automigrate/multi-project.ts @@ -1,6 +1,6 @@ import type { JsPackageManager } from 'storybook/internal/common'; import { CLI_COLORS, type TaskLogInstance, logger, prompt } from 'storybook/internal/node-logger'; -import { sanitizeError } from 'storybook/internal/telemetry'; +import { ErrorCollector, sanitizeError } from 'storybook/internal/telemetry'; import type { StorybookConfigRaw } from 'storybook/internal/types'; import type { UpgradeOptions } from '../upgrade'; @@ -124,6 +124,7 @@ export async function collectAutomigrationsAcrossProjects( `Failed to check fix ${fix.id} for project ${shortenPath(project.configDir)}.` ); logger.debug(`${error instanceof Error ? error.stack : String(error)}`); + ErrorCollector.addError(error); } } } @@ -388,6 +389,7 @@ export async function runAutomigrationsForProjects( fixFailures[fix.id] = sanitizeError(error as Error); taskLog.message(CLI_COLORS.error(`${logger.SYMBOLS.error} ${automigration.fix.id}`)); logger.debug(errorMessage); + ErrorCollector.addError(error); } } diff --git a/code/lib/cli-storybook/src/bin/run.ts b/code/lib/cli-storybook/src/bin/run.ts index 7299b01cff56..6a916e49e190 100644 --- a/code/lib/cli-storybook/src/bin/run.ts +++ b/code/lib/cli-storybook/src/bin/run.ts @@ -8,7 +8,7 @@ import { versions, } from 'storybook/internal/common'; import { withTelemetry } from 'storybook/internal/core-server'; -import { CLI_COLORS, logTracker, logger, prompt } from 'storybook/internal/node-logger'; +import { CLI_COLORS, logTracker, logger } from 'storybook/internal/node-logger'; import { addToGlobalContext, telemetry } from 'storybook/internal/telemetry'; import { program } from 'commander'; @@ -28,16 +28,18 @@ import { type UpgradeOptions, upgrade } from '../upgrade'; addToGlobalContext('cliVersion', versions.storybook); // Return a failed exit code but write the logs to a file first -const handleCommandFailure = async (error: unknown): Promise => { - if (!(error instanceof HandledError)) { - logger.error(String(error)); - } +const handleCommandFailure = + (logFilePath: string | boolean | undefined) => + async (error: unknown): Promise => { + if (!(error instanceof HandledError)) { + logger.error(String(error)); + } - const logFile = await logTracker.writeToFile(); - logger.log(`Storybook debug logs can be found at: ${logFile}`); - logger.outro(''); - process.exit(1); -}; + const logFile = await logTracker.writeToFile(logFilePath); + logger.log(`Storybook debug logs can be found at: ${logFile}`); + logger.outro(''); + process.exit(1); + }; const command = (name: string) => program @@ -49,27 +51,34 @@ const command = (name: string) => ) .option('--debug', 'Get more logs in debug mode', false) .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') - .option('--write-logs', 'Write all debug logs to a file at the end of the run') + .option( + '--logfile [path]', + 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' + ) .option('--loglevel ', 'Define log level', 'info') .hook('preAction', async (self) => { - try { - const options = self.opts(); - if (options.loglevel) { - logger.setLogLevel(options.loglevel); - } + const options = self.opts(); + if (options.debug) { + logger.setLogLevel('debug'); + } - if (options.writeLogs) { - logTracker.enableLogWriting(); - } + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + + if (options.logfile) { + logTracker.enableLogWriting(); + } + try { await globalSettings(); } catch (e) { logger.error('Error loading global settings:\n' + String(e)); } }) - .hook('postAction', async () => { + .hook('postAction', async ({ getOptionValue }) => { if (logTracker.shouldWriteLogsToFile) { - const logFile = await logTracker.writeToFile(); + const logFile = await logTracker.writeToFile(getOptionValue('logfile')); logger.log(`Storybook debug logs can be found at: ${logFile}`); logger.outro(CLI_COLORS.success('Done!')); } @@ -108,7 +117,18 @@ command('add ') .option('-s --skip-postinstall', 'Skip package specific postinstall config modifications') .option('-y --yes', 'Skip prompting the user') .option('--skip-doctor', 'Skip doctor check') - .action((addonName: string, options: any) => add(addonName, options)); + .action((addonName: string, options: any) => { + withTelemetry('add', { cliOptions: options }, async () => { + logger.intro(`Setting up your project for ${addonName}`); + + await add(addonName, options); + + if (!options.disableTelemetry) { + await telemetry('add', { addon: addonName, source: 'cli' }); + } + logger.outro('Done!'); + }).catch(handleCommandFailure); + }); command('remove ') .description('Remove an addon from your Storybook') @@ -120,6 +140,7 @@ command('remove ') .option('-s --skip-install', 'Skip installing deps') .action((addonName: string, options: any) => withTelemetry('remove', { cliOptions: options }, async () => { + logger.intro(`Removing ${addonName} from your Storybook`); const packageManager = JsPackageManagerFactory.getPackageManager({ configDir: options.configDir, force: options.packageManager, @@ -132,7 +153,8 @@ command('remove ') if (!options.disableTelemetry) { await telemetry('remove', { addon: addonName, source: 'cli' }); } - }) + logger.outro('Done!'); + }).catch(handleCommandFailure(options.logfile)) ); command('upgrade') @@ -150,8 +172,15 @@ command('upgrade') 'Directory(ies) where to load Storybook configurations from' ) .action(async (options: UpgradeOptions) => { - prompt.setPromptLibrary('clack'); - await upgrade(options).catch(handleCommandFailure); + await withTelemetry( + 'upgrade', + { cliOptions: { ...options, configDir: options.configDir?.[0] } }, + async () => { + logger.intro(`Storybook upgrade - v${versions.storybook}`); + await upgrade(options); + logger.outro('Storybook upgrade completed!'); + } + ).catch(handleCommandFailure(options.logfile)); }); command('info') @@ -190,15 +219,12 @@ command('migrate [migration]') '-r --rename ', 'Rename suffix of matching files after codemod has been applied, e.g. ".js:.ts"' ) - .action((migration, { configDir, glob, dryRun, list, rename, parser }) => { - migrate(migration, { - configDir, - glob, - dryRun, - list, - rename, - parser, - }).catch(handleCommandFailure); + .action((migration, options) => { + withTelemetry('migrate', { cliOptions: options }, async () => { + logger.intro(`Running ${migration} migration`); + await migrate(migration, options); + logger.outro('Migration completed'); + }).catch(handleCommandFailure(options.logfile)); }); command('sandbox [filterValue]') @@ -206,15 +232,22 @@ command('sandbox [filterValue]') .description('Create a sandbox from a set of possible templates') .option('-o --output ', 'Define an output directory') .option('--no-init', 'Whether to download a template without an initialized Storybook', false) - .action((filterValue, options) => - sandbox({ filterValue, ...options }).catch(handleCommandFailure) - ); + .action((filterValue, options) => { + logger.intro(`Creating a Storybook sandbox...`); + sandbox({ filterValue, ...options }) + .catch(handleCommandFailure) + .finally(() => { + logger.outro('Done!'); + }); + }); command('link ') .description('Pull down a repro from a URL (or a local directory), link it, and run storybook') .option('--local', 'Link a local directory already in your file system') .option('--no-start', 'Start the storybook', true) - .action((target, { local, start }) => link({ target, local, start }).catch(handleCommandFailure)); + .action((target, { local, start, logfile }) => + link({ target, local, start }).catch(handleCommandFailure(logfile)) + ); command('automigrate [fixId]') .description('Check storybook for incompatibilities or migrations and apply fixes') @@ -230,8 +263,11 @@ command('automigrate [fixId]') ) .option('--skip-doctor', 'Skip doctor check') .action(async (fixId, options) => { - prompt.setPromptLibrary('clack'); - await doAutomigrate({ fixId, ...options }).catch(handleCommandFailure); + withTelemetry('automigrate', { cliOptions: options }, async () => { + logger.intro(fixId ? `Running ${fixId} automigration` : 'Running automigrations'); + await doAutomigrate({ fixId, ...options }); + logger.outro('Done'); + }).catch(handleCommandFailure(options.logfile)); }); command('doctor') @@ -239,7 +275,11 @@ command('doctor') .option('--package-manager ', 'Force package manager') .option('-c, --config-dir ', 'Directory of Storybook configuration') .action(async (options) => { - await doctor(options).catch(handleCommandFailure); + withTelemetry('doctor', { cliOptions: options }, async () => { + logger.intro('Doctoring Storybook'); + await doctor(options); + logger.outro('Done'); + }).catch(handleCommandFailure(options.logfile)); }); program.on('command:*', ([invalidCmd]) => { diff --git a/code/lib/cli-storybook/src/codemod/csf-factories.ts b/code/lib/cli-storybook/src/codemod/csf-factories.ts index 5ab36e88e111..ec089e3f087c 100644 --- a/code/lib/cli-storybook/src/codemod/csf-factories.ts +++ b/code/lib/cli-storybook/src/codemod/csf-factories.ts @@ -31,18 +31,10 @@ async function runStoriesCodemod(options: { }); } - logger.log('\n🛠️ Applying codemod on your stories, this might take some time...'); - - // TODO: Move the csf-2-to-3 codemod into automigrations - await packageManager.executeCommand({ - command: packageManager.getRemoteRunCommand('storybook', [ - 'migrate', - 'csf-2-to-3', - `--glob='${globString}'`, - ]), - args: [], - stdio: 'ignore', - ignoreError: true, + logger.step('Applying codemod on your stories, this might take some time...'); + + await packageManager.runPackageCommand({ + args: ['storybook', 'migrate', 'csf-2-to-3', `--glob="${globString}"`], }); await runCodemod(globString, (info) => storyToCsfFactory(info, codemodOptions), { @@ -88,8 +80,8 @@ export const csfFactories: CommandFix = { const { packageJson } = packageManager.primaryPackageJson; if (useSubPathImports && !packageJson.imports?.['#*']) { - logger.log( - `🗺️ Adding imports map in ${picocolors.cyan(packageManager.primaryPackageJson.packageJsonPath)}` + logger.step( + `Adding imports map in ${picocolors.cyan(packageManager.primaryPackageJson.packageJsonPath)}` ); packageJson.imports = { ...packageJson.imports, @@ -106,14 +98,14 @@ export const csfFactories: CommandFix = { previewConfigPath: previewConfigPath!, }); - logger.log('\n🛠️ Applying codemod on your main config...'); + logger.step('Applying codemod on your main config...'); const frameworkPackage = getFrameworkPackageName(mainConfig) || '@storybook/your-framework-here'; await runCodemod(mainConfigPath, (fileInfo) => configToCsfFactory(fileInfo, { configType: 'main', frameworkPackage }, { dryRun }) ); - logger.log('\n🛠️ Applying codemod on your preview config...'); + logger.step('Applying codemod on your preview config...'); await runCodemod(previewConfigPath, (fileInfo) => configToCsfFactory(fileInfo, { configType: 'preview', frameworkPackage }, { dryRun }) ); diff --git a/code/lib/cli-storybook/src/link.ts b/code/lib/cli-storybook/src/link.ts index 99fbbaea0df4..65f06c0a4418 100644 --- a/code/lib/cli-storybook/src/link.ts +++ b/code/lib/cli-storybook/src/link.ts @@ -1,12 +1,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, extname, join } from 'node:path'; +import { executeCommand } from 'storybook/internal/common'; import { logger } from 'storybook/internal/node-logger'; -import { spawn as spawnAsync, sync as spawnSync } from 'cross-spawn'; -import picocolors from 'picocolors'; - -type ExecOptions = Parameters[2]; +import { sync as spawnSync } from 'cross-spawn'; interface LinkOptions { target: string; @@ -14,50 +12,6 @@ interface LinkOptions { start: boolean; } -// TODO: Extract this to somewhere else, or use `exec` from a different file that might already have it -export const exec = async ( - command: string, - options: ExecOptions = {}, - { - startMessage, - errorMessage, - dryRun, - }: { startMessage?: string; errorMessage?: string; dryRun?: boolean } = {} -) => { - if (startMessage) { - logger.info(startMessage); - } - - if (dryRun) { - logger.info(`\n> ${command}\n`); - return undefined; - } - - logger.info(command); - return new Promise((resolve, reject) => { - const child = spawnAsync(command, { - ...options, - shell: true, - stdio: 'pipe', - }); - - child.stderr.pipe(process.stdout); - child.stdout.pipe(process.stdout); - - child.on('exit', (code) => { - if (code === 0) { - resolve(undefined); - } else { - logger.error(picocolors.red(`An error occurred while executing: \`${command}\``)); - if (errorMessage) { - logger.info(errorMessage); - } - reject(new Error(`command exited with code: ${code}: `)); - } - }); - }); -}; - export const link = async ({ target, local, start }: LinkOptions) => { const storybookDir = process.cwd(); try { @@ -80,7 +34,11 @@ export const link = async ({ target, local, start }: LinkOptions) => { await mkdir(reprosDir, { recursive: true }); logger.info(`Cloning ${target}`); - await exec(`git clone ${target}`, { cwd: reprosDir }); + await executeCommand({ + command: 'git', + args: ['clone', target], + cwd: reprosDir, + }); // Extract a repro name from url given as input (take the last part of the path and remove the extension) reproName = basename(target, extname(target)); reproDir = join(reprosDir, reproName); @@ -101,7 +59,11 @@ export const link = async ({ target, local, start }: LinkOptions) => { } logger.info(`Linking ${reproDir}`); - await exec(`yarn link --all --relative "${storybookDir}"`, { cwd: reproDir }); + await executeCommand({ + command: 'yarn', + args: ['link', '--all', '--relative', storybookDir], + cwd: reproDir, + }); logger.info(`Installing ${reproName}`); @@ -124,10 +86,18 @@ export const link = async ({ target, local, start }: LinkOptions) => { await writeFile(join(reproDir, 'package.json'), JSON.stringify(reproPackageJson, null, 2)); - await exec(`yarn install`, { cwd: reproDir }); + await executeCommand({ + command: 'yarn', + args: ['install'], + cwd: reproDir, + }); if (start) { logger.info(`Running ${reproName} storybook`); - await exec(`yarn run storybook`, { cwd: reproDir }); + await executeCommand({ + command: 'yarn', + args: ['run', 'storybook'], + cwd: reproDir, + }); } }; diff --git a/code/lib/cli-storybook/src/postinstallAddon.ts b/code/lib/cli-storybook/src/postinstallAddon.ts index bdb633c8cad1..d4c0c985355b 100644 --- a/code/lib/cli-storybook/src/postinstallAddon.ts +++ b/code/lib/cli-storybook/src/postinstallAddon.ts @@ -6,6 +6,7 @@ import type { PostinstallOptions } from './add'; const DIR_CWD = process.cwd(); const require = createRequire(DIR_CWD); + export const postinstallAddon = async (addonName: string, options: PostinstallOptions) => { const hookPath = `${addonName}/postinstall`; let modulePath: string; @@ -33,17 +34,16 @@ export const postinstallAddon = async (addonName: string, options: PostinstallOp } const postinstall = moduledLoaded?.default || moduledLoaded?.postinstall || moduledLoaded; + const logger = options.logger; if (!postinstall || typeof postinstall !== 'function') { - console.log(`Error finding postinstall function for ${addonName}`); + logger.error(`Error finding postinstall function for ${addonName}`); return; } try { - console.log(`Running postinstall script for ${addonName}`); await postinstall(options); } catch (e) { - console.error(`Error running postinstall script for ${addonName}`); - console.error(e); + throw e; } }; diff --git a/code/lib/cli-storybook/src/sandbox-templates.ts b/code/lib/cli-storybook/src/sandbox-templates.ts index 8211002ebf8c..d286fe14eae1 100644 --- a/code/lib/cli-storybook/src/sandbox-templates.ts +++ b/code/lib/cli-storybook/src/sandbox-templates.ts @@ -1,5 +1,8 @@ import type { ConfigFile } from 'storybook/internal/csf-tools'; -import type { StoriesEntry, StorybookConfigRaw } from 'storybook/internal/types'; +import { type StoriesEntry, type StorybookConfigRaw } from 'storybook/internal/types'; + +import { ProjectType } from '../../../core/src/cli/projectTypes'; +import { SupportedBuilder } from '../../../core/src/types/modules/builders'; export type SkippableTask = | 'smoke-test' @@ -87,6 +90,12 @@ export type Template = { editAddons?: (addons: string[]) => string[]; useCsfFactory?: boolean; }; + /** Additional options to pass to the initiate command when initializing Storybook. */ + initOptions?: { + builder?: SupportedBuilder; + type?: ProjectType; + [key: string]: unknown; + }; /** * Flag to indicate that this template is a secondary template, which is used mainly to test * rather specific features. This means the template might be hidden from the Storybook status @@ -180,7 +189,10 @@ export const baseTemplates = { }, extraDependencies: ['server-only', 'prop-types'], }, - skipTasks: ['e2e-tests', 'e2e-tests-dev', 'bench', 'vitest-integration'], + initOptions: { + builder: SupportedBuilder.WEBPACK5, + }, + skipTasks: ['e2e-tests-dev', 'e2e-tests', 'bench', 'vitest-integration'], }, 'nextjs/15-ts': { name: 'Next.js v15 (Webpack | TypeScript)', @@ -202,6 +214,9 @@ export const baseTemplates = { }, extraDependencies: ['server-only', 'prop-types'], }, + initOptions: { + builder: SupportedBuilder.WEBPACK5, + }, skipTasks: ['e2e-tests', 'bench', 'vitest-integration'], }, 'nextjs/default-ts': { @@ -224,6 +239,9 @@ export const baseTemplates = { }, extraDependencies: ['server-only', 'prop-types'], }, + initOptions: { + builder: SupportedBuilder.WEBPACK5, + }, skipTasks: ['bench', 'vitest-integration'], }, 'nextjs/prerelease': { @@ -246,6 +264,9 @@ export const baseTemplates = { }, extraDependencies: ['server-only', 'prop-types'], }, + initOptions: { + builder: SupportedBuilder.WEBPACK5, + }, skipTasks: ['e2e-tests', 'bench', 'vitest-integration'], }, 'nextjs-vite/14-ts': { @@ -267,7 +288,7 @@ export const baseTemplates = { experimentalTestSyntax: true, }, }, - extraDependencies: ['server-only', '@storybook/nextjs-vite', 'vite', 'prop-types'], + extraDependencies: ['server-only', 'vite', 'prop-types'], }, skipTasks: ['e2e-tests', 'bench'], }, @@ -290,7 +311,7 @@ export const baseTemplates = { experimentalTestSyntax: true, }, }, - extraDependencies: ['server-only', '@storybook/nextjs-vite', 'vite', 'prop-types'], + extraDependencies: ['server-only', 'vite', 'prop-types'], }, skipTasks: ['e2e-tests', 'bench'], }, @@ -313,7 +334,7 @@ export const baseTemplates = { experimentalTestSyntax: true, }, }, - extraDependencies: ['server-only', '@storybook/nextjs-vite', 'vite', 'prop-types'], + extraDependencies: ['server-only', 'vite', 'prop-types'], }, skipTasks: ['bench'], }, @@ -520,6 +541,9 @@ export const baseTemplates = { builder: '@storybook/builder-vite', }, skipTasks: ['e2e-tests', 'bench', 'vitest-integration'], + initOptions: { + type: ProjectType.HTML, + }, }, 'html-vite/default-ts': { name: 'HTML Latest (Vite | TypeScript)', @@ -531,6 +555,9 @@ export const baseTemplates = { builder: '@storybook/builder-vite', }, skipTasks: ['e2e-tests', 'bench', 'vitest-integration'], + initOptions: { + type: ProjectType.HTML, + }, }, 'svelte-vite/default-js': { name: 'Svelte Latest (Vite | JavaScript)', @@ -695,6 +722,9 @@ export const baseTemplates = { }, }, skipTasks: ['bench', 'vitest-integration'], + initOptions: { + type: ProjectType.REACT_NATIVE_WEB, + }, }, 'react-native-web-vite/rn-cli-ts': { // NOTE: create-expo-app installs React 18.2.0. But yarn portal @@ -714,6 +744,9 @@ export const baseTemplates = { builder: '@storybook/builder-vite', }, skipTasks: ['e2e-tests', 'bench', 'vitest-integration'], + initOptions: { + type: ProjectType.REACT_NATIVE_WEB, + }, }, } satisfies Record; @@ -778,6 +811,9 @@ const internalTemplates = { }, isInternal: true, skipTasks: ['bench', 'vitest-integration'], + initOptions: { + type: ProjectType.SERVER, + }, }, } satisfies Record<`internal/${string}`, Template & { isInternal: true }>; diff --git a/code/lib/cli-storybook/src/sandbox.ts b/code/lib/cli-storybook/src/sandbox.ts index c5b73f86baa8..9e4467f6260c 100644 --- a/code/lib/cli-storybook/src/sandbox.ts +++ b/code/lib/cli-storybook/src/sandbox.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { mkdir, readdir, rm } from 'node:fs/promises'; +import { readdir, rm } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; import type { PackageManagerName } from 'storybook/internal/common'; @@ -11,7 +11,7 @@ import { } from 'storybook/internal/common'; import { logger, prompt } from 'storybook/internal/node-logger'; -import { downloadTemplate } from 'giget'; +import { sync as spawnSync } from 'cross-spawn'; import { join } from 'pathe'; import picocolors from 'picocolors'; import { lt, prerelease } from 'semver'; @@ -52,7 +52,6 @@ export const sandbox = async ({ const currentVersion = versions.storybook; const isPrerelease = prerelease(currentVersion); const isOutdated = lt(currentVersion, isPrerelease ? nextVersion : latestVersion); - const borderColor = isOutdated ? '#FC521F' : '#F1618C'; const downloadType = !isOutdated && init ? 'after-storybook' : 'before-storybook'; const branch = isPrerelease ? 'next' : 'main'; @@ -78,7 +77,9 @@ export const sandbox = async ({ .concat(init && (isOutdated || isPrerelease) ? [messages.longInitTime] : []) .concat(isPrerelease ? [messages.prerelease] : []) .join('\n'), - { borderStyle: 'round', padding: 1, borderColor } + { + rounded: true, + } ); if (!selectedConfig) { @@ -197,13 +198,8 @@ export const sandbox = async ({ logger.log(`📦 Downloading sandbox template (${picocolors.bold(downloadType)})...`); try { // Download the sandbox based on subfolder "after-storybook" and selected branch - const gitPath = `github:storybookjs/sandboxes/${templateId}/${downloadType}#${branch}`; - // create templateDestination first (because it errors on Windows if it doesn't exist) - await mkdir(templateDestination, { recursive: true }); - await downloadTemplate(gitPath, { - force: true, - dir: templateDestination, - }); + const gitPath = `storybookjs/sandboxes/tree/${branch}/${templateId}/${downloadType}`; + spawnSync('npx', ['gitpick@4.12.4', gitPath, templateDestination, '-o']); // throw an error if templateDestination is an empty directory if ((await readdir(templateDestination)).length === 0) { const selected = picocolors.yellow(templateId); @@ -228,6 +224,7 @@ export const sandbox = async ({ await initiate({ dev: isCI() && !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX), ...options, + ...(selectedConfig.initOptions || {}), features: ['docs', 'test'], }); process.chdir(before); @@ -259,7 +256,7 @@ export const sandbox = async ({ Having a clean repro helps us solve your issue faster! 🙏 `.trim(), - { borderStyle: 'round', padding: 1, borderColor: '#F1618C' } + { rounded: true } ); } catch (error) { logger.error('🚨 Failed to create sandbox'); diff --git a/code/lib/cli-storybook/src/upgrade.ts b/code/lib/cli-storybook/src/upgrade.ts index 0209de238178..9c3d49aaab88 100644 --- a/code/lib/cli-storybook/src/upgrade.ts +++ b/code/lib/cli-storybook/src/upgrade.ts @@ -1,7 +1,5 @@ -import type { PackageManagerName } from 'storybook/internal/common'; -import { versions } from 'storybook/internal/common'; +import { PackageManagerName } from 'storybook/internal/common'; import { HandledError, JsPackageManagerFactory, isCorePackage } from 'storybook/internal/common'; -import { withTelemetry } from 'storybook/internal/core-server'; import { CLI_COLORS, createHyperlink, @@ -76,7 +74,7 @@ const formatPackage = (pkg: Package) => `${pkg.package}@${pkg.version}`; const warnPackages = (pkgs: Package[]) => pkgs.map((pkg) => `- ${formatPackage(pkg)}`).join('\n'); export const checkVersionConsistency = () => { - const lines = spawnSync('npm ls', { stdio: 'pipe', shell: true }).output.toString().split('\n'); + const lines = spawnSync('npm', ['ls'], { stdio: 'pipe' }).output.toString().split('\n'); const storybookPackages = lines .map(getStorybookVersion) .filter((item): item is NonNullable => !!item) @@ -125,6 +123,7 @@ export type UpgradeOptions = { configDir?: string[]; fixId?: string; skipInstall?: boolean; + logfile?: string | boolean; }; function getUpgradeResults( @@ -232,11 +231,10 @@ function logUpgradeResults( logger.log(`${CLI_COLORS.info('No applicable migrations:')}\n${projectList}`); } } else { - logger.step('The upgrade is complete!'); if (Object.values(doctorResults).every((result) => result.status === 'healthy')) { - logger.log(`${CLI_COLORS.success('Your project(s) have been upgraded successfully! 🎉')}`); + logger.step(`${CLI_COLORS.success('Your project(s) have been upgraded successfully! 🎉')}`); } else { - logger.log( + logger.step( `${picocolors.yellow('Your project(s) have been upgraded successfully, but some issues were found which need your attention, please check Storybook doctor logs above.')}` ); } @@ -319,223 +317,219 @@ async function sendMultiUpgradeTelemetry(options: MultiUpgradeTelemetryOptions) } export async function upgrade(options: UpgradeOptions): Promise { - await withTelemetry( - 'upgrade', - { cliOptions: { ...options, configDir: options.configDir?.[0] } }, - async () => { - logger.intro(`Storybook Upgrade - ${picocolors.bold(`v${versions.storybook}`)}`); - const projectsResult = await getProjects(options); - - if (projectsResult === undefined || projectsResult.selectedProjects.length === 0) { - // nothing to upgrade - return; - } + const projectsResult = await getProjects(options); - const { allProjects, selectedProjects: storybookProjects } = projectsResult; + if (projectsResult === undefined || projectsResult.selectedProjects.length === 0) { + // nothing to upgrade + return; + } - if (storybookProjects.length > 1) { - logger.info(`Upgrading the following projects: - ${storybookProjects.map((p) => `${picocolors.cyan(shortenPath(p.configDir))}: ${picocolors.bold(p.beforeVersion)} -> ${picocolors.bold(p.currentCLIVersion)}`).join('\n')}`); - } else { - logger.info( - `Upgrading from ${picocolors.bold(storybookProjects[0].beforeVersion)} to ${picocolors.bold(storybookProjects[0].currentCLIVersion)}` - ); - } + const { allProjects, selectedProjects: storybookProjects } = projectsResult; - const automigrationResults: Record = {}; - let doctorResults: Record = {}; - - // Set up signal handling for interruptions - const handleInterruption = async () => { - logger.log('\n\nUpgrade interrupted by user.'); - if (allProjects.length > 1) { - await sendMultiUpgradeTelemetry({ - allProjects, - selectedProjects: storybookProjects, - projectResults: automigrationResults, - doctorResults, - hasUserInterrupted: true, - }); - } - throw new HandledError('Upgrade cancelled by user'); - }; + if (storybookProjects.length > 1) { + logger.info(`Upgrading the following projects: + ${storybookProjects.map((p) => `${picocolors.cyan(shortenPath(p.configDir))}: ${picocolors.bold(p.beforeVersion)} -> ${picocolors.bold(p.currentCLIVersion)}`).join('\n')}`); + } else { + logger.info( + `Upgrading from ${picocolors.bold(storybookProjects[0].beforeVersion)} to ${picocolors.bold(storybookProjects[0].currentCLIVersion)}` + ); + } - process.on('SIGINT', handleInterruption); - process.on('SIGTERM', handleInterruption); + const automigrationResults: Record = {}; + let doctorResults: Record = {}; + + // Set up signal handling for interruptions + const handleInterruption = async () => { + logger.log('\n\nUpgrade interrupted by user.'); + if (allProjects.length > 1) { + await sendMultiUpgradeTelemetry({ + allProjects, + selectedProjects: storybookProjects, + projectResults: automigrationResults, + doctorResults, + hasUserInterrupted: true, + }); + } + throw new HandledError('Upgrade cancelled by user'); + }; - try { - // Handle autoblockers - const hasBlockers = processAutoblockerResults(storybookProjects, (message) => { - logger.error(dedent`Blockers detected\n\n${message}`); - }); + process.on('SIGINT', handleInterruption); + process.on('SIGTERM', handleInterruption); - if (hasBlockers) { - throw new HandledError('Blockers detected'); - } + try { + // Handle autoblockers + const hasBlockers = processAutoblockerResults(storybookProjects, (message) => { + logger.error(dedent`Blockers detected\n\n${message}`); + }); - // Checks whether we can upgrade - storybookProjects.some((project) => { - if (!project.isCanary && lt(project.currentCLIVersion, project.beforeVersion)) { - throw new UpgradeStorybookToLowerVersionError({ - beforeVersion: project.beforeVersion, - currentVersion: project.currentCLIVersion, - }); - } + if (hasBlockers) { + throw new HandledError('Blockers detected'); + } - if (!project.beforeVersion) { - throw new UpgradeStorybookUnknownCurrentVersionError(); - } + // Checks whether we can upgrade + storybookProjects.some((project) => { + if (!project.isCanary && lt(project.currentCLIVersion, project.beforeVersion)) { + throw new UpgradeStorybookToLowerVersionError({ + beforeVersion: project.beforeVersion, + currentVersion: project.currentCLIVersion, }); + } - // Update dependencies in package.jsons for all projects - if (!options.dryRun) { - const task = prompt.taskLog({ - id: 'upgrade-dependencies', - title: `Fetching versions to update package.json files..`, - }); - try { - const loggedPaths: string[] = []; - for (const project of storybookProjects) { - logger.debug(`Updating dependencies in ${shortenPath(project.configDir)}...`); - const packageJsonPaths = project.packageManager.packageJsonPaths.map(shortenPath); - const newPaths = packageJsonPaths.filter((path) => !loggedPaths.includes(path)); - if (newPaths.length > 0) { - task.message(newPaths.join('\n')); - loggedPaths.push(...newPaths); - } - await upgradeStorybookDependencies({ - packageManager: project.packageManager, - isCanary: project.isCanary, - isCLIOutdated: project.isCLIOutdated, - isCLIPrerelease: project.isCLIPrerelease, - isCLIExactLatest: project.isCLIExactLatest, - isCLIExactPrerelease: project.isCLIExactPrerelease, - }); - } - task.success(`Updated package versions in package.json files`); - } catch (err) { - task.error(`Failed to upgrade dependencies: ${String(err)}`); + if (!project.beforeVersion) { + throw new UpgradeStorybookUnknownCurrentVersionError(); + } + }); + + // Update dependencies in package.jsons for all projects + if (!options.dryRun) { + const task = prompt.taskLog({ + id: 'upgrade-dependencies', + title: `Fetching versions to update package.json files..`, + }); + try { + const loggedPaths: string[] = []; + for (const project of storybookProjects) { + logger.debug(`Updating dependencies in ${shortenPath(project.configDir)}...`); + const packageJsonPaths = project.packageManager.packageJsonPaths.map(shortenPath); + const newPaths = packageJsonPaths.filter((path) => !loggedPaths.includes(path)); + if (newPaths.length > 0) { + task.message(newPaths.join('\n')); + loggedPaths.push(...newPaths); } + await upgradeStorybookDependencies({ + packageManager: project.packageManager, + isCanary: project.isCanary, + isCLIOutdated: project.isCLIOutdated, + isCLIPrerelease: project.isCLIPrerelease, + isCLIExactLatest: project.isCLIExactLatest, + isCLIExactPrerelease: project.isCLIExactPrerelease, + }); } + task.success(`Updated package versions in package.json files`); + } catch (err) { + task.error(`Failed to upgrade dependencies: ${String(err)}`); + } + } - // Run automigrations for all projects - const { automigrationResults, detectedAutomigrations } = await runAutomigrations( - storybookProjects, - options - ); + // Run automigrations for all projects + const { automigrationResults, detectedAutomigrations } = await runAutomigrations( + storybookProjects, + options + ); - // Install dependencies - const rootPackageManager = - storybookProjects.length > 1 - ? JsPackageManagerFactory.getPackageManager({ force: options.packageManager }) - : storybookProjects[0].packageManager; + // Install dependencies + const rootPackageManager = + storybookProjects.length > 1 + ? JsPackageManagerFactory.getPackageManager({ force: options.packageManager }) + : storybookProjects[0].packageManager; + if (rootPackageManager.type === 'npm') { + // see https://github.com/npm/cli/issues/8059 for more details + await rootPackageManager.installDependencies({ force: true }); + } else { + await rootPackageManager.installDependencies(); + } + + if ( + rootPackageManager.type !== PackageManagerName.YARN1 && + rootPackageManager.isStorybookInMonorepo() + ) { + logger.warn( + `Since you are in a monorepo, we advise you to deduplicate your dependencies. We can do this for you but it might take some time.` + ); + + const dedupe = + options.yes || + (await prompt.confirm({ + message: `Execute ${rootPackageManager.getRunCommand('dedupe')}?`, + initialValue: true, + })); + + if (dedupe) { if (rootPackageManager.type === 'npm') { // see https://github.com/npm/cli/issues/8059 for more details - await rootPackageManager.installDependencies({ force: true }); + await rootPackageManager.dedupeDependencies({ force: true }); } else { - await rootPackageManager.installDependencies(); - } - - if (rootPackageManager.type !== 'yarn1' && rootPackageManager.isStorybookInMonorepo()) { - logger.warn( - `Since you are in a monorepo, we advise you to deduplicate your dependencies. We can do this for you but it might take some time.` - ); - - const dedupe = - options.yes || - (await prompt.confirm({ - message: `Execute ${rootPackageManager.getRunCommand('dedupe')}?`, - initialValue: true, - })); - - if (dedupe) { - if (rootPackageManager.type === 'npm') { - // see https://github.com/npm/cli/issues/8059 for more details - await rootPackageManager.dedupeDependencies({ force: true }); - } else { - await rootPackageManager.dedupeDependencies(); - } - } else { - logger.log( - `If you find any issues running Storybook, you can run ${rootPackageManager.getRunCommand('dedupe')} manually to deduplicate your dependencies and try again.` - ); - } + await rootPackageManager.dedupeDependencies(); } + } else { + logger.log( + `If you find any issues running Storybook, you can run ${rootPackageManager.getRunCommand('dedupe')} manually to deduplicate your dependencies and try again.` + ); + } + } - // Run doctor for each project - const doctorProjects: ProjectDoctorData[] = storybookProjects.map((project) => ({ - configDir: project.configDir, - packageManager: project.packageManager, - storybookVersion: project.currentCLIVersion, - mainConfig: project.mainConfig, - })); + // Run doctor for each project + const doctorProjects: ProjectDoctorData[] = storybookProjects.map((project) => ({ + configDir: project.configDir, + packageManager: project.packageManager, + storybookVersion: project.currentCLIVersion, + mainConfig: project.mainConfig, + })); + + logger.step('Checking the health of your project(s)..'); + doctorResults = await runMultiProjectDoctor(doctorProjects); + const hasIssues = displayDoctorResults(doctorResults); + if (hasIssues) { + logTracker.enableLogWriting(); + } - logger.step('Checking the health of your project(s)..'); - doctorResults = await runMultiProjectDoctor(doctorProjects); - const hasIssues = displayDoctorResults(doctorResults); - if (hasIssues) { - logTracker.enableLogWriting(); - } + // Display upgrade results summary + logUpgradeResults(automigrationResults, detectedAutomigrations, doctorResults); - // Display upgrade results summary - logUpgradeResults(automigrationResults, detectedAutomigrations, doctorResults); - - // TELEMETRY - if (!options.disableTelemetry) { - for (const project of storybookProjects) { - const resultData = automigrationResults[project.configDir] || { - automigrationStatuses: {}, - automigrationErrors: {}, - }; - let doctorFailureCount = 0; - let doctorErrorCount = 0; - Object.values(doctorResults[project.configDir]?.diagnostics || {}).forEach((status) => { - if (status === 'has_issues') { - doctorFailureCount++; - } - - if (status === 'check_error') { - doctorErrorCount++; - } - }); - const automigrationFailureCount = Object.keys(resultData.automigrationErrors).length; - const automigrationPreCheckFailure = - project.autoblockerCheckResults && project.autoblockerCheckResults.length > 0 - ? project.autoblockerCheckResults - ?.map((result) => { - if (result.result !== null) { - return result.blocker.id; - } - return null; - }) - .filter(Boolean) - : null; - await telemetry('upgrade', { - beforeVersion: project.beforeVersion, - afterVersion: project.currentCLIVersion, - automigrationResults: resultData.automigrationStatuses, - automigrationErrors: resultData.automigrationErrors, - automigrationFailureCount, - automigrationPreCheckFailure, - doctorResults: doctorResults[project.configDir]?.diagnostics || {}, - doctorFailureCount, - doctorErrorCount, - }); + // TELEMETRY + if (!options.disableTelemetry) { + for (const project of storybookProjects) { + const resultData = automigrationResults[project.configDir] || { + automigrationStatuses: {}, + automigrationErrors: {}, + }; + let doctorFailureCount = 0; + let doctorErrorCount = 0; + Object.values(doctorResults[project.configDir]?.diagnostics || {}).forEach((status) => { + if (status === 'has_issues') { + doctorFailureCount++; } - await sendMultiUpgradeTelemetry({ - allProjects, - selectedProjects: storybookProjects, - projectResults: automigrationResults, - doctorResults, - }); - } - } finally { - // Clean up signal handlers - process.removeListener('SIGINT', handleInterruption); - process.removeListener('SIGTERM', handleInterruption); + if (status === 'check_error') { + doctorErrorCount++; + } + }); + const automigrationFailureCount = Object.keys(resultData.automigrationErrors).length; + const automigrationPreCheckFailure = + project.autoblockerCheckResults && project.autoblockerCheckResults.length > 0 + ? project.autoblockerCheckResults + ?.map((result) => { + if (result.result !== null) { + return result.blocker.id; + } + return null; + }) + .filter(Boolean) + : null; + await telemetry('upgrade', { + beforeVersion: project.beforeVersion, + afterVersion: project.currentCLIVersion, + automigrationResults: resultData.automigrationStatuses, + automigrationErrors: resultData.automigrationErrors, + automigrationFailureCount, + automigrationPreCheckFailure, + doctorResults: doctorResults[project.configDir]?.diagnostics || {}, + doctorFailureCount, + doctorErrorCount, + }); } + + await sendMultiUpgradeTelemetry({ + allProjects, + selectedProjects: storybookProjects, + projectResults: automigrationResults, + doctorResults, + }); } - ); + } finally { + // Clean up signal handlers + process.removeListener('SIGINT', handleInterruption); + process.removeListener('SIGTERM', handleInterruption); + } } diff --git a/code/lib/cli-storybook/src/util.ts b/code/lib/cli-storybook/src/util.ts index c12e9c8128f8..a1009b5f056c 100644 --- a/code/lib/cli-storybook/src/util.ts +++ b/code/lib/cli-storybook/src/util.ts @@ -9,7 +9,6 @@ import { } from 'storybook/internal/server-errors'; import type { StorybookConfigRaw } from 'storybook/internal/types'; -import boxen, { type Options } from 'boxen'; import * as walk from 'empathic/walk'; // eslint-disable-next-line depend/ban-dependencies import { globby, globbySync } from 'globby'; @@ -72,40 +71,13 @@ interface VersionModifier { // CONSTANTS // ============================================================================ -/** Default boxen styling for messages */ -const DEFAULT_BOXEN_STYLE: Options = { - borderStyle: 'round', - padding: 1, - borderColor: '#F1618C', -} as const; - /** Glob pattern for finding Storybook directories */ const STORYBOOK_DIR_PATTERN = ['**/.storybook', '**/.rnstorybook']; -/** Default fallback version when none is found */ -const DEFAULT_FALLBACK_VERSION = '0.0.0'; - // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ -/** - * Creates a styled boxed message for console output - * - * @example - * - * ```typescript - * const message = printBoxedMessage('Hello World!'); - * console.log(message); - * ``` - * - * @param message - The message to display in the box - * @param style - Optional styling options for the box - * @returns Formatted boxed message string - */ -export const printBoxedMessage = (message: string, style?: Options): string => - boxen(message, { ...DEFAULT_BOXEN_STYLE, ...style }); - /** * Type guard to check if a result is a success result * diff --git a/code/lib/cli-storybook/src/warn.ts b/code/lib/cli-storybook/src/warn.ts index f76cd1c6ea4e..7de253963cef 100644 --- a/code/lib/cli-storybook/src/warn.ts +++ b/code/lib/cli-storybook/src/warn.ts @@ -19,7 +19,6 @@ export const warn = async ({ hasTSDependency }: Options) => { 'We have detected TypeScript files in your project directory, however TypeScript is not listed as a project dependency.' ); logger.warn('Storybook will continue as though this is a JavaScript project.'); - logger.line(); logger.info( 'For more information, see: https://storybook.js.org/docs/configurations/typescript-config/' ); diff --git a/code/lib/cli-storybook/test/default/cli.test.cjs b/code/lib/cli-storybook/test/default/cli.test.cjs index a1ade1ab0857..90fd0a704a52 100755 --- a/code/lib/cli-storybook/test/default/cli.test.cjs +++ b/code/lib/cli-storybook/test/default/cli.test.cjs @@ -3,13 +3,15 @@ import { expect, test } from 'vitest'; const { run, cleanLog } = require('../helpers.cjs'); test('suggests the closest match to an unknown command', () => { - const { status, stderr } = run(['upgraed']); + const { status, stdout } = run(['upgraed']); // Assertions expect(status).toBe(1); - const stderrString = cleanLog(stderr.toString()); - expect(stderrString).toContain('Invalid command: upgraed.'); - expect(stderrString).toContain('Did you mean upgrade?'); + const stdoutString = cleanLog(stdout.toString()); + + // Error messages are now written to stdout + expect(stdoutString).toContain('Invalid command: upgraed.'); + expect(stdoutString).toContain('Did you mean upgrade?'); }); test('help command', () => { diff --git a/code/lib/codemod/src/index.ts b/code/lib/codemod/src/index.ts index 418b6a91ae71..9ed9c6686700 100644 --- a/code/lib/codemod/src/index.ts +++ b/code/lib/codemod/src/index.ts @@ -64,10 +64,10 @@ export async function runCodemod( const extensions = new Set(files.map((file) => extname(file).slice(1))); const commaSeparatedExtensions = Array.from(extensions).join(','); - logger.log(`=> Applying ${codemod}: ${files.length} files`); + logger.step(`Applying ${codemod}: ${files.length} files`); if (files.length === 0) { - logger.log(`=> No matching files for glob: ${glob}`); + logger.step(`No matching files for glob: ${glob}`); return; } @@ -90,7 +90,6 @@ export async function runCodemod( ], { stdio: 'inherit', - shell: true, } ); @@ -106,7 +105,7 @@ export async function runCodemod( if (renameParts) { const [from, to] = renameParts; - logger.log(`=> Renaming ${rename}: ${files.length} files`); + logger.step(`Renaming ${rename}: ${files.length} files`); await Promise.all( files.map((file) => renameFile(file, new RegExp(`${from}$`), to, { logger })) ); diff --git a/code/lib/create-storybook/package.json b/code/lib/create-storybook/package.json index 0cfe35f05f0d..8e8168a09a95 100644 --- a/code/lib/create-storybook/package.json +++ b/code/lib/create-storybook/package.json @@ -48,14 +48,10 @@ "devDependencies": { "@types/prompts": "^2.0.9", "@types/semver": "^7.3.4", - "boxen": "^8.0.1", "commander": "^14.0.1", "empathic": "^2.0.0", - "execa": "^5.0.0", - "ora": "^5.4.1", "picocolors": "^1.1.0", "process-ancestry": "^0.0.2", - "prompts": "^2.4.0", "react": "^18.2.0", "tiny-invariant": "^1.3.1", "ts-dedent": "^2.0.0", diff --git a/code/lib/create-storybook/src/bin/run.ts b/code/lib/create-storybook/src/bin/run.ts index 849d33121ec7..a43bb4e89d0e 100644 --- a/code/lib/create-storybook/src/bin/run.ts +++ b/code/lib/create-storybook/src/bin/run.ts @@ -1,7 +1,10 @@ -import { isCI, optionalEnvToBoolean } from 'storybook/internal/common'; +import { ProjectType } from 'storybook/internal/cli'; +import { PackageManagerName, isCI, 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'; -import { program } from 'commander'; +import { Option, program } from 'commander'; import { version } from '../../package.json'; import type { CommandOptions } from '../generators/types'; @@ -22,21 +25,43 @@ const createStorybookProgram = program 'Disable sending telemetry data', optionalEnvToBoolean(process.env.STORYBOOK_DISABLE_TELEMETRY) ) - .option('--features ', 'What features of storybook are you interested in?') + .addOption( + new Option('--features ', 'Storybook features') + .choices(Object.values(Feature)) + .default(undefined) + ) + .option('--no-features', 'Disable all features (overrides --features)') .option('--debug', 'Get more logs in debug mode') .option('--enable-crash-reports', 'Enable sending crash reports to telemetry data') .option('-f --force', 'Force add Storybook') .option('-s --skip-install', 'Skip installing deps') - .option( - '--package-manager ', - 'Force package manager for installing deps' + .addOption( + new Option('--package-manager ', 'Force package manager for installing deps').choices( + Object.values(PackageManagerName) + ) ) // TODO: Remove in SB11 .option('--use-pnp', 'Enable pnp mode for Yarn 2+') - .option('-p --parser ', 'jscodeshift parser') - .option('-t --type ', 'Add Storybook for a specific project type') + .addOption( + new Option('--parser ', 'jscodeshift parser').choices([ + 'babel', + 'babylon', + 'flow', + 'ts', + 'tsx', + ]) + ) + .addOption( + new Option('--type ', 'Add Storybook for a specific project type').choices( + Object.values(ProjectType).filter( + (type) => ![ProjectType.UNDETECTED, ProjectType.UNSUPPORTED, ProjectType.NX].includes(type) + ) + ) + ) .option('-y --yes', 'Answer yes to all prompts') - .option('-b --builder ', 'Builder library') + .addOption( + new Option('--builder ', 'Builder library').choices(Object.values(SupportedBuilder)) + ) .option('-l --linkable', 'Prepare installation for link (contributor helper)') // due to how Commander handles default values and negated options, we have to elevate the default into Commander, and we have to specify `--dev` // alongside `--no-dev` even if we are unlikely to directly use `--dev`. https://github.com/tj/commander.js/issues/2068#issuecomment-1804524585 @@ -47,7 +72,41 @@ const createStorybookProgram = program .option( '--no-dev', 'Complete the initialization of Storybook without launching the Storybook development server' - ); + ) + .option( + '--logfile [path]', + 'Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided' + ) + .addOption( + new Option('--loglevel ', 'Define log level').choices([ + 'trace', + 'debug', + 'info', + 'warn', + 'error', + 'silent', + ]) + ) + .hook('preAction', async (self) => { + const options = self.opts(); + + if (options.debug) { + logger.setLogLevel('debug'); + } + + if (options.loglevel) { + logger.setLogLevel(options.loglevel); + } + + if (options.logfile) { + logTracker.enableLogWriting(); + } + }) + .hook('postAction', async ({ getOptionValue }) => { + if (logTracker.shouldWriteLogsToFile) { + await logTracker.writeToFile(getOptionValue('logfile')); + } + }); createStorybookProgram .action(async (options) => { @@ -56,6 +115,11 @@ createStorybookProgram options.debug = options.debug ?? false; options.dev = options.dev ?? isNeitherCiNorSandbox; + if (options.features === false) { + // Ensure features are treated as empty when --no-features is set + options.features = []; + } + await initiate(options as CommandOptions).catch(() => process.exit(1)); }) .version(String(version)) diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts new file mode 100644 index 000000000000..2443c4424703 --- /dev/null +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type JsPackageManager, PackageManagerName } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; + +import { AddonConfigurationCommand } from './AddonConfigurationCommand'; + +vi.mock('storybook/internal/node-logger', { spy: true }); + +vi.mock('storybook/internal/cli', () => ({ + AddonVitestService: vi.fn().mockImplementation(() => ({ + installPlaywright: vi.fn().mockResolvedValue([]), + })), +})); + +vi.mock('../../../cli-storybook/src/postinstallAddon', () => ({ + postinstallAddon: vi.fn(), +})); + +describe('AddonConfigurationCommand', () => { + let command: AddonConfigurationCommand; + let mockPackageManager: JsPackageManager; + let mockTask: { + success: ReturnType; + error: ReturnType; + message: ReturnType; + group: ReturnType; + }; + let mockPostinstallAddon: ReturnType; + let mockAddonVitestService: ReturnType; + + beforeEach(async () => { + const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); + mockPostinstallAddon = vi.mocked(postinstallAddon); + mockPostinstallAddon.mockResolvedValue(undefined); + + // Mock the AddonVitestService + const { AddonVitestService } = await import('storybook/internal/cli'); + mockAddonVitestService = vi.mocked(AddonVitestService); + const mockInstance = { + installPlaywright: vi.fn().mockResolvedValue([]), + }; + mockAddonVitestService.mockImplementation(() => mockInstance); + + command = new AddonConfigurationCommand(); + + mockPackageManager = { + type: 'npm', + getVersionedPackages: vi.fn(), + executeCommand: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }), + } as Partial as JsPackageManager; + + mockTask = { + success: vi.fn(), + error: vi.fn(), + message: vi.fn(), + group: vi.fn(), + }; + + vi.mocked(prompt.taskLog).mockReturnValue(mockTask); + vi.mocked(logger.log).mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should skip configuration when no addons are provided', async () => { + const addons: string[] = []; + const options = { + packageManager: PackageManagerName.NPM, + features: [], + }; + + const result = await command.execute({ + packageManager: mockPackageManager, + dependencyInstallationResult: { status: 'success' }, + addons, + configDir: '.storybook', + options, + }); + + expect(result.status).toBe('success'); + expect(prompt.taskLog).not.toHaveBeenCalled(); + expect(mockPackageManager.getVersionedPackages).not.toHaveBeenCalled(); + }); + + it('should configure test addons when test feature is enabled', async () => { + const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + const options = { + packageManager: PackageManagerName.NPM, + features: [], + yes: true, + }; + + const result = await command.execute({ + packageManager: mockPackageManager, + dependencyInstallationResult: { status: 'success' }, + addons, + configDir: '.storybook', + options, + }); + + expect(result.status).toBe('success'); + expect(prompt.taskLog).toHaveBeenCalledWith({ + id: 'configure-addons', + title: 'Configuring addons...', + }); + }); + + it('should handle configuration errors gracefully', async () => { + const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + const options = { + packageManager: PackageManagerName.NPM, + features: [], + }; + const error = new Error('Configuration failed'); + + mockPostinstallAddon.mockRejectedValue(error); + + const result = await command.execute({ + packageManager: mockPackageManager, + dependencyInstallationResult: { status: 'success' }, + addons, + configDir: '.storybook', + options, + }); + + expect(result.status).toBe('failed'); + expect(mockTask.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to configure addons') + ); + }); + + it('should complete successfully with valid configuration', async () => { + const addons = ['@storybook/addon-a11y', '@storybook/addon-vitest']; + const options = { + packageManager: PackageManagerName.NPM, + features: [], + yes: true, + }; + + const result = await command.execute({ + packageManager: mockPackageManager, + dependencyInstallationResult: { status: 'success' }, + addons, + configDir: '.storybook', + options, + }); + + expect(result.status).toBe('success'); + expect(mockPostinstallAddon).toHaveBeenCalledTimes(2); + expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-a11y', { + packageManager: 'npm', + configDir: '.storybook', + yes: true, + skipInstall: true, + skipDependencyManagement: true, + logger: expect.any(Object), + prompt: expect.any(Object), + }); + expect(mockPostinstallAddon).toHaveBeenCalledWith('@storybook/addon-vitest', { + packageManager: 'npm', + configDir: '.storybook', + yes: true, + skipInstall: true, + skipDependencyManagement: true, + logger: expect.any(Object), + prompt: expect.any(Object), + }); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts new file mode 100644 index 000000000000..989ffcbf1852 --- /dev/null +++ b/code/lib/create-storybook/src/commands/AddonConfigurationCommand.ts @@ -0,0 +1,184 @@ +import { AddonVitestService } from 'storybook/internal/cli'; +import { type JsPackageManager } from 'storybook/internal/common'; +import { CLI_COLORS, logger, prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; + +import { dedent } from 'ts-dedent'; + +import type { CommandOptions } from '../generators/types'; + +const ADDON_INSTALLATION_INSTRUCTIONS = { + '@storybook/addon-vitest': + 'https://storybook.js.org/docs/writing-tests/integrations/vitest-addon#manual-setup', +} as { [key: string]: string }; + +type ExecuteAddonConfigurationParams = { + packageManager: JsPackageManager; + addons: string[]; + options: CommandOptions; + configDir?: string; + dependencyInstallationResult: { status: 'success' | 'failed' }; +}; + +export type ExecuteAddonConfigurationResult = { + status: 'failed' | 'success'; +}; + +/** + * Command for configuring Storybook addons + * + * Responsibilities: + * + * - Run postinstall scripts for test addons (a11y, vitest) + * - Configure addons without triggering installations + * - Handle configuration errors gracefully + */ +export class AddonConfigurationCommand { + constructor(private readonly addonVitestService = new AddonVitestService()) {} + + /** Execute addon configuration */ + async execute({ + packageManager, + options, + addons, + configDir, + dependencyInstallationResult, + }: ExecuteAddonConfigurationParams): Promise { + const areDependenciesInstalled = + dependencyInstallationResult.status === 'success' && !options.skipInstall; + + if (!areDependenciesInstalled && this.getAddonsWithInstructions(addons).length > 0) { + this.logManualAddonInstructions(addons); + return { status: 'failed' }; + } + + if (!configDir || addons.length === 0) { + return { status: 'success' }; + } + + try { + const { hasFailures, addonResults } = await this.configureAddons( + packageManager, + configDir, + addons, + options + ); + + if (addonResults.has('@storybook/addon-vitest')) { + await this.addonVitestService.installPlaywright(packageManager, { + yes: options.yes, + }); + } + + return { status: hasFailures ? 'failed' : 'success' }; + } catch (e) { + logger.error('Unexpected error during addon configuration:'); + logger.error(e); + return { status: 'failed' }; + } + } + + private getAddonsWithInstructions(addons: string[]): string[] { + return addons.filter((addon) => ADDON_INSTALLATION_INSTRUCTIONS[addon]); + } + + private logManualAddonInstructions(addons: string[]): void { + const addonsWithInstructions = this.getAddonsWithInstructions(addons); + + if (addonsWithInstructions.length > 0) { + logger.warn(dedent` + The following addons couldn't be configured: + + ${addonsWithInstructions + .map((addon) => { + const manualInstructionLink = ADDON_INSTALLATION_INSTRUCTIONS[addon]; + + return `- ${addon}: ${manualInstructionLink}`; + }) + .join('\n')} + + ${ + addonsWithInstructions.length > 0 + ? `Please follow each addon's configuration instructions manually.` + : '' + } + `); + } + } + + private getAddonInstructions(addons: string[]): string { + return addons + .map((addon) => { + const instructions = + ADDON_INSTALLATION_INSTRUCTIONS[addon as keyof typeof ADDON_INSTALLATION_INSTRUCTIONS]; + return instructions ? dedent`- ${addon}: ${instructions}` : null; + }) + .filter(Boolean) + .join('\n'); + } + + /** Configure test addons (a11y and vitest) */ + private async configureAddons( + packageManager: JsPackageManager, + configDir: string, + addons: string[], + options: CommandOptions + ) { + // Import postinstallAddon from cli-storybook package + const { postinstallAddon } = await import('../../../cli-storybook/src/postinstallAddon'); + + const task = prompt.taskLog({ + id: 'configure-addons', + title: 'Configuring addons...', + }); + + // Track failures for each addon + const addonResults = new Map(); + + // Configure each addon + for (const addon of addons) { + try { + task.message(`Configuring ${addon}...`); + + await postinstallAddon(addon, { + packageManager: packageManager.type, + configDir, + yes: options.yes, + skipInstall: true, + skipDependencyManagement: true, + logger, + prompt, + }); + + task.message(`${addon} configured\n`); + addonResults.set(addon, null); + } catch (e) { + ErrorCollector.addError(e); + addonResults.set(addon, e); + } + } + + const hasFailures = [...addonResults.values()].some((result) => result !== null); + + // Set final task status + if (hasFailures) { + task.error('Failed to configure addons'); + } else { + task.success('Addons configured successfully'); + } + + // Log results for each addon, each as a separate log entry + addons.forEach((addon, index) => { + const error = addonResults.get(addon); + logger.log(CLI_COLORS.muted(error ? `❌ ${addon}` : `✅ ${addon}`), { + spacing: index === 0 ? 1 : 0, + }); + }); + + return { hasFailures, addonResults }; + } +} + +export const executeAddonConfiguration = (params: ExecuteAddonConfigurationParams) => { + return new AddonConfigurationCommand().execute(params); +}; diff --git a/code/lib/create-storybook/src/commands/DependencyInstallationCommand.test.ts b/code/lib/create-storybook/src/commands/DependencyInstallationCommand.test.ts new file mode 100644 index 000000000000..de89e07964e4 --- /dev/null +++ b/code/lib/create-storybook/src/commands/DependencyInstallationCommand.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { Feature } from 'storybook/internal/types'; + +import { DependencyCollector } from '../dependency-collector'; +import { DependencyInstallationCommand } from './DependencyInstallationCommand'; + +describe('DependencyInstallationCommand', () => { + let command: DependencyInstallationCommand; + let mockPackageManager: JsPackageManager; + let dependencyCollector: DependencyCollector; + + beforeEach(async () => { + dependencyCollector = new DependencyCollector(); + command = new DependencyInstallationCommand(dependencyCollector); + + mockPackageManager = { + addDependencies: vi.fn().mockResolvedValue(undefined), + installDependencies: vi.fn().mockResolvedValue(undefined), + } as Partial as JsPackageManager; + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should install dependencies when collector has packages', async () => { + dependencyCollector.addDevDependencies(['storybook@8.0.0']); + + await command.execute({ + packageManager: mockPackageManager, + skipInstall: false, + selectedFeatures: new Set([Feature.TEST]), + }); + + expect(mockPackageManager.addDependencies).toHaveBeenCalledWith( + { type: 'devDependencies', skipInstall: true }, + ['storybook@8.0.0'] + ); + expect(mockPackageManager.installDependencies).toHaveBeenCalled(); + }); + + it('should skip installation when skipInstall is true and no packages', async () => { + await command.execute({ + packageManager: mockPackageManager, + skipInstall: true, + selectedFeatures: new Set([Feature.TEST]), + }); + + expect(mockPackageManager.addDependencies).not.toHaveBeenCalled(); + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should install packages even when skipInstall is true if packages exist', async () => { + dependencyCollector.addDevDependencies(['storybook@8.0.0']); + + await command.execute({ + packageManager: mockPackageManager, + skipInstall: true, + selectedFeatures: new Set([Feature.TEST]), + }); + + expect(mockPackageManager.addDependencies).toHaveBeenCalledWith( + { type: 'devDependencies', skipInstall: true }, + ['storybook@8.0.0'] + ); + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should pass skipInstall flag to package manager service', async () => { + dependencyCollector.addDependencies(['react@18.0.0']); + + await command.execute({ + packageManager: mockPackageManager, + skipInstall: true, + selectedFeatures: new Set([Feature.TEST]), + }); + + expect(mockPackageManager.addDependencies).toHaveBeenCalledWith( + { type: 'dependencies', skipInstall: true }, + ['react@18.0.0'] + ); + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should throw error if installation fails', async () => { + dependencyCollector.addDevDependencies(['storybook@8.0.0']); + const error = new Error('Installation failed'); + vi.mocked(mockPackageManager.addDependencies).mockRejectedValue(error); + + await expect( + command.execute({ + packageManager: mockPackageManager, + skipInstall: false, + selectedFeatures: new Set([Feature.TEST]), + }) + ).rejects.toThrow('Installation failed'); + }); + + it('should handle empty dependency collector', async () => { + await command.execute({ + packageManager: mockPackageManager, + skipInstall: false, + selectedFeatures: new Set([Feature.TEST]), + }); + + expect(mockPackageManager.addDependencies).not.toHaveBeenCalled(); + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should not collect test dependencies if test feature is not selected', async () => { + await command.execute({ + packageManager: mockPackageManager, + skipInstall: false, + selectedFeatures: new Set([Feature.DOCS]), + }); + + expect(dependencyCollector.getAllPackages()).not.toContain('vitest'); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/DependencyInstallationCommand.ts b/code/lib/create-storybook/src/commands/DependencyInstallationCommand.ts new file mode 100644 index 000000000000..a41d185184ef --- /dev/null +++ b/code/lib/create-storybook/src/commands/DependencyInstallationCommand.ts @@ -0,0 +1,102 @@ +import { AddonVitestService } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; +import { Feature } from 'storybook/internal/types'; + +import type { DependencyCollector } from '../dependency-collector'; + +type DependencyInstallationCommandParams = { + packageManager: JsPackageManager; + skipInstall: boolean; + selectedFeatures: Set; +}; + +/** + * Command for installing all collected dependencies + * + * Responsibilities: + * + * - Update package.json with all dependencies + * - Run single installation operation + * - Handle skipInstall option + */ +export class DependencyInstallationCommand { + constructor( + private dependencyCollector: DependencyCollector, + private addonVitestService = new AddonVitestService() + ) {} + /** Execute dependency installation */ + async execute({ + packageManager, + skipInstall = false, + selectedFeatures, + }: DependencyInstallationCommandParams): Promise<{ status: 'success' | 'failed' }> { + await this.collectAddonDependencies(packageManager, selectedFeatures); + + if (!this.dependencyCollector.hasPackages() && skipInstall) { + return { status: 'success' }; + } + + const { dependencies, devDependencies } = this.dependencyCollector.getAllPackages(); + + const task = prompt.taskLog({ + id: 'adding-dependencies', + title: 'Adding dependencies to package.json', + }); + + if (dependencies.length > 0) { + task.message('Adding dependencies:\n' + dependencies.map((dep) => `- ${dep}`).join('\n')); + + await packageManager.addDependencies( + { type: 'dependencies', skipInstall: true }, + dependencies + ); + } + + if (devDependencies.length > 0) { + task.message( + 'Adding devDependencies:\n' + devDependencies.map((dep) => `- ${dep}`).join('\n') + ); + + await packageManager.addDependencies( + { type: 'devDependencies', skipInstall: true }, + devDependencies + ); + } + + task.success('Dependencies added to package.json', { showLog: true }); + + if (!skipInstall && this.dependencyCollector.hasPackages()) { + try { + await packageManager.installDependencies(); + } catch (err) { + ErrorCollector.addError(err); + return { status: 'failed' }; + } + } + + return { status: 'success' }; + } + + /** Collect addon dependencies without installing them */ + private async collectAddonDependencies( + packageManager: JsPackageManager, + selectedFeatures: Set + ): Promise { + try { + if (selectedFeatures.has(Feature.TEST)) { + const vitestDeps = await this.addonVitestService.collectDependencies(packageManager); + this.dependencyCollector.addDevDependencies(vitestDeps); + } + } catch (err) { + logger.warn(`Failed to collect addon dependencies: ${err}`); + } + } +} + +export const executeDependencyInstallation = ( + params: DependencyInstallationCommandParams & { dependencyCollector: DependencyCollector } +) => { + return new DependencyInstallationCommand(params.dependencyCollector).execute(params); +}; diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts new file mode 100644 index 000000000000..e8a8ad8564d7 --- /dev/null +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.test.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs/promises'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; + +import * as find from 'empathic/find'; + +import { FinalizationCommand } from './FinalizationCommand'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('storybook/internal/common', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('empathic/find', { spy: true }); + +describe('FinalizationCommand', () => { + let command: FinalizationCommand; + + beforeEach(() => { + command = new FinalizationCommand(undefined); + + vi.mocked(getProjectRoot).mockReturnValue('/test/project'); + vi.mocked(logger.step).mockImplementation(() => {}); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(logger.outro).mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should update gitignore and print success message', async () => { + vi.mocked(find.up).mockReturnValue('/test/project/.gitignore'); + vi.mocked(fs.readFile).mockResolvedValue('node_modules/\n'); + vi.mocked(fs.appendFile).mockResolvedValue(undefined); + + await command.execute({ + storybookCommand: 'npm run storybook', + }); + + expect(fs.appendFile).toHaveBeenCalledWith( + '/test/project/.gitignore', + '\n*storybook.log\nstorybook-static\n' + ); + expect(logger.step).toHaveBeenCalledWith(expect.stringContaining('successfully installed')); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('npm run storybook')); + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('storybook.js.org')); + }); + + it('should not update gitignore if file not found', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await command.execute({ + storybookCommand: 'yarn storybook', + }); + + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.appendFile).not.toHaveBeenCalled(); + expect(logger.step).toHaveBeenCalled(); + }); + + it('should not update gitignore if file is outside project root', async () => { + vi.mocked(find.up).mockReturnValue('/other/path/.gitignore'); + vi.mocked(getProjectRoot).mockReturnValue('/test/project'); + + await command.execute({ + storybookCommand: 'npm run storybook', + }); + + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.appendFile).not.toHaveBeenCalled(); + }); + + it('should not add entries that already exist in gitignore', async () => { + vi.mocked(find.up).mockReturnValue('/test/project/.gitignore'); + vi.mocked(fs.readFile).mockResolvedValue('node_modules/\n*storybook.log\nstorybook-static\n'); + + await command.execute({ + storybookCommand: 'npm run storybook', + }); + + expect(fs.appendFile).not.toHaveBeenCalled(); + }); + + it('should add only missing entries to gitignore', async () => { + vi.mocked(find.up).mockReturnValue('/test/project/.gitignore'); + vi.mocked(fs.readFile).mockResolvedValue('node_modules/\n*storybook.log\n'); + vi.mocked(fs.appendFile).mockResolvedValue(undefined); + + await command.execute({ + storybookCommand: 'npm run storybook', + }); + + expect(fs.appendFile).toHaveBeenCalledWith( + '/test/project/.gitignore', + '\nstorybook-static\n' + ); + }); + + it('should include storybook command in output', async () => { + vi.mocked(find.up).mockReturnValue(undefined); + + await command.execute({ + storybookCommand: 'ng run my-app:storybook', + }); + + expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('ng run my-app:storybook')); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/FinalizationCommand.ts b/code/lib/create-storybook/src/commands/FinalizationCommand.ts new file mode 100644 index 000000000000..b7fe8efe9b1c --- /dev/null +++ b/code/lib/create-storybook/src/commands/FinalizationCommand.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs/promises'; + +import { getProjectRoot } from 'storybook/internal/common'; +import { CLI_COLORS, logTracker, logger } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; + +import * as find from 'empathic/find'; +import { dedent } from 'ts-dedent'; + +type ExecuteFinalizationParams = { + storybookCommand?: string | null; +}; + +/** + * Command for finalizing Storybook installation + * + * Responsibilities: + * + * - Update .gitignore with Storybook entries + * - Print success message + * - Display feature summary + * - Show next steps + */ +export class FinalizationCommand { + constructor(private logfile: string | boolean | undefined) {} + /** Execute finalization steps */ + async execute({ storybookCommand }: ExecuteFinalizationParams): Promise { + // Update .gitignore + await this.updateGitignore(); + + const errors = ErrorCollector.getErrors(); + + if (errors.length > 0) { + await this.printFailureMessage(storybookCommand); + } else { + this.printSuccessMessage(storybookCommand); + } + } + + /** Update .gitignore with Storybook-specific entries */ + private async updateGitignore(): Promise { + const foundGitIgnoreFile = find.up('.gitignore'); + const rootDirectory = getProjectRoot(); + + if (!foundGitIgnoreFile || !foundGitIgnoreFile.includes(rootDirectory)) { + return; + } + + const contents = await fs.readFile(foundGitIgnoreFile, 'utf-8'); + const hasStorybookLog = contents.includes('*storybook.log'); + const hasStorybookStatic = contents.includes('storybook-static'); + + const linesToAdd = [ + !hasStorybookLog ? '*storybook.log' : '', + !hasStorybookStatic ? 'storybook-static' : '', + ] + .filter(Boolean) + .join('\n'); + + if (linesToAdd) { + await fs.appendFile(foundGitIgnoreFile, `\n${linesToAdd}\n`); + } + } + + private async printFailureMessage(storybookCommand?: string | null): Promise { + logger.warn('Storybook setup completed, but some non-blocking errors occurred.'); + this.printNextSteps(storybookCommand); + + const logFile = await logTracker.writeToFile(this.logfile); + logger.warn(`Storybook debug logs can be found at: ${logFile}`); + } + + /** Print success message with feature summary */ + private printSuccessMessage(storybookCommand?: string | null): void { + 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(dedent` + Wanna know more about Storybook? Check out ${CLI_COLORS.cta('https://storybook.js.org/')} + Having trouble or want to chat? Join us at ${CLI_COLORS.cta('https://discord.gg/storybook/')} + `); + } +} +export const executeFinalization = ({ + logfile, + ...params +}: ExecuteFinalizationParams & { logfile: string | boolean | undefined }) => { + return new FinalizationCommand(logfile).execute(params); +}; diff --git a/code/lib/create-storybook/src/commands/FrameworkDetectionCommand.ts b/code/lib/create-storybook/src/commands/FrameworkDetectionCommand.ts new file mode 100644 index 000000000000..4d92dc99930a --- /dev/null +++ b/code/lib/create-storybook/src/commands/FrameworkDetectionCommand.ts @@ -0,0 +1,91 @@ +import { type ProjectType } from 'storybook/internal/cli'; +import { type JsPackageManager } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import type { SupportedBuilder } from 'storybook/internal/types'; +import { type SupportedRenderer } from 'storybook/internal/types'; +import type { SupportedFramework } from 'storybook/internal/types'; + +import { generatorRegistry } from '../generators/GeneratorRegistry'; +import type { CommandOptions } from '../generators/types'; +import { FrameworkDetectionService } from '../services/FrameworkDetectionService'; + +export interface FrameworkDetectionResult { + renderer: SupportedRenderer; + builder: SupportedBuilder; + framework: SupportedFramework | null; +} + +/** + * Command for detecting framework, renderer, and builder from ProjectType + * + * Uses generator metadata to determine the correct framework and renderer, and detects or uses + * overridden builder configuration. + */ +export class FrameworkDetectionCommand { + constructor( + packageManager: JsPackageManager, + private frameworkDetectionService = new FrameworkDetectionService(packageManager) + ) {} + async execute( + projectType: ProjectType, + options: CommandOptions + ): Promise { + // Get generator for the project type + const generatorModule = generatorRegistry.get(projectType); + + if (!generatorModule) { + throw new Error(`No generator found for project type: ${projectType}`); + } + + const { metadata } = generatorModule; + + // Determine builder - use override if specified, otherwise detect + let builder: SupportedBuilder; + if (options.builder) { + // CLI option takes precedence + builder = options.builder as SupportedBuilder; + } else if (metadata.builderOverride) { + if (typeof metadata.builderOverride === 'function') { + builder = await metadata.builderOverride(); + } else { + builder = metadata.builderOverride; + } + } else { + // Detect builder from project configuration + builder = await this.frameworkDetectionService.detectBuilder(); + } + + // Get framework and renderer from metadata + const renderer = metadata.renderer; + + // Handle dynamic framework selection based on builder + let framework: SupportedFramework | null; + if (metadata.framework !== undefined) { + if (typeof metadata.framework === 'function') { + framework = metadata.framework(builder); + } else { + framework = metadata.framework; + } + } else { + framework = this.frameworkDetectionService.detectFramework(renderer, builder); + } + + if (framework) { + logger.step(`Framework detected: ${framework}`); + } + + return { + framework, + renderer, + builder, + }; + } +} + +export const executeFrameworkDetection = ( + projectType: ProjectType, + packageManager: JsPackageManager, + options: CommandOptions +) => { + return new FrameworkDetectionCommand(packageManager).execute(projectType, options); +}; diff --git a/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.test.ts b/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.test.ts new file mode 100644 index 000000000000..e3dceeda0120 --- /dev/null +++ b/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.test.ts @@ -0,0 +1,191 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectType } from 'storybook/internal/cli'; +import { type JsPackageManager, PackageManagerName } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import { + Feature, + SupportedBuilder, + SupportedFramework, + SupportedLanguage, + SupportedRenderer, +} from 'storybook/internal/types'; + +import { DependencyCollector } from '../dependency-collector'; +import { generatorRegistry } from '../generators/GeneratorRegistry'; +import { baseGenerator } from '../generators/baseGenerator'; +import { AddonService } from '../services'; +import type { FrameworkDetectionResult } from './FrameworkDetectionCommand'; +import { GeneratorExecutionCommand } from './GeneratorExecutionCommand'; + +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('../generators/GeneratorRegistry', { spy: true }); +vi.mock('../generators/baseGenerator', { spy: true }); +vi.mock('../services', { spy: true }); + +describe('GeneratorExecutionCommand', () => { + let command: GeneratorExecutionCommand; + let mockPackageManager: JsPackageManager; + let dependencyCollector: DependencyCollector; + let mockGenerator: { + metadata: { + projectType: ProjectType; + renderer: SupportedRenderer; + framework?: SupportedFramework; + }; + configure: ReturnType; + }; + let mockFrameworkInfo: FrameworkDetectionResult; + let mockAddonService: { getAddonsForFeatures: ReturnType }; + + beforeEach(() => { + dependencyCollector = new DependencyCollector(); + mockAddonService = { + getAddonsForFeatures: vi.fn().mockReturnValue([]), + }; + vi.mocked(AddonService).mockImplementation( + () => mockAddonService as unknown as InstanceType + ); + mockPackageManager = { + getRunCommand: vi.fn().mockReturnValue('npm run storybook'), + } as unknown as JsPackageManager; + + command = new GeneratorExecutionCommand(dependencyCollector, mockPackageManager); + + mockFrameworkInfo = { + renderer: SupportedRenderer.REACT, + builder: SupportedBuilder.VITE, + framework: SupportedFramework.REACT_VITE, + }; + + // Mock new-style generator module + mockGenerator = { + metadata: { + projectType: ProjectType.REACT, + renderer: SupportedRenderer.REACT, + framework: undefined, + }, + configure: vi.fn().mockResolvedValue({ + extraPackages: [], + extraAddons: [], + }), + }; + + vi.mocked(generatorRegistry.get).mockReturnValue(mockGenerator); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(baseGenerator).mockResolvedValue({ + configDir: '.storybook', + storybookCommand: undefined, + shouldRunDev: undefined, + }); + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should execute generator with all features', async () => { + const selectedFeatures = new Set([Feature.DOCS, Feature.TEST, Feature.ONBOARDING]); + mockAddonService.getAddonsForFeatures.mockReturnValue([ + '@chromatic-com/storybook', + '@storybook/addon-vitest', + '@storybook/addon-docs', + '@storybook/addon-onboarding', + ]); + const options = { + skipInstall: false, + packageManager: PackageManagerName.NPM, + }; + + await command.execute({ + projectType: ProjectType.REACT, + frameworkInfo: mockFrameworkInfo, + language: SupportedLanguage.TYPESCRIPT, + options, + selectedFeatures, + }); + + expect(generatorRegistry.get).toHaveBeenCalledWith(ProjectType.REACT); + expect(mockGenerator.configure).toHaveBeenCalled(); + expect(baseGenerator).toHaveBeenCalled(); + expect(mockAddonService.getAddonsForFeatures).toHaveBeenCalledWith(selectedFeatures); + }); + + it('should throw error if generator not found', async () => { + vi.mocked(generatorRegistry.get).mockReturnValue(undefined); + const selectedFeatures = new Set([]); + const options = { + packageManager: PackageManagerName.NPM, + }; + + await expect( + command.execute({ + projectType: ProjectType.UNSUPPORTED, + frameworkInfo: mockFrameworkInfo, + language: SupportedLanguage.TYPESCRIPT, + options, + selectedFeatures, + }) + ).rejects.toThrow('No generator found for project type'); + }); + + it('should pass correct options to generator', async () => { + const selectedFeatures = new Set([Feature.DOCS, Feature.TEST, Feature.A11Y]); + mockAddonService.getAddonsForFeatures.mockReturnValue([ + '@chromatic-com/storybook', + '@storybook/addon-vitest', + '@storybook/addon-a11y', + '@storybook/addon-docs', + ]); + const options = { + skipInstall: true, + builder: SupportedBuilder.VITE, + linkable: true, + usePnp: true, + yes: true, + packageManager: PackageManagerName.NPM, + }; + + await command.execute({ + projectType: ProjectType.VUE3, + frameworkInfo: mockFrameworkInfo, + language: SupportedLanguage.TYPESCRIPT, + options, + selectedFeatures, + }); + + expect(mockGenerator.configure).toHaveBeenCalledWith( + mockPackageManager, + expect.objectContaining({ + framework: mockFrameworkInfo.framework, + renderer: mockFrameworkInfo.renderer, + builder: mockFrameworkInfo.builder, + features: selectedFeatures, + }) + ); + + expect(baseGenerator).toHaveBeenCalledWith( + mockPackageManager, + { type: 'devDependencies', skipInstall: true }, + expect.objectContaining({ + builder: SupportedBuilder.VITE, + linkable: true, + pnp: true, + yes: true, + projectType: ProjectType.VUE3, + features: expect.any(Set), + dependencyCollector: expect.any(Object), + }), + expect.objectContaining({ + extraAddons: expect.arrayContaining([ + '@chromatic-com/storybook', + '@storybook/addon-vitest', + '@storybook/addon-a11y', + '@storybook/addon-docs', + ]), + extraPackages: [], + }) + ); + expect(mockAddonService.getAddonsForFeatures).toHaveBeenCalledWith(selectedFeatures); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.ts b/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.ts new file mode 100644 index 000000000000..8a2bf5f18f15 --- /dev/null +++ b/code/lib/create-storybook/src/commands/GeneratorExecutionCommand.ts @@ -0,0 +1,158 @@ +import type { ProjectType } from 'storybook/internal/cli'; +import { type JsPackageManager } from 'storybook/internal/common'; +import { type Feature, type SupportedLanguage } from 'storybook/internal/types'; + +import type { DependencyCollector } from '../dependency-collector'; +import { generatorRegistry } from '../generators/GeneratorRegistry'; +import { baseGenerator } from '../generators/baseGenerator'; +import type { CommandOptions, GeneratorModule, GeneratorOptions } from '../generators/types'; +import { AddonService } from '../services'; +import type { FrameworkDetectionResult } from './FrameworkDetectionCommand'; + +type ExecuteProjectGeneratorOptions = { + projectType: ProjectType; + language: SupportedLanguage; + frameworkInfo: FrameworkDetectionResult; + options: CommandOptions; + selectedFeatures: Set; +}; + +/** + * Command for executing the project-specific generator + * + * Responsibilities: + * + * - Get generator module from registry + * - Call generator's configure() to get framework-specific options + * - Execute baseGenerator with complete configuration + * - Determine Storybook command + */ +export class GeneratorExecutionCommand { + /** Execute generator for the detected project type */ + constructor( + private readonly dependencyCollector: DependencyCollector, + private readonly jsPackageManager: JsPackageManager, + private readonly addonService = new AddonService() + ) {} + + async execute({ + projectType, + options, + frameworkInfo, + selectedFeatures, + language, + }: ExecuteProjectGeneratorOptions) { + // Get and execute generator (supports both old and new style) + const generatorResult = await this.executeProjectGenerator({ + projectType, + frameworkInfo, + options, + selectedFeatures, + language, + }); + + // Determine Storybook command + + return { + ...generatorResult, + configDir: 'configDir' in generatorResult ? generatorResult.configDir : undefined, + storybookCommand: + generatorResult.storybookCommand !== undefined + ? generatorResult.storybookCommand + : this.jsPackageManager.getRunCommand('storybook'), + }; + } + + /** Execute the project-specific generator */ + private readonly executeProjectGenerator = async ({ + projectType, + frameworkInfo, + options, + selectedFeatures, + language, + }: ExecuteProjectGeneratorOptions) => { + const generator = generatorRegistry.get(projectType); + + if (!generator) { + throw new Error(`No generator found for project type: ${projectType}`); + } + + const npmOptions = { + type: 'devDependencies' as const, + skipInstall: options.skipInstall, + }; + + // All generators must be new-style modules with metadata + configure + const generatorModule = generator as GeneratorModule; + + // Call configure function to get framework-specific options + const frameworkOptions = await generatorModule.configure(this.jsPackageManager, { + framework: frameworkInfo.framework, + renderer: frameworkInfo.renderer, + builder: frameworkInfo.builder, + language, + linkable: !!options.linkable, + features: selectedFeatures, + dependencyCollector: this.dependencyCollector, + yes: options.yes, + }); + + const generatorOptions = { + language, + builder: frameworkInfo.builder, + framework: frameworkInfo.framework, + renderer: frameworkInfo.renderer, + linkable: !!options.linkable, + pnp: !!options.usePnp, + yes: !!options.yes, + projectType, + features: selectedFeatures, + dependencyCollector: this.dependencyCollector, + } as GeneratorOptions; + + if (frameworkOptions.skipGenerator) { + if (generatorModule.postConfigure) { + await generatorModule.postConfigure({ packageManager: this.jsPackageManager }); + } + + return { + shouldRunDev: frameworkOptions.shouldRunDev, + storybookCommand: frameworkOptions.storybookCommand, + extraAddons: [], + }; + } + + const extraAddons = this.addonService.getAddonsForFeatures(selectedFeatures); + + // Call baseGenerator with complete configuration + const generatorResult = await baseGenerator( + this.jsPackageManager, + npmOptions, + generatorOptions, + { + ...frameworkOptions, + extraAddons: [...(frameworkOptions.extraAddons ?? []), ...extraAddons], + } + ); + + if (generatorModule.postConfigure) { + await generatorModule.postConfigure({ packageManager: this.jsPackageManager }); + } + + return { + ...generatorResult, + extraAddons, + }; + }; +} + +export const executeGeneratorExecution = ({ + dependencyCollector, + packageManager, + ...options +}: ExecuteProjectGeneratorOptions & { + dependencyCollector: DependencyCollector; + packageManager: JsPackageManager; +}) => { + return new GeneratorExecutionCommand(dependencyCollector, packageManager).execute(options); +}; diff --git a/code/lib/create-storybook/src/commands/PreflightCheckCommand.test.ts b/code/lib/create-storybook/src/commands/PreflightCheckCommand.test.ts new file mode 100644 index 000000000000..c2753babd7f8 --- /dev/null +++ b/code/lib/create-storybook/src/commands/PreflightCheckCommand.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + JsPackageManagerFactory, + PackageManagerName, + invalidateProjectRootCache, +} from 'storybook/internal/common'; + +import * as scaffoldModule from '../scaffold-new-project'; +import { PreflightCheckCommand } from './PreflightCheckCommand'; + +vi.mock('storybook/internal/common', { spy: true }); +vi.mock('../scaffold-new-project', { spy: true }); + +describe('PreflightCheckCommand', () => { + let command: PreflightCheckCommand; + let mockPackageManager: any; + + beforeEach(() => { + command = new PreflightCheckCommand(); + mockPackageManager = { + installDependencies: vi.fn(), + latestVersion: vi.fn().mockResolvedValue('8.0.0'), + type: PackageManagerName.NPM, + }; + + vi.mocked(JsPackageManagerFactory.getPackageManager).mockReturnValue(mockPackageManager); + vi.mocked(JsPackageManagerFactory.getPackageManagerType).mockReturnValue( + PackageManagerName.NPM + ); + vi.mocked(scaffoldModule.scaffoldNewProject).mockResolvedValue(undefined); + vi.mocked(invalidateProjectRootCache).mockImplementation(() => {}); + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should return package manager for non-empty directory', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(false); + + const result = await command.execute({ force: false } as any); + + expect(result.packageManager).toBe(mockPackageManager); + expect(result.isEmptyProject).toBe(false); + expect(scaffoldModule.scaffoldNewProject).not.toHaveBeenCalled(); + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should scaffold new project when directory is empty', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(true); + + const result = await command.execute({ force: false, skipInstall: true } as any); + + expect(scaffoldModule.scaffoldNewProject).toHaveBeenCalledWith('npm', { + force: false, + skipInstall: true, + }); + expect(invalidateProjectRootCache).toHaveBeenCalled(); + expect(result.isEmptyProject).toBe(true); + }); + + it('should install dependencies for empty project when not skipping install', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(true); + + await command.execute({ force: false, skipInstall: false } as any); + + expect(mockPackageManager.installDependencies).toHaveBeenCalled(); + }); + + it('should not install dependencies when skipInstall is true', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(true); + + await command.execute({ force: false, skipInstall: true } as any); + + expect(mockPackageManager.installDependencies).not.toHaveBeenCalled(); + }); + + it('should use npm instead of yarn1 for empty directory', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(true); + vi.mocked(JsPackageManagerFactory.getPackageManagerType).mockReturnValue( + PackageManagerName.YARN1 + ); + + await command.execute({ force: false, skipInstall: true } as any); + + expect(scaffoldModule.scaffoldNewProject).toHaveBeenCalledWith('npm', expect.any(Object)); + }); + + it('should skip scaffolding when force is true', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(true); + + const result = await command.execute({ force: true } as any); + + expect(scaffoldModule.scaffoldNewProject).not.toHaveBeenCalled(); + expect(result.isEmptyProject).toBe(false); + }); + + it('should use provided package manager', async () => { + vi.mocked(scaffoldModule.currentDirectoryIsEmpty).mockReturnValue(false); + + await command.execute({ packageManager: 'yarn' } as any); + + expect(JsPackageManagerFactory.getPackageManager).toHaveBeenCalledWith({ + force: 'yarn', + }); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/PreflightCheckCommand.ts b/code/lib/create-storybook/src/commands/PreflightCheckCommand.ts new file mode 100644 index 000000000000..574c5a39fd50 --- /dev/null +++ b/code/lib/create-storybook/src/commands/PreflightCheckCommand.ts @@ -0,0 +1,110 @@ +import { detectPnp } from 'storybook/internal/cli'; +import { + type JsPackageManager, + JsPackageManagerFactory, + PackageManagerName, + invalidateProjectRootCache, +} from 'storybook/internal/common'; +import { CLI_COLORS, deprecate, logger } from 'storybook/internal/node-logger'; + +import { dedent } from 'ts-dedent'; + +import type { CommandOptions } from '../generators/types'; +import { currentDirectoryIsEmpty, scaffoldNewProject } from '../scaffold-new-project'; +import { VersionService } from '../services'; + +export interface PreflightCheckResult { + packageManager: JsPackageManager; + isEmptyProject: boolean; +} + +/** + * Command for running preflight checks before Storybook initialization + * + * Responsibilities: + * + * - Handle empty directory detection and scaffolding + * - Initialize package manager + * - Install base dependencies if needed + */ +export class PreflightCheckCommand { + /** Execute preflight checks */ + constructor(private readonly versionService = new VersionService()) {} + async execute(options: CommandOptions): Promise { + const isEmptyDirProject = options.force !== true && currentDirectoryIsEmpty(); + let packageManagerType = JsPackageManagerFactory.getPackageManagerType(); + + // Check if the current directory is empty + if (isEmptyDirProject) { + // Initializing Storybook in an empty directory with yarn1 + // will very likely fail due to different kinds of hoisting issues + // which doesn't get fixed anymore in yarn1. + // We will fallback to npm in this case. + if ( + options.packageManager + ? options.packageManager === PackageManagerName.YARN1 + : packageManagerType === PackageManagerName.YARN1 + ) { + logger.warn('Empty directory with yarn1 is unsupported. Falling back to npm.'); + packageManagerType = PackageManagerName.NPM; + options.packageManager = packageManagerType; + } + + // Prompt the user to create a new project from our list + logger.intro(CLI_COLORS.info(`Initializing a new project`)); + await scaffoldNewProject(packageManagerType, options); + logger.outro(CLI_COLORS.info(`Project created successfully`)); + invalidateProjectRootCache(); + } + + logger.intro(CLI_COLORS.info(`Initializing Storybook`)); + + const packageManager = JsPackageManagerFactory.getPackageManager({ + force: options.packageManager, + }); + + // Install base project dependencies if we scaffolded a new project + if (isEmptyDirProject && !options.skipInstall) { + await packageManager.installDependencies(); + } + + const pnp = await detectPnp(); + if (pnp) { + deprecate(dedent` + As of Storybook 10.0, PnP is deprecated. + If you are using PnP, you can continue to use Storybook 10.0, but we recommend migrating to a different package manager or linker-mode. In future versions, PnP compatibility will be removed. + `); + } + + await this.displayVersionInfo(packageManager); + + return { packageManager, isEmptyProject: isEmptyDirProject }; + } + + /** Display version information and warnings */ + private async displayVersionInfo(packageManager: JsPackageManager): Promise { + const { currentVersion, latestVersion, isPrerelease, isOutdated } = + await this.versionService.getVersionInfo(packageManager); + + if (isOutdated && !isPrerelease) { + logger.warn(dedent` + This version is behind the latest release, which is: ${latestVersion}! + You likely ran the init command through npx, which can use a locally cached version. + + To get the latest, please run: ${CLI_COLORS.cta('npx storybook@latest init')} + You may want to ${CLI_COLORS.cta('CTRL+C')} to stop, and run with the latest version instead. + `); + } else if (isPrerelease) { + logger.warn(`This is a pre-release version: ${currentVersion}`); + } else { + logger.info(`Adding Storybook version ${currentVersion} to your project`); + } + } +} + +export const executePreflightCheck = async ( + options: CommandOptions +): Promise => { + const command = new PreflightCheckCommand(); + return command.execute(options); +}; diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts new file mode 100644 index 000000000000..3bc07e5a2325 --- /dev/null +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectType } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { HandledError, PackageManagerName } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import type { Feature } from 'storybook/internal/types'; +import { SupportedLanguage } from 'storybook/internal/types'; + +import type { CommandOptions } from '../generators/types'; +import { ProjectTypeService } from '../services/ProjectTypeService'; +import { ProjectDetectionCommand } from './ProjectDetectionCommand'; + +vi.mock('storybook/internal/common', async () => { + const actual = await vi.importActual('storybook/internal/common'); + return { + ...actual, + HandledError: class HandledError extends Error {}, + }; +}); + +vi.mock('storybook/internal/node-logger', { spy: true }); +vi.mock('../services/ProjectTypeService', { spy: true }); + +describe('ProjectDetectionCommand', () => { + let command: ProjectDetectionCommand; + let mockPackageManager: JsPackageManager; + let mockProjectTypeService: { + validateProvidedType: ReturnType; + autoDetectProjectType: ReturnType; + isStorybookInstantiated: ReturnType; + detectLanguage: ReturnType; + }; + let options: CommandOptions; + + beforeEach(() => { + mockPackageManager = { + primaryPackageJson: { packageJson: {} }, + } as unknown as JsPackageManager; + + mockProjectTypeService = { + validateProvidedType: vi.fn(), + autoDetectProjectType: vi.fn(), + isStorybookInstantiated: vi.fn().mockReturnValue(false), + detectLanguage: vi.fn().mockResolvedValue(SupportedLanguage.JAVASCRIPT), + }; + + vi.mocked(ProjectTypeService).mockImplementation( + () => mockProjectTypeService as unknown as InstanceType + ); + + options = { + packageManager: PackageManagerName.NPM, + features: undefined as unknown as Array, + }; + + command = new ProjectDetectionCommand(options, mockPackageManager); + + vi.mocked(logger.step).mockImplementation(() => {}); + vi.mocked(logger.error).mockImplementation(() => {}); + vi.mocked(logger.debug).mockImplementation(() => {}); + vi.mocked(logger.warn).mockImplementation(() => {}); + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should use provided project type when valid', async () => { + options.type = ProjectType.REACT; + vi.mocked(mockProjectTypeService.validateProvidedType).mockResolvedValue(ProjectType.REACT); + + const result = await command.execute(); + + expect(result.projectType).toBe(ProjectType.REACT); + expect(mockProjectTypeService.validateProvidedType).toHaveBeenCalledWith(ProjectType.REACT); + expect(logger.step).toHaveBeenCalledWith( + 'Installing Storybook for user specified project type: react' + ); + expect(mockProjectTypeService.autoDetectProjectType).not.toHaveBeenCalled(); + }); + + it('should auto-detect project type when not provided', async () => { + options.type = undefined; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.VUE3); + + const result = await command.execute(); + + expect(result.projectType).toBe(ProjectType.VUE3); + expect(mockProjectTypeService.autoDetectProjectType).toHaveBeenCalledWith(options); + expect(logger.debug).toHaveBeenCalledWith('Project type detected: vue3'); + }); + + it('should throw error for invalid provided type', async () => { + options.type = ProjectType.UNSUPPORTED; + const error = new HandledError('Unknown project type supplied: unsupported'); + vi.mocked(mockProjectTypeService.validateProvidedType).mockImplementation(async () => { + logger.error( + `The provided project type ${ProjectType.UNSUPPORTED} was not recognized by Storybook` + ); + throw error; + }); + + await expect(command.execute()).rejects.toThrow(HandledError); + + expect(logger.error).toHaveBeenCalledWith( + 'The provided project type unsupported was not recognized by Storybook' + ); + }); + + it('should prompt for React Native variant when detected', async () => { + options.type = undefined; + options.yes = false; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue( + ProjectType.REACT_NATIVE + ); + vi.mocked(prompt.select).mockResolvedValue(ProjectType.REACT_NATIVE_WEB); + + const result = await command.execute(); + + expect(result.projectType).toBe(ProjectType.REACT_NATIVE_WEB); + expect(prompt.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "We've detected a React Native project. Install:", + }) + ); + }); + + it('should not prompt for React Native variant when yes flag is set', async () => { + options.type = undefined; + options.yes = true; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue( + ProjectType.REACT_NATIVE + ); + + const result = await command.execute(); + + expect(result.projectType).toBe(ProjectType.REACT_NATIVE); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should handle all React Native variants', async () => { + options.type = undefined; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue( + ProjectType.REACT_NATIVE + ); + vi.mocked(prompt.select).mockResolvedValue(ProjectType.REACT_NATIVE_AND_RNW); + + const result = await command.execute(); + + expect(result.projectType).toBe(ProjectType.REACT_NATIVE_AND_RNW); + }); + + it('should check for existing Storybook installation', async () => { + options.type = undefined; + options.force = false; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.REACT); + vi.mocked(mockProjectTypeService.isStorybookInstantiated).mockReturnValue(true); + vi.mocked(prompt.confirm).mockResolvedValue(true); + + await command.execute(); + + expect(mockProjectTypeService.isStorybookInstantiated).toHaveBeenCalled(); + expect(prompt.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('already instantiated'), + }) + ); + expect(options.force).toBe(true); + }); + + it('should exit if user declines to force install', async () => { + options.type = undefined; + options.force = false; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.REACT); + vi.mocked(mockProjectTypeService.isStorybookInstantiated).mockReturnValue(true); + vi.mocked(prompt.confirm).mockResolvedValue(false); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await command.execute(); + + expect(exitSpy).toHaveBeenCalledWith(0); + exitSpy.mockRestore(); + }); + + it('should not check existing installation for Angular projects', async () => { + options.type = undefined; + options.force = false; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue( + ProjectType.ANGULAR + ); + vi.mocked(mockProjectTypeService.isStorybookInstantiated).mockReturnValue(true); + + await command.execute(); + + expect(prompt.confirm).not.toHaveBeenCalled(); + }); + + it('should handle detection errors', async () => { + options.type = undefined; + const error = new Error('Detection failed'); + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockImplementation(async () => { + logger.error(String(error)); + throw new HandledError(error.message); + }); + + await expect(command.execute()).rejects.toThrow(HandledError); + + expect(logger.error).toHaveBeenCalledWith('Error: Detection failed'); + }); + + it('should detect language from options or service', async () => { + options.type = undefined; + options.language = SupportedLanguage.TYPESCRIPT; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.REACT); + + const result = await command.execute(); + + expect(result.language).toBe(SupportedLanguage.TYPESCRIPT); + expect(mockProjectTypeService.detectLanguage).not.toHaveBeenCalled(); + }); + + it('should use service to detect language when not provided', async () => { + options.type = undefined; + options.language = undefined; + vi.mocked(mockProjectTypeService.autoDetectProjectType).mockResolvedValue(ProjectType.REACT); + vi.mocked(mockProjectTypeService.detectLanguage).mockResolvedValue( + SupportedLanguage.TYPESCRIPT + ); + + const result = await command.execute(); + + expect(result.language).toBe(SupportedLanguage.TYPESCRIPT); + expect(mockProjectTypeService.detectLanguage).toHaveBeenCalled(); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts new file mode 100644 index 000000000000..f879edc78773 --- /dev/null +++ b/code/lib/create-storybook/src/commands/ProjectDetectionCommand.ts @@ -0,0 +1,105 @@ +import { ProjectType } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import type { SupportedLanguage } from 'storybook/internal/types'; + +import picocolors from 'picocolors'; +import { dedent } from 'ts-dedent'; + +import type { CommandOptions } from '../generators/types'; +import { ProjectTypeService } from '../services/ProjectTypeService'; + +/** + * Command for detecting the project type during Storybook initialization + * + * Responsibilities: + * + * - Auto-detect project type or use user-provided type + * - Handle React Native variant selection + * - Check for existing Storybook installation + * - Prompt for force install if needed + */ +export class ProjectDetectionCommand { + constructor( + private options: CommandOptions, + jsPackageManager: JsPackageManager, + private projectTypeService: ProjectTypeService = new ProjectTypeService(jsPackageManager) + ) {} + + /** Execute project type detection */ + async execute(): Promise<{ projectType: ProjectType; language: SupportedLanguage }> { + let projectType: ProjectType; + const projectTypeProvided = this.options.type; + + // Use provided type or auto-detect + if (projectTypeProvided) { + projectType = await this.projectTypeService.validateProvidedType(projectTypeProvided); + logger.step(`Installing Storybook for user specified project type: ${projectTypeProvided}`); + } else { + const detected = await this.projectTypeService.autoDetectProjectType(this.options); + projectType = detected; + if (detected === ProjectType.REACT_NATIVE && !this.options.yes) { + projectType = await this.promptReactNativeVariant(); + } + logger.debug(`Project type detected: ${projectType}`); + } + + // Check for existing installation + await this.checkExistingInstallation(projectType); + + const language = this.options.language || (await this.projectTypeService.detectLanguage()); + + return { projectType, language }; + } + + /** Prompt user to select React Native variant */ + private async promptReactNativeVariant(): Promise { + const manualType = await prompt.select({ + message: "We've detected a React Native project. Install:", + options: [ + { + label: `${picocolors.bold('React Native')}: Storybook on your device/simulator`, + value: ProjectType.REACT_NATIVE, + }, + { + label: `${picocolors.bold('React Native Web')}: Storybook on web for docs, test, and sharing`, + value: ProjectType.REACT_NATIVE_WEB, + }, + { + label: `${picocolors.bold('Both')}: Add both native and web Storybooks`, + value: ProjectType.REACT_NATIVE_AND_RNW, + }, + ], + }); + return manualType as ProjectType; + } + + /** Check if Storybook is already installed and handle force option */ + private async checkExistingInstallation(projectType: ProjectType): Promise { + const storybookInstantiated = this.projectTypeService.isStorybookInstantiated(); + const options = this.options; + if ( + options.force !== true && + options.yes !== true && + storybookInstantiated && + projectType !== ProjectType.ANGULAR + ) { + const force = await prompt.confirm({ + message: dedent`We found a .storybook config directory in your project. +We assume that Storybook is already instantiated for your project. Do you still want to continue and force the initialization?`, + }); + if (force || options.yes) { + options.force = true; + } else { + process.exit(0); + } + } + } +} + +export const executeProjectDetection = ( + packageManager: JsPackageManager, + options: CommandOptions +) => { + return new ProjectDetectionCommand(options, packageManager).execute(); +}; diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts new file mode 100644 index 000000000000..b02fd343841e --- /dev/null +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { 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 } from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; + +import type { CommandOptions } from '../generators/types'; +import { UserPreferencesCommand } from './UserPreferencesCommand'; + +vi.mock('storybook/internal/cli', async () => { + const actual = await vi.importActual('storybook/internal/cli'); + return { + ...actual, + AddonVitestService: vi.fn().mockImplementation(() => ({ + validateCompatibility: vi.fn(), + })), + globalSettings: vi.fn(), + }; +}); +vi.mock('storybook/internal/common', { spy: true }); +vi.mock('storybook/internal/node-logger', { spy: true }); + +interface CommandWithPrivates { + telemetryService: { + trackNewUserCheck: ReturnType; + trackInstallType: ReturnType; + }; + featureService: { validateTestFeatureCompatibility: ReturnType }; +} + +describe('UserPreferencesCommand', () => { + let command: UserPreferencesCommand; + let mockPackageManager: JsPackageManager; + + beforeEach(() => { + // Provide required CommandOptions to avoid undefined access + const commandOptions: CommandOptions = { + packageManager: PackageManagerName.NPM, + disableTelemetry: true, + }; + + command = new UserPreferencesCommand(commandOptions); + mockPackageManager = {} as Partial as JsPackageManager; + + // Mock globalSettings + const mockSettings = { + value: { init: {} }, + save: vi.fn().mockResolvedValue(undefined), + filePath: 'test-config.json', + }; + vi.mocked(globalSettings).mockResolvedValue( + mockSettings as unknown as Awaited> + ); + + // Create mock services + const mockTelemetryService = { + trackNewUserCheck: vi.fn(), + trackInstallType: vi.fn(), + }; + + const mockFeatureService = { + validateTestFeatureCompatibility: 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(() => {}); + vi.mocked(logger.info).mockImplementation(() => {}); + vi.mocked(logger.warn).mockImplementation(() => {}); + vi.mocked(logger.log).mockImplementation(() => {}); + vi.mocked(isCI).mockReturnValue(false); + + // Default feature validation (compatible) + const featureService = (command as unknown as CommandWithPrivates).featureService; + vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ + compatible: true, + }); + + vi.clearAllMocks(); + }); + + describe('execute', () => { + it('should return recommended config for new users in non-interactive mode', async () => { + const result = await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + projectType: ProjectType.REACT, + }); + + expect(result.newUser).toBe(true); + expect(result.selectedFeatures).toContain('docs'); + expect(result.selectedFeatures).toContain('test'); + expect(result.selectedFeatures).toContain('onboarding'); + }); + + it('should prompt for new user in interactive mode', async () => { + // Mock TTY + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + + const result = await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + projectType: ProjectType.REACT, + }); + + expect(prompt.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'New to Storybook?', + }) + ); + expect(result.newUser).toBe(true); + const telemetryService = (command as unknown as CommandWithPrivates).telemetryService; + expect(telemetryService.trackNewUserCheck).toHaveBeenCalledWith(true); + }); + + it('should prompt for install type when not a new user', async () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + + vi.mocked(prompt.select) + .mockResolvedValueOnce(false) // not new user + .mockResolvedValueOnce('light'); // minimal install + + const result = await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + 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 + + const result = await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + projectType: ProjectType.REACT, + }); + + 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 () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); + + vi.mocked(prompt.select).mockResolvedValueOnce(true); // new user + const featureService = (command as unknown as CommandWithPrivates).featureService; + vi.mocked(featureService.validateTestFeatureCompatibility).mockResolvedValue({ + compatible: true, + }); + + await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + projectType: ProjectType.REACT, + }); + + expect(featureService.validateTestFeatureCompatibility).toHaveBeenCalledWith( + mockPackageManager, + null, + 'vite', + process.cwd() + ); + }); + + it('should remove test feature if user chooses to continue without it', async () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: 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 + + const result = await command.execute(mockPackageManager, { + framework: null, + builder: 'vite' as SupportedBuilder, + projectType: ProjectType.REACT, + }); + + expect(result.selectedFeatures.has(Feature.TEST)).toBe(false); + expect(result.selectedFeatures.has(Feature.DOCS)).toBe(true); + expect(result.selectedFeatures.has(Feature.ONBOARDING)).toBe(true); + }); + }); +}); diff --git a/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts new file mode 100644 index 000000000000..d718317e9335 --- /dev/null +++ b/code/lib/create-storybook/src/commands/UserPreferencesCommand.ts @@ -0,0 +1,235 @@ +import type { ProjectType } from 'storybook/internal/cli'; +import { globalSettings } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { isCI } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import type { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; + +import picocolors from 'picocolors'; +import { dedent } from 'ts-dedent'; + +import type { CommandOptions } from '../generators/types'; +import { FeatureCompatibilityService } from '../services/FeatureCompatibilityService'; +import { TelemetryService } from '../services/TelemetryService'; + +export type InstallType = 'recommended' | 'light'; + +export interface UserPreferencesResult { + /** Whether the user is a new user */ + newUser: boolean; + /** + * The features that the user has selected explicitly or implicitly and which can actually be + * installed based on the project type or other constraints. + */ + selectedFeatures: Set; +} + +export interface UserPreferencesOptions { + skipPrompt?: boolean; + framework: SupportedFramework | null; + builder: SupportedBuilder; + projectType: ProjectType; +} + +/** + * Command for gathering user preferences during Storybook initialization + * + * Responsibilities: + * + * - Display version information + * - Prompt for new user / onboarding preference + * - Prompt for install type (recommended vs minimal) + * - Run feature compatibility checks + * - Track telemetry events + */ +export class UserPreferencesCommand { + private telemetryService: TelemetryService; + + constructor( + private commandOptions: CommandOptions, + private featureService = new FeatureCompatibilityService() + ) { + this.telemetryService = new TelemetryService(commandOptions.disableTelemetry); + } + + /** Execute user preferences gathering */ + async execute( + packageManager: JsPackageManager, + options: UserPreferencesOptions + ): Promise { + // Display version information + const isInteractive = process.stdout.isTTY && !isCI(); + const skipPrompt = !isInteractive || !!this.commandOptions.yes; + + const isTestFeatureAvailable = await this.isTestFeatureAvailable( + packageManager, + options.framework, + options.builder + ); + + // Get new user preference + const newUser = await this.promptNewUser(skipPrompt); + + const commandOptionsFeatures = this.handleCommandOptionsFeatureFlag(); + + if (commandOptionsFeatures) { + return { + newUser, + selectedFeatures: commandOptionsFeatures, + }; + } + + // Get install type + const installType: InstallType = + !newUser && !this.commandOptions.features + ? await this.promptInstallType(skipPrompt, isTestFeatureAvailable) + : 'recommended'; + + const selectedFeatures = this.determineFeatures( + installType, + newUser, + isTestFeatureAvailable, + options.projectType + ); + + return { newUser, selectedFeatures }; + } + + private handleCommandOptionsFeatureFlag(): Set | null { + if (this.commandOptions.features && this.commandOptions.features?.length > 0) { + logger.warn(dedent` + Skipping feature validation as these features were explicitly selected: + ${Array.from(this.commandOptions.features).join(', ')} + `); + return new Set(this.commandOptions.features); + } else if (this.commandOptions.features?.length === 0) { + logger.warn(dedent` + All features have been disabled via --no-features flag. + `); + return new Set(); + } + + return null; + } + + /** Prompt user about onboarding */ + private async promptNewUser(skipPrompt: boolean): Promise { + const settings = await globalSettings(); + const { skipOnboarding } = settings.value.init || {}; + let isNewUser = skipOnboarding !== undefined ? !skipOnboarding : true; + + if (skipPrompt || skipOnboarding) { + settings.value.init ||= {}; + settings.value.init.skipOnboarding = !!skipOnboarding; + } else { + isNewUser = await prompt.select({ + message: 'New to Storybook?', + options: [ + { + label: `${picocolors.bold('Yes:')} Help me with onboarding`, + value: true, + }, + { + label: `${picocolors.bold('No:')} Skip onboarding & don't ask again`, + value: false, + }, + ], + }); + + settings.value.init ||= {}; + settings.value.init.skipOnboarding = !isNewUser; + + if (typeof isNewUser !== 'undefined') { + await this.telemetryService.trackNewUserCheck(isNewUser); + } + } + + try { + await settings.save(); + } catch (err) { + logger.warn(`Failed to save user settings: ${err}`); + } + + return isNewUser; + } + + /** Prompt user for install type */ + private async promptInstallType( + skipPrompt: boolean, + isTestFeatureAvailable: boolean + ): Promise { + let installType: InstallType = 'recommended'; + + const recommendedLabel = isTestFeatureAvailable + ? `Recommended: Includes component development, docs and testing features.` + : `Recommended: Includes component development and docs`; + + if (!skipPrompt) { + installType = await prompt.select({ + message: 'What configuration should we install?', + options: [ + { + label: recommendedLabel, + value: 'recommended', + }, + { + label: `Minimal: Just the essentials for component development.`, + value: 'light', + }, + ], + }); + } + + await this.telemetryService.trackInstallType(installType); + + return installType; + } + + /** Determine features based on install type and user status */ + private determineFeatures( + installType: InstallType, + newUser: boolean, + isTestFeatureAvailable: boolean, + projectType: ProjectType + ): Set { + const features = new Set(); + + if (installType === 'recommended') { + features.add(Feature.DOCS); + features.add(Feature.A11Y); + + if (isTestFeatureAvailable) { + features.add(Feature.TEST); + } + if (newUser && FeatureCompatibilityService.supportsOnboarding(projectType)) { + features.add(Feature.ONBOARDING); + } + } + + return features; + } + + /** Validate test feature compatibility and prompt user if issues found */ + private async isTestFeatureAvailable( + packageManager: JsPackageManager, + framework: SupportedFramework | null, + builder: SupportedBuilder + ): Promise { + const result = await this.featureService.validateTestFeatureCompatibility( + packageManager, + framework, + builder, + process.cwd() + ); + + return result.compatible; + } +} + +export const executeUserPreferences = ( + packageManager: JsPackageManager, + { options, ...restOptions }: UserPreferencesOptions & { options: CommandOptions } +) => { + return new UserPreferencesCommand(options).execute(packageManager, restOptions); +}; diff --git a/code/lib/create-storybook/src/commands/index.ts b/code/lib/create-storybook/src/commands/index.ts new file mode 100644 index 000000000000..df57f70b73d7 --- /dev/null +++ b/code/lib/create-storybook/src/commands/index.ts @@ -0,0 +1,28 @@ +/** + * Command classes for Storybook initialization workflow + * + * Each command represents a discrete step in the init process with clear responsibilities + */ + +export { executePreflightCheck } from './PreflightCheckCommand'; +export type { PreflightCheckResult } from './PreflightCheckCommand'; + +export { executeProjectDetection } from './ProjectDetectionCommand'; + +export { executeFrameworkDetection } from './FrameworkDetectionCommand'; +export type { FrameworkDetectionResult } from './FrameworkDetectionCommand'; + +export { executeUserPreferences } from './UserPreferencesCommand'; +export type { + InstallType, + UserPreferencesOptions, + UserPreferencesResult, +} from './UserPreferencesCommand'; + +export { executeGeneratorExecution } from './GeneratorExecutionCommand'; + +export { executeAddonConfiguration } from './AddonConfigurationCommand'; + +export { executeDependencyInstallation } from './DependencyInstallationCommand'; + +export { executeFinalization } from './FinalizationCommand'; diff --git a/code/lib/create-storybook/src/dependency-collector.test.ts b/code/lib/create-storybook/src/dependency-collector.test.ts new file mode 100644 index 000000000000..424416560a88 --- /dev/null +++ b/code/lib/create-storybook/src/dependency-collector.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { DependencyCollector } from './dependency-collector'; + +describe('DependencyCollector', () => { + let collector: DependencyCollector; + + beforeEach(() => { + collector = new DependencyCollector(); + }); + + describe('addDependencies', () => { + it('should add dependencies', () => { + collector.addDependencies(['react@18.0.0', 'react-dom@18.0.0']); + + const { dependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('react@18.0.0'); + expect(dependencies).toContain('react-dom@18.0.0'); + }); + + it('should add dependencies without version', () => { + collector.addDependencies(['react', 'react-dom']); + + const { dependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('react'); + expect(dependencies).toContain('react-dom'); + }); + }); + + describe('addDevDependencies', () => { + it('should add dev dependencies', () => { + collector.addDevDependencies(['typescript@5.0.0', 'vitest@1.0.0']); + + const { devDependencies } = collector.getAllPackages(); + + expect(devDependencies).toContain('typescript@5.0.0'); + expect(devDependencies).toContain('vitest@1.0.0'); + }); + }); + + describe('getAllPackages', () => { + it('should return all packages by type', () => { + collector.addDependencies(['react@18.0.0']); + collector.addDevDependencies(['typescript@5.0.0']); + + const result = collector.getAllPackages(); + + expect(result.dependencies).toEqual(['react@18.0.0']); + expect(result.devDependencies).toEqual(['typescript@5.0.0']); + }); + + it('should return empty arrays when no packages', () => { + const result = collector.getAllPackages(); + + expect(result.dependencies).toEqual([]); + expect(result.devDependencies).toEqual([]); + }); + }); + + describe('hasPackages', () => { + it('should return false when no packages added', () => { + expect(collector.hasPackages()).toBe(false); + }); + + it('should return true when dependencies added', () => { + collector.addDependencies(['react']); + expect(collector.hasPackages()).toBe(true); + }); + + it('should return true when devDependencies added', () => { + collector.addDevDependencies(['typescript']); + expect(collector.hasPackages()).toBe(true); + }); + }); + + describe('getVersionConflicts', () => { + it('should return empty array when no conflicts', () => { + collector.addDependencies(['react@18.0.0', 'vue@3.0.0']); + + const conflicts = collector.getVersionConflicts(); + + expect(conflicts).toEqual([]); + }); + + it('should detect no conflicts when package is updated', () => { + // When adding same package twice, it updates (doesn't create conflict) + collector.addDependencies(['react@18.0.0']); + collector.addDependencies(['react@17.0.0']); + + const conflicts = collector.getVersionConflicts(); + + // No conflict because the second add updated the first + expect(conflicts).toEqual([]); + + // Version should be updated to latest + const { dependencies } = collector.getAllPackages(); + expect(dependencies).toContain('react@17.0.0'); + }); + + it('should not report conflict for same version', () => { + collector.addDevDependencies(['typescript@5.0.0']); + collector.addDevDependencies(['typescript@5.0.0']); + + const conflicts = collector.getVersionConflicts(); + + expect(conflicts).toEqual([]); + }); + + it('should handle scoped packages without conflicts', () => { + // When adding same package twice, it updates (doesn't create conflict) + collector.addDependencies(['@storybook/react@8.0.0']); + collector.addDependencies(['@storybook/react@7.0.0']); + + const conflicts = collector.getVersionConflicts(); + + // No conflict - version was updated + expect(conflicts).toEqual([]); + + const { dependencies } = collector.getAllPackages(); + expect(dependencies).toContain('@storybook/react@7.0.0'); + }); + }); + + describe('merge', () => { + it('should merge dependencies from another collector', () => { + collector.addDependencies(['react@18.0.0']); + collector.addDevDependencies(['typescript@5.0.0']); + + const other = new DependencyCollector(); + other.addDependencies(['vue@3.0.0']); + other.addDevDependencies(['vitest@1.0.0']); + + collector.merge(other); + + const { dependencies, devDependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('react@18.0.0'); + expect(dependencies).toContain('vue@3.0.0'); + expect(devDependencies).toContain('typescript@5.0.0'); + expect(devDependencies).toContain('vitest@1.0.0'); + }); + + it('should handle empty collector merge', () => { + collector.addDependencies(['react@18.0.0']); + + const other = new DependencyCollector(); + + collector.merge(other); + + const { dependencies } = collector.getAllPackages(); + expect(dependencies).toEqual(['react@18.0.0']); + }); + }); + + describe('validate', () => { + it('should return valid for valid packages', () => { + collector.addDependencies(['react@18.0.0']); + collector.addDevDependencies(['typescript@5.0.0']); + + const result = collector.validate(); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should detect empty package names', () => { + const typeMap = (collector as any).packages.get('dependencies'); + typeMap.set('', '1.0.0'); + + const result = collector.validate(); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('Invalid package name'))).toBe(true); + }); + + it('should detect empty versions', () => { + const typeMap = (collector as any).packages.get('dependencies'); + typeMap.set('react', ''); + + const result = collector.validate(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Package react in dependencies has empty version'); + }); + + it('should return multiple errors', () => { + const typeMap = (collector as any).packages.get('dependencies'); + typeMap.set('', '1.0.0'); + typeMap.set('react', ''); + + const result = collector.validate(); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + }); + + describe('getPackageCount', () => { + it('should return 0 for empty collector', () => { + expect(collector.getPackageCount()).toBe(0); + }); + + it('should return total count', () => { + collector.addDependencies(['react', 'vue']); + collector.addDevDependencies(['typescript', 'vitest']); + + expect(collector.getPackageCount()).toBe(4); + }); + + it('should return count for specific type', () => { + collector.addDependencies(['react', 'vue']); + collector.addDevDependencies(['typescript']); + + expect(collector.getPackageCount('dependencies')).toBe(2); + expect(collector.getPackageCount('devDependencies')).toBe(1); + }); + }); + + describe('version handling', () => { + it('should update version when adding same package with different version', () => { + collector.addDependencies(['react@18.0.0']); + collector.addDependencies(['react@18.1.0']); + + const { dependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('react@18.1.0'); + expect(dependencies).not.toContain('react@18.0.0'); + expect(dependencies).toHaveLength(1); + }); + + it('should keep version when adding same package without version', () => { + collector.addDependencies(['react@18.0.0']); + collector.addDependencies(['react']); + + const { dependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('react@18.0.0'); + expect(dependencies).toHaveLength(1); + }); + + it('should handle scoped packages', () => { + collector.addDependencies(['@storybook/react@8.0.0']); + + const { dependencies } = collector.getAllPackages(); + + expect(dependencies).toContain('@storybook/react@8.0.0'); + }); + }); +}); diff --git a/code/lib/create-storybook/src/dependency-collector.ts b/code/lib/create-storybook/src/dependency-collector.ts new file mode 100644 index 000000000000..17266e86b117 --- /dev/null +++ b/code/lib/create-storybook/src/dependency-collector.ts @@ -0,0 +1,185 @@ +export type DependencyType = 'dependencies' | 'devDependencies'; + +interface PackageInfo { + name: string; + version?: string; +} + +export interface VersionConflict { + packageName: string; + existingVersion: string; + newVersion: string; + type: DependencyType; +} + +/** + * Collects all dependencies that need to be installed during the init process. This allows us to + * gather all packages first and then install them in a single operation. + */ +export class DependencyCollector { + private packages: Map> = new Map([ + ['dependencies', new Map()], + ['devDependencies', new Map()], + ]); + + /** Add development dependencies */ + addDevDependencies(packageNames: string[]): void { + this.add('devDependencies', packageNames); + } + + /** Add regular dependencies */ + addDependencies(packageNames: string[]): void { + this.add('dependencies', packageNames); + } + + /** Get all packages across all types */ + getAllPackages(): { dependencies: string[]; devDependencies: string[] } { + return { + dependencies: this.getDependencies(), + devDependencies: this.getDevDependencies(), + }; + } + + /** Check if collector has any packages */ + hasPackages(): boolean { + return ( + this.packages.get('dependencies')!.size > 0 || this.packages.get('devDependencies')!.size > 0 + ); + } + + /** Get all version conflicts across all dependency types */ + getVersionConflicts(): VersionConflict[] { + const conflicts: VersionConflict[] = []; + + for (const [type, typeMap] of this.packages.entries()) { + const packageNames = new Map(); + + // Group packages by name to find conflicts + typeMap.forEach((version, name) => { + const versions = packageNames.get(name) || []; + versions.push(version); + packageNames.set(name, versions); + }); + + // Find packages with multiple versions + packageNames.forEach((versions, name) => { + if (versions.length > 1 && new Set(versions).size > 1) { + conflicts.push({ + packageName: name, + existingVersion: versions[0], + newVersion: versions[versions.length - 1], + type, + }); + } + }); + } + + return conflicts; + } + + /** Merge dependencies from another collector */ + merge(other: DependencyCollector): void { + const { dependencies, devDependencies } = other.getAllPackages(); + this.addDependencies(dependencies); + this.addDevDependencies(devDependencies); + } + + /** Validate that all packages have valid version specifiers */ + validate(): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + for (const [type, typeMap] of this.packages.entries()) { + typeMap.forEach((version, name) => { + if (!name || name.trim() === '') { + errors.push(`Invalid package name in ${type}: empty or whitespace`); + } + + if (version === '') { + errors.push(`Package ${name} in ${type} has empty version`); + } + }); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** Get count of packages by type */ + getPackageCount(type?: DependencyType): number { + if (type) { + return this.packages.get(type)!.size; + } + return this.packages.get('dependencies')!.size + this.packages.get('devDependencies')!.size; + } + + /** + * Add packages to the collector + * + * @param type - The dependency type (dependencies or devDependencies) + * @param packageNames - Array of package names, optionally with version specifiers (e.g., + * 'react@18.0.0') + */ + private add(type: DependencyType, packageNames: string[]): void { + const typeMap = this.packages.get(type)!; + + for (const pkg of packageNames) { + const { name, version } = this.parsePackage(pkg); + + // If package already exists, only update if new version is specified + if (typeMap.has(name)) { + if (version) { + typeMap.set(name, version); + } + } else { + typeMap.set(name, version || 'latest'); + } + } + } + + /** Get all packages with their versions for a specific type */ + private getPackages(type: DependencyType): string[] { + const typeMap = this.packages.get(type)!; + return Array.from(typeMap.entries()).map(([name, version]) => + version === 'latest' ? name : `${name}@${version}` + ); + } + + /** Get all development dependencies */ + private getDevDependencies(): string[] { + return this.getPackages('devDependencies'); + } + + /** Get all regular dependencies */ + private getDependencies(): string[] { + return this.getPackages('dependencies'); + } + + /** + * Parse a package string into name and version + * + * @param pkg - Package string (e.g., 'react@18.0.0' or 'react') + */ + private parsePackage(pkg: string): PackageInfo { + // Handle scoped packages like @storybook/react@1.0.0 + const scopedMatch = pkg.match(/^(@[^@]+\/[^@]+)(?:@(.+))?$/); + if (scopedMatch) { + return { + name: scopedMatch[1], + version: scopedMatch[2], + }; + } + + // Handle regular packages like react@18.0.0 + const regularMatch = pkg.match(/^([^@]+)(?:@(.+))?$/); + if (regularMatch) { + return { + name: regularMatch[1], + version: regularMatch[2], + }; + } + + return { name: pkg }; + } +} diff --git a/code/lib/create-storybook/src/generators/ANGULAR/index.ts b/code/lib/create-storybook/src/generators/ANGULAR/index.ts index 14fcc75b5899..784058453232 100644 --- a/code/lib/create-storybook/src/generators/ANGULAR/index.ts +++ b/code/lib/create-storybook/src/generators/ANGULAR/index.ts @@ -1,122 +1,121 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - AngularJSON, - CoreBuilder, - compoDocPreviewPrefix, - copyTemplate, - promptForCompoDocs, -} from 'storybook/internal/cli'; -import { commandLog } from 'storybook/internal/common'; - -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; - -const generator: Generator<{ projectName: string }> = async ( - packageManager, - npmOptions, - options, - commandOptions -) => { - const angularJSON = new AngularJSON(); - - if ( - !angularJSON.projects || - (angularJSON.projects && Object.keys(angularJSON.projects).length === 0) - ) { - throw new Error( - 'Storybook was not able to find any projects in your angular.json file. Are you sure this is an Angular CLI project?' - ); - } - - if (angularJSON.projectsWithoutStorybook.length === 0) { - throw new Error( - 'Every project in your workspace is already set up with Storybook. There is nothing to do!' - ); - } - - const angularProjectName = await angularJSON.getProjectName(); - commandLog(`Adding Storybook support to your "${angularProjectName}" project`); - - const angularProject = angularJSON.getProjectSettingsByName(angularProjectName); - - if (!angularProject) { - throw new Error( - `Somehow we were not able to retrieve the "${angularProjectName}" project in your angular.json file. This is likely a bug in Storybook, please file an issue.` +import { AngularJSON, ProjectType, copyTemplate } from 'storybook/internal/cli'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; + +import { dedent } from 'ts-dedent'; + +import { defineGeneratorModule } from '../modules/GeneratorModule'; + +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.ANGULAR, + renderer: SupportedRenderer.ANGULAR, + framework: SupportedFramework.ANGULAR, + builderOverride: SupportedBuilder.WEBPACK5, + }, + configure: async (packageManager, context) => { + const angularJSON = new AngularJSON(); + + if ( + !angularJSON.projects || + (angularJSON.projects && Object.keys(angularJSON.projects).length === 0) + ) { + throw new Error( + 'Storybook was not able to find any projects in your angular.json file. Are you sure this is an Angular CLI project?' + ); + } + + if (angularJSON.projectsWithoutStorybook.length === 0) { + throw new Error( + 'Every project in your workspace is already set up with Storybook. There is nothing to do!' + ); + } + + const angularProjectName = await angularJSON.getProjectName(); + logger.log(`Adding Storybook support to your "${angularProjectName}" project`); + + const angularProject = angularJSON.getProjectSettingsByName(angularProjectName); + + if (!angularProject) { + throw new Error( + `Somehow we were not able to retrieve the "${angularProjectName}" project in your angular.json file. This is likely a bug in Storybook, please file an issue.` + ); + } + + const { root, projectType } = angularProject; + const { projects } = angularJSON; + const useCompodoc = context.yes ? true : await promptForCompoDocs(); + const storybookFolder = root ? `${root}/.storybook` : '.storybook'; + + angularJSON.addStorybookEntries({ + angularProjectName, + storybookFolder, + useCompodoc, + root, + }); + angularJSON.write(); + + const angularVersion = packageManager.getDependencyVersion('@angular/core'); + + // Handle script addition for single-project workspaces + if (Object.keys(projects).length === 1) { + packageManager.addScripts({ + storybook: `ng run ${angularProjectName}:storybook`, + 'build-storybook': `ng run ${angularProjectName}:build-storybook`, + }); + } + + // Copy Angular templates + let projectTypeValue = projectType || 'application'; + if (projectTypeValue !== 'application' && projectTypeValue !== 'library') { + projectTypeValue = 'application'; + } + + const templateDir = join( + dirname(fileURLToPath(import.meta.resolve('create-storybook/package.json'))), + 'templates', + 'angular', + projectTypeValue ); - } - - const { root, projectType } = angularProject; - const { projects } = angularJSON; - const useCompodoc = commandOptions?.yes ? true : await promptForCompoDocs(); - const storybookFolder = root ? `${root}/.storybook` : '.storybook'; - - angularJSON.addStorybookEntries({ - angularProjectName, - storybookFolder, - useCompodoc, - root, - }); - angularJSON.write(); - const angularVersion = packageManager.getDependencyVersion('@angular/core'); + if (templateDir) { + copyTemplate(templateDir, root || undefined); + } - await baseGenerator( - packageManager, - npmOptions, - { - ...options, - builder: CoreBuilder.Webpack5, - ...(useCompodoc && { - frameworkPreviewParts: { - prefix: compoDocPreviewPrefix, - }, - }), - }, - 'angular', - { + return { extraPackages: [ angularVersion ? `@angular-devkit/build-angular@${angularVersion}` : '@angular-devkit/build-angular', ...(useCompodoc ? ['@compodoc/compodoc', '@storybook/addon-docs'] : []), ], - addScripts: false, + addScripts: false, // Handled above based on project count componentsDestinationPath: root ? `${root}/src/stories` : undefined, storybookConfigFolder: storybookFolder, - webpackCompiler: () => undefined, - }, - 'angular' - ); + storybookCommand: `ng run ${angularProjectName}:storybook`, + ...(useCompodoc && { + frameworkPreviewParts: { + prefix: dedent` + import { setCompodocJson } from "@storybook/addon-docs/angular"; + import docJson from "../documentation.json"; + setCompodocJson(docJson); + `.trimStart(), + }, + }), + }; + }, +}); - if (Object.keys(projects).length === 1) { - packageManager.addScripts({ - storybook: `ng run ${angularProjectName}:storybook`, - 'build-storybook': `ng run ${angularProjectName}:build-storybook`, - }); - } - - let projectTypeValue = projectType || 'application'; - if (projectTypeValue !== 'application' && projectTypeValue !== 'library') { - projectTypeValue = 'application'; - } - - const templateDir = join( - dirname(fileURLToPath(import.meta.resolve('create-storybook/package.json'))), - 'templates', - 'angular', - projectTypeValue +function promptForCompoDocs(): Promise { + logger.log( + `Compodoc is a great tool to generate documentation for your Angular projects. Storybook can use the documentation generated by Compodoc to extract argument definitions and JSDOC comments to display them in the Storybook UI. We highly recommend using Compodoc for your Angular projects to get the best experience out of Storybook.` ); - if (templateDir) { - copyTemplate(templateDir, root || undefined); - } - - return { - projectName: angularProjectName, - configDir: storybookFolder, - }; -}; - -export default generator; + return prompt.confirm({ + message: 'Do you want to use Compodoc for documentation?', + initialValue: true, + }); +} diff --git a/code/lib/create-storybook/src/generators/EMBER/index.ts b/code/lib/create-storybook/src/generators/EMBER/index.ts index 40e276743f1a..c0e13a00c692 100644 --- a/code/lib/create-storybook/src/generators/EMBER/index.ts +++ b/code/lib/create-storybook/src/generators/EMBER/index.ts @@ -1,17 +1,18 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Webpack5 }, - 'ember', - { staticDir: 'dist' }, - 'ember' - ); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.EMBER, + renderer: SupportedRenderer.EMBER, + framework: SupportedFramework.EMBER, + builderOverride: SupportedBuilder.WEBPACK5, + }, + configure: async () => { + return { + staticDir: 'dist', + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/GeneratorRegistry.test.ts b/code/lib/create-storybook/src/generators/GeneratorRegistry.test.ts new file mode 100644 index 000000000000..1a8ff2093f2c --- /dev/null +++ b/code/lib/create-storybook/src/generators/GeneratorRegistry.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectType } from 'storybook/internal/cli'; +import { logger } from 'storybook/internal/node-logger'; +import { SupportedRenderer } from 'storybook/internal/types'; + +import { GeneratorRegistry } from './GeneratorRegistry'; +import type { GeneratorModule } from './types'; + +vi.mock('storybook/internal/node-logger', { spy: true }); + +describe('GeneratorRegistry', () => { + let registry: GeneratorRegistry; + let mockGeneratorModule: GeneratorModule; + + beforeEach(() => { + registry = new GeneratorRegistry(); + mockGeneratorModule = { + metadata: { + projectType: ProjectType.REACT, + renderer: SupportedRenderer.REACT, + }, + configure: vi.fn(), + }; + vi.clearAllMocks(); + }); + + describe('register', () => { + it('should register a generator for a project type', () => { + registry.register(mockGeneratorModule); + + expect(registry.get(ProjectType.REACT)).toBe(mockGeneratorModule); + }); + + it('should warn when overwriting an existing generator', () => { + const newGeneratorModule: GeneratorModule = { + metadata: { + projectType: ProjectType.REACT, + renderer: SupportedRenderer.REACT, + }, + configure: vi.fn(), + }; + + // Mock logger.warn to prevent throwing in vitest-setup + vi.mocked(logger.warn).mockImplementation(() => {}); + + registry.register(mockGeneratorModule); + registry.register(newGeneratorModule); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('already registered. Overwriting') + ); + expect(registry.get(ProjectType.REACT)).toBe(newGeneratorModule); + }); + }); + + describe('get', () => { + it('should return generator for registered project type', () => { + registry.register(mockGeneratorModule); + + expect(registry.get(ProjectType.REACT)).toBe(mockGeneratorModule); + }); + + it('should return undefined for unregistered project type', () => { + expect(registry.get(ProjectType.VUE3)).toBeUndefined(); + }); + }); +}); diff --git a/code/lib/create-storybook/src/generators/GeneratorRegistry.ts b/code/lib/create-storybook/src/generators/GeneratorRegistry.ts new file mode 100644 index 000000000000..a800d3f5f2df --- /dev/null +++ b/code/lib/create-storybook/src/generators/GeneratorRegistry.ts @@ -0,0 +1,34 @@ +import type { ProjectType } from 'storybook/internal/cli'; +import { logger } from 'storybook/internal/node-logger'; + +import type { GeneratorModule } from './types'; + +/** + * Registry for managing Storybook project generators + * + * All new generators should use the GeneratorModule format with metadata + configure. Legacy + * generators (not yet refactored) can still be registered with LegacyGeneratorMetadata. + */ +export class GeneratorRegistry { + private generators: Map = new Map(); + + /** Register a generator for a specific project type */ + register(generator: GeneratorModule): void { + const { metadata } = generator; + if (this.generators.has(metadata.projectType)) { + logger.warn( + `Generator for project type ${metadata.projectType} is already registered. Overwriting.` + ); + } + + this.generators.set(metadata.projectType, generator); + } + + /** Get a generator for a specific project type */ + get(projectType: ProjectType): GeneratorModule | undefined { + return this.generators.get(projectType); + } +} + +// Create and export a singleton instance +export const generatorRegistry = new GeneratorRegistry(); diff --git a/code/lib/create-storybook/src/generators/HTML/index.ts b/code/lib/create-storybook/src/generators/HTML/index.ts index 884f83bb0d72..efdaf32c50ba 100755 --- a/code/lib/create-storybook/src/generators/HTML/index.ts +++ b/code/lib/create-storybook/src/generators/HTML/index.ts @@ -1,12 +1,16 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'html', { - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.HTML, + renderer: SupportedRenderer.HTML, + }, + configure: async () => { + return { + webpackCompiler: ({ builder }) => (builder === SupportedBuilder.WEBPACK5 ? 'swc' : undefined), + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/NEXTJS/index.ts b/code/lib/create-storybook/src/generators/NEXTJS/index.ts index 629417ef2105..00c5819abc59 100644 --- a/code/lib/create-storybook/src/generators/NEXTJS/index.ts +++ b/code/lib/create-storybook/src/generators/NEXTJS/index.ts @@ -1,26 +1,102 @@ import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { findFilesUp } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { dedent } from 'ts-dedent'; -const generator: Generator = async (packageManager, npmOptions, options) => { - let staticDir; +import { defineGeneratorModule } from '../modules/GeneratorModule'; - if (existsSync(join(process.cwd(), 'public'))) { - staticDir = 'public'; - } +const NEXT_CONFIG_FILES = [ + 'next.config.mjs', + 'next.config.js', + 'next.config.ts', + 'next.config.mts', +]; - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Webpack5 }, - 'react', - { staticDir, webpackCompiler: () => undefined }, - 'nextjs' - ); -}; +const BABEL_CONFIG_FILES = [ + '.babelrc', + '.babelrc.json', + '.babelrc.js', + '.babelrc.mjs', + '.babelrc.cjs', + 'babel.config.js', + 'babel.config.json', + 'babel.config.mjs', + 'babel.config.cjs', +]; -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.NEXTJS, + renderer: SupportedRenderer.REACT, + framework: (builder: SupportedBuilder) => { + return builder === SupportedBuilder.VITE + ? SupportedFramework.NEXTJS_VITE + : SupportedFramework.NEXTJS; + }, + builderOverride: async () => { + const nextConfigFile = findFilesUp(NEXT_CONFIG_FILES, process.cwd())[0]; + if (!nextConfigFile) { + return SupportedBuilder.VITE; + } + + const nextConfig = await readFile(nextConfigFile, 'utf-8'); + const hasCustomWebpackConfig = nextConfig.includes('webpack'); + const babelConfigFile = findFilesUp(BABEL_CONFIG_FILES, process.cwd())[0]; + + if (!hasCustomWebpackConfig && !babelConfigFile) { + return SupportedBuilder.VITE; + } else { + // prompt to ask the user which framework to select + // based on the framework, either webpack5 or vite will be selected + // We want to tell users in this special case, that due to their custom webpack config or babel config + // they should select wisely, because the nextjs-vite framework may not be compatible with their setup + const reason = + hasCustomWebpackConfig && babelConfigFile + ? 'custom webpack config and babel config' + : hasCustomWebpackConfig + ? 'custom webpack config' + : 'custom babel config'; + logger.info(dedent` + Storybook has two Next.js builder options: Webpack 5 and Vite. + + We generally recommend nextjs-vite, which is much faster, more modern, and supports our latest testing features. + + However, your project has a ${reason}, which is not supported by nextjs-vite, so please be aware of that if you choose that option. + `); + + return prompt.select({ + message: 'Which framework would you like to use?', + options: [ + { label: '@storybook/nextjs-vite', value: SupportedBuilder.VITE }, + { label: '@storybook/nextjs (Webpack)', value: SupportedBuilder.WEBPACK5 }, + ], + }); + } + }, + }, + configure: async (packageManager, context) => { + let staticDir; + + if (existsSync(join(process.cwd(), 'public'))) { + staticDir = 'public'; + } + + const extraPackages: string[] = []; + + if (context.builder === SupportedBuilder.VITE) { + extraPackages.push('vite'); + // Add any Vite-specific configuration here + } + + return { + staticDir, + extraPackages, + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/NUXT/index.ts b/code/lib/create-storybook/src/generators/NUXT/index.ts index e116896fa6f7..8f91162fee76 100644 --- a/code/lib/create-storybook/src/generators/NUXT/index.ts +++ b/code/lib/create-storybook/src/generators/NUXT/index.ts @@ -1,45 +1,43 @@ -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { ProjectType } from 'storybook/internal/cli'; +import { logger } from 'storybook/internal/node-logger'; +import { + Feature, + SupportedBuilder, + SupportedFramework, + SupportedRenderer, +} from 'storybook/internal/types'; -const generator: Generator = async (packageManager, npmOptions, options) => { - const extraStories = options.features.includes('docs') ? ['../components/**/*.mdx'] : []; +import { defineGeneratorModule } from '../modules/GeneratorModule'; - extraStories.push('../components/**/*.stories.@(js|jsx|ts|tsx|mdx)'); +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.NUXT, + renderer: SupportedRenderer.VUE3, + framework: SupportedFramework.NUXT, + builderOverride: SupportedBuilder.VITE, + }, + configure: async (packageManager, context) => { + const extraStories = context.features.has(Feature.DOCS) ? ['../components/**/*.mdx'] : []; + extraStories.push('../components/**/*.stories.@(js|jsx|ts|tsx|mdx)'); - await baseGenerator( - packageManager, - { - ...npmOptions, - }, - options, - 'vue3', - { - extraPackages: async () => { - return ['@nuxtjs/storybook']; - }, + // Nuxt requires special handling - always install dependencies even with skipInstall + // This is handled here to ensure Nuxt modules work correctly + logger.info( + 'Note: Nuxt requires dependency installation to configure modules. Dependencies will be installed even if --skip-install is specified.' + ); + + // Add nuxtjs/storybook to nuxt.config.js + await packageManager.runPackageCommand({ + args: ['nuxi', 'module', 'add', '@nuxtjs/storybook', '--skipInstall'], + }); + + return { + extraPackages: ['@nuxtjs/storybook'], installFrameworkPackages: false, componentsDestinationPath: './components', extraMain: { stories: extraStories, }, - }, - 'nuxt' - ); - - if (npmOptions.skipInstall === true) { - console.log( - 'The --skip-install flag is not supported for generating Storybook for Nuxt. We will continue to install dependencies.' - ); - await packageManager.installDependencies(); - } - - // Add nuxtjs/storybook to nuxt.config.js - await packageManager.runPackageCommand('nuxi', [ - 'module', - 'add', - '@nuxtjs/storybook', - '--skipInstall', - ]); -}; - -export default generator; + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/PREACT/index.ts b/code/lib/create-storybook/src/generators/PREACT/index.ts index 66b1bb15d2c3..b02cf6aec0bf 100644 --- a/code/lib/create-storybook/src/generators/PREACT/index.ts +++ b/code/lib/create-storybook/src/generators/PREACT/index.ts @@ -1,12 +1,16 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'preact', { - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.PREACT, + renderer: SupportedRenderer.PREACT, + }, + configure: async () => { + return { + webpackCompiler: ({ builder }) => (builder === SupportedBuilder.WEBPACK5 ? 'swc' : undefined), + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/QWIK/index.ts b/code/lib/create-storybook/src/generators/QWIK/index.ts index baa3c86ac94d..2b9ca2a454b8 100644 --- a/code/lib/create-storybook/src/generators/QWIK/index.ts +++ b/code/lib/create-storybook/src/generators/QWIK/index.ts @@ -1,8 +1,15 @@ -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'qwik', {}, 'qwik'); -}; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.QWIK, + renderer: SupportedRenderer.QWIK, + framework: SupportedFramework.QWIK, + }, + configure: async () => { + return {}; + }, +}); diff --git a/code/lib/create-storybook/src/generators/REACT/index.ts b/code/lib/create-storybook/src/generators/REACT/index.ts index 2ba8a0cab3f1..36a030bf0d8a 100644 --- a/code/lib/create-storybook/src/generators/REACT/index.ts +++ b/code/lib/create-storybook/src/generators/REACT/index.ts @@ -1,17 +1,20 @@ -import { CoreBuilder, SupportedLanguage, detectLanguage } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedLanguage, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - // Add prop-types dependency if not using TypeScript - const language = await detectLanguage(packageManager as any); - const extraPackages = language === SupportedLanguage.JAVASCRIPT ? ['prop-types'] : []; +// Export as module +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.REACT, + renderer: SupportedRenderer.REACT, + }, + configure: async (packageManager, { language }) => { + const extraPackages = language === SupportedLanguage.JAVASCRIPT ? ['prop-types'] : []; - await baseGenerator(packageManager, npmOptions, options, 'react', { - extraPackages, - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; + return { + extraPackages, + webpackCompiler: ({ builder }) => (builder === SupportedBuilder.WEBPACK5 ? 'swc' : undefined), + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts index eeef13b4808c..411970ad32b7 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE/index.ts @@ -1,71 +1,99 @@ -import { SupportedLanguage, copyTemplateFiles, getBabelDependencies } from 'storybook/internal/cli'; - -import type { Generator } from '../types'; - -const generator: Generator = async (packageManager, npmOptions, options) => { - const missingReactDom = !packageManager.getDependencyVersion('react-dom'); - - const reactVersion = packageManager.getDependencyVersion('react'); - - const peerDependencies = [ - 'react-native-safe-area-context', - '@react-native-async-storage/async-storage', - '@react-native-community/datetimepicker', - '@react-native-community/slider', - 'react-native-reanimated', - 'react-native-gesture-handler', - '@gorhom/bottom-sheet', - 'react-native-svg', - ].filter((dep) => !packageManager.getDependencyVersion(dep)); - - const packagesToResolve = [ - ...peerDependencies, - '@storybook/addon-ondevice-controls', - '@storybook/addon-ondevice-actions', - '@storybook/react-native', - 'storybook', - ]; - - const packagesWithFixedVersion: string[] = []; - - const versionedPackages = await packageManager.getVersionedPackages(packagesToResolve); - - // TODO: Investigate why packageManager type does not match on CI - const babelDependencies = await getBabelDependencies(packageManager as any); - - const packages: string[] = []; - - packages.push(...babelDependencies); - - packages.push(...packagesWithFixedVersion); - - packages.push(...versionedPackages); - - if (missingReactDom && reactVersion) { - packages.push(`react-dom@${reactVersion}`); - } - - await packageManager.addDependencies( - { - ...npmOptions, - }, - packages - ); - - packageManager.addScripts({ - 'storybook-generate': 'sb-rn-get-stories', - }); - - const storybookConfigFolder = '.rnstorybook'; - - await copyTemplateFiles({ - packageManager: packageManager as any, - templateLocation: 'react-native', - // this value for language is not used since we only ship the ts template. This means we just fallback to @storybook/react-native/template/cli. - language: SupportedLanguage.TYPESCRIPT, - destination: storybookConfigFolder, - features: options.features, - }); -}; - -export default generator; +import { ProjectType, copyTemplateFiles, getBabelDependencies } from 'storybook/internal/cli'; +import { CLI_COLORS, logger } from 'storybook/internal/node-logger'; +import { SupportedBuilder, SupportedLanguage, SupportedRenderer } from 'storybook/internal/types'; + +import { dedent } from 'ts-dedent'; + +import { defineGeneratorModule } from '../modules/GeneratorModule'; + +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.REACT_NATIVE, + renderer: SupportedRenderer.REACT, + builderOverride: SupportedBuilder.WEBPACK5, + framework: null, + }, + configure: async (packageManager, context) => { + const missingReactDom = !packageManager.getDependencyVersion('react-dom'); + const reactVersion = packageManager.getDependencyVersion('react'); + const dependencyCollector = context.dependencyCollector; + + const peerDependencies = [ + 'react-native-safe-area-context', + '@react-native-async-storage/async-storage', + '@react-native-community/datetimepicker', + '@react-native-community/slider', + 'react-native-reanimated', + 'react-native-gesture-handler', + '@gorhom/bottom-sheet', + 'react-native-svg', + ].filter((dep) => !packageManager.getDependencyVersion(dep)); + + const packagesToResolve = [ + ...peerDependencies, + '@storybook/addon-ondevice-controls', + '@storybook/addon-ondevice-actions', + '@storybook/react-native', + 'storybook', + ]; + + const versionedPackages = await packageManager.getVersionedPackages(packagesToResolve); + const babelDependencies = await getBabelDependencies(packageManager as any); + + const packages: string[] = [ + ...babelDependencies, + ...versionedPackages, + ...(missingReactDom && reactVersion ? [`react-dom@${reactVersion}`] : []), + ]; + + dependencyCollector.addDependencies(packages); + + // Add React Native specific scripts + packageManager.addScripts({ + 'storybook-generate': 'sb-rn-get-stories', + }); + + const storybookConfigFolder = '.rnstorybook'; + + // Copy React Native templates + await copyTemplateFiles({ + packageManager: packageManager as any, + templateLocation: SupportedRenderer.REACT_NATIVE, + language: SupportedLanguage.TYPESCRIPT, + destination: storybookConfigFolder, + features: context.features, + }); + + // React Native doesn't use baseGenerator - return special config + return { + // Signal to skip baseGenerator by returning minimal config + storybookConfigFolder, + skipGenerator: true, + storybookCommand: null, + shouldRunDev: false, // React Native needs additional manual steps to configure the project + }; + }, + postConfigure: ({ packageManager }) => { + logger.log(dedent` + ${CLI_COLORS.warning('The Storybook for React Native installation is not 100% automated.')} + + To run Storybook for React Native, you will need to: + + 1. Replace the contents of your app entry with the following + + ${CLI_COLORS.info(' ' + "export {default} from './.rnstorybook';" + ' ')} + + 2. Wrap your metro config with the withStorybook enhancer function like this: + + ${CLI_COLORS.info(' ' + "const { withStorybook } = require('@storybook/react-native/metro/withStorybook');" + ' ')} + ${CLI_COLORS.info(' ' + 'module.exports = withStorybook(defaultConfig);' + ' ')} + + For more details go to: + https://github.com/storybookjs/react-native#getting-started + + Then to start Storybook for React Native, run: + + ${CLI_COLORS.cta(' ' + packageManager.getRunCommand('start') + ' ')} + `); + }, +}); diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE_AND_RNW/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE_AND_RNW/index.ts new file mode 100644 index 000000000000..bd66de500c2b --- /dev/null +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE_AND_RNW/index.ts @@ -0,0 +1,31 @@ +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; + +import reactNativeGeneratorModule from '../REACT_NATIVE'; +import reactNativeWebGeneratorModule from '../REACT_NATIVE_WEB'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; + +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.REACT_NATIVE_AND_RNW, + renderer: SupportedRenderer.REACT, + framework: SupportedFramework.REACT_NATIVE_WEB_VITE, + builderOverride: SupportedBuilder.VITE, + }, + configure: async (packageManager, context) => { + await reactNativeGeneratorModule.configure(packageManager, context); + const configurationResult = await reactNativeWebGeneratorModule.configure( + packageManager, + context + ); + + return { + ...configurationResult, + shouldRunDev: false, // React Native needs additional manual steps to configure the project + }; + }, + postConfigure: async ({ packageManager }) => { + await reactNativeWebGeneratorModule.postConfigure(); + reactNativeGeneratorModule.postConfigure({ packageManager }); + }, +}); diff --git a/code/lib/create-storybook/src/generators/REACT_NATIVE_WEB/index.ts b/code/lib/create-storybook/src/generators/REACT_NATIVE_WEB/index.ts index e645c32bafe6..3187f4a75a3b 100644 --- a/code/lib/create-storybook/src/generators/REACT_NATIVE_WEB/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_NATIVE_WEB/index.ts @@ -1,32 +1,42 @@ import { readdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { SupportedLanguage, cliStoriesTargetPath, detectLanguage } from 'storybook/internal/cli'; +import { ProjectType, cliStoriesTargetPath } from 'storybook/internal/cli'; +import { + SupportedBuilder, + SupportedFramework, + SupportedLanguage, + SupportedRenderer, +} from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - // Add prop-types dependency if not using TypeScript - const language = await detectLanguage(packageManager as any); - const extraPackages = ['vite', 'react-native-web']; - if (language === SupportedLanguage.JAVASCRIPT) { - extraPackages.push('prop-types'); - } +// Export as module +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.REACT_NATIVE_WEB, + renderer: SupportedRenderer.REACT, + framework: SupportedFramework.REACT_NATIVE_WEB_VITE, + builderOverride: SupportedBuilder.VITE, + }, + configure: async (packageManager, { language }) => { + // Add prop-types dependency if not using TypeScript + const extraPackages = ['vite', 'react-native-web']; + if (language === SupportedLanguage.JAVASCRIPT) { + extraPackages.push('prop-types'); + } - await baseGenerator( - packageManager, - npmOptions, - options, - 'react', - { extraPackages }, - 'react-native-web-vite' - ); - - // Remove CSS files automatically copeied by baseGenerator - const targetPath = await cliStoriesTargetPath(); - const cssFiles = (await readdir(targetPath)).filter((f) => f.endsWith('.css')); - await Promise.all(cssFiles.map((f) => rm(join(targetPath, f)))); -}; - -export default generator; + return { + extraPackages, + }; + }, + postConfigure: async () => { + try { + const targetPath = await cliStoriesTargetPath(); + const cssFiles = (await readdir(targetPath)).filter((f) => f.endsWith('.css')); + await Promise.all(cssFiles.map((f) => rm(join(targetPath, f)))); + } catch { + // Silent fail if CSS cleanup fails - not critical + } + }, +}); diff --git a/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts b/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts index 2da9f60d5cca..67a3860cf80a 100644 --- a/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts +++ b/code/lib/create-storybook/src/generators/REACT_SCRIPTS/index.ts @@ -2,69 +2,66 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; import semver from 'semver'; import { dedent } from 'ts-dedent'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - const monorepoRootPath = fileURLToPath(new URL('../../../../../../..', import.meta.url)); - const extraMain = options.linkable - ? { - webpackFinal: `%%(config) => { - // add monorepo root as a valid directory to import modules from - config.resolve.plugins.forEach((p) => { - if (Array.isArray(p.appSrcs)) { - p.appSrcs.push('${monorepoRootPath}'); - } - }); - return config; - } - %%`, - } - : {}; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.REACT_SCRIPTS, + renderer: SupportedRenderer.REACT, + builderOverride: SupportedBuilder.WEBPACK5, + }, + configure: async (packageManager, context) => { + const monorepoRootPath = fileURLToPath(new URL('../../../../../../..', import.meta.url)); + const extraMain = context.linkable + ? { + webpackFinal: `%%(config) => { + // add monorepo root as a valid directory to import modules from + config.resolve.plugins.forEach((p) => { + if (Array.isArray(p.appSrcs)) { + p.appSrcs.push('${monorepoRootPath}'); + } + }); + return config; + } + %%`, + } + : {}; - const craVersion = (await packageManager.getModulePackageJSON('react-scripts'))?.version ?? null; + const craVersion = + (await packageManager.getModulePackageJSON('react-scripts'))?.version ?? null; - if (craVersion === null) { - throw new Error(dedent` - It looks like you're trying to initialize Storybook in a CRA project that does not have react-scripts installed. - Please install it and make sure it's of version 5 or higher, which are the versions supported by Storybook 7.0+. - `); - } - - if (!craVersion && semver.gte(craVersion, '5.0.0')) { - throw new Error(dedent` - Storybook 7.0+ doesn't support react-scripts@<5.0.0. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#create-react-app-dropped-cra4-support - `); - } + if (craVersion === null) { + throw new Error(dedent` + It looks like you're trying to initialize Storybook in a CRA project that does not have react-scripts installed. + Please install it and make sure it's of version 5 or higher, which are the versions supported by Storybook 7.0+. + `); + } - const extraPackages = []; - extraPackages.push('webpack'); - // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 - // Miscellaneous dependency to add to be sure Storybook + CRA is working fine with Yarn PnP mode - extraPackages.push('prop-types'); + if (craVersion && semver.lt(craVersion, '5.0.0')) { + throw new Error(dedent` + Storybook 7.0+ doesn't support react-scripts@<5.0.0. + + https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#create-react-app-dropped-cra4-support + `); + } - const extraAddons = [`@storybook/preset-create-react-app`]; + // TODO: Evaluate if adding prop-types is correct after removing pnp compatibility code in SB11 + // Miscellaneous dependency to add to be sure Storybook + CRA is working fine with Yarn PnP mode + const extraPackages = ['webpack', 'prop-types']; + const extraAddons = ['@storybook/preset-create-react-app']; - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Webpack5 }, - 'react', - { + return { webpackCompiler: () => undefined, extraAddons, extraPackages, staticDir: existsSync(resolve('./public')) ? 'public' : undefined, extraMain, - } - ); -}; - -export default generator; + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/SERVER/index.ts b/code/lib/create-storybook/src/generators/SERVER/index.ts index 280551bd3791..27b8f5f46ee6 100755 --- a/code/lib/create-storybook/src/generators/SERVER/index.ts +++ b/code/lib/create-storybook/src/generators/SERVER/index.ts @@ -1,19 +1,19 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Webpack5 }, - 'server', - { +// Export as module +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.SERVER, + renderer: SupportedRenderer.SERVER, + builderOverride: SupportedBuilder.WEBPACK5, + }, + configure: async () => { + return { webpackCompiler: () => 'swc', extensions: ['json', 'yaml', 'yml'], - } - ); -}; - -export default generator; + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/SOLID/index.ts b/code/lib/create-storybook/src/generators/SOLID/index.ts index 18d9cd6c05c4..a91eae9d2893 100644 --- a/code/lib/create-storybook/src/generators/SOLID/index.ts +++ b/code/lib/create-storybook/src/generators/SOLID/index.ts @@ -1,17 +1,18 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Vite }, - 'solid', - { addComponents: false }, - 'solid' - ); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.SOLID, + renderer: SupportedRenderer.SOLID, + framework: SupportedFramework.SOLID, + builderOverride: SupportedBuilder.VITE, + }, + configure: async () => { + return { + addComponents: false, + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/SVELTE/index.ts b/code/lib/create-storybook/src/generators/SVELTE/index.ts index d3b4a89a7351..5f31dda8ac08 100644 --- a/code/lib/create-storybook/src/generators/SVELTE/index.ts +++ b/code/lib/create-storybook/src/generators/SVELTE/index.ts @@ -1,11 +1,17 @@ -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedRenderer } from 'storybook/internal/types'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'svelte', { - extensions: ['js', 'ts', 'svelte'], - extraAddons: ['@storybook/addon-svelte-csf'], - }); -}; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.SVELTE, + renderer: SupportedRenderer.SVELTE, + }, + configure: async () => { + return { + extensions: ['js', 'ts', 'svelte'], + extraAddons: ['@storybook/addon-svelte-csf'], + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/SVELTEKIT/index.ts b/code/lib/create-storybook/src/generators/SVELTEKIT/index.ts index 4a891b9a68bf..6e9b0e632793 100644 --- a/code/lib/create-storybook/src/generators/SVELTEKIT/index.ts +++ b/code/lib/create-storybook/src/generators/SVELTEKIT/index.ts @@ -1,20 +1,19 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator( - packageManager, - npmOptions, - { ...options, builder: CoreBuilder.Vite }, - 'svelte', - { +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.SVELTEKIT, + renderer: SupportedRenderer.SVELTE, + framework: SupportedFramework.SVELTEKIT, + builderOverride: SupportedBuilder.VITE, + }, + configure: async () => { + return { extensions: ['js', 'ts', 'svelte'], extraAddons: ['@storybook/addon-svelte-csf'], - }, - 'sveltekit' - ); -}; - -export default generator; + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/VUE3/index.ts b/code/lib/create-storybook/src/generators/VUE3/index.ts index 6121cba8c6c8..93e89c6de15f 100644 --- a/code/lib/create-storybook/src/generators/VUE3/index.ts +++ b/code/lib/create-storybook/src/generators/VUE3/index.ts @@ -1,17 +1,21 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'vue3', { - extraPackages: async ({ builder }) => { - return builder === CoreBuilder.Webpack5 - ? ['vue-loader@^17.0.0', '@vue/compiler-sfc@^3.2.0'] - : []; - }, - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.VUE3, + renderer: SupportedRenderer.VUE3, + }, + configure: async () => { + return { + extraPackages: async ({ builder }) => { + return builder === SupportedBuilder.WEBPACK5 + ? ['vue-loader@^17.0.0', '@vue/compiler-sfc@^3.2.0'] + : []; + }, + webpackCompiler: ({ builder }) => (builder === SupportedBuilder.WEBPACK5 ? 'swc' : undefined), + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/WEB-COMPONENTS/index.ts b/code/lib/create-storybook/src/generators/WEB-COMPONENTS/index.ts index bb6c9c607286..8e7a031934c3 100755 --- a/code/lib/create-storybook/src/generators/WEB-COMPONENTS/index.ts +++ b/code/lib/create-storybook/src/generators/WEB-COMPONENTS/index.ts @@ -1,13 +1,17 @@ -import { CoreBuilder } from 'storybook/internal/cli'; +import { ProjectType } from 'storybook/internal/cli'; +import { SupportedBuilder, SupportedRenderer } from 'storybook/internal/types'; -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; +import { defineGeneratorModule } from '../modules/GeneratorModule'; -const generator: Generator = async (packageManager, npmOptions, options) => { - return baseGenerator(packageManager, npmOptions, options, 'web-components', { - extraPackages: ['lit'], - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; +export default defineGeneratorModule({ + metadata: { + projectType: ProjectType.WEB_COMPONENTS, + renderer: SupportedRenderer.WEB_COMPONENTS, + }, + configure: async () => { + return { + extraPackages: ['lit'], + webpackCompiler: ({ builder }) => (builder === SupportedBuilder.WEBPACK5 ? 'swc' : undefined), + }; + }, +}); diff --git a/code/lib/create-storybook/src/generators/WEBPACK_REACT/index.ts b/code/lib/create-storybook/src/generators/WEBPACK_REACT/index.ts deleted file mode 100644 index d741e316fa39..000000000000 --- a/code/lib/create-storybook/src/generators/WEBPACK_REACT/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CoreBuilder } from 'storybook/internal/cli'; - -import { baseGenerator } from '../baseGenerator'; -import type { Generator } from '../types'; - -const generator: Generator = async (packageManager, npmOptions, options) => { - await baseGenerator(packageManager, npmOptions, options, 'react', { - webpackCompiler: ({ builder }) => (builder === CoreBuilder.Webpack5 ? 'swc' : undefined), - }); -}; - -export default generator; diff --git a/code/lib/create-storybook/src/generators/baseGenerator.ts b/code/lib/create-storybook/src/generators/baseGenerator.ts index 677d57de5b42..ce681f875f59 100644 --- a/code/lib/create-storybook/src/generators/baseGenerator.ts +++ b/code/lib/create-storybook/src/generators/baseGenerator.ts @@ -3,14 +3,9 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - type Builder, type NpmOptions, - ProjectType, - SupportedLanguage, configureEslintPlugin, copyTemplateFiles, - detectBuilder, - externalFrameworks, extractEslintInfo, } from 'storybook/internal/cli'; import { @@ -19,98 +14,52 @@ import { getPackageDetails, isCI, optionalEnvToBoolean, - versions, } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; -import type { SupportedFrameworks, SupportedRenderers } from 'storybook/internal/types'; +import { prompt } from 'storybook/internal/node-logger'; +import { SupportedFramework, SupportedLanguage } from 'storybook/internal/types'; -// eslint-disable-next-line depend/ban-dependencies -import ora from 'ora'; import invariant from 'tiny-invariant'; import { dedent } from 'ts-dedent'; +import { AddonService } from '../services'; import { configureMain, configurePreview } from './configure'; import type { FrameworkOptions, GeneratorOptions } from './types'; -const defaultOptions: FrameworkOptions = { +const defaultOptions = { extraPackages: [], extraAddons: [], staticDir: undefined, addScripts: true, - addMainFile: true, - addPreviewFile: true, addComponents: true, webpackCompiler: () => undefined, extraMain: undefined, - framework: undefined, extensions: undefined, componentsDestinationPath: undefined, storybookConfigFolder: '.storybook', installFrameworkPackages: true, -}; - -const getBuilderDetails = (builder: string) => { - const map = versions as Record; - - if (map[builder]) { - return builder; +} satisfies FrameworkOptions; + +const getPackageByValue = ( + type: 'framework' | 'renderer' | 'builder', + value: string, + packages: Record +) => { + const foundPackage = value + ? Object.entries(packages).find(([key, pkgValue]) => pkgValue === value)?.[0] + : undefined; + + if (foundPackage) { + return foundPackage; } - const builderPackage = `@storybook/${builder}`; - if (map[builderPackage]) { - return builderPackage; - } - - return builder; -}; - -const getExternalFramework = (framework?: string) => - externalFrameworks.find( - (exFramework) => - framework !== undefined && - (exFramework.name === framework || - exFramework.packageName === framework || - exFramework?.frameworks?.some?.((item) => item === framework)) - ); - -const getFrameworkPackage = (framework: string | undefined, renderer: string, builder: string) => { - const externalFramework = getExternalFramework(framework); - const storybookBuilder = builder?.replace(/^@storybook\/builder-/, ''); - const storybookFramework = framework?.replace(/^@storybook\//, ''); - - if (externalFramework === undefined) { - const frameworkPackage = framework - ? `@storybook/${storybookFramework}` - : `@storybook/${renderer}-${storybookBuilder}`; - - if (versions[frameworkPackage as keyof typeof versions]) { - return frameworkPackage; - } - - throw new Error( - dedent` - Could not find framework package: ${frameworkPackage}. - Make sure this package exists, and if it does, please file an issue as this might be a bug in Storybook. - ` - ); - } - - return ( - externalFramework.frameworks?.find((item) => item.match(new RegExp(`-${storybookBuilder}`))) ?? - externalFramework.packageName + throw new Error( + dedent` + Could not find ${type} package for ${value}. + Make sure this package exists, and if it does, please file an issue as this might be a bug in Storybook. + ` ); }; -const getRendererPackage = (framework: string | undefined, renderer: string) => { - const externalFramework = getExternalFramework(framework); - - if (externalFramework !== undefined) { - return externalFramework.renderer || externalFramework.packageName; - } - - return `@storybook/${renderer}`; -}; - const applyGetAbsolutePathWrapper = (packageName: string) => `%%getAbsolutePath('${packageName}')%%`; @@ -123,72 +72,28 @@ const applyAddonGetAbsolutePathWrapper = (pkg: string | { name: string }) => { return obj; }; -const getFrameworkDetails = ( - renderer: SupportedRenderers, - builder: Builder, - // TODO: Remove in SB11 - pnp: boolean, - language: SupportedLanguage, - framework?: SupportedFrameworks, - shouldApplyRequireWrapperOnPackageNames?: boolean -): { - type: 'framework' | 'renderer'; - packages: string[]; - builder?: string; - frameworkPackagePath?: string; - renderer?: string; - rendererId: SupportedRenderers; - frameworkPackage?: string; +const getFrameworkDetails = ({ + framework, + shouldApplyRequireWrapperOnPackageNames, +}: { + framework: SupportedFramework; + shouldApplyRequireWrapperOnPackageNames?: boolean; +}): { + frameworkPackage: string; + frameworkPackagePath: string; } => { - const frameworkPackage = getFrameworkPackage(framework, renderer, builder); - invariant(frameworkPackage, 'Missing framework package.'); + const frameworkPackage = getPackageByValue('framework', framework, frameworkPackages); const frameworkPackagePath = shouldApplyRequireWrapperOnPackageNames ? applyGetAbsolutePathWrapper(frameworkPackage) : frameworkPackage; - const rendererPackage = getRendererPackage(framework, renderer) as string; - const rendererPackagePath = shouldApplyRequireWrapperOnPackageNames - ? applyGetAbsolutePathWrapper(rendererPackage) - : rendererPackage; - - const builderPackage = getBuilderDetails(builder); - const builderPackagePath = shouldApplyRequireWrapperOnPackageNames - ? applyGetAbsolutePathWrapper(builderPackage) - : builderPackage; - - const isExternalFramework = !!getExternalFramework(frameworkPackage); - const isKnownFramework = - isExternalFramework || !!(versions as Record)[frameworkPackage]; - const isKnownRenderer = !!(versions as Record)[rendererPackage]; - - if (isKnownFramework) { - return { - packages: [frameworkPackage], - frameworkPackagePath, - frameworkPackage, - rendererId: renderer, - type: 'framework', - }; - } - - if (isKnownRenderer) { - return { - packages: [rendererPackage, builderPackage], - builder: builderPackagePath, - renderer: rendererPackagePath, - rendererId: renderer, - type: 'renderer', - }; - } - - throw new Error( - `Could not find the framework (${frameworkPackage}) or renderer (${rendererPackage}) package` - ); + return { + frameworkPackage, + frameworkPackagePath, + }; }; -const stripVersions = (addons: string[]) => addons.map((addon) => getPackageDetails(addon)[0]); - const hasFrameworkTemplates = (framework?: string) => { if (!framework) { return false; @@ -200,84 +105,51 @@ const hasFrameworkTemplates = (framework?: string) => { return !optionalEnvToBoolean(process.env.IN_STORYBOOK_SANDBOX); } - const frameworksWithTemplates: SupportedFrameworks[] = [ - 'angular', - 'ember', - 'html-vite', - 'nextjs', - 'nextjs-vite', - 'preact-vite', - 'react-native-web-vite', - 'react-vite', - 'react-webpack5', - 'server-webpack5', - 'svelte-vite', - 'sveltekit', - 'vue3-vite', - 'web-components-vite', + const frameworksWithTemplates: SupportedFramework[] = [ + SupportedFramework.ANGULAR, + SupportedFramework.EMBER, + SupportedFramework.HTML_VITE, + SupportedFramework.NEXTJS, + SupportedFramework.NEXTJS_VITE, + SupportedFramework.PREACT_VITE, + SupportedFramework.REACT_NATIVE_WEB_VITE, + SupportedFramework.REACT_VITE, + SupportedFramework.REACT_WEBPACK5, + SupportedFramework.SERVER_WEBPACK5, + SupportedFramework.SVELTE_VITE, + SupportedFramework.SVELTEKIT, + SupportedFramework.VUE3_VITE, + SupportedFramework.WEB_COMPONENTS_VITE, ]; - return frameworksWithTemplates.includes(framework as SupportedFrameworks); + return frameworksWithTemplates.includes(framework as SupportedFramework); }; export async function baseGenerator( packageManager: JsPackageManager, npmOptions: NpmOptions, - { language, builder, pnp, frameworkPreviewParts, projectType, features }: GeneratorOptions, - renderer: SupportedRenderers, - options: FrameworkOptions = defaultOptions, - framework?: SupportedFrameworks + { language, builder, framework, renderer, pnp, features, dependencyCollector }: GeneratorOptions, + _options: FrameworkOptions ) { + const options = { ...defaultOptions, ..._options }; const isStorybookInMonorepository = packageManager.isStorybookInMonorepo(); const shouldApplyRequireWrapperOnPackageNames = isStorybookInMonorepository || pnp; - if (!builder) { - builder = await detectBuilder(packageManager as any, projectType); - } + const taskLog = prompt.taskLog({ + id: 'base-generator', + title: 'Generating Storybook configuration', + }); - if (features.includes('test')) { - const supportedFrameworks: ProjectType[] = [ - ProjectType.REACT, - ProjectType.VUE3, - ProjectType.NEXTJS, - ProjectType.NUXT, - ProjectType.PREACT, - ProjectType.SVELTE, - ProjectType.SVELTEKIT, - ProjectType.WEB_COMPONENTS, - ProjectType.REACT_NATIVE_WEB, - ]; - const supportsTestAddon = - projectType === ProjectType.NEXTJS || - (builder !== 'webpack5' && supportedFrameworks.includes(projectType)); - if (!supportsTestAddon) { - features.splice(features.indexOf('test'), 1); - } - } - - const { - packages, - type, - rendererId, - frameworkPackagePath, - builder: builderInclude, - frameworkPackage, - } = getFrameworkDetails( - renderer, - builder, - pnp, - language, + const { frameworkPackagePath, frameworkPackage } = getFrameworkDetails({ framework, - shouldApplyRequireWrapperOnPackageNames - ); + shouldApplyRequireWrapperOnPackageNames, + }); const { extraAddons = [], extraPackages, staticDir, addScripts, - addMainFile, - addPreviewFile, addComponents, extraMain, extensions, @@ -290,58 +162,31 @@ export async function baseGenerator( ...options, }; - const compiler = webpackCompiler ? webpackCompiler({ builder }) : undefined; - - if (features.includes('test')) { - extraAddons.push('@chromatic-com/storybook'); - } - - if (features.includes('docs')) { - extraAddons.push('@storybook/addon-docs'); - } - - if (features.includes('onboarding')) { - extraAddons.push('@storybook/addon-onboarding'); - } - - // added to main.js - const addons = [ - ...(compiler ? [`@storybook/addon-webpack5-compiler-${compiler}`] : []), - ...stripVersions(extraAddons), - ].filter(Boolean); - - // added to package.json - const addonPackages = [ - ...(compiler ? [`@storybook/addon-webpack5-compiler-${compiler}`] : []), - ...extraAddons, - ].filter(Boolean); + // Configure addons using AddonManager + const addonManager = new AddonService(); + const { addonsForMain: addons, addonPackages } = addonManager.configureAddons( + features, + extraAddons, + builder, + webpackCompiler + ); const { packageJson } = packageManager.primaryPackageJson; + const installedDependencies = new Set( Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies }) ); - // TODO: We need to start supporting this at some point - if (type === 'renderer') { - throw new Error( - dedent` - Sorry, for now, you can not do this, please use a framework such as @storybook/react-webpack5 - - https://github.com/storybookjs/storybook/issues/18360 - ` - ); - } - const extraPackagesToInstall = typeof extraPackages === 'function' ? await extraPackages({ - builder: (builder || builderInclude) as string, + builder, }) : extraPackages; const allPackages = [ 'storybook', - ...(installFrameworkPackages ? packages : []), + ...(installFrameworkPackages ? [frameworkPackage] : []), ...addonPackages, ...(extraPackagesToInstall || []), ].filter(Boolean); @@ -351,13 +196,7 @@ export async function baseGenerator( !installedDependencies.has(getPackageDetails(packageToInstall as string)[0]) ); - logger.log(''); - - const versionedPackagesSpinner = ora({ - indent: 2, - text: `Getting the correct version of ${packagesToInstall.length} packages`, - }).start(); - + let eslintPluginPackage: string | null = null; try { if (!isCI()) { const { hasEslint, isStorybookPluginInstalled, isFlatConfig, eslintConfigFile } = @@ -365,7 +204,9 @@ export async function baseGenerator( await extractEslintInfo(packageManager as any); if (hasEslint && !isStorybookPluginInstalled) { - packagesToInstall.push('eslint-plugin-storybook'); + eslintPluginPackage = 'eslint-plugin-storybook'; + packagesToInstall.push(eslintPluginPackage); + taskLog.message(`- Configuring ESLint plugin`); await configureEslintPlugin({ eslintConfigFile, // TODO: Investigate why packageManager type does not match on CI @@ -381,93 +222,81 @@ export async function baseGenerator( const versionedPackages = await packageManager.getVersionedPackages( packagesToInstall as string[] ); - versionedPackagesSpinner.succeed(); if (versionedPackages.length > 0) { - const addDependenciesSpinner = ora({ - indent: 2, - text: 'Installing Storybook dependencies', - }).start(); - - await packageManager.addDependencies({ ...npmOptions }, versionedPackages); - addDependenciesSpinner.succeed(); + // When using the dependency collector, just collect the packages + if (npmOptions.type === 'devDependencies') { + dependencyCollector.addDevDependencies(versionedPackages); + } else { + dependencyCollector.addDependencies(versionedPackages); + } } - if (addMainFile || addPreviewFile) { - await mkdir(`./${storybookConfigFolder}`, { recursive: true }); - } + await mkdir(`./${storybookConfigFolder}`, { recursive: true }); - if (addMainFile) { - // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 - const prefixes = shouldApplyRequireWrapperOnPackageNames - ? [ - 'import { dirname } from "path"', - 'import { fileURLToPath } from "url"', - language === SupportedLanguage.JAVASCRIPT - ? dedent`/** + // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 + const prefixes = shouldApplyRequireWrapperOnPackageNames + ? [ + 'import { dirname } from "path"', + 'import { fileURLToPath } from "url"', + language === SupportedLanguage.JAVASCRIPT + ? dedent`/** * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ function getAbsolutePath(value) { return dirname(fileURLToPath(import.meta.resolve(\`\${value}/package.json\`))) }` - : dedent`/** + : dedent`/** * This function is used to resolve the absolute path of a package. * It is needed in projects that use Yarn PnP or are set up within a monorepo. */ function getAbsolutePath(value: string): any { return dirname(fileURLToPath(import.meta.resolve(\`\${value}/package.json\`))) }`, - ] - : []; - - await configureMain({ - framework: { - name: frameworkPackagePath, - options: options.framework || {}, - }, - features, - frameworkPackage, - prefixes, - storybookConfigFolder, - addons: shouldApplyRequireWrapperOnPackageNames - ? addons.map((addon) => applyAddonGetAbsolutePathWrapper(addon)) - : addons, - extensions, - language, - ...(staticDir ? { staticDirs: [join('..', staticDir)] } : null), - ...extraMain, - ...(type !== 'framework' - ? { - core: { - builder: builderInclude, - }, - } - : {}), - }); - } + ] + : []; - if (addPreviewFile) { - await configurePreview({ - frameworkPreviewParts, - storybookConfigFolder: storybookConfigFolder as string, - language, - frameworkPackage, - }); - } + const configurationFileExtension = language === SupportedLanguage.TYPESCRIPT ? 'ts' : 'js'; + + taskLog.message(`- Configuring main.${configurationFileExtension}`); + await configureMain({ + framework: frameworkPackagePath, + features, + frameworkPackage, + prefixes, + storybookConfigFolder, + addons: shouldApplyRequireWrapperOnPackageNames + ? addons.map((addon) => applyAddonGetAbsolutePathWrapper(addon)) + : addons, + extensions, + language, + ...(staticDir ? { staticDirs: [join('..', staticDir)] } : null), + ...extraMain, + }); + + taskLog.message(`- Configuring preview.${configurationFileExtension}`); + + await configurePreview({ + frameworkPreviewParts: _options.frameworkPreviewParts, + storybookConfigFolder: storybookConfigFolder as string, + language, + frameworkPackage, + }); if (addScripts) { + taskLog.message(`- Adding Storybook command to package.json`); packageManager.addStorybookCommandInScripts({ port: 6006, }); } if (addComponents) { - const finalFramework = framework || frameworkPackages[frameworkPackage!] || frameworkPackage; - const templateLocation = hasFrameworkTemplates(finalFramework) ? finalFramework : rendererId; - if (!templateLocation) { - throw new Error(`Could not find template location for ${framework} or ${rendererId}`); - } + const templateLocation = hasFrameworkTemplates(framework) ? framework : renderer; + invariant(templateLocation, `Could not find template location for ${framework} or ${renderer}`); + + taskLog.message(`- Copying framework templates`); + await copyTemplateFiles({ templateLocation, packageManager: packageManager as any, @@ -481,4 +310,12 @@ export async function baseGenerator( features, }); } + + taskLog.success('Storybook configuration generated', { showLog: true }); + + return { + configDir: storybookConfigFolder, + storybookCommand: _options.storybookCommand, + shouldRunDev: _options.shouldRunDev, + }; } diff --git a/code/lib/create-storybook/src/generators/configure.test.ts b/code/lib/create-storybook/src/generators/configure.test.ts index 148a210d1fc7..aba39ace440a 100644 --- a/code/lib/create-storybook/src/generators/configure.test.ts +++ b/code/lib/create-storybook/src/generators/configure.test.ts @@ -3,7 +3,7 @@ import * as fsp from 'node:fs/promises'; import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { SupportedLanguage } from 'storybook/internal/cli'; +import { Feature, SupportedLanguage } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; @@ -23,11 +23,9 @@ describe('configureMain', () => { addons: [], prefixes: [], storybookConfigFolder: '.storybook', - framework: { - name: '@storybook/react-vite', - }, + framework: '@storybook/react-vite', frameworkPackage: '@storybook/react-vite', - features: [], + features: new Set([]), }); const { calls } = vi.mocked(fsp.writeFile).mock; @@ -43,9 +41,7 @@ describe('configureMain', () => { "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" ], "addons": [], - "framework": { - "name": "@storybook/react-vite" - } + "framework": "@storybook/react-vite" }; export default config;" `); @@ -57,11 +53,9 @@ describe('configureMain', () => { addons: [], prefixes: [], storybookConfigFolder: '.storybook', - framework: { - name: '@storybook/react-vite', - }, + framework: '@storybook/react-vite', frameworkPackage: '@storybook/react-vite', - features: ['docs'], + features: new Set([Feature.DOCS]), }); const { calls } = vi.mocked(fsp.writeFile).mock; @@ -77,9 +71,7 @@ describe('configureMain', () => { "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" ], "addons": [], - "framework": { - "name": "@storybook/react-vite" - } + "framework": "@storybook/react-vite" }; export default config;" `); @@ -91,11 +83,9 @@ describe('configureMain', () => { addons: [], prefixes: [], storybookConfigFolder: '.storybook', - framework: { - name: '@storybook/react-vite', - }, + framework: '@storybook/react-vite', frameworkPackage: '@storybook/react-vite', - features: [], + features: new Set([]), }); const { calls } = vi.mocked(fsp.writeFile).mock; @@ -110,9 +100,7 @@ describe('configureMain', () => { "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)" ], "addons": [], - "framework": { - "name": "@storybook/react-vite" - } + "framework": "@storybook/react-vite" }; export default config;" `); @@ -127,11 +115,10 @@ describe('configureMain', () => { "%%path.dirname(require.resolve(path.join('@storybook/preset-create-react-app', 'package.json')))%%", ], storybookConfigFolder: '.storybook', - framework: { - name: "%%path.dirname(require.resolve(path.join('@storybook/react-webpack5', 'package.json')))%%", - }, + framework: + "%%path.dirname(require.resolve(path.join('@storybook/react-webpack5', 'package.json')))%%", frameworkPackage: '@storybook/react-webpack5', - features: ['docs'], + features: new Set([Feature.DOCS]), }); const { calls } = vi.mocked(fsp.writeFile).mock; @@ -151,9 +138,7 @@ describe('configureMain', () => { path.dirname(require.resolve(path.join('@storybook/addon-essentials', 'package.json'))), path.dirname(require.resolve(path.join('@storybook/preset-create-react-app', 'package.json'))) ], - "framework": { - "name": path.dirname(require.resolve(path.join('@storybook/react-webpack5', 'package.json'))) - } + "framework": path.dirname(require.resolve(path.join('@storybook/react-webpack5', 'package.json'))) }; export default config;" `); diff --git a/code/lib/create-storybook/src/generators/configure.ts b/code/lib/create-storybook/src/generators/configure.ts index 955f52fded1b..b0204b6ef032 100644 --- a/code/lib/create-storybook/src/generators/configure.ts +++ b/code/lib/create-storybook/src/generators/configure.ts @@ -1,13 +1,11 @@ import { stat, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { SupportedLanguage } from 'storybook/internal/cli'; import { logger } from 'storybook/internal/node-logger'; +import { Feature, SupportedLanguage } from 'storybook/internal/types'; import { dedent } from 'ts-dedent'; -import type { GeneratorFeature } from './types'; - interface ConfigureMainOptions { addons: string[]; extensions?: string[]; @@ -16,7 +14,7 @@ interface ConfigureMainOptions { language: SupportedLanguage; prefixes: string[]; frameworkPackage: string; - features: GeneratorFeature[]; + features: Set; /** * Extra values for main.js * @@ -52,12 +50,12 @@ export async function configureMain({ language, frameworkPackage, prefixes = [], - features = [], + features, ...custom }: ConfigureMainOptions) { const srcPath = resolve(storybookConfigFolder, '../src'); const prefix = (await pathExists(srcPath)) ? '../src' : '../stories'; - const stories = features.includes('docs') ? [`${prefix}/**/*.mdx`] : []; + const stories = features.has(Feature.DOCS) ? [`${prefix}/**/*.mdx`] : []; stories.push(`${prefix}/**/*.stories.@(${extensions.join('|')})`); @@ -84,7 +82,7 @@ export async function configureMain({ const imports = []; const finalPrefixes = [...prefixes]; - if (custom.framework?.name.includes('path.dirname(')) { + if (custom.framework.includes('path.dirname(')) { imports.push(`import path from 'node:path';`); } @@ -104,17 +102,19 @@ export async function configureMain({ const mainPath = `./${storybookConfigFolder}/main.${isTypescript ? 'ts' : 'js'}`; await writeFile(mainPath, mainJsContents, { encoding: 'utf8' }); + + return { mainPath }; } export async function configurePreview(options: ConfigurePreviewOptions) { const { prefix: frameworkPrefix = '' } = options.frameworkPreviewParts || {}; const isTypescript = options.language === SupportedLanguage.TYPESCRIPT; - const previewPath = `./${options.storybookConfigFolder}/preview.${isTypescript ? 'ts' : 'js'}`; + const previewConfigPath = `./${options.storybookConfigFolder}/preview.${isTypescript ? 'ts' : 'js'}`; // If the framework template included a preview then we have nothing to do - if (await pathExists(previewPath)) { - return; + if (await pathExists(previewConfigPath)) { + return { previewConfigPath }; } const frameworkPackage = options.frameworkPackage; @@ -149,5 +149,7 @@ export async function configurePreview(options: ConfigurePreviewOptions) { .replace(' \n', '') .trim(); - await writeFile(previewPath, preview, { encoding: 'utf8' }); + await writeFile(previewConfigPath, preview, { encoding: 'utf8' }); + + return { previewConfigPath }; } diff --git a/code/lib/create-storybook/src/generators/index.ts b/code/lib/create-storybook/src/generators/index.ts new file mode 100644 index 000000000000..5aeafa0f7779 --- /dev/null +++ b/code/lib/create-storybook/src/generators/index.ts @@ -0,0 +1,9 @@ +/** + * Generator registry and utilities + * + * Provides a centralized way to manage and access Storybook generators + */ + +export { GeneratorRegistry, generatorRegistry } from './GeneratorRegistry'; + +export { registerAllGenerators } from './registerGenerators'; diff --git a/code/lib/create-storybook/src/generators/modules/GeneratorModule.ts b/code/lib/create-storybook/src/generators/modules/GeneratorModule.ts new file mode 100644 index 000000000000..bf22fbf56e92 --- /dev/null +++ b/code/lib/create-storybook/src/generators/modules/GeneratorModule.ts @@ -0,0 +1,5 @@ +import type { GeneratorModule } from '../types'; + +export function defineGeneratorModule(generatorModule: T) { + return generatorModule; +} diff --git a/code/lib/create-storybook/src/generators/registerGenerators.ts b/code/lib/create-storybook/src/generators/registerGenerators.ts new file mode 100644 index 000000000000..3ce2683888f7 --- /dev/null +++ b/code/lib/create-storybook/src/generators/registerGenerators.ts @@ -0,0 +1,48 @@ +import angularGenerator from './ANGULAR'; +import emberGenerator from './EMBER'; +import { generatorRegistry } from './GeneratorRegistry'; +import htmlGenerator from './HTML'; +import nextjsGenerator from './NEXTJS'; +import nuxtGenerator from './NUXT'; +import preactGenerator from './PREACT'; +import qwikGenerator from './QWIK'; +import reactGenerator from './REACT'; +import reactNativeGenerator from './REACT_NATIVE'; +import reactNativeAndRNWGenerator from './REACT_NATIVE_AND_RNW'; +import reactNativeWebGenerator from './REACT_NATIVE_WEB'; +import reactScriptsGenerator from './REACT_SCRIPTS'; +import serverGenerator from './SERVER'; +import solidGenerator from './SOLID'; +import svelteGenerator from './SVELTE'; +import svelteKitGenerator from './SVELTEKIT'; +import vue3Generator from './VUE3'; +import webComponentsGenerator from './WEB-COMPONENTS'; +import type { GeneratorModule } from './types'; + +const setOfGenerators = new Set([ + reactGenerator, + reactScriptsGenerator, + reactNativeGenerator, + reactNativeWebGenerator, + reactNativeAndRNWGenerator, + vue3Generator, + nuxtGenerator, + angularGenerator, + nextjsGenerator, + svelteGenerator, + svelteKitGenerator, + emberGenerator, + htmlGenerator, + webComponentsGenerator, + preactGenerator, + solidGenerator, + serverGenerator, + qwikGenerator, +]); + +/** Register all framework generators with the central registry */ +export function registerAllGenerators(): void { + setOfGenerators.forEach((generator) => { + generatorRegistry.register(generator); + }); +} diff --git a/code/lib/create-storybook/src/generators/types.ts b/code/lib/create-storybook/src/generators/types.ts index 89a661a50312..97300e3d2caf 100644 --- a/code/lib/create-storybook/src/generators/types.ts +++ b/code/lib/create-storybook/src/generators/types.ts @@ -1,62 +1,137 @@ -import type { Builder, NpmOptions, ProjectType, SupportedLanguage } from 'storybook/internal/cli'; +import type { NpmOptions, ProjectType } from 'storybook/internal/cli'; import type { JsPackageManager, PackageManagerName } from 'storybook/internal/common'; +import type { ConfigFile } from 'storybook/internal/csf-tools'; +import type { + Feature, + StorybookConfig, + SupportedBuilder, + SupportedFramework, + SupportedLanguage, + SupportedRenderer, +} from 'storybook/internal/types'; +import type { DependencyCollector } from '../dependency-collector'; import type { FrameworkPreviewParts } from './configure'; export type GeneratorOptions = { language: SupportedLanguage; - builder: Builder; + builder: SupportedBuilder; + framework: SupportedFramework; + renderer: SupportedRenderer; linkable: boolean; // TODO: Remove in SB11 pnp: boolean; - projectType: ProjectType; frameworkPreviewParts?: FrameworkPreviewParts; // skip prompting the user yes: boolean; - features: Array; + features: Set; + dependencyCollector: DependencyCollector; }; export interface FrameworkOptions { - extraPackages?: string[] | ((details: { builder: Builder }) => Promise); + extraPackages?: string[] | ((details: { builder: SupportedBuilder }) => Promise); extraAddons?: string[]; staticDir?: string; addScripts?: boolean; - addMainFile?: boolean; - addPreviewFile?: boolean; addComponents?: boolean; - webpackCompiler?: ({ builder }: { builder: Builder }) => 'babel' | 'swc' | undefined; + webpackCompiler?: ({ builder }: { builder: SupportedBuilder }) => 'babel' | 'swc' | undefined; extraMain?: any; extensions?: string[]; - framework?: Record; storybookConfigFolder?: string; componentsDestinationPath?: string; installFrameworkPackages?: boolean; + skipGenerator?: boolean; + storybookCommand?: string | null; + shouldRunDev?: boolean; + frameworkPreviewParts?: FrameworkPreviewParts; } -export type Generator = ( +export type Generator> = ( packageManagerInstance: JsPackageManager, npmOptions: NpmOptions, generatorOptions: GeneratorOptions, commandOptions?: CommandOptions -) => Promise; +) => Promise< + { + rendererPackage: string; + builderPackage: string; + frameworkPackage: string; + configDir: string; + mainConfig?: StorybookConfig; + mainConfigCSFFile?: ConfigFile; + previewConfigPath?: string; + } & T +>; + +// New generator interface for configuration-based generators + +export interface GeneratorMetadata { + projectType: ProjectType; + renderer: SupportedRenderer; + /** + * If the framework is a function, it will be called with the detected builder to determine the + * framework. This is useful for project types that support multiple frameworks based on the + * builder (e.g., Next.js with Vite vs Webpack). + */ + framework?: SupportedFramework | null | ((builder: SupportedBuilder) => SupportedFramework); + /** + * If the builder is a function, it will be called to determine the builder. This is useful for + * generators that need to determine the builder based on the project type in cases where the + * builder cannot be detected (Webpack and Vite are both non-existent dependencies). + */ + builderOverride?: SupportedBuilder | (() => SupportedBuilder | Promise); +} -export type GeneratorFeature = 'docs' | 'test' | 'onboarding'; +export interface GeneratorContext { + framework: SupportedFramework | null | undefined; + renderer: SupportedRenderer; + builder: SupportedBuilder; + language: SupportedLanguage; + features: Set; + dependencyCollector: DependencyCollector; + linkable?: boolean; + yes?: boolean; +} + +export interface GeneratorModule { + /** Metadata about the generator This is used to register the generator with the generator registry */ + metadata: GeneratorMetadata; + /** + * The function that configures the generator This is used to configure the generator It returns a + * promise that resolves to the framework options + */ + configure: ( + packageManager: JsPackageManager, + context: GeneratorContext + ) => Promise; + /** + * The function that runs after the generator is configured. This is used to run any + * post-configuration tasks + */ + postConfigure?: ({ + packageManager, + }: { + packageManager: JsPackageManager; + }) => Promise | void; +} export type CommandOptions = { packageManager: PackageManagerName; usePnp?: boolean; - features: GeneratorFeature[]; + features?: Array; type?: ProjectType; force?: any; html?: boolean; skipInstall?: boolean; + language?: SupportedLanguage; parser?: string; // Automatically answer yes to prompts yes?: boolean; - builder?: Builder; + builder?: SupportedBuilder; linkable?: boolean; disableTelemetry?: boolean; enableCrashReports?: boolean; debug?: boolean; dev?: boolean; + logfile?: string | boolean; }; diff --git a/code/lib/create-storybook/src/initiate.test.ts b/code/lib/create-storybook/src/initiate.test.ts index 0db184b45191..17c50007e59c 100644 --- a/code/lib/create-storybook/src/initiate.test.ts +++ b/code/lib/create-storybook/src/initiate.test.ts @@ -1,166 +1,23 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +/** + * NOTE: These tests use the VersionService from the refactored implementation. The promptNewUser + * and promptInstallType functions are tested in: + * + * - Services/VersionService.test.ts (for version detection) + * - Commands/UserPreferencesCommand.test.ts (for user prompts) + */ +import { describe, expect, it, vi } from 'vitest'; + +import { VersionService } from './services/VersionService'; + +// Create a version service instance for testing +const versionService = new VersionService(); +const getStorybookVersionFromAncestry = + versionService.getStorybookVersionFromAncestry.bind(versionService); +const getCliIntegrationFromAncestry = + versionService.getCliIntegrationFromAncestry.bind(versionService); -import { ProjectType, type Settings } from 'storybook/internal/cli'; -import { telemetry } from 'storybook/internal/telemetry'; - -import prompts from 'prompts'; - -import { - getCliIntegrationFromAncestry, - getStorybookVersionFromAncestry, - promptInstallType, - promptNewUser, -} from './initiate'; - -vi.mock('prompts', { spy: true }); vi.mock('storybook/internal/telemetry'); -describe('promptNewUser', () => { - let settings: Settings; - beforeEach(() => { - settings = { - value: { version: 1 }, - save: vi.fn(), - } as any as Settings; - vi.resetAllMocks(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('skips prompt if non-interactive', async () => { - const newUser = await promptNewUser({ settings, skipPrompt: true }); - expect(newUser).toBe(true); - - expect(settings.value.init?.skipOnboarding).toEqual(false); - expect(prompts).not.toHaveBeenCalled(); - expect(vi.mocked(telemetry).mock.calls[0][1]).toMatchInlineSnapshot(` - { - "newUser": true, - "step": "new-user-check", - } - `); - }); - - it('skips prompt if user set previously opted out', async () => { - settings.value.init = { skipOnboarding: true }; - const newUser = await promptNewUser({ settings }); - - expect(newUser).toBe(false); - expect(settings.value.init?.skipOnboarding).toEqual(true); - expect(prompts).not.toHaveBeenCalled(); - expect(vi.mocked(telemetry).mock.calls[0][1]).toMatchInlineSnapshot(` - { - "newUser": false, - "step": "new-user-check", - } - `); - }); - - it('prompts user and sets settings when interactive', async () => { - prompts.inject([true]); - const newUser = await promptNewUser({ settings }); - - expect(newUser).toBe(true); - expect(settings.value.init?.skipOnboarding).toEqual(false); - expect(prompts).toHaveBeenCalled(); - expect(vi.mocked(telemetry).mock.calls[0][1]).toMatchInlineSnapshot(` - { - "newUser": true, - "step": "new-user-check", - } - `); - }); - - it('returns undefined when user cancels the prompt', async () => { - prompts.inject([undefined]); - const newUser = await promptNewUser({ settings }); - expect(prompts).toHaveBeenCalled(); - expect(newUser).toBeUndefined(); - expect(settings.value.init).toBeUndefined(); - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('skips telemetry when disabled', async () => { - prompts.inject([false]); - const newUser = await promptNewUser({ settings, disableTelemetry: true }); - - expect(prompts).toHaveBeenCalled(); - expect(newUser).toBe(false); - expect(settings.value.init?.skipOnboarding).toEqual(true); - expect(telemetry).not.toHaveBeenCalled(); - }); -}); - -describe('promptInstallType', () => { - const settings = { - value: { version: 1 }, - save: vi.fn(), - } as any as Settings; - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('returns "recommended" when not interactive', async () => { - const result = await promptInstallType({ settings, skipPrompt: true }); - expect(result).toBe('recommended'); - expect(vi.mocked(telemetry).mock.calls[0][1]).toMatchInlineSnapshot(` - { - "installType": "recommended", - "step": "install-type", - } - `); - }); - - it('prompts user when interactive and yes option is not set', async () => { - prompts.inject(['recommended']); - const result = await promptInstallType({ settings }); - expect(result).toBe('recommended'); - }); - - it('returns "light" when user selects minimal configuration', async () => { - prompts.inject(['light']); - const result = await promptInstallType({ settings }); - expect(result).toBe('light'); - }); - - it('returns undefined when user cancels the prompt', async () => { - prompts.inject([undefined]); - const result = await promptInstallType({ settings }); - expect(result).toBeUndefined(); - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('skips telemetry when disabled', async () => { - prompts.inject(['recommended']); - const result = await promptInstallType({ settings, disableTelemetry: true }); - expect(result).toBe('recommended'); - expect(telemetry).not.toHaveBeenCalled(); - }); - - it('uses specific prompt options for React Native projects', async () => { - prompts.inject(['recommended']); - const result = await promptInstallType({ - settings, - projectType: ProjectType.REACT_NATIVE, - }); - - expect(result).toBe('recommended'); - expect(prompts).not.toHaveBeenCalled(); - expect(vi.mocked(telemetry).mock.calls[0][1]).toMatchInlineSnapshot(` - { - "installType": "recommended", - "step": "install-type", - } - `); - }); -}); - describe('getStorybookVersionFromAncestry', () => { it('possible storybook path', () => { const ancestry = [{ command: 'node' }, { command: 'storybook@7.0.0' }, { command: 'npm' }]; diff --git a/code/lib/create-storybook/src/initiate.ts b/code/lib/create-storybook/src/initiate.ts index 5833f1db2a7f..368c7e738ae7 100644 --- a/code/lib/create-storybook/src/initiate.ts +++ b/code/lib/create-storybook/src/initiate.ts @@ -1,796 +1,118 @@ -import { execSync } from 'node:child_process'; -import fs from 'node:fs/promises'; - -import * as babel from 'storybook/internal/babel'; -import { - type Builder, - type NpmOptions, - ProjectType, - type Settings, - detect, - detectLanguage, - detectPnp, - globalSettings, - installableProjectTypes, - isStorybookInstantiated, -} from 'storybook/internal/cli'; -import { - HandledError, - type JsPackageManager, - JsPackageManagerFactory, - commandLog, - getProjectRoot, - invalidateProjectRootCache, - isCI, - paddedLog, - versions, -} from 'storybook/internal/common'; +import { ProjectType } from 'storybook/internal/cli'; +import { type JsPackageManager, executeCommand } from 'storybook/internal/common'; import { withTelemetry } from 'storybook/internal/core-server'; -import { deprecate, logger } from 'storybook/internal/node-logger'; -import { NxProjectDetectedError } from 'storybook/internal/server-errors'; -import { telemetry } from 'storybook/internal/telemetry'; - -import boxen from 'boxen'; -import * as find from 'empathic/find'; -// eslint-disable-next-line depend/ban-dependencies -import execa from 'execa'; -import picocolors from 'picocolors'; -import { getProcessAncestry } from 'process-ancestry'; -import prompts from 'prompts'; -import { lt, prerelease } from 'semver'; -import { dedent } from 'ts-dedent'; - -import angularGenerator from './generators/ANGULAR'; -import emberGenerator from './generators/EMBER'; -import htmlGenerator from './generators/HTML'; -import nextjsGenerator from './generators/NEXTJS'; -import nuxtGenerator from './generators/NUXT'; -import preactGenerator from './generators/PREACT'; -import qwikGenerator from './generators/QWIK'; -import reactGenerator from './generators/REACT'; -import reactNativeGenerator from './generators/REACT_NATIVE'; -import reactNativeWebGenerator from './generators/REACT_NATIVE_WEB'; -import reactScriptsGenerator from './generators/REACT_SCRIPTS'; -import serverGenerator from './generators/SERVER'; -import solidGenerator from './generators/SOLID'; -import svelteGenerator from './generators/SVELTE'; -import svelteKitGenerator from './generators/SVELTEKIT'; -import vue3Generator from './generators/VUE3'; -import webComponentsGenerator from './generators/WEB-COMPONENTS'; -import webpackReactGenerator from './generators/WEBPACK_REACT'; -import type { CommandOptions, GeneratorFeature, GeneratorOptions } from './generators/types'; -import { packageVersions } from './ink/steps/checks/packageVersions'; -import { vitestConfigFiles } from './ink/steps/checks/vitestConfigFiles'; -import { currentDirectoryIsEmpty, scaffoldNewProject } from './scaffold-new-project'; - -const ONBOARDING_PROJECT_TYPES = [ - ProjectType.REACT, - ProjectType.REACT_SCRIPTS, - ProjectType.REACT_NATIVE_WEB, - ProjectType.REACT_PROJECT, - ProjectType.WEBPACK_REACT, - ProjectType.NEXTJS, - ProjectType.VUE3, - ProjectType.ANGULAR, -]; - -const installStorybook = async ( - projectType: Project, - packageManager: JsPackageManager, - options: CommandOptions -): Promise => { - const npmOptions: NpmOptions = { - type: 'devDependencies', - skipInstall: options.skipInstall, - }; - - const language = await detectLanguage(packageManager as any); - - // TODO: Evaluate if this is correct after removing pnp compatibility code in SB11 - const pnp = await detectPnp(); - if (pnp) { - deprecate(dedent` - As of Storybook 10.0, PnP is deprecated. - If you are using PnP, you can continue to use Storybook 10.0, but we recommend migrating to a different package manager or linker-mode. - - In future versions, PnP compatibility will be removed. - `); - } - - const generatorOptions: GeneratorOptions = { - language, - builder: options.builder as Builder, - linkable: !!options.linkable, - pnp: pnp || (options.usePnp as boolean), - yes: options.yes as boolean, - projectType, - features: options.features || [], - }; - - const runGenerator: () => Promise = async () => { - switch (projectType) { - case ProjectType.REACT_SCRIPTS: - return reactScriptsGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Create React App" based project') - ); - - case ProjectType.REACT: - return reactGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "React" app') - ); - - case ProjectType.REACT_NATIVE: { - return reactNativeGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "React Native" app') - ); - } - - case ProjectType.REACT_NATIVE_WEB: { - return reactNativeWebGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "React Native" app') - ); - } - - case ProjectType.REACT_NATIVE_AND_RNW: { - commandLog('Adding Storybook support to your "React Native" app'); - await reactNativeGenerator(packageManager, npmOptions, generatorOptions); - return reactNativeWebGenerator(packageManager, npmOptions, generatorOptions); - } - - case ProjectType.QWIK: { - return qwikGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Qwik" app') - ); - } - - case ProjectType.WEBPACK_REACT: - return webpackReactGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Webpack React" app') - ); - - case ProjectType.REACT_PROJECT: - return reactGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "React" library') - ); - - case ProjectType.NEXTJS: - return nextjsGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Next" app') - ); - - case ProjectType.VUE3: - return vue3Generator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Vue 3" app') - ); - - case ProjectType.NUXT: - return nuxtGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Nuxt" app') - ); - - case ProjectType.ANGULAR: - commandLog('Adding Storybook support to your "Angular" app'); - return angularGenerator(packageManager, npmOptions, generatorOptions, options); - - case ProjectType.EMBER: - return emberGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Ember" app') - ); - - case ProjectType.HTML: - return htmlGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "HTML" app') - ); - - case ProjectType.WEB_COMPONENTS: - return webComponentsGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "web components" app') - ); - - case ProjectType.PREACT: - return preactGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Preact" app') - ); - - case ProjectType.SVELTE: - return svelteGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Svelte" app') - ); - - case ProjectType.SVELTEKIT: - return svelteKitGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "SvelteKit" app') - ); - - case ProjectType.SERVER: - return serverGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "Server" app') - ); - - case ProjectType.NX: - throw new NxProjectDetectedError(); - - case ProjectType.SOLID: - return solidGenerator(packageManager, npmOptions, generatorOptions).then( - commandLog('Adding Storybook support to your "SolidJS" app') - ); - - case ProjectType.UNSUPPORTED: - paddedLog(`We detected a project type that we don't support yet.`); - paddedLog( - `If you'd like your framework to be supported, please let use know about it at https://github.com/storybookjs/storybook/issues` - ); - - // Add a new line for the clear visibility. - logger.log(''); - - return Promise.resolve(); - - default: - paddedLog(`We couldn't detect your project type. (code: ${projectType})`); - paddedLog( - 'You can specify a project type explicitly via `storybook init --type `, see our docs on how to configure Storybook for your framework: https://storybook.js.org/docs/get-started/install' - ); - - // Add a new line for the clear visibility. - logger.log(''); - - return projectTypeInquirer(options, packageManager); - } - }; - - try { - return await runGenerator(); - } catch (err: any) { - if (err?.message !== 'Canceled by the user' && err?.stack) { - logger.error(`\n ${picocolors.red(err.stack)}`); - } - throw new HandledError(err); - } -}; - -const projectTypeInquirer = async ( - options: CommandOptions & { yes?: boolean }, - packageManager: JsPackageManager -) => { - const manualAnswer = options.yes - ? true - : await prompts([ - { - type: 'confirm', - name: 'manual', - message: 'Do you want to manually choose a Storybook project type to install?', - initial: true, - }, - ]); - - if (manualAnswer !== true && manualAnswer.manual) { - const { manualFramework } = await prompts([ - { - type: 'select', - name: 'manualFramework', - message: 'Please choose a project type from the following list:', - choices: installableProjectTypes.map((type) => ({ - title: type, - value: type.toUpperCase(), - })), - }, - ]); +import { logTracker, logger } from 'storybook/internal/node-logger'; +import { ErrorCollector } from 'storybook/internal/telemetry'; - if (manualFramework) { - return installStorybook(manualFramework, packageManager, options); - } - } - - logger.log(''); - logger.log('For more information about installing Storybook: https://storybook.js.org/docs'); - process.exit(0); -}; - -interface PromptOptions { - skipPrompt?: boolean; - disableTelemetry?: boolean; - settings: Settings; - projectType?: ProjectType; -} - -type InstallType = 'recommended' | 'light'; - -/** - * Prompt the user whether they are a new user and whether to include onboarding. Return whether or - * not this is a new user. - * - * ``` - * New to Storybook? - * > Yes: Help me with onboarding - * No: Skip onboarding & don't ask me again - * ``` - */ -export const promptNewUser = async ({ - settings, - skipPrompt, - disableTelemetry, -}: PromptOptions): Promise => { - const { skipOnboarding } = settings.value.init || {}; - - if (!skipPrompt && !skipOnboarding) { - const { newUser } = await prompts({ - type: 'select', - name: 'newUser', - message: 'New to Storybook?', - choices: [ - { - title: `${picocolors.bold('Yes:')} Help me with onboarding`, - value: true, - }, - { - title: `${picocolors.bold('No:')} Skip onboarding & don't ask again`, - value: false, - }, - ], - }); - - if (typeof newUser === 'undefined') { - return newUser; - } - - settings.value.init ||= {}; - settings.value.init.skipOnboarding = !newUser; - } else { - // true if new user and not interactive, false if interactive - settings.value.init ||= {}; - settings.value.init.skipOnboarding = !!skipOnboarding; - } - - const newUser = !settings.value.init.skipOnboarding; - if (!disableTelemetry) { - await telemetry('init-step', { - step: 'new-user-check', - newUser, - }); - } - - return newUser; -}; +import { + executeAddonConfiguration, + executeDependencyInstallation, + executeFinalization, + executeFrameworkDetection, + executeGeneratorExecution, + executePreflightCheck, + executeProjectDetection, + executeUserPreferences, +} from './commands'; +import { DependencyCollector } from './dependency-collector'; +import { registerAllGenerators } from './generators'; +import type { CommandOptions } from './generators/types'; +import { FeatureCompatibilityService } from './services/FeatureCompatibilityService'; +import { TelemetryService } from './services/TelemetryService'; /** - * Prompt the user to choose the configuration to install. + * Main entry point for Storybook initialization * - * ``` - * What configuration should we install? - * > Recommended: Component dev, docs, test - * Minimal: Dev only - * ``` + * This is a clean, command-based orchestration that replaces the monolithic 986-line implementation + * with a modular, testable approach. */ -export const promptInstallType = async ({ - skipPrompt, - disableTelemetry, - projectType, -}: PromptOptions): Promise => { - let installType = 'recommended' as InstallType; - if (!skipPrompt && projectType !== ProjectType.REACT_NATIVE) { - const { configuration } = await prompts({ - type: 'select', - name: 'configuration', - message: 'What configuration should we install?', - choices: [ - { - title: `${picocolors.bold('Recommended:')} Component dev, docs, test`, - value: 'recommended', - }, - { - title: `${picocolors.bold('Minimal:')} Component dev only`, - value: 'light', - }, - ], - }); - if (typeof configuration === 'undefined') { - return configuration; - } - installType = configuration; - } - if (!disableTelemetry) { - await telemetry('init-step', { step: 'install-type', installType }); - } - return installType; -}; - -export function getStorybookVersionFromAncestry( - ancestry: ReturnType -): string | undefined { - for (const ancestor of ancestry.toReversed()) { - const match = ancestor.command?.match(/\s(?:create-storybook|storybook)@([^\s]+)/); - if (match) { - return match[1]; - } - } - return undefined; -} - -export function getCliIntegrationFromAncestry( - ancestry: ReturnType -): string | undefined { - for (const ancestor of ancestry.toReversed()) { - const match = ancestor.command?.match(/\s(sv(@[^ ]+)? create|sv(@[^ ]+)? add)/i); - if (match) { - return match[1].includes('add') ? 'sv add' : 'sv create'; - } - } - return undefined; -} - export async function doInitiate(options: CommandOptions): Promise< | { shouldRunDev: true; shouldOnboard: boolean; projectType: ProjectType; packageManager: JsPackageManager; - storybookCommand: string; + storybookCommand?: string | null; } | { shouldRunDev: false } > { - const { packageManager: pkgMgr } = options; - - const isEmptyDirProject = options.force !== true && currentDirectoryIsEmpty(); - let packageManagerType = JsPackageManagerFactory.getPackageManagerType(); - - // Check if the current directory is empty. - if (isEmptyDirProject) { - // Initializing Storybook in an empty directory with yarn1 - // will very likely fail due to different kinds of hoisting issues - // which doesn't get fixed anymore in yarn1. - // We will fallback to npm in this case. - if (packageManagerType === 'yarn1') { - packageManagerType = 'npm'; - } - - // Prompt the user to create a new project from our list. - await scaffoldNewProject(packageManagerType, options); - invalidateProjectRootCache(); - } + // Initialize services + const telemetryService = new TelemetryService(options.disableTelemetry); - const packageManager = JsPackageManagerFactory.getPackageManager({ - force: pkgMgr, - }); - - if (!options.skipInstall) { - await packageManager.installDependencies(); - } + // Register all framework generators + registerAllGenerators(); - const latestVersion = (await packageManager.latestVersion('storybook'))!; - const currentVersion = versions.storybook; - const isPrerelease = prerelease(currentVersion); - const isOutdated = lt(currentVersion, latestVersion); - const borderColor = isOutdated ? '#FC521F' : '#F1618C'; - let versionSpecifier = undefined; - let cliIntegration = undefined; - try { - const ancestry = getProcessAncestry(); - versionSpecifier = getStorybookVersionFromAncestry(ancestry); - cliIntegration = getCliIntegrationFromAncestry(ancestry); - } catch (err) { - // - } + let dependencyCollector: DependencyCollector | null = new DependencyCollector(); - const messages = { - welcome: `Adding Storybook version ${picocolors.bold(currentVersion)} to your project..`, - notLatest: picocolors.red(dedent` - This version is behind the latest release, which is: ${picocolors.bold(latestVersion)}! - You likely ran the init command through npx, which can use a locally cached version, to get the latest please run: - ${picocolors.bold('npx storybook@latest init')} + // Step 1: Run preflight checks + const { packageManager } = await executePreflightCheck(options); - You may want to CTRL+C to stop, and run with the latest version instead. - `), - prelease: picocolors.yellow('This is a pre-release version.'), - }; + // Step 2: Detect project type + const { projectType, language } = await executeProjectDetection(packageManager, options); - logger.log( - boxen( - [messages.welcome] - .concat(isOutdated && !isPrerelease ? [messages.notLatest] : []) - .concat(isPrerelease ? [messages.prelease] : []) - .join('\n'), - { borderStyle: 'round', padding: 1, borderColor } - ) + // Step 3: Detect framework, renderer, and builder + const { framework, builder, renderer } = await executeFrameworkDetection( + projectType, + packageManager, + options ); - const isInteractive = process.stdout.isTTY && !isCI(); - - const settings = await globalSettings(); - const promptOptions = { - ...options, - settings, - skipPrompt: !isInteractive || options.yes, - projectType: options.type, - }; - const newUser = await promptNewUser(promptOptions); - - try { - await settings.save(); - } catch (err) { - logger.warn(`Failed to save user settings: ${err}`); - } - - if (typeof newUser === 'undefined') { - logger.log('canceling'); - process.exit(0); - } - - let installType = 'recommended' as InstallType; - if (!newUser) { - const install = await promptInstallType(promptOptions); - if (typeof install === 'undefined') { - logger.log('canceling'); - process.exit(0); - } - installType = install; - } - - let selectedFeatures = new Set(options.features || []); - if (installType === 'recommended') { - selectedFeatures.add('docs'); - // Don't install in CI but install in non-TTY environments like agentic installs - if (!isCI()) { - selectedFeatures.add('test'); - } - if (newUser) { - selectedFeatures.add('onboarding'); - } - } - - const telemetryFeatures = { - dev: true, - docs: selectedFeatures.has('docs'), - test: selectedFeatures.has('test'), - onboarding: selectedFeatures.has('onboarding'), - }; - - let projectType: ProjectType; - const projectTypeProvided = options.type; - const infoText = projectTypeProvided - ? `Installing Storybook for user specified project type: ${projectTypeProvided}` - : 'Detecting project type'; - const done = commandLog(infoText); - - if (projectTypeProvided) { - if (installableProjectTypes.includes(projectTypeProvided)) { - projectType = projectTypeProvided.toUpperCase() as ProjectType; - } else { - done(`The provided project type was not recognized by Storybook: ${projectTypeProvided}`); - logger.log(`\nThe project types currently supported by Storybook are:\n`); - installableProjectTypes.sort().forEach((framework) => paddedLog(`- ${framework}`)); - logger.log(''); - throw new HandledError(`Unknown project type supplied: ${projectTypeProvided}`); - } - } else { - try { - projectType = (await detect(packageManager as any, options)) as ProjectType; - - if (projectType === ProjectType.REACT_NATIVE && !options.yes) { - const { manualType } = await prompts({ - type: 'select', - name: 'manualType', - message: "We've detected a React Native project. Install:", - choices: [ - { - title: `${picocolors.bold('React Native')}: Storybook on your device/simulator`, - value: ProjectType.REACT_NATIVE, - }, - { - title: `${picocolors.bold('React Native Web')}: Storybook on web for docs, test, and sharing`, - value: ProjectType.REACT_NATIVE_WEB, - }, - { - title: `${picocolors.bold('Both')}: Add both native and web Storybooks`, - value: ProjectType.REACT_NATIVE_AND_RNW, - }, - ], - }); - projectType = manualType; - } - } catch (err) { - console.log(err); - done(String(err)); - throw new HandledError(err); - } - } - done(); - - const storybookInstantiated = isStorybookInstantiated(); - - if (options.force === false && storybookInstantiated && projectType !== ProjectType.ANGULAR) { - logger.log(''); - const { force } = await prompts([ - { - type: 'confirm', - name: 'force', - message: - 'We found a .storybook config directory in your project. Therefore we assume that Storybook is already instantiated for your project. Do you still want to continue and force the initialization?', - }, - ]); - logger.log(''); - - if (force) { - options.force = true; - } else { - process.exit(0); - } - } - - if (selectedFeatures.has('test')) { - const packageVersionsData = await packageVersions.condition({ packageManager }, {} as any); - if (packageVersionsData.type === 'incompatible') { - const { ignorePackageVersions } = isInteractive - ? await prompts([ - { - type: 'confirm', - name: 'ignorePackageVersions', - message: dedent` - ${packageVersionsData.reasons.join('\n')} - Do you want to continue without Storybook's testing features? - `, - }, - ]) - : { ignorePackageVersions: true }; - if (ignorePackageVersions) { - selectedFeatures.delete('test'); - } else { - process.exit(0); - } - } - - const vitestConfigFilesData = await vitestConfigFiles.condition( - { babel, empathic: find, fs } as any, - { directory: process.cwd() } as any - ); - if (vitestConfigFilesData.type === 'incompatible') { - const { ignoreVitestConfigFiles } = isInteractive - ? await prompts([ - { - type: 'confirm', - name: 'ignoreVitestConfigFiles', - message: dedent` - ${vitestConfigFilesData.reasons.join('\n')} - Do you want to continue without Storybook's testing features? - `, - }, - ]) - : { ignoreVitestConfigFiles: true }; - if (ignoreVitestConfigFiles) { - selectedFeatures.delete('test'); - } else { - process.exit(0); - } - } - } - - if (selectedFeatures.has('onboarding') && !ONBOARDING_PROJECT_TYPES.includes(projectType)) { - selectedFeatures.delete('onboarding'); - } - - // Update the options object with the selected features before passing it down to the generator - options.features = Array.from(selectedFeatures); - - const installResult = await installStorybook(projectType as ProjectType, packageManager, options); - - // Sync features back because they may have been mutated by the generator (e.g. in case of undetected project type) - selectedFeatures = new Set(options.features); + // Step 4: Get user preferences and feature selections (with framework/builder for validation) + const { newUser, selectedFeatures } = await executeUserPreferences(packageManager, { + options, + framework, + builder, + projectType, + }); - if (!options.skipInstall) { - await packageManager.installDependencies(); - } + // Step 5: Execute generator with dependency collector (now with frameworkInfo) - if (!options.disableTelemetry) { - await telemetry('init', { + const { configDir, storybookCommand, shouldRunDev, extraAddons } = + await executeGeneratorExecution({ projectType, - features: telemetryFeatures, - newUser, - versionSpecifier, - cliIntegration, + packageManager, + frameworkInfo: { builder, framework, renderer }, + options, + dependencyCollector, + selectedFeatures, + language, }); - } - - if ([ProjectType.REACT_NATIVE, ProjectType.REACT_NATIVE_AND_RNW].includes(projectType)) { - logger.log(dedent` - ${picocolors.yellow('React Native (RN) Storybook installation is not 100% automated.')} - - To run RN Storybook, you will need to: - - 1. Replace the contents of your app entry with the following - - ${picocolors.inverse(' ' + "export {default} from './.rnstorybook';" + ' ')} - - 2. Wrap your metro config with the withStorybook enhancer function like this: - - ${picocolors.inverse(' ' + "const { withStorybook } = require('@storybook/react-native/metro/withStorybook');" + ' ')} - ${picocolors.inverse(' ' + 'module.exports = withStorybook(defaultConfig);' + ' ')} - - For more details go to: - ${picocolors.cyan('https://github.com/storybookjs/react-native#getting-started')} - - Then to start RN Storybook, run: - - ${picocolors.inverse(' ' + packageManager.getRunCommand('start') + ' ')} - `); - - if (projectType === ProjectType.REACT_NATIVE_AND_RNW) { - logger.log(dedent` - - ${picocolors.yellow('React Native Web (RNW) Storybook is fully installed.')} - - To start RNW Storybook, run: - - ${picocolors.inverse(' ' + packageManager.getRunCommand('storybook') + ' ')} - `); - } - return { shouldRunDev: false }; - } - - const foundGitIgnoreFile = find.up('.gitignore'); - const rootDirectory = getProjectRoot(); - if (foundGitIgnoreFile && foundGitIgnoreFile.includes(rootDirectory)) { - const contents = await fs.readFile(foundGitIgnoreFile, 'utf-8'); - const hasStorybookLog = contents.includes('*storybook.log'); - const hasStorybookStatic = contents.includes('storybook-static'); - const linesToAdd = [ - !hasStorybookLog ? '*storybook.log' : '', - !hasStorybookStatic ? 'storybook-static' : '', - ] - .filter(Boolean) - .join('\n'); - if (linesToAdd) { - await fs.appendFile(foundGitIgnoreFile, `\n${linesToAdd}\n`); - } - } - - const storybookCommand = - projectType === ProjectType.ANGULAR - ? `ng run ${installResult.projectName}:storybook` - : packageManager.getRunCommand('storybook'); - - if (selectedFeatures.has('test')) { - const flags = ['--yes', options.skipInstall && '--skip-install'].filter(Boolean).join(' '); - logger.log( - `> npx storybook@${versions.storybook} add ${flags} @storybook/addon-a11y@${versions['@storybook/addon-a11y']}` - ); - execSync( - `npx storybook@${versions.storybook} add ${flags} @storybook/addon-a11y@${versions['@storybook/addon-a11y']}`, - { cwd: process.cwd(), stdio: 'inherit' } - ); - logger.log( - `> npx storybook@${versions.storybook} add ${flags} @storybook/addon-vitest@${versions['@storybook/addon-vitest']}` - ); - execSync( - `npx storybook@${versions.storybook} add ${flags} @storybook/addon-vitest@${versions['@storybook/addon-vitest']}`, - { cwd: process.cwd(), stdio: 'inherit' } - ); - } + // Step 6: Install all dependencies in a single operation + const dependencyInstallationResult = await executeDependencyInstallation({ + packageManager, + dependencyCollector, + skipInstall: !!options.skipInstall, + selectedFeatures, + }); - const printFeatures = (features: Set) => - Array.from(features).join(', ') || 'none'; + // After dependencies are installed, we must not use the dependency collector anymore + dependencyCollector = null; - logger.log( - boxen( - dedent` - Storybook was successfully installed in your project! 🎉 - Additional features: ${printFeatures(selectedFeatures)} + // Step 7: Configure addons (run postinstall scripts for configuration only) + await executeAddonConfiguration({ + packageManager, + addons: extraAddons, + configDir, + dependencyInstallationResult, + options, + }); - To run Storybook manually, run ${picocolors.yellow( - picocolors.bold(storybookCommand) - )}. CTRL+C to stop. + // Step 8: Print final summary + await executeFinalization({ + logfile: options.logfile, + storybookCommand, + }); - Wanna know more about Storybook? Check out ${picocolors.cyan('https://storybook.js.org/')} - Having trouble or want to chat? Join us at ${picocolors.cyan( - 'https://discord.gg/storybook/' - )} - `, - { borderStyle: 'round', padding: 1, borderColor: '#F1618C' } - ) - ); + // Step 9: Track telemetry + await telemetryService.trackInitWithContext(projectType, selectedFeatures, newUser); return { - shouldRunDev: !!options.dev && !options.skipInstall, + shouldRunDev: + !!options.dev && + !options.skipInstall && + shouldRunDev !== false && + ErrorCollector.getErrors().length === 0, shouldOnboard: newUser, projectType, packageManager, @@ -798,6 +120,19 @@ export async function doInitiate(options: CommandOptions): Promise< }; } +const handleCommandFailure = async (logFilePath: string | boolean | undefined): Promise => { + const logFile = await logTracker.writeToFile(logFilePath); + logger.error('Storybook encountered an error during initialization'); + logger.log(`Storybook debug logs can be found at: ${logFile}`); + logger.outro('Storybook exited with an error'); + process.exit(1); +}; + +// cli command -> ctrl c -> exit 0 +// process.on('SIGINT', () => { +// }) + +/** Main initiate function with telemetry wrapper */ export async function initiate(options: CommandOptions): Promise { const initiateResult = await withTelemetry( 'init', @@ -805,8 +140,16 @@ export async function initiate(options: CommandOptions): Promise { cliOptions: options, printError: (err) => !err.handled && logger.error(err), }, - () => doInitiate(options) - ); + async () => { + const result = await doInitiate(options); + + logger.outro(''); + + return result; + } + ).catch(() => { + handleCommandFailure(options.logfile); + }); if (initiateResult?.shouldRunDev) { await runStorybookDev(initiateResult); @@ -817,7 +160,7 @@ export async function initiate(options: CommandOptions): Promise { async function runStorybookDev(result: { projectType: ProjectType; packageManager: JsPackageManager; - storybookCommand?: string; + storybookCommand?: string | null; shouldOnboard: boolean; }): Promise { const { projectType, packageManager, storybookCommand, shouldOnboard } = result; @@ -827,18 +170,14 @@ async function runStorybookDev(result: { } try { - const supportsOnboarding = [ - ProjectType.REACT_SCRIPTS, - ProjectType.REACT, - ProjectType.WEBPACK_REACT, - ProjectType.REACT_PROJECT, - ProjectType.NEXTJS, - ProjectType.VUE3, - ProjectType.ANGULAR, - ].includes(projectType); + const supportsOnboarding = FeatureCompatibilityService.supportsOnboarding(projectType); const flags = []; + if (packageManager.type === 'npm') { + flags.push('--silent'); + } + // npm needs extra -- to pass flags to the command // in the case of Angular, we are calling `ng run` which doesn't need the extra `--` if (packageManager.type === 'npm' && projectType !== ProjectType.ANGULAR) { @@ -854,8 +193,10 @@ async function runStorybookDev(result: { // instead of calling 'dev' automatically, we spawn a subprocess so that it gets // executed directly in the user's project directory. This avoid potential issues // with packages running in npxs' node_modules - logger.log('\nRunning Storybook'); - execa.command(`${storybookCommand} ${flags.join(' ')}`, { + const [command, ...args] = [...storybookCommand.split(' '), ...flags]; + executeCommand({ + command: command, + args, stdio: 'inherit', }); } catch { diff --git a/code/lib/create-storybook/src/ink/steps/checks/Check.tsx b/code/lib/create-storybook/src/ink/steps/checks/Check.tsx deleted file mode 100644 index 3631cccba859..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/Check.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { ContextType } from 'react'; - -import type { State } from '..'; -import type { AppContext } from '../../utils/context'; -import type { CompatibilityResult } from './CompatibilityType'; - -export interface Check { - condition: ( - context: ContextType, - state: State - ) => Promise; -} diff --git a/code/lib/create-storybook/src/ink/steps/checks/CompatibilityType.tsx b/code/lib/create-storybook/src/ink/steps/checks/CompatibilityType.tsx deleted file mode 100644 index e63cfa65cfe7..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/CompatibilityType.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const CompatibilityType = { - LOADING: 'loading' as const, - IGNORED: 'ignored' as const, - COMPATIBLE: 'compatible' as const, - INCOMPATIBLE: 'incompatible' as const, -}; - -export type CompatibilityResult = - | { type: typeof CompatibilityType.LOADING } - | { type: typeof CompatibilityType.IGNORED } - | { type: typeof CompatibilityType.COMPATIBLE } - | { type: typeof CompatibilityType.INCOMPATIBLE; reasons: string[] }; diff --git a/code/lib/create-storybook/src/ink/steps/checks/configDir.tsx b/code/lib/create-storybook/src/ink/steps/checks/configDir.tsx deleted file mode 100644 index b4c92ade4e82..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/configDir.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as fs from 'node:fs/promises'; -import path from 'node:path'; - -import { type Check } from './Check'; -import { CompatibilityType } from './CompatibilityType'; - -const configPath = '.storybook'; - -/** - * When configDir already exists, prompt: - * - * - Yes -> overwrite (delete) - * - No -> exit - */ -const name = '.storybook directory'; -export const configDir: Check = { - condition: async (context, state) => { - return fs - .stat(path.join(state.directory, configPath)) - .then(() => ({ - type: CompatibilityType.INCOMPATIBLE, - reasons: ['exists'], - })) - .catch(() => ({ type: CompatibilityType.COMPATIBLE })); - - return { - type: CompatibilityType.INCOMPATIBLE, - reasons: ['bad context'], - }; - }, -}; diff --git a/code/lib/create-storybook/src/ink/steps/checks/frameworkPackage.tsx b/code/lib/create-storybook/src/ink/steps/checks/frameworkPackage.tsx deleted file mode 100644 index b27ad60fc397..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/frameworkPackage.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { type Check } from './Check'; -import { CompatibilityType } from './CompatibilityType'; - -/** - * Check for presence of nextjs when using @storybook/nextjs, prompt if there's a mismatch - * - * - Yes -> continue - * - No -> exit - */ -const name = 'Framework package'; -export const frameworkPackage: Check = { - condition: async (context, state) => { - if (state.framework !== 'nextjs') { - return { type: CompatibilityType.COMPATIBLE }; - } - if (context.packageManager) { - const packageManager = context.packageManager; - const nextJsVersionSpecifier = await packageManager.getInstalledVersion('next'); - - return nextJsVersionSpecifier - ? { type: CompatibilityType.COMPATIBLE } - : { type: CompatibilityType.INCOMPATIBLE, reasons: ['Missing nextjs dependency'] }; - } - return { - type: CompatibilityType.INCOMPATIBLE, - reasons: ['Missing JsPackageManagerFactory on context'], - }; - }, -}; diff --git a/code/lib/create-storybook/src/ink/steps/checks/frameworkTest.tsx b/code/lib/create-storybook/src/ink/steps/checks/frameworkTest.tsx deleted file mode 100644 index 72acfe44a540..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/frameworkTest.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Framework } from '../../../bin/modernInputs'; -import { supportedFrameworksNames } from '../../../bin/modernInputs'; -import { type Check } from './Check'; -import { CompatibilityType } from './CompatibilityType'; - -const FOUND_NEXTJS = `Found Next.js with test intent`; - -export const SUPPORTED_FRAMEWORKS: Framework[] = [ - 'react-vite', - 'vue3-vite', - 'html-vite', - 'preact-vite', - 'svelte-vite', - 'web-components-vite', - 'nextjs', - 'nextjs-vite', - 'sveltekit', -]; - -/** - * When selecting framework nextjs & intent includes test, prompt for nextjs-vite. When selecting - * another framework that doesn't support test addon, prompt for ignoring test intent. - */ -const name = 'Framework test compatibility'; -export const frameworkTest: Check = { - condition: async (context, state) => { - if ( - !state.features || - !state.features.includes('test') || - SUPPORTED_FRAMEWORKS.includes(state.framework) - ) { - return { type: CompatibilityType.COMPATIBLE }; - } - return { - type: CompatibilityType.INCOMPATIBLE, - reasons: - state.framework === 'nextjs' - ? [FOUND_NEXTJS] - : [`Found ${supportedFrameworksNames[state.framework]} with test intent`], - }; - }, -}; diff --git a/code/lib/create-storybook/src/ink/steps/checks/index.tsx b/code/lib/create-storybook/src/ink/steps/checks/index.tsx deleted file mode 100644 index bfa511a31a6e..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Check } from './Check'; -import { configDir } from './configDir'; -import { frameworkPackage } from './frameworkPackage'; -import { frameworkTest } from './frameworkTest'; -import { packageVersions } from './packageVersions'; -import { vitestConfigFiles } from './vitestConfigFiles'; - -export const checks = { - configDir, - frameworkPackage, - frameworkTest, - packageVersions, - vitestConfigFiles, -} satisfies Record; diff --git a/code/lib/create-storybook/src/ink/steps/checks/packageVersions.tsx b/code/lib/create-storybook/src/ink/steps/checks/packageVersions.tsx deleted file mode 100644 index 18b85477b0da..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/packageVersions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { coerce, satisfies } from 'semver'; - -import { type Check } from './Check'; -import { CompatibilityType } from './CompatibilityType'; - -/** - * Detect existing Vitest/MSW version, if mismatch prompt for ignoring test intent - * - * - Yes -> ignore test intent - * - No -> exit - */ -const name = 'Vitest and MSW compatibility'; -export const packageVersions: Check = { - condition: async (context) => { - if (context.packageManager) { - const reasons = []; - const packageManager = context.packageManager; - - const vitestVersionSpecifier = await packageManager.getInstalledVersion('vitest'); - const coercedVitestVersion = vitestVersionSpecifier ? coerce(vitestVersionSpecifier) : null; - if (coercedVitestVersion && !satisfies(coercedVitestVersion, '>=2.1.0')) { - reasons.push(`Vitest >=2.1.0 is required, found ${coercedVitestVersion}`); - } - - const mswVersionSpecifier = await packageManager.getInstalledVersion('msw'); - const coercedMswVersion = mswVersionSpecifier ? coerce(mswVersionSpecifier) : null; - if (coercedMswVersion && !satisfies(coercedMswVersion, '>=2.0.0')) { - reasons.push(`Mock Service Worker (msw) >=2.0.0 is required, found ${coercedMswVersion}`); - } - - return reasons.length - ? { type: CompatibilityType.INCOMPATIBLE, reasons } - : { type: CompatibilityType.COMPATIBLE }; - } - return { - type: CompatibilityType.INCOMPATIBLE, - reasons: ['Missing packageManager or JsPackageManagerFactory on context'], - }; - }, -}; diff --git a/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.test.ts b/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.test.ts deleted file mode 100644 index 9ee937954270..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import * as find from 'empathic/find'; - -import { vitestConfigFiles } from './vitestConfigFiles'; - -vi.mock('empathic/find', () => ({ - any: vi.fn(), -})); - -const fileMocks = { - 'vitest.config.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig({}) - `, - 'vitest.merge-config.ts': ` - import { mergeConfig, defineConfig } from 'vitest/config' - import viteConfig from './vite.config' - - export default mergeConfig( - viteConfig, - defineConfig({ - test: { - environment: 'jsdom', - }, - }), - ) - `, - 'vitest.merge-config-multi-args.ts': ` - import { mergeConfig, defineConfig } from 'vitest/config' - import viteConfig from './vite.config' - - export default mergeConfig( - viteConfig, - {}, - defineConfig({ - test: { - environment: 'jsdom', - }, - }), - ) - `, - 'invalidConfig.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig(['packages/*']) - `, - 'testConfig.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig({ - test: { - coverage: { - provider: 'istanbul' - }, - }, - }) - `, - 'testConfig-invalid.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig({ - test: true, - }) - `, - 'workspaceConfig.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig({ - test: { - workspace: ['packages/*'], - }, - }) - `, - 'workspaceConfig-invalid.ts': ` - import { defineConfig } from 'vitest/config' - export default defineConfig({ - test: { - workspace: { "test": "packages/*" }, - }, - }) - `, - 'vitest.workspace.json': ` - ["packages/*"] - `, - 'vitest.workspace.ts': ` - export default ['packages/*'] - `, - 'invalidWorkspace.ts': ` - export default { "test": "packages/*" } - `, - 'defineWorkspace.ts': ` - import { defineWorkspace } from 'vitest/config' - export default defineWorkspace(['packages/*']) - `, - 'defineWorkspace-invalid.ts': ` - import { defineWorkspace } from 'vitest/config' - export default defineWorkspace({ "test": "packages/*" }) - `, -}; - -vi.mock(import('node:fs/promises'), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readFile: vi - .fn() - .mockImplementation((filePath) => fileMocks[filePath as keyof typeof fileMocks]), - }; -}); - -const mockContext: any = {}; - -const coerce = - (from: string, to: string) => - ([name]: string[]) => - name.includes(from) ? to : name; - -const state: any = { - directory: '.', -}; - -describe('vitestConfigFiles', () => { - it('should run properly with mock dependencies', async () => { - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ type: 'compatible' }); - }); - - describe('Check Vitest workspace files', () => { - it('should disallow JSON workspace file', async () => { - vi.mocked(find.any).mockImplementation(coerce('workspace', 'vitest.workspace.json')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Cannot auto-update JSON workspace file: vitest.workspace.json'], - }); - }); - - it('should disallow invalid workspace file', async () => { - vi.mocked(find.any).mockImplementation(coerce('workspace', 'invalidWorkspace.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Found an invalid workspace config file: invalidWorkspace.ts'], - }); - }); - - it('should allow defineWorkspace syntax', async () => { - vi.mocked(find.any).mockImplementation(coerce('workspace', 'defineWorkspace.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'compatible', - }); - }); - - it('should disallow invalid defineWorkspace syntax', async () => { - vi.mocked(find.any).mockImplementation(coerce('workspace', 'defineWorkspace-invalid.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Found an invalid workspace config file: defineWorkspace-invalid.ts'], - }); - }); - }); - - describe('Check Vitest config files', () => { - it('should disallow CommonJS config file', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'vitest.config.cjs')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Cannot auto-update CommonJS config file: vitest.config.cjs'], - }); - }); - - it('should disallow invalid config file', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'invalidConfig.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Found an invalid Vitest config file: invalidConfig.ts'], - }); - }); - - it('should allow existing test config option', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'testConfig.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'compatible', - }); - }); - - it('should allow existing test config option with mergeConfig', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'vitest.merge-config.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'compatible', - }); - }); - - it('should allow existing test config option with mergeConfig and defineConfig as argument in a different position', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'vitest.merge-config-multi-args.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'compatible', - }); - }); - - it('should disallow invalid test config option', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'testConfig-invalid.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Found an invalid Vitest config file: testConfig-invalid.ts'], - }); - }); - - it('should allow existing test.workspace config option', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'workspaceConfig.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'compatible', - }); - }); - - it('should disallow invalid test.workspace config option', async () => { - vi.mocked(find.any).mockImplementation(coerce('config', 'workspaceConfig-invalid.ts')); - const result = await vitestConfigFiles.condition(mockContext, state); - expect(result).toEqual({ - type: 'incompatible', - reasons: ['Found an invalid Vitest config file: workspaceConfig-invalid.ts'], - }); - }); - }); -}); diff --git a/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.tsx b/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.tsx deleted file mode 100644 index 613517eb3a35..000000000000 --- a/code/lib/create-storybook/src/ink/steps/checks/vitestConfigFiles.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import * as fs from 'node:fs/promises'; - -import * as babel from 'storybook/internal/babel'; -import { getProjectRoot } from 'storybook/internal/common'; - -import * as find from 'empathic/find'; - -import type { Check } from './Check'; -import { CompatibilityType } from './CompatibilityType'; - -interface Declaration { - type: string; -} -interface CallExpression extends Declaration { - type: 'CallExpression'; - callee: { type: 'Identifier'; name: string }; - arguments: Declaration[]; -} -interface ObjectExpression extends Declaration { - type: 'ObjectExpression'; - properties: { type: 'Property'; key: { name: string }; value: Declaration }[]; -} -interface ArrayExpression extends Declaration { - type: 'ArrayExpression'; - elements: any[]; -} -interface StringLiteral extends Declaration { - type: 'StringLiteral'; - value: any; -} - -const isCallExpression = (path: Declaration): path is CallExpression => - path?.type === 'CallExpression'; - -const isObjectExpression = (path: Declaration): path is ObjectExpression => - path?.type === 'ObjectExpression'; - -const isArrayExpression = (path: Declaration): path is ArrayExpression => - path?.type === 'ArrayExpression'; - -const isStringLiteral = (path: Declaration): path is StringLiteral => - path?.type === 'StringLiteral'; - -const isWorkspaceConfigArray = (path: Declaration) => - isArrayExpression(path) && - path?.elements.every((el: any) => isStringLiteral(el) || isObjectExpression(el)); - -const isDefineWorkspaceExpression = (path: Declaration) => - isCallExpression(path) && - path.callee.name === 'defineWorkspace' && - isWorkspaceConfigArray(path.arguments[0]); - -const isDefineConfigExpression = (path: Declaration) => - isCallExpression(path) && - path.callee.name === 'defineConfig' && - isObjectExpression(path.arguments[0]); - -const isMergeConfigExpression = (path: Declaration) => - isCallExpression(path) && path.callee.name === 'mergeConfig'; - -const isSafeToExtendWorkspace = (path: CallExpression) => - isCallExpression(path) && - path.arguments.length > 0 && - isObjectExpression(path.arguments[0]) && - path.arguments[0]?.properties.every( - (p) => - p.key.name !== 'test' || - (isObjectExpression(p.value) && - p.value.properties.every( - ({ key, value }) => key.name !== 'workspace' || isArrayExpression(value) - )) - ); - -export const isValidWorkspaceConfigFile: (fileContents: string, babel: any) => boolean = ( - fileContents -) => { - let isValidWorkspaceConfig = false; - const parsedFile = babel.babelParse(fileContents); - babel.traverse(parsedFile, { - ExportDefaultDeclaration(path: any) { - isValidWorkspaceConfig = - isWorkspaceConfigArray(path.node.declaration) || - isDefineWorkspaceExpression(path.node.declaration); - }, - }); - return isValidWorkspaceConfig; -}; - -/** - * Check if existing Vite/Vitest workspace/config file can be safely modified, if not prompt: - * - * - Yes -> ignore test intent - * - No -> exit - */ -export const vitestConfigFiles: Check = { - condition: async (_context, state) => { - const reasons = []; - - const projectRoot = getProjectRoot(); - - const vitestWorkspaceFile = find.any( - ['ts', 'js', 'json'].flatMap((ex) => [`vitest.workspace.${ex}`, `vitest.projects.${ex}`]), - { cwd: state.directory, last: projectRoot } - ); - if (vitestWorkspaceFile?.endsWith('.json')) { - reasons.push(`Cannot auto-update JSON workspace file: ${vitestWorkspaceFile}`); - } else if (vitestWorkspaceFile) { - const fileContents = await fs.readFile(vitestWorkspaceFile, 'utf8'); - if (!isValidWorkspaceConfigFile(fileContents, babel)) { - reasons.push(`Found an invalid workspace config file: ${vitestWorkspaceFile}`); - } - } - - const vitestConfigFile = find.any( - ['ts', 'js', 'tsx', 'jsx', 'cts', 'cjs', 'mts', 'mjs'].map((ex) => `vitest.config.${ex}`), - { cwd: state.directory, last: projectRoot } - ); - if (vitestConfigFile?.endsWith('.cts') || vitestConfigFile?.endsWith('.cjs')) { - reasons.push(`Cannot auto-update CommonJS config file: ${vitestConfigFile}`); - } else if (vitestConfigFile) { - let isValidVitestConfig = false; - const configContent = await fs.readFile(vitestConfigFile, 'utf8'); - const parsedConfig = babel.babelParse(configContent); - babel.traverse(parsedConfig, { - ExportDefaultDeclaration(path) { - if (isDefineConfigExpression(path.node.declaration)) { - isValidVitestConfig = isSafeToExtendWorkspace(path.node.declaration as CallExpression); - } else if (isMergeConfigExpression(path.node.declaration)) { - // the config could be anywhere in the mergeConfig call, so we need to check each argument - const mergeCall = path.node.declaration as CallExpression; - isValidVitestConfig = mergeCall.arguments.some((arg) => - isSafeToExtendWorkspace(arg as CallExpression) - ); - } - }, - }); - if (!isValidVitestConfig) { - reasons.push(`Found an invalid Vitest config file: ${vitestConfigFile}`); - } - } - - return reasons.length - ? { type: CompatibilityType.INCOMPATIBLE, reasons } - : { type: CompatibilityType.COMPATIBLE }; - }, -}; diff --git a/code/lib/create-storybook/src/ink/steps/index.tsx b/code/lib/create-storybook/src/ink/steps/index.tsx deleted file mode 100644 index ac4fb780fe92..000000000000 --- a/code/lib/create-storybook/src/ink/steps/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Framework } from '../../bin/modernInputs'; - -export type State = Omit< - { - features: string[]; - framework: Framework; - }, - 'width' | 'height' -> & { - directory: string; - version: 'latest' | 'outdated' | undefined; -}; diff --git a/code/lib/create-storybook/src/ink/utils/context.ts b/code/lib/create-storybook/src/ink/utils/context.ts deleted file mode 100644 index 5a6c71d11d30..000000000000 --- a/code/lib/create-storybook/src/ink/utils/context.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext } from 'react'; - -export const AppContext = createContext({ - packageManager: undefined as import('storybook/internal/common').JsPackageManager | undefined, -}); diff --git a/code/lib/create-storybook/src/scaffold-new-project.ts b/code/lib/create-storybook/src/scaffold-new-project.ts index bba45c83e278..11db471d014c 100644 --- a/code/lib/create-storybook/src/scaffold-new-project.ts +++ b/code/lib/create-storybook/src/scaffold-new-project.ts @@ -1,18 +1,11 @@ import { readdirSync } from 'node:fs'; import { rm } from 'node:fs/promises'; -import type { PackageManagerName } from 'storybook/internal/common'; -import { logger } from 'storybook/internal/node-logger'; +import { type PackageManagerName, executeCommand } from 'storybook/internal/common'; +import { logger, prompt } from 'storybook/internal/node-logger'; import { GenerateNewProjectOnInitError } from 'storybook/internal/server-errors'; import { telemetry } from 'storybook/internal/telemetry'; -import boxen from 'boxen'; -// eslint-disable-next-line depend/ban-dependencies -import execa from 'execa'; -import picocolors from 'picocolors'; -import prompts from 'prompts'; -import { dedent } from 'ts-dedent'; - import type { CommandOptions } from './generators/types'; type CoercedPackageManagerName = 'npm' | 'yarn' | 'pnpm'; @@ -104,7 +97,7 @@ const packageManagerToCoercedName = ( const buildProjectDisplayNameForPrint = ({ displayName }: SupportedProject) => { const { type, builder, language } = displayName; - return `${picocolors.bold(picocolors.blue(type))} ${builder ? `+ ${builder} ` : ''}(${language})`; + return `${type} ${builder ? `+ ${builder} ` : ''}(${language})`; }; /** @@ -118,27 +111,6 @@ export const scaffoldNewProject = async ( ) => { const packageManagerName = packageManagerToCoercedName(packageManager); - logger.plain( - boxen( - dedent` - Would you like to generate a new project from the following list? - - ${picocolors.bold('Note:')} - Storybook supports many more frameworks and bundlers than listed below. If you don't see your - preferred setup, you can still generate a project then rerun this command to add Storybook. - - ${picocolors.bold('Press ^C at any time to quit.')} - `, - { - title: picocolors.bold('🔎 Empty directory detected'), - padding: 1, - borderStyle: 'double', - borderColor: 'yellow', - } - ) - ); - logger.line(1); - let projectStrategy; if (process.env.STORYBOOK_INIT_EMPTY_TYPE) { @@ -146,31 +118,39 @@ export const scaffoldNewProject = async ( } if (!projectStrategy) { - const { project } = await prompts( - { - type: 'select', - name: 'project', - message: 'Choose a project template', - choices: Object.entries(SUPPORTED_PROJECTS).map(([key, value]) => ({ - title: buildProjectDisplayNameForPrint(value), + projectStrategy = await prompt.select({ + message: 'Empty directory detected:', + options: [ + ...Object.entries(SUPPORTED_PROJECTS).map(([key, value]) => ({ + label: buildProjectDisplayNameForPrint(value), value: key, })), - }, - { onCancel: () => process.exit(0) } - ); + { + label: 'Other', + value: 'other', + hint: 'To install Storybook on another framework, first generate a project with that framework and then rerun this command.', + }, + ], + }); + } - projectStrategy = project; + if (projectStrategy === 'other') { + logger.warn( + 'To install Storybook on another framework, first generate a project with that framework and then rerun this command.' + ); + logger.outro('Exiting...'); + process.exit(1); } const projectStrategyConfig = SUPPORTED_PROJECTS[projectStrategy]; const projectDisplayName = buildProjectDisplayNameForPrint(projectStrategyConfig); const createScript = projectStrategyConfig.createScript[packageManagerName]; - logger.line(1); - logger.plain( - `Creating a new "${projectDisplayName}" project with ${picocolors.bold(packageManagerName)}...` - ); - logger.line(1); + const spinner = prompt.spinner({ + id: 'create-new-project', + }); + + spinner.start(`Creating a new "${projectDisplayName}" project with ${packageManagerName}...`); const targetDir = process.cwd(); @@ -191,13 +171,17 @@ export const scaffoldNewProject = async ( try { // Create new project in temp directory - await execa.command(createScript, { - stdio: 'pipe', + spinner.message(`Executing ${createScript}`); + await executeCommand({ + command: createScript, shell: true, + stdio: 'pipe', cwd: targetDir, - cleanup: true, }); } catch (e) { + spinner.stop( + `Failed to create a new "${projectDisplayName}" project with ${packageManagerName}` + ); throw new GenerateNewProjectOnInitError({ error: e, packageManager: packageManagerName, @@ -205,31 +189,14 @@ export const scaffoldNewProject = async ( }); } + spinner.stop(`${projectDisplayName} project with ${packageManagerName} created successfully!`); + if (!disableTelemetry) { - telemetry('scaffolded-empty', { + await telemetry('scaffolded-empty', { packageManager: packageManagerName, projectType: projectStrategy, }); } - - logger.plain( - boxen( - dedent` - "${projectDisplayName}" project with ${picocolors.bold( - packageManagerName - )} created successfully! - - Continuing with Storybook installation... - `, - { - title: picocolors.bold('✅ Success!'), - padding: 1, - borderStyle: 'double', - borderColor: 'green', - } - ) - ); - logger.line(1); }; const FILES_TO_IGNORE = [ diff --git a/code/lib/create-storybook/src/services/AddonService.test.ts b/code/lib/create-storybook/src/services/AddonService.test.ts new file mode 100644 index 000000000000..55245f8f0afe --- /dev/null +++ b/code/lib/create-storybook/src/services/AddonService.test.ts @@ -0,0 +1,166 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Feature, SupportedBuilder } from 'storybook/internal/types'; + +import { AddonService } from './AddonService'; + +vi.mock('storybook/internal/common', async () => { + const actual = await vi.importActual('storybook/internal/common'); + return { + ...actual, + getPackageDetails: vi.fn().mockImplementation((pkg: string) => { + const match = pkg.match(/^(@?[^@]+)(?:@(.+))?$/); + return match ? [match[1], match[2]] : [pkg, undefined]; + }), + }; +}); + +describe('AddonService', () => { + let manager: AddonService; + + beforeEach(() => { + manager = new AddonService(); + }); + + describe('getWebpackCompilerAddon', () => { + it('should return undefined when no compiler function provided', () => { + const result = manager.getWebpackCompilerAddon(SupportedBuilder.WEBPACK5, undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined when compiler function returns undefined', () => { + const webpackCompiler = vi.fn().mockReturnValue(undefined); + const result = manager.getWebpackCompilerAddon(SupportedBuilder.WEBPACK5, webpackCompiler); + expect(result).toBeUndefined(); + }); + + it('should return swc compiler addon', () => { + const webpackCompiler = vi.fn().mockReturnValue('swc'); + const result = manager.getWebpackCompilerAddon(SupportedBuilder.WEBPACK5, webpackCompiler); + expect(result).toBe('@storybook/addon-webpack5-compiler-swc'); + }); + + it('should return babel compiler addon', () => { + const webpackCompiler = vi.fn().mockReturnValue('babel'); + const result = manager.getWebpackCompilerAddon(SupportedBuilder.WEBPACK5, webpackCompiler); + expect(result).toBe('@storybook/addon-webpack5-compiler-babel'); + }); + }); + + describe('getAddonsForFeatures', () => { + it('should return empty array for no features', () => { + const addons = manager.getAddonsForFeatures(new Set([])); + expect(addons).toEqual([]); + }); + + it('should add chromatic and vitest addons for test feature', () => { + const addons = manager.getAddonsForFeatures(new Set([Feature.TEST])); + expect(addons).toContain('@chromatic-com/storybook'); + expect(addons).toContain('@storybook/addon-vitest'); + }); + + it('should add docs addon for docs feature', () => { + const addons = manager.getAddonsForFeatures(new Set([Feature.DOCS])); + expect(addons).toContain('@storybook/addon-docs'); + }); + + it('should add onboarding addon for onboarding feature', () => { + const addons = manager.getAddonsForFeatures(new Set([Feature.ONBOARDING])); + expect(addons).toContain('@storybook/addon-onboarding'); + }); + + it('should add a11y addon for a11y feature', () => { + const addons = manager.getAddonsForFeatures(new Set([Feature.A11Y])); + expect(addons).toContain('@storybook/addon-a11y'); + }); + + it('should add all addons for all features', () => { + const addons = manager.getAddonsForFeatures( + new Set([Feature.DOCS, Feature.TEST, Feature.ONBOARDING, Feature.A11Y]) + ); + expect(addons).toContain('@storybook/addon-docs'); + expect(addons).toContain('@chromatic-com/storybook'); + expect(addons).toContain('@storybook/addon-vitest'); + expect(addons).toContain('@storybook/addon-onboarding'); + expect(addons).toContain('@storybook/addon-a11y'); + }); + }); + + describe('stripVersions', () => { + it('should strip version from addon names', () => { + const addons = ['@storybook/addon-essentials@8.0.0', '@storybook/addon-links@8.0.0']; + const stripped = manager.stripVersions(addons); + + expect(stripped).toEqual(['@storybook/addon-essentials', '@storybook/addon-links']); + }); + + it('should handle addons without versions', () => { + const addons = ['@storybook/addon-essentials', '@storybook/addon-links']; + const stripped = manager.stripVersions(addons); + + expect(stripped).toEqual(['@storybook/addon-essentials', '@storybook/addon-links']); + }); + }); + + describe('configureAddons', () => { + it('should include compiler addon when specified', () => { + const webpackCompiler = vi.fn().mockReturnValue('swc'); + const config = manager.configureAddons( + new Set([Feature.DOCS]), + [], + SupportedBuilder.WEBPACK5, + webpackCompiler + ); + + expect(config.addonsForMain).toContain('@storybook/addon-webpack5-compiler-swc'); + expect(config.addonPackages).toContain('@storybook/addon-webpack5-compiler-swc'); + }); + + it('should strip versions from addons in main config', () => { + const config = manager.configureAddons( + new Set([Feature.DOCS]), + ['@storybook/addon-links@8.0.0'], + SupportedBuilder.VITE, + undefined + ); + + expect(config.addonsForMain).toContain('@storybook/addon-links'); + expect(config.addonsForMain).not.toContain('@storybook/addon-links@8.0.0'); + }); + + it('should keep versions in addon packages', () => { + const config = manager.configureAddons( + new Set([Feature.TEST]), + ['@storybook/addon-links@8.0.0'], + SupportedBuilder.VITE, + undefined + ); + + expect(config.addonPackages).toContain('@storybook/addon-links@8.0.0'); + }); + + it('should handle all features together', () => { + const webpackCompiler = vi.fn().mockReturnValue('swc'); + const config = manager.configureAddons( + new Set([Feature.DOCS, Feature.TEST, Feature.ONBOARDING, Feature.A11Y]), + ['@storybook/addon-links'], + SupportedBuilder.WEBPACK5, + webpackCompiler + ); + + expect(config.addonsForMain).toHaveLength(2); // compiler + links + expect(config.addonPackages).toHaveLength(2); // compiler + links + expect(config.addonsForMain).toContain('@storybook/addon-webpack5-compiler-swc'); + expect(config.addonsForMain).toContain('@storybook/addon-links'); + }); + + it('should filter out falsy values', () => { + const config = manager.configureAddons(new Set([]), [], SupportedBuilder.VITE, undefined); + + expect(config.addonsForMain).not.toContain(undefined); + expect(config.addonsForMain).not.toContain(null); + expect(config.addonPackages).not.toContain(undefined); + expect(config.addonPackages).not.toContain(null); + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/AddonService.ts b/code/lib/create-storybook/src/services/AddonService.ts new file mode 100644 index 000000000000..3d7603795959 --- /dev/null +++ b/code/lib/create-storybook/src/services/AddonService.ts @@ -0,0 +1,77 @@ +import { getPackageDetails } from 'storybook/internal/common'; +import type { SupportedBuilder } from 'storybook/internal/types'; +import { Feature } from 'storybook/internal/types'; + +export interface AddonConfiguration { + addonsForMain: Array; + addonPackages: string[]; +} + +/** Module for managing Storybook addons */ +export class AddonService { + /** Determine webpack compiler addon if needed */ + getWebpackCompilerAddon( + builder: SupportedBuilder, + webpackCompiler?: ({ builder }: { builder: SupportedBuilder }) => 'babel' | 'swc' | undefined + ): string | undefined { + if (!webpackCompiler) { + return undefined; + } + + const compiler = webpackCompiler({ builder }); + return compiler ? `@storybook/addon-webpack5-compiler-${compiler}` : undefined; + } + + /** Get addons based on selected features */ + getAddonsForFeatures(features: Set): string[] { + const addons: string[] = []; + + if (features.has(Feature.TEST)) { + addons.push('@chromatic-com/storybook'); + addons.push('@storybook/addon-vitest'); + } + + if (features.has(Feature.A11Y)) { + addons.push('@storybook/addon-a11y'); + } + + if (features.has(Feature.DOCS)) { + addons.push('@storybook/addon-docs'); + } + + if (features.has(Feature.ONBOARDING)) { + addons.push('@storybook/addon-onboarding'); + } + + return addons; + } + + /** Strip version numbers from addon names */ + stripVersions(addons: string[]): string[] { + return addons.map((addon) => getPackageDetails(addon)[0]); + } + + /** Configure addons for the project */ + configureAddons( + features: Set, + extraAddons: string[] = [], + builder: SupportedBuilder, + webpackCompiler?: ({ builder }: { builder: SupportedBuilder }) => 'babel' | 'swc' | undefined + ): AddonConfiguration { + const compiler = this.getWebpackCompilerAddon(builder, webpackCompiler); + + // Addons added to main.js + const addonsForMain = [ + ...(compiler ? [compiler] : []), + ...this.stripVersions(extraAddons), + ].filter(Boolean); + + // Packages added to package.json + const addonPackages = [...(compiler ? [compiler] : []), ...extraAddons].filter(Boolean); + + return { + addonsForMain, + addonPackages, + }; + } +} diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts new file mode 100644 index 000000000000..31de0b70be87 --- /dev/null +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.test.ts @@ -0,0 +1,94 @@ +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 { FeatureCompatibilityService } from './FeatureCompatibilityService'; + +vi.mock('storybook/internal/cli', async () => { + const actual = await vi.importActual('storybook/internal/cli'); + return { + ...actual, + AddonVitestService: vi.fn().mockImplementation(() => ({ + validateCompatibility: vi.fn(), + })), + }; +}); + +describe('FeatureCompatibilityService', () => { + let service: FeatureCompatibilityService; + let mockAddonVitestService: AddonVitestService; + + beforeEach(() => { + mockAddonVitestService = new AddonVitestService(); + service = new FeatureCompatibilityService(mockAddonVitestService); + }); + + describe('supportsOnboarding', () => { + it('should return true for supported project types', () => { + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.REACT)).toBe(true); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.REACT_SCRIPTS)).toBe(true); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.NEXTJS)).toBe(true); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.VUE3)).toBe(true); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.ANGULAR)).toBe(true); + }); + + it('should return false for unsupported project types', () => { + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.SVELTE)).toBe(false); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.EMBER)).toBe(false); + expect(FeatureCompatibilityService.supportsOnboarding(ProjectType.HTML)).toBe(false); + }); + }); + + describe('validateTestFeatureCompatibility', () => { + let mockPackageManager: JsPackageManager; + let mockValidateCompatibility: ReturnType; + + beforeEach(() => { + mockPackageManager = { + getInstalledVersion: vi.fn(), + } as Partial as JsPackageManager; + + // Get the mocked validateCompatibility method + mockValidateCompatibility = vi.mocked(mockAddonVitestService.validateCompatibility); + }); + + it('should return compatible when all checks pass', async () => { + mockValidateCompatibility.mockResolvedValue({ compatible: true }); + + const result = await service.validateTestFeatureCompatibility( + mockPackageManager, + SupportedFramework.REACT_VITE, + SupportedBuilder.VITE, + '/test' + ); + + expect(result.compatible).toBe(true); + expect(mockValidateCompatibility).toHaveBeenCalledWith({ + packageManager: mockPackageManager, + framework: 'react-vite', + builder: SupportedBuilder.VITE, + projectRoot: '/test', + }); + }); + + it('should return incompatible if package versions check fails', async () => { + mockValidateCompatibility.mockResolvedValue({ + compatible: false, + reasons: ['Vitest version is too old'], + }); + + const result = await service.validateTestFeatureCompatibility( + mockPackageManager, + SupportedFramework.REACT_VITE, + SupportedBuilder.VITE, + '/test' + ); + + expect(result.compatible).toBe(false); + expect(result.reasons).toBeDefined(); + expect(result.reasons).toContain('Vitest version is too old'); + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts new file mode 100644 index 000000000000..3cdb5545e148 --- /dev/null +++ b/code/lib/create-storybook/src/services/FeatureCompatibilityService.ts @@ -0,0 +1,61 @@ +import { AddonVitestService, ProjectType } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import type { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; + +/** Project types that support the onboarding feature */ +const ONBOARDING_PROJECT_TYPES: ProjectType[] = [ + ProjectType.REACT, + ProjectType.REACT_SCRIPTS, + ProjectType.REACT_NATIVE_WEB, + ProjectType.REACT_PROJECT, + ProjectType.NEXTJS, + ProjectType.VUE3, + ProjectType.ANGULAR, +]; + +export interface FeatureCompatibilityResult { + compatible: boolean; + reasons?: string[]; +} + +/** Service for validating feature compatibility with project configurations */ +export class FeatureCompatibilityService { + constructor(private readonly addonVitestService = new AddonVitestService()) {} + + /** Check if a project type supports onboarding */ + + static supportsOnboarding(projectType: ProjectType): boolean { + return ONBOARDING_PROJECT_TYPES.includes( + projectType as (typeof ONBOARDING_PROJECT_TYPES)[number] + ); + } + + /** + * Validate all compatibility checks for test feature + * + * @param packageManager - Package manager instance + * @param framework - Detected framework (e.g., 'nextjs', 'react-vite') + * @param builder - Detected builder (e.g. SupportedBuilder.Vite) + * @param directory - Project root directory + * @returns Compatibility result with reasons if incompatible + */ + async validateTestFeatureCompatibility( + packageManager: JsPackageManager, + framework: SupportedFramework | null | undefined, + builder: SupportedBuilder, + directory: string + ): Promise { + const compatibilityResult = await this.addonVitestService.validateCompatibility({ + packageManager, + framework, + builder, + projectRoot: directory, + }); + + if (!compatibilityResult.compatible) { + return compatibilityResult; + } + + return { compatible: true }; + } +} diff --git a/code/lib/create-storybook/src/services/FrameworkDetectionService.test.ts b/code/lib/create-storybook/src/services/FrameworkDetectionService.test.ts new file mode 100644 index 000000000000..9ad45b36f1dc --- /dev/null +++ b/code/lib/create-storybook/src/services/FrameworkDetectionService.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; +import { prompt } from 'storybook/internal/node-logger'; +import { SupportedBuilder, SupportedFramework, SupportedRenderer } from 'storybook/internal/types'; + +import * as find from 'empathic/find'; + +import { FrameworkDetectionService } from './FrameworkDetectionService'; + +vi.mock('empathic/find', () => ({ + any: vi.fn(), +})); + +vi.mock('storybook/internal/common', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProjectRoot: vi.fn(() => '/project/root'), + }; +}); + +vi.mock('storybook/internal/node-logger', () => ({ + prompt: { + select: vi.fn(), + }, +})); + +describe('FrameworkDetectionService', () => { + let service: FrameworkDetectionService; + let mockPackageManager: JsPackageManager; + + beforeEach(() => { + vi.clearAllMocks(); + mockPackageManager = { + getAllDependencies: vi.fn(() => ({})), + } as unknown as JsPackageManager; + service = new FrameworkDetectionService(mockPackageManager); + }); + + describe('detectFramework', () => { + it('should return renderer directly if it is a valid framework', () => { + const result = service.detectFramework( + SupportedRenderer.REACT as SupportedRenderer, + SupportedBuilder.VITE + ); + expect(result).toBe(SupportedFramework.REACT_VITE); + }); + + it('should combine renderer and builder when renderer is not a framework', () => { + const result = service.detectFramework( + SupportedRenderer.REACT as SupportedRenderer, + SupportedBuilder.VITE + ); + expect(result).toBe(SupportedFramework.REACT_VITE); + }); + + it('should return react-webpack5 framework for react renderer with webpack5 builder', () => { + const result = service.detectFramework( + SupportedRenderer.REACT as SupportedRenderer, + SupportedBuilder.WEBPACK5 + ); + expect(result).toBe(SupportedFramework.REACT_WEBPACK5); + }); + + it('should return react-vite framework for react renderer with vite builder', () => { + const result = service.detectFramework( + SupportedRenderer.REACT as SupportedRenderer, + SupportedBuilder.VITE + ); + expect(result).toBe(SupportedFramework.REACT_VITE); + }); + + it('should return vue3-vite framework for vue3 renderer with vite builder', () => { + const result = service.detectFramework( + SupportedRenderer.VUE3 as SupportedRenderer, + SupportedBuilder.VITE + ); + expect(result).toBe(SupportedFramework.VUE3_VITE); + }); + + it('should return react-rsbuild framework for react renderer with rsbuild builder', () => { + const result = service.detectFramework( + SupportedRenderer.REACT as SupportedRenderer, + SupportedBuilder.RSBUILD + ); + expect(result).toBe(SupportedFramework.REACT_RSBUILD); + }); + + it('should throw error for invalid renderer and builder combination', () => { + const invalidRenderer = 'invalid-renderer' as SupportedRenderer; + const invalidBuilder = SupportedBuilder.VITE; + + expect(() => { + service.detectFramework(invalidRenderer, invalidBuilder); + }).toThrow('Could not find framework for renderer: invalid-renderer and builder: vite'); + }); + }); + + describe('detectBuilder', () => { + it('should detect vite builder from config file', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes('vite.config.ts')) { + return 'vite.config.ts'; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect vite builder from dependencies', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + vite: '^5.0.0', + }); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect webpack5 builder from config file', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes('webpack.config.js')) { + return 'webpack.config.js'; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.WEBPACK5); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect webpack5 builder from dependencies', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + webpack: '^5.0.0', + }); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.WEBPACK5); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect rsbuild builder from config file', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes('rsbuild.config.ts')) { + return 'rsbuild.config.ts'; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.RSBUILD); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect rsbuild builder from dependencies', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + '@rsbuild/core': '^1.0.0', + }); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.RSBUILD); + expect(prompt.select).not.toHaveBeenCalled(); + }); + + it('should detect both config file and dependency, then prompt user', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + // Check if this is the vite config files array (has vite.config.ts) + if (files.includes('vite.config.ts')) { + return 'vite.config.ts'; + } + // For webpack and rsbuild config files, return undefined + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + webpack: '^5.0.0', + }); + vi.mocked(prompt.select).mockResolvedValue(SupportedBuilder.VITE); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + expect(prompt.select).toHaveBeenCalledWith({ + message: expect.stringContaining('Multiple builders were detected'), + options: [ + { label: 'Vite', value: SupportedBuilder.VITE }, + { label: 'Webpack 5', value: SupportedBuilder.WEBPACK5 }, + { label: 'Rsbuild', value: SupportedBuilder.RSBUILD }, + ], + }); + }); + + it('should prompt user when multiple builders are detected', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes('vite.config.ts')) { + return 'vite.config.ts'; + } + if (files.includes('webpack.config.js')) { + return 'webpack.config.js'; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(prompt.select).mockResolvedValue(SupportedBuilder.VITE); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + expect(prompt.select).toHaveBeenCalledWith({ + message: expect.stringContaining('Multiple builders were detected'), + options: [ + { label: 'Vite', value: SupportedBuilder.VITE }, + { label: 'Webpack 5', value: SupportedBuilder.WEBPACK5 }, + { label: 'Rsbuild', value: SupportedBuilder.RSBUILD }, + ], + }); + }); + + it('should prompt user when no builders are detected', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(prompt.select).mockResolvedValue(SupportedBuilder.VITE); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + expect(prompt.select).toHaveBeenCalledWith({ + message: expect.stringContaining('We were not able to detect the right builder'), + options: [ + { label: 'Vite', value: SupportedBuilder.VITE }, + { label: 'Webpack 5', value: SupportedBuilder.WEBPACK5 }, + { label: 'Rsbuild', value: SupportedBuilder.RSBUILD }, + ], + }); + }); + + it('should detect multiple builders from dependencies', async () => { + vi.mocked(find.any).mockReturnValue(undefined); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({ + vite: '^5.0.0', + webpack: '^5.0.0', + }); + vi.mocked(prompt.select).mockResolvedValue(SupportedBuilder.WEBPACK5); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.WEBPACK5); + expect(prompt.select).toHaveBeenCalled(); + }); + + it('should detect all three builders when all are present', async () => { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes('vite.config.ts')) { + return 'vite.config.ts'; + } + if (files.includes('webpack.config.js')) { + return 'webpack.config.js'; + } + if (files.includes('rsbuild.config.ts')) { + return 'rsbuild.config.ts'; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + vi.mocked(prompt.select).mockResolvedValue(SupportedBuilder.RSBUILD); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.RSBUILD); + expect(prompt.select).toHaveBeenCalled(); + }); + + it('should check all vite config file variants', async () => { + const viteConfigs = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; + for (const config of viteConfigs) { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes(config)) { + return config; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.VITE); + vi.clearAllMocks(); + } + }); + + it('should check all rsbuild config file variants', async () => { + const rsbuildConfigs = ['rsbuild.config.ts', 'rsbuild.config.js', 'rsbuild.config.mjs']; + for (const config of rsbuildConfigs) { + vi.mocked(find.any).mockImplementation((files: string[]) => { + if (files.includes(config)) { + return config; + } + return undefined; + }); + vi.mocked(mockPackageManager.getAllDependencies).mockReturnValue({}); + + const result = await service.detectBuilder(); + + expect(result).toBe(SupportedBuilder.RSBUILD); + vi.clearAllMocks(); + } + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/FrameworkDetectionService.ts b/code/lib/create-storybook/src/services/FrameworkDetectionService.ts new file mode 100644 index 000000000000..b341afc424d5 --- /dev/null +++ b/code/lib/create-storybook/src/services/FrameworkDetectionService.ts @@ -0,0 +1,78 @@ +import { type JsPackageManager, getProjectRoot } from 'storybook/internal/common'; +import { prompt } from 'storybook/internal/node-logger'; +import type { SupportedRenderer } from 'storybook/internal/types'; +import { SupportedBuilder, SupportedFramework } from 'storybook/internal/types'; + +import * as find from 'empathic/find'; +import { dedent } from 'ts-dedent'; + +const viteConfigFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']; +const webpackConfigFiles = ['webpack.config.js']; +const rsbuildConfigFiles = ['rsbuild.config.ts', 'rsbuild.config.js', 'rsbuild.config.mjs']; + +export class FrameworkDetectionService { + constructor(private jsPackageManager: JsPackageManager) {} + + detectFramework(renderer: SupportedRenderer, builder: SupportedBuilder): SupportedFramework { + if (Object.values(SupportedFramework).includes(renderer as any)) { + return renderer as any as SupportedFramework; + } + + const maybeFramework = `${renderer}-${builder}`; + + if (Object.values(SupportedFramework).includes(maybeFramework as SupportedFramework)) { + return maybeFramework as SupportedFramework; + } + + throw new Error(`Could not find framework for renderer: ${renderer} and builder: ${builder}`); + } + + async detectBuilder() { + const viteConfig = find.any(viteConfigFiles, { last: getProjectRoot() }); + const webpackConfig = find.any(webpackConfigFiles, { last: getProjectRoot() }); + const rsbuildConfig = find.any(rsbuildConfigFiles, { last: getProjectRoot() }); + const dependencies = this.jsPackageManager.getAllDependencies(); + + // Detect which builders are present + const hasVite = viteConfig || !!dependencies.vite; + const hasWebpack = webpackConfig || !!dependencies.webpack; + const hasRsbuild = rsbuildConfig || !!dependencies['@rsbuild/core']; + + const detectedBuilders: SupportedBuilder[] = []; + + if (hasVite) { + detectedBuilders.push(SupportedBuilder.VITE); + } + + if (hasWebpack) { + detectedBuilders.push(SupportedBuilder.WEBPACK5); + } + + if (hasRsbuild) { + detectedBuilders.push(SupportedBuilder.RSBUILD); + } + + // If exactly one builder is detected, return it + if (detectedBuilders.length === 1) { + return detectedBuilders[0]; + } + + // If multiple builders are detected or none are detected, prompt the user + const options = [ + { label: 'Vite', value: SupportedBuilder.VITE }, + { label: 'Webpack 5', value: SupportedBuilder.WEBPACK5 }, + { label: 'Rsbuild', value: SupportedBuilder.RSBUILD }, + ]; + + return prompt.select({ + message: dedent` + ${ + detectedBuilders.length > 1 + ? 'Multiple builders were detected in your project. Please select one:' + : 'We were not able to detect the right builder for your project. Please select one:' + } + `, + options, + }); + } +} diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.test.ts b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts new file mode 100644 index 000000000000..1d0609a95b14 --- /dev/null +++ b/code/lib/create-storybook/src/services/ProjectTypeService.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectType } from 'storybook/internal/cli'; +import type { JsPackageManager } from 'storybook/internal/common'; +import { logger } from 'storybook/internal/node-logger'; +import { NxProjectDetectedError } from 'storybook/internal/server-errors'; + +import type { CommandOptions } from '../generators/types'; +import { ProjectTypeService } from './ProjectTypeService'; + +describe('ProjectTypeService', () => { + let pm: JsPackageManager; + + beforeEach(() => { + pm = { + getAllDependencies: vi.fn(() => ({}) as any), + getModulePackageJSON: vi.fn(async () => ({ version: '0.0.0' })) as any, + primaryPackageJson: { packageJson: {} as any }, + } as unknown as JsPackageManager; + vi.spyOn(logger, 'error').mockImplementation(() => undefined as any); + vi.spyOn(logger, 'warn').mockImplementation(() => undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('autoDetectProjectType', () => { + it('logs a helpful message when framework cannot be detected', async () => { + const service = new ProjectTypeService(pm); + const options = { html: false } as unknown as CommandOptions; + // @ts-expect-error accessing private for test + vi.spyOn(service, 'detectProjectType').mockResolvedValue(ProjectType.UNDETECTED); + + await expect(service.autoDetectProjectType(options)).rejects.toThrowError( + 'Storybook failed to detect your project type' + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Unable to initialize Storybook in this directory.') + ); + }); + + it('throws NxProjectDetectedError when NX project is detected', async () => { + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(true); + await expect(service.autoDetectProjectType({} as CommandOptions)).rejects.toBeInstanceOf( + NxProjectDetectedError + ); + }); + + it('returns HTML when options.html is true', async () => { + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: true } as CommandOptions); + expect(result).toBe(ProjectType.HTML); + }); + + it('detects framework from package.json (nextjs)', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { next: '^13.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.NEXTJS); + }); + + it('detects REACT_PROJECT via peerDependencies', async () => { + (pm as any).primaryPackageJson.packageJson = { + peerDependencies: { react: '^18.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_PROJECT); + }); + + it('detects VUE3 when vue major is 3', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { vue: '^3.2.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.VUE3); + }); + + it('detects SVELTEKIT via @sveltejs/kit', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { '@sveltejs/kit': '^2.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.SVELTEKIT); + }); + + it('detects WEB_COMPONENTS via lit', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { lit: '^3.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.WEB_COMPONENTS); + }); + + it('detects SOLID via solid-js', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { 'solid-js': '^1.8.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.SOLID); + }); + + it('detects REACT_SCRIPTS via dependency', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { 'react-scripts': '^5.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_SCRIPTS); + }); + + it('detects ANGULAR via @angular/core', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { '@angular/core': '^17.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.ANGULAR); + }); + + it('detects PREACT via preact', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { preact: '^10.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.PREACT); + }); + + it('detects EMBER via ember-cli', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { 'ember-cli': '^5.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.EMBER); + }); + + it('detects QWIK via @builder.io/qwik', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { '@builder.io/qwik': '^1.4.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.QWIK); + }); + + it('detects SVELTE via svelte', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { svelte: '^4.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.SVELTE); + }); + + it('detects REACT_NATIVE via react-native-scripts', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { 'react-native-scripts': '^5.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.REACT_NATIVE); + }); + + it('detects NUXT via nuxt', async () => { + (pm as any).primaryPackageJson.packageJson = { + dependencies: { nuxt: '^3.0.0' }, + }; + const service = new ProjectTypeService(pm); + // @ts-expect-error private method spy + vi.spyOn(service, 'isNxProject').mockReturnValue(false); + const result = await service.autoDetectProjectType({ html: false } as CommandOptions); + expect(result).toBe(ProjectType.NUXT); + }); + }); + + describe('validateProvidedType', () => { + it('accepts installable types and rejects invalid ones', async () => { + const service = new ProjectTypeService(pm); + await expect(service.validateProvidedType(ProjectType.REACT)).resolves.toBe( + ProjectType.REACT + ); + await expect(service.validateProvidedType(ProjectType.UNSUPPORTED)).rejects.toThrow( + /Unknown project type supplied/ + ); + }); + }); + + describe('detectLanguage', () => { + // Note: FS-based language detection (jsconfig/tsconfig) is not tested here to avoid + // mutating the real filesystem or mocking ESM builtin modules. Covered by TS tooling path. + it('returns typescript when TS and compatible tooling are present', async () => { + (pm.getAllDependencies as any) = vi.fn(() => ({ typescript: '^5.0.0' })); + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '5.2.0', + prettier: '3.3.0', + '@babel/plugin-transform-typescript': '7.23.0', + '@typescript-eslint/parser': '6.7.0', + 'eslint-plugin-storybook': '0.7.0', + }; + return { version: versions[name] } as any; + }); + const service = new ProjectTypeService(pm); + await expect(service.detectLanguage()).resolves.toBe('typescript'); + }); + + it('warns and returns javascript when TS/tooling versions incompatible', async () => { + (pm.getAllDependencies as any) = vi.fn(() => ({ typescript: '^4.8.0' })); + (pm.getModulePackageJSON as any) = vi.fn(async (name: string) => { + const versions: Record = { + typescript: '4.8.4', + prettier: '2.7.1', // below 2.8.0 + '@babel/plugin-transform-typescript': '7.19.0', + '@typescript-eslint/parser': '5.43.0', + 'eslint-plugin-storybook': '0.6.7', + }; + return { version: versions[name] } as any; + }); + const warnSpy = vi.spyOn(logger, 'warn'); + const service = new ProjectTypeService(pm); + await expect(service.detectLanguage()).resolves.toBe('javascript'); + expect(warnSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/ProjectTypeService.ts b/code/lib/create-storybook/src/services/ProjectTypeService.ts new file mode 100644 index 000000000000..fe25cb5fda2a --- /dev/null +++ b/code/lib/create-storybook/src/services/ProjectTypeService.ts @@ -0,0 +1,384 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { ProjectType } 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 * as find from 'empathic/find'; +import semver from 'semver'; +import { dedent } from 'ts-dedent'; + +import type { CommandOptions } from '../generators/types'; + +type TemplateMatcher = { + files?: boolean[]; + dependencies?: boolean[]; + peerDependencies?: boolean[]; +}; + +type TemplateConfiguration = { + preset: ProjectType; + /** Will be checked both against dependencies and devDependencies */ + dependencies?: string[] | { [dependency: string]: (version: string) => boolean }; + peerDependencies?: string[] | { [dependency: string]: (version: string) => boolean }; + files?: string[]; + matcherFunction: (matcher: TemplateMatcher) => boolean; +}; + +/** Service encapsulating helpers for ProjectType usage */ +export class ProjectTypeService { + constructor(private readonly jsPackageManager: JsPackageManager) {} + + /** Sorted configuration to match a Storybook preset template */ + getSupportedTemplates(): TemplateConfiguration[] { + return [ + { + preset: ProjectType.NUXT, + dependencies: ['nuxt'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.VUE3, + dependencies: { + // This Vue template works with Vue 3 + vue: (versionRange) => versionRange === 'next' || this.eqMajor(versionRange, 3), + }, + matcherFunction: ({ dependencies }) => { + return dependencies?.some(Boolean) ?? false; + }, + }, + { + preset: ProjectType.EMBER, + dependencies: ['ember-cli'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.NEXTJS, + dependencies: ['next'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.QWIK, + dependencies: ['@builder.io/qwik'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.REACT_PROJECT, + peerDependencies: ['react'], + matcherFunction: ({ peerDependencies }) => { + return peerDependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.REACT_NATIVE, + dependencies: ['react-native', 'react-native-scripts'], + matcherFunction: ({ dependencies }) => { + return dependencies?.some(Boolean) ?? false; + }, + }, + { + preset: ProjectType.REACT_SCRIPTS, + // For projects using a custom/forked `react-scripts` package. + files: ['/node_modules/.bin/react-scripts'], + // For standard CRA projects + dependencies: ['react-scripts'], + matcherFunction: ({ dependencies, files }) => { + return (dependencies?.every(Boolean) || files?.every(Boolean)) ?? false; + }, + }, + { + preset: ProjectType.ANGULAR, + dependencies: ['@angular/core'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.WEB_COMPONENTS, + dependencies: ['lit-element', 'lit-html', 'lit'], + matcherFunction: ({ dependencies }) => { + return dependencies?.some(Boolean) ?? false; + }, + }, + { + preset: ProjectType.PREACT, + dependencies: ['preact'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + // TODO: This only works because it is before the SVELTE template. could be more explicit + preset: ProjectType.SVELTEKIT, + dependencies: ['@sveltejs/kit'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.SVELTE, + dependencies: ['svelte'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + { + preset: ProjectType.SOLID, + dependencies: ['solid-js'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + // DO NOT MOVE ANY TEMPLATES BELOW THIS LINE + // React is part of every Template, after Storybook is initialized once + { + preset: ProjectType.REACT, + dependencies: ['react'], + matcherFunction: ({ dependencies }) => { + return dependencies?.every(Boolean) ?? true; + }, + }, + ]; + } + + isStorybookInstantiated(configDir = resolve(process.cwd(), '.storybook')) { + return existsSync(configDir); + } + + async validateProvidedType(projectTypeProvided: ProjectType): Promise { + // Allow only installable types according to core list + const installable = Object.values(ProjectType).filter( + (t) => !['undetected', 'unsupported', 'nx'].includes(String(t)) + ); + if (installable.includes(projectTypeProvided)) { + return projectTypeProvided; + } + logger.error( + `The provided project type ${projectTypeProvided} was not recognized by Storybook` + ); + throw new HandledError(`Unknown project type supplied: ${projectTypeProvided}`); + } + + async autoDetectProjectType(options: CommandOptions): Promise { + try { + const detectedType = await this.detectProjectType(options); + + // prompting handled by command layer + + if (detectedType === ProjectType.UNDETECTED || detectedType === null) { + logger.error(dedent` + Unable to initialize Storybook in this directory. + + Storybook couldn't detect a supported framework or configuration for your project. Make sure you're inside a framework project (e.g., React, Vue, Svelte, Angular, Next.js) and that its dependencies are installed. + + Tips: + - Run init in an empty directory or create a new framework app first. + - If this directory contains unrelated files, try a new directory for Storybook. + `); + throw new HandledError('Storybook failed to detect your project type'); + } + + if (detectedType === ProjectType.NX) { + throw new NxProjectDetectedError(); + } + + return detectedType; + } catch (err) { + if (err instanceof HandledError || err instanceof NxProjectDetectedError) { + throw err; + } + logger.error(String(err)); + throw new HandledError(err instanceof Error ? err.message : String(err)); + } + } + + async detectLanguage(): Promise { + let language = SupportedLanguage.JAVASCRIPT; + + if (existsSync('jsconfig.json')) { + return language; + } + + const isTypescriptDirectDependency = !!this.jsPackageManager.getAllDependencies().typescript; + + 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 }); + }; + + if (isTypescriptDirectDependency && typescriptVersion) { + if ( + satisfies(typescriptVersion, '>=4.9.0') && + (!prettierVersion || semver.gte(prettierVersion, '2.8.0')) && + (!babelPluginTransformTypescriptVersion || + satisfies(babelPluginTransformTypescriptVersion, '>=7.20.0')) && + (!typescriptEslintParserVersion || satisfies(typescriptEslintParserVersion, '>=5.44.0')) && + (!eslintPluginStorybookVersion || satisfies(eslintPluginStorybookVersion, '>=0.6.8')) + ) { + language = SupportedLanguage.TYPESCRIPT; + } else { + logger.warn( + 'Detected TypeScript < 4.9 or incompatible tooling, populating with JavaScript examples' + ); + } + } 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; + } + + private eqMajor(versionRange: string, major: number) { + // Uses validRange to avoid a throw from minVersion if an invalid range gets passed + if (semver.validRange(versionRange)) { + return semver.minVersion(versionRange)?.major === major; + } + return false; + } + + private async detectProjectType(options: CommandOptions): Promise { + try { + if (this.isNxProject()) { + return ProjectType.NX; + } + if (options.html) { + return ProjectType.HTML; + } + const { packageJson } = this.jsPackageManager.primaryPackageJson; + return this.detectFrameworkPreset(packageJson); + } catch { + return ProjectType.UNDETECTED; + } + } + + private detectFrameworkPreset(packageJson: PackageJsonWithMaybeDeps): ProjectType | null { + const result = [...this.getSupportedTemplates(), this.getUnsupportedTemplate()].find( + (framework) => { + return this.getProjectType(packageJson, framework) !== null; + } + ); + return result ? result.preset : ProjectType.UNDETECTED; + } + + /** Template that matches unsupported frameworks */ + private getUnsupportedTemplate(): TemplateConfiguration { + return { + preset: ProjectType.UNSUPPORTED, + dependencies: {}, + matcherFunction: ({ dependencies }) => { + return dependencies?.some(Boolean) ?? false; + }, + }; + } + + private getProjectType( + packageJson: PackageJsonWithMaybeDeps, + framework: TemplateConfiguration + ): ProjectType | null { + const matcher: TemplateMatcher = { + dependencies: [false], + peerDependencies: [false], + files: [false], + }; + const { preset, files, dependencies, peerDependencies, matcherFunction } = framework; + + let dependencySearches: [string, ((version: string) => boolean) | undefined][] = []; + + if (Array.isArray(dependencies)) { + dependencySearches = dependencies.map((name) => [name, undefined]); + } else if (typeof dependencies === 'object') { + dependencySearches = Object.entries(dependencies); + } + + if (dependencySearches.length > 0) { + matcher.dependencies = dependencySearches.map(([name, matchFn]) => + this.hasDependency(packageJson, name, matchFn) + ); + } + + let peerDependencySearches: [string, ((version: string) => boolean) | undefined][] = []; + + if (Array.isArray(peerDependencies)) { + peerDependencySearches = peerDependencies.map((name) => [name, undefined]); + } else if (typeof peerDependencies === 'object') { + peerDependencySearches = Object.entries(peerDependencies); + } + + if (peerDependencySearches.length > 0) { + matcher.peerDependencies = peerDependencySearches.map(([name, matchFn]) => + this.hasPeerDependency(packageJson, name, matchFn) + ); + } + + if (Array.isArray(files) && files.length > 0) { + matcher.files = files.map((name) => existsSync(name)); + } + + return matcherFunction(matcher) ? preset : null; + } + + private hasDependency( + packageJson: PackageJsonWithMaybeDeps, + name: string, + matcher?: (version: string) => boolean + ) { + const version = packageJson.dependencies?.[name] || packageJson.devDependencies?.[name]; + if (version && typeof matcher === 'function') { + return matcher(version); + } + return !!version; + } + + private hasPeerDependency( + packageJson: PackageJsonWithMaybeDeps, + name: string, + matcher?: (version: string) => boolean + ) { + const version = packageJson.peerDependencies?.[name]; + if (version && typeof matcher === 'function') { + return matcher(version); + } + return !!version; + } + + private isNxProject() { + return find.up('nx.json', { last: getProjectRoot() }); + } +} diff --git a/code/lib/create-storybook/src/services/TelemetryService.test.ts b/code/lib/create-storybook/src/services/TelemetryService.test.ts new file mode 100644 index 000000000000..d993bb462a19 --- /dev/null +++ b/code/lib/create-storybook/src/services/TelemetryService.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectType } from 'storybook/internal/cli'; +import { telemetry } from 'storybook/internal/telemetry'; +import { Feature } from 'storybook/internal/types'; + +import { getProcessAncestry } from 'process-ancestry'; + +import { TelemetryService } from './TelemetryService'; + +vi.mock('storybook/internal/telemetry', { spy: true }); +vi.mock('process-ancestry', { spy: true }); + +describe('TelemetryService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('when telemetry is enabled', () => { + let telemetryService: TelemetryService; + + beforeEach(() => { + telemetryService = new TelemetryService(false); + }); + + it('should track new user check', async () => { + await telemetryService.trackNewUserCheck(true); + + expect(telemetry).toHaveBeenCalledWith('init-step', { + step: 'new-user-check', + newUser: true, + }); + }); + + it('should track install type', async () => { + await telemetryService.trackInstallType('recommended'); + + expect(telemetry).toHaveBeenCalledWith('init-step', { + step: 'install-type', + installType: 'recommended', + }); + }); + + it('should track init event', async () => { + const data = { + projectType: ProjectType.REACT, + features: { + dev: true, + docs: true, + test: false, + onboarding: true, + }, + newUser: true, + versionSpecifier: '8.0.0', + cliIntegration: 'sv create', + }; + + await telemetryService.trackInit(data); + + expect(telemetry).toHaveBeenCalledWith('init', data); + }); + + it('should track scaffolded event', async () => { + const data = { + packageManager: 'npm', + projectType: 'react-vite-ts', + }; + + await telemetryService.trackScaffolded(data); + + expect(telemetry).toHaveBeenCalledWith('scaffolded-empty', data); + }); + }); + + describe('when telemetry is disabled', () => { + let telemetryService: TelemetryService; + + beforeEach(() => { + telemetryService = new TelemetryService(true); + }); + + it('should not track new user check', async () => { + await telemetryService.trackNewUserCheck(true); + + expect(telemetry).not.toHaveBeenCalled(); + }); + + it('should not track install type', async () => { + await telemetryService.trackInstallType('light'); + + expect(telemetry).not.toHaveBeenCalled(); + }); + + it('should not track init event', async () => { + await telemetryService.trackInit({ + projectType: ProjectType.VUE3, + features: { + dev: true, + docs: false, + test: false, + onboarding: false, + }, + newUser: false, + }); + + expect(telemetry).not.toHaveBeenCalled(); + }); + + it('should not track scaffolded event', async () => { + await telemetryService.trackScaffolded({ + packageManager: 'yarn', + projectType: 'vue-vite-ts', + }); + + expect(telemetry).not.toHaveBeenCalled(); + }); + }); + + describe('trackInitWithContext', () => { + it('should track init with version and CLI integration from ancestry', async () => { + const telemetryService = new TelemetryService(false); + const selectedFeatures = new Set([Feature.DOCS, Feature.TEST]); + + vi.mocked(getProcessAncestry).mockReturnValue([ + { command: 'npx storybook@8.0.5 init' }, + ] as any); + + await telemetryService.trackInitWithContext(ProjectType.REACT, selectedFeatures, true); + + expect(getProcessAncestry).toHaveBeenCalled(); + expect(telemetry).toHaveBeenCalledWith('init', { + projectType: ProjectType.REACT, + features: { + dev: true, + docs: true, + test: true, + onboarding: false, + }, + newUser: true, + versionSpecifier: '8.0.5', + cliIntegration: undefined, + }); + }); + + it('should handle ancestry errors gracefully', async () => { + const telemetryService = new TelemetryService(false); + const selectedFeatures = new Set([]); + + vi.mocked(getProcessAncestry).mockImplementation(() => { + throw new Error('Ancestry error'); + }); + + await telemetryService.trackInitWithContext(ProjectType.VUE3, selectedFeatures, false); + + expect(telemetry).toHaveBeenCalledWith('init', { + projectType: ProjectType.VUE3, + features: { + dev: true, + docs: false, + test: false, + onboarding: false, + }, + newUser: false, + versionSpecifier: undefined, + cliIntegration: undefined, + }); + }); + + it('should not track when telemetry is disabled', async () => { + const telemetryService = new TelemetryService(true); + const selectedFeatures = new Set([Feature.DOCS]); + + await telemetryService.trackInitWithContext(ProjectType.ANGULAR, selectedFeatures, true); + + expect(getProcessAncestry).not.toHaveBeenCalled(); + expect(telemetry).not.toHaveBeenCalled(); + }); + + it('should detect CLI integration from ancestry', async () => { + const telemetryService = new TelemetryService(false); + const selectedFeatures = new Set([]); + + vi.mocked(getProcessAncestry).mockReturnValue([{ command: 'sv create my-app' }] as any); + + await telemetryService.trackInitWithContext(ProjectType.NEXTJS, selectedFeatures, false); + + expect(telemetry).toHaveBeenCalledWith( + 'init', + expect.objectContaining({ + cliIntegration: 'sv create', + }) + ); + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/TelemetryService.ts b/code/lib/create-storybook/src/services/TelemetryService.ts new file mode 100644 index 000000000000..4d395e6cb33d --- /dev/null +++ b/code/lib/create-storybook/src/services/TelemetryService.ts @@ -0,0 +1,105 @@ +import type { ProjectType } from 'storybook/internal/cli'; +import { telemetry } from 'storybook/internal/telemetry'; +import { Feature } from 'storybook/internal/types'; + +import { getProcessAncestry } from 'process-ancestry'; + +import { VersionService } from './VersionService'; + +/** Service for tracking telemetry events during Storybook initialization */ +export class TelemetryService { + private disableTelemetry: boolean; + private versionService: VersionService; + + constructor(disableTelemetry: boolean = false) { + this.disableTelemetry = disableTelemetry; + this.versionService = new VersionService(); + } + + /** Track a new user check step */ + async trackNewUserCheck(newUser: boolean): Promise { + this.runTelemetryIfEnabled('init-step', { + step: 'new-user-check', + newUser, + }); + } + + /** Track install type selection */ + async trackInstallType(installType: 'recommended' | 'light'): Promise { + await this.runTelemetryIfEnabled('init-step', { + step: 'install-type', + installType, + }); + } + + /** Track the main init event with all metadata */ + async trackInit(data: { + projectType: ProjectType; + features: { + dev: boolean; + docs: boolean; + test: boolean; + onboarding: boolean; + }; + newUser: boolean; + versionSpecifier?: string; + cliIntegration?: string; + }): Promise { + await this.runTelemetryIfEnabled('init', data); + } + + /** Track empty directory scaffolding event */ + async trackScaffolded(data: { packageManager: string; projectType: string }): Promise { + await this.runTelemetryIfEnabled('scaffolded-empty', data); + } + + /** + * Track init with complete context including version and CLI integration from ancestry This + * method encapsulates all telemetry gathering and tracking logic + */ + async trackInitWithContext( + projectType: ProjectType, + selectedFeatures: Set, + newUser: boolean + ): Promise { + if (this.disableTelemetry) { + return; + } + + // Get telemetry info from process ancestry + let versionSpecifier: string | undefined; + let cliIntegration: string | undefined; + + try { + const ancestry = getProcessAncestry(); + versionSpecifier = this.versionService.getStorybookVersionFromAncestry(ancestry); + cliIntegration = this.versionService.getCliIntegrationFromAncestry(ancestry); + } catch { + // Ignore errors getting ancestry + } + + // Create features object and track + const telemetryFeatures = { + dev: true, // Always true during init + docs: selectedFeatures.has(Feature.DOCS), + test: selectedFeatures.has(Feature.TEST), + onboarding: selectedFeatures.has(Feature.ONBOARDING), + }; + + await telemetry('init', { + projectType, + features: telemetryFeatures, + newUser, + versionSpecifier, + cliIntegration, + }); + } + + private runTelemetryIfEnabled(...args: Parameters): Promise { + if (this.disableTelemetry) { + return Promise.resolve(); + } + + return telemetry(...args); + } +} diff --git a/code/lib/create-storybook/src/services/VersionService.test.ts b/code/lib/create-storybook/src/services/VersionService.test.ts new file mode 100644 index 000000000000..1927fef28334 --- /dev/null +++ b/code/lib/create-storybook/src/services/VersionService.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { JsPackageManager } from 'storybook/internal/common'; + +import { VersionService } from './VersionService'; + +vi.mock('storybook/internal/common', async () => { + const actual = await vi.importActual('storybook/internal/common'); + return { + ...actual, + versions: { + storybook: '8.0.0', + }, + }; +}); + +describe('VersionService', () => { + let versionService: VersionService; + let mockPackageManager: JsPackageManager; + + beforeEach(() => { + versionService = new VersionService(); + mockPackageManager = { + latestVersion: vi.fn(), + } as any; + }); + + describe('getCurrentVersion', () => { + it('should return the current Storybook version', () => { + expect(versionService.getCurrentVersion()).toBe('8.0.0'); + }); + }); + + describe('getLatestVersion', () => { + it('should fetch the latest version from package manager', async () => { + vi.mocked(mockPackageManager.latestVersion).mockResolvedValue('8.1.0'); + + const latestVersion = await versionService.getLatestVersion(mockPackageManager); + + expect(latestVersion).toBe('8.1.0'); + expect(mockPackageManager.latestVersion).toHaveBeenCalledWith('storybook'); + }); + }); + + describe('isPrerelease', () => { + it('should return true for prerelease versions', () => { + expect(versionService.isPrerelease('8.0.0-alpha.1')).toBe(true); + expect(versionService.isPrerelease('8.0.0-beta.2')).toBe(true); + expect(versionService.isPrerelease('8.0.0-rc.3')).toBe(true); + }); + + it('should return false for stable versions', () => { + expect(versionService.isPrerelease('8.0.0')).toBe(false); + expect(versionService.isPrerelease('8.1.2')).toBe(false); + }); + }); + + describe('isOutdated', () => { + it('should return true when current version is older than latest', () => { + expect(versionService.isOutdated('8.0.0', '8.1.0')).toBe(true); + expect(versionService.isOutdated('7.6.0', '8.0.0')).toBe(true); + }); + + it('should return false when current version is same or newer', () => { + expect(versionService.isOutdated('8.1.0', '8.1.0')).toBe(false); + expect(versionService.isOutdated('8.2.0', '8.1.0')).toBe(false); + }); + }); + + describe('getStorybookVersionFromAncestry', () => { + it('should extract version from create-storybook command', () => { + const ancestry = [ + { command: 'npx create-storybook@8.0.5' }, + { command: 'node /usr/local/bin/npm' }, + ]; + + const version = versionService.getStorybookVersionFromAncestry(ancestry as any); + + expect(version).toBe('8.0.5'); + }); + + it('should extract version from storybook command', () => { + const ancestry = [ + { command: 'npx storybook@latest init' }, + { command: 'node /usr/local/bin/npm' }, + ]; + + const version = versionService.getStorybookVersionFromAncestry(ancestry as any); + + expect(version).toBe('latest'); + }); + + it('should return undefined if no version found', () => { + const ancestry = [{ command: 'npm install' }, { command: 'node /usr/local/bin/npm' }]; + + const version = versionService.getStorybookVersionFromAncestry(ancestry as any); + + expect(version).toBeUndefined(); + }); + }); + + describe('getCliIntegrationFromAncestry', () => { + it('should detect sv create command', () => { + const ancestry = [{ command: 'sv create my-app' }, { command: 'node /usr/local/bin/npm' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('sv create'); + }); + + it('should detect sv add command', () => { + const ancestry = [{ command: 'sv add storybook' }, { command: 'node /usr/local/bin/npm' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('sv add'); + }); + + it('should detect sv with version specifier', () => { + const ancestry = [{ command: 'sv@1.0.0 create my-app' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBe('sv create'); + }); + + it('should return undefined if no sv command found', () => { + const ancestry = [{ command: 'npm init' }, { command: 'node /usr/local/bin/npm' }]; + + const integration = versionService.getCliIntegrationFromAncestry(ancestry as any); + + expect(integration).toBeUndefined(); + }); + }); + + describe('getVersionInfo', () => { + it('should return complete version info for stable version', async () => { + vi.mocked(mockPackageManager.latestVersion).mockResolvedValue('8.1.0'); + + const versionInfo = await versionService.getVersionInfo(mockPackageManager); + + expect(versionInfo).toEqual({ + currentVersion: '8.0.0', + latestVersion: '8.1.0', + isPrerelease: false, + isOutdated: true, + }); + }); + + it('should not mark prerelease as outdated', async () => { + const prereleaseService = new VersionService(); + vi.mocked(mockPackageManager.latestVersion).mockResolvedValue('8.1.0'); + + // Mock getCurrentVersion to return prerelease + vi.spyOn(prereleaseService, 'getCurrentVersion').mockReturnValue('8.0.0-alpha.1'); + + const versionInfo = await prereleaseService.getVersionInfo(mockPackageManager); + + expect(versionInfo).toEqual({ + currentVersion: '8.0.0-alpha.1', + latestVersion: '8.1.0', + isPrerelease: true, + isOutdated: false, + }); + }); + + it('should handle null latest version', async () => { + vi.mocked(mockPackageManager.latestVersion).mockResolvedValue(null); + + const versionInfo = await versionService.getVersionInfo(mockPackageManager); + + expect(versionInfo).toEqual({ + currentVersion: '8.0.0', + latestVersion: null, + isPrerelease: false, + isOutdated: false, + }); + }); + }); +}); diff --git a/code/lib/create-storybook/src/services/VersionService.ts b/code/lib/create-storybook/src/services/VersionService.ts new file mode 100644 index 000000000000..019911ecebda --- /dev/null +++ b/code/lib/create-storybook/src/services/VersionService.ts @@ -0,0 +1,83 @@ +import type { JsPackageManager } from 'storybook/internal/common'; +import { versions } from 'storybook/internal/common'; + +import type { getProcessAncestry } from 'process-ancestry'; +import { lt, prerelease } from 'semver'; + +/** Service for handling version-related operations during Storybook initialization */ +export class VersionService { + /** Get the current Storybook version */ + getCurrentVersion(): string { + return versions.storybook; + } + + /** Get the latest Storybook version from the package manager */ + async getLatestVersion(packageManager: JsPackageManager): Promise { + return packageManager.latestVersion('storybook'); + } + + /** Check if the current version is a prerelease version */ + isPrerelease(version: string): boolean { + return !!prerelease(version); + } + + /** Check if the current version is outdated compared to the latest version */ + isOutdated(currentVersion: string, latestVersion: string): boolean { + return lt(currentVersion, latestVersion); + } + + /** + * Extract Storybook version from process ancestry Looks for version specifiers in command history + * like: create-storybook@1.0.0 or storybook@1.0.0 + */ + getStorybookVersionFromAncestry( + ancestry: ReturnType + ): string | undefined { + for (const ancestor of ancestry.toReversed()) { + const match = ancestor.command?.match(/\s(?:create-storybook|storybook)@([^\s]+)/); + if (match) { + return match[1]; + } + } + return undefined; + } + + /** + * Extract CLI integration from process ancestry Detects if Storybook was invoked via sv create or + * sv add commands + */ + getCliIntegrationFromAncestry( + ancestry: ReturnType + ): string | undefined { + for (const ancestor of ancestry.toReversed()) { + const match = ancestor.command?.match(/(?:^|\s)(sv(?:@[^ ]+)? (?:create|add))/i); + if (match) { + return match[1].toLowerCase().includes('add') ? 'sv add' : 'sv create'; + } + } + return undefined; + } + + /** Get version info including current, latest, and prerelease status */ + async getVersionInfo(packageManager: JsPackageManager): Promise<{ + currentVersion: string; + latestVersion: string | null; + isPrerelease: boolean; + isOutdated: boolean; + }> { + const currentVersion = this.getCurrentVersion(); + const latestVersion = await this.getLatestVersion(packageManager); + const isPrereleaseVersion = this.isPrerelease(currentVersion); + const isOutdatedVersion = + latestVersion && !isPrereleaseVersion + ? this.isOutdated(currentVersion, latestVersion) + : false; + + return { + currentVersion, + latestVersion, + isPrerelease: isPrereleaseVersion, + isOutdated: isOutdatedVersion, + }; + } +} diff --git a/code/lib/create-storybook/src/services/index.ts b/code/lib/create-storybook/src/services/index.ts new file mode 100644 index 000000000000..8bba402027c4 --- /dev/null +++ b/code/lib/create-storybook/src/services/index.ts @@ -0,0 +1,12 @@ +/** + * Core services for Storybook initialization + * + * These services provide centralized, testable functionality for the init process + */ + +export { FeatureCompatibilityService } from './FeatureCompatibilityService'; +export type { FeatureCompatibilityResult } from './FeatureCompatibilityService'; + +export { TelemetryService } from './TelemetryService'; +export { AddonService } from './AddonService'; +export { VersionService } from './VersionService'; diff --git a/code/presets/create-react-app/src/index.ts b/code/presets/create-react-app/src/index.ts index e978a8f2ed8a..22cc3d9785bf 100644 --- a/code/presets/create-react-app/src/index.ts +++ b/code/presets/create-react-app/src/index.ts @@ -71,10 +71,10 @@ const webpack = async ( return webpackConfig; } - logger.info(`=> Loading Webpack configuration from \`${relative(CWD, scriptsPath)}\``); + logger.step(`Loading Webpack configuration from \`${relative(CWD, scriptsPath)}\``); // Remove existing rules related to JavaScript and TypeScript. - logger.info(`=> Removing existing JavaScript and TypeScript rules.`); + logger.step(`Removing existing JavaScript and TypeScript rules.`); const filteredRules = (webpackConfig.module?.rules as RuleSetRule[])?.filter((rule) => { if (typeof rule === 'string') { return false; @@ -89,7 +89,7 @@ const webpack = async ( const craWebpackConfig = require(craWebpackConfigPath)(webpackConfig.mode) as Configuration; // Select the relevant CRA rules and add the Storybook config directory. - logger.info(`=> Modifying Create React App rules.`); + logger.step(`Modifying Create React App rules.`); const craRules = await processCraConfig(craWebpackConfig, options); // NOTE: This is code replicated from diff --git a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts index 3d1bc7e954e8..117f3d1a931e 100644 --- a/code/presets/react-webpack/src/loaders/react-docgen-loader.ts +++ b/code/presets/react-webpack/src/loaders/react-docgen-loader.ts @@ -85,6 +85,7 @@ export default async function reactDocgenLoader( const tsconfig = TsconfigPaths.loadConfig(tsconfigPath); if (tsconfig.resultType === 'success') { + logger.debug('Using tsconfig paths for react-docgen'); matchPath = TsconfigPaths.createMatchPath(tsconfig.absoluteBaseUrl, tsconfig.paths, [ 'browser', 'module', diff --git a/code/vitest-setup.ts b/code/vitest-setup.ts index fe6fdcc0b9ed..8286e7c9c972 100644 --- a/code/vitest-setup.ts +++ b/code/vitest-setup.ts @@ -94,6 +94,7 @@ vi.mock('storybook/internal/node-logger', async (importOriginal) => { info: vi.fn(), trace: vi.fn(), debug: vi.fn(), + box: vi.fn(), verbose: vi.fn(), logBox: vi.fn(), intro: vi.fn(), diff --git a/code/yarn.lock b/code/yarn.lock index dfdb59b3dffd..8e9a3a02d44c 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -2114,7 +2114,7 @@ __metadata: languageName: node linkType: hard -"@clack/prompts@npm:^1.0.0-alpha.0": +"@clack/prompts@npm:1.0.0-alpha.6": version: 1.0.0-alpha.6 resolution: "@clack/prompts@npm:1.0.0-alpha.6" dependencies: @@ -2125,13 +2125,6 @@ __metadata: languageName: node linkType: hard -"@colors/colors@npm:1.5.0": - version: 1.5.0 - resolution: "@colors/colors@npm:1.5.0" - checksum: 10c0/eb42729851adca56d19a08e48d5a1e95efd2a32c55ae0323de8119052be0510d4b7a1611f2abcbf28c044a6c11e6b7d38f99fccdad7429300c37a8ea5fb95b44 - languageName: node - linkType: hard - "@design-systems/utils@npm:2.12.0": version: 2.12.0 resolution: "@design-systems/utils@npm:2.12.0" @@ -7777,15 +7770,12 @@ __metadata: "@types/semver": "npm:^7" "@vitest/browser-playwright": "npm:^4.0.1" "@vitest/runner": "npm:^4.0.1" - boxen: "npm:^8.0.1" empathic: "npm:^2.0.0" es-toolkit: "npm:^1.36.0" - execa: "npm:^8.0.1" istanbul-lib-report: "npm:^3.0.1" micromatch: "npm:^4.0.8" pathe: "npm:^1.1.2" picocolors: "npm:^1.1.0" - prompts: "npm:^2.4.0" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" semver: "npm:^7.6.3" @@ -7900,6 +7890,7 @@ __metadata: dependencies: "@storybook/csf-plugin": "workspace:*" "@types/node": "npm:^22.0.0" + "@vitest/mocker": "npm:3.2.4" empathic: "npm:^2.0.0" es-module-lexer: "npm:^1.5.0" glob: "npm:^10.0.0" @@ -7923,6 +7914,7 @@ __metadata: "@types/node": "npm:^22.0.0" "@types/pretty-hrtime": "npm:^1.0.0" "@types/webpack-hot-middleware": "npm:^2.25.6" + "@vitest/mocker": "npm:3.2.4" case-sensitive-paths-webpack-plugin: "npm:^2.4.0" cjs-module-lexer: "npm:^1.2.3" css-loader: "npm:^7.1.2" @@ -7957,21 +7949,17 @@ __metadata: "@types/cross-spawn": "npm:^6.0.6" "@types/prompts": "npm:^2.0.9" "@types/semver": "npm:^7.3.4" - boxen: "npm:^8.0.1" commander: "npm:^14.0.1" comment-json: "npm:^4.2.5" create-storybook: "workspace:*" cross-spawn: "npm:^7.0.6" empathic: "npm:^2.0.0" envinfo: "npm:^7.14.0" - execa: "npm:^9.6.0" - giget: "npm:^2.0.0" globby: "npm:^14.0.1" jscodeshift: "npm:^0.15.1" leven: "npm:^4.0.0" p-limit: "npm:^6.2.0" picocolors: "npm:^1.1.0" - prompts: "npm:^2.4.0" semver: "npm:^7.7.2" slash: "npm:^5.0.0" storybook: "workspace:*" @@ -11108,15 +11096,6 @@ __metadata: languageName: node linkType: hard -"ansi-align@npm:^3.0.1": - version: 3.0.1 - resolution: "ansi-align@npm:3.0.1" - dependencies: - string-width: "npm:^4.1.0" - checksum: 10c0/ad8b755a253a1bc8234eb341e0cec68a857ab18bf97ba2bda529e86f6e30460416523e0ec58c32e5c21f0ca470d779503244892873a5895dbd0c39c788e82467 - languageName: node - linkType: hard - "ansi-colors@npm:4.1.3, ansi-colors@npm:^4.1.1": version: 4.1.3 resolution: "ansi-colors@npm:4.1.3" @@ -12062,22 +12041,6 @@ __metadata: languageName: node linkType: hard -"boxen@npm:^8.0.1": - version: 8.0.1 - resolution: "boxen@npm:8.0.1" - dependencies: - ansi-align: "npm:^3.0.1" - camelcase: "npm:^8.0.0" - chalk: "npm:^5.3.0" - cli-boxes: "npm:^3.0.0" - string-width: "npm:^7.2.0" - type-fest: "npm:^4.21.0" - widest-line: "npm:^5.0.0" - wrap-ansi: "npm:^9.0.0" - checksum: 10c0/8c54f9797bf59eec0b44c9043d9cb5d5b2783dc673e4650235e43a5155c43334e78ec189fd410cf92056c1054aee3758279809deed115b49e68f1a1c6b3faa32 - languageName: node - linkType: hard - "bplist-parser@npm:^0.2.0": version: 0.2.0 resolution: "bplist-parser@npm:0.2.0" @@ -12695,7 +12658,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.0.1, chalk@npm:^5.3.0": +"chalk@npm:^5.0.1": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 @@ -12853,15 +12816,6 @@ __metadata: languageName: node linkType: hard -"citty@npm:^0.1.6": - version: 0.1.6 - resolution: "citty@npm:0.1.6" - dependencies: - consola: "npm:^3.2.3" - checksum: 10c0/d26ad82a9a4a8858c7e149d90b878a3eceecd4cfd3e2ed3cd5f9a06212e451fb4f8cbe0fa39a3acb1b3e8f18e22db8ee5def5829384bad50e823d4b301609b48 - languageName: node - linkType: hard - "cjs-module-lexer@npm:^1.2.3": version: 1.4.3 resolution: "cjs-module-lexer@npm:1.4.3" @@ -12885,13 +12839,6 @@ __metadata: languageName: node linkType: hard -"cli-boxes@npm:^3.0.0": - version: 3.0.0 - resolution: "cli-boxes@npm:3.0.0" - checksum: 10c0/4db3e8fbfaf1aac4fb3a6cbe5a2d3fa048bee741a45371b906439b9ffc821c6e626b0f108bdcd3ddf126a4a319409aedcf39a0730573ff050fdd7b6731e99fb9 - languageName: node - linkType: hard - "cli-cursor@npm:3.1.0, cli-cursor@npm:^3.1.0": version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" @@ -12933,19 +12880,6 @@ __metadata: languageName: node linkType: hard -"cli-table3@npm:^0.6.1": - version: 0.6.5 - resolution: "cli-table3@npm:0.6.5" - dependencies: - "@colors/colors": "npm:1.5.0" - string-width: "npm:^4.2.0" - dependenciesMeta: - "@colors/colors": - optional: true - checksum: 10c0/d7cc9ed12212ae68241cc7a3133c52b844113b17856e11f4f81308acc3febcea7cc9fd298e70933e294dd642866b29fd5d113c2c098948701d0c35f09455de78 - languageName: node - linkType: hard - "cli-truncate@npm:^3.1.0": version: 3.1.0 resolution: "cli-truncate@npm:3.1.0" @@ -13287,13 +13221,6 @@ __metadata: languageName: node linkType: hard -"confbox@npm:^0.2.2": - version: 0.2.2 - resolution: "confbox@npm:0.2.2" - checksum: 10c0/7c246588d533d31e8cdf66cb4701dff6de60f9be77ab54c0d0338e7988750ac56863cc0aca1b3f2046f45ff223a765d3e5d4977a7674485afcd37b6edf3fd129 - languageName: node - linkType: hard - "config-chain@npm:^1.1.13": version: 1.1.13 resolution: "config-chain@npm:1.1.13" @@ -13318,13 +13245,6 @@ __metadata: languageName: node linkType: hard -"consola@npm:^3.2.3, consola@npm:^3.4.0, consola@npm:^3.4.2": - version: 3.4.2 - resolution: "consola@npm:3.4.2" - checksum: 10c0/7cebe57ecf646ba74b300bcce23bff43034ed6fbec9f7e39c27cee1dc00df8a21cd336b466ad32e304ea70fba04ec9e890c200270de9a526ce021ba8a7e4c11a - languageName: node - linkType: hard - "console-browserify@npm:^1.2.0": version: 1.2.0 resolution: "console-browserify@npm:1.2.0" @@ -13568,14 +13488,10 @@ __metadata: dependencies: "@types/prompts": "npm:^2.0.9" "@types/semver": "npm:^7.3.4" - boxen: "npm:^8.0.1" commander: "npm:^14.0.1" empathic: "npm:^2.0.0" - execa: "npm:^5.0.0" - ora: "npm:^5.4.1" picocolors: "npm:^1.1.0" process-ancestry: "npm:^0.0.2" - prompts: "npm:^2.4.0" react: "npm:^18.2.0" semver: "npm:^7.6.2" storybook: "workspace:*" @@ -14076,13 +13992,6 @@ __metadata: languageName: node linkType: hard -"defu@npm:^6.1.4": - version: 6.1.4 - resolution: "defu@npm:6.1.4" - checksum: 10c0/2d6cc366262dc0cb8096e429368e44052fdf43ed48e53ad84cc7c9407f890301aa5fcb80d0995abaaf842b3949f154d060be4160f7a46cb2bc2f7726c81526f5 - languageName: node - linkType: hard - "delayed-stream@npm:~1.0.0": version: 1.0.0 resolution: "delayed-stream@npm:1.0.0" @@ -16085,23 +15994,6 @@ __metadata: languageName: node linkType: hard -"execa@npm:^5.0.0": - version: 5.1.1 - resolution: "execa@npm:5.1.1" - dependencies: - cross-spawn: "npm:^7.0.3" - get-stream: "npm:^6.0.0" - human-signals: "npm:^2.1.0" - is-stream: "npm:^2.0.0" - merge-stream: "npm:^2.0.0" - npm-run-path: "npm:^4.0.1" - onetime: "npm:^5.1.2" - signal-exit: "npm:^3.0.3" - strip-final-newline: "npm:^2.0.0" - checksum: 10c0/c8e615235e8de4c5addf2fa4c3da3e3aa59ce975a3e83533b4f6a71750fb816a2e79610dc5f1799b6e28976c9ae86747a36a606655bf8cb414a74d8d507b304f - languageName: node - linkType: hard - "execa@npm:^8.0.1": version: 8.0.1 resolution: "execa@npm:8.0.1" @@ -16119,7 +16011,7 @@ __metadata: languageName: node linkType: hard -"execa@npm:^9.5.2, execa@npm:^9.6.0": +"execa@npm:^9.5.2": version: 9.6.0 resolution: "execa@npm:9.6.0" dependencies: @@ -17088,7 +16980,7 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": +"get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" checksum: 10c0/49825d57d3fd6964228e6200a58169464b8e8970489b3acdc24906c782fb7f01f9f56f8e6653c4a50713771d6658f7cfe051e5eb8c12e334138c9c918b296341 @@ -17132,22 +17024,6 @@ __metadata: languageName: node linkType: hard -"giget@npm:^2.0.0": - version: 2.0.0 - resolution: "giget@npm:2.0.0" - dependencies: - citty: "npm:^0.1.6" - consola: "npm:^3.4.0" - defu: "npm:^6.1.4" - node-fetch-native: "npm:^1.6.6" - nypm: "npm:^0.6.0" - pathe: "npm:^2.0.3" - bin: - giget: dist/cli.mjs - checksum: 10c0/606d81652643936ee7f76653b4dcebc09703524ff7fd19692634ce69e3fc6775a377760d7508162379451c03bf43cc6f46716aeadeb803f7cef3fc53d0671396 - languageName: node - linkType: hard - "git-hooks-list@npm:1.0.3": version: 1.0.3 resolution: "git-hooks-list@npm:1.0.3" @@ -18041,13 +17917,6 @@ __metadata: languageName: node linkType: hard -"human-signals@npm:^2.1.0": - version: 2.1.0 - resolution: "human-signals@npm:2.1.0" - checksum: 10c0/695edb3edfcfe9c8b52a76926cd31b36978782062c0ed9b1192b36bebc75c4c87c82e178dfcb0ed0fc27ca59d434198aac0bd0be18f5781ded775604db22304a - languageName: node - linkType: hard - "human-signals@npm:^4.3.0": version: 4.3.1 resolution: "human-signals@npm:4.3.1" @@ -21472,13 +21341,6 @@ __metadata: languageName: node linkType: hard -"node-fetch-native@npm:^1.6.6": - version: 1.6.7 - resolution: "node-fetch-native@npm:1.6.7" - checksum: 10c0/8b748300fb053d21ca4d3db9c3ff52593d5e8f8a2d9fe90cbfad159676e324b954fdaefab46aeca007b5b9edab3d150021c4846444e4e8ab1f4e44cd3807be87 - languageName: node - linkType: hard - "node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -21777,21 +21639,6 @@ __metadata: languageName: node linkType: hard -"nypm@npm:^0.6.0": - version: 0.6.2 - resolution: "nypm@npm:0.6.2" - dependencies: - citty: "npm:^0.1.6" - consola: "npm:^3.4.2" - pathe: "npm:^2.0.3" - pkg-types: "npm:^2.3.0" - tinyexec: "npm:^1.0.1" - bin: - nypm: dist/cli.mjs - checksum: 10c0/b1aca658e29ed616ad6e487f9c3fd76773485ad75c1f99efe130ccb304de60b639a3dda43c3ce6c060113a3eebaee7ccbea554f5fbd1f244474181dc9bf3f17c - languageName: node - linkType: hard - "object-assign@npm:4.1.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -21937,7 +21784,7 @@ __metadata: languageName: node linkType: hard -"onetime@npm:^5.1.0, onetime@npm:^5.1.2": +"onetime@npm:^5.1.0": version: 5.1.2 resolution: "onetime@npm:5.1.2" dependencies: @@ -22047,7 +21894,7 @@ __metadata: languageName: node linkType: hard -"ora@npm:5.4.1, ora@npm:^5.4.1": +"ora@npm:5.4.1": version: 5.4.1 resolution: "ora@npm:5.4.1" dependencies: @@ -22741,17 +22588,6 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^2.3.0": - version: 2.3.0 - resolution: "pkg-types@npm:2.3.0" - dependencies: - confbox: "npm:^0.2.2" - exsolve: "npm:^1.0.7" - pathe: "npm:^2.0.3" - checksum: 10c0/d2bbddc5b81bd4741e1529c08ef4c5f1542bbdcf63498b73b8e1d84cff71806d1b8b1577800549bb569cb7aa20056257677b979bff48c97967cba7e64f72ae12 - languageName: node - linkType: hard - "pkg-up@npm:^2.0.0": version: 2.0.0 resolution: "pkg-up@npm:2.0.0" @@ -25652,7 +25488,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 @@ -26054,7 +25890,7 @@ __metadata: "@babel/parser": "npm:^7.26.9" "@babel/traverse": "npm:^7.26.9" "@babel/types": "npm:^7.26.8" - "@clack/prompts": "npm:^1.0.0-alpha.0" + "@clack/prompts": "npm:1.0.0-alpha.6" "@devtools-ds/object-inspector": "npm:^1.1.2" "@discoveryjs/json-ext": "npm:^0.5.3" "@emotion/cache": "npm:^11.14.0" @@ -26099,19 +25935,16 @@ __metadata: "@types/semver": "npm:^7.5.8" "@types/ws": "npm:^8" "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" "@vitest/spy": "npm:3.2.4" "@vitest/utils": "npm:^3.2.4" "@yarnpkg/esbuild-plugin-pnp": "npm:^3.0.0-rc.10" "@yarnpkg/fslib": "npm:2.10.3" "@yarnpkg/libzip": "npm:2.3.0" ansi-to-html: "npm:^0.7.2" - boxen: "npm:^8.0.1" browser-dtector: "npm:^3.4.0" bundle-require: "npm:^5.1.0" camelcase: "npm:^8.0.0" chai: "npm:^5.1.1" - cli-table3: "npm:^0.6.1" commander: "npm:^14.0.1" comment-parser: "npm:^1.4.1" copy-to-clipboard: "npm:^3.3.1" @@ -26155,7 +25988,6 @@ __metadata: polka: "npm:^1.0.0-next.28" prettier: "npm:^3.5.3" pretty-hrtime: "npm:^1.0.3" - prompts: "npm:^2.4.0" qrcode.react: "npm:^4.2.0" react: "npm:^18.2.0" react-aria-components: "patch:react-aria-components@npm%3A1.12.2#~/.yarn/patches/react-aria-components-npm-1.12.2-6c5dcdafab.patch" @@ -26267,7 +26099,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^7.0.0, string-width@npm:^7.2.0": +"string-width@npm:^7.0.0": version: 7.2.0 resolution: "string-width@npm:7.2.0" dependencies: @@ -26425,13 +26257,6 @@ __metadata: languageName: node linkType: hard -"strip-final-newline@npm:^2.0.0": - version: 2.0.0 - resolution: "strip-final-newline@npm:2.0.0" - checksum: 10c0/bddf8ccd47acd85c0e09ad7375409d81653f645fda13227a9d459642277c253d877b68f2e5e4d819fe75733b0e626bac7e954c04f3236f6d196f79c94fa4a96f - languageName: node - linkType: hard - "strip-final-newline@npm:^3.0.0": version: 3.0.0 resolution: "strip-final-newline@npm:3.0.0" @@ -26898,13 +26723,6 @@ __metadata: languageName: node linkType: hard -"tinyexec@npm:^1.0.1": - version: 1.0.2 - resolution: "tinyexec@npm:1.0.2" - checksum: 10c0/1261a8e34c9b539a9aae3b7f0bb5372045ff28ee1eba035a2a059e532198fe1a182ec61ac60fa0b4a4129f0c4c4b1d2d57355b5cb9aa2d17ac9454ecace502ee - languageName: node - linkType: hard - "tinyglobby@npm:^0.2.10, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.9": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -28943,15 +28761,6 @@ __metadata: languageName: node linkType: hard -"widest-line@npm:^5.0.0": - version: 5.0.0 - resolution: "widest-line@npm:5.0.0" - dependencies: - string-width: "npm:^7.0.0" - checksum: 10c0/6bd6cca8cda502ef50e05353fd25de0df8c704ffc43ada7e0a9cf9a5d4f4e12520485d80e0b77cec8a21f6c3909042fcf732aa9281e5dbb98cc9384a138b2578 - languageName: node - linkType: hard - "wildcard@npm:^2.0.1": version: 2.0.1 resolution: "wildcard@npm:2.0.1" diff --git a/docs/_snippets/storybook-main-webpackfinal-example.md b/docs/_snippets/storybook-main-webpackfinal-example.md index 5ec6c4b7646e..ffbd8bc2860b 100644 --- a/docs/_snippets/storybook-main-webpackfinal-example.md +++ b/docs/_snippets/storybook-main-webpackfinal-example.md @@ -1,11 +1,11 @@ ```js filename=".storybook/main.js" renderer="common" language="js" export function webpackFinal(config, { configDir }) { if (!isReactScriptsInstalled()) { - logger.info('=> Using base config because react-scripts is not installed.'); + logger.info('Using base config because react-scripts is not installed.'); return config; } - logger.info('=> Loading create-react-app config.'); + logger.info('Loading create-react-app config.'); return applyCRAWebpackConfig(config, configDir); } ``` diff --git a/docs/api/cli-options.mdx b/docs/api/cli-options.mdx index e3889911e503..6890686804ce 100644 --- a/docs/api/cli-options.mdx +++ b/docs/api/cli-options.mdx @@ -123,7 +123,7 @@ Options include: | `-s`, `--skip-install` | Skips the dependency installation step. Used only when you need to configure Storybook manually.
`storybook init --skip-install` | | `-t`, `--type` | Defines the [framework](../configure/integration/frameworks.mdx) to use for your Storybook instance.
`storybook init --type solid` | | `-y`, `--yes` | Skips interactive prompts and automatically installs Storybook per specified version, including all features.
`storybook init --yes` | -| `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs` and `test`, space separated.
`storybook init --features docs test` | +| `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs`, `test` and `a11y`, space separated.
`storybook init --features docs test a11y` | | `--package-manager` | Sets the package manager to use when installing Storybook.
Available package managers include `npm`, `yarn`, and `pnpm`.
`storybook init --package-manager pnpm` | | `--use-pnp` | Enables [Plug'n'Play](https://yarnpkg.com/features/pnp) support for Yarn. This option is only available when using Yarn as your package manager.
`storybook init --use-pnp` | | `-p`, `--parser` | Sets the [jscodeshift parser](https://github.com/facebook/jscodeshift#parser).
Available parsers include `babel`, `babylon`, `flow`, `ts`, and `tsx`.
`storybook init --parser tsx` | @@ -192,7 +192,7 @@ Options include: | `--debug` | Outputs more logs in the CLI to assist debugging.
`storybook upgrade --debug` | | `--disable-telemetry` | Disables Storybook's telemetry. Learn more about it [here](../configure/telemetry.mdx#how-to-opt-out).
`storybook upgrade --disable-telemetry` | | `--enable-crash-reports` | Enables sending crash reports to Storybook's telemetry. Learn more about it [here](../configure/telemetry.mdx#crash-reports-disabled-by-default).
`storybook upgrade --enable-crash-reports` | -| `--write-logs` | Write all debug logs to a file at the end of the run.
`storybook upgrade --write-logs` | +| `-logfile [path]` | Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided.
`storybook upgrade --logfile /tmp/debug-storybook.log` | | `--loglevel ` | Define log level: `debug`, `error`, `info`, `silent`, `trace`, or `warn` (default: `info`).
`storybook upgrade --loglevel debug` | ### `migrate` @@ -366,7 +366,7 @@ Options include: | `-s`, `--skip-install` | Skips the dependency installation step. Used only when you need to configure Storybook manually.
`create storybook --skip-install` | | `-t`, `--type` | Defines the [framework](../configure/integration/frameworks.mdx) to use for your Storybook instance.
`create storybook --type solid` | | `-y`, `--yes` | Skips interactive prompts and automatically installs Storybook per specified version, including all features.
`create storybook --yes` | -| `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs` and `test`, space separated.
`create storybook --features docs test` | +| `--features [...values]` | Use these features when installing, skipping the prompt. Supported values are `docs`, `test` and `a11y`, space separated.
`create storybook --features docs test a11y` | | `--package-manager` | Sets the package manager to use when installing Storybook.
Available package managers include `npm`, `yarn`, and `pnpm`.
`create storybook --package-manager pnpm` | | `--use-pnp` | Enables [Plug'n'Play](https://yarnpkg.com/features/pnp) support for Yarn. This option is only available when using Yarn as your package manager.
`create storybook --use-pnp` | | `-p`, `--parser` | Sets the [jscodeshift parser](https://github.com/facebook/jscodeshift#parser).
Available parsers include `babel`, `babylon`, `flow`, `ts`, and `tsx`.
`create storybook --parser tsx` | diff --git a/docs/releases/upgrading.mdx b/docs/releases/upgrading.mdx index a77d61fa5f6a..92c6534c30f1 100644 --- a/docs/releases/upgrading.mdx +++ b/docs/releases/upgrading.mdx @@ -108,14 +108,14 @@ storybook@latest upgrade [options] | `--loglevel ` | Define log level: `debug`, `error`, `info`, `silent`, `trace`, or `warn` (default: `info`) | | `--package-manager ` | Force package manager: `npm`, `pnpm`, `yarn1`, `yarn2`, or `bun` | | `-s, --skip-check` | Skip postinstall version and automigration checks | -| `--write-logs` | Write all debug logs to a file at the end of the run | +| `--logfile [path]` | Write all debug logs to the specified file at the end of the run. Defaults to debug-storybook.log when [path] is not provided. | | `-y, --yes` | Skip prompting the user | ### Example usage ```bash # Upgrade with logging for debugging -storybook@latest upgrade --loglevel debug --write-logs +storybook@latest upgrade --loglevel debug --logfile debug-storybook.log # Force upgrade without prompts storybook@latest upgrade --force --yes diff --git a/scripts/sandbox/generate.ts b/scripts/sandbox/generate.ts index 0ccff9a23327..8ef1092cd3e4 100755 --- a/scripts/sandbox/generate.ts +++ b/scripts/sandbox/generate.ts @@ -14,7 +14,10 @@ import { dedent } from 'ts-dedent'; import { temporaryDirectory } from '../../code/core/src/common/utils/cli'; import storybookVersions from '../../code/core/src/common/versions'; -import { allTemplates as sandboxTemplates } from '../../code/lib/cli-storybook/src/sandbox-templates'; +import { + type Template, + allTemplates as sandboxTemplates, +} from '../../code/lib/cli-storybook/src/sandbox-templates'; import { AFTER_DIR_NAME, BEFORE_DIR_NAME, @@ -26,7 +29,6 @@ import { esMain } from '../utils/esmain'; import type { OptionValues } from '../utils/options'; import { createOptions } from '../utils/options'; import { getStackblitzUrl, renderTemplate } from './utils/template'; -import type { GeneratorConfig } from './utils/types'; import { localizeYarnConfigFiles, setupYarn } from './utils/yarn'; const isCI = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true'; @@ -149,8 +151,34 @@ const addDocumentation = async ( await writeFile(join(afterDir, 'README.md'), contents); }; +const toFlags = (opts: Record): string[] => { + const result: string[] = []; + for (const [key, value] of Object.entries(opts)) { + if (value === undefined || value === null) { + continue; + } + if (typeof value === 'boolean') { + if (value) { + result.push(`--${key}`); + } + } else if (Array.isArray(value)) { + for (const v of value) { + result.push(`--${key} ${String(v)}`); + } + } else if (typeof value === 'string') { + // Normalize ProjectType-like values to lower-case for CLI + const val = key === 'type' ? value.toLowerCase() : value; + result.push(`--${key} ${val}`); + } else { + // Fallback: stringify + result.push(`--${key} ${JSON.stringify(value)}`); + } + } + return result; +}; + const runGenerators = async ( - generators: (GeneratorConfig & { dirName: string })[], + generators: (Template & { dirName: string })[], localRegistry = true, debug = false ) => { @@ -163,19 +191,15 @@ const runGenerators = async ( const limit = pLimit(1); const generationResults = await Promise.allSettled( - generators.map(({ dirName, name, script, expected, env }) => + generators.map(({ dirName, name, script, env, initOptions }) => limit(async () => { const baseDir = join(REPROS_DIRECTORY, dirName); const beforeDir = join(baseDir, BEFORE_DIR_NAME); try { let flags: string[] = ['--no-dev']; - if (expected.renderer === '@storybook/html') { - flags = ['--type html']; - } else if (expected.renderer === '@storybook/server') { - flags = ['--type server']; - } else if (expected.framework === '@storybook/react-native-web-vite') { - flags = ['--type react_native_web']; + if (initOptions && typeof initOptions === 'object') { + flags = [...flags, ...toFlags(initOptions as Record)]; } const time = process.hrtime(); diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index b1bdd77eb05c..f180df1bc6bf 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -12,9 +12,8 @@ import { join, relative, resolve, sep } from 'path'; import slash from 'slash'; import { dedent } from 'ts-dedent'; +import { SupportedLanguage } from '../../code/core/dist/types'; import { babelParse, types as t } from '../../code/core/src/babel'; -import { detectLanguage } from '../../code/core/src/cli/detect'; -import { SupportedLanguage } from '../../code/core/src/cli/project_types'; import { JsPackageManagerFactory } from '../../code/core/src/common/js-package-manager'; import storybookPackages from '../../code/core/src/common/versions'; import type { ConfigFile } from '../../code/core/src/csf-tools'; @@ -24,6 +23,7 @@ import { writeConfig, } from '../../code/core/src/csf-tools'; import type { TemplateKey } from '../../code/lib/cli-storybook/src/sandbox-templates'; +import { ProjectTypeService } from '../../code/lib/create-storybook/src/services/ProjectTypeService'; import type { PassedOptionValues, Task, TemplateDetails } from '../task'; import { executeCLIStep, steps } from '../utils/cli-step'; import { CODE_DIRECTORY, REPROS_DIRECTORY } from '../utils/constants'; @@ -197,7 +197,12 @@ export const init: Task['run'] = async ( await executeCLIStep(steps.init, { cwd, - optionValues: { debug, yes: true, 'skip-install': true, ...extra }, + optionValues: { + loglevel: debug ? 'debug' : 'info', + yes: true, + ...extra, + ...(template.initOptions || {}), + }, dryRun, debug, }); @@ -593,11 +598,13 @@ export const addStories: Task['run'] = async ( const mainConfig = await readConfig({ fileName: 'main', cwd }); const packageManager = JsPackageManagerFactory.getPackageManager({}, sandboxDir); + // Package manager types differ slightly due to private methods and compilation differences of types + const projectTypeService = new ProjectTypeService(packageManager as any); + // Ensure that we match the right stories in the stories directory updateStoriesField( mainConfig, - (await detectLanguage(packageManager as any as Parameters[0])) === - SupportedLanguage.JAVASCRIPT + (await projectTypeService.detectLanguage()) === SupportedLanguage.JAVASCRIPT ); const isCoreRenderer = diff --git a/scripts/tasks/sandbox.ts b/scripts/tasks/sandbox.ts index fca0e61dabee..55d4dc08b9e9 100644 --- a/scripts/tasks/sandbox.ts +++ b/scripts/tasks/sandbox.ts @@ -92,13 +92,11 @@ export const sandbox: Task = { const shouldAddVitestIntegration = !details.template.skipTasks?.includes('vitest-integration'); - options.addon.push('@storybook/addon-a11y'); - if (shouldAddVitestIntegration) { extraDeps.push('happy-dom'); if (details.template.expected.framework.includes('nextjs')) { - extraDeps.push('@storybook/nextjs-vite', 'jsdom'); + extraDeps.push('jsdom'); } // if (details.template.expected.renderer === '@storybook/svelte') { @@ -108,8 +106,6 @@ export const sandbox: Task = { // if (details.template.expected.framework === '@storybook/angular') { // extraDeps.push('@testing-library/angular', '@analogjs/vitest-angular'); // } - - options.addon.push('@storybook/addon-vitest'); } let startTime = now(); diff --git a/scripts/utils/cli-step.ts b/scripts/utils/cli-step.ts index cd3faaeb9b94..05b809b11cc5 100644 --- a/scripts/utils/cli-step.ts +++ b/scripts/utils/cli-step.ts @@ -41,7 +41,8 @@ export const steps = { options: createOptions({ yes: { type: 'boolean' }, type: { type: 'string' }, - debug: { type: 'boolean' }, + loglevel: { type: 'string' }, + builder: { type: 'string' }, 'skip-install': { type: 'boolean' }, }), },