diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 25f742dcd..8134e6a37 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -85,11 +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: always() + if: ${{ matrix.eslint-version >= 8 }} test-os: name: Test on ${{ matrix.os }} using Node.js LTS needs: prepare-yarn-cache @@ -108,11 +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: always() + if: ${{ matrix.eslint-version >= 8 }} docs: if: ${{ github.event_name == 'pull_request' }} diff --git a/src/rules/__tests__/utils.test.ts b/src/rules/__tests__/utils.test.ts index 6db68f689..b11c17e08 100644 --- a/src/rules/__tests__/utils.test.ts +++ b/src/rules/__tests__/utils.test.ts @@ -1,12 +1,33 @@ +import { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package'; import { TSESLint } from '@typescript-eslint/utils'; +import dedent from 'dedent'; import { createRule, getNodeName, isDescribeCall, + isHookCall, isTestCaseCall, } from '../utils'; import { espreeParser } from './test-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: { @@ -37,9 +58,11 @@ const rule = createRule({ defaultOptions: [], create: context => ({ CallExpression(node) { + const scope = context.getScope(); const callType = - (isDescribeCall(node) && ('describe' as const)) || - (isTestCaseCall(node) && ('test' as const)); + (isDescribeCall(node, scope) && ('describe' as const)) || + (isTestCaseCall(node, scope) && ('test' as const)) || + (isHookCall(node, scope) && ('hook' as const)); if (callType) { context.report({ @@ -299,3 +322,674 @@ testUtilsAgainst( ], 'describe', ); + +const hooks = ['beforeAll', 'beforeEach', 'afterEach', 'afterAll']; + +ruleTester.run('hooks', rule, { + valid: [...hooks, 'beforeAll.each(() => {})'], + invalid: hooks.map(hook => ({ + code: `${hook}(() => {})`, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'hook', + numOfArgs: 1, + nodeName: expectedNodeName(hook), + }, + column: 1, + line: 1, + }, + ], + })), +}); + +describe('reference checking', () => { + ruleTester.run('general', rule, { + valid: [ + "([]).skip('is not a jest function', () => {});", + { + code: dedent` + const test = () => {}; + + test('is not a jest function', () => {}); + `, + }, + { + code: dedent` + (async () => { + const { test } = await Promise.resolve(); + + test('is not a jest function', () => {}); + })(); + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2017 }, + }, + ], + invalid: [ + { + code: 'describe()', + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 0, + nodeName: 'describe', + }, + column: 1, + line: 1, + }, + ], + }, + ], + }); + + 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: [ + { + code: dedent` + import { describe } from '@jest/globals'; + + describe('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { describe } from '@jest/globals'; + + describe.skip('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe.skip', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { it } from '@jest/globals'; + + it('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'it', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { beforeEach } from '@jest/globals'; + + beforeEach(() => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'hook', + numOfArgs: 1, + nodeName: 'beforeEach', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { beforeEach as it } from '@jest/globals'; + + it(() => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'hook', + numOfArgs: 1, + nodeName: 'it', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { it as testThis, xit as skipThis } from '@jest/globals'; + + testThis('is a jest function', () => {}); + skipThis('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'testThis', + }, + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'skipThis', + }, + column: 1, + line: 4, + }, + ], + }, + { + code: dedent` + import { it as xit, xit as skipThis } from '@jest/globals'; + + xit('is a jest function', () => {}); + skipThis('is a jest function'); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'xit', + }, + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 1, + nodeName: 'skipThis', + }, + column: 1, + line: 4, + }, + ], + }, + { + code: dedent` + import { test as testWithJest } from '@jest/globals'; + const test = () => {}; + + describe(test, () => { + testWithJest('should do something good', () => { + expect(test({})).toBeDefined(); + }); + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe', + }, + column: 1, + line: 4, + }, + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'testWithJest', + }, + column: 3, + line: 5, + }, + ], + }, + ], + }); + + 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: { + callType: 'test', + numOfArgs: 2, + nodeName: 'it', + }, + 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: { + callType: 'test', + numOfArgs: 2, + nodeName: 'it', + }, + 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: [ + { + code: dedent` + const { describe } = require('@jest/globals'); + + describe('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { describe } = require('@jest/globals'); + + describe.skip('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe.skip', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { describe } = require(\`@jest/globals\`); + + describe('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'describe', + numOfArgs: 2, + nodeName: 'describe', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { it } = require('@jest/globals'); + + it('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'it', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { beforeEach } = require('@jest/globals'); + + beforeEach(() => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'hook', + numOfArgs: 1, + nodeName: 'beforeEach', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { beforeEach: it } = require('@jest/globals'); + + it(() => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'hook', + numOfArgs: 1, + nodeName: 'it', + }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + const { it: testThis, xit: skipThis } = require('@jest/globals'); + + testThis('is a jest function', () => {}); + skipThis('is a jest function', () => {}); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'testThis', + }, + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'skipThis', + }, + column: 1, + line: 4, + }, + ], + }, + { + code: dedent` + const { it: xit, xit: skipThis } = require('@jest/globals'); + + xit('is a jest function', () => {}); + skipThis('is a jest function'); + `, + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 2, + nodeName: 'xit', + }, + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: { + callType: 'test', + numOfArgs: 1, + nodeName: 'skipThis', + }, + column: 1, + line: 4, + }, + ], + }, + ], + }); + + 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' }, + }, + ], + invalid: [], + }); +}); diff --git a/src/rules/consistent-test-it.ts b/src/rules/consistent-test-it.ts index 1b34ed67a..bee9976d8 100644 --- a/src/rules/consistent-test-it.ts +++ b/src/rules/consistent-test-it.ts @@ -64,6 +64,7 @@ export default createRule< }, defaultOptions: [{ fn: TestCaseName.test, withinDescribe: TestCaseName.it }], create(context) { + const scope = context.getScope(); const configObj = context.options[0] || {}; const testKeyword = configObj.fn || TestCaseName.test; const testKeywordWithinDescribe = @@ -79,7 +80,7 @@ export default createRule< return; } - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { describeNestingLevel++; } @@ -91,7 +92,7 @@ export default createRule< : node.callee; if ( - isTestCaseCall(node) && + isTestCaseCall(node, scope) && describeNestingLevel === 0 && !nodeName.includes(testKeyword) ) { @@ -106,7 +107,7 @@ export default createRule< } if ( - isTestCaseCall(node) && + isTestCaseCall(node, scope) && describeNestingLevel > 0 && !nodeName.includes(testKeywordWithinDescribe) ) { @@ -123,7 +124,7 @@ export default createRule< } }, 'CallExpression:exit'(node) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { describeNestingLevel--; } }, diff --git a/src/rules/expect-expect.ts b/src/rules/expect-expect.ts index e94c8f8f0..c84710742 100644 --- a/src/rules/expect-expect.ts +++ b/src/rules/expect-expect.ts @@ -82,6 +82,7 @@ export default createRule< context, [{ assertFunctionNames = ['expect'], additionalTestBlockFunctions = [] }], ) { + const scope = context.getScope(); const unchecked: TSESTree.CallExpression[] = []; function checkCallExpressionUsed(nodes: TSESTree.Node[]) { @@ -94,7 +95,10 @@ export default createRule< if (node.type === AST_NODE_TYPES.FunctionDeclaration) { const declaredVariables = context.getDeclaredVariables(node); const testCallExpressions = - getTestCallExpressionsFromDeclaredVariables(declaredVariables); + getTestCallExpressionsFromDeclaredVariables( + declaredVariables, + scope, + ); checkCallExpressionUsed(testCallExpressions); } @@ -111,7 +115,7 @@ export default createRule< const name = getNodeName(node.callee) ?? ''; if ( - isTestCaseCall(node) || + isTestCaseCall(node, scope) || additionalTestBlockFunctions.includes(name) ) { if ( diff --git a/src/rules/max-nested-describe.ts b/src/rules/max-nested-describe.ts index 4596d32ec..4394fcd7e 100644 --- a/src/rules/max-nested-describe.ts +++ b/src/rules/max-nested-describe.ts @@ -29,6 +29,7 @@ export default createRule({ }, defaultOptions: [{ max: 5 }], create(context, [{ max }]) { + const scope = context.getScope(); const describeCallbackStack: number[] = []; function pushDescribeCallback( @@ -38,7 +39,7 @@ export default createRule({ if ( parent?.type !== AST_NODE_TYPES.CallExpression || - !isDescribeCall(parent) + !isDescribeCall(parent, scope) ) { return; } @@ -61,7 +62,7 @@ export default createRule({ if ( parent?.type === AST_NODE_TYPES.CallExpression && - isDescribeCall(parent) + isDescribeCall(parent, scope) ) { describeCallbackStack.pop(); } diff --git a/src/rules/no-conditional-expect.ts b/src/rules/no-conditional-expect.ts index 96afaeca9..3217544a3 100644 --- a/src/rules/no-conditional-expect.ts +++ b/src/rules/no-conditional-expect.ts @@ -30,6 +30,7 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); let conditionalDepth = 0; let inTestCase = false; let inPromiseCatch = false; @@ -40,15 +41,17 @@ export default createRule({ return { FunctionDeclaration(node) { const declaredVariables = context.getDeclaredVariables(node); - const testCallExpressions = - getTestCallExpressionsFromDeclaredVariables(declaredVariables); + const testCallExpressions = getTestCallExpressionsFromDeclaredVariables( + declaredVariables, + scope, + ); if (testCallExpressions.length > 0) { inTestCase = true; } }, CallExpression(node: TSESTree.CallExpression) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCase = true; } @@ -71,7 +74,7 @@ export default createRule({ } }, 'CallExpression:exit'(node) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCase = false; } diff --git a/src/rules/no-conditional-in-test.ts b/src/rules/no-conditional-in-test.ts index 3fb06176e..8e4aa3520 100644 --- a/src/rules/no-conditional-in-test.ts +++ b/src/rules/no-conditional-in-test.ts @@ -17,6 +17,7 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); let inTestCase = false; const maybeReportConditional = (node: TSESTree.Node) => { @@ -30,12 +31,12 @@ export default createRule({ return { CallExpression(node: TSESTree.CallExpression) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCase = true; } }, 'CallExpression:exit'(node) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCase = false; } }, diff --git a/src/rules/no-done-callback.ts b/src/rules/no-done-callback.ts index a766eb451..62e023b95 100644 --- a/src/rules/no-done-callback.ts +++ b/src/rules/no-done-callback.ts @@ -1,25 +1,26 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { createRule, getNodeName, isFunction, - isHook, + isHookCall, isTestCaseCall, } from './utils'; const findCallbackArg = ( node: TSESTree.CallExpression, isJestEach: boolean, + scope: TSESLint.Scope.Scope, ): TSESTree.CallExpression['arguments'][0] | null => { if (isJestEach) { return node.arguments[1]; } - if (isHook(node) && node.arguments.length >= 1) { + if (isHookCall(node, scope) && node.arguments.length >= 1) { return node.arguments[0]; } - if (isTestCaseCall(node) && node.arguments.length >= 2) { + if (isTestCaseCall(node, scope) && node.arguments.length >= 2) { return node.arguments[1]; } @@ -48,6 +49,8 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); + return { CallExpression(node) { // done is the second argument for it.each, not the first @@ -63,7 +66,7 @@ export default createRule({ return; } - const callback = findCallbackArg(node, isJestEach); + const callback = findCallbackArg(node, isJestEach, scope); const callbackArgIndex = Number(isJestEach); if ( diff --git a/src/rules/no-duplicate-hooks.ts b/src/rules/no-duplicate-hooks.ts index e8b02754a..374e65f06 100644 --- a/src/rules/no-duplicate-hooks.ts +++ b/src/rules/no-duplicate-hooks.ts @@ -1,4 +1,4 @@ -import { createRule, isDescribeCall, isHook } from './utils'; +import { createRule, isDescribeCall, isHookCall } from './utils'; const newHookContext = () => ({ beforeAll: 0, @@ -23,15 +23,16 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); const hookContexts = [newHookContext()]; return { CallExpression(node) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { hookContexts.push(newHookContext()); } - if (isHook(node)) { + if (isHookCall(node, scope)) { const currentLayer = hookContexts[hookContexts.length - 1]; currentLayer[node.callee.name] += 1; @@ -45,7 +46,7 @@ export default createRule({ } }, 'CallExpression:exit'(node) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { hookContexts.pop(); } }, diff --git a/src/rules/no-export.ts b/src/rules/no-export.ts index 1106c6450..4ea447f8d 100644 --- a/src/rules/no-export.ts +++ b/src/rules/no-export.ts @@ -17,6 +17,7 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); const exportNodes: Array< | TSESTree.ExportNamedDeclaration | TSESTree.ExportDefaultDeclaration @@ -34,7 +35,7 @@ export default createRule({ }, CallExpression(node) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { hasTestCase = true; } }, diff --git a/src/rules/no-focused-tests.ts b/src/rules/no-focused-tests.ts index 15410d61d..85be0efba 100644 --- a/src/rules/no-focused-tests.ts +++ b/src/rules/no-focused-tests.ts @@ -52,49 +52,53 @@ export default createRule({ hasSuggestions: true, }, defaultOptions: [], - create: context => ({ - CallExpression(node) { - if (!isDescribeCall(node) && !isTestCaseCall(node)) { - return; - } + create(context) { + const scope = context.getScope(); + + return { + CallExpression(node) { + if (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) { + return; + } + + if (getNodeName(node).startsWith('f')) { + context.report({ + messageId: 'focusedTest', + node, + suggest: [ + { + messageId: 'suggestRemoveFocus', + fix: fixer => + fixer.removeRange([node.range[0], node.range[0] + 1]), + }, + ], + }); + + return; + } + + const onlyNode = findOnlyNode(node); + + if (!onlyNode) { + return; + } - if (getNodeName(node).startsWith('f')) { context.report({ messageId: 'focusedTest', - node, + node: onlyNode, suggest: [ { messageId: 'suggestRemoveFocus', fix: fixer => - fixer.removeRange([node.range[0], node.range[0] + 1]), + fixer.removeRange([ + onlyNode.range[0] - 1, + onlyNode.range[1] + + Number(onlyNode.type !== AST_NODE_TYPES.Identifier), + ]), }, ], }); - - return; - } - - const onlyNode = findOnlyNode(node); - - if (!onlyNode) { - return; - } - - context.report({ - messageId: 'focusedTest', - node: onlyNode, - suggest: [ - { - messageId: 'suggestRemoveFocus', - fix: fixer => - fixer.removeRange([ - onlyNode.range[0] - 1, - onlyNode.range[1] + - Number(onlyNode.type !== AST_NODE_TYPES.Identifier), - ]), - }, - ], - }); - }, - }), + }, + }; + }, }); diff --git a/src/rules/no-hooks.ts b/src/rules/no-hooks.ts index 6894093bc..ec26b1ed5 100644 --- a/src/rules/no-hooks.ts +++ b/src/rules/no-hooks.ts @@ -1,4 +1,4 @@ -import { HookName, createRule, isHook } from './utils'; +import { HookName, createRule, isHookCall } from './utils'; export default createRule< [Partial<{ allow: readonly HookName[] }>], @@ -30,9 +30,11 @@ export default createRule< }, defaultOptions: [{ allow: [] }], create(context, [{ allow = [] }]) { + const scope = context.getScope(); + return { CallExpression(node) { - if (isHook(node) && !allow.includes(node.callee.name)) { + if (isHookCall(node, scope) && !allow.includes(node.callee.name)) { context.report({ node, messageId: 'unexpectedHook', diff --git a/src/rules/no-identical-title.ts b/src/rules/no-identical-title.ts index 6f905bb18..e9091ecb3 100644 --- a/src/rules/no-identical-title.ts +++ b/src/rules/no-identical-title.ts @@ -36,13 +36,14 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); const contexts = [newDescribeContext()]; return { CallExpression(node) { const currentLayer = contexts[contexts.length - 1]; - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { contexts.push(newDescribeContext()); } @@ -58,7 +59,7 @@ export default createRule({ const title = getStringValue(argument); - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { if (currentLayer.testTitles.includes(title)) { context.report({ messageId: 'multipleTestTitle', @@ -68,7 +69,7 @@ export default createRule({ currentLayer.testTitles.push(title); } - if (!isDescribeCall(node)) { + if (!isDescribeCall(node, scope)) { return; } if (currentLayer.describeTitles.includes(title)) { @@ -80,7 +81,7 @@ export default createRule({ currentLayer.describeTitles.push(title); }, 'CallExpression:exit'(node) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { contexts.pop(); } }, diff --git a/src/rules/no-if.ts b/src/rules/no-if.ts index 66a706ae1..c32a4eef9 100644 --- a/src/rules/no-if.ts +++ b/src/rules/no-if.ts @@ -51,6 +51,7 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); const stack: boolean[] = []; function validate( @@ -74,7 +75,7 @@ export default createRule({ return { CallExpression(node) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { stack.push(true); if (getNodeName(node).endsWith('each')) { @@ -87,8 +88,10 @@ export default createRule({ }, FunctionDeclaration(node) { const declaredVariables = context.getDeclaredVariables(node); - const testCallExpressions = - getTestCallExpressionsFromDeclaredVariables(declaredVariables); + const testCallExpressions = getTestCallExpressionsFromDeclaredVariables( + declaredVariables, + scope, + ); stack.push(testCallExpressions.length > 0); }, diff --git a/src/rules/no-standalone-expect.ts b/src/rules/no-standalone-expect.ts index 30675e2b8..4d34ebbbb 100644 --- a/src/rules/no-standalone-expect.ts +++ b/src/rules/no-standalone-expect.ts @@ -1,4 +1,4 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { DescribeAlias, createRule, @@ -11,6 +11,7 @@ import { const getBlockType = ( statement: TSESTree.BlockStatement, + scope: TSESLint.Scope.Scope, ): 'function' | 'describe' | null => { const func = statement.parent; @@ -35,7 +36,10 @@ const getBlockType = ( } // if it's not a variable, it will be callExpr, we only care about describe - if (expr.type === AST_NODE_TYPES.CallExpression && isDescribeCall(expr)) { + if ( + expr.type === AST_NODE_TYPES.CallExpression && + isDescribeCall(expr, scope) + ) { return 'describe'; } } @@ -74,6 +78,7 @@ export default createRule< }, defaultOptions: [{ additionalTestBlockFunctions: [] }], create(context, [{ additionalTestBlockFunctions = [] }]) { + const scope = context.getScope(); const callStack: BlockType[] = []; const isCustomTestBlockFunction = ( @@ -82,7 +87,7 @@ export default createRule< additionalTestBlockFunctions.includes(getNodeName(node) || ''); const isTestBlock = (node: TSESTree.CallExpression): boolean => - isTestCaseCall(node) || isCustomTestBlockFunction(node); + isTestCaseCall(node, scope) || isCustomTestBlockFunction(node); return { CallExpression(node) { @@ -119,14 +124,16 @@ export default createRule< }, BlockStatement(statement) { - const blockType = getBlockType(statement); + const blockType = getBlockType(statement, scope); if (blockType) { callStack.push(blockType); } }, 'BlockStatement:exit'(statement: TSESTree.BlockStatement) { - if (callStack[callStack.length - 1] === getBlockType(statement)) { + if ( + callStack[callStack.length - 1] === getBlockType(statement, scope) + ) { callStack.pop(); } }, diff --git a/src/rules/no-test-prefixes.ts b/src/rules/no-test-prefixes.ts index 21bab4c2d..0a505b570 100644 --- a/src/rules/no-test-prefixes.ts +++ b/src/rules/no-test-prefixes.ts @@ -23,11 +23,16 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); + return { CallExpression(node) { const nodeName = getNodeName(node.callee); - if (!nodeName || (!isDescribeCall(node) && !isTestCaseCall(node))) + if ( + !nodeName || + (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) + ) return; const preferredNodeName = getPreferredNodeName(nodeName); diff --git a/src/rules/no-test-return-statement.ts b/src/rules/no-test-return-statement.ts index 832b00c88..3616c8a74 100644 --- a/src/rules/no-test-return-statement.ts +++ b/src/rules/no-test-return-statement.ts @@ -36,9 +36,11 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); + return { CallExpression(node) { - if (!isTestCaseCall(node)) return; + if (!isTestCaseCall(node, scope)) return; const body = getBody(node.arguments); const returnStmt = body.find( t => t.type === AST_NODE_TYPES.ReturnStatement, @@ -50,8 +52,10 @@ export default createRule({ }, FunctionDeclaration(node) { const declaredVariables = context.getDeclaredVariables(node); - const testCallExpressions = - getTestCallExpressionsFromDeclaredVariables(declaredVariables); + const testCallExpressions = getTestCallExpressionsFromDeclaredVariables( + declaredVariables, + scope, + ); if (testCallExpressions.length === 0) return; diff --git a/src/rules/prefer-expect-assertions.ts b/src/rules/prefer-expect-assertions.ts index 99ca8a770..401a73c5c 100644 --- a/src/rules/prefer-expect-assertions.ts +++ b/src/rules/prefer-expect-assertions.ts @@ -104,6 +104,8 @@ export default createRule<[RuleOptions], MessageIds>({ }, ], create(context, [options]) { + const scope = context.getScope(); + let expressionDepth = 0; let hasExpectInCallback = false; let hasExpectInLoop = false; @@ -157,7 +159,7 @@ export default createRule<[RuleOptions], MessageIds>({ ForOfStatement: enterForLoop, 'ForOfStatement:exit': exitForLoop, CallExpression(node) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCaseCall = true; return; @@ -174,7 +176,7 @@ export default createRule<[RuleOptions], MessageIds>({ } }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (!isTestCaseCall(node)) { + if (!isTestCaseCall(node, scope)) { return; } diff --git a/src/rules/prefer-hooks-on-top.ts b/src/rules/prefer-hooks-on-top.ts index e9d5e0cde..ebe86b560 100644 --- a/src/rules/prefer-hooks-on-top.ts +++ b/src/rules/prefer-hooks-on-top.ts @@ -1,4 +1,4 @@ -import { createRule, isHook, isTestCaseCall } from './utils'; +import { createRule, isHookCall, isTestCaseCall } from './utils'; export default createRule({ name: __filename, @@ -16,14 +16,15 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); const hooksContext = [false]; return { CallExpression(node) { - if (!isHook(node) && isTestCaseCall(node)) { + if (!isHookCall(node, scope) && isTestCaseCall(node, scope)) { hooksContext[hooksContext.length - 1] = true; } - if (hooksContext[hooksContext.length - 1] && isHook(node)) { + if (hooksContext[hooksContext.length - 1] && isHookCall(node, scope)) { context.report({ messageId: 'noHookOnTop', node, diff --git a/src/rules/prefer-lowercase-title.ts b/src/rules/prefer-lowercase-title.ts index ce4860e4c..86b16c667 100644 --- a/src/rules/prefer-lowercase-title.ts +++ b/src/rules/prefer-lowercase-title.ts @@ -24,8 +24,9 @@ const hasStringAsFirstArgument = ( const findNodeNameAndArgument = ( node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, ): [name: string, firstArg: StringNode] | null => { - if (!(isTestCaseCall(node) || isDescribeCall(node))) { + if (!(isTestCaseCall(node, scope) || isDescribeCall(node, scope))) { return null; } @@ -114,12 +115,13 @@ export default createRule< context, [{ ignore = [], allowedPrefixes = [], ignoreTopLevelDescribe }], ) { + const scope = context.getScope(); const ignores = populateIgnores(ignore); let numberOfDescribeBlocks = 0; return { CallExpression(node: TSESTree.CallExpression) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { numberOfDescribeBlocks++; if (ignoreTopLevelDescribe && numberOfDescribeBlocks === 1) { @@ -127,7 +129,7 @@ export default createRule< } } - const results = findNodeNameAndArgument(node); + const results = findNodeNameAndArgument(node, scope); if (!results) { return; @@ -173,7 +175,7 @@ export default createRule< }); }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { numberOfDescribeBlocks--; } }, diff --git a/src/rules/prefer-snapshot-hint.ts b/src/rules/prefer-snapshot-hint.ts index 31ddb8316..74f751a2e 100644 --- a/src/rules/prefer-snapshot-hint.ts +++ b/src/rules/prefer-snapshot-hint.ts @@ -61,6 +61,7 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ }, defaultOptions: ['multi'], create(context, [mode]) { + const scope = context.getScope(); const snapshotMatchers: ParsedExpectMatcher[] = []; const depths: number[] = []; let expressionDepth = 0; @@ -107,13 +108,13 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ ArrowFunctionExpression: enterExpression, 'ArrowFunctionExpression:exit': exitExpression, 'CallExpression:exit'(node) { - if (isDescribeCall(node) || isTestCaseCall(node)) { + if (isDescribeCall(node, scope) || isTestCaseCall(node, scope)) { /* istanbul ignore next */ expressionDepth = depths.pop() ?? 0; } }, CallExpression(node) { - if (isDescribeCall(node) || isTestCaseCall(node)) { + if (isDescribeCall(node, scope) || isTestCaseCall(node, scope)) { depths.push(expressionDepth); expressionDepth = 0; } diff --git a/src/rules/prefer-todo.ts b/src/rules/prefer-todo.ts index 03b716d71..d69c35acc 100644 --- a/src/rules/prefer-todo.ts +++ b/src/rules/prefer-todo.ts @@ -31,8 +31,9 @@ function createTodoFixer( const isTargetedTestCase = ( node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, ): node is JestFunctionCallExpression => - isTestCaseCall(node) && + isTestCaseCall(node, scope) && [TestCaseName.it, TestCaseName.test, 'it.skip', 'test.skip'].includes( getNodeName(node), ); @@ -55,11 +56,17 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); + return { CallExpression(node) { const [title, callback] = node.arguments; - if (!title || !isTargetedTestCase(node) || !isStringNode(title)) { + if ( + !title || + !isTargetedTestCase(node, scope) || + !isStringNode(title) + ) { return; } diff --git a/src/rules/require-hook.ts b/src/rules/require-hook.ts index 9073e69d7..b7034cbd8 100644 --- a/src/rules/require-hook.ts +++ b/src/rules/require-hook.ts @@ -1,16 +1,23 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { createRule, getNodeName, isDescribeCall, isFunction, - isHook, + isHookCall, isIdentifier, isTestCaseCall, } from './utils'; -const isJestFnCall = (node: TSESTree.CallExpression): boolean => { - if (isDescribeCall(node) || isTestCaseCall(node) || isHook(node)) { +const isJestFnCall = ( + node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, +): boolean => { + if ( + isDescribeCall(node, scope) || + isTestCaseCall(node, scope) || + isHookCall(node, scope) + ) { return true; } @@ -26,14 +33,15 @@ const isNullOrUndefined = (node: TSESTree.Expression): boolean => { const shouldBeInHook = ( node: TSESTree.Node, + scope: TSESLint.Scope.Scope, allowedFunctionCalls: readonly string[] = [], ): boolean => { switch (node.type) { case AST_NODE_TYPES.ExpressionStatement: - return shouldBeInHook(node.expression, allowedFunctionCalls); + return shouldBeInHook(node.expression, scope, allowedFunctionCalls); case AST_NODE_TYPES.CallExpression: return !( - isJestFnCall(node) || + isJestFnCall(node, scope) || allowedFunctionCalls.includes(getNodeName(node) as string) ); case AST_NODE_TYPES.VariableDeclaration: { @@ -85,11 +93,12 @@ export default createRule< }, ], create(context) { + const scope = context.getScope(); const { allowedFunctionCalls } = context.options[0] ?? {}; const checkBlockBody = (body: TSESTree.BlockStatement['body']) => { for (const statement of body) { - if (shouldBeInHook(statement, allowedFunctionCalls)) { + if (shouldBeInHook(statement, scope, allowedFunctionCalls)) { context.report({ node: statement, messageId: 'useHook', @@ -103,7 +112,7 @@ export default createRule< checkBlockBody(program.body); }, CallExpression(node) { - if (!isDescribeCall(node) || node.arguments.length < 2) { + if (!isDescribeCall(node, scope) || node.arguments.length < 2) { return; } diff --git a/src/rules/require-top-level-describe.ts b/src/rules/require-top-level-describe.ts index 8ff333486..049cb7249 100644 --- a/src/rules/require-top-level-describe.ts +++ b/src/rules/require-top-level-describe.ts @@ -1,5 +1,10 @@ import { TSESTree } from '@typescript-eslint/utils'; -import { createRule, isDescribeCall, isHook, isTestCaseCall } from './utils'; +import { + createRule, + isDescribeCall, + isHookCall, + isTestCaseCall, +} from './utils'; const messages = { tooManyDescribes: @@ -39,12 +44,13 @@ export default createRule< create(context) { const { maxNumberOfTopLevelDescribes = Infinity } = context.options[0] ?? {}; + const scope = context.getScope(); let numberOfTopLevelDescribeBlocks = 0; let numberOfDescribeBlocks = 0; return { CallExpression(node) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { numberOfDescribeBlocks++; if (numberOfDescribeBlocks === 1) { @@ -65,13 +71,13 @@ export default createRule< } if (numberOfDescribeBlocks === 0) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { context.report({ node, messageId: 'unexpectedTestCase' }); return; } - if (isHook(node)) { + if (isHookCall(node, scope)) { context.report({ node, messageId: 'unexpectedHook' }); return; @@ -79,7 +85,7 @@ export default createRule< } }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (isDescribeCall(node)) { + if (isDescribeCall(node, scope)) { numberOfDescribeBlocks--; } }, diff --git a/src/rules/utils.ts b/src/rules/utils.ts index 873aa25d4..a024c4cb6 100644 --- a/src/rules/utils.ts +++ b/src/rules/utils.ts @@ -649,14 +649,24 @@ export const isFunction = (node: TSESTree.Node): node is FunctionExpression => node.type === AST_NODE_TYPES.FunctionExpression || node.type === AST_NODE_TYPES.ArrowFunctionExpression; -export const isHook = ( +export const isHookCall = ( node: TSESTree.CallExpression, -): node is JestFunctionCallExpressionWithIdentifierCallee => - node.callee.type === AST_NODE_TYPES.Identifier && - HookName.hasOwnProperty(node.callee.name); + scope: TSESLint.Scope.Scope, +): node is JestFunctionCallExpressionWithIdentifierCallee => { + let name = findFirstCallPropertyName(node, []); + + if (!name) { + return false; + } + + name = resolveToJestFn(scope, name); + + return name !== null && HookName.hasOwnProperty(name); +}; export const getTestCallExpressionsFromDeclaredVariables = ( declaredVariables: readonly TSESLint.Scope.Variable[], + scope: TSESLint.Scope.Scope, ): Array> => { return declaredVariables.reduce< Array> @@ -669,39 +679,41 @@ export const getTestCallExpressionsFromDeclaredVariables = ( (node): node is JestFunctionCallExpression => !!node && node.type === AST_NODE_TYPES.CallExpression && - isTestCaseCall(node), + isTestCaseCall(node, scope), ), ), [], ); }; -const isTestCaseName = (node: TSESTree.LeftHandSideExpression) => - node.type === AST_NODE_TYPES.Identifier && - TestCaseName.hasOwnProperty(node.name); - -const isTestCaseProperty = ( - node: TSESTree.Expression | TSESTree.PrivateIdentifier, -): node is AccessorNode => - isSupportedAccessor(node) && - TestCaseProperty.hasOwnProperty(getAccessorValue(node)); - /** * Checks if the given `node` is a *call* to a test case function that would * result in tests being run by `jest`. * * Note that `.each()` does not count as a call in this context, as it will not * result in `jest` running any tests. - * - * @param {TSESTree.CallExpression} node - * - * @return {node is JestFunctionCallExpression} */ export const isTestCaseCall = ( node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, ): node is JestFunctionCallExpression => { - if (isTestCaseName(node.callee)) { - return true; + let name = findFirstCallPropertyName(node, Object.keys(TestCaseProperty)); + + if (!name) { + return false; + } + + name = resolveToJestFn(scope, name); + + return name !== null && TestCaseName.hasOwnProperty(name); +}; + +const findFirstCallPropertyName = ( + node: TSESTree.CallExpression, + properties: readonly string[], +): string | null => { + if (isIdentifier(node.callee)) { + return node.callee.name; } const callee = @@ -713,7 +725,8 @@ export const isTestCaseCall = ( if ( callee.type === AST_NODE_TYPES.MemberExpression && - isTestCaseProperty(callee.property) + isSupportedAccessor(callee.property) && + properties.includes(getAccessorValue(callee.property)) ) { // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`) if ( @@ -721,88 +734,183 @@ export const isTestCaseCall = ( node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression && node.callee.type !== AST_NODE_TYPES.CallExpression ) { - return false; + return null; } - return callee.object.type === AST_NODE_TYPES.MemberExpression - ? isTestCaseName(callee.object.object) - : isTestCaseName(callee.object); + const nod = + callee.object.type === AST_NODE_TYPES.MemberExpression + ? callee.object.object + : callee.object; + + if (isSupportedAccessor(nod)) { + return getAccessorValue(nod); + } } - return false; + return null; }; -const isDescribeAlias = (node: TSESTree.LeftHandSideExpression) => - node.type === AST_NODE_TYPES.Identifier && - DescribeAlias.hasOwnProperty(node.name); - -const isDescribeProperty = ( - node: TSESTree.Expression | TSESTree.PrivateIdentifier, -): node is AccessorNode => - isSupportedAccessor(node) && - DescribeProperty.hasOwnProperty(getAccessorValue(node)); - /** * Checks if the given `node` is a *call* to a `describe` function that would * result in a `describe` block being created by `jest`. * * Note that `.each()` does not count as a call in this context, as it will not * result in `jest` creating any `describe` blocks. - * - * @param {TSESTree.CallExpression} node - * - * @return {node is JestFunctionCallExpression} */ export const isDescribeCall = ( node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, ): node is JestFunctionCallExpression => { - if (isDescribeAlias(node.callee)) { - return true; + let name = findFirstCallPropertyName(node, Object.keys(DescribeProperty)); + + if (!name) { + return false; } - const callee = - node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression - ? node.callee.tag - : node.callee.type === AST_NODE_TYPES.CallExpression - ? node.callee.callee - : node.callee; + name = resolveToJestFn(scope, name); + + return name !== null && DescribeAlias.hasOwnProperty(name); +}; + +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 ( - callee.type === AST_NODE_TYPES.MemberExpression && - isDescribeProperty(callee.property) + node.type === AST_NODE_TYPES.CallExpression && + isIdentifier(node.callee, 'require') ) { - // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`) - if ( - getAccessorValue(callee.property) === 'each' && - node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression && - node.callee.type !== AST_NODE_TYPES.CallExpression - ) { - return false; - } + return node.arguments[0] ?? null; + } - return callee.object.type === AST_NODE_TYPES.MemberExpression - ? isDescribeAlias(callee.object.object) - : isDescribeAlias(callee.object); + 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; } - return false; + 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) { - const isReferenceDefined = ref.defs.some(def => { - return def.type !== 'ImplicitGlobalVariable'; - }); + if (ref.defs.length === 0) { + continue; + } + + /* istanbul ignore if */ + if (ref.defs.length > 1) { + throw new Error( + `Reference unexpected had more than one definition - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`, + ); + } + + const [def] = ref.defs; + + const importDetails = describePossibleImportDef(def); - if (isReferenceDefined) { - locals.add(ref.name); + if (importDetails) { + imports.set(importDetails.local, importDetails); + + continue; } + + locals.add(ref.name); } for (const ref of currentScope.through) { @@ -812,7 +920,7 @@ const collectReferences = (scope: TSESLint.Scope.Scope) => { currentScope = currentScope.upper; } - return { locals, unresolved }; + return { locals, imports, unresolved }; }; export const scopeHasLocalReference = ( @@ -824,8 +932,34 @@ export const scopeHasLocalReference = ( return ( // referenceName was found as a local variable or function declaration. references.locals.has(referenceName) || + // referenceName was found as an imported identifier + references.imports.has(referenceName) || // referenceName was not found as an unresolved reference, // meaning it is likely not an implicit global reference. !references.unresolved.has(referenceName) ); }; + +const resolveToJestFn = (scope: TSESLint.Scope.Scope, identifier: string) => { + const references = collectReferences(scope); + + 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 maybeImport.imported; + } + + 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 identifier; +}; diff --git a/src/rules/valid-describe-callback.ts b/src/rules/valid-describe-callback.ts index 9bcdc8122..3026269a5 100644 --- a/src/rules/valid-describe-callback.ts +++ b/src/rules/valid-describe-callback.ts @@ -34,9 +34,11 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); + return { CallExpression(node) { - if (!isDescribeCall(node)) { + if (!isDescribeCall(node, scope)) { return; } diff --git a/src/rules/valid-expect-in-promise.ts b/src/rules/valid-expect-in-promise.ts index 0a6171598..a3cac350d 100644 --- a/src/rules/valid-expect-in-promise.ts +++ b/src/rules/valid-expect-in-promise.ts @@ -1,4 +1,4 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { KnownCallExpression, ModifierName, @@ -69,8 +69,9 @@ const findTopMostCallExpression = ( const isTestCaseCallWithCallbackArg = ( node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, ): boolean => { - if (!isTestCaseCall(node)) { + if (!isTestCaseCall(node, scope)) { return false; } @@ -320,7 +321,10 @@ const findFirstBlockBodyUp = ( ); }; -const isDirectlyWithinTestCaseCall = (node: TSESTree.Node): boolean => { +const isDirectlyWithinTestCaseCall = ( + node: TSESTree.Node, + scope: TSESLint.Scope.Scope, +): boolean => { let parent: TSESTree.Node['parent'] = node; while (parent) { @@ -328,7 +332,8 @@ const isDirectlyWithinTestCaseCall = (node: TSESTree.Node): boolean => { parent = parent.parent; return !!( - parent?.type === AST_NODE_TYPES.CallExpression && isTestCaseCall(parent) + parent?.type === AST_NODE_TYPES.CallExpression && + isTestCaseCall(parent, scope) ); } @@ -370,6 +375,7 @@ export default createRule({ }, defaultOptions: [], create(context) { + const scope = context.getScope(); let inTestCaseWithDoneCallback = false; // an array of booleans representing each promise chain we enter, with the // boolean value representing if we think a given chain contains an expect @@ -384,7 +390,7 @@ export default createRule({ CallExpression(node: TSESTree.CallExpression) { // there are too many ways that the done argument could be used with // promises that contain expect that would make the promise safe for us - if (isTestCaseCallWithCallbackArg(node)) { + if (isTestCaseCallWithCallbackArg(node, scope)) { inTestCaseWithDoneCallback = true; return; @@ -409,7 +415,7 @@ export default createRule({ // make promises containing expects safe in a test for us to be able to // accurately check, so we just bail out completely if it's present if (inTestCaseWithDoneCallback) { - if (isTestCaseCall(node)) { + if (isTestCaseCall(node, scope)) { inTestCaseWithDoneCallback = false; } @@ -436,7 +442,7 @@ export default createRule({ // or our parent is not directly within the test case, we stop checking // because we're most likely in the body of a function being defined // within the test, which we can't track - if (!parent || !isDirectlyWithinTestCaseCall(parent)) { + if (!parent || !isDirectlyWithinTestCaseCall(parent, scope)) { return; } diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 9e02ce428..d4c5189cc 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -171,6 +171,7 @@ export default createRule<[Options], MessageIds>({ }, ], ) { + const scope = context.getScope(); const disallowedWordsRegexp = new RegExp( `\\b(${disallowedWords.join('|')})\\b`, 'iu', @@ -181,7 +182,7 @@ export default createRule<[Options], MessageIds>({ return { CallExpression(node: TSESTree.CallExpression) { - if (!isDescribeCall(node) && !isTestCaseCall(node)) { + if (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) { return; } @@ -201,7 +202,7 @@ export default createRule<[Options], MessageIds>({ if ( argument.type !== AST_NODE_TYPES.TemplateLiteral && - !(ignoreTypeOfDescribeName && isDescribeCall(node)) + !(ignoreTypeOfDescribeName && isDescribeCall(node, scope)) ) { context.report({ messageId: 'titleMustBeString', @@ -218,7 +219,7 @@ export default createRule<[Options], MessageIds>({ context.report({ messageId: 'emptyTitle', data: { - jestFunctionName: isDescribeCall(node) + jestFunctionName: isDescribeCall(node, scope) ? DescribeAlias.describe : TestCaseName.test, }, diff --git a/yarn.lock b/yarn.lock index f99d58e1c..45df6bed6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2634,12 +2634,12 @@ __metadata: linkType: hard "@typescript-eslint/eslint-plugin@npm:^5.0.0": - version: 5.22.0 - resolution: "@typescript-eslint/eslint-plugin@npm:5.22.0" + version: 5.23.0 + resolution: "@typescript-eslint/eslint-plugin@npm:5.23.0" dependencies: - "@typescript-eslint/scope-manager": 5.22.0 - "@typescript-eslint/type-utils": 5.22.0 - "@typescript-eslint/utils": 5.22.0 + "@typescript-eslint/scope-manager": 5.23.0 + "@typescript-eslint/type-utils": 5.23.0 + "@typescript-eslint/utils": 5.23.0 debug: ^4.3.2 functional-red-black-tree: ^1.0.1 ignore: ^5.1.8 @@ -2652,53 +2652,53 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 3b083f7003f091c3ef7b3970dca9cfd507ab8c52a9b8a52259c630010adf765e9766f0e6fd9c901fc0e807319a4e8c003e12287b1f12a4b9eb4d7222e8d6db83 + checksum: 19ee37c0be172469968f61d156d6ce36a975ab72ccbb8f702eb4573c94d1cf9247ff32352ed85eda5e7b2eaace567d5c66b32846f042f9711349213496ec37d4 languageName: node linkType: hard "@typescript-eslint/experimental-utils@npm:^5.0.0": - version: 5.22.0 - resolution: "@typescript-eslint/experimental-utils@npm:5.22.0" + version: 5.23.0 + resolution: "@typescript-eslint/experimental-utils@npm:5.23.0" dependencies: - "@typescript-eslint/utils": 5.22.0 + "@typescript-eslint/utils": 5.23.0 peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: de97dee9ec964981fe161cec7256e94adb6fdfa7dfa7f3b58d817b9d3b2e1923b84440c3c4d32ba71048af97c69321700862cfc76604beeaf617542783c0df5d + checksum: 7db18b7d21cd41c4b8e846d6123803e6612f66acaf5853467cd356fc9a4413ccb1772cd1ddd12241cecdfffc42bba797c3684e6a1fc300284409f65a9035b98f languageName: node linkType: hard "@typescript-eslint/parser@npm:^5.0.0": - version: 5.22.0 - resolution: "@typescript-eslint/parser@npm:5.22.0" + version: 5.23.0 + resolution: "@typescript-eslint/parser@npm:5.23.0" dependencies: - "@typescript-eslint/scope-manager": 5.22.0 - "@typescript-eslint/types": 5.22.0 - "@typescript-eslint/typescript-estree": 5.22.0 + "@typescript-eslint/scope-manager": 5.23.0 + "@typescript-eslint/types": 5.23.0 + "@typescript-eslint/typescript-estree": 5.23.0 debug: ^4.3.2 peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - checksum: 28a7d4b73154fc97336be9a4efd5ffdc659f748232c82479909e86ed87ed8a78d23280b3aaf532ca4e735caaffac43d9576e6af2dfd11865e30a9d70c8a3f275 + checksum: b65a732b0be06ac9e4b13df78c466517e33fd382985c5d85b6d51cfa295cdf3351594cc2f95dda41d57abb6115e3b8df815fbbb7793aa0c4eddbac11077b90a8 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:5.22.0": - version: 5.22.0 - resolution: "@typescript-eslint/scope-manager@npm:5.22.0" +"@typescript-eslint/scope-manager@npm:5.23.0": + version: 5.23.0 + resolution: "@typescript-eslint/scope-manager@npm:5.23.0" dependencies: - "@typescript-eslint/types": 5.22.0 - "@typescript-eslint/visitor-keys": 5.22.0 - checksum: ebf2ad44f4e5a4dfd55225419804f81f68056086c20f1549adbcca4236634eac3aae461e30d6cab6539ce6f42346ed6e1fbbb2710d2cc058a3283ef91a0fe174 + "@typescript-eslint/types": 5.23.0 + "@typescript-eslint/visitor-keys": 5.23.0 + checksum: cd3dda0b18d6730e34784fc63135fc9fe31673898d3e0868cd765ad78855351f285fe577297193cf179b3ce918c3d44453de85159a925f5c02d12a5626e787d8 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:5.22.0": - version: 5.22.0 - resolution: "@typescript-eslint/type-utils@npm:5.22.0" +"@typescript-eslint/type-utils@npm:5.23.0": + version: 5.23.0 + resolution: "@typescript-eslint/type-utils@npm:5.23.0" dependencies: - "@typescript-eslint/utils": 5.22.0 + "@typescript-eslint/utils": 5.23.0 debug: ^4.3.2 tsutils: ^3.21.0 peerDependencies: @@ -2706,23 +2706,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 7128085bfbeca3a9646a795a34730cdfeca110bc00240569f6a7b3dc0854680afa56e015715675a78198b414de869339bd6036cc33cb14903919780a60321a95 + checksum: 88bf7c7a08c11f2a02a05fe331750c569bfc2b4759e0dea6ec72ffd1597624a01100965052a5fede1e3f25ea8ef503bd424e03c9805f0a1af223f28b4fd74946 languageName: node linkType: hard -"@typescript-eslint/types@npm:5.22.0": - version: 5.22.0 - resolution: "@typescript-eslint/types@npm:5.22.0" - checksum: 74f822c5a3b96bba05229eea4ed370c4bd48b17f475c37f08d6ba708adf65c3aa026bb544f1d0308c96e043b30015e396fd53b1e8e4e9fbb6dc9c92d2ccc0a15 +"@typescript-eslint/types@npm:5.23.0": + version: 5.23.0 + resolution: "@typescript-eslint/types@npm:5.23.0" + checksum: 96ae3e80cfae7b34f2846db692c31fb1804bf9651bce1d29f2eb8ae4c763d22f3283adc02dedeebd7cf70e4d8be54ec7f6ca593e03cdca26c791207e7556c2c1 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:5.22.0": - version: 5.22.0 - resolution: "@typescript-eslint/typescript-estree@npm:5.22.0" +"@typescript-eslint/typescript-estree@npm:5.23.0": + version: 5.23.0 + resolution: "@typescript-eslint/typescript-estree@npm:5.23.0" dependencies: - "@typescript-eslint/types": 5.22.0 - "@typescript-eslint/visitor-keys": 5.22.0 + "@typescript-eslint/types": 5.23.0 + "@typescript-eslint/visitor-keys": 5.23.0 debug: ^4.3.2 globby: ^11.0.4 is-glob: ^4.0.3 @@ -2731,33 +2731,33 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 2797a79d7d32a9a547b7f1de77a353d8e8c8519791f865f5e061bfc4918d12cdaddec51afa015f5aac5d068ef525c92bd65afc83b84dc9e52e697303acf0873a + checksum: 8d85bb1cd777e93cc7322ae8fea25f9b924def02494cdb8395c1d5d17b5fd3ac9bc969418a1d20a5dc28c2cdd85da20e13527e28b595c06ff6f84cd22a78d73f languageName: node linkType: hard -"@typescript-eslint/utils@npm:5.22.0, @typescript-eslint/utils@npm:^5.10.0": - version: 5.22.0 - resolution: "@typescript-eslint/utils@npm:5.22.0" +"@typescript-eslint/utils@npm:5.23.0, @typescript-eslint/utils@npm:^5.10.0": + version: 5.23.0 + resolution: "@typescript-eslint/utils@npm:5.23.0" dependencies: "@types/json-schema": ^7.0.9 - "@typescript-eslint/scope-manager": 5.22.0 - "@typescript-eslint/types": 5.22.0 - "@typescript-eslint/typescript-estree": 5.22.0 + "@typescript-eslint/scope-manager": 5.23.0 + "@typescript-eslint/types": 5.23.0 + "@typescript-eslint/typescript-estree": 5.23.0 eslint-scope: ^5.1.1 eslint-utils: ^3.0.0 peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 5019485e76d754a7a60c042545fd884dc666fddf9d4223ff706bbf0c275f19ea25a6b210fb5cf7ed368b019fe538fd854a925e9c6f12007d51b1731a29d95cc1 + checksum: 72207399f29856b601148fe1aff07049021fad8e780ee6e896279d2291806d4608f1c28ddc5c3c5616ce94f25dcbcd26f295669e524fc1c4b4db810569c90f85 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:5.22.0": - version: 5.22.0 - resolution: "@typescript-eslint/visitor-keys@npm:5.22.0" +"@typescript-eslint/visitor-keys@npm:5.23.0": + version: 5.23.0 + resolution: "@typescript-eslint/visitor-keys@npm:5.23.0" dependencies: - "@typescript-eslint/types": 5.22.0 + "@typescript-eslint/types": 5.23.0 eslint-visitor-keys: ^3.0.0 - checksum: d30dfa98dcce75da49a6a204a0132d42e63228c35681cb9b3643e47a0a24a633e259832d48d101265bd85b8eb5a9f2b4858f9447646c1d3df6a2ac54258dfe8f + checksum: 322e10d52a985e8a90d3612bb9d09a87dc64fc4cb1248484f1a9a7a98f65d3ef65a465ce868773a4939e35fa3b726ad609dac5a168efd7eaca4b06df33e965e3 languageName: node linkType: hard