diff --git a/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts b/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts new file mode 100644 index 000000000000..d4f9940a6a0e --- /dev/null +++ b/lib/cli/src/automigrate/fixes/eslint-plugin.test.ts @@ -0,0 +1,119 @@ +/* eslint-disable no-underscore-dangle */ +import dedent from 'ts-dedent'; +import { JsPackageManager } from '../../js-package-manager'; +import { eslintPlugin } from './eslint-plugin'; + +// eslint-disable-next-line global-require, jest/no-mocks-import +jest.mock('fs-extra', () => require('../../../../../__mocks__/fs-extra')); + +const checkEslint = async ({ + packageJson, + main = {}, + hasEslint = true, + eslintExtension = 'js', +}) => { + // eslint-disable-next-line global-require + require('fs-extra').__setMockFiles({ + '.storybook/main.js': !main ? null : `module.exports = ${JSON.stringify(main)};`, + [`.eslintrc.${eslintExtension}`]: !hasEslint + ? null + : dedent(` + module.exports = { + extends: ['plugin:react/recommended', 'airbnb-typescript', 'plugin:prettier/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + project: 'tsconfig.eslint.json', + }, + plugins: ['react', '@typescript-eslint'], + rules: { + 'some/rule': 'warn', + }, + } + `), + }); + const packageManager = { + retrievePackageJson: () => ({ dependencies: {}, devDependencies: {}, ...packageJson }), + } as JsPackageManager; + return eslintPlugin.check({ packageManager }); +}; + +describe('eslint-plugin fix', () => { + describe('should skip migration when', () => { + it('project does not have eslint installed', async () => { + const packageJson = { dependencies: {} }; + + await expect( + checkEslint({ + packageJson, + }) + ).resolves.toBeFalsy(); + }); + + it('project already contains eslint-plugin-storybook dependency', async () => { + const packageJson = { dependencies: { 'eslint-plugin-storybook': '^0.0.0' } }; + + await expect( + checkEslint({ + packageJson, + }) + ).resolves.toBeFalsy(); + }); + }); + + describe('when project does not contain eslint-plugin-storybook but has eslint installed', () => { + const packageJson = { dependencies: { '@storybook/react': '^6.2.0', eslint: '^7.0.0' } }; + + describe('should no-op and warn when', () => { + it('main.js is not found', async () => { + const loggerSpy = jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn); + const result = await checkEslint({ + packageJson, + main: null, + hasEslint: false, + }); + + expect(loggerSpy).toHaveBeenCalledWith('Unable to find storybook main.js config, skipping'); + + await expect(result).toBeFalsy(); + }); + + it('.eslintrc is not found', async () => { + const loggerSpy = jest.spyOn(console, 'warn').mockImplementationOnce(jest.fn); + const result = await checkEslint({ + packageJson, + hasEslint: false, + }); + + expect(loggerSpy).toHaveBeenCalledWith('Unable to find .eslintrc config file, skipping'); + + await expect(result).toBeFalsy(); + }); + }); + + describe('should install eslint plugin', () => { + it('when .eslintrc is using a supported extension', async () => { + await expect( + checkEslint({ + packageJson, + }) + ).resolves.toMatchObject({ + unsupportedExtension: undefined, + }); + }); + + it('when .eslintrc is using unsupported extension', async () => { + await expect( + checkEslint({ + packageJson, + eslintExtension: 'yml', + }) + ).resolves.toMatchObject({ unsupportedExtension: 'yml' }); + }); + }); + }); +}); diff --git a/lib/cli/src/automigrate/fixes/eslint-plugin.ts b/lib/cli/src/automigrate/fixes/eslint-plugin.ts new file mode 100644 index 000000000000..6426f1ea459d --- /dev/null +++ b/lib/cli/src/automigrate/fixes/eslint-plugin.ts @@ -0,0 +1,107 @@ +import chalk from 'chalk'; +import dedent from 'ts-dedent'; +import { ConfigFile, readConfig, writeConfig } from '@storybook/csf-tools'; + +import { findEslintFile, SUPPORTED_ESLINT_EXTENSIONS } from '../helpers/getEslintInfo'; +import { getStorybookInfo } from '../helpers/getStorybookInfo'; + +import type { Fix } from '../types'; + +const logger = console; + +interface EslintPluginRunOptions { + main: ConfigFile; + eslintFile: string; + unsupportedExtension?: string; +} + +/** + * Does the user not have eslint-plugin-storybook installed? + * + * If so: + * - Install it, and if possible configure it + */ +export const eslintPlugin: Fix = { + id: 'eslintPlugin', + + async check({ packageManager }) { + const packageJson = packageManager.retrievePackageJson(); + const { dependencies, devDependencies } = packageJson; + + const eslintPluginStorybook = + dependencies['eslint-plugin-storybook'] || devDependencies['eslint-plugin-storybook']; + const eslintDependency = dependencies.eslint || devDependencies.eslint; + + if (eslintPluginStorybook || !eslintDependency) { + return null; + } + + const config = getStorybookInfo(packageJson); + + const { mainConfig } = config; + if (!mainConfig) { + logger.warn('Unable to find storybook main.js config, skipping'); + return null; + } + + let eslintFile; + let unsupportedExtension; + try { + eslintFile = findEslintFile(); + } catch (err) { + unsupportedExtension = err.message; + } + + if (!eslintFile && !unsupportedExtension) { + logger.warn('Unable to find .eslintrc config file, skipping'); + 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); + + return { eslintFile, main, unsupportedExtension }; + }, + + prompt() { + return dedent` + We've detected you are not using our eslint-plugin. + + In order to have the best experience with Storybook and follow best practices, we advise you to install eslint-plugin-storybook. + + More info: ${chalk.yellow('https://github.com/storybookjs/eslint-plugin-storybook#readme')} + `; + }, + + async run({ result: { eslintFile, unsupportedExtension }, packageManager, dryRun }) { + const deps = [`eslint-plugin-storybook`]; + + logger.info(`✅ Adding dependencies: ${deps}`); + if (!dryRun) packageManager.addDependencies({ installAsDevDependencies: true }, deps); + + if (!dryRun && unsupportedExtension) { + logger.warn( + dedent(` + ⚠️ The plugin was successfuly installed but failed to configure. + + Found an .eslintrc config file with an unsupported automigration format: ${unsupportedExtension}. + Supported formats for automigration are: ${SUPPORTED_ESLINT_EXTENSIONS.join(', ')}. + + Please refer to https://github.com/storybookjs/eslint-plugin-storybook#usage to finish setting up the plugin manually. + `) + ); + + return; + } + + const eslint = await readConfig(eslintFile); + logger.info(`✅ Configuring eslint rules in ${eslint.fileName}`); + + if (!dryRun) { + logger.info(`✅ Adding Storybook to extends list`); + const extendsConfig = eslint.getFieldValue(['extends']) || []; + eslint.setFieldValue(['extends'], [...extendsConfig, 'plugin:storybook/recommended']); + await writeConfig(eslint); + } + }, +}; diff --git a/lib/cli/src/automigrate/fixes/index.ts b/lib/cli/src/automigrate/fixes/index.ts index be323e6ea0b1..2a30b3725dce 100644 --- a/lib/cli/src/automigrate/fixes/index.ts +++ b/lib/cli/src/automigrate/fixes/index.ts @@ -2,7 +2,8 @@ import { cra5 } from './cra5'; import { webpack5 } from './webpack5'; import { angular12 } from './angular12'; import { mainjsFramework } from './mainjsFramework'; +import { eslintPlugin } from './eslint-plugin'; import { Fix } from '../types'; export * from '../types'; -export const fixes: Fix[] = [cra5, webpack5, angular12, mainjsFramework]; +export const fixes: Fix[] = [cra5, webpack5, angular12, mainjsFramework, eslintPlugin]; diff --git a/lib/cli/src/automigrate/helpers/getEslintInfo.ts b/lib/cli/src/automigrate/helpers/getEslintInfo.ts new file mode 100644 index 000000000000..fb153b8fe5b9 --- /dev/null +++ b/lib/cli/src/automigrate/helpers/getEslintInfo.ts @@ -0,0 +1,20 @@ +import fse from 'fs-extra'; + +export const SUPPORTED_ESLINT_EXTENSIONS = ['js', 'cjs']; +const UNSUPPORTED_ESLINT_EXTENSIONS = ['yaml', 'yml', 'json']; + +export const findEslintFile = () => { + const filePrefix = '.eslintrc'; + const unsupportedExtension = UNSUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + + if (unsupportedExtension) { + throw new Error(unsupportedExtension); + } + + const extension = SUPPORTED_ESLINT_EXTENSIONS.find((ext: string) => + fse.existsSync(`${filePrefix}.${ext}`) + ); + return extension ? `${filePrefix}.${extension}` : null; +};