From 05f20b80ecd42b8d1f1f18ca19d4bc9cba45e22e Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sat, 9 May 2020 12:00:57 +1200 Subject: [PATCH] feat(no-deprecated-functions): support jest `version` setting (#564) * feat(no-deprecated-functions): support jest `version` setting * chore(no-deprecated-functions): cache jest version --- README.md | 17 ++ docs/rules/no-deprecated-functions.md | 22 +- package.json | 1 + .../__tests__/no-deprecated-functions.test.ts | 284 +++++++++++++++--- src/rules/no-deprecated-functions.ts | 82 ++++- yarn.lock | 5 + 6 files changed, 360 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 450638c9b..ae8aa58b9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,23 @@ You can also whitelist the environment variables provided by Jest by doing: } ``` +The behaviour of some rules (specifically `no-deprecated-functions`) change +depending on the version of `jest` being used. + +This setting is detected automatically based off the version of the `jest` +package installed in `node_modules`, but it can also be provided explicitly if +desired: + +```json +{ + "settings": { + "jest": { + "version": 26 + } + } +} +``` + ## Shareable configurations ### Recommended diff --git a/docs/rules/no-deprecated-functions.md b/docs/rules/no-deprecated-functions.md index 18a87affe..7800088a9 100644 --- a/docs/rules/no-deprecated-functions.md +++ b/docs/rules/no-deprecated-functions.md @@ -9,10 +9,20 @@ of majors, eventually they are removed completely. ## Rule details This rule warns about calls to deprecated functions, and provides details on -what to replace them with. +what to replace them with, based on the version of Jest that is installed. This rule can also autofix a number of these deprecations for you. +### `jest.resetModuleRegistry` + +This function was renamed to `resetModules` in Jest 15, and is scheduled for +removal in Jest 27. + +### `jest.addMatchers` + +This function was replaced with `expect.extend` in Jest 17, and is scheduled for +removal in Jest 27. + ### `require.requireActual` & `require.requireMock` These functions were replaced in Jest 21 and removed in Jest 26. @@ -25,16 +35,6 @@ for type checkers to handle, and their use via `require` deprecated. Finally, the release of Jest 26 saw them removed from the `require` function all together. -### `jest.addMatchers` - -This function was replaced with `expect.extend` in Jest 17, and is scheduled for -removal in Jest 27. - -### `jest.resetModuleRegistry` - -This function was renamed to `resetModules` in Jest 15, and is scheduled for -removal in Jest 27. - ### `jest.runTimersToTime` This function was renamed to `advanceTimersByTime` in Jest 22, and is scheduled diff --git a/package.json b/package.json index 734d3c8f0..07eb1829a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@babel/preset-typescript": "^7.3.3", "@commitlint/cli": "^8.2.0", "@commitlint/config-conventional": "^8.2.0", + "@schemastore/package": "^0.0.5", "@semantic-release/changelog": "^3.0.5", "@semantic-release/git": "^7.0.17", "@types/eslint": "^6.1.3", diff --git a/src/rules/__tests__/no-deprecated-functions.test.ts b/src/rules/__tests__/no-deprecated-functions.test.ts index 40805d2ca..7ffdd3fe8 100644 --- a/src/rules/__tests__/no-deprecated-functions.test.ts +++ b/src/rules/__tests__/no-deprecated-functions.test.ts @@ -1,49 +1,265 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package'; import { TSESLint } from '@typescript-eslint/experimental-utils'; -import rule from '../no-deprecated-functions'; +import rule, { + JestVersion, + _clearCachedJestVersion, +} from '../no-deprecated-functions'; const ruleTester = new TSESLint.RuleTester(); -[ - ['require.requireMock', 'jest.requireMock'], - ['require.requireActual', 'jest.requireActual'], - ['jest.addMatchers', 'expect.extend'], - ['jest.resetModuleRegistry', 'jest.resetModules'], - ['jest.runTimersToTime', 'jest.advanceTimersByTime'], - ['jest.genMockFromModule', 'jest.createMockFromModule'], -].forEach(([deprecation, replacement]) => { +/** + * Makes a new temp directory, prefixed with `eslint-plugin-jest-` + * + * @return {Promise} + */ +const makeTempDir = async () => + fs.mkdtempSync(path.join(os.tmpdir(), 'eslint-plugin-jest-')); + +/** + * Sets up a fake project with a `package.json` file located in + * `node_modules/jest` whose version is set to the given `jestVersion`. + * + * @param {JestVersion} jestVersion + * + * @return {Promise} + */ +const setupFakeProjectDirectory = async ( + jestVersion: JestVersion, +): Promise => { + const jestPackageJson: JSONSchemaForNPMPackageJsonFiles = { + name: 'jest', + version: `${jestVersion}.0.0`, + }; + + const tempDir = await makeTempDir(); + const jestPackagePath = path.join(tempDir, 'node_modules', 'jest'); + + // todo: remove in node@10 & replace with { recursive: true } + fs.mkdirSync(path.join(tempDir, 'node_modules')); + + fs.mkdirSync(jestPackagePath); + await fs.writeFileSync( + path.join(jestPackagePath, 'package.json'), + JSON.stringify(jestPackageJson), + ); + + return tempDir; +}; + +const generateValidCases = ( + jestVersion: JestVersion | undefined, + functionCall: string, +): Array> => { + const [name, func] = functionCall.split('.'); + const settings = { jest: { version: jestVersion } } as const; + + return [ + { settings, code: `${functionCall}()` }, + { settings, code: `${functionCall}` }, + { settings, code: `${name}['${func}']()` }, + { settings, code: `${name}['${func}']` }, + ]; +}; + +const generateInvalidCases = ( + jestVersion: JestVersion | undefined, + deprecation: string, + replacement: string, +): Array> => { const [deprecatedName, deprecatedFunc] = deprecation.split('.'); const [replacementName, replacementFunc] = replacement.split('.'); + const settings = { jest: { version: jestVersion } }; + const errors: [TSESLint.TestCaseError<'deprecatedFunction'>] = [ + { messageId: 'deprecatedFunction', data: { deprecation, replacement } }, + ]; + + return [ + { + code: `${deprecation}()`, + output: `${replacement}()`, + settings, + errors, + }, + { + code: `${deprecatedName}['${deprecatedFunc}']()`, + output: `${replacementName}['${replacementFunc}']()`, + settings, + errors, + }, + ]; +}; + +describe('the jest version cache', () => { + beforeEach(async () => process.chdir(await setupFakeProjectDirectory(17))); + + // change the jest version *after* each test case + afterEach(async () => { + const jestPackageJson: JSONSchemaForNPMPackageJsonFiles = { + name: 'jest', + version: '24.0.0', + }; + + const tempDir = process.cwd(); + + await fs.writeFileSync( + path.join(tempDir, 'node_modules', 'jest', 'package.json'), + JSON.stringify(jestPackageJson), + ); + }); + + ruleTester.run('no-deprecated-functions', rule, { + valid: [ + 'require("fs")', // this will cause jest version to be read & cached + 'jest.requireActual()', // deprecated after jest 17 + ], + invalid: [], + }); +}); - ruleTester.run(`${deprecation} -> ${replacement}`, rule, { +// contains the cache-clearing beforeEach so we can test the cache too +describe('the rule', () => { + beforeEach(() => _clearCachedJestVersion()); + + // a few sanity checks before doing our massive loop + ruleTester.run('no-deprecated-functions', rule, { valid: [ 'jest', 'require("fs")', - `${replacement}()`, - replacement, - `${replacementName}['${replacementFunc}']()`, - `${replacementName}['${replacementFunc}']`, + ...generateValidCases(14, 'jest.resetModuleRegistry'), + ...generateValidCases(17, 'require.requireActual'), + ...generateValidCases(25, 'jest.genMockFromModule'), ], invalid: [ - { - code: `${deprecation}()`, - output: `${replacement}()`, - errors: [ - { - messageId: 'deprecatedFunction', - data: { deprecation, replacement }, - }, - ], - }, - { - code: `${deprecatedName}['${deprecatedFunc}']()`, - output: `${replacementName}['${replacementFunc}']()`, - errors: [ - { - messageId: 'deprecatedFunction', - data: { deprecation, replacement }, - }, - ], - }, + ...generateInvalidCases( + 21, + 'jest.resetModuleRegistry', + 'jest.resetModules', + ), + ...generateInvalidCases(24, 'jest.addMatchers', 'expect.extend'), + ...generateInvalidCases( + 26, + 'jest.genMockFromModule', + 'jest.createMockFromModule', + ), ], }); + + describe.each([ + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + ])('when using jest version %i', jestVersion => { + beforeEach(async () => + process.chdir(await setupFakeProjectDirectory(jestVersion)), + ); + + const allowedFunctions: string[] = []; + const deprecations = ([ + [15, 'jest.resetModuleRegistry', 'jest.resetModules'], + [17, 'jest.addMatchers', 'expect.extend'], + [21, 'require.requireMock', 'jest.requireMock'], + [21, 'require.requireActual', 'jest.requireActual'], + [22, 'jest.runTimersToTime', 'jest.advanceTimersByTime'], + [26, 'jest.genMockFromModule', 'jest.createMockFromModule'], + ] as const).filter(deprecation => { + if (deprecation[0] > jestVersion) { + allowedFunctions.push(deprecation[1]); + + return false; + } + + return true; + }); + + ruleTester.run('explict jest version', rule, { + valid: [ + 'jest', + 'require("fs")', + ...allowedFunctions + .map(func => generateValidCases(jestVersion, func)) + .reduce((acc, arr) => acc.concat(arr), []), + ], + invalid: deprecations + .map(([, deprecation, replacement]) => + generateInvalidCases(jestVersion, deprecation, replacement), + ) + .reduce((acc, arr) => acc.concat(arr), []), + }); + + ruleTester.run('detected jest version', rule, { + valid: [ + 'jest', + 'require("fs")', + ...allowedFunctions + .map(func => generateValidCases(undefined, func)) + .reduce((acc, arr) => acc.concat(arr), []), + ], + invalid: deprecations + .map(([, deprecation, replacement]) => + generateInvalidCases(undefined, deprecation, replacement), + ) + .reduce((acc, arr) => acc.concat(arr), []), + }); + }); + + describe('when no jest version is provided', () => { + describe('when the jest package.json is missing the version property', () => { + beforeEach(async () => { + const tempDir = await setupFakeProjectDirectory(1); + + await fs.writeFileSync( + path.join(tempDir, 'node_modules', 'jest', 'package.json'), + JSON.stringify({}), + ); + + process.chdir(tempDir); + }); + + it('requires the version to be set explicitly', () => { + expect(() => { + const linter = new TSESLint.Linter(); + + linter.defineRule('no-deprecated-functions', rule); + + linter.verify('jest.resetModuleRegistry()', { + rules: { 'no-deprecated-functions': 'error' }, + }); + }).toThrow( + 'Unable to detect Jest version - please ensure jest package is installed, or otherwise set version explicitly', + ); + }); + }); + + describe('when the jest package.json is not found', () => { + beforeEach(async () => process.chdir(await makeTempDir())); + + it('requires the version to be set explicitly', () => { + expect(() => { + const linter = new TSESLint.Linter(); + + linter.defineRule('no-deprecated-functions', rule); + + linter.verify('jest.resetModuleRegistry()', { + rules: { 'no-deprecated-functions': 'error' }, + }); + }).toThrow( + 'Unable to detect Jest version - please ensure jest package is installed, or otherwise set version explicitly', + ); + }); + }); + }); }); diff --git a/src/rules/no-deprecated-functions.ts b/src/rules/no-deprecated-functions.ts index 60c0ad382..cdbf42d11 100644 --- a/src/rules/no-deprecated-functions.ts +++ b/src/rules/no-deprecated-functions.ts @@ -1,9 +1,65 @@ +import { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package'; import { AST_NODE_TYPES, TSESTree, } from '@typescript-eslint/experimental-utils'; import { createRule, getNodeName } from './utils'; +interface ContextSettings { + jest?: EslintPluginJestSettings; +} + +export type JestVersion = + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24 + | 25 + | 26 + | 27 + | number; + +interface EslintPluginJestSettings { + version: JestVersion; +} + +let cachedJestVersion: JestVersion | null = null; + +/** @internal */ +export const _clearCachedJestVersion = () => (cachedJestVersion = null); + +const detectJestVersion = (): JestVersion => { + if (cachedJestVersion) { + return cachedJestVersion; + } + + try { + const jestPath = require.resolve('jest/package.json', { + paths: [process.cwd()], + }); + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const jestPackageJson = require(jestPath) as JSONSchemaForNPMPackageJsonFiles; + + if (jestPackageJson.version) { + const [majorVersion] = jestPackageJson.version.split('.'); + + return (cachedJestVersion = parseInt(majorVersion, 10)); + } + } catch {} + + throw new Error( + 'Unable to detect Jest version - please ensure jest package is installed, or otherwise set version explicitly', + ); +}; + export default createRule({ name: __filename, meta: { @@ -22,13 +78,27 @@ export default createRule({ }, defaultOptions: [], create(context) { + const jestVersion = + (context.settings as ContextSettings)?.jest?.version || + detectJestVersion(); + const deprecations: Record = { - 'require.requireMock': 'jest.requireMock', - 'require.requireActual': 'jest.requireActual', - 'jest.addMatchers': 'expect.extend', - 'jest.resetModuleRegistry': 'jest.resetModules', - 'jest.runTimersToTime': 'jest.advanceTimersByTime', - 'jest.genMockFromModule': 'jest.createMockFromModule', + ...(jestVersion >= 15 && { + 'jest.resetModuleRegistry': 'jest.resetModules', + }), + ...(jestVersion >= 17 && { + 'jest.addMatchers': 'expect.extend', + }), + ...(jestVersion >= 21 && { + 'require.requireMock': 'jest.requireMock', + 'require.requireActual': 'jest.requireActual', + }), + ...(jestVersion >= 22 && { + 'jest.runTimersToTime': 'jest.advanceTimersByTime', + }), + ...(jestVersion >= 26 && { + 'jest.genMockFromModule': 'jest.createMockFromModule', + }), }; return { diff --git a/yarn.lock b/yarn.lock index 38a98798a..fc646aa4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1259,6 +1259,11 @@ dependencies: any-observable "^0.3.0" +"@schemastore/package@^0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@schemastore/package/-/package-0.0.5.tgz#67b621d5c833ad2d5a29a1acf868717b7839bb8a" + integrity sha512-0XEiMT/Rh8I0SEIO81fo5MN3AHhONFv9SJ1IIJ5OTI3PN/jG032OPlHUMxvrun7yc6YUGtufVg2WPQVK8PaH5Q== + "@semantic-release/changelog@^3.0.5": version "3.0.6" resolved "https://registry.yarnpkg.com/@semantic-release/changelog/-/changelog-3.0.6.tgz#9d68d68bf732cbba1034c028bb6720091f783b2a"