diff --git a/code/lib/cli/src/automigrate/fixes/index.ts b/code/lib/cli/src/automigrate/fixes/index.ts index 766d7db30352..e43ad3f2025d 100644 --- a/code/lib/cli/src/automigrate/fixes/index.ts +++ b/code/lib/cli/src/automigrate/fixes/index.ts @@ -7,6 +7,7 @@ import { eslintPlugin } from './eslint-plugin'; import { builderVite } from './builder-vite'; import { npm7 } from './npm7'; import { sbScripts } from './sb-scripts'; +import { newFrameworks } from './new-frameworks'; import { Fix } from '../types'; export * from '../types'; @@ -20,4 +21,5 @@ export const fixes: Fix[] = [ builderVite, npm7, sbScripts, + newFrameworks, ]; diff --git a/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts b/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts new file mode 100644 index 000000000000..482389d3bf8e --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/new-frameworks.test.ts @@ -0,0 +1,229 @@ +/* eslint-disable no-underscore-dangle */ +import path from 'path'; +import { JsPackageManager } from '../../js-package-manager'; +import { newFrameworks } from './new-frameworks'; + +// eslint-disable-next-line global-require, jest/no-mocks-import +jest.mock('fs-extra', () => require('../../../../../__mocks__/fs-extra')); + +const checkNewFrameworks = async ({ packageJson, main }) => { + if (main) { + // eslint-disable-next-line global-require + require('fs-extra').__setMockFiles({ + [path.join('.storybook', 'main.js')]: `module.exports = ${JSON.stringify(main)};`, + }); + } + const packageManager = { + retrievePackageJson: () => ({ dependencies: {}, devDependencies: {}, ...packageJson }), + } as JsPackageManager; + return newFrameworks.check({ packageManager }); +}; + +describe('new-frameworks fix', () => { + describe('should no-op', () => { + it('in sb < 7', async () => { + const packageJson = { dependencies: { '@storybook/vue': '^6.2.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: {}, + }) + ).resolves.toBeFalsy(); + }); + + it('in sb 7 with no main', async () => { + const packageJson = { dependencies: { '@storybook/vue': '^7.0.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: undefined, + }) + ).resolves.toBeFalsy(); + }); + + it('in sb 7 with no framework field in main', async () => { + const packageJson = { dependencies: { '@storybook/vue': '^7.0.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: {}, + }) + ).resolves.toBeFalsy(); + }); + + it('in sb 7 with no builder', async () => { + const packageJson = { dependencies: { '@storybook/vue': '^7.0.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/vue', + }, + }) + ).resolves.toEqual( + expect.objectContaining({ + frameworkPackage: '@storybook/vue-webpack5', + dependenciesToAdd: ['@storybook/vue-webpack5'], + dependenciesToRemove: ['@storybook/vue'], + }) + ); + }); + + it('in sb 7 with unsupported package', async () => { + const packageJson = { dependencies: { '@storybook/riot': '^7.0.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/riot', + core: { + builder: 'webpack5', + }, + }, + }) + ).resolves.toBeFalsy(); + }); + + // TODO: once we have vite frameworks e.g. @storybook/react-vite, then we should remove this test + it('in sb 7 with vite', async () => { + const packageJson = { dependencies: { '@storybook/react': '^7.0.0' } }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/react', + core: { + builder: '@storybook/builder-vite', + }, + }, + }) + ).resolves.toBeFalsy(); + }); + }); + + describe('sb >= 7', () => { + describe('new-frameworks dependency', () => { + it('should update to @storybook/react-webpack5', async () => { + const packageJson = { + dependencies: { + '@storybook/react': '^7.0.0-alpha.0', + '@storybook/builder-webpack5': '^6.5.9', + '@storybook/manager-webpack5': '^6.5.9', + }, + }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/react', + core: { + builder: { + name: 'webpack5', + options: { + lazyCompilation: true, + }, + }, + }, + reactOptions: { + fastRefresh: true, + }, + }, + }) + ).resolves.toEqual( + expect.objectContaining({ + frameworkPackage: '@storybook/react-webpack5', + dependenciesToAdd: ['@storybook/react-webpack5'], + dependenciesToRemove: [ + '@storybook/builder-webpack5', + '@storybook/manager-webpack5', + '@storybook/react', + ], + frameworkOptions: { + fastRefresh: true, + }, + builderInfo: { + name: 'webpack5', + options: { + lazyCompilation: true, + }, + }, + }) + ); + }); + it('should update only builders in @storybook/angular', async () => { + const packageJson = { + dependencies: { + '@storybook/angular': '^7.0.0-alpha.0', + '@storybook/builder-webpack5': '^6.5.9', + '@storybook/manager-webpack5': '^6.5.9', + }, + }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/angular', + core: { + builder: { + name: 'webpack5', + options: { + lazyCompilation: true, + }, + }, + }, + angularOptions: { + enableIvy: true, + }, + }, + }) + ).resolves.toEqual( + expect.objectContaining({ + frameworkPackage: '@storybook/angular', + dependenciesToAdd: [], + dependenciesToRemove: ['@storybook/builder-webpack5', '@storybook/manager-webpack5'], + frameworkOptions: { + enableIvy: true, + }, + builderInfo: { + name: 'webpack5', + options: { + lazyCompilation: true, + }, + }, + }) + ); + }); + }); + + // TODO: enable this once Vite is supported + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should update to @storybook/react-vite', async () => { + const packageJson = { + dependencies: { + '@storybook/react': '^7.0.0-alpha.0', + '@storybook/builder-vite': '^0.0.2', + }, + }; + await expect( + checkNewFrameworks({ + packageJson, + main: { + framework: '@storybook/react', + core: { + builder: '@storybook/builder-vite', + }, + }, + }) + ).resolves.toEqual( + expect.objectContaining({ + dependenciesToAdd: '@storybook/react-vite', + dependenciesToRemove: [ + '@storybook/react', + 'storybook-builder-vite', + '@storybook/builder-vite', + ], + }) + ); + }); + }); +}); diff --git a/code/lib/cli/src/automigrate/fixes/new-frameworks.ts b/code/lib/cli/src/automigrate/fixes/new-frameworks.ts new file mode 100644 index 000000000000..f288787485bd --- /dev/null +++ b/code/lib/cli/src/automigrate/fixes/new-frameworks.ts @@ -0,0 +1,245 @@ +import chalk from 'chalk'; +import dedent from 'ts-dedent'; +import semver from '@storybook/semver'; +import { ConfigFile, readConfig, writeConfig } from '@storybook/csf-tools'; +import { getStorybookInfo } from '@storybook/core-common'; + +import type { Fix } from '../types'; +import type { PackageJsonWithDepsAndDevDeps } from '../../js-package-manager'; +import { getStorybookVersionSpecifier } from '../../helpers'; + +const logger = console; + +const packagesMap = { + '@storybook/react': { + webpack5: '@storybook/react-webpack5', + vite: '@storybook/react-vite', + }, + '@storybook/preact': { + webpack5: '@storybook/preact-webpack5', + }, + '@storybook/server': { + webpack5: '@storybook/server-webpack5', + }, + '@storybook/angular': { + webpack5: '@storybook/angular', + }, + '@storybook/vue': { + webpack5: '@storybook/vue-webpack5', + vite: '@storybook/vue-vite', + }, + '@storybook/vue3': { + webpack5: '@storybook/vue3-webpack5', + vite: '@storybook/vue3-vite', + }, + '@storybook/svelte': { + webpack5: '@storybook/svelte-webpack5', + vite: '@storybook/svelte-vite', + }, + '@storybook/web-components': { + webpack5: '@storybook/web-components-webpack5', + }, + '@storybook/html': { + webpack5: '@storybook/html-webpack5', + }, +}; + +interface NewFrameworkRunOptions { + main: ConfigFile; + packageJson: PackageJsonWithDepsAndDevDeps; + dependenciesToAdd: string[]; + dependenciesToRemove: string[]; + frameworkPackage: keyof typeof packagesMap; + frameworkOptions: Record; + builderInfo: { + name: string; + options: Record; + }; +} + +export const getBuilder = (builder: string | { name: string }) => { + if (typeof builder === 'string') { + return builder.includes('vite') ? 'vite' : 'webpack5'; + } + + return builder?.name.includes('vite') ? 'vite' : 'webpack5'; +}; + +export const getFrameworkOptions = (framework: string, main: ConfigFile) => { + const frameworkOptions = main.getFieldValue([`${framework}Options`]); + return frameworkOptions || {}; +}; + +/** + * Does the user have separate framework and builders (e.g. @storybook/react + core.builder -> webpack5? + * + * If so: + * - Remove the dependencies (@storybook/react + @storybook/builder-webpack5 + @storybook/manager-webpack5) + * - Install the correct new package e.g. (@storybook/react-webpack5) + * - Update the main config to use the new framework + * -- moving core.builder into framework.options.builder + * -- moving frameworkOptions (e.g. reactOptions) into framework.options + */ +export const newFrameworks: Fix = { + id: 'newFrameworks', + + async check({ packageManager }) { + const packageJson = packageManager.retrievePackageJson(); + const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies }; + + const config = getStorybookInfo(packageJson); + const { mainConfig, version: storybookVersion, framework } = config; + if (!mainConfig) { + logger.warn('Unable to find storybook main.js config, skipping'); + return null; + } + + const storybookCoerced = storybookVersion && semver.coerce(storybookVersion)?.version; + if (!storybookCoerced) { + logger.warn(dedent` + ❌ Unable to determine storybook version, skipping ${chalk.cyan('newFrameworks')} fix. + 🤔 Are you running automigrate from your project directory? + `); + return null; + } + + if (!semver.gte(storybookCoerced, '7.0.0')) { + return null; + } + + // If in the future the eslint plugin has a framework option, using main to extract the framework field will be very useful + const main = await readConfig(mainConfig); + + const frameworkPackage = main.getFieldValue(['framework']) as keyof typeof packagesMap; + const builder = main.getFieldValue(['core', 'builder']); + + if (!frameworkPackage) { + return null; + } + + const supportedPackages = Object.keys(packagesMap); + if (!supportedPackages.includes(frameworkPackage)) { + return null; + } + + const builderInfo = { + name: getBuilder(builder), + options: main.getFieldValue(['core', 'builder', 'options']) || {}, + } as const; + + // TODO: once we have vite frameworks e.g. @storybook/react-vite, then we support it here + // and remove ['storybook-builder-vite', '@storybook/builder-vite'] from deps + if (builderInfo.name === 'vite') { + return null; + } + + const frameworkOptions = getFrameworkOptions(framework, main); + + const dependenciesToRemove = [ + '@storybook/builder-webpack5', + '@storybook/manager-webpack5', + '@storybook/builder-webpack4', + '@storybook/manager-webpack4', + ].filter((dep) => allDeps[dep]); + + const newFrameworkPackage = packagesMap[frameworkPackage][builderInfo.name]; + const dependenciesToAdd = []; + + // some frameworks didn't change e.g. Angular, Ember + if (newFrameworkPackage !== frameworkPackage) { + dependenciesToRemove.push(frameworkPackage); + dependenciesToAdd.push(newFrameworkPackage); + } + + return { + main, + dependenciesToAdd, + dependenciesToRemove, + frameworkPackage: newFrameworkPackage, + frameworkOptions, + builderInfo, + packageJson, + }; + }, + + prompt() { + return dedent` + We've detected you are using an older format of Storybook frameworks and builders. + + In Storybook 7, frameworks also specify the builder to be used. + + We can remove the dependencies that are no longer needed and install the new framework that already includes the builder. + + To learn more about the framework field, see: ${chalk.yellow( + 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#framework-field-mandatory' + )} + + ${chalk.underline(chalk.bold(chalk.cyan('Webpack4 users')))} + + Unless you're using Storybook's Vite builder, this automigration will install a Webpack5-based framework. + + If you were using Storybook's Webpack4 builder (default in 6.x, discontinued in 7.0), this could be a breaking + change--especially if your project has a custom webpack configuration. + + To learn more about migrating from Webpack4, see: ${chalk.yellow( + 'https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#webpack4-support-discontinued' + )} + `; + }, + + async run({ + result: { + dependenciesToAdd, + dependenciesToRemove, + main, + frameworkPackage, + frameworkOptions, + builderInfo, + packageJson, + }, + packageManager, + dryRun, + }) { + logger.info(`✅ Removing legacy dependencies: ${dependenciesToRemove.join(', ')}`); + if (!dryRun) { + packageManager.removeDependencies( + { skipInstall: dependenciesToAdd.length > 0, packageJson }, + dependenciesToRemove + ); + } + if (dependenciesToAdd.length > 0) { + logger.info(`✅ Installing new dependencies: ${dependenciesToAdd.join(', ')}`); + if (!dryRun) { + const versionToInstall = getStorybookVersionSpecifier(packageJson); + const depsToAdd = dependenciesToAdd.map((dep) => `${dep}@${versionToInstall}`); + packageManager.addDependencies({ installAsDevDependencies: true }, depsToAdd); + } + } + + if (!dryRun) { + logger.info(`✅ Updating framework field in main.js`); + const currentCore = main.getFieldValue(['core']); + main.setFieldValue(['framework', 'name'], frameworkPackage); + main.setFieldValue(['framework', 'options'], frameworkOptions); + + if (currentCore?.builder) { + delete currentCore.builder; + } + + if (Object.keys(builderInfo.options).length > 0) { + main.setFieldValue(['framework', 'options', 'builder'], builderInfo.options); + } + + if (currentCore) { + if (Object.keys(currentCore).length === 0) { + // TODO: this should delete the field instead + main.setFieldValue(['core'], {}); + } else { + main.setFieldValue(['core'], currentCore); + } + } + + await writeConfig(main); + } + }, +}; diff --git a/code/lib/cli/src/automigrate/index.ts b/code/lib/cli/src/automigrate/index.ts index a20325c86119..0eab9bca3d9c 100644 --- a/code/lib/cli/src/automigrate/index.ts +++ b/code/lib/cli/src/automigrate/index.ts @@ -57,7 +57,7 @@ export const automigrate = async ({ fixId, dryRun, yes }: FixOptions = {}) => { logger.info(`✅ ran ${chalk.cyan(f.id)} migration`); } catch (error) { logger.info(`❌ error when running ${chalk.cyan(f.id)} migration:`); - logger.info(error.message); + logger.info(error); logger.info(); } } else {