From 781f00e0120a02e992e213042e05c0c03da90330 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sun, 29 May 2022 06:30:28 +1200 Subject: [PATCH] feat: improve how jest function calls are resolved to account for import aliases (#1122) * feat: improve how jest function calls are resolved to account for import aliases * feat(no-focused-tests): switch to using new jest fn call parser * feat(require-top-level-describe): switch to using new jest fn call parser * feat(no-test-prefixes): switch to using new jest fn call parser * feat(prefer-lowercase-title): switch to using new jest fn call parser * feat(valid-title): switch to using new jest fn call parser * feat(no-identical-titles): switch to using new jest fn call parser * feat(no-duplicate-hooks): switch to using new jest fn call parser * feat(consistent-test-it): switch to using new jest fn call parser * feat: switch rules to using new jest fn call parser * chore: remove unused utils * refactor: move `scopeHasLocalReference` util function to deduplicate internal code * refactor: ensure complete test coverage * fix: consider imported `jest` property as a jest function * test: cover advanced cases for `parseJestFnCall` * chore: remove duplicated entries in valid jest fn call chains array * chore: linting fixes * fix: remove `failing` support (for now) * refactor: rename variable to be consistent * test: remove empty suggestions check --- .../__tests__/consistent-test-it.test.ts | 92 ++ src/rules/__tests__/expect-expect.test.ts | 79 ++ .../__tests__/no-conditional-in-test.test.ts | 38 +- src/rules/__tests__/no-done-callback.test.ts | 60 + .../__tests__/no-duplicate-hooks.test.ts | 44 + src/rules/__tests__/no-focused-tests.test.ts | 82 +- src/rules/__tests__/no-hooks.test.ts | 17 + src/rules/__tests__/no-test-prefixes.test.ts | 76 +- .../__tests__/prefer-lowercase-title.test.ts | 70 ++ .../require-top-level-describe.test.ts | 46 + src/rules/__tests__/utils.test.ts | 1049 ----------------- .../__tests__/valid-expect-in-promise.test.ts | 23 + src/rules/__tests__/valid-title.test.ts | 70 +- src/rules/consistent-test-it.ts | 31 +- src/rules/expect-expect.ts | 4 +- src/rules/max-nested-describe.ts | 6 +- src/rules/no-conditional-expect.ts | 6 +- src/rules/no-conditional-in-test.ts | 6 +- src/rules/no-done-callback.ts | 14 +- src/rules/no-duplicate-hooks.ts | 42 +- src/rules/no-export.ts | 4 +- src/rules/no-focused-tests.ts | 58 +- src/rules/no-hooks.ts | 10 +- src/rules/no-identical-title.ts | 22 +- src/rules/no-if.ts | 9 +- src/rules/no-standalone-expect.ts | 7 +- src/rules/no-test-prefixes.ts | 41 +- src/rules/no-test-return-statement.ts | 7 +- src/rules/prefer-expect-assertions.ts | 6 +- src/rules/prefer-hooks-in-order.ts | 17 +- src/rules/prefer-hooks-on-top.ts | 9 +- src/rules/prefer-lowercase-title.ts | 42 +- src/rules/prefer-snapshot-hint.ts | 7 +- src/rules/prefer-todo.ts | 53 +- src/rules/require-hook.ts | 13 +- src/rules/require-top-level-describe.ts | 21 +- src/rules/utils.ts | 289 +---- .../utils/__tests__/parseJestFnCall.test.ts | 418 +++++++ src/rules/utils/parseJestFnCall.ts | 403 +++++++ src/rules/valid-describe-callback.ts | 16 +- src/rules/valid-expect-in-promise.ts | 35 +- src/rules/valid-title.ts | 23 +- 42 files changed, 1720 insertions(+), 1645 deletions(-) delete mode 100644 src/rules/__tests__/utils.test.ts create mode 100644 src/rules/utils/__tests__/parseJestFnCall.test.ts create mode 100644 src/rules/utils/parseJestFnCall.ts diff --git a/src/rules/__tests__/consistent-test-it.test.ts b/src/rules/__tests__/consistent-test-it.test.ts index 6f9a97a0d..354629e17 100644 --- a/src/rules/__tests__/consistent-test-it.test.ts +++ b/src/rules/__tests__/consistent-test-it.test.ts @@ -61,6 +61,52 @@ ruleTester.run('consistent-test-it with fn=test', rule, { }, ], }, + { + code: dedent` + import { it } from '@jest/globals'; + + it("foo") + `, + output: dedent` + import { it } from '@jest/globals'; + + test("foo") + `, + options: [{ fn: TestCaseName.test }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'consistentMethod', + data: { + testKeyword: TestCaseName.test, + oppositeTestKeyword: TestCaseName.it, + }, + }, + ], + }, + { + code: dedent` + import { it as testThisThing } from '@jest/globals'; + + testThisThing("foo") + `, + output: dedent` + import { it as testThisThing } from '@jest/globals'; + + test("foo") + `, + options: [{ fn: TestCaseName.test }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'consistentMethod', + data: { + testKeyword: TestCaseName.test, + oppositeTestKeyword: TestCaseName.it, + }, + }, + ], + }, { code: 'xit("foo")', output: 'xtest("foo")', @@ -495,6 +541,52 @@ ruleTester.run('consistent-test-it with fn=test and withinDescribe=it ', rule, { }, ], }, + { + code: dedent` + import { xtest as dontTestThis } from '@jest/globals'; + + describe("suite", () => { dontTestThis("foo") }); + `, + output: dedent` + import { xtest as dontTestThis } from '@jest/globals'; + + describe("suite", () => { xit("foo") }); + `, + options: [{ fn: TestCaseName.test, withinDescribe: TestCaseName.it }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'consistentMethodWithinDescribe', + data: { + testKeywordWithinDescribe: TestCaseName.it, + oppositeTestKeyword: TestCaseName.test, + }, + }, + ], + }, + { + code: dedent` + import { describe as context, xtest as dontTestThis } from '@jest/globals'; + + context("suite", () => { dontTestThis("foo") }); + `, + output: dedent` + import { describe as context, xtest as dontTestThis } from '@jest/globals'; + + context("suite", () => { xit("foo") }); + `, + options: [{ fn: TestCaseName.test, withinDescribe: TestCaseName.it }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'consistentMethodWithinDescribe', + data: { + testKeywordWithinDescribe: TestCaseName.it, + oppositeTestKeyword: TestCaseName.test, + }, + }, + ], + }, { code: 'describe("suite", () => { test.skip("foo") })', output: 'describe("suite", () => { it.skip("foo") })', diff --git a/src/rules/__tests__/expect-expect.test.ts b/src/rules/__tests__/expect-expect.test.ts index 88617888f..c2f02ed84 100644 --- a/src/rules/__tests__/expect-expect.test.ts +++ b/src/rules/__tests__/expect-expect.test.ts @@ -273,3 +273,82 @@ ruleTester.run('wildcards', rule, { }, ], }); + +ruleTester.run('expect-expect (aliases)', rule, { + valid: [ + { + code: dedent` + import { test } from '@jest/globals'; + + test('should pass', () => { + expect(true).toBeDefined(); + foo(true).toBe(true); + }); + `, + options: [{ assertFunctionNames: ['expect', 'foo'] }], + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { test as checkThat } from '@jest/globals'; + + checkThat('this passes', () => { + expect(true).toBeDefined(); + foo(true).toBe(true); + }); + `, + options: [{ assertFunctionNames: ['expect', 'foo'] }], + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + const { test } = require('@jest/globals'); + + test('verifies chained expect method call', () => { + tester + .foo() + .bar() + .expect(456); + }); + `, + options: [{ assertFunctionNames: ['tester.foo.bar.expect'] }], + parserOptions: { sourceType: 'module' }, + }, + ], + + invalid: [ + { + code: dedent` + import { test as checkThat } from '@jest/globals'; + + checkThat('this passes', () => { + // ... + }); + `, + options: [{ assertFunctionNames: ['expect', 'foo'] }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noAssertions', + type: AST_NODE_TYPES.CallExpression, + }, + ], + }, + { + code: dedent` + import { test as checkThat } from '@jest/globals'; + + checkThat.skip('this passes', () => { + // ... + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noAssertions', + type: AST_NODE_TYPES.CallExpression, + }, + ], + }, + ], +}); diff --git a/src/rules/__tests__/no-conditional-in-test.test.ts b/src/rules/__tests__/no-conditional-in-test.test.ts index 0afee2bb8..32b6aa86a 100644 --- a/src/rules/__tests__/no-conditional-in-test.test.ts +++ b/src/rules/__tests__/no-conditional-in-test.test.ts @@ -31,6 +31,11 @@ ruleTester.run('conditional expressions', rule, { foo(); }); `, + dedent` + fit.concurrent('foo', () => { + switch('bar') {} + }) + `, ], invalid: [ { @@ -341,20 +346,6 @@ ruleTester.run('switch statements', rule, { }, ], }, - { - code: dedent` - fit.concurrent('foo', () => { - switch('bar') {} - }) - `, - errors: [ - { - messageId: 'conditionalInTest', - column: 3, - line: 2, - }, - ], - }, { code: dedent` test('foo', () => { @@ -616,6 +607,11 @@ ruleTester.run('if statements', rule, { }); }); `, + dedent` + fit.concurrent('foo', () => { + if ('bar') {} + }) + `, ], invalid: [ { @@ -770,20 +766,6 @@ ruleTester.run('if statements', rule, { }, ], }, - { - code: dedent` - fit.concurrent('foo', () => { - if ('bar') {} - }) - `, - errors: [ - { - messageId: 'conditionalInTest', - column: 3, - line: 2, - }, - ], - }, { code: dedent` test('foo', () => { diff --git a/src/rules/__tests__/no-done-callback.test.ts b/src/rules/__tests__/no-done-callback.test.ts index fe825e139..7d99213b4 100644 --- a/src/rules/__tests__/no-done-callback.test.ts +++ b/src/rules/__tests__/no-done-callback.test.ts @@ -392,6 +392,66 @@ ruleTester.run('no-done-callback', rule, { }, ], }, + { + code: dedent` + import { beforeEach } from '@jest/globals'; + + beforeEach((done) => { + done(); + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noDoneCallback', + line: 3, + column: 13, + suggestions: [ + { + messageId: 'suggestWrappingInPromise', + data: { callback: 'done' }, + output: dedent` + import { beforeEach } from '@jest/globals'; + + beforeEach(() => {return new Promise((done) => { + done(); + })}); + `, + }, + ], + }, + ], + }, + { + code: dedent` + import { beforeEach as atTheStartOfEachTest } from '@jest/globals'; + + atTheStartOfEachTest((done) => { + done(); + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noDoneCallback', + line: 3, + column: 23, + suggestions: [ + { + messageId: 'suggestWrappingInPromise', + data: { callback: 'done' }, + output: dedent` + import { beforeEach as atTheStartOfEachTest } from '@jest/globals'; + + atTheStartOfEachTest(() => {return new Promise((done) => { + done(); + })}); + `, + }, + ], + }, + ], + }, { code: 'test.each``("something", ({ a, b }, done) => { done(); })', errors: [ diff --git a/src/rules/__tests__/no-duplicate-hooks.test.ts b/src/rules/__tests__/no-duplicate-hooks.test.ts index 595f56c98..12e067392 100644 --- a/src/rules/__tests__/no-duplicate-hooks.test.ts +++ b/src/rules/__tests__/no-duplicate-hooks.test.ts @@ -99,6 +99,50 @@ ruleTester.run('basic describe block', rule, { }, ], }, + { + code: dedent` + import { afterEach } from '@jest/globals'; + + describe.skip("foo", () => { + afterEach(() => {}), + afterEach(() => {}), + test("bar", () => { + someFn(); + }) + }) + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterEach' }, + column: 3, + line: 5, + }, + ], + }, + { + code: dedent` + import { afterEach, afterEach as somethingElse } from '@jest/globals'; + + describe.skip("foo", () => { + afterEach(() => {}), + somethingElse(() => {}), + test("bar", () => { + someFn(); + }) + }) + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'noDuplicateHook', + data: { hook: 'afterEach' }, + column: 3, + line: 5, + }, + ], + }, { code: dedent` describe.skip("foo", () => { diff --git a/src/rules/__tests__/no-focused-tests.test.ts b/src/rules/__tests__/no-focused-tests.test.ts index 0179c24c2..cad882f46 100644 --- a/src/rules/__tests__/no-focused-tests.test.ts +++ b/src/rules/__tests__/no-focused-tests.test.ts @@ -325,9 +325,9 @@ ruleTester.run('no-focused-tests (with imports)', rule, { valid: [ { code: dedent` - import { fdescribe as describeJustThis } from '@jest/globals'; + import { describe as fdescribe } from '@jest/globals'; - describeJustThis() + fdescribe() `, parserOptions: { sourceType: 'module' }, }, @@ -409,3 +409,81 @@ ruleTester.run('no-focused-tests (with imports)', rule, { }, ], }); + +ruleTester.run('no-focused-tests (aliases)', rule, { + valid: [], + + invalid: [ + { + code: dedent` + import { describe as describeThis } from '@jest/globals'; + + describeThis.only() + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'focusedTest', + column: 14, + line: 3, + suggestions: [ + { + messageId: 'suggestRemoveFocus', + output: dedent` + import { describe as describeThis } from '@jest/globals'; + + describeThis() + `, + }, + ], + }, + ], + }, + { + code: dedent` + import { fdescribe as describeJustThis } from '@jest/globals'; + + describeJustThis() + describeJustThis.each()() + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'focusedTest', + column: 1, + line: 3, + }, + { + messageId: 'focusedTest', + column: 1, + line: 4, + }, + ], + }, + { + code: dedent` + import { describe as context } from '@jest/globals'; + + context.only.each()() + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'focusedTest', + column: 9, + line: 3, + suggestions: [ + { + messageId: 'suggestRemoveFocus', + output: dedent` + import { describe as context } from '@jest/globals'; + + context.each()() + `, + }, + ], + }, + ], + }, + ], +}); diff --git a/src/rules/__tests__/no-hooks.test.ts b/src/rules/__tests__/no-hooks.test.ts index 0478a4705..ffb3e1c8f 100644 --- a/src/rules/__tests__/no-hooks.test.ts +++ b/src/rules/__tests__/no-hooks.test.ts @@ -1,4 +1,5 @@ import { TSESLint } from '@typescript-eslint/utils'; +import dedent from 'dedent'; import rule from '../no-hooks'; import { HookName } from '../utils'; import { espreeParser } from './test-utils'; @@ -59,5 +60,21 @@ ruleTester.run('no-hooks', rule, { }, ], }, + { + code: dedent` + import { beforeEach as afterEach, afterEach as beforeEach } from '@jest/globals'; + + afterEach(() => {}); + beforeEach(() => { jest.resetModules() }); + `, + options: [{ allow: [HookName.afterEach] }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'unexpectedHook', + data: { hookName: HookName.beforeEach }, + }, + ], + }, ], }); diff --git a/src/rules/__tests__/no-test-prefixes.test.ts b/src/rules/__tests__/no-test-prefixes.test.ts index 5a671e352..e2b0cd19d 100644 --- a/src/rules/__tests__/no-test-prefixes.test.ts +++ b/src/rules/__tests__/no-test-prefixes.test.ts @@ -1,4 +1,5 @@ import { TSESLint } from '@typescript-eslint/utils'; +import dedent from 'dedent'; import rule from '../no-test-prefixes'; const ruleTester = new TSESLint.RuleTester(); @@ -70,18 +71,6 @@ ruleTester.run('no-test-prefixes', rule, { }, ], }, - { - code: 'fit.concurrent("foo", function () {})', - output: 'it.concurrent.only("foo", function () {})', - errors: [ - { - messageId: 'usePreferredName', - data: { preferredNodeName: 'it.concurrent.only' }, - column: 1, - line: 1, - }, - ], - }, { code: 'xdescribe("foo", function () {})', output: 'describe.skip("foo", function () {})', @@ -168,5 +157,68 @@ ruleTester.run('no-test-prefixes', rule, { }, ], }, + { + code: dedent` + import { xit } from '@jest/globals'; + + xit("foo", function () {}) + `, + output: dedent` + import { xit } from '@jest/globals'; + + it.skip("foo", function () {}) + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2015 }, + errors: [ + { + messageId: 'usePreferredName', + data: { preferredNodeName: 'it.skip' }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { xit as skipThis } from '@jest/globals'; + + skipThis("foo", function () {}) + `, + output: dedent` + import { xit as skipThis } from '@jest/globals'; + + it.skip("foo", function () {}) + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2015 }, + errors: [ + { + messageId: 'usePreferredName', + data: { preferredNodeName: 'it.skip' }, + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { fit as onlyThis } from '@jest/globals'; + + onlyThis("foo", function () {}) + `, + output: dedent` + import { fit as onlyThis } from '@jest/globals'; + + it.only("foo", function () {}) + `, + parserOptions: { sourceType: 'module', ecmaVersion: 2015 }, + errors: [ + { + messageId: 'usePreferredName', + data: { preferredNodeName: 'it.only' }, + column: 1, + line: 3, + }, + ], + }, ], }); diff --git a/src/rules/__tests__/prefer-lowercase-title.test.ts b/src/rules/__tests__/prefer-lowercase-title.test.ts index 0bac6a50d..f745885d3 100644 --- a/src/rules/__tests__/prefer-lowercase-title.test.ts +++ b/src/rules/__tests__/prefer-lowercase-title.test.ts @@ -56,6 +56,15 @@ ruleTester.run('prefer-lowercase-title', rule, { describe.each()(1); describe.each()(2); `, + 'jest.doMock("my-module")', + { + code: dedent` + import { jest } from '@jest/globals'; + + jest.doMock('my-module'); + `, + parserOptions: { sourceType: 'module' }, + }, 'describe(42)', { code: 'describe(42)', @@ -196,6 +205,27 @@ ruleTester.run('prefer-lowercase-title', rule, { }, ], }, + { + code: dedent` + import { describe as context } from '@jest/globals'; + + context(\`Foo\`, () => {}); + `, + output: dedent` + import { describe as context } from '@jest/globals'; + + context(\`foo\`, () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'unexpectedLowercase', + data: { method: DescribeAlias.describe }, + column: 9, + line: 3, + }, + ], + }, { code: 'describe(`Some longer description`, function () {})', output: 'describe(`some longer description`, function () {})', @@ -534,6 +564,46 @@ ruleTester.run('prefer-lowercase-title with ignoreTopLevelDescribe', rule, { }, ], }, + { + code: dedent` + import { describe, describe as context } from '@jest/globals'; + + describe('MyClass', () => { + context('MyMethod', () => { + it('Does things', () => { + // + }); + }); + }); + `, + output: dedent` + import { describe, describe as context } from '@jest/globals'; + + describe('MyClass', () => { + context('myMethod', () => { + it('does things', () => { + // + }); + }); + }); + `, + options: [{ ignoreTopLevelDescribe: true }], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'unexpectedLowercase', + data: { method: DescribeAlias.describe }, + column: 11, + line: 4, + }, + { + messageId: 'unexpectedLowercase', + data: { method: TestCaseName.it }, + column: 8, + line: 5, + }, + ], + }, { code: dedent` describe('MyClass', () => { diff --git a/src/rules/__tests__/require-top-level-describe.test.ts b/src/rules/__tests__/require-top-level-describe.test.ts index 52d8e4d97..be01704f7 100644 --- a/src/rules/__tests__/require-top-level-describe.test.ts +++ b/src/rules/__tests__/require-top-level-describe.test.ts @@ -61,6 +61,15 @@ ruleTester.run('require-top-level-describe', rule, { }); }); `, + { + code: dedent` + import { jest } from '@jest/globals'; + + jest.doMock('my-module'); + `, + parserOptions: { sourceType: 'module' }, + }, + 'jest.doMock("my-module")', ], invalid: [ { @@ -90,6 +99,16 @@ ruleTester.run('require-top-level-describe', rule, { `, errors: [{ messageId: 'unexpectedHook' }], }, + { + code: dedent` + import { describe, afterAll as onceEverythingIsDone } from '@jest/globals'; + + describe("test suite", () => {}); + onceEverythingIsDone("my test", () => {}) + `, + parserOptions: { sourceType: 'module' }, + errors: [{ messageId: 'unexpectedHook' }], + }, { code: "it.skip('test', () => {});", errors: [{ messageId: 'unexpectedTestCase' }], @@ -166,6 +185,33 @@ ruleTester.run( options: [{ maxNumberOfTopLevelDescribes: 2 }], errors: [{ messageId: 'tooManyDescribes', line: 10 }], }, + { + code: dedent` + import { + describe as describe1, + describe as describe2, + describe as describe3, + } from '@jest/globals'; + + describe1('one', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + }); + describe2('two', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + describe3('three', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + `, + options: [{ maxNumberOfTopLevelDescribes: 2 }], + parserOptions: { sourceType: 'module' }, + errors: [{ messageId: 'tooManyDescribes', line: 16 }], + }, { code: dedent` describe('one', () => {}); diff --git a/src/rules/__tests__/utils.test.ts b/src/rules/__tests__/utils.test.ts deleted file mode 100644 index e99977c59..000000000 --- a/src/rules/__tests__/utils.test.ts +++ /dev/null @@ -1,1049 +0,0 @@ -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: { - ecmaVersion: 2015, - }, -}); - -const rule = createRule({ - name: __filename, - meta: { - docs: { - category: 'Possible Errors', - description: 'Fake rule for testing AST guards', - recommended: false, - }, - messages: { - details: [ - 'callType', // - 'numOfArgs', - 'nodeName', - ] - .map(data => `${data}: {{ ${data} }}`) - .join('\n'), - }, - schema: [], - type: 'problem', - }, - defaultOptions: [], - create: context => ({ - CallExpression(node) { - const scope = context.getScope(); - const callType = - (isDescribeCall(node, scope) && ('describe' as const)) || - (isTestCaseCall(node, scope) && ('test' as const)) || - (isHookCall(node, scope) && ('hook' as const)); - - if (callType) { - context.report({ - messageId: 'details', - node, - data: { - callType, - numOfArgs: node.arguments.length, - nodeName: getNodeName(node), - }, - }); - } - }, - }), -}); - -/** - * Determines what the expected "node name" should be for the given code by normalizing - * the line of code to be using dot property accessors and then applying regexp. - * - * @param {string} code - * - * @return {string} - */ -const expectedNodeName = (code: string): string => { - const normalizedCode = code - .replace(/\[["']/gu, '.') // - .replace(/["']\]/gu, ''); - - const [expectedName] = /^[\w.]+/u.exec(normalizedCode) ?? ['NAME NOT FOUND']; - - return expectedName; -}; - -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``()', - ], - invalid: [], -}); - -/** - * Tests the AST utils against the given member expressions both - * as is and as call expressions. - * - * @param {string[]} memberExpressions - * @param {"describe" | "test"} callType - * @param {boolean} skip - */ -const testUtilsAgainst = ( - memberExpressions: string[], - callType: 'describe' | 'test', - skip = false, -) => { - if (skip) { - return; - } - - ruleTester.run('it', rule, { - valid: memberExpressions, - invalid: memberExpressions.map(code => ({ - code: `${code}("works", () => {})`, - errors: [ - { - messageId: 'details' as const, - data: { - callType, - numOfArgs: 2, - nodeName: expectedNodeName(code), - }, - column: 1, - line: 1, - }, - ], - })), - }); -}; - -testUtilsAgainst( - [ - 'it["concurrent"]["skip"]', - 'it["concurrent"].skip', - 'it.concurrent["skip"]', - 'it.concurrent.skip', - - 'it["concurrent"]["only"]', - 'it["concurrent"].only', - 'it.concurrent["only"]', - 'it.concurrent.only', - - 'it["skip"]["each"]()', - 'it["skip"].each()', - 'it.skip["each"]()', - 'it.skip.each()', - - 'it["skip"]["each"]``', - 'it["skip"].each``', - 'it.skip["each"]``', - 'it.skip.each``', - - 'it["only"]["each"]()', - 'it["only"].each()', - 'it.only["each"]()', - 'it.only.each()', - - 'it["only"]["each"]``', - 'it["only"].each``', - 'it.only["each"]``', - 'it.only.each``', - - 'xit["each"]()', - 'xit.each()', - - 'xit["each"]``', - 'xit.each``', - - 'fit["each"]()', - 'fit.each()', - - 'fit["each"]``', - 'fit.each``', - - 'it["skip"]', - 'it.skip', - - 'it["only"]', - 'it.only', - - 'it["each"]()', - 'it.each()', - - 'it["each"]``', - 'it.each``', - - 'fit', - 'xit', - 'it', - ], - 'test', -); - -testUtilsAgainst( - [ - 'test["concurrent"]["skip"]', - 'test["concurrent"].skip', - 'test.concurrent["skip"]', - 'test.concurrent.skip', - - 'test["concurrent"]["only"]', - 'test["concurrent"].only', - 'test.concurrent["only"]', - 'test.concurrent.only', - - 'test["skip"]["each"]()', - 'test["skip"].each()', - 'test.skip["each"]()', - 'test.skip.each()', - - 'test["skip"]["each"]``', - 'test["skip"].each``', - 'test.skip["each"]``', - 'test.skip.each``', - - 'test["only"]["each"]()', - 'test["only"].each()', - 'test.only["each"]()', - 'test.only.each()', - - 'test["only"]["each"]``', - 'test["only"].each``', - 'test.only["each"]``', - 'test.only.each``', - - 'xtest["each"]()', - 'xtest.each()', - - 'xtest["each"]``', - 'xtest.each``', - - 'test["skip"]', - 'test.skip', - - 'test["only"]', - 'test.only', - - 'test["each"]()', - 'test.each()', - - 'test["each"]``', - 'test.each``', - - 'xtest', - 'test', - ], - 'test', -); - -testUtilsAgainst( - [ - 'describe["skip"]["each"]()', - 'describe["skip"].each()', - 'describe.skip["each"]()', - 'describe.skip.each()', - - 'describe["skip"]["each"]``', - 'describe["skip"].each``', - 'describe.skip["each"]``', - 'describe.skip.each``', - - 'describe["only"]["each"]()', - 'describe["only"].each()', - 'describe.only["each"]()', - 'describe.only.each()', - - 'describe["only"]["each"]``', - 'describe["only"].each``', - 'describe.only["each"]``', - 'describe.only.each``', - - 'xdescribe["each"]()', - 'xdescribe.each()', - - 'xdescribe["each"]``', - 'xdescribe.each``', - - 'fdescribe["each"]()', - 'fdescribe.each()', - - 'fdescribe["each"]``', - 'fdescribe.each``', - - 'describe["skip"]', - 'describe.skip', - - 'describe["only"]', - 'describe.only', - - 'describe["each"]()', - 'describe.each()', - - 'describe["each"]``', - 'describe.each``', - - 'fdescribe', - 'xdescribe', - 'describe', - ], - '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' }, - }, - { - 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: { - callType: 'test', - numOfArgs: 2, - nodeName: 'it', - }, - column: 1, - line: 4, - }, - ], - }, - ], - }); -}); diff --git a/src/rules/__tests__/valid-expect-in-promise.test.ts b/src/rules/__tests__/valid-expect-in-promise.test.ts index 83fc1b239..a070f23ff 100644 --- a/src/rules/__tests__/valid-expect-in-promise.test.ts +++ b/src/rules/__tests__/valid-expect-in-promise.test.ts @@ -1590,5 +1590,28 @@ ruleTester.run('valid-expect-in-promise', rule, { }, ], }, + { + code: dedent` + import { it as promiseThatThis } from '@jest/globals'; + + promiseThatThis('is valid', async () => { + const promise = loadNumber().then(number => { + expect(typeof number).toBe('number'); + + return number + 1; + }); + + expect(anotherPromise).resolves.toBe(1); + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'expectInFloatingPromise', + line: 4, + column: 9, + }, + ], + }, ], }); diff --git a/src/rules/__tests__/valid-title.test.ts b/src/rules/__tests__/valid-title.test.ts index 7bd97b2c7..e97ba561a 100644 --- a/src/rules/__tests__/valid-title.test.ts +++ b/src/rules/__tests__/valid-title.test.ts @@ -188,6 +188,50 @@ ruleTester.run('mustMatch & mustNotMatch options', rule, { }, ], }, + { + code: dedent` + import { describe, describe as context, it as thisTest } from '@jest/globals'; + + describe('things to test', () => { + context('unit tests #unit', () => { + thisTest('is true', () => { + expect(true).toBe(true); + }); + }); + + context('e2e tests #e4e', () => { + thisTest('is another test #e2e #jest4life', () => {}); + }); + }); + `, + options: [ + { + mustNotMatch: /(?:#(?!unit|e2e))\w+/u.source, + mustMatch: /^[^#]+$|(?:#(?:unit|e2e))/u.source, + }, + ], + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'describe', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 11, + line: 10, + }, + { + messageId: 'mustNotMatch', + data: { + jestFunctionName: 'it', + pattern: /(?:#(?!unit|e2e))\w+/u, + }, + column: 14, + line: 11, + }, + ], + }, { code: dedent` describe('things to test', () => { @@ -873,9 +917,9 @@ ruleTester.run('no-accidental-space', rule, { errors: [{ messageId: 'accidentalSpace', column: 5, line: 1 }], }, { - code: 'fit.concurrent(" foo", function () {})', - output: 'fit.concurrent("foo", function () {})', - errors: [{ messageId: 'accidentalSpace', column: 16, line: 1 }], + code: 'it.concurrent.skip(" foo", function () {})', + output: 'it.concurrent.skip("foo", function () {})', + errors: [{ messageId: 'accidentalSpace', column: 20, line: 1 }], }, { code: 'fit("foo ", function () {})', @@ -883,9 +927,23 @@ ruleTester.run('no-accidental-space', rule, { errors: [{ messageId: 'accidentalSpace', column: 5, line: 1 }], }, { - code: 'fit.concurrent("foo ", function () {})', - output: 'fit.concurrent("foo", function () {})', - errors: [{ messageId: 'accidentalSpace', column: 16, line: 1 }], + code: 'it.concurrent.skip("foo ", function () {})', + output: 'it.concurrent.skip("foo", function () {})', + errors: [{ messageId: 'accidentalSpace', column: 20, line: 1 }], + }, + { + code: dedent` + import { test as testThat } from '@jest/globals'; + + testThat('foo works ', () => {}); + `, + output: dedent` + import { test as testThat } from '@jest/globals'; + + testThat('foo works', () => {}); + `, + parserOptions: { sourceType: 'module' }, + errors: [{ messageId: 'accidentalSpace', column: 10, line: 3 }], }, { code: 'xit(" foo", function () {})', diff --git a/src/rules/consistent-test-it.ts b/src/rules/consistent-test-it.ts index 9136cfba2..91975354a 100644 --- a/src/rules/consistent-test-it.ts +++ b/src/rules/consistent-test-it.ts @@ -2,9 +2,8 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { TestCaseName, createRule, - getNodeName, - isDescribeCall, - isTestCaseCall, + isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; const buildFixer = @@ -74,14 +73,16 @@ export default createRule< return { CallExpression(node: TSESTree.CallExpression) { const scope = context.getScope(); - const nodeName = getNodeName(node.callee); + const jestFnCall = parseJestFnCall(node, scope); - if (!nodeName) { + if (!jestFnCall) { return; } - if (isDescribeCall(node, scope)) { + if (jestFnCall.type === 'describe') { describeNestingLevel++; + + return; } const funcNode = @@ -92,9 +93,9 @@ export default createRule< : node.callee; if ( - isTestCaseCall(node, scope) && + jestFnCall.type === 'test' && describeNestingLevel === 0 && - !nodeName.includes(testKeyword) + !jestFnCall.name.endsWith(testKeyword) ) { const oppositeTestKeyword = getOppositeTestKeyword(testKeyword); @@ -102,14 +103,14 @@ export default createRule< messageId: 'consistentMethod', node: node.callee, data: { testKeyword, oppositeTestKeyword }, - fix: buildFixer(funcNode, nodeName, testKeyword), + fix: buildFixer(funcNode, jestFnCall.name, testKeyword), }); } if ( - isTestCaseCall(node, scope) && + jestFnCall.type === 'test' && describeNestingLevel > 0 && - !nodeName.includes(testKeywordWithinDescribe) + !jestFnCall.name.endsWith(testKeywordWithinDescribe) ) { const oppositeTestKeyword = getOppositeTestKeyword( testKeywordWithinDescribe, @@ -119,12 +120,16 @@ export default createRule< messageId: 'consistentMethodWithinDescribe', node: node.callee, data: { testKeywordWithinDescribe, oppositeTestKeyword }, - fix: buildFixer(funcNode, nodeName, testKeywordWithinDescribe), + fix: buildFixer( + funcNode, + jestFnCall.name, + testKeywordWithinDescribe, + ), }); } }, 'CallExpression:exit'(node) { - if (isDescribeCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['describe'])) { describeNestingLevel--; } }, diff --git a/src/rules/expect-expect.ts b/src/rules/expect-expect.ts index 8a420a669..c7c61ccff 100644 --- a/src/rules/expect-expect.ts +++ b/src/rules/expect-expect.ts @@ -9,7 +9,7 @@ import { getNodeName, getTestCallExpressionsFromDeclaredVariables, isSupportedAccessor, - isTestCaseCall, + isTypeOfJestFnCall, } from './utils'; /** @@ -114,7 +114,7 @@ export default createRule< const name = getNodeName(node.callee) ?? ''; if ( - isTestCaseCall(node, context.getScope()) || + isTypeOfJestFnCall(node, context.getScope(), ['test']) || additionalTestBlockFunctions.includes(name) ) { if ( diff --git a/src/rules/max-nested-describe.ts b/src/rules/max-nested-describe.ts index 851811e51..dfa1f4c82 100644 --- a/src/rules/max-nested-describe.ts +++ b/src/rules/max-nested-describe.ts @@ -1,5 +1,5 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { createRule, isDescribeCall } from './utils'; +import { createRule, isTypeOfJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -38,7 +38,7 @@ export default createRule({ if ( parent?.type !== AST_NODE_TYPES.CallExpression || - !isDescribeCall(parent, context.getScope()) + !isTypeOfJestFnCall(parent, context.getScope(), ['describe']) ) { return; } @@ -61,7 +61,7 @@ export default createRule({ if ( parent?.type === AST_NODE_TYPES.CallExpression && - isDescribeCall(parent, context.getScope()) + isTypeOfJestFnCall(parent, context.getScope(), ['describe']) ) { describeCallbackStack.pop(); } diff --git a/src/rules/no-conditional-expect.ts b/src/rules/no-conditional-expect.ts index 484c7a441..96a7a424f 100644 --- a/src/rules/no-conditional-expect.ts +++ b/src/rules/no-conditional-expect.ts @@ -5,7 +5,7 @@ import { getTestCallExpressionsFromDeclaredVariables, isExpectCall, isSupportedAccessor, - isTestCaseCall, + isTypeOfJestFnCall, } from './utils'; const isCatchCall = ( @@ -50,7 +50,7 @@ export default createRule({ } }, CallExpression(node: TSESTree.CallExpression) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCase = true; } @@ -73,7 +73,7 @@ export default createRule({ } }, 'CallExpression:exit'(node) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCase = false; } diff --git a/src/rules/no-conditional-in-test.ts b/src/rules/no-conditional-in-test.ts index 09392dca3..354bb9410 100644 --- a/src/rules/no-conditional-in-test.ts +++ b/src/rules/no-conditional-in-test.ts @@ -1,5 +1,5 @@ import { TSESTree } from '@typescript-eslint/utils'; -import { createRule, isTestCaseCall } from './utils'; +import { createRule, isTypeOfJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -30,12 +30,12 @@ export default createRule({ return { CallExpression(node: TSESTree.CallExpression) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCase = true; } }, 'CallExpression:exit'(node) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCase = false; } }, diff --git a/src/rules/no-done-callback.ts b/src/rules/no-done-callback.ts index 3eeaa6a9e..c2fda6063 100644 --- a/src/rules/no-done-callback.ts +++ b/src/rules/no-done-callback.ts @@ -1,11 +1,5 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; -import { - createRule, - getNodeName, - isFunction, - isHookCall, - isTestCaseCall, -} from './utils'; +import { createRule, getNodeName, isFunction, parseJestFnCall } from './utils'; const findCallbackArg = ( node: TSESTree.CallExpression, @@ -16,11 +10,13 @@ const findCallbackArg = ( return node.arguments[1]; } - if (isHookCall(node, scope) && node.arguments.length >= 1) { + const jestFnCall = parseJestFnCall(node, scope); + + if (jestFnCall?.type === 'hook' && node.arguments.length >= 1) { return node.arguments[0]; } - if (isTestCaseCall(node, scope) && node.arguments.length >= 2) { + if (jestFnCall?.type === 'test' && node.arguments.length >= 2) { return node.arguments[1]; } diff --git a/src/rules/no-duplicate-hooks.ts b/src/rules/no-duplicate-hooks.ts index 4c51dc5d1..497cb6b88 100644 --- a/src/rules/no-duplicate-hooks.ts +++ b/src/rules/no-duplicate-hooks.ts @@ -1,11 +1,4 @@ -import { createRule, isDescribeCall, isHookCall } from './utils'; - -const newHookContext = () => ({ - beforeAll: 0, - beforeEach: 0, - afterAll: 0, - afterEach: 0, -}); +import { createRule, isTypeOfJestFnCall, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -23,31 +16,36 @@ export default createRule({ }, defaultOptions: [], create(context) { - const hookContexts = [newHookContext()]; + const hookContexts: Array> = [{}]; return { CallExpression(node) { const scope = context.getScope(); - if (isDescribeCall(node, scope)) { - hookContexts.push(newHookContext()); + const jestFnCall = parseJestFnCall(node, scope); + + if (jestFnCall?.type === 'describe') { + hookContexts.push({}); + } + + if (jestFnCall?.type !== 'hook') { + return; } - if (isHookCall(node, scope)) { - const currentLayer = hookContexts[hookContexts.length - 1]; + const currentLayer = hookContexts[hookContexts.length - 1]; - currentLayer[node.callee.name] += 1; - if (currentLayer[node.callee.name] > 1) { - context.report({ - messageId: 'noDuplicateHook', - data: { hook: node.callee.name }, - node, - }); - } + currentLayer[jestFnCall.name] ||= 0; + currentLayer[jestFnCall.name] += 1; + if (currentLayer[jestFnCall.name] > 1) { + context.report({ + messageId: 'noDuplicateHook', + data: { hook: jestFnCall.name }, + node, + }); } }, 'CallExpression:exit'(node) { - if (isDescribeCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['describe'])) { hookContexts.pop(); } }, diff --git a/src/rules/no-export.ts b/src/rules/no-export.ts index 7cdb5a8c5..d35fd9af0 100644 --- a/src/rules/no-export.ts +++ b/src/rules/no-export.ts @@ -1,5 +1,5 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { createRule, isTestCaseCall } from './utils'; +import { createRule, isTypeOfJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -34,7 +34,7 @@ export default createRule({ }, CallExpression(node) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { hasTestCase = true; } }, diff --git a/src/rules/no-focused-tests.ts b/src/rules/no-focused-tests.ts index 2b854f677..2010cf3f7 100644 --- a/src/rules/no-focused-tests.ts +++ b/src/rules/no-focused-tests.ts @@ -1,38 +1,5 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { - AccessorNode, - JestFunctionCallExpression, - createRule, - getNodeName, - isDescribeCall, - isSupportedAccessor, - isTestCaseCall, -} from './utils'; - -const findOnlyNode = ( - node: JestFunctionCallExpression, -): AccessorNode<'only'> | null => { - const callee = - node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression - ? node.callee.tag - : node.callee.type === AST_NODE_TYPES.CallExpression - ? node.callee.callee - : node.callee; - - if (callee.type === AST_NODE_TYPES.MemberExpression) { - if (callee.object.type === AST_NODE_TYPES.MemberExpression) { - if (isSupportedAccessor(callee.object.property, 'only')) { - return callee.object.property; - } - } - - if (isSupportedAccessor(callee.property, 'only')) { - return callee.property; - } - } - - return null; -}; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -57,19 +24,30 @@ export default createRule({ CallExpression(node) { const scope = context.getScope(); - if (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) { + const jestFnCall = parseJestFnCall(node, scope); + + if (jestFnCall?.type !== 'test' && jestFnCall?.type !== 'describe') { return; } - if (getNodeName(node).startsWith('f')) { + if (jestFnCall.name.startsWith('f')) { context.report({ messageId: 'focusedTest', node, suggest: [ { messageId: 'suggestRemoveFocus', - fix: fixer => - fixer.removeRange([node.range[0], node.range[0] + 1]), + fix(fixer) { + // don't apply the fixer if we're an aliased import + if ( + jestFnCall.head.type === 'import' && + jestFnCall.name !== jestFnCall.head.local + ) { + return null; + } + + return fixer.removeRange([node.range[0], node.range[0] + 1]); + }, }, ], }); @@ -77,7 +55,9 @@ export default createRule({ return; } - const onlyNode = findOnlyNode(node); + const onlyNode = jestFnCall.members.find( + s => getAccessorValue(s) === 'only', + ); if (!onlyNode) { return; diff --git a/src/rules/no-hooks.ts b/src/rules/no-hooks.ts index ffac41562..778d3095f 100644 --- a/src/rules/no-hooks.ts +++ b/src/rules/no-hooks.ts @@ -1,4 +1,4 @@ -import { HookName, createRule, isHookCall } from './utils'; +import { HookName, createRule, parseJestFnCall } from './utils'; export default createRule< [Partial<{ allow: readonly HookName[] }>], @@ -32,14 +32,16 @@ export default createRule< create(context, [{ allow = [] }]) { return { CallExpression(node) { + const jestFnCall = parseJestFnCall(node, context.getScope()); + if ( - isHookCall(node, context.getScope()) && - !allow.includes(node.callee.name) + jestFnCall?.type === 'hook' && + !allow.includes(jestFnCall.name as HookName) ) { context.report({ node, messageId: 'unexpectedHook', - data: { hookName: node.callee.name }, + data: { hookName: jestFnCall.name }, }); } }, diff --git a/src/rules/no-identical-title.ts b/src/rules/no-identical-title.ts index 47b1ce138..c013c93fa 100644 --- a/src/rules/no-identical-title.ts +++ b/src/rules/no-identical-title.ts @@ -1,10 +1,10 @@ import { createRule, - getNodeName, getStringValue, - isDescribeCall, isStringNode, - isTestCaseCall, + isSupportedAccessor, + isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; interface DescribeContext { @@ -43,11 +43,17 @@ export default createRule({ const scope = context.getScope(); const currentLayer = contexts[contexts.length - 1]; - if (isDescribeCall(node, scope)) { + const jestFnCall = parseJestFnCall(node, scope); + + if (!jestFnCall) { + return; + } + + if (jestFnCall.type === 'describe') { contexts.push(newDescribeContext()); } - if (getNodeName(node.callee)?.endsWith('.each')) { + if (jestFnCall.members.find(s => isSupportedAccessor(s, 'each'))) { return; } @@ -59,7 +65,7 @@ export default createRule({ const title = getStringValue(argument); - if (isTestCaseCall(node, scope)) { + if (jestFnCall.type === 'test') { if (currentLayer.testTitles.includes(title)) { context.report({ messageId: 'multipleTestTitle', @@ -69,7 +75,7 @@ export default createRule({ currentLayer.testTitles.push(title); } - if (!isDescribeCall(node, scope)) { + if (jestFnCall.type !== 'describe') { return; } if (currentLayer.describeTitles.includes(title)) { @@ -81,7 +87,7 @@ export default createRule({ currentLayer.describeTitles.push(title); }, 'CallExpression:exit'(node) { - if (isDescribeCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['describe'])) { contexts.pop(); } }, diff --git a/src/rules/no-if.ts b/src/rules/no-if.ts index 9413e9280..8700d75fd 100644 --- a/src/rules/no-if.ts +++ b/src/rules/no-if.ts @@ -2,9 +2,10 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { TestCaseName, createRule, + getAccessorValue, getNodeName, getTestCallExpressionsFromDeclaredVariables, - isTestCaseCall, + parseJestFnCall, } from './utils'; const testCaseNames = new Set([ @@ -74,10 +75,12 @@ export default createRule({ return { CallExpression(node) { - if (isTestCaseCall(node, context.getScope())) { + const jestFnCall = parseJestFnCall(node, context.getScope()); + + if (jestFnCall?.type === 'test') { stack.push(true); - if (getNodeName(node).endsWith('each')) { + if (jestFnCall.members.some(s => getAccessorValue(s) === 'each')) { stack.push(true); } } diff --git a/src/rules/no-standalone-expect.ts b/src/rules/no-standalone-expect.ts index 9ff4bca90..26fc50710 100644 --- a/src/rules/no-standalone-expect.ts +++ b/src/rules/no-standalone-expect.ts @@ -3,10 +3,9 @@ import { DescribeAlias, createRule, getNodeName, - isDescribeCall, isExpectCall, isFunction, - isTestCaseCall, + isTypeOfJestFnCall, } from './utils'; const getBlockType = ( @@ -38,7 +37,7 @@ 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, scope) + isTypeOfJestFnCall(expr, scope, ['describe']) ) { return 'describe'; } @@ -86,7 +85,7 @@ export default createRule< additionalTestBlockFunctions.includes(getNodeName(node) || ''); const isTestBlock = (node: TSESTree.CallExpression): boolean => - isTestCaseCall(node, context.getScope()) || + isTypeOfJestFnCall(node, context.getScope(), ['test']) || isCustomTestBlockFunction(node); return { diff --git a/src/rules/no-test-prefixes.ts b/src/rules/no-test-prefixes.ts index 97f4e0e79..23f8941b9 100644 --- a/src/rules/no-test-prefixes.ts +++ b/src/rules/no-test-prefixes.ts @@ -1,10 +1,5 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { - createRule, - getNodeName, - isDescribeCall, - isTestCaseCall, -} from './utils'; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -26,17 +21,21 @@ export default createRule({ return { CallExpression(node) { const scope = context.getScope(); - const nodeName = getNodeName(node.callee); + const jestFnCall = parseJestFnCall(node, scope); - if ( - !nodeName || - (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) - ) + if (jestFnCall?.type !== 'describe' && jestFnCall?.type !== 'test') { return; + } - const preferredNodeName = getPreferredNodeName(nodeName); + if (jestFnCall.name[0] !== 'f' && jestFnCall.name[0] !== 'x') { + return; + } - if (!preferredNodeName) return; + const preferredNodeName = [ + jestFnCall.name.slice(1), + jestFnCall.name[0] === 'f' ? 'only' : 'skip', + ...jestFnCall.members.map(s => getAccessorValue(s)), + ].join('.'); const funcNode = node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression @@ -57,19 +56,3 @@ export default createRule({ }; }, }); - -function getPreferredNodeName(nodeName: string) { - const firstChar = nodeName.charAt(0); - - const suffix = nodeName.endsWith('.each') ? '.each' : ''; - - if (firstChar === 'f') { - return `${nodeName.slice(1).replace('.each', '')}.only${suffix}`; - } - - if (firstChar === 'x') { - return `${nodeName.slice(1).replace('.each', '')}.skip${suffix}`; - } - - return null; -} diff --git a/src/rules/no-test-return-statement.ts b/src/rules/no-test-return-statement.ts index b9a4a5c04..ce1d6b290 100644 --- a/src/rules/no-test-return-statement.ts +++ b/src/rules/no-test-return-statement.ts @@ -3,7 +3,7 @@ import { createRule, getTestCallExpressionsFromDeclaredVariables, isFunction, - isTestCaseCall, + isTypeOfJestFnCall, } from './utils'; const getBody = (args: TSESTree.CallExpressionArgument[]) => { @@ -38,7 +38,10 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isTestCaseCall(node, context.getScope())) return; + if (!isTypeOfJestFnCall(node, context.getScope(), ['test'])) { + return; + } + const body = getBody(node.arguments); const returnStmt = body.find( t => t.type === AST_NODE_TYPES.ReturnStatement, diff --git a/src/rules/prefer-expect-assertions.ts b/src/rules/prefer-expect-assertions.ts index cde133370..64cb49c42 100644 --- a/src/rules/prefer-expect-assertions.ts +++ b/src/rules/prefer-expect-assertions.ts @@ -7,7 +7,7 @@ import { isExpectCall, isFunction, isSupportedAccessor, - isTestCaseCall, + isTypeOfJestFnCall, } from './utils'; const isExpectAssertionsOrHasAssertionsCall = ( @@ -157,7 +157,7 @@ export default createRule<[RuleOptions], MessageIds>({ ForOfStatement: enterForLoop, 'ForOfStatement:exit': exitForLoop, CallExpression(node) { - if (isTestCaseCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCaseCall = true; return; @@ -174,7 +174,7 @@ export default createRule<[RuleOptions], MessageIds>({ } }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (!isTestCaseCall(node, context.getScope())) { + if (!isTypeOfJestFnCall(node, context.getScope(), ['test'])) { return; } diff --git a/src/rules/prefer-hooks-in-order.ts b/src/rules/prefer-hooks-in-order.ts index 06508af1e..5a449f0ac 100644 --- a/src/rules/prefer-hooks-in-order.ts +++ b/src/rules/prefer-hooks-in-order.ts @@ -1,11 +1,6 @@ -import { createRule, isHookCall } from './utils'; +import { createRule, isTypeOfJestFnCall, parseJestFnCall } from './utils'; -const HooksOrder = [ - 'beforeAll', - 'beforeEach', - 'afterEach', - 'afterAll', -] as const; +const HooksOrder = ['beforeAll', 'beforeEach', 'afterEach', 'afterAll']; export default createRule({ name: __filename, @@ -33,7 +28,9 @@ export default createRule({ return; } - if (!isHookCall(node, context.getScope())) { + const jestFnCall = parseJestFnCall(node, context.getScope()); + + if (jestFnCall?.type !== 'hook') { // Reset the previousHookIndex when encountering something different from a hook previousHookIndex = -1; @@ -41,7 +38,7 @@ export default createRule({ } inHook = true; - const currentHook = node.callee.name; + const currentHook = jestFnCall.name; const currentHookIndex = HooksOrder.indexOf(currentHook); if (currentHookIndex < previousHookIndex) { @@ -60,7 +57,7 @@ export default createRule({ previousHookIndex = currentHookIndex; }, 'CallExpression:exit'(node) { - if (isHookCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['hook'])) { inHook = false; return; diff --git a/src/rules/prefer-hooks-on-top.ts b/src/rules/prefer-hooks-on-top.ts index 2484766c2..9b024747e 100644 --- a/src/rules/prefer-hooks-on-top.ts +++ b/src/rules/prefer-hooks-on-top.ts @@ -1,4 +1,4 @@ -import { createRule, isHookCall, isTestCaseCall } from './utils'; +import { createRule, isTypeOfJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -22,10 +22,13 @@ export default createRule({ CallExpression(node) { const scope = context.getScope(); - if (!isHookCall(node, scope) && isTestCaseCall(node, scope)) { + if (isTypeOfJestFnCall(node, scope, ['test'])) { hooksContext[hooksContext.length - 1] = true; } - if (hooksContext[hooksContext.length - 1] && isHookCall(node, scope)) { + if ( + hooksContext[hooksContext.length - 1] && + isTypeOfJestFnCall(node, scope, ['hook']) + ) { context.report({ messageId: 'noHookOnTop', node, diff --git a/src/rules/prefer-lowercase-title.ts b/src/rules/prefer-lowercase-title.ts index 20c082ffd..d2deba93e 100644 --- a/src/rules/prefer-lowercase-title.ts +++ b/src/rules/prefer-lowercase-title.ts @@ -5,11 +5,10 @@ import { StringNode, TestCaseName, createRule, - getNodeName, getStringValue, - isDescribeCall, isStringNode, - isTestCaseCall, + isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; type IgnorableFunctionExpressions = @@ -22,21 +21,6 @@ const hasStringAsFirstArgument = ( ): node is CallExpressionWithSingleArgument => node.arguments[0] && isStringNode(node.arguments[0]); -const findNodeNameAndArgument = ( - node: TSESTree.CallExpression, - scope: TSESLint.Scope.Scope, -): [name: string, firstArg: StringNode] | null => { - if (!(isTestCaseCall(node, scope) || isDescribeCall(node, scope))) { - return null; - } - - if (!hasStringAsFirstArgument(node)) { - return null; - } - - return [getNodeName(node).split('.')[0], node.arguments[0]]; -}; - const populateIgnores = (ignore: readonly string[]): string[] => { const ignores: string[] = []; @@ -122,21 +106,23 @@ export default createRule< CallExpression(node: TSESTree.CallExpression) { const scope = context.getScope(); - if (isDescribeCall(node, scope)) { + const jestFnCall = parseJestFnCall(node, scope); + + if (!jestFnCall || !hasStringAsFirstArgument(node)) { + return; + } + + if (jestFnCall.type === 'describe') { numberOfDescribeBlocks++; if (ignoreTopLevelDescribe && numberOfDescribeBlocks === 1) { return; } - } - - const results = findNodeNameAndArgument(node, scope); - - if (!results) { + } else if (jestFnCall.type !== 'test') { return; } - const [name, firstArg] = results; + const [firstArg] = node.arguments; const description = getStringValue(firstArg); @@ -149,7 +135,7 @@ export default createRule< if ( !firstCharacter || firstCharacter === firstCharacter.toLowerCase() || - ignores.includes(name as IgnorableFunctionExpressions) + ignores.includes(jestFnCall.name as IgnorableFunctionExpressions) ) { return; } @@ -157,7 +143,7 @@ export default createRule< context.report({ messageId: 'unexpectedLowercase', node: node.arguments[0], - data: { method: name }, + data: { method: jestFnCall.name }, fix(fixer) { const description = getStringValue(firstArg); @@ -176,7 +162,7 @@ export default createRule< }); }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (isDescribeCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['describe'])) { numberOfDescribeBlocks--; } }, diff --git a/src/rules/prefer-snapshot-hint.ts b/src/rules/prefer-snapshot-hint.ts index 9cd1e5482..e418fac2f 100644 --- a/src/rules/prefer-snapshot-hint.ts +++ b/src/rules/prefer-snapshot-hint.ts @@ -1,10 +1,9 @@ import { ParsedExpectMatcher, createRule, - isDescribeCall, isExpectCall, isStringNode, - isTestCaseCall, + isTypeOfJestFnCall, parseExpectCall, } from './utils'; @@ -109,7 +108,7 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ 'CallExpression:exit'(node) { const scope = context.getScope(); - if (isDescribeCall(node, scope) || isTestCaseCall(node, scope)) { + if (isTypeOfJestFnCall(node, scope, ['describe', 'test'])) { /* istanbul ignore next */ expressionDepth = depths.pop() ?? 0; } @@ -117,7 +116,7 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ CallExpression(node) { const scope = context.getScope(); - if (isDescribeCall(node, scope) || isTestCaseCall(node, scope)) { + if (isTypeOfJestFnCall(node, scope, ['describe', 'test'])) { depths.push(expressionDepth); expressionDepth = 0; } diff --git a/src/rules/prefer-todo.ts b/src/rules/prefer-todo.ts index 337a42253..31e81248b 100644 --- a/src/rules/prefer-todo.ts +++ b/src/rules/prefer-todo.ts @@ -1,13 +1,12 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { - JestFunctionCallExpression, - TestCaseName, + ParsedJestFnCall, createRule, - getNodeName, + getAccessorValue, hasOnlyOneArgument, isFunction, isStringNode, - isTestCaseCall, + parseJestFnCall, } from './utils'; function isEmptyFunction(node: TSESTree.CallExpressionArgument) { @@ -21,22 +20,37 @@ function isEmptyFunction(node: TSESTree.CallExpressionArgument) { } function createTodoFixer( - node: JestFunctionCallExpression, + jestFnCall: ParsedJestFnCall, fixer: TSESLint.RuleFixer, ) { - const testName = getNodeName(node).split('.').shift(); + const fixes = [ + fixer.replaceText(jestFnCall.head.node, `${jestFnCall.head.local}.todo`), + ]; - return fixer.replaceText(node.callee, `${testName}.todo`); + if (jestFnCall.members.length) { + fixes.unshift( + fixer.removeRange([ + jestFnCall.head.node.range[1], + jestFnCall.members[0].range[1], + ]), + ); + } + + return fixes; } -const isTargetedTestCase = ( - node: TSESTree.CallExpression, - scope: TSESLint.Scope.Scope, -): node is JestFunctionCallExpression => - isTestCaseCall(node, scope) && - [TestCaseName.it, TestCaseName.test, 'it.skip', 'test.skip'].includes( - getNodeName(node), - ); +const isTargetedTestCase = (jestFnCall: ParsedJestFnCall): boolean => { + if (jestFnCall.members.some(s => getAccessorValue(s) !== 'skip')) { + return false; + } + + // todo: we should support this too (needs custom fixer) + if (jestFnCall.name.startsWith('x')) { + return false; + } + + return !jestFnCall.name.startsWith('f'); +}; export default createRule({ name: __filename, @@ -60,9 +74,12 @@ export default createRule({ CallExpression(node) { const [title, callback] = node.arguments; + const jestFnCall = parseJestFnCall(node, context.getScope()); + if ( !title || - !isTargetedTestCase(node, context.getScope()) || + jestFnCall?.type !== 'test' || + !isTargetedTestCase(jestFnCall) || !isStringNode(title) ) { return; @@ -74,7 +91,7 @@ export default createRule({ node, fix: fixer => [ fixer.removeRange([title.range[1], callback.range[1]]), - createTodoFixer(node, fixer), + ...createTodoFixer(jestFnCall, fixer), ], }); } @@ -83,7 +100,7 @@ export default createRule({ context.report({ messageId: 'unimplementedTest', node, - fix: fixer => [createTodoFixer(node, fixer)], + fix: fixer => createTodoFixer(jestFnCall, fixer), }); } }, diff --git a/src/rules/require-hook.ts b/src/rules/require-hook.ts index d977a2f6e..79e43fec4 100644 --- a/src/rules/require-hook.ts +++ b/src/rules/require-hook.ts @@ -2,22 +2,17 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { createRule, getNodeName, - isDescribeCall, isFunction, - isHookCall, isIdentifier, - isTestCaseCall, + isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; const isJestFnCall = ( node: TSESTree.CallExpression, scope: TSESLint.Scope.Scope, ): boolean => { - if ( - isDescribeCall(node, scope) || - isTestCaseCall(node, scope) || - isHookCall(node, scope) - ) { + if (parseJestFnCall(node, scope)) { return true; } @@ -114,7 +109,7 @@ export default createRule< }, CallExpression(node) { if ( - !isDescribeCall(node, context.getScope()) || + !isTypeOfJestFnCall(node, context.getScope(), ['describe']) || node.arguments.length < 2 ) { return; diff --git a/src/rules/require-top-level-describe.ts b/src/rules/require-top-level-describe.ts index fb05af0ed..1d57bc1a8 100644 --- a/src/rules/require-top-level-describe.ts +++ b/src/rules/require-top-level-describe.ts @@ -1,10 +1,5 @@ import { TSESTree } from '@typescript-eslint/utils'; -import { - createRule, - isDescribeCall, - isHookCall, - isTestCaseCall, -} from './utils'; +import { createRule, isTypeOfJestFnCall, parseJestFnCall } from './utils'; const messages = { tooManyDescribes: @@ -51,7 +46,13 @@ export default createRule< CallExpression(node) { const scope = context.getScope(); - if (isDescribeCall(node, scope)) { + const jestFnCall = parseJestFnCall(node, scope); + + if (!jestFnCall) { + return; + } + + if (jestFnCall.type === 'describe') { numberOfDescribeBlocks++; if (numberOfDescribeBlocks === 1) { @@ -72,13 +73,13 @@ export default createRule< } if (numberOfDescribeBlocks === 0) { - if (isTestCaseCall(node, scope)) { + if (jestFnCall.type === 'test') { context.report({ node, messageId: 'unexpectedTestCase' }); return; } - if (isHookCall(node, scope)) { + if (jestFnCall.type === 'hook') { context.report({ node, messageId: 'unexpectedHook' }); return; @@ -86,7 +87,7 @@ export default createRule< } }, 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (isDescribeCall(node, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['describe'])) { numberOfDescribeBlocks--; } }, diff --git a/src/rules/utils.ts b/src/rules/utils.ts index e2ca51217..30e4d7ba7 100644 --- a/src/rules/utils.ts +++ b/src/rules/utils.ts @@ -6,6 +6,7 @@ import { TSESTree, } from '@typescript-eslint/utils'; import { version } from '../../package.json'; +import { isTypeOfJestFnCall } from './utils/parseJestFnCall'; const REPO_URL = 'https://github.com/jest-community/eslint-plugin-jest'; @@ -649,21 +650,6 @@ export const isFunction = (node: TSESTree.Node): node is FunctionExpression => node.type === AST_NODE_TYPES.FunctionExpression || node.type === AST_NODE_TYPES.ArrowFunctionExpression; -export const isHookCall = ( - node: TSESTree.CallExpression, - 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, @@ -679,280 +665,11 @@ export const getTestCallExpressionsFromDeclaredVariables = ( (node): node is JestFunctionCallExpression => !!node && node.type === AST_NODE_TYPES.CallExpression && - isTestCaseCall(node, scope), + isTypeOfJestFnCall(node, scope, ['test']), ), ), [], ); }; -/** - * 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. - */ -export const isTestCaseCall = ( - node: TSESTree.CallExpression, - scope: TSESLint.Scope.Scope, -): node is JestFunctionCallExpression => { - 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 = - node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression - ? node.callee.tag - : node.callee.type === AST_NODE_TYPES.CallExpression - ? node.callee.callee - : node.callee; - - if ( - callee.type === AST_NODE_TYPES.MemberExpression && - isSupportedAccessor(callee.property) && - properties.includes(getAccessorValue(callee.property)) - ) { - // 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 null; - } - - const nod = - callee.object.type === AST_NODE_TYPES.MemberExpression - ? callee.object.object - : callee.object; - - if (isSupportedAccessor(nod)) { - return getAccessorValue(nod); - } - } - - return null; -}; - -/** - * 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. - */ -export const isDescribeCall = ( - node: TSESTree.CallExpression, - scope: TSESLint.Scope.Scope, -): node is JestFunctionCallExpression => { - let name = findFirstCallPropertyName(node, Object.keys(DescribeProperty)); - - if (!name) { - return false; - } - - 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 ( - 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 }; -}; - -export const scopeHasLocalReference = ( - scope: TSESLint.Scope.Scope, - referenceName: string, -) => { - const references = collectReferences(scope); - - 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; -}; +export * from './utils/parseJestFnCall'; diff --git a/src/rules/utils/__tests__/parseJestFnCall.test.ts b/src/rules/utils/__tests__/parseJestFnCall.test.ts new file mode 100644 index 000000000..49b6cc825 --- /dev/null +++ b/src/rules/utils/__tests__/parseJestFnCall.test.ts @@ -0,0 +1,418 @@ +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.getScope()); + + if (jestFnCall) { + context.report({ + messageId: 'details', + node, + data: { + data: JSON.stringify(jestFnCall, (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 expectedParsedJestFnCallResultData = (result: TestParsedJestFnCall) => ({ + data: JSON.stringify(result), +}); + +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``()', + ], + invalid: [], +}); + +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('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, + }, + ], + }, + ], +}); diff --git a/src/rules/utils/parseJestFnCall.ts b/src/rules/utils/parseJestFnCall.ts new file mode 100644 index 000000000..ce41fda1d --- /dev/null +++ b/src/rules/utils/parseJestFnCall.ts @@ -0,0 +1,403 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { + AccessorNode, + DescribeAlias, + HookName, + TestCaseName, + getAccessorValue, + getStringValue, + isIdentifier, + isStringNode, + isSupportedAccessor, +} from '../utils'; + +export const isTypeOfJestFnCall = ( + node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, + types: JestFnType[], +): boolean => { + const jestFnCall = parseJestFnCall(node, scope); + + return jestFnCall !== null && types.includes(jestFnCall.type); +}; + +export function getNodeChain(node: TSESTree.Node): AccessorNode[] { + if (isSupportedAccessor(node)) { + return [node]; + } + + switch (node.type) { + case AST_NODE_TYPES.TaggedTemplateExpression: + return getNodeChain(node.tag); + case AST_NODE_TYPES.MemberExpression: + return [...getNodeChain(node.object), ...getNodeChain(node.property)]; + case AST_NODE_TYPES.NewExpression: + case AST_NODE_TYPES.CallExpression: + return getNodeChain(node.callee); + } + + return []; +} + +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'; +}; + +export interface ParsedJestFnCall { + /** + * 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: AccessorNode[]; +} + +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.skip', + 'it.skip.each', + 'it.todo', + 'fit', + 'fit.each', + 'xit', + 'xit.each', + 'test', + 'test.concurrent', + 'test.concurrent.each', + 'test.concurrent.only.each', + 'test.concurrent.skip.each', + 'test.each', + 'test.only', + 'test.only.each', + 'test.skip', + 'test.skip.each', + 'test.todo', + 'xtest', + 'xtest.each', + + // todo: check if actually valid (not in docs) + 'test.concurrent.skip', + 'test.concurrent.only', + 'it.concurrent.skip', + 'it.concurrent.only', +]; + +export const parseJestFnCall = ( + node: TSESTree.CallExpression, + scope: TSESLint.Scope.Scope, +): ParsedJestFnCall | 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; + } + + const chain = getNodeChain(node); + + if (chain.length === 0) { + return null; + } + + // ensure that the only call expression in the chain is at the end + if ( + chain + .slice(0, chain.length - 1) + .some(nod => nod.parent?.type === AST_NODE_TYPES.CallExpression) + ) { + return null; + } + + const [first, ...rest] = chain; + + const lastNode = chain[chain.length - 1]; + + // if we're an `each()`, ensure we're the outer CallExpression (i.e `.each()()`) + if (isSupportedAccessor(lastNode, 'each')) { + if ( + node.callee.type !== AST_NODE_TYPES.CallExpression && + node.callee.type !== AST_NODE_TYPES.TaggedTemplateExpression + ) { + return null; + } + } + + const resolved = resolveToJestFn(scope, 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' && !ValidJestFnCallChains.includes(links.join('.'))) { + return null; + } + + return { + name, + type: determineJestFnType(name), + head: { ...resolved, node: first }, + members: rest, + }; +}; + +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 = ( + scope: TSESLint.Scope.Scope, + identifier: string, +): ResolvedJestFn | null => { + 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 { + 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: null, + local: identifier, + type: 'global', + }; +}; + +export const scopeHasLocalReference = ( + scope: TSESLint.Scope.Scope, + referenceName: string, +) => { + const references = collectReferences(scope); + + 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) + ); +}; diff --git a/src/rules/valid-describe-callback.ts b/src/rules/valid-describe-callback.ts index 907c12c65..88c4fb70c 100644 --- a/src/rules/valid-describe-callback.ts +++ b/src/rules/valid-describe-callback.ts @@ -1,5 +1,10 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { createRule, getNodeName, isDescribeCall, isFunction } from './utils'; +import { + createRule, + getAccessorValue, + isFunction, + parseJestFnCall, +} from './utils'; const paramsLocation = ( params: TSESTree.CallExpressionArgument[] | TSESTree.Parameter[], @@ -36,7 +41,9 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isDescribeCall(node, context.getScope())) { + const jestFnCall = parseJestFnCall(node, context.getScope()); + + if (jestFnCall?.type !== 'describe') { return; } @@ -74,7 +81,10 @@ export default createRule({ }); } - if (!getNodeName(node).endsWith('each') && callback.params.length) { + if ( + jestFnCall.members.every(s => getAccessorValue(s) !== 'each') && + callback.params.length + ) { context.report({ messageId: 'unexpectedDescribeArgument', loc: paramsLocation(callback.params), diff --git a/src/rules/valid-expect-in-promise.ts b/src/rules/valid-expect-in-promise.ts index aa1630b96..a87400cf7 100644 --- a/src/rules/valid-expect-in-promise.ts +++ b/src/rules/valid-expect-in-promise.ts @@ -9,8 +9,9 @@ import { isFunction, isIdentifier, isSupportedAccessor, - isTestCaseCall, + isTypeOfJestFnCall, parseExpectCall, + parseJestFnCall, } from './utils'; type PromiseChainCallExpression = KnownCallExpression< @@ -71,11 +72,15 @@ const isTestCaseCallWithCallbackArg = ( node: TSESTree.CallExpression, scope: TSESLint.Scope.Scope, ): boolean => { - if (!isTestCaseCall(node, scope)) { + const jestCallFn = parseJestFnCall(node, scope); + + if (jestCallFn?.type !== 'test') { return false; } - const isJestEach = getNodeName(node).endsWith('.each'); + const isJestEach = jestCallFn.members.some( + s => getAccessorValue(s) === 'each', + ); if ( isJestEach && @@ -88,19 +93,15 @@ const isTestCaseCallWithCallbackArg = ( return true; } - if (isJestEach || node.arguments.length >= 2) { - const [, callback] = node.arguments; - - const callbackArgIndex = Number(isJestEach); + const [, callback] = node.arguments; - return ( - callback && - isFunction(callback) && - callback.params.length === 1 + callbackArgIndex - ); - } + const callbackArgIndex = Number(isJestEach); - return false; + return ( + callback && + isFunction(callback) && + callback.params.length === 1 + callbackArgIndex + ); }; const isPromiseMethodThatUsesValue = ( @@ -331,9 +332,9 @@ const isDirectlyWithinTestCaseCall = ( if (isFunction(parent)) { parent = parent.parent; - return !!( + return ( parent?.type === AST_NODE_TYPES.CallExpression && - isTestCaseCall(parent, scope) + isTypeOfJestFnCall(parent, scope, ['test']) ); } @@ -414,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, context.getScope())) { + if (isTypeOfJestFnCall(node, context.getScope(), ['test'])) { inTestCaseWithDoneCallback = false; } diff --git a/src/rules/valid-title.ts b/src/rules/valid-title.ts index 66dd09fd9..88845aad3 100644 --- a/src/rules/valid-title.ts +++ b/src/rules/valid-title.ts @@ -4,11 +4,9 @@ import { StringNode, TestCaseName, createRule, - getNodeName, getStringValue, - isDescribeCall, isStringNode, - isTestCaseCall, + parseJestFnCall, } from './utils'; const trimFXprefix = (word: string) => @@ -183,7 +181,9 @@ export default createRule<[Options], MessageIds>({ CallExpression(node: TSESTree.CallExpression) { const scope = context.getScope(); - if (!isDescribeCall(node, scope) && !isTestCaseCall(node, scope)) { + const jestFnCall = parseJestFnCall(node, scope); + + if (jestFnCall?.type !== 'describe' && jestFnCall?.type !== 'test') { return; } @@ -203,7 +203,7 @@ export default createRule<[Options], MessageIds>({ if ( argument.type !== AST_NODE_TYPES.TemplateLiteral && - !(ignoreTypeOfDescribeName && isDescribeCall(node, scope)) + !(ignoreTypeOfDescribeName && jestFnCall.type === 'describe') ) { context.report({ messageId: 'titleMustBeString', @@ -220,9 +220,10 @@ export default createRule<[Options], MessageIds>({ context.report({ messageId: 'emptyTitle', data: { - jestFunctionName: isDescribeCall(node, scope) - ? DescribeAlias.describe - : TestCaseName.test, + jestFunctionName: + jestFnCall.type === 'describe' + ? DescribeAlias.describe + : TestCaseName.test, }, node, }); @@ -259,10 +260,10 @@ export default createRule<[Options], MessageIds>({ }); } - const nodeName = trimFXprefix(getNodeName(node)); + const unprefixedName = trimFXprefix(jestFnCall.name); const [firstWord] = title.split(' '); - if (firstWord.toLowerCase() === nodeName) { + if (firstWord.toLowerCase() === unprefixedName) { context.report({ messageId: 'duplicatePrefix', node: argument, @@ -275,7 +276,7 @@ export default createRule<[Options], MessageIds>({ }); } - const [jestFunctionName] = nodeName.split('.'); + const jestFunctionName = unprefixedName; const [mustNotMatchPattern, mustNotMatchMessage] = mustNotMatchPatterns[jestFunctionName] ?? [];