diff --git a/code/core/src/common/js-package-manager/JsPackageManager.ts b/code/core/src/common/js-package-manager/JsPackageManager.ts index 66ac85860e72..1000cc7bf174 100644 --- a/code/core/src/common/js-package-manager/JsPackageManager.ts +++ b/code/core/src/common/js-package-manager/JsPackageManager.ts @@ -500,6 +500,10 @@ export abstract class JsPackageManager { stdio?: 'inherit' | 'pipe' ): string; public abstract findInstallations(pattern?: string[]): Promise; + public abstract findInstallations( + pattern?: string[], + options?: { depth: number } + ): Promise; public abstract parseErrorFromLogs(logs?: string): string; public executeCommandSync({ diff --git a/code/core/src/common/js-package-manager/NPMProxy.ts b/code/core/src/common/js-package-manager/NPMProxy.ts index 09379285d276..ff77aedfa95a 100644 --- a/code/core/src/common/js-package-manager/NPMProxy.ts +++ b/code/core/src/common/js-package-manager/NPMProxy.ts @@ -132,12 +132,12 @@ export class NPMProxy extends JsPackageManager { }); } - public async findInstallations(pattern: string[]) { - const exec = async ({ depth }: { depth: number }) => { + 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({ command: 'npm', - args: ['ls', '--json', `--depth=${depth}`, pipeToNull], + args: ['ls', '--json', `--depth=${packageDepth}`, pipeToNull], env: { FORCE_COLOR: 'false', }, @@ -145,7 +145,7 @@ export class NPMProxy extends JsPackageManager { }; try { - const commandResult = await exec({ depth: 99 }); + const commandResult = await exec({ packageDepth: depth }); const parsedOutput = JSON.parse(commandResult); return this.mapDependencies(parsedOutput, pattern); @@ -153,7 +153,7 @@ export class NPMProxy extends JsPackageManager { // when --depth is higher than 0, npm can return a non-zero exit code // in case the user's project has peer dependency issues. So we try again with no depth try { - const commandResult = await exec({ depth: 0 }); + const commandResult = await exec({ packageDepth: 0 }); const parsedOutput = JSON.parse(commandResult); return this.mapDependencies(parsedOutput, pattern); diff --git a/code/core/src/common/js-package-manager/PNPMProxy.ts b/code/core/src/common/js-package-manager/PNPMProxy.ts index c44172aebb86..41c2858763c8 100644 --- a/code/core/src/common/js-package-manager/PNPMProxy.ts +++ b/code/core/src/common/js-package-manager/PNPMProxy.ts @@ -98,16 +98,16 @@ export class PNPMProxy extends JsPackageManager { }); } - public async findInstallations(pattern: string[]) { - const commandResult = await this.executeCommand({ - command: 'pnpm', - args: ['list', pattern.map((p) => `"${p}"`).join(' '), '--json', '--depth=99'], - env: { - FORCE_COLOR: 'false', - }, - }); - + public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { try { + const commandResult = await this.executeCommand({ + command: 'pnpm', + args: ['list', pattern.map((p) => `"${p}"`).join(' '), '--json', `--depth=${depth}`], + env: { + FORCE_COLOR: 'false', + }, + }); + const parsedOutput = JSON.parse(commandResult); return this.mapDependencies(parsedOutput, pattern); } catch (e) { diff --git a/code/core/src/common/js-package-manager/Yarn1Proxy.ts b/code/core/src/common/js-package-manager/Yarn1Proxy.ts index 9924afd0fb91..b193d4db4f15 100644 --- a/code/core/src/common/js-package-manager/Yarn1Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn1Proxy.ts @@ -83,16 +83,22 @@ export class Yarn1Proxy extends JsPackageManager { return JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as Record; } - public async findInstallations(pattern: string[]) { - const commandResult = await this.executeCommand({ - command: 'yarn', - args: ['list', '--pattern', pattern.map((p) => `"${p}"`).join(' '), '--recursive', '--json'], - env: { - FORCE_COLOR: 'false', - }, - }); + public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { + const yarnArgs = ['list', '--pattern', pattern.map((p) => `"${p}"`).join(' '), '--json']; + + if (depth !== 0) { + yarnArgs.push('--recursive'); + } try { + const commandResult = await this.executeCommand({ + command: 'yarn', + args: yarnArgs.concat(pattern), + env: { + FORCE_COLOR: 'false', + }, + }); + const parsedOutput = JSON.parse(commandResult); return this.mapDependencies(parsedOutput, pattern); } catch (e) { diff --git a/code/core/src/common/js-package-manager/Yarn2Proxy.ts b/code/core/src/common/js-package-manager/Yarn2Proxy.ts index 0f824262df21..7917bc7e1ebd 100644 --- a/code/core/src/common/js-package-manager/Yarn2Proxy.ts +++ b/code/core/src/common/js-package-manager/Yarn2Proxy.ts @@ -120,16 +120,22 @@ export class Yarn2Proxy extends JsPackageManager { return this.executeCommand({ command: 'yarn', args: [command, ...args], cwd }); } - public async findInstallations(pattern: string[]) { - const commandResult = await this.executeCommand({ - command: 'yarn', - args: ['info', '--name-only', '--recursive', ...pattern], - env: { - FORCE_COLOR: 'false', - }, - }); + public async findInstallations(pattern: string[], { depth = 99 }: { depth?: number } = {}) { + const yarnArgs = ['info', '--name-only']; + + if (depth !== 0) { + yarnArgs.push('--recursive'); + } try { + const commandResult = await this.executeCommand({ + command: 'yarn', + args: yarnArgs.concat(pattern), + env: { + FORCE_COLOR: 'false', + }, + }); + return this.mapDependencies(commandResult, pattern); } catch (e) { return undefined; diff --git a/code/lib/cli/src/automigrate/fixes/index.ts b/code/lib/cli/src/automigrate/fixes/index.ts index 3d68c5eec7c7..531606a6d095 100644 --- a/code/lib/cli/src/automigrate/fixes/index.ts +++ b/code/lib/cli/src/automigrate/fixes/index.ts @@ -30,10 +30,12 @@ import { vta } from './vta'; import { upgradeStorybookRelatedDependencies } from './upgrade-storybook-related-dependencies'; import { autodocsTags } from './autodocs-tags'; import { initialGlobals } from './initial-globals'; +import { missingStorybookDependencies } from './missing-storybook-dependencies'; export * from '../types'; export const allFixes: Fix[] = [ + missingStorybookDependencies, addonsAPI, newFrameworks, cra5, diff --git a/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.test.ts b/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.test.ts new file mode 100644 index 000000000000..52f0e42d8ff0 --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, vi, it, beforeEach } from 'vitest'; +import type { JsPackageManager } from '@storybook/core/common'; +import stripAnsi from 'strip-ansi'; + +import { missingStorybookDependencies } from './missing-storybook-dependencies'; + +vi.mock('globby', () => ({ + __esModule: true, + globby: vi.fn().mockResolvedValue(['.storybook/manager.ts', 'path/to/file.stories.tsx']), +})); + +vi.mock('node:fs/promises', () => ({ + __esModule: true, + readFile: vi.fn().mockResolvedValue(` + // these are NOT installed, will be reported + import { someFunction } from '@storybook/preview-api'; + import { anotherFunction } from '@storybook/manager-api'; + import { SomeError } from '@storybook/core-events/server-errors'; + // this IS installed, will not be reported + import { yetAnotherFunction } from '@storybook/theming'; + `), +})); + +vi.mock('../../helpers', () => ({ + getStorybookVersionSpecifier: vi.fn().mockReturnValue('^8.1.10'), +})); + +const check = async ({ + packageManager, + storybookVersion = '8.1.10', +}: { + packageManager: JsPackageManager; + storybookVersion?: string; +}) => { + return missingStorybookDependencies.check({ + packageManager, + mainConfig: {} as any, + storybookVersion, + }); +}; + +describe('missingStorybookDependencies', () => { + const mockPackageManager = { + findInstallations: vi.fn().mockResolvedValue({ + dependencies: { + '@storybook/react': '8.1.0', + '@storybook/theming': '8.1.0', + }, + }), + retrievePackageJson: vi.fn().mockResolvedValue({ + dependencies: { + '@storybook/core': '8.1.0', + }, + }), + addDependencies: vi.fn().mockResolvedValue(undefined), + } as Partial; + + describe('check function', () => { + it('should identify missing dependencies', async () => { + const result = await check({ + packageManager: mockPackageManager as JsPackageManager, + }); + + expect(Object.keys(result!.packageUsage)).not.includes('@storybook/theming'); + expect(result).toEqual({ + packageUsage: { + '@storybook/preview-api': ['.storybook/manager.ts', 'path/to/file.stories.tsx'], + '@storybook/manager-api': ['.storybook/manager.ts', 'path/to/file.stories.tsx'], + '@storybook/core-events': ['.storybook/manager.ts', 'path/to/file.stories.tsx'], + }, + }); + }); + }); + + describe('prompt function', () => { + it('should provide a proper message with the missing dependencies', () => { + const packageUsage = { + '@storybook/preview-api': ['.storybook/manager.ts'], + '@storybook/manager-api': ['path/to/file.stories.tsx'], + }; + + const message = missingStorybookDependencies.prompt({ packageUsage }); + + expect(stripAnsi(message)).toMatchInlineSnapshot(` + "Found the following Storybook packages used in your project, but they are missing from your project dependencies: + - @storybook/manager-api: (1 file) + - @storybook/preview-api: (1 file) + + Referencing missing packages can cause your project to crash. We can automatically add them to your dependencies. + + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#failed-to-resolve-import-storybookx-error" + `); + }); + }); + + describe('run function', () => { + it('should add missing dependencies', async () => { + const dryRun = false; + const packageUsage = { + '@storybook/preview-api': ['.storybook/manager.ts'], + '@storybook/manager-api': ['path/to/file.stories.tsx'], + }; + + await missingStorybookDependencies.run!({ + result: { packageUsage }, + dryRun, + packageManager: mockPackageManager as JsPackageManager, + mainConfigPath: 'path/to/main-config.js', + }); + + expect(mockPackageManager.addDependencies).toHaveBeenNthCalledWith( + 1, + { installAsDevDependencies: true }, + ['@storybook/preview-api@8.1.10', '@storybook/manager-api@8.1.10'] + ); + expect(mockPackageManager.addDependencies).toHaveBeenNthCalledWith( + 2, + { installAsDevDependencies: true, skipInstall: true, packageJson: expect.anything() }, + ['@storybook/preview-api@^8.1.10', '@storybook/manager-api@^8.1.10'] + ); + }); + }); +}); diff --git a/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.ts b/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.ts new file mode 100644 index 000000000000..cdc9bf0fe505 --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/missing-storybook-dependencies.ts @@ -0,0 +1,157 @@ +import chalk from 'chalk'; +import { readFile } from 'node:fs/promises'; +import { dedent } from 'ts-dedent'; + +import type { Fix } from '../types'; +import { getStorybookVersionSpecifier } from '../../helpers'; +import type { InstallationMetadata, JsPackageManager } from '@storybook/core/common'; + +const logger = console; + +type PackageUsage = Record; + +interface MissingStorybookDependenciesOptions { + packageUsage: PackageUsage; +} + +const consolidatedPackages = [ + '@storybook/channels', + '@storybook/client-logger', + '@storybook/core-common', + '@storybook/core-events', + '@storybook/csf-tools', + '@storybook/docs-tools', + '@storybook/node-logger', + '@storybook/preview-api', + '@storybook/router', + '@storybook/telemetry', + '@storybook/theming', + '@storybook/types', + '@storybook/manager-api', + '@storybook/manager', + '@storybook/preview', + '@storybook/core-server', + '@storybook/builder-manager', + '@storybook/components', +]; + +async function checkInstallations( + packageManager: JsPackageManager, + packages: string[] +): Promise { + let result: Record = {}; + + // go through each package and get installation info at depth 0 to make sure + // the dependency is directly installed, else they could come from other dependencies + const promises = packages.map((pkg) => packageManager.findInstallations([pkg], { depth: 0 })); + + const analyses = await Promise.all(promises); + + analyses.forEach((analysis) => { + if (analysis?.dependencies) { + result = { + ...result, + ...analysis.dependencies, + }; + } + }); + + return result; +} + +/** + * Find usage of Storybook packages in the project files which are not present in the dependencies. + */ +export const missingStorybookDependencies: Fix = { + id: 'missingStorybookDependencies', + promptType: 'auto', + versionRange: ['<8.2', '>=8.2'], + + async check({ packageManager }) { + // Dynamically import globby because it is a pure ESM module + const { globby } = await import('globby'); + + const result = await checkInstallations(packageManager, consolidatedPackages); + if (!result) { + return null; + } + + const installedDependencies = Object.keys(result).sort(); + const dependenciesToCheck = consolidatedPackages.filter( + (pkg) => !installedDependencies.includes(pkg) + ); + + const patterns = ['**/.storybook/*', '**/*.stories.*', '**/*.story.*']; + + const files = await globby(patterns, { + ignore: ['**/node_modules/**'], + }); + const packageUsage: PackageUsage = {}; + + for (const file of files) { + const content = await readFile(file, 'utf-8'); + dependenciesToCheck.forEach((pkg) => { + // match imports like @storybook/theming or @storybook/theming/create + const regex = new RegExp(`['"]${pkg}(/[^'"]*)?['"]`); + if (regex.test(content)) { + if (!packageUsage[pkg]) { + packageUsage[pkg] = []; + } + packageUsage[pkg].push(file); + } + }); + } + + return Object.keys(packageUsage).length > 0 ? { packageUsage } : null; + }, + + prompt({ packageUsage }) { + return dedent` + Found the following Storybook packages used in your project, but they are missing from your project dependencies: + ${Object.entries(packageUsage) + .map( + ([pkg, files]) => + `- ${chalk.cyan(pkg)}: (${files.length} ${files.length === 1 ? 'file' : 'files'})` + ) + .sort() + .join('\n')} + + Referencing missing packages can cause your project to crash. We can automatically add them to your dependencies. + + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#failed-to-resolve-import-storybookx-error + `; + }, + + async run({ result: { packageUsage }, dryRun, packageManager }) { + logger.info( + `✅ Installing the following packages as devDependencies: ${Object.keys(packageUsage)}` + ); + if (!dryRun) { + const dependenciesToInstall = Object.keys(packageUsage); + const versionToInstall = getStorybookVersionSpecifier( + await packageManager.retrievePackageJson() + ); + + const versionToInstallWithoutModifiers = versionToInstall?.replace(/[\^~]/, ''); + + /** + * WORKAROUND: necessary for the following scenario: + * Storybook latest is currently at 8.2.2 + * User has all Storybook deps at ^8.2.1 + * We run e.g. npm install with the dependency@^8.2.1 + * The package.json will have ^8.2.1 but install 8.2.2 + * So we first install the exact version, then run code again + * to write to package.json to add the caret back, but without running install + */ + await packageManager.addDependencies( + { installAsDevDependencies: true }, + dependenciesToInstall.map((pkg) => `${pkg}@${versionToInstallWithoutModifiers}`) + ); + const packageJson = await packageManager.retrievePackageJson(); + await packageManager.addDependencies( + { installAsDevDependencies: true, skipInstall: true, packageJson }, + dependenciesToInstall.map((pkg) => `${pkg}@${versionToInstall}`) + ); + } + }, +}; diff --git a/code/lib/cli/src/automigrate/index.test.ts b/code/lib/cli/src/automigrate/index.test.ts index b7fc079655cd..7e5c7dacfb94 100644 --- a/code/lib/cli/src/automigrate/index.test.ts +++ b/code/lib/cli/src/automigrate/index.test.ts @@ -1,8 +1,7 @@ -import { vi, it, expect, describe, beforeEach } from 'vitest'; +import { vi, it, expect, describe, beforeEach, afterEach } from 'vitest'; import { runFixes } from './index'; import type { Fix } from './types'; import type { JsPackageManager, PackageJsonWithDepsAndDevDeps } from '@storybook/core/common'; -import { afterEach } from 'node:test'; const check1 = vi.fn(); const run1 = vi.fn();