Skip to content

Commit

Permalink
feat: add prefer-importing-jest-globals rule (#1490)
Browse files Browse the repository at this point in the history
* feat: prefer importing jest globals [new rule]

- Fix #1101

Issue: #1101

* test(prefer-importing-jest-globals): use `FlatCompatRuleTester`

* test(prefer-importing-jest-globals): clean up tests

* test(prefer-importing-jest-globals): explicitly set the source type to `script`

* fix(prefer-importing-jest-globals): remove unneeded properties from rule config

* fix(prefer-importing-jest-globals): support template literals

* refactor(prefer-importing-jest-globals): use accessor utilities and optional chaining

* refactor(prefer-importing-jest-globals): remove unneeded code

* refactor(prefer-importing-jest-globals): use a Set

---------

Co-authored-by: Gareth Jones <Jones258@Gmail.com>
MadeinFrance and G-Rath authored Apr 6, 2024
1 parent 9aa7aee commit 37478d8
Showing 6 changed files with 718 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -328,6 +328,7 @@ set to warn in.\
| [prefer-expect-resolves](docs/rules/prefer-expect-resolves.md) | Prefer `await expect(...).resolves` over `expect(await ...)` syntax | | | 🔧 | |
| [prefer-hooks-in-order](docs/rules/prefer-hooks-in-order.md) | Prefer having hooks in a consistent order | | | | |
| [prefer-hooks-on-top](docs/rules/prefer-hooks-on-top.md) | Suggest having hooks before any test cases | | | | |
| [prefer-importing-jest-globals](docs/rules/prefer-importing-jest-globals.md) | Prefer importing Jest globals | | | 🔧 | |
| [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase test names | | | 🔧 | |
| [prefer-mock-promise-shorthand](docs/rules/prefer-mock-promise-shorthand.md) | Prefer mock resolved/rejected shorthands for promises | | | 🔧 | |
| [prefer-snapshot-hint](docs/rules/prefer-snapshot-hint.md) | Prefer including a hint with external snapshots | | | | |
47 changes: 47 additions & 0 deletions docs/rules/prefer-importing-jest-globals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Prefer importing Jest globals (`prefer-importing-jest-globals`)

🔧 This rule is automatically fixable by the
[`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

This rule aims to enforce explicit imports from `@jest/globals`.

1. This is useful for ensuring that the Jest APIs are imported the same way in
the codebase.
2. When you can't modify Jest's
[`injectGlobals`](https://jestjs.io/docs/configuration#injectglobals-boolean)
configuration property, this rule can help to ensure that the Jest globals
are imported explicitly and facilitate a migration to `@jest/globals`.

## Rule details

Examples of **incorrect** code for this rule

```js
/* eslint jest/prefer-importing-jest-globals: "error" */

describe('foo', () => {
it('accepts this input', () => {
// ...
});
});
```

Examples of **correct** code for this rule

```js
/* eslint jest/prefer-importing-jest-globals: "error" */

import { describe, it } from '@jest/globals';

describe('foo', () => {
it('accepts this input', () => {
// ...
});
});
```

## Further Reading

- [Documentation](https://jestjs.io/docs/api)
2 changes: 2 additions & 0 deletions src/__tests__/__snapshots__/rules.test.ts.snap
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/prefer-expect-resolves": "error",
"jest/prefer-hooks-in-order": "error",
"jest/prefer-hooks-on-top": "error",
"jest/prefer-importing-jest-globals": "error",
"jest/prefer-lowercase-title": "error",
"jest/prefer-mock-promise-shorthand": "error",
"jest/prefer-snapshot-hint": "error",
@@ -126,6 +127,7 @@ exports[`rules should export configs that refer to actual rules 1`] = `
"jest/prefer-expect-resolves": "error",
"jest/prefer-hooks-in-order": "error",
"jest/prefer-hooks-on-top": "error",
"jest/prefer-importing-jest-globals": "error",
"jest/prefer-lowercase-title": "error",
"jest/prefer-mock-promise-shorthand": "error",
"jest/prefer-snapshot-hint": "error",
2 changes: 1 addition & 1 deletion src/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import { existsSync } from 'fs';
import { resolve } from 'path';
import plugin from '../';

const numberOfRules = 52;
const numberOfRules = 53;
const ruleNames = Object.keys(plugin.rules);
const deprecatedRules = Object.entries(plugin.rules)
.filter(([, rule]) => rule.meta.deprecated)
504 changes: 504 additions & 0 deletions src/rules/__tests__/prefer-importing-jest-globals.test.ts

Large diffs are not rendered by default.

163 changes: 163 additions & 0 deletions src/rules/prefer-importing-jest-globals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils';
import {
createRule,
getAccessorValue,
getSourceCode,
isIdentifier,
isStringNode,
isSupportedAccessor,
parseJestFnCall,
} from './utils';

const createFixerImports = (
isModule: boolean,
functionsToImport: Set<string>,
) => {
const allImportsFormatted = Array.from(functionsToImport).sort().join(', ');

return isModule
? `import { ${allImportsFormatted} } from '@jest/globals';`
: `const { ${allImportsFormatted} } = require('@jest/globals');`;
};

export default createRule({
name: __filename,
meta: {
docs: {
description: 'Prefer importing Jest globals',
},
messages: {
preferImportingJestGlobal: `Import the following Jest functions from '@jest/globals': {{ jestFunctions }}`,
},
fixable: 'code',
type: 'problem',
schema: [],
},
defaultOptions: [],
create(context) {
const importedFunctionsWithSource: Record<string, string> = {};
const functionsToImport = new Set<string>();
let reportingNode: TSESTree.Node;

return {
ImportDeclaration(node: TSESTree.ImportDeclaration) {
node.specifiers.forEach(specifier => {
if (specifier.type === 'ImportSpecifier') {
importedFunctionsWithSource[specifier.local.name] =
node.source.value;
}
});
},
CallExpression(node: TSESTree.CallExpression) {
const jestFnCall = parseJestFnCall(node, context);

if (!jestFnCall) {
return;
}

if (jestFnCall.head.type !== 'import') {
functionsToImport.add(jestFnCall.name);
reportingNode ||= jestFnCall.head.node;
}
},
'Program:exit'() {
// this means we found at least one function to import
if (!reportingNode) {
return;
}

const isModule = context.parserOptions.sourceType === 'module';

context.report({
node: reportingNode,
messageId: 'preferImportingJestGlobal',
data: { jestFunctions: Array.from(functionsToImport).join(', ') },
fix(fixer) {
const sourceCode = getSourceCode(context);
const [firstNode] = sourceCode.ast.body;

// check if "use strict" directive exists
if (
firstNode.type === AST_NODE_TYPES.ExpressionStatement &&
isStringNode(firstNode.expression, 'use strict')
) {
return fixer.insertTextAfter(
firstNode,
`\n${createFixerImports(isModule, functionsToImport)}`,
);
}

const importNode = sourceCode.ast.body.find(
node =>
node.type === AST_NODE_TYPES.ImportDeclaration &&
node.source.value === '@jest/globals',
);

if (importNode?.type === AST_NODE_TYPES.ImportDeclaration) {
for (const specifier of importNode.specifiers) {
if (
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
specifier.imported?.name
) {
functionsToImport.add(specifier.imported.name);
}

if (specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier) {
functionsToImport.add(specifier.local.name);
}
}

return fixer.replaceText(
importNode,
createFixerImports(isModule, functionsToImport),
);
}

const requireNode = sourceCode.ast.body.find(
node =>
node.type === AST_NODE_TYPES.VariableDeclaration &&
node.declarations.some(
declaration =>
declaration.init?.type === AST_NODE_TYPES.CallExpression &&
isIdentifier(declaration.init.callee, 'require') &&
isStringNode(
declaration.init.arguments[0],
'@jest/globals',
) &&
(declaration.id.type === AST_NODE_TYPES.Identifier ||
declaration.id.type === AST_NODE_TYPES.ObjectPattern),
),
);

if (requireNode?.type !== AST_NODE_TYPES.VariableDeclaration) {
return fixer.insertTextBefore(
reportingNode,
`${createFixerImports(isModule, functionsToImport)}\n`,
);
}

if (
requireNode.declarations[0]?.id.type ===
AST_NODE_TYPES.ObjectPattern
) {
for (const property of requireNode.declarations[0].id
.properties) {
if (
property.type === AST_NODE_TYPES.Property &&
isSupportedAccessor(property.key)
) {
functionsToImport.add(getAccessorValue(property.key));
}
}
}

return fixer.replaceText(
requireNode,
`${createFixerImports(isModule, functionsToImport)}`,
);
},
});
},
};
},
});

0 comments on commit 37478d8

Please sign in to comment.