From 35ddfedd58de975ca2c235a5295dfa28aab11ac5 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 21 Aug 2022 10:50:38 +1200 Subject: [PATCH] feat: switch to new scope-based jest fn call parser to support `@jest/globals` (#20) * feat: switch to new scope-based jest fn call parser to support `@jest/globals` * chore: remove unneeded utils * test: add some extra cases for coverage * ci: only collect coverage on runs using ESLint v8+ * chore: add back test project --- .github/workflows/nodejs.yml | 10 +- package.json | 3 + .../__tests__/prefer-to-be-array.test.ts | 2 + .../__tests__/prefer-to-be-object.test.ts | 1 + src/rules/prefer-to-be-array.ts | 52 +- src/rules/prefer-to-be-false.ts | 43 +- src/rules/prefer-to-be-object.ts | 52 +- src/rules/prefer-to-be-true.ts | 43 +- src/rules/prefer-to-have-been-called-once.ts | 41 +- src/rules/utils.ts | 520 ---------- .../utils/__tests__/parseJestFnCall.test.ts | 915 ++++++++++++++++++ src/rules/utils/accessors.ts | 169 ++++ src/rules/utils/followTypeAssertionChain.ts | 36 + src/rules/utils/index.ts | 4 + src/rules/utils/misc.ts | 164 ++++ src/rules/utils/parseJestFnCall.ts | 577 +++++++++++ yarn.lock | 17 + 17 files changed, 1996 insertions(+), 653 deletions(-) delete mode 100644 src/rules/utils.ts create mode 100644 src/rules/utils/__tests__/parseJestFnCall.test.ts create mode 100644 src/rules/utils/accessors.ts create mode 100644 src/rules/utils/followTypeAssertionChain.ts create mode 100644 src/rules/utils/index.ts create mode 100644 src/rules/utils/misc.ts create mode 100644 src/rules/utils/parseJestFnCall.ts diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index bd9453b..8134e6a 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -3,12 +3,10 @@ name: Unit tests & Release on: push: branches: - - master - main - next pull_request: branches: - - master - main - next @@ -87,10 +85,12 @@ jobs: yarn yarn add --dev eslint@${{ matrix.eslint-version }} - name: run tests - run: yarn test --coverage + # only collect coverage on eslint versions that support dynamic import + run: yarn test --coverage ${{ matrix.eslint-version >= 8 }} env: CI: true - uses: codecov/codecov-action@v3 + if: ${{ matrix.eslint-version >= 8 }} test-os: name: Test on ${{ matrix.os }} using Node.js LTS needs: prepare-yarn-cache @@ -109,10 +109,12 @@ jobs: - name: install run: yarn - name: run tests - run: yarn test --coverage + # only collect coverage on eslint versions that support dynamic import + run: yarn test --coverage ${{ matrix.eslint-version >= 8 }} env: CI: true - uses: codecov/codecov-action@v3 + if: ${{ matrix.eslint-version >= 8 }} docs: if: ${{ github.event_name == 'pull_request' }} diff --git a/package.json b/package.json index d5ee111..93415b3 100644 --- a/package.json +++ b/package.json @@ -112,9 +112,11 @@ "@babel/preset-typescript": "^7.3.3", "@commitlint/cli": "^16.0.0", "@commitlint/config-conventional": "^16.0.0", + "@schemastore/package": "^0.0.6", "@semantic-release/changelog": "^6.0.0", "@semantic-release/git": "^10.0.0", "@tsconfig/node12": "^1.0.11", + "@types/dedent": "^0.7.0", "@types/jest": "^28.0.0", "@types/node": "^16.0.0", "@types/prettier": "^2.7.0", @@ -122,6 +124,7 @@ "@typescript-eslint/parser": "^5.0.0", "babel-jest": "^28.0.0", "babel-plugin-replace-ts-export-assignment": "^0.0.2", + "dedent": "^0.7.0", "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-eslint-comments": "^3.1.2", diff --git a/src/rules/__tests__/prefer-to-be-array.test.ts b/src/rules/__tests__/prefer-to-be-array.test.ts index 9f723ec..80774fb 100644 --- a/src/rules/__tests__/prefer-to-be-array.test.ts +++ b/src/rules/__tests__/prefer-to-be-array.test.ts @@ -50,6 +50,8 @@ const createTestsForEqualityMatchers = (): Array< ruleTester.run('prefer-to-be-array', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', 'expect', 'expect()', 'expect().toBe(true)', diff --git a/src/rules/__tests__/prefer-to-be-object.test.ts b/src/rules/__tests__/prefer-to-be-object.test.ts index 629dccd..e9007e2 100644 --- a/src/rules/__tests__/prefer-to-be-object.test.ts +++ b/src/rules/__tests__/prefer-to-be-object.test.ts @@ -37,6 +37,7 @@ const createTestsForEqualityMatchers = (): Array< ruleTester.run('prefer-to-be-object', rule, { valid: [ 'expect.hasAssertions', + 'expect.hasAssertions()', 'expect', 'expect().not', 'expect().toBe', diff --git a/src/rules/prefer-to-be-array.ts b/src/rules/prefer-to-be-array.ts index 5c1cd4d..c1b6109 100644 --- a/src/rules/prefer-to-be-array.ts +++ b/src/rules/prefer-to-be-array.ts @@ -1,14 +1,13 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { - ModifierName, createRule, followTypeAssertionChain, + getAccessorValue, isBooleanEqualityMatcher, - isExpectCall, isInstanceOfBinaryExpression, isParsedInstanceOfMatcherCall, isSupportedAccessor, - parseExpectCall, + parseJestFnCall, } from './utils'; const isArrayIsArrayCall = ( @@ -42,25 +41,21 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } - - const { expect, modifier, matcher } = parseExpectCall(node); + const jestFnCall = parseJestFnCall(node, context); - if (!matcher) { + if (jestFnCall?.type !== 'expect') { return; } - if (isParsedInstanceOfMatcherCall(matcher, 'Array')) { + if (isParsedInstanceOfMatcherCall(jestFnCall, 'Array')) { context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeArray', fix: fixer => [ fixer.replaceTextRange( [ - matcher.node.property.range[0], - matcher.node.property.range[1] + '(Array)'.length, + jestFnCall.matcher.range[0], + jestFnCall.matcher.range[1] + '(Array)'.length, ], 'toBeArray()', ), @@ -70,11 +65,17 @@ export default createRule({ return; } + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { + return; + } + const [expectArg] = expect.arguments; if ( !expectArg || - !isBooleanEqualityMatcher(matcher) || + !isBooleanEqualityMatcher(jestFnCall) || !( isArrayIsArrayCall(expectArg) || isInstanceOfBinaryExpression(expectArg, 'Array') @@ -84,11 +85,11 @@ export default createRule({ } context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeArray', fix(fixer) { const fixes = [ - fixer.replaceText(matcher.node.property, 'toBeArray'), + fixer.replaceText(jestFnCall.matcher, 'toBeArray'), expectArg.type === AST_NODE_TYPES.CallExpression ? fixer.remove(expectArg.callee) : fixer.removeRange([ @@ -97,10 +98,11 @@ export default createRule({ ]), ]; - let invertCondition = matcher.name === 'toBeFalse'; + let invertCondition = + getAccessorValue(jestFnCall.matcher) === 'toBeFalse'; - if (matcher.arguments?.length) { - const [matcherArg] = matcher.arguments; + if (jestFnCall.args.length) { + const [matcherArg] = jestFnCall.args; fixes.push(fixer.remove(matcherArg)); @@ -111,13 +113,17 @@ export default createRule({ } if (invertCondition) { + const notModifier = jestFnCall.modifiers.find( + nod => getAccessorValue(nod) === 'not', + ); + fixes.push( - modifier && modifier.name === ModifierName.not + notModifier ? fixer.removeRange([ - modifier.node.property.range[0] - 1, - modifier.node.property.range[1], + notModifier.range[0] - 1, + notModifier.range[1], ]) - : fixer.insertTextBefore(matcher.node.property, 'not.'), + : fixer.insertTextBefore(jestFnCall.matcher, 'not.'), ); } diff --git a/src/rules/prefer-to-be-false.ts b/src/rules/prefer-to-be-false.ts index 4711c69..1dc87f6 100644 --- a/src/rules/prefer-to-be-false.ts +++ b/src/rules/prefer-to-be-false.ts @@ -1,13 +1,10 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { - MaybeTypeCast, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, + EqualityMatcher, createRule, - followTypeAssertionChain, - isExpectCall, - isParsedEqualityMatcherCall, - parseExpectCall, + getAccessorValue, + getFirstMatcherArg, + parseJestFnCall, } from './utils'; interface FalseLiteral extends TSESTree.BooleanLiteral { @@ -17,20 +14,6 @@ interface FalseLiteral extends TSESTree.BooleanLiteral { const isFalseLiteral = (node: TSESTree.Node): node is FalseLiteral => node.type === AST_NODE_TYPES.Literal && node.value === false; -/** - * Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers, - * with a `false` literal as the sole argument. - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedEqualityMatcherCall>} - */ -const isFalseEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedEqualityMatcherCall> => - isParsedEqualityMatcherCall(matcher) && - isFalseLiteral(followTypeAssertionChain(matcher.arguments[0])); - export default createRule({ name: __filename, meta: { @@ -50,19 +33,23 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher } = parseExpectCall(node); - - if (matcher && isFalseEqualityMatcher(matcher)) { + if ( + jestFnCall.args.length === 1 && + isFalseLiteral(getFirstMatcherArg(jestFnCall)) && + EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher)) + ) { context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeFalse', fix: fixer => [ - fixer.replaceText(matcher.node.property, 'toBeFalse'), - fixer.remove(matcher.arguments[0]), + fixer.replaceText(jestFnCall.matcher, 'toBeFalse'), + fixer.remove(jestFnCall.args[0]), ], }); } diff --git a/src/rules/prefer-to-be-object.ts b/src/rules/prefer-to-be-object.ts index 767ebee..4ef8617 100644 --- a/src/rules/prefer-to-be-object.ts +++ b/src/rules/prefer-to-be-object.ts @@ -1,13 +1,12 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { - ModifierName, createRule, followTypeAssertionChain, + getAccessorValue, isBooleanEqualityMatcher, - isExpectCall, isInstanceOfBinaryExpression, isParsedInstanceOfMatcherCall, - parseExpectCall, + parseJestFnCall, } from './utils'; export type MessageIds = 'preferToBeObject'; @@ -33,25 +32,21 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } - - const { expect, modifier, matcher } = parseExpectCall(node); + const jestFnCall = parseJestFnCall(node, context); - if (!matcher) { + if (jestFnCall?.type !== 'expect') { return; } - if (isParsedInstanceOfMatcherCall(matcher, 'Object')) { + if (isParsedInstanceOfMatcherCall(jestFnCall, 'Object')) { context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeObject', fix: fixer => [ fixer.replaceTextRange( [ - matcher.node.property.range[0], - matcher.node.property.range[1] + '(Object)'.length, + jestFnCall.matcher.range[0], + jestFnCall.matcher.range[1] + '(Object)'.length, ], 'toBeObject()', ), @@ -61,29 +56,36 @@ export default createRule({ return; } + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { + return; + } + const [expectArg] = expect.arguments; if ( !expectArg || - !isBooleanEqualityMatcher(matcher) || + !isBooleanEqualityMatcher(jestFnCall) || !isInstanceOfBinaryExpression(expectArg, 'Object') ) { return; } context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeObject', fix(fixer) { const fixes = [ - fixer.replaceText(matcher.node.property, 'toBeObject'), + fixer.replaceText(jestFnCall.matcher, 'toBeObject'), fixer.removeRange([expectArg.left.range[1], expectArg.range[1]]), ]; - let invertCondition = matcher.name === 'toBeFalse'; + let invertCondition = + getAccessorValue(jestFnCall.matcher) === 'toBeFalse'; - if (matcher.arguments?.length) { - const [matcherArg] = matcher.arguments; + if (jestFnCall.args?.length) { + const [matcherArg] = jestFnCall.args; fixes.push(fixer.remove(matcherArg)); @@ -94,13 +96,17 @@ export default createRule({ } if (invertCondition) { + const notModifier = jestFnCall.modifiers.find( + nod => getAccessorValue(nod) === 'not', + ); + fixes.push( - modifier && modifier.name === ModifierName.not + notModifier ? fixer.removeRange([ - modifier.node.property.range[0] - 1, - modifier.node.property.range[1], + notModifier.range[0] - 1, + notModifier.range[1], ]) - : fixer.insertTextBefore(matcher.node.property, 'not.'), + : fixer.insertTextBefore(jestFnCall.matcher, 'not.'), ); } diff --git a/src/rules/prefer-to-be-true.ts b/src/rules/prefer-to-be-true.ts index 1250ffd..726bd01 100644 --- a/src/rules/prefer-to-be-true.ts +++ b/src/rules/prefer-to-be-true.ts @@ -1,13 +1,10 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { - MaybeTypeCast, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, + EqualityMatcher, createRule, - followTypeAssertionChain, - isExpectCall, - isParsedEqualityMatcherCall, - parseExpectCall, + getAccessorValue, + getFirstMatcherArg, + parseJestFnCall, } from './utils'; interface TrueLiteral extends TSESTree.BooleanLiteral { @@ -17,20 +14,6 @@ interface TrueLiteral extends TSESTree.BooleanLiteral { const isTrueLiteral = (node: TSESTree.Node): node is TrueLiteral => node.type === AST_NODE_TYPES.Literal && node.value === true; -/** - * Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers, - * with a `true` literal as the sole argument. - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedEqualityMatcherCall>} - */ -const isTrueEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedEqualityMatcherCall> => - isParsedEqualityMatcherCall(matcher) && - isTrueLiteral(followTypeAssertionChain(matcher.arguments[0])); - export default createRule({ name: __filename, meta: { @@ -50,19 +33,23 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher } = parseExpectCall(node); - - if (matcher && isTrueEqualityMatcher(matcher)) { + if ( + jestFnCall.args.length === 1 && + isTrueLiteral(getFirstMatcherArg(jestFnCall)) && + EqualityMatcher.hasOwnProperty(getAccessorValue(jestFnCall.matcher)) + ) { context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferToBeTrue', fix: fixer => [ - fixer.replaceText(matcher.node.property, 'toBeTrue'), - fixer.remove(matcher.arguments[0]), + fixer.replaceText(jestFnCall.matcher, 'toBeTrue'), + fixer.remove(jestFnCall.args[0]), ], }); } diff --git a/src/rules/prefer-to-have-been-called-once.ts b/src/rules/prefer-to-have-been-called-once.ts index 8c71d3e..33021f5 100644 --- a/src/rules/prefer-to-have-been-called-once.ts +++ b/src/rules/prefer-to-have-been-called-once.ts @@ -1,23 +1,11 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { createRule, - followTypeAssertionChain, - isExpectCall, - parseExpectCall, + getAccessorValue, + getFirstMatcherArg, + parseJestFnCall, } from './utils'; -const isNumberOne = ( - firstArg: TSESTree.CallExpression['arguments'][number], -): boolean => { - if (firstArg.type === AST_NODE_TYPES.SpreadElement) { - return false; - } - - const arg = followTypeAssertionChain(firstArg); - - return arg.type === AST_NODE_TYPES.Literal && arg.value === 1; -}; - export default createRule({ name: __filename, meta: { @@ -37,29 +25,28 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher } = parseExpectCall(node); - if ( - matcher && - matcher.name === 'toHaveBeenCalledTimes' && - matcher.arguments?.length === 1 + getAccessorValue(jestFnCall.matcher) === 'toHaveBeenCalledTimes' && + jestFnCall.args.length === 1 ) { - const [arg] = matcher.arguments; + const arg = getFirstMatcherArg(jestFnCall); - if (!isNumberOne(arg)) { + if (arg.type !== AST_NODE_TYPES.Literal || arg.value !== 1) { return; } context.report({ - node: matcher.node.property, + node: jestFnCall.matcher, messageId: 'preferCalledOnce', fix: fixer => [ - fixer.replaceText(matcher.node.property, 'toHaveBeenCalledOnce'), - fixer.remove(arg), + fixer.replaceText(jestFnCall.matcher, 'toHaveBeenCalledOnce'), + fixer.remove(jestFnCall.args[0]), ], }); } diff --git a/src/rules/utils.ts b/src/rules/utils.ts deleted file mode 100644 index 92e57ba..0000000 --- a/src/rules/utils.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { parse as parsePath } from 'path'; -import { - AST_NODE_TYPES, - ESLintUtils, - TSESTree, -} from '@typescript-eslint/utils'; -import { repository, version } from '../../package.json'; - -export const createRule = ESLintUtils.RuleCreator(name => { - const ruleName = parsePath(name).name; - - return `${repository}/blob/v${version}/docs/rules/${ruleName}.md`; -}); - -export type MaybeTypeCast = - | TSTypeCastExpression - | Expression; - -type TSTypeCastExpression< - Expression extends TSESTree.Expression = TSESTree.Expression, -> = AsExpressionChain | TypeAssertionChain; - -interface AsExpressionChain< - Expression extends TSESTree.Expression = TSESTree.Expression, -> extends TSESTree.TSAsExpression { - expression: AsExpressionChain | Expression; -} - -interface TypeAssertionChain< - Expression extends TSESTree.Expression = TSESTree.Expression, -> extends TSESTree.TSTypeAssertion { - expression: TypeAssertionChain | Expression; -} - -const isTypeCastExpression = ( - node: MaybeTypeCast, -): node is TSTypeCastExpression => - node.type === AST_NODE_TYPES.TSAsExpression || - node.type === AST_NODE_TYPES.TSTypeAssertion; - -export const followTypeAssertionChain = < - Expression extends TSESTree.Expression, ->( - expression: MaybeTypeCast, -): Expression => - isTypeCastExpression(expression) - ? followTypeAssertionChain(expression.expression) - : expression; - -export const isInstanceOfBinaryExpression = ( - node: TSESTree.Node, - className: string, -): node is TSESTree.BinaryExpression => - node.type === AST_NODE_TYPES.BinaryExpression && - node.operator === 'instanceof' && - isSupportedAccessor(node.right, className); - -/** - * A `Literal` with a `value` of type `string`. - */ -interface StringLiteral - extends TSESTree.StringLiteral { - value: Value; -} - -/** - * Checks if the given `node` is a `StringLiteral`. - * - * If a `value` is provided & the `node` is a `StringLiteral`, - * the `value` will be compared to that of the `StringLiteral`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is StringLiteral} - * - * @template V - */ -const isStringLiteral = ( - node: TSESTree.Node, - value?: V, -): node is StringLiteral => - node.type === AST_NODE_TYPES.Literal && - typeof node.value === 'string' && - (value === undefined || node.value === value); - -/** - * Checks if the given `node` is a `BooleanLiteral`. - * - * @param {Node} node - * - * @return {node is BooleanLiteral} - */ -export const isBooleanLiteral = ( - node: TSESTree.Node, -): node is TSESTree.BooleanLiteral => - node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; - -interface TemplateLiteral - extends TSESTree.TemplateLiteral { - quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }]; -} - -/** - * Checks if the given `node` is a `TemplateLiteral`. - * - * Complex `TemplateLiteral`s are not considered specific, and so will return `false`. - * - * If a `value` is provided & the `node` is a `TemplateLiteral`, - * the `value` will be compared to that of the `TemplateLiteral`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is TemplateLiteral} - * - * @template V - */ -const isTemplateLiteral = ( - node: TSESTree.Node, - value?: V, -): node is TemplateLiteral => - node.type === AST_NODE_TYPES.TemplateLiteral && - node.quasis.length === 1 && // bail out if not simple - (value === undefined || node.quasis[0].value.raw === value); - -export type StringNode = - | StringLiteral - | TemplateLiteral; - -/** - * Checks if the given `node` is a {@link StringNode}. - * - * @param {Node} node - * @param {V} [specifics] - * - * @return {node is StringNode} - * - * @template V - */ -export const isStringNode = ( - node: TSESTree.Node, - specifics?: V, -): node is StringNode => - isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics); - -/** - * Gets the value of the given `StringNode`. - * - * If the `node` is a `TemplateLiteral`, the `raw` value is used; - * otherwise, `value` is returned instead. - * - * @param {StringNode} node - * - * @return {S} - * - * @template S - */ -/* istanbul ignore next */ -export const getStringValue = (node: StringNode): S => - isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value; - -/** - * Represents a `MemberExpression` with a "known" `property`. - */ -interface KnownMemberExpression - extends TSESTree.MemberExpressionComputedName { - property: AccessorNode; -} - -/** - * An `Identifier` with a known `name` value - i.e `expect`. - */ -interface KnownIdentifier extends TSESTree.Identifier { - name: Name; -} - -/** - * Checks if the given `node` is an `Identifier`. - * - * If a `name` is provided, & the `node` is an `Identifier`, - * the `name` will be compared to that of the `identifier`. - * - * @param {Node} node - * @param {V} [name] - * - * @return {node is KnownIdentifier} - * - * @template V - */ -const isIdentifier = ( - node: TSESTree.Node, - name?: V, -): node is KnownIdentifier => - node.type === AST_NODE_TYPES.Identifier && - (name === undefined || node.name === name); - -/** - * Checks if the given `node` is a "supported accessor". - * - * This means that it's a node can be used to access properties, - * and who's "value" can be statically determined. - * - * `MemberExpression` nodes most commonly contain accessors, - * but it's possible for other nodes to contain them. - * - * If a `value` is provided & the `node` is an `AccessorNode`, - * the `value` will be compared to that of the `AccessorNode`. - * - * Note that `value` here refers to the normalised value. - * The property that holds the value is not always called `name`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is AccessorNode} - * - * @template V - */ -export const isSupportedAccessor = ( - node: TSESTree.Node, - value?: V, -): node is AccessorNode => - isIdentifier(node, value) || isStringNode(node, value); - -/** - * Gets the value of the given `AccessorNode`, - * account for the different node types. - * - * @param {AccessorNode} accessor - * - * @return {S} - * - * @template S - */ -const getAccessorValue = ( - accessor: AccessorNode, -): S => - accessor.type === AST_NODE_TYPES.Identifier - ? accessor.name - : getStringValue(accessor); - -type AccessorNode = - | StringNode - | KnownIdentifier; - -interface ExpectCall extends TSESTree.CallExpression { - callee: AccessorNode<'expect'>; - parent: TSESTree.Node; -} - -/** - * Checks if the given `node` is a valid `ExpectCall`. - * - * In order to be an `ExpectCall`, the `node` must: - * * be a `CallExpression`, - * * have an accessor named 'expect', - * * have a `parent`. - * - * @param {Node} node - * - * @return {node is ExpectCall} - */ -export const isExpectCall = (node: TSESTree.Node): node is ExpectCall => - node.type === AST_NODE_TYPES.CallExpression && - isSupportedAccessor(node.callee, 'expect') && - node.parent !== undefined; - -interface ParsedExpectMember< - Name extends ExpectPropertyName = ExpectPropertyName, - Node extends ExpectMember = ExpectMember, -> { - name: Name; - node: Node; -} - -/** - * Represents a `MemberExpression` that comes after an `ExpectCall`. - */ -interface ExpectMember< - PropertyName extends ExpectPropertyName = ExpectPropertyName, - Parent extends TSESTree.Node | undefined = TSESTree.Node | undefined, -> extends KnownMemberExpression { - object: ExpectCall | ExpectMember; - parent: Parent; -} - -export const isExpectMember = < - Name extends ExpectPropertyName = ExpectPropertyName, ->( - node: TSESTree.Node, - name?: Name, -): node is ExpectMember => - node.type === AST_NODE_TYPES.MemberExpression && - isSupportedAccessor(node.property, name); - -/** - * Represents all the jest matchers. - */ -type MatcherName = string /* & not ModifierName */; -type ExpectPropertyName = ModifierName | MatcherName; - -export type ParsedEqualityMatcherCall< - Argument extends TSESTree.Expression = TSESTree.Expression, - Matcher extends EqualityMatcher = EqualityMatcher, -> = Omit, 'arguments'> & { - // todo: probs should also type node parent as CallExpression - arguments: [Argument]; -}; - -export enum ModifierName { - not = 'not', - rejects = 'rejects', - resolves = 'resolves', -} - -export enum EqualityMatcher { - toBe = 'toBe', - toEqual = 'toEqual', - toStrictEqual = 'toStrictEqual', -} - -export const isParsedEqualityMatcherCall = < - MatcherName extends EqualityMatcher = EqualityMatcher, ->( - matcher: ParsedExpectMatcher, -): matcher is ParsedEqualityMatcherCall => - EqualityMatcher.hasOwnProperty(matcher.name) && - matcher.arguments !== null && - matcher.arguments.length === 1; - -export const isParsedInstanceOfMatcherCall = ( - matcher: ParsedExpectMatcher, - classArg?: string, -): matcher is ParsedExpectMatcher<'toBeInstanceOf'> => - matcher.name === 'toBeInstanceOf' && - !!matcher.arguments?.length && - isSupportedAccessor(matcher.arguments[0], classArg); - -export type ParsedBooleanEqualityMatcherCall = - | ParsedEqualityMatcherCall> - | ParsedExpectMatcher<'toBeTrue' | 'toBeFalse'>; - -/** - * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers, - * with a boolean` literal as the sole argument, *or* is a call to `toBeTrue` or `toBeFalse`. - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedBooleanEqualityMatcherCall} - */ -export const isBooleanEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedBooleanEqualityMatcherCall => - matcher.name === 'toBeTrue' || - matcher.name === 'toBeFalse' || - (isParsedEqualityMatcherCall(matcher) && - isBooleanLiteral(followTypeAssertionChain(matcher.arguments[0]))); - -/** - * Represents a parsed expect matcher, such as `toBe`, `toContain`, and so on. - */ -export interface ParsedExpectMatcher< - Matcher extends MatcherName = MatcherName, - Node extends ExpectMember = ExpectMember, -> extends ParsedExpectMember { - /** - * The arguments being passed to the matcher. - * A value of `null` means the matcher isn't being called. - */ - arguments: TSESTree.CallExpression['arguments'] | null; -} - -type BaseParsedModifier = - ParsedExpectMember; - -type NegatableModifierName = ModifierName.rejects | ModifierName.resolves; -type NotNegatableModifierName = ModifierName.not; - -/** - * Represents a parsed modifier that can be followed by a `not` negation modifier. - */ -interface NegatableParsedModifier< - Modifier extends NegatableModifierName = NegatableModifierName, -> extends BaseParsedModifier { - negation?: ExpectMember; -} - -/** - * Represents a parsed modifier that cannot be followed by a `not` negation modifier. - */ -export interface NotNegatableParsedModifier< - Modifier extends NotNegatableModifierName = NotNegatableModifierName, -> extends BaseParsedModifier { - negation?: never; -} - -type ParsedExpectModifier = - | NotNegatableParsedModifier - | NegatableParsedModifier; - -interface Expectation { - expect: ExpectNode; - modifier?: ParsedExpectModifier; - matcher?: ParsedExpectMatcher; -} - -const parseExpectMember = ( - expectMember: ExpectMember, -): ParsedExpectMember => ({ - name: getAccessorValue(expectMember.property), - node: expectMember, -}); - -const reparseAsMatcher = ( - parsedMember: ParsedExpectMember, -): ParsedExpectMatcher => ({ - ...parsedMember, - /** - * The arguments being passed to this `Matcher`, if any. - * - * If this matcher isn't called, this will be `null`. - */ - arguments: - parsedMember.node.parent && - parsedMember.node.parent.type === AST_NODE_TYPES.CallExpression - ? parsedMember.node.parent.arguments - : null, -}); - -/** - * Re-parses the given `parsedMember` as a `ParsedExpectModifier`. - * - * If the given `parsedMember` does not have a `name` of a valid `Modifier`, - * an exception will be thrown. - * - * @param {ParsedExpectMember} parsedMember - * - * @return {ParsedExpectModifier} - */ -const reparseMemberAsModifier = ( - parsedMember: ParsedExpectMember, -): ParsedExpectModifier => { - if (isSpecificMember(parsedMember, ModifierName.not)) { - return parsedMember; - } - - /* istanbul ignore if */ - if ( - !isSpecificMember(parsedMember, ModifierName.resolves) && - !isSpecificMember(parsedMember, ModifierName.rejects) - ) { - // ts doesn't think that the ModifierName.not check is the direct inverse as the above two checks - // todo: impossible at runtime, but can't be typed w/o negation support - throw new Error( - `modifier name must be either "${ModifierName.resolves}" or "${ModifierName.rejects}" (got "${parsedMember.name}")`, - ); - } - - const negation = - parsedMember.node.parent && - isExpectMember(parsedMember.node.parent, ModifierName.not) - ? parsedMember.node.parent - : undefined; - - return { - ...parsedMember, - negation, - }; -}; - -const isSpecificMember = ( - member: ParsedExpectMember, - specific: Name, -): member is ParsedExpectMember => member.name === specific; - -/** - * Checks if the given `ParsedExpectMember` should be re-parsed as an `ParsedExpectModifier`. - * - * @param {ParsedExpectMember} member - * - * @return {member is ParsedExpectMember} - */ -const shouldBeParsedExpectModifier = ( - member: ParsedExpectMember, -): member is ParsedExpectMember => - ModifierName.hasOwnProperty(member.name); - -export const parseExpectCall = ( - expect: ExpectNode, -): Expectation => { - const expectation: Expectation = { - expect, - }; - - if (!isExpectMember(expect.parent)) { - return expectation; - } - - const parsedMember = parseExpectMember(expect.parent); - - if (!shouldBeParsedExpectModifier(parsedMember)) { - expectation.matcher = reparseAsMatcher(parsedMember); - - return expectation; - } - - const modifier = (expectation.modifier = - reparseMemberAsModifier(parsedMember)); - - const memberNode = modifier.negation || modifier.node; - - if (!memberNode.parent || !isExpectMember(memberNode.parent)) { - return expectation; - } - - expectation.matcher = reparseAsMatcher(parseExpectMember(memberNode.parent)); - - return expectation; -}; diff --git a/src/rules/utils/__tests__/parseJestFnCall.test.ts b/src/rules/utils/__tests__/parseJestFnCall.test.ts new file mode 100644 index 0000000..0e8214e --- /dev/null +++ b/src/rules/utils/__tests__/parseJestFnCall.test.ts @@ -0,0 +1,915 @@ +import { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package'; +import { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import dedent from 'dedent'; +import { espreeParser } from '../../__tests__/test-utils'; +import { + ParsedJestFnCall, + ResolvedJestFnWithNode, + createRule, + getAccessorValue, + isSupportedAccessor, + parseJestFnCall, +} from '../../utils'; + +const findESLintVersion = (): number => { + const eslintPath = require.resolve('eslint/package.json'); + + const eslintPackageJson = + // eslint-disable-next-line @typescript-eslint/no-require-imports + require(eslintPath) as JSONSchemaForNPMPackageJsonFiles; + + if (!eslintPackageJson.version) { + throw new Error('eslint package.json does not have a version!'); + } + + const [majorVersion] = eslintPackageJson.version.split('.'); + + return parseInt(majorVersion, 10); +}; + +const eslintVersion = findESLintVersion(); + +const ruleTester = new TSESLint.RuleTester({ + parser: espreeParser, + parserOptions: { + ecmaVersion: 2015, + }, +}); + +const isNode = (obj: unknown): obj is TSESTree.Node => { + if (typeof obj === 'object' && obj !== null) { + return ['type', 'loc', 'range', 'parent'].every(p => p in obj); + } + + return false; +}; + +const rule = createRule({ + name: __filename, + meta: { + docs: { + category: 'Possible Errors', + description: 'Fake rule for testing parseJestFnCall', + recommended: false, + }, + messages: { + details: '{{ data }}', + }, + schema: [], + type: 'problem', + }, + defaultOptions: [], + create: context => ({ + CallExpression(node) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall) { + const sorted = { + // ...jestFnCall, + name: jestFnCall.name, + type: jestFnCall.type, + head: jestFnCall.head, + members: jestFnCall.members, + }; + + context.report({ + messageId: 'details', + node, + data: { + data: JSON.stringify(sorted, (key, value) => { + if (isNode(value)) { + if (isSupportedAccessor(value)) { + return getAccessorValue(value); + } + + return undefined; + } + + return value; + }), + }, + }); + } + }, + }), +}); + +interface TestResolvedJestFnWithNode + extends Omit { + node: string; +} + +interface TestParsedJestFnCall + extends Omit { + head: TestResolvedJestFnWithNode; + members: string[]; +} + +// const sortParsedJestFnCallResults = () + +const expectedParsedJestFnCallResultData = (result: TestParsedJestFnCall) => ({ + data: JSON.stringify({ + name: result.name, + type: result.type, + head: result.head, + members: result.members, + }), +}); + +ruleTester.run('nonexistent methods', rule, { + valid: [ + 'describe.something()', + 'describe.me()', + 'test.me()', + 'it.fails()', + 'context()', + 'context.each``()', + 'context.each()', + 'describe.context()', + 'describe.concurrent()()', + 'describe.concurrent``()', + 'describe.every``()', + '/regex/.test()', + '"something".describe()', + '[].describe()', + 'new describe().only()', + '``.test()', + 'test.only``()', + 'test``.only()', + ], + invalid: [], +}); + +ruleTester.run('expect', rule, { + valid: [ + { + code: dedent` + import { expect } from './test-utils'; + + expect(x).toBe(y); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not.resolves.toBe(x); + `, + parserOptions: { sourceType: 'module' }, + }, + // { + // code: dedent` + // import { expect } from '@jest/globals'; + // + // expect(x).not().toBe(x); + // `, + // parserOptions: { sourceType: 'module' }, + // }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).is.toBe(x); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect; + expect(x); + expect(x).toBe; + expect(x).not.toBe; + //expect(x).toBe(x).not(); + `, + parserOptions: { sourceType: 'module' }, + }, + ], + invalid: [ + { + code: 'expect(x).toBe(y);', + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: null, + local: 'expect', + type: 'global', + node: 'expect', + }, + members: ['toBe'], + }), + column: 1, + line: 1, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect.assertions(); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['assertions'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).toBe(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['toBe'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not.toBe(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not', 'toBe'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect.assertions(); + expect.hasAssertions(); + expect.anything(); + expect.not.arrayContaining(); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['assertions'], + }), + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['hasAssertions'], + }), + column: 1, + line: 4, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['anything'], + }), + column: 1, + line: 5, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not', 'arrayContaining'], + }), + column: 1, + line: 6, + }, + ], + }, + ], +}); + +ruleTester.run('esm', rule, { + valid: [ + { + code: dedent` + import { it } from './test-utils'; + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { defineFeature, loadFeature } from "jest-cucumber"; + + const feature = loadFeature("some/feature"); + + defineFeature(feature, (test) => { + test("A scenario", ({ given, when, then }) => {}); + }); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { describe } from './test-utils'; + + describe('a function that is not from jest', () => {}); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { fn as it } from './test-utils'; + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import * as jest from '@jest/globals'; + const { it } = jest; + + it('is not supported', () => {}); + `, + parserOptions: { sourceType: 'module' }, + }, + ], + invalid: [], +}); + +if (eslintVersion >= 8) { + ruleTester.run('esm (dynamic)', rule, { + valid: [ + { + code: dedent` + const { it } = await import('./test-utils'); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, + }, + { + code: dedent` + const { it } = await import(\`./test-utils\`); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, + }, + ], + invalid: [ + { + code: dedent` + const { it } = await import("@jest/globals"); + + it('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'it', + type: 'test', + head: { + original: 'it', + local: 'it', + type: 'import', + node: 'it', + }, + members: [], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { it } = await import(\`@jest/globals\`); + + it('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2022 }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'it', + type: 'test', + head: { + original: 'it', + local: 'it', + type: 'import', + node: 'it', + }, + members: [], + }), + column: 1, + line: 3, + }, + ], + }, + ], + }); +} + +ruleTester.run('cjs', rule, { + valid: [ + { + code: dedent` + const { it } = require('./test-utils'); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { it } = require(\`./test-utils\`); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { describe } = require('./test-utils'); + + describe('a function that is not from jest', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { fn: it } = require('./test-utils'); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { fn: it } = require('@jest/globals'); + + it('is not considered a test function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { it } = aliasedRequire('@jest/globals'); + + it('is not considered a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { it } = require(); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { it } = require(pathToMyPackage); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + { + code: dedent` + const { [() => {}]: it } = require('@jest/globals'); + + it('is not a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + }, + ], + invalid: [], +}); + +ruleTester.run('global aliases', rule, { + valid: [ + { + code: 'xcontext("skip this please", () => {});', + settings: { jest: { globalAliases: { describe: ['context'] } } }, + }, + ], + invalid: [ + { + code: 'context("when there is an error", () => {})', + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'describe', + type: 'describe', + head: { + original: 'describe', + local: 'context', + type: 'global', + node: 'context', + }, + members: [], + }), + column: 1, + line: 1, + }, + ], + settings: { jest: { globalAliases: { describe: ['context'] } } }, + }, + { + code: 'context.skip("when there is an error", () => {})', + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'describe', + type: 'describe', + head: { + original: 'describe', + local: 'context', + type: 'global', + node: 'context', + }, + members: ['skip'], + }), + column: 1, + line: 1, + }, + ], + settings: { jest: { globalAliases: { describe: ['context'] } } }, + }, + { + code: dedent` + context("when there is an error", () => {}) + xcontext("skip this please", () => {}); + `, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'xdescribe', + type: 'describe', + head: { + original: 'xdescribe', + local: 'xcontext', + type: 'global', + node: 'xcontext', + }, + members: [], + }), + column: 1, + line: 2, + }, + ], + settings: { jest: { globalAliases: { xdescribe: ['xcontext'] } } }, + }, + { + code: dedent` + context("when there is an error", () => {}) + describe("when there is an error", () => {}) + xcontext("skip this please", () => {}); + `, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'describe', + type: 'describe', + head: { + original: 'describe', + local: 'context', + type: 'global', + node: 'context', + }, + members: [], + }), + column: 1, + line: 1, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'describe', + type: 'describe', + head: { + original: null, + local: 'describe', + type: 'global', + node: 'describe', + }, + members: [], + }), + column: 1, + line: 2, + }, + ], + settings: { jest: { globalAliases: { describe: ['context'] } } }, + }, + ], +}); + +ruleTester.run('typescript', rule, { + valid: [ + { + code: dedent` + const { test }; + + test('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + }, + { + code: dedent` + import type { it } from '@jest/globals'; + + it('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import jest = require('@jest/globals'); + const { it } = jest; + + it('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + function it(message: string, fn: () => void): void; + function it(cases: unknown[], message: string, fn: () => void): void; + function it(...all: any[]): void {} + + it('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + interface it {} + function it(...all: any[]): void {} + + it('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { it } from '@jest/globals'; + import { it } from '../it-utils'; + + it('is not a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + }, + ], + invalid: [ + { + code: dedent` + import { it } from '../it-utils'; + import { it } from '@jest/globals'; + + it('is a jest function', () => {}); + `, + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'it', + type: 'test', + head: { + original: 'it', + local: 'it', + type: 'import', + node: 'it', + }, + members: [], + }), + column: 1, + line: 4, + }, + ], + }, + ], +}); + +ruleTester.run('misc', rule, { + valid: [ + 'var spyOn = require("actions"); spyOn("foo")', + 'test().finally()', + 'expect(true).not.not.toBeDefined();', + 'expect(true).resolves.not.exactly.toBeDefined();', + ], + invalid: [ + { + code: 'beforeEach(() => {});', + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'beforeEach', + type: 'hook', + head: { + original: null, + local: 'beforeEach', + type: 'global', + node: 'beforeEach', + }, + members: [], + }), + column: 1, + line: 1, + }, + ], + }, + { + code: 'jest.spyOn(console, "log");', + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'jest', + type: 'jest', + head: { + original: null, + local: 'jest', + type: 'global', + node: 'jest', + }, + members: ['spyOn'], + }), + column: 1, + line: 1, + }, + ], + }, + { + code: dedent` + test('valid-expect-in-promise', async () => { + const text = await fetch('url') + .then(res => res.text()) + .then(text => text); + + expect(text).toBe('text'); + }); + `, + parserOptions: { ecmaVersion: 2017 }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'test', + type: 'test', + head: { + original: null, + local: 'test', + type: 'global', + node: 'test', + }, + members: [], + }), + column: 1, + line: 1, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: null, + local: 'expect', + type: 'global', + node: 'expect', + }, + members: ['toBe'], + }), + column: 3, + line: 6, + }, + ], + }, + ], +}); diff --git a/src/rules/utils/accessors.ts b/src/rules/utils/accessors.ts new file mode 100644 index 0000000..c7b7f4e --- /dev/null +++ b/src/rules/utils/accessors.ts @@ -0,0 +1,169 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +/** + * A `Literal` with a `value` of type `string`. + */ +interface StringLiteral + extends TSESTree.StringLiteral { + value: Value; +} + +/** + * Checks if the given `node` is a `StringLiteral`. + * + * If a `value` is provided & the `node` is a `StringLiteral`, + * the `value` will be compared to that of the `StringLiteral`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is StringLiteral} + * + * @template V + */ +const isStringLiteral = ( + node: TSESTree.Node, + value?: V, +): node is StringLiteral => + node.type === AST_NODE_TYPES.Literal && + typeof node.value === 'string' && + (value === undefined || node.value === value); + +interface TemplateLiteral + extends TSESTree.TemplateLiteral { + quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }]; +} + +/** + * Checks if the given `node` is a `TemplateLiteral`. + * + * Complex `TemplateLiteral`s are not considered specific, and so will return `false`. + * + * If a `value` is provided & the `node` is a `TemplateLiteral`, + * the `value` will be compared to that of the `TemplateLiteral`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is TemplateLiteral} + * + * @template V + */ +const isTemplateLiteral = ( + node: TSESTree.Node, + value?: V, +): node is TemplateLiteral => + node.type === AST_NODE_TYPES.TemplateLiteral && + node.quasis.length === 1 && // bail out if not simple + (value === undefined || node.quasis[0].value.raw === value); + +export type StringNode = + | StringLiteral + | TemplateLiteral; + +/** + * Checks if the given `node` is a {@link StringNode}. + * + * @param {Node} node + * @param {V} [specifics] + * + * @return {node is StringNode} + * + * @template V + */ +export const isStringNode = ( + node: TSESTree.Node, + specifics?: V, +): node is StringNode => + isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics); + +/** + * Gets the value of the given `StringNode`. + * + * If the `node` is a `TemplateLiteral`, the `raw` value is used; + * otherwise, `value` is returned instead. + * + * @param {StringNode} node + * + * @return {S} + * + * @template S + */ +export const getStringValue = (node: StringNode): S => + isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value; + +/** + * An `Identifier` with a known `name` value - i.e `expect`. + */ +interface KnownIdentifier extends TSESTree.Identifier { + name: Name; +} + +/** + * Checks if the given `node` is an `Identifier`. + * + * If a `name` is provided, & the `node` is an `Identifier`, + * the `name` will be compared to that of the `identifier`. + * + * @param {Node} node + * @param {V} [name] + * + * @return {node is KnownIdentifier} + * + * @template V + */ +export const isIdentifier = ( + node: TSESTree.Node, + name?: V, +): node is KnownIdentifier => + node.type === AST_NODE_TYPES.Identifier && + (name === undefined || node.name === name); + +/** + * Checks if the given `node` is a "supported accessor". + * + * This means that it's a node can be used to access properties, + * and who's "value" can be statically determined. + * + * `MemberExpression` nodes most commonly contain accessors, + * but it's possible for other nodes to contain them. + * + * If a `value` is provided & the `node` is an `AccessorNode`, + * the `value` will be compared to that of the `AccessorNode`. + * + * Note that `value` here refers to the normalised value. + * The property that holds the value is not always called `name`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is AccessorNode} + * + * @template V + */ +export const isSupportedAccessor = ( + node: TSESTree.Node, + value?: V, +): node is AccessorNode => + isIdentifier(node, value) || isStringNode(node, value); + +/** + * Gets the value of the given `AccessorNode`, + * account for the different node types. + * + * @param {AccessorNode} accessor + * + * @return {S} + * + * @template S + */ +export const getAccessorValue = ( + accessor: AccessorNode, +): S => + accessor.type === AST_NODE_TYPES.Identifier + ? accessor.name + : getStringValue(accessor); + +export type AccessorNode = + | StringNode + | KnownIdentifier; diff --git a/src/rules/utils/followTypeAssertionChain.ts b/src/rules/utils/followTypeAssertionChain.ts new file mode 100644 index 0000000..1e3620a --- /dev/null +++ b/src/rules/utils/followTypeAssertionChain.ts @@ -0,0 +1,36 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +export type MaybeTypeCast = + | TSTypeCastExpression + | Expression; + +type TSTypeCastExpression< + Expression extends TSESTree.Expression = TSESTree.Expression, +> = AsExpressionChain | TypeAssertionChain; + +interface AsExpressionChain< + Expression extends TSESTree.Expression = TSESTree.Expression, +> extends TSESTree.TSAsExpression { + expression: AsExpressionChain | Expression; +} + +interface TypeAssertionChain< + Expression extends TSESTree.Expression = TSESTree.Expression, +> extends TSESTree.TSTypeAssertion { + expression: TypeAssertionChain | Expression; +} + +const isTypeCastExpression = ( + node: MaybeTypeCast, +): node is TSTypeCastExpression => + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion; + +export const followTypeAssertionChain = < + Expression extends TSESTree.Expression, +>( + expression: MaybeTypeCast, +): Expression => + isTypeCastExpression(expression) + ? followTypeAssertionChain(expression.expression) + : expression; diff --git a/src/rules/utils/index.ts b/src/rules/utils/index.ts new file mode 100644 index 0000000..79c986a --- /dev/null +++ b/src/rules/utils/index.ts @@ -0,0 +1,4 @@ +export * from './accessors'; +export * from './followTypeAssertionChain'; +export * from './misc'; +export * from './parseJestFnCall'; diff --git a/src/rules/utils/misc.ts b/src/rules/utils/misc.ts new file mode 100644 index 0000000..21dd7a1 --- /dev/null +++ b/src/rules/utils/misc.ts @@ -0,0 +1,164 @@ +import { parse as parsePath } from 'path'; +import { + AST_NODE_TYPES, + ESLintUtils, + TSESTree, +} from '@typescript-eslint/utils'; +import { repository, version } from '../../../package.json'; +import { + AccessorNode, + getAccessorValue, + isSupportedAccessor, +} from './accessors'; +import { followTypeAssertionChain } from './followTypeAssertionChain'; +import { ParsedExpectFnCall } from './parseJestFnCall'; + +export const createRule = ESLintUtils.RuleCreator(name => { + const ruleName = parsePath(name).name; + + return `${repository}/blob/v${version}/docs/rules/${ruleName}.md`; +}); + +/** + * Represents a `MemberExpression` with a "known" `property`. + */ +export interface KnownMemberExpression + extends TSESTree.MemberExpressionComputedName { + property: AccessorNode; +} + +/** + * Represents a `CallExpression` with a "known" `property` accessor. + * + * i.e `KnownCallExpression<'includes'>` represents `.includes()`. + */ +export interface KnownCallExpression + extends TSESTree.CallExpression { + callee: CalledKnownMemberExpression; +} + +/** + * Represents a `MemberExpression` with a "known" `property`, that is called. + * + * This is `KnownCallExpression` from the perspective of the `MemberExpression` node. + */ +interface CalledKnownMemberExpression + extends KnownMemberExpression { + parent: KnownCallExpression; +} + +export enum DescribeAlias { + 'describe' = 'describe', + 'fdescribe' = 'fdescribe', + 'xdescribe' = 'xdescribe', +} + +export enum TestCaseName { + 'fit' = 'fit', + 'it' = 'it', + 'test' = 'test', + 'xit' = 'xit', + 'xtest' = 'xtest', +} + +export enum HookName { + 'beforeAll' = 'beforeAll', + 'beforeEach' = 'beforeEach', + 'afterAll' = 'afterAll', + 'afterEach' = 'afterEach', +} + +export enum ModifierName { + not = 'not', + rejects = 'rejects', + resolves = 'resolves', +} + +export enum EqualityMatcher { + toBe = 'toBe', + toEqual = 'toEqual', + toStrictEqual = 'toStrictEqual', +} + +export const findTopMostCallExpression = ( + node: TSESTree.CallExpression, +): TSESTree.CallExpression => { + let topMostCallExpression = node; + let { parent } = node; + + while (parent) { + if (parent.type === AST_NODE_TYPES.CallExpression) { + topMostCallExpression = parent; + + parent = parent.parent; + + continue; + } + + if (parent.type !== AST_NODE_TYPES.MemberExpression) { + break; + } + + parent = parent.parent; + } + + return topMostCallExpression; +}; + +export const isBooleanLiteral = ( + node: TSESTree.Node, +): node is TSESTree.BooleanLiteral => + node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; + +export const getFirstMatcherArg = ( + expectFnCall: ParsedExpectFnCall, +): TSESTree.SpreadElement | TSESTree.Expression => { + const [firstArg] = expectFnCall.args; + + if (firstArg.type === AST_NODE_TYPES.SpreadElement) { + return firstArg; + } + + return followTypeAssertionChain(firstArg); +}; + +export const isInstanceOfBinaryExpression = ( + node: TSESTree.Node, + className: string, +): node is TSESTree.BinaryExpression => + node.type === AST_NODE_TYPES.BinaryExpression && + node.operator === 'instanceof' && + isSupportedAccessor(node.right, className); + +export const isParsedInstanceOfMatcherCall = ( + expectFnCall: ParsedExpectFnCall, + classArg?: string, +): boolean => { + return ( + getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' && + expectFnCall.args.length === 1 && + isSupportedAccessor(expectFnCall.args[0], classArg) + ); +}; + +/** + * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers, + * with a boolean` literal as the sole argument, *or* is a call to `toBeTrue` or `toBeFalse`. + */ +export const isBooleanEqualityMatcher = ( + expectFnCall: ParsedExpectFnCall, +): boolean => { + const matcherName = getAccessorValue(expectFnCall.matcher); + + if (['toBeTrue', 'toBeFalse'].includes(matcherName)) { + return true; + } + + if (expectFnCall.args.length !== 1) { + return false; + } + + const arg = getFirstMatcherArg(expectFnCall); + + return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg); +}; diff --git a/src/rules/utils/parseJestFnCall.ts b/src/rules/utils/parseJestFnCall.ts new file mode 100644 index 0000000..a179645 --- /dev/null +++ b/src/rules/utils/parseJestFnCall.ts @@ -0,0 +1,577 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { + AccessorNode, + DescribeAlias, + HookName, + KnownMemberExpression, + ModifierName, + TestCaseName, + findTopMostCallExpression, + getAccessorValue, + getStringValue, + isIdentifier, + isStringNode, + isSupportedAccessor, +} from '../utils'; + +const joinChains = ( + a: AccessorNode[] | null, + b: AccessorNode[] | null, +): AccessorNode[] | null => (a && b ? [...a, ...b] : null); + +export function getNodeChain(node: TSESTree.Node): AccessorNode[] | null { + if (isSupportedAccessor(node)) { + return [node]; + } + + switch (node.type) { + case AST_NODE_TYPES.TaggedTemplateExpression: + return getNodeChain(node.tag); + case AST_NODE_TYPES.MemberExpression: + return joinChains(getNodeChain(node.object), getNodeChain(node.property)); + case AST_NODE_TYPES.CallExpression: + return getNodeChain(node.callee); + } + + return null; +} + +export interface ResolvedJestFnWithNode extends ResolvedJestFn { + node: AccessorNode; +} + +type JestFnType = 'hook' | 'describe' | 'test' | 'expect' | 'jest' | 'unknown'; + +const determineJestFnType = (name: string): JestFnType => { + if (name === 'expect') { + return 'expect'; + } + + if (name === 'jest') { + return 'jest'; + } + + if (DescribeAlias.hasOwnProperty(name)) { + return 'describe'; + } + + if (TestCaseName.hasOwnProperty(name)) { + return 'test'; + } + + /* istanbul ignore else */ + if (HookName.hasOwnProperty(name)) { + return 'hook'; + } + + /* istanbul ignore next */ + return 'unknown'; +}; + +interface BaseParsedJestFnCall { + /** + * The name of the underlying Jest function that is being called. + * This is the result of `(head.original ?? head.local)`. + */ + name: string; + type: JestFnType; + head: ResolvedJestFnWithNode; + members: KnownMemberExpressionProperty[]; +} + +interface ParsedGeneralJestFnCall extends BaseParsedJestFnCall { + type: Exclude; +} + +export interface ParsedExpectFnCall + extends BaseParsedJestFnCall, + ModifiersAndMatcher { + type: 'expect'; +} + +export type ParsedJestFnCall = ParsedGeneralJestFnCall | ParsedExpectFnCall; + +const ValidJestFnCallChains = [ + 'afterAll', + 'afterEach', + 'beforeAll', + 'beforeEach', + 'describe', + 'describe.each', + 'describe.only', + 'describe.only.each', + 'describe.skip', + 'describe.skip.each', + 'fdescribe', + 'fdescribe.each', + 'xdescribe', + 'xdescribe.each', + 'it', + 'it.concurrent', + 'it.concurrent.each', + 'it.concurrent.only.each', + 'it.concurrent.skip.each', + 'it.each', + 'it.failing', + 'it.only', + 'it.only.each', + 'it.only.failing', + 'it.skip', + 'it.skip.each', + 'it.skip.failing', + 'it.todo', + 'fit', + 'fit.each', + 'fit.failing', + 'xit', + 'xit.each', + 'xit.failing', + 'test', + 'test.concurrent', + 'test.concurrent.each', + 'test.concurrent.only.each', + 'test.concurrent.skip.each', + 'test.each', + 'test.failing', + 'test.only', + 'test.only.each', + 'test.only.failing', + 'test.skip', + 'test.skip.each', + 'test.skip.failing', + 'test.todo', + 'xtest', + 'xtest.each', + 'xtest.failing', +]; + +declare module '@typescript-eslint/utils/dist/ts-eslint' { + export interface SharedConfigurationSettings { + jest?: { + globalAliases?: Record; + version?: number | string; + }; + } +} + +const resolvePossibleAliasedGlobal = ( + global: string, + context: TSESLint.RuleContext, +) => { + const globalAliases = context.settings.jest?.globalAliases ?? {}; + + const alias = Object.entries(globalAliases).find(([, aliases]) => + aliases.includes(global), + ); + + if (alias) { + return alias[0]; + } + + return null; +}; + +const parseJestFnCallCache = new WeakMap< + TSESTree.CallExpression, + ParsedJestFnCall | string | null +>(); + +export const parseJestFnCall = ( + node: TSESTree.CallExpression, + context: TSESLint.RuleContext, +): ParsedJestFnCall | null => { + const jestFnCall = parseJestFnCallWithReason(node, context); + + if (typeof jestFnCall === 'string') { + return null; + } + + return jestFnCall; +}; + +export const parseJestFnCallWithReason = ( + node: TSESTree.CallExpression, + context: TSESLint.RuleContext, +): ParsedJestFnCall | string | null => { + let parsedJestFnCall = parseJestFnCallCache.get(node); + + /* istanbul ignore next */ + if (parsedJestFnCall) { + return parsedJestFnCall; + } + + parsedJestFnCall = parseJestFnCallWithReasonInner(node, context); + + parseJestFnCallCache.set(node, parsedJestFnCall); + + return parsedJestFnCall; +}; + +const parseJestFnCallWithReasonInner = ( + node: TSESTree.CallExpression, + context: TSESLint.RuleContext, +): ParsedJestFnCall | string | null => { + const chain = getNodeChain(node); + + if (!chain?.length) { + return null; + } + + const [first, ...rest] = chain; + + const lastLink = getAccessorValue(chain[chain.length - 1]); + + // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`) + if (lastLink === 'each') { + if ( + node.callee.type !== AST_NODE_TYPES.CallExpression && + node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression + ) { + return null; + } + } + + if ( + node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression && + lastLink !== 'each' + ) { + return null; + } + + const resolved = resolveToJestFn(context, getAccessorValue(first)); + + // we're not a jest function + if (!resolved) { + return null; + } + + const name = resolved.original ?? resolved.local; + + const links = [name, ...rest.map(link => getAccessorValue(link))]; + + if ( + name !== 'jest' && + name !== 'expect' && + !ValidJestFnCallChains.includes(links.join('.')) + ) { + return null; + } + + const parsedJestFnCall: Omit = { + name, + head: { ...resolved, node: first }, + // every member node must have a member expression as their parent + // in order to be part of the call chain we're parsing + members: rest as KnownMemberExpressionProperty[], + }; + + const type = determineJestFnType(name); + + if (type === 'expect') { + const result = parseJestExpectCall(parsedJestFnCall); + + // if the `expect` call chain is not valid, only report on the topmost node + // since all members in the chain are likely to get flagged for some reason + if ( + typeof result === 'string' && + findTopMostCallExpression(node) !== node + ) { + return null; + } + + if (result === 'matcher-not-found') { + if (node.parent?.type === AST_NODE_TYPES.MemberExpression) { + return 'matcher-not-called'; + } + } + + return result; + } + + // check that every link in the chain except the last is a member expression + if ( + chain + .slice(0, chain.length - 1) + .some(nod => nod.parent?.type !== AST_NODE_TYPES.MemberExpression) + ) { + return null; + } + + // ensure that we're at the "top" of the function call chain otherwise when + // parsing e.g. x().y.z(), we'll incorrectly find & parse "x()" even though + // the full chain is not a valid jest function call chain + if ( + node.parent?.type === AST_NODE_TYPES.CallExpression || + node.parent?.type === AST_NODE_TYPES.MemberExpression + ) { + return null; + } + + return { ...parsedJestFnCall, type }; +}; + +type KnownMemberExpressionProperty = + AccessorNode & { parent: KnownMemberExpression }; + +interface ModifiersAndMatcher { + modifiers: KnownMemberExpressionProperty[]; + matcher: KnownMemberExpressionProperty; + /** The arguments that are being passed to the `matcher` */ + args: TSESTree.CallExpression['arguments']; +} + +const findModifiersAndMatcher = ( + members: KnownMemberExpressionProperty[], +): ModifiersAndMatcher | string => { + const modifiers: KnownMemberExpressionProperty[] = []; + + for (const member of members) { + // check if the member is being called, which means it is the matcher + // (and also the end of the entire "expect" call chain) + if ( + member.parent?.type === AST_NODE_TYPES.MemberExpression && + member.parent.parent?.type === AST_NODE_TYPES.CallExpression + ) { + return { + matcher: member, + args: member.parent.parent.arguments, + modifiers, + }; + } + + // otherwise, it should be a modifier + const name = getAccessorValue(member); + + if (modifiers.length === 0) { + // the first modifier can be any of the three modifiers + if (!ModifierName.hasOwnProperty(name)) { + return 'modifier-unknown'; + } + } else if (modifiers.length === 1) { + // the second modifier can only be "not" + if (name !== ModifierName.not) { + return 'modifier-unknown'; + } + + const firstModifier = getAccessorValue(modifiers[0]); + + // and the first modifier has to be either "resolves" or "rejects" + if ( + firstModifier !== ModifierName.resolves && + firstModifier !== ModifierName.rejects + ) { + return 'modifier-unknown'; + } + } else { + return 'modifier-unknown'; + } + + modifiers.push(member); + } + + // this will only really happen if there are no members + return 'matcher-not-found'; +}; + +const parseJestExpectCall = ( + typelessParsedJestFnCall: Omit, +): ParsedExpectFnCall | string => { + const modifiersAndMatcher = findModifiersAndMatcher( + typelessParsedJestFnCall.members, + ); + + if (typeof modifiersAndMatcher === 'string') { + return modifiersAndMatcher; + } + + return { + ...typelessParsedJestFnCall, + type: 'expect', + ...modifiersAndMatcher, + }; +}; + +interface ImportDetails { + source: string; + local: string; + imported: string; +} + +const describeImportDefAsImport = ( + def: TSESLint.Scope.Definitions.ImportBindingDefinition, +): ImportDetails | null => { + if (def.parent.type === AST_NODE_TYPES.TSImportEqualsDeclaration) { + return null; + } + + if (def.node.type !== AST_NODE_TYPES.ImportSpecifier) { + return null; + } + + // we only care about value imports + if (def.parent.importKind === 'type') { + return null; + } + + return { + source: def.parent.source.value, + imported: def.node.imported.name, + local: def.node.local.name, + }; +}; + +/** + * Attempts to find the node that represents the import source for the + * given expression node, if it looks like it's an import. + * + * If no such node can be found (e.g. because the expression doesn't look + * like an import), then `null` is returned instead. + */ +const findImportSourceNode = ( + node: TSESTree.Expression, +): TSESTree.Node | null => { + if (node.type === AST_NODE_TYPES.AwaitExpression) { + if (node.argument.type === AST_NODE_TYPES.ImportExpression) { + return (node.argument as TSESTree.ImportExpression).source; + } + + return null; + } + + if ( + node.type === AST_NODE_TYPES.CallExpression && + isIdentifier(node.callee, 'require') + ) { + return node.arguments[0] ?? null; + } + + return null; +}; + +const describeVariableDefAsImport = ( + def: TSESLint.Scope.Definitions.VariableDefinition, +): ImportDetails | null => { + // make sure that we've actually being assigned a value + if (!def.node.init) { + return null; + } + + const sourceNode = findImportSourceNode(def.node.init); + + if (!sourceNode || !isStringNode(sourceNode)) { + return null; + } + + if (def.name.parent?.type !== AST_NODE_TYPES.Property) { + return null; + } + + if (!isSupportedAccessor(def.name.parent.key)) { + return null; + } + + return { + source: getStringValue(sourceNode), + imported: getAccessorValue(def.name.parent.key), + local: def.name.name, + }; +}; + +/** + * Attempts to describe a definition as an import if possible. + * + * If the definition is an import binding, it's described as you'd expect. + * If the definition is a variable, then we try and determine if it's either + * a dynamic `import()` or otherwise a call to `require()`. + * + * If it's neither of these, `null` is returned to indicate that the definition + * is not describable as an import of any kind. + */ +const describePossibleImportDef = (def: TSESLint.Scope.Definition) => { + if (def.type === 'Variable') { + return describeVariableDefAsImport(def); + } + + if (def.type === 'ImportBinding') { + return describeImportDefAsImport(def); + } + + return null; +}; + +const collectReferences = (scope: TSESLint.Scope.Scope) => { + const locals = new Set(); + const imports = new Map(); + const unresolved = new Set(); + + let currentScope: TSESLint.Scope.Scope | null = scope; + + while (currentScope !== null) { + for (const ref of currentScope.variables) { + if (ref.defs.length === 0) { + continue; + } + + const def = ref.defs[ref.defs.length - 1]; + + const importDetails = describePossibleImportDef(def); + + if (importDetails) { + imports.set(importDetails.local, importDetails); + + continue; + } + + locals.add(ref.name); + } + + for (const ref of currentScope.through) { + unresolved.add(ref.identifier.name); + } + + currentScope = currentScope.upper; + } + + return { locals, imports, unresolved }; +}; + +interface ResolvedJestFn { + original: string | null; + local: string; + type: 'import' | 'global'; +} + +const resolveToJestFn = ( + context: TSESLint.RuleContext, + identifier: string, +): ResolvedJestFn | null => { + const references = collectReferences(context.getScope()); + + const maybeImport = references.imports.get(identifier); + + if (maybeImport) { + // the identifier is imported from @jest/globals, + // so return the original import name + if (maybeImport.source === '@jest/globals') { + return { + original: maybeImport.imported, + local: maybeImport.local, + type: 'import', + }; + } + + return null; + } + + // the identifier was found as a local variable or function declaration + // meaning it's not a function from jest + if (references.locals.has(identifier)) { + return null; + } + + return { + original: resolvePossibleAliasedGlobal(identifier, context), + local: identifier, + type: 'global', + }; +}; diff --git a/yarn.lock b/yarn.lock index 485f44a..620f2dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2307,6 +2307,13 @@ __metadata: languageName: node linkType: hard +"@schemastore/package@npm:^0.0.6": + version: 0.0.6 + resolution: "@schemastore/package@npm:0.0.6" + checksum: 40cd45f27bea78dd5274b7d7f5e544c88442ec67e378abfee274fa781ce802ccfa495d78cbb5c182717810095df7bc1594062de56909d89bb235ce2f905f0d0c + languageName: node + linkType: hard + "@semantic-release/changelog@npm:^6.0.0": version: 6.0.1 resolution: "@semantic-release/changelog@npm:6.0.1" @@ -2540,6 +2547,13 @@ __metadata: languageName: node linkType: hard +"@types/dedent@npm:^0.7.0": + version: 0.7.0 + resolution: "@types/dedent@npm:0.7.0" + checksum: b9b7f0c42d99da764be75dd00b2f2be162f5a0ca43aafe0c740efa76b5f37f8a4f4e2ccc84883054e06e05c1169733aa8ee7750da0334c9cba26d9e10ae844f1 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.5 resolution: "@types/graceful-fs@npm:4.1.5" @@ -4369,9 +4383,11 @@ __metadata: "@babel/preset-typescript": ^7.3.3 "@commitlint/cli": ^16.0.0 "@commitlint/config-conventional": ^16.0.0 + "@schemastore/package": ^0.0.6 "@semantic-release/changelog": ^6.0.0 "@semantic-release/git": ^10.0.0 "@tsconfig/node12": ^1.0.11 + "@types/dedent": ^0.7.0 "@types/jest": ^28.0.0 "@types/node": ^16.0.0 "@types/prettier": ^2.7.0 @@ -4380,6 +4396,7 @@ __metadata: "@typescript-eslint/utils": ^5.10.0 babel-jest: ^28.0.0 babel-plugin-replace-ts-export-assignment: ^0.0.2 + dedent: ^0.7.0 eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 eslint-config-prettier: ^8.3.0 eslint-plugin-eslint-comments: ^3.1.2