-
-
Notifications
You must be signed in to change notification settings - Fork 9.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #16550 from storybookjs/feature/eslint-plugin-auto…
…migrate CLI: Add eslint-plugin-storybook to automigrate
- Loading branch information
Showing
4 changed files
with
248 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<EslintPluginRunOptions> = { | ||
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); | ||
} | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |