diff --git a/src/rules/__tests__/no-restricted-matchers.test.ts b/src/rules/__tests__/no-restricted-matchers.test.ts index eb7c84124..38018f55f 100644 --- a/src/rules/__tests__/no-restricted-matchers.test.ts +++ b/src/rules/__tests__/no-restricted-matchers.test.ts @@ -70,7 +70,7 @@ ruleTester.run('no-restricted-matchers', rule, { ], }, { - code: 'expect(a).not', + code: 'expect(a).not[x]()', options: [{ not: null }], errors: [ { @@ -159,6 +159,22 @@ ruleTester.run('no-restricted-matchers', rule, { }, ], }, + { + code: 'expect(a).resolves.not.toBe(b)', + options: [{ 'not.toBe': null }], + errors: [ + { + messageId: 'restrictedChain', + data: { + message: null, + chain: 'not.toBe', + }, + endColumn: 28, + column: 20, + line: 1, + }, + ], + }, { code: 'expect(a).not.toBe(b)', options: [{ 'not.toBe': null }], diff --git a/src/rules/__tests__/no-standalone-expect.test.ts b/src/rules/__tests__/no-standalone-expect.test.ts index 9da1e3f90..16b38baab 100644 --- a/src/rules/__tests__/no-standalone-expect.test.ts +++ b/src/rules/__tests__/no-standalone-expect.test.ts @@ -20,7 +20,6 @@ ruleTester.run('no-standalone-expect', rule, { 'it("an it", () => expect(1).toBe(1))', 'const func = function(){ expect(1).toBe(1); };', 'const func = () => expect(1).toBe(1);', - 'expect.hasAssertions()', '{}', 'it.each([1, true])("trues", value => { expect(value).toBe(true); });', 'it.each([1, true])("trues", value => { expect(value).toBe(true); }); it("an it", () => { expect(1).toBe(1) });', @@ -59,27 +58,31 @@ ruleTester.run('no-standalone-expect', rule, { ], invalid: [ { - code: "(() => {})('testing', () => expect(true))", - errors: [{ endColumn: 41, column: 29, messageId: 'unexpectedExpect' }], + code: "(() => {})('testing', () => expect(true).toBe(false))", + errors: [{ endColumn: 53, column: 29, messageId: 'unexpectedExpect' }], + }, + { + code: 'expect.hasAssertions()', + errors: [{ endColumn: 23, column: 1, messageId: 'unexpectedExpect' }], }, { code: dedent` describe('scenario', () => { const t = Math.random() ? it.only : it; - t('testing', () => expect(true)); + t('testing', () => expect(true).toBe(false)); }); `, - errors: [{ endColumn: 34, column: 22, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 46, column: 22, messageId: 'unexpectedExpect' }], }, { code: dedent` describe('scenario', () => { const t = Math.random() ? it.only : it; - t('testing', () => expect(true)); + t('testing', () => expect(true).toBe(false)); }); `, options: [{ additionalTestBlockFunctions: undefined }], - errors: [{ endColumn: 34, column: 22, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 46, column: 22, messageId: 'unexpectedExpect' }], }, { code: dedent` @@ -91,7 +94,7 @@ ruleTester.run('no-standalone-expect', rule, { expect(a + b).toBe(expected); }); `, - errors: [{ endColumn: 16, column: 3, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 31, column: 3, messageId: 'unexpectedExpect' }], }, { code: dedent` @@ -104,7 +107,7 @@ ruleTester.run('no-standalone-expect', rule, { }); `, options: [{ additionalTestBlockFunctions: ['each'] }], - errors: [{ endColumn: 16, column: 3, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 31, column: 3, messageId: 'unexpectedExpect' }], }, { code: dedent` @@ -117,43 +120,48 @@ ruleTester.run('no-standalone-expect', rule, { }); `, options: [{ additionalTestBlockFunctions: ['test'] }], - errors: [{ endColumn: 16, column: 3, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 31, column: 3, messageId: 'unexpectedExpect' }], }, { code: 'describe("a test", () => { expect(1).toBe(1); });', - errors: [{ endColumn: 37, column: 28, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 45, column: 28, messageId: 'unexpectedExpect' }], }, { code: 'describe("a test", () => expect(1).toBe(1));', - errors: [{ endColumn: 35, column: 26, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 43, column: 26, messageId: 'unexpectedExpect' }], }, { code: 'describe("a test", () => { const func = () => { expect(1).toBe(1); }; expect(1).toBe(1); });', - errors: [{ endColumn: 80, column: 71, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 88, column: 71, messageId: 'unexpectedExpect' }], }, { code: 'describe("a test", () => { it(() => { expect(1).toBe(1); }); expect(1).toBe(1); });', - errors: [{ endColumn: 72, column: 63, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 80, column: 63, messageId: 'unexpectedExpect' }], }, { code: 'expect(1).toBe(1);', - errors: [{ endColumn: 10, column: 1, messageId: 'unexpectedExpect' }], - }, - { - code: 'expect(1).toBe', - errors: [{ endColumn: 10, column: 1, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 18, column: 1, messageId: 'unexpectedExpect' }], }, { code: '{expect(1).toBe(1)}', - errors: [{ endColumn: 11, column: 2, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 19, column: 2, messageId: 'unexpectedExpect' }], }, { code: 'it.each([1, true])("trues", value => { expect(value).toBe(true); }); expect(1).toBe(1);', - errors: [{ endColumn: 79, column: 70, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 87, column: 70, messageId: 'unexpectedExpect' }], }, { code: 'describe.each([1, true])("trues", value => { expect(value).toBe(true); });', - errors: [{ endColumn: 59, column: 46, messageId: 'unexpectedExpect' }], + errors: [{ endColumn: 70, column: 46, messageId: 'unexpectedExpect' }], + }, + { + code: dedent` + import { expect as pleaseExpect } from '@jest/globals'; + + describe("a test", () => { pleaseExpect(1).toBe(1); }); + `, + parserOptions: { sourceType: 'module' }, + errors: [{ endColumn: 51, column: 28, messageId: 'unexpectedExpect' }], }, ], }); diff --git a/src/rules/__tests__/prefer-comparison-matcher.test.ts b/src/rules/__tests__/prefer-comparison-matcher.test.ts index 2112abfb7..2daf914e3 100644 --- a/src/rules/__tests__/prefer-comparison-matcher.test.ts +++ b/src/rules/__tests__/prefer-comparison-matcher.test.ts @@ -263,6 +263,10 @@ testComparisonOperator('<=', 'toBeLessThanOrEqual', 'toBeGreaterThan'); ruleTester.run(`prefer-to-be-comparison`, rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', + 'expect.assertions(1)', + 'expect(true).toBe(...true)', 'expect()', 'expect({}).toStrictEqual({})', 'expect(a === b).toBe(true)', diff --git a/src/rules/__tests__/prefer-equality-matcher.test.ts b/src/rules/__tests__/prefer-equality-matcher.test.ts index 3f5ac1c85..458c96218 100644 --- a/src/rules/__tests__/prefer-equality-matcher.test.ts +++ b/src/rules/__tests__/prefer-equality-matcher.test.ts @@ -32,6 +32,10 @@ const expectSuggestions = ( ruleTester.run('prefer-equality-matcher: ===', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', + 'expect.assertions(1)', + 'expect(true).toBe(...true)', 'expect(a == 1).toBe(true)', 'expect(1 == a).toBe(true)', 'expect(a == b).toBe(true)', @@ -172,6 +176,10 @@ ruleTester.run('prefer-equality-matcher: ===', rule, { ruleTester.run('prefer-equality-matcher: !==', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', + 'expect.assertions(1)', + 'expect(true).toBe(...true)', 'expect(a != 1).toBe(true)', 'expect(1 != a).toBe(true)', 'expect(a != b).toBe(true)', diff --git a/src/rules/__tests__/prefer-expect-resolves.test.ts b/src/rules/__tests__/prefer-expect-resolves.test.ts index 674689ccd..c3f867d2a 100644 --- a/src/rules/__tests__/prefer-expect-resolves.test.ts +++ b/src/rules/__tests__/prefer-expect-resolves.test.ts @@ -12,6 +12,7 @@ const ruleTester = new TSESLint.RuleTester({ ruleTester.run('prefer-expect-resolves', rule, { valid: [ + 'expect.hasAssertions()', dedent` it('passes', async () => { await expect(someValue()).resolves.toBe(true); @@ -61,5 +62,27 @@ ruleTester.run('prefer-expect-resolves', rule, { `, errors: [{ endColumn: 25, column: 10, messageId: 'expectResolves' }], }, + { + code: dedent` + import { expect as pleaseExpect } from '@jest/globals'; + + it('is true', async () => { + const myPromise = Promise.resolve(true); + + pleaseExpect(await myPromise).toBe(true); + }); + `, + output: dedent` + import { expect as pleaseExpect } from '@jest/globals'; + + it('is true', async () => { + const myPromise = Promise.resolve(true); + + await pleaseExpect(myPromise).resolves.toBe(true); + }); + `, + parserOptions: { sourceType: 'module' }, + errors: [{ endColumn: 31, column: 16, messageId: 'expectResolves' }], + }, ], }); diff --git a/src/rules/__tests__/prefer-to-be.test.ts b/src/rules/__tests__/prefer-to-be.test.ts index df0286901..0e62a1a70 100644 --- a/src/rules/__tests__/prefer-to-be.test.ts +++ b/src/rules/__tests__/prefer-to-be.test.ts @@ -14,6 +14,7 @@ ruleTester.run('prefer-to-be', rule, { 'expect(null).toBeNull();', 'expect(null).not.toBeNull();', 'expect(null).toBe(1);', + 'expect(null).toBe(...1);', 'expect(obj).toStrictEqual([ x, 1 ]);', 'expect(obj).toStrictEqual({ x: 1 });', 'expect(obj).not.toStrictEqual({ x: 1 });', diff --git a/src/rules/__tests__/prefer-to-contain.test.ts b/src/rules/__tests__/prefer-to-contain.test.ts index ce5c22587..8fd8f0bf1 100644 --- a/src/rules/__tests__/prefer-to-contain.test.ts +++ b/src/rules/__tests__/prefer-to-contain.test.ts @@ -1,10 +1,20 @@ import { TSESLint } from '@typescript-eslint/utils'; +import dedent from 'dedent'; import rule from '../prefer-to-contain'; +import { espreeParser } from './test-utils'; -const ruleTester = new TSESLint.RuleTester(); +const ruleTester = new TSESLint.RuleTester({ + parser: espreeParser, + parserOptions: { + ecmaVersion: 2015, + }, +}); ruleTester.run('prefer-to-contain', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', + 'expect.assertions(1)', 'expect().toBe(false);', 'expect(a).toContain(b);', "expect(a.name).toBe('b');", @@ -24,6 +34,8 @@ ruleTester.run('prefer-to-contain', rule, { `expect(a.test(b)).resolves.toEqual(true)`, `expect(a.test(b)).resolves.not.toEqual(true)`, `expect(a).not.toContain(b)`, + 'expect(a.includes(...[])).toBe(true)', + 'expect(a.includes(b)).toBe(...true)', 'expect(a);', ], invalid: [ @@ -177,6 +189,20 @@ ruleTester.run('prefer-to-contain', rule, { output: 'expect([{a:1}]).toContain({a:1});', errors: [{ messageId: 'useToContain', column: 37, line: 1 }], }, + { + code: dedent` + import { expect as pleaseExpect } from '@jest/globals'; + + pleaseExpect([{a:1}].includes({a:1})).not.toStrictEqual(false); + `, + output: dedent` + import { expect as pleaseExpect } from '@jest/globals'; + + pleaseExpect([{a:1}]).toContain({a:1}); + `, + parserOptions: { sourceType: 'module' }, + errors: [{ messageId: 'useToContain', column: 43, line: 3 }], + }, ], }); diff --git a/src/rules/__tests__/prefer-to-have-length.test.ts b/src/rules/__tests__/prefer-to-have-length.test.ts index 259dc6dd5..e4a948455 100644 --- a/src/rules/__tests__/prefer-to-have-length.test.ts +++ b/src/rules/__tests__/prefer-to-have-length.test.ts @@ -11,6 +11,8 @@ const ruleTester = new TSESLint.RuleTester({ ruleTester.run('prefer-to-have-length', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', 'expect(files).toHaveLength(1);', "expect(files.name).toBe('file');", "expect(files[`name`]).toBe('file');", diff --git a/src/rules/__tests__/valid-expect.test.ts b/src/rules/__tests__/valid-expect.test.ts index 14e91f766..bb503d825 100644 --- a/src/rules/__tests__/valid-expect.test.ts +++ b/src/rules/__tests__/valid-expect.test.ts @@ -12,6 +12,8 @@ const ruleTester = new TSESLint.RuleTester({ ruleTester.run('valid-expect', rule, { valid: [ + 'expect.hasAssertions', + 'expect.hasAssertions()', 'expect("something").toEqual("else");', 'expect(true).toBeDefined();', 'expect([1, 2, 3]).toEqual([1, 2, 3]);', @@ -32,8 +34,6 @@ ruleTester.run('valid-expect', rule, { 'test("valid-expect", () => expect(Promise.reject(2)).rejects.toBeDefined());', 'test("valid-expect", () => expect(Promise.reject(2)).resolves.not.toBeDefined());', 'test("valid-expect", () => expect(Promise.reject(2)).rejects.not.toBeDefined());', - // 'test("valid-expect", () => expect(Promise.reject(2)).not.resolves.toBeDefined());', - // 'test("valid-expect", () => expect(Promise.reject(2)).not.rejects.toBeDefined());', 'test("valid-expect", async () => { await expect(Promise.reject(2)).resolves.not.toBeDefined(); });', 'test("valid-expect", async () => { await expect(Promise.reject(2)).rejects.not.toBeDefined(); });', 'test("valid-expect", async function () { await expect(Promise.reject(2)).resolves.not.toBeDefined(); });', @@ -132,28 +132,6 @@ ruleTester.run('valid-expect', rule, { }, ], invalid: [ - /* - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined(); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.rejects.toBeDefined(); });', - 'test("valid-expect", async function () { await expect(Promise.reject(2)).not.resolves.toBeDefined(); });', - 'test("valid-expect", async function () { await expect(Promise.reject(2)).not.rejects.toBeDefined(); });', - 'test("valid-expect", async () => { await Promise.resolve(expect(Promise.reject(2)).not.rejects.toBeDefined()); });', - 'test("valid-expect", async () => { await Promise.reject(expect(Promise.reject(2)).not.rejects.toBeDefined()); });', - 'test("valid-expect", async () => { await Promise.all([expect(Promise.reject(2)).not.rejects.toBeDefined(), expect(Promise.reject(2)).not.rejects.toBeDefined()]); });', - 'test("valid-expect", async () => { await Promise.race([expect(Promise.reject(2)).not.rejects.toBeDefined(), expect(Promise.reject(2)).rejects.not.toBeDefined()]); });', - 'test("valid-expect", async () => { await Promise.allSettled([expect(Promise.reject(2)).not.rejects.toBeDefined(), expect(Promise.reject(2)).rejects.not.toBeDefined()]); });', - 'test("valid-expect", async () => { await Promise.any([expect(Promise.reject(2)).not.rejects.toBeDefined(), expect(Promise.reject(2)).rejects.not.toBeDefined()]); });', - 'test("valid-expect", async () => { return expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")); });', - 'test("valid-expect", async () => { return expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")).then(() => console.log("another valid case")); });', - 'test("valid-expect", async () => { return expect(Promise.reject(2)).not.resolves.toBeDefined().catch(() => console.log("valid-case")); });', - 'test("valid-expect", async () => { return expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")).catch(() => console.log("another valid case")); });', - 'test("valid-expect", async () => { return expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => { expect(someMock).toHaveBeenCalledTimes(1); }); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")).then(() => console.log("another valid case")); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined().catch(() => console.log("valid-case")); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => console.log("valid-case")).catch(() => console.log("another valid case")); });', - 'test("valid-expect", async () => { await expect(Promise.reject(2)).not.resolves.toBeDefined().then(() => { expect(someMock).toHaveBeenCalledTimes(1); }); });', - */ { code: 'expect().toBe(2);', options: [{ minArgs: undefined, maxArgs: undefined }], @@ -300,18 +278,7 @@ ruleTester.run('valid-expect', rule, { }, { code: 'expect();', - errors: [ - { endColumn: 9, column: 1, messageId: 'matcherNotFound' }, - { - endColumn: 8, - column: 7, - messageId: 'notEnoughArgs', - data: { - s: '', - amount: 1, - }, - }, - ], + errors: [{ endColumn: 9, column: 1, messageId: 'matcherNotFound' }], }, { code: 'expect(true).toBeDefined;', @@ -337,10 +304,49 @@ ruleTester.run('valid-expect', rule, { code: 'expect(true).nope.toBeDefined;', errors: [ { - endColumn: 18, - column: 14, + endColumn: 30, + column: 19, + messageId: 'matcherNotCalled', + }, + ], + }, + { + code: 'expect(true).nope.toBeDefined();', + errors: [ + { + endColumn: 32, + column: 1, + messageId: 'modifierUnknown', + }, + ], + }, + { + code: 'expect(true).not.resolves.toBeDefined();', + errors: [ + { + endColumn: 40, + column: 1, + messageId: 'modifierUnknown', + }, + ], + }, + { + code: 'expect(true).not.not.toBeDefined();', + errors: [ + { + endColumn: 35, + column: 1, + messageId: 'modifierUnknown', + }, + ], + }, + { + code: 'expect(true).resolves.not.exactly.toBeDefined();', + errors: [ + { + endColumn: 48, + column: 1, messageId: 'modifierUnknown', - data: { modifierName: 'nope' }, }, ], }, @@ -918,17 +924,9 @@ ruleTester.run('valid-expect', rule, { }, ], }, - // Code coverage for line 29 { code: 'expect(Promise.resolve(2)).resolves.toBe;', errors: [ - { - line: 1, - column: 1, - endLine: 1, - endColumn: 42, - messageId: 'asyncMustBeAwaited', - }, { column: 37, endColumn: 41, diff --git a/src/rules/max-expects.ts b/src/rules/max-expects.ts index 0de46d24b..349f21eca 100644 --- a/src/rules/max-expects.ts +++ b/src/rules/max-expects.ts @@ -1,10 +1,5 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { - FunctionExpression, - createRule, - isExpectCall, - isTypeOfJestFnCall, -} from './utils'; +import { FunctionExpression, createRule, isTypeOfJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -50,7 +45,7 @@ export default createRule({ FunctionExpression: onFunctionExpressionEnter, ArrowFunctionExpression: onFunctionExpressionEnter, CallExpression(node) { - if (!isExpectCall(node)) { + if (!isTypeOfJestFnCall(node, context, ['expect'])) { return; } diff --git a/src/rules/no-alias-methods.ts b/src/rules/no-alias-methods.ts index 395f13dae..814b73f5c 100644 --- a/src/rules/no-alias-methods.ts +++ b/src/rules/no-alias-methods.ts @@ -1,7 +1,7 @@ import { createRule, - isExpectCall, - parseExpectCall, + getAccessorValue, + parseJestFnCall, replaceAccessorFixer, } from './utils'; @@ -39,31 +39,24 @@ export default createRule({ return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } + const jestFnCall = parseJestFnCall(node, context); - const { matcher } = parseExpectCall(node); - - if (!matcher) { + if (jestFnCall?.type !== 'expect') { return; } - const alias = matcher.name; + const { matcher } = jestFnCall; + + const alias = getAccessorValue(matcher); if (alias in methodNames) { const canonical = methodNames[alias]; context.report({ messageId: 'replaceAlias', - data: { - alias, - canonical, - }, - node: matcher.node.property, - fix: fixer => [ - replaceAccessorFixer(fixer, matcher.node.property, canonical), - ], + data: { alias, canonical }, + node: matcher, + fix: fixer => [replaceAccessorFixer(fixer, matcher, canonical)], }); } }, diff --git a/src/rules/no-conditional-expect.ts b/src/rules/no-conditional-expect.ts index 04b0a69cc..b6489df11 100644 --- a/src/rules/no-conditional-expect.ts +++ b/src/rules/no-conditional-expect.ts @@ -3,9 +3,9 @@ import { KnownCallExpression, createRule, getTestCallExpressionsFromDeclaredVariables, - isExpectCall, isSupportedAccessor, isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; const isCatchCall = ( @@ -50,7 +50,9 @@ export default createRule({ } }, CallExpression(node: TSESTree.CallExpression) { - if (isTypeOfJestFnCall(node, context, ['test'])) { + const { type: jestFnCallType } = parseJestFnCall(node, context) ?? {}; + + if (jestFnCallType === 'test') { inTestCase = true; } @@ -58,14 +60,14 @@ export default createRule({ inPromiseCatch = true; } - if (inTestCase && isExpectCall(node) && conditionalDepth > 0) { + if (inTestCase && jestFnCallType === 'expect' && conditionalDepth > 0) { context.report({ messageId: 'conditionalExpect', node, }); } - if (inPromiseCatch && isExpectCall(node)) { + if (inPromiseCatch && jestFnCallType === 'expect') { context.report({ messageId: 'conditionalExpect', node, diff --git a/src/rules/no-interpolation-in-snapshots.ts b/src/rules/no-interpolation-in-snapshots.ts index 14322f365..4b7d87daf 100644 --- a/src/rules/no-interpolation-in-snapshots.ts +++ b/src/rules/no-interpolation-in-snapshots.ts @@ -1,5 +1,5 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import { createRule, isExpectCall, parseExpectCall } from './utils'; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -19,13 +19,9 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } - - const { matcher } = parseExpectCall(node); + const jestFnCall = parseJestFnCall(node, context); - if (!matcher) { + if (jestFnCall?.type !== 'expect') { return; } @@ -33,10 +29,10 @@ export default createRule({ [ 'toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot', - ].includes(matcher.name) + ].includes(getAccessorValue(jestFnCall.matcher)) ) { // Check all since the optional 'propertyMatchers' argument might be present - matcher.arguments?.forEach(argument => { + jestFnCall.args.forEach(argument => { if ( argument.type === AST_NODE_TYPES.TemplateLiteral && argument.expressions.length > 0 diff --git a/src/rules/no-large-snapshots.ts b/src/rules/no-large-snapshots.ts index deb64ca27..d3d88131c 100644 --- a/src/rules/no-large-snapshots.ts +++ b/src/rules/no-large-snapshots.ts @@ -3,9 +3,8 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { createRule, getAccessorValue, - isExpectCall, - isExpectMember, - parseExpectCall, + isSupportedAccessor, + parseJestFnCall, } from './utils'; interface RuleOptions { @@ -40,7 +39,8 @@ const reportOnViolation = ( if ( node.type === AST_NODE_TYPES.ExpressionStatement && 'left' in node.expression && - isExpectMember(node.expression.left) + node.expression.left.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(node.expression.left.property) ) { const fileName = context.getFilename(); const allowedSnapshotsInFile = allowedSnapshots[fileName]; @@ -112,13 +112,9 @@ export default createRule<[RuleOptions], MessageId>({ return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } - - const { matcher } = parseExpectCall(node); + const jestFnCall = parseJestFnCall(node, context); - if (matcher?.node.parent.type !== AST_NODE_TYPES.CallExpression) { + if (jestFnCall?.type !== 'expect') { return; } @@ -126,10 +122,10 @@ export default createRule<[RuleOptions], MessageId>({ [ 'toMatchInlineSnapshot', 'toThrowErrorMatchingInlineSnapshot', - ].includes(matcher.name) && - matcher.arguments?.length + ].includes(getAccessorValue(jestFnCall.matcher)) && + jestFnCall.args.length ) { - reportOnViolation(context, matcher.arguments[0], { + reportOnViolation(context, jestFnCall.args[0], { ...options, maxSize: options.inlineMaxSize ?? options.maxSize, }); diff --git a/src/rules/no-restricted-matchers.ts b/src/rules/no-restricted-matchers.ts index 3998e197f..e918db706 100644 --- a/src/rules/no-restricted-matchers.ts +++ b/src/rules/no-restricted-matchers.ts @@ -1,5 +1,4 @@ -import { TSESTree } from '@typescript-eslint/utils'; -import { createRule, isExpectCall, parseExpectCall } from './utils'; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule< [Record], @@ -28,77 +27,44 @@ export default createRule< }, defaultOptions: [{}], create(context, [restrictedChains]) { - const reportIfRestricted = ( - loc: TSESTree.SourceLocation, - chain: string, - ): boolean => { - if (!(chain in restrictedChains)) { - return false; - } - - const message = restrictedChains[chain]; - - context.report({ - messageId: message ? 'restrictedChainWithMessage' : 'restrictedChain', - data: { message, chain }, - loc, - }); - - return true; - }; - return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher, modifier } = parseExpectCall(node); + const permutations = [jestFnCall.members]; - if ( - matcher && - reportIfRestricted(matcher.node.property.loc, matcher.name) - ) { - return; + if (jestFnCall.members.length > 2) { + permutations.push([jestFnCall.members[0], jestFnCall.members[1]]); + permutations.push([jestFnCall.members[1], jestFnCall.members[2]]); } - if (modifier) { - if (reportIfRestricted(modifier.node.property.loc, modifier.name)) { - return; - } - - if (modifier.negation) { - if ( - reportIfRestricted(modifier.negation.property.loc, 'not') || - reportIfRestricted( - { - start: modifier.node.property.loc.start, - end: modifier.negation.property.loc.end, - }, - `${modifier.name}.not`, - ) - ) { - return; - } - } + if (jestFnCall.members.length > 1) { + permutations.push(...jestFnCall.members.map(nod => [nod])); } - if (matcher && modifier) { - let chain: string = modifier.name; + for (const permutation of permutations) { + const chain = permutation.map(nod => getAccessorValue(nod)).join('.'); - if (modifier.negation) { - chain += '.not'; - } + if (chain in restrictedChains) { + const message = restrictedChains[chain]; - chain += `.${matcher.name}`; + context.report({ + messageId: message + ? 'restrictedChainWithMessage' + : 'restrictedChain', + data: { message, chain }, + loc: { + start: permutation[0].loc.start, + end: permutation[permutation.length - 1].loc.end, + }, + }); - reportIfRestricted( - { - start: modifier.node.property.loc.start, - end: matcher.node.property.loc.end, - }, - chain, - ); + break; + } } }, }; diff --git a/src/rules/no-standalone-expect.ts b/src/rules/no-standalone-expect.ts index 4bb852fd1..6e4b6b0d2 100644 --- a/src/rules/no-standalone-expect.ts +++ b/src/rules/no-standalone-expect.ts @@ -3,9 +3,9 @@ import { DescribeAlias, createRule, getNodeName, - isExpectCall, isFunction, isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; const getBlockType = ( @@ -84,13 +84,11 @@ export default createRule< ): boolean => additionalTestBlockFunctions.includes(getNodeName(node) || ''); - const isTestBlock = (node: TSESTree.CallExpression): boolean => - isTypeOfJestFnCall(node, context, ['test']) || - isCustomTestBlockFunction(node); - return { CallExpression(node) { - if (isExpectCall(node)) { + const { type: jestFnCallType } = parseJestFnCall(node, context) ?? {}; + + if (jestFnCallType === 'expect') { const parent = callStack[callStack.length - 1]; if (!parent || parent === DescribeAlias.describe) { @@ -100,7 +98,7 @@ export default createRule< return; } - if (isTestBlock(node)) { + if (jestFnCallType === 'test' || isCustomTestBlockFunction(node)) { callStack.push('test'); } @@ -113,7 +111,8 @@ export default createRule< if ( (top === 'test' && - isTestBlock(node) && + (isTypeOfJestFnCall(node, context, ['test']) || + isCustomTestBlockFunction(node)) && node.callee.type !== AST_NODE_TYPES.MemberExpression) || (top === 'template' && node.callee.type === AST_NODE_TYPES.TaggedTemplateExpression) diff --git a/src/rules/prefer-called-with.ts b/src/rules/prefer-called-with.ts index 723a793b2..b4b6709a2 100644 --- a/src/rules/prefer-called-with.ts +++ b/src/rules/prefer-called-with.ts @@ -1,9 +1,4 @@ -import { - ModifierName, - createRule, - isExpectCall, - parseExpectCall, -} from './utils'; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -24,25 +19,24 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { modifier, matcher } = parseExpectCall(node); - - if ( - !matcher || - modifier?.name === ModifierName.not || - modifier?.negation - ) { + if (jestFnCall.modifiers.some(nod => getAccessorValue(nod) === 'not')) { return; } - if (['toBeCalled', 'toHaveBeenCalled'].includes(matcher.name)) { + const { matcher } = jestFnCall; + const matcherName = getAccessorValue(matcher); + + if (['toBeCalled', 'toHaveBeenCalled'].includes(matcherName)) { context.report({ - data: { matcherName: matcher.name }, + data: { matcherName }, messageId: 'preferCalledWith', - node: matcher.node.property, + node: matcher, }); } }, diff --git a/src/rules/prefer-comparison-matcher.ts b/src/rules/prefer-comparison-matcher.ts index 013a7e5d2..9de4f237b 100644 --- a/src/rules/prefer-comparison-matcher.ts +++ b/src/rules/prefer-comparison-matcher.ts @@ -1,44 +1,14 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { - MaybeTypeCast, - ModifierName, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, + EqualityMatcher, createRule, - followTypeAssertionChain, - isExpectCall, - isParsedEqualityMatcherCall, + getAccessorValue, + getFirstMatcherArg, + isBooleanLiteral, isStringNode, - parseExpectCall, + parseJestFnCall, } from './utils'; -const isBooleanLiteral = ( - node: TSESTree.Node, -): node is TSESTree.BooleanLiteral => - node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; - -type ParsedBooleanEqualityMatcherCall = ParsedEqualityMatcherCall< - MaybeTypeCast ->; - -/** - * Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers, - * with a boolean literal as the sole argument. - * - * @example javascript - * toBe(true); - * toEqual(false); - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedBooleanEqualityMatcher} - */ -const isBooleanEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedBooleanEqualityMatcherCall => - isParsedEqualityMatcherCall(matcher) && - isBooleanLiteral(followTypeAssertionChain(matcher.arguments[0])); - const isString = (node: TSESTree.Node) => { return isStringNode(node) || node.type === AST_NODE_TYPES.TemplateLiteral; }; @@ -101,37 +71,43 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect' || jestFnCall.args.length === 0) { + return; + } + + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { return; } const { - expect: { - arguments: [comparison], - range: [, expectCallEnd], - }, - matcher, - modifier, - } = parseExpectCall(node); + arguments: [comparison], + range: [, expectCallEnd], + } = expect; + + const { matcher } = jestFnCall; + const matcherArg = getFirstMatcherArg(jestFnCall); if ( - !matcher || comparison?.type !== AST_NODE_TYPES.BinaryExpression || isComparingToString(comparison) || - !isBooleanEqualityMatcher(matcher) + !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || + !isBooleanLiteral(matcherArg) ) { return; } - const negation = modifier?.negation - ? { node: modifier.negation } - : modifier?.name === ModifierName.not - ? modifier - : null; + const [modifier] = jestFnCall.modifiers; + const hasNot = jestFnCall.modifiers.some( + nod => getAccessorValue(nod) === 'not', + ); const preferredMatcher = determineMatcher( comparison.operator, - followTypeAssertionChain(matcher.arguments[0]).value === !!negation, + matcherArg.value === hasNot, ); if (!preferredMatcher) { @@ -144,8 +120,8 @@ export default createRule({ // preserve the existing modifier if it's not a negation const modifierText = - modifier && modifier?.node !== negation?.node - ? `.${modifier.name}` + modifier && getAccessorValue(modifier) !== 'not' + ? `.${getAccessorValue(modifier)}` : ''; return [ @@ -156,19 +132,19 @@ export default createRule({ ), // replace the current matcher & modifier with the preferred matcher fixer.replaceTextRange( - [expectCallEnd, matcher.node.range[1]], + [expectCallEnd, matcher.parent.range[1]], `${modifierText}.${preferredMatcher}`, ), // replace the matcher argument with the right-hand side of the comparison fixer.replaceText( - matcher.arguments[0], + matcherArg, sourceCode.getText(comparison.right), ), ]; }, messageId: 'useToBeComparison', data: { preferredMatcher }, - node: matcher.node.property, + node: matcher, }); }, }; diff --git a/src/rules/prefer-equality-matcher.ts b/src/rules/prefer-equality-matcher.ts index 4f3c31c1f..71b7a0bb4 100644 --- a/src/rules/prefer-equality-matcher.ts +++ b/src/rules/prefer-equality-matcher.ts @@ -1,43 +1,14 @@ -import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, TSESLint } from '@typescript-eslint/utils'; import { - MaybeTypeCast, + EqualityMatcher, ModifierName, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, createRule, - followTypeAssertionChain, - isExpectCall, - isParsedEqualityMatcherCall, - parseExpectCall, + getAccessorValue, + getFirstMatcherArg, + isBooleanLiteral, + parseJestFnCall, } from './utils'; -const isBooleanLiteral = ( - node: TSESTree.Node, -): node is TSESTree.BooleanLiteral => - node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; - -type ParsedBooleanEqualityMatcherCall = ParsedEqualityMatcherCall< - MaybeTypeCast ->; - -/** - * Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers, - * with a boolean literal as the sole argument. - * - * @example javascript - * toBe(true); - * toEqual(false); - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedBooleanEqualityMatcher} - */ -const isBooleanEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedBooleanEqualityMatcherCall => - isParsedEqualityMatcherCall(matcher) && - isBooleanLiteral(followTypeAssertionChain(matcher.arguments[0])); - export default createRule({ name: __filename, meta: { @@ -59,43 +30,47 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect' || jestFnCall.args.length === 0) { + return; + } + + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { return; } const { - expect: { - arguments: [comparison], - range: [, expectCallEnd], - }, - matcher, - modifier, - } = parseExpectCall(node); + arguments: [comparison], + range: [, expectCallEnd], + } = expect; + + const { matcher } = jestFnCall; + const matcherArg = getFirstMatcherArg(jestFnCall); if ( - !matcher || comparison?.type !== AST_NODE_TYPES.BinaryExpression || (comparison.operator !== '===' && comparison.operator !== '!==') || - !isBooleanEqualityMatcher(matcher) + !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || + !isBooleanLiteral(matcherArg) ) { return; } - const matcherValue = followTypeAssertionChain( - matcher.arguments[0], - ).value; + const matcherValue = matcherArg.value; - const negation = modifier?.negation - ? { node: modifier.negation } - : modifier?.name === ModifierName.not - ? modifier - : null; + const [modifier] = jestFnCall.modifiers; + const hasNot = jestFnCall.modifiers.some( + nod => getAccessorValue(nod) === 'not', + ); // we need to negate the expectation if the current expected // value is itself negated by the "not" modifier const addNotModifier = (comparison.operator === '!==' ? !matcherValue : matcherValue) === - !!negation; + hasNot; const buildFixer = (equalityMatcher: string): TSESLint.ReportFixFunction => @@ -104,8 +79,8 @@ export default createRule({ // preserve the existing modifier if it's not a negation let modifierText = - modifier && modifier?.node !== negation?.node - ? `.${modifier.name}` + modifier && getAccessorValue(modifier) !== 'not' + ? `.${getAccessorValue(modifier)}` : ''; if (addNotModifier) { @@ -120,12 +95,12 @@ export default createRule({ ), // replace the current matcher & modifier with the preferred matcher fixer.replaceTextRange( - [expectCallEnd, matcher.node.range[1]], + [expectCallEnd, matcher.parent.range[1]], `${modifierText}.${equalityMatcher}`, ), // replace the matcher argument with the right-hand side of the comparison fixer.replaceText( - matcher.arguments[0], + matcherArg, sourceCode.getText(comparison.right), ), ]; @@ -140,7 +115,7 @@ export default createRule({ fix: buildFixer(equalityMatcher), }), ), - node: matcher.node.property, + node: matcher, }); }, }; diff --git a/src/rules/prefer-expect-assertions.ts b/src/rules/prefer-expect-assertions.ts index db1d60553..4252d056e 100644 --- a/src/rules/prefer-expect-assertions.ts +++ b/src/rules/prefer-expect-assertions.ts @@ -4,10 +4,10 @@ import { createRule, getAccessorValue, hasOnlyOneArgument, - isExpectCall, isFunction, isSupportedAccessor, isTypeOfJestFnCall, + parseJestFnCall, } from './utils'; const isExpectAssertionsOrHasAssertionsCall = ( @@ -157,13 +157,15 @@ export default createRule<[RuleOptions], MessageIds>({ ForOfStatement: enterForLoop, 'ForOfStatement:exit': exitForLoop, CallExpression(node) { - if (isTypeOfJestFnCall(node, context, ['test'])) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type === 'test') { inTestCaseCall = true; return; } - if (isExpectCall(node) && inTestCaseCall) { + if (jestFnCall?.type === 'expect' && inTestCaseCall) { if (inForLoop) { hasExpectInLoop = true; } diff --git a/src/rules/prefer-expect-resolves.ts b/src/rules/prefer-expect-resolves.ts index 265ba0d51..34f76adee 100644 --- a/src/rules/prefer-expect-resolves.ts +++ b/src/rules/prefer-expect-resolves.ts @@ -1,5 +1,5 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { createRule, isExpectCall } from './utils'; +import { createRule, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -20,23 +20,32 @@ export default createRule({ defaultOptions: [], create: context => ({ CallExpression(node: TSESTree.CallExpression) { - const [awaitNode] = node.arguments; + const jestFnCall = parseJestFnCall(node, context); - if ( - isExpectCall(node) && - awaitNode?.type === AST_NODE_TYPES.AwaitExpression - ) { + if (jestFnCall?.type !== 'expect') { + return; + } + + const { parent } = jestFnCall.head.node; + + if (parent?.type !== AST_NODE_TYPES.CallExpression) { + return; + } + + const [awaitNode] = parent.arguments; + + if (awaitNode?.type === AST_NODE_TYPES.AwaitExpression) { context.report({ - node: node.arguments[0], + node: awaitNode, messageId: 'expectResolves', fix(fixer) { return [ - fixer.insertTextBefore(node, 'await '), + fixer.insertTextBefore(parent, 'await '), fixer.removeRange([ awaitNode.range[0], awaitNode.argument.range[0], ]), - fixer.insertTextAfter(node, '.resolves'), + fixer.insertTextAfter(parent, '.resolves'), ]; }, }); diff --git a/src/rules/prefer-snapshot-hint.ts b/src/rules/prefer-snapshot-hint.ts index b14f5a403..5cb38deef 100644 --- a/src/rules/prefer-snapshot-hint.ts +++ b/src/rules/prefer-snapshot-hint.ts @@ -1,35 +1,33 @@ import { - ParsedExpectMatcher, + ParsedExpectFnCall, createRule, - isExpectCall, + getAccessorValue, isStringNode, + isSupportedAccessor, isTypeOfJestFnCall, - parseExpectCall, + parseJestFnCall, } from './utils'; const snapshotMatchers = ['toMatchSnapshot', 'toThrowErrorMatchingSnapshot']; +const snapshotMatcherNames = snapshotMatchers; -const isSnapshotMatcher = (matcher: ParsedExpectMatcher) => { - return snapshotMatchers.includes(matcher.name); -}; - -const isSnapshotMatcherWithoutHint = (matcher: ParsedExpectMatcher) => { - if (!matcher.arguments || matcher.arguments.length === 0) { +const isSnapshotMatcherWithoutHint = (expectFnCall: ParsedExpectFnCall) => { + if (expectFnCall.args.length === 0) { return true; } // this matcher only supports one argument which is the hint - if (matcher.name !== 'toMatchSnapshot') { - return matcher.arguments.length !== 1; + if (!isSupportedAccessor(expectFnCall.matcher, 'toMatchSnapshot')) { + return expectFnCall.args.length !== 1; } // if we're being passed two arguments, // the second one should be the hint - if (matcher.arguments.length === 2) { + if (expectFnCall.args.length === 2) { return false; } - const [arg] = matcher.arguments; + const [arg] = expectFnCall.args; // the first argument to `toMatchSnapshot` can be _either_ a snapshot hint or // an object with asymmetric matchers, so we can't just assume that the first @@ -60,7 +58,7 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ }, defaultOptions: ['multi'], create(context, [mode]) { - const snapshotMatchers: ParsedExpectMatcher[] = []; + const snapshotMatchers: ParsedExpectFnCall[] = []; const depths: number[] = []; let expressionDepth = 0; @@ -69,7 +67,7 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ if (isSnapshotMatcherWithoutHint(snapshotMatcher)) { context.report({ messageId: 'missingHint', - node: snapshotMatcher.node.property, + node: snapshotMatcher.matcher, }); } } @@ -112,22 +110,24 @@ export default createRule<[('always' | 'multi')?], keyof typeof messages>({ } }, CallExpression(node) { - if (isTypeOfJestFnCall(node, context, ['describe', 'test'])) { - depths.push(expressionDepth); - expressionDepth = 0; - } + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { + if (jestFnCall?.type === 'describe' || jestFnCall?.type === 'test') { + depths.push(expressionDepth); + expressionDepth = 0; + } - if (!isExpectCall(node)) { return; } - const { matcher } = parseExpectCall(node); + const matcherName = getAccessorValue(jestFnCall.matcher); - if (!matcher || !isSnapshotMatcher(matcher)) { + if (!snapshotMatcherNames.includes(matcherName)) { return; } - snapshotMatchers.push(matcher); + snapshotMatchers.push(jestFnCall); }, }; }, diff --git a/src/rules/prefer-strict-equal.ts b/src/rules/prefer-strict-equal.ts index ef2f792dc..bd688056c 100644 --- a/src/rules/prefer-strict-equal.ts +++ b/src/rules/prefer-strict-equal.ts @@ -1,9 +1,8 @@ import { EqualityMatcher, createRule, - isExpectCall, - isParsedEqualityMatcherCall, - parseExpectCall, + isSupportedAccessor, + parseJestFnCall, replaceAccessorFixer, } from './utils'; @@ -28,26 +27,25 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher } = parseExpectCall(node); + const { matcher } = jestFnCall; - if ( - matcher && - isParsedEqualityMatcherCall(matcher, EqualityMatcher.toEqual) - ) { + if (isSupportedAccessor(matcher, 'toEqual')) { context.report({ messageId: 'useToStrictEqual', - node: matcher.node.property, + node: matcher, suggest: [ { messageId: 'suggestReplaceWithStrictEqual', fix: fixer => [ replaceAccessorFixer( fixer, - matcher.node.property, + matcher, EqualityMatcher.toStrictEqual, ), ], diff --git a/src/rules/prefer-to-be.ts b/src/rules/prefer-to-be.ts index e25a9533f..acf26dbd0 100644 --- a/src/rules/prefer-to-be.ts +++ b/src/rules/prefer-to-be.ts @@ -1,17 +1,13 @@ import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; import { + AccessorNode, EqualityMatcher, - MaybeTypeCast, - ModifierName, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, - ParsedExpectModifier, + ParsedExpectFnCall, createRule, - followTypeAssertionChain, - isExpectCall, + getAccessorValue, + getFirstMatcherArg, isIdentifier, - isParsedEqualityMatcherCall, - parseExpectCall, + parseJestFnCall, replaceAccessorFixer, } from './utils'; @@ -22,18 +18,16 @@ const isNullLiteral = (node: TSESTree.Node): node is TSESTree.NullLiteral => * Checks if the given `ParsedEqualityMatcherCall` is a call to one of the equality matchers, * with a `null` literal as the sole argument. */ -const isNullEqualityMatcher = ( - matcher: ParsedEqualityMatcherCall, -): matcher is ParsedEqualityMatcherCall> => - isNullLiteral(getFirstArgument(matcher)); +const isNullEqualityMatcher = (expectFnCall: ParsedExpectFnCall) => + isNullLiteral(getFirstMatcherArg(expectFnCall)); const isFirstArgumentIdentifier = ( - matcher: ParsedEqualityMatcherCall, + expectFnCall: ParsedExpectFnCall, name: string, -) => isIdentifier(getFirstArgument(matcher), name); +) => isIdentifier(getFirstMatcherArg(expectFnCall), name); -const shouldUseToBe = (matcher: ParsedEqualityMatcherCall): boolean => { - const firstArg = getFirstArgument(matcher); +const shouldUseToBe = (expectFnCall: ParsedExpectFnCall): boolean => { + const firstArg = getFirstMatcherArg(expectFnCall); if (firstArg.type === AST_NODE_TYPES.Literal) { // regex literals are classed as literals, but they're actually objects @@ -44,10 +38,6 @@ const shouldUseToBe = (matcher: ParsedEqualityMatcherCall): boolean => { return firstArg.type === AST_NODE_TYPES.TemplateLiteral; }; -const getFirstArgument = (matcher: ParsedEqualityMatcherCall) => { - return followTypeAssertionChain(matcher.arguments[0]); -}; - type MessageId = | 'useToBe' | 'useToBeUndefined' @@ -60,36 +50,29 @@ type ToBeWhat = MessageId extends `useToBe${infer M}` ? M : never; const reportPreferToBe = ( context: TSESLint.RuleContext, whatToBe: ToBeWhat, - matcher: ParsedExpectMatcher, - modifier?: ParsedExpectModifier, + expectFnCall: ParsedExpectFnCall, + modifierNode?: AccessorNode, ) => { - const modifierNode = - modifier?.negation || - (modifier?.name === ModifierName.not && modifier?.node); - context.report({ messageId: `useToBe${whatToBe}`, fix(fixer) { const fixes = [ - replaceAccessorFixer(fixer, matcher.node.property, `toBe${whatToBe}`), + replaceAccessorFixer(fixer, expectFnCall.matcher, `toBe${whatToBe}`), ]; - if (matcher.arguments?.length && whatToBe !== '') { - fixes.push(fixer.remove(matcher.arguments[0])); + if (expectFnCall.args?.length && whatToBe !== '') { + fixes.push(fixer.remove(expectFnCall.args[0])); } if (modifierNode) { fixes.push( - fixer.removeRange([ - modifierNode.property.range[0] - 1, - modifierNode.property.range[1], - ]), + fixer.removeRange([modifierNode.range[0] - 1, modifierNode.range[1]]), ); } return fixes; }, - node: matcher.node.property, + node: expectFnCall.matcher, }); }; @@ -116,59 +99,60 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { - return; - } + const jestFnCall = parseJestFnCall(node, context); - const { matcher, modifier } = parseExpectCall(node); - - if (!matcher) { + if (jestFnCall?.type !== 'expect') { return; } + const matcherName = getAccessorValue(jestFnCall.matcher); + const notModifier = jestFnCall.modifiers.find( + nod => getAccessorValue(nod) === 'not', + ); + if ( - (modifier?.name === ModifierName.not || modifier?.negation) && - ['toBeUndefined', 'toBeDefined'].includes(matcher.name) + notModifier && + ['toBeUndefined', 'toBeDefined'].includes(matcherName) ) { reportPreferToBe( context, - matcher.name === 'toBeDefined' ? 'Undefined' : 'Defined', - matcher, - modifier, + matcherName === 'toBeDefined' ? 'Undefined' : 'Defined', + jestFnCall, + notModifier, ); return; } - if (!isParsedEqualityMatcherCall(matcher)) { + if ( + !EqualityMatcher.hasOwnProperty(matcherName) || + jestFnCall.args.length === 0 + ) { return; } - if (isNullEqualityMatcher(matcher)) { - reportPreferToBe(context, 'Null', matcher); + if (isNullEqualityMatcher(jestFnCall)) { + reportPreferToBe(context, 'Null', jestFnCall); return; } - if (isFirstArgumentIdentifier(matcher, 'undefined')) { - const name = - modifier?.name === ModifierName.not || modifier?.negation - ? 'Defined' - : 'Undefined'; + if (isFirstArgumentIdentifier(jestFnCall, 'undefined')) { + const name = notModifier ? 'Defined' : 'Undefined'; - reportPreferToBe(context, name, matcher, modifier); + reportPreferToBe(context, name, jestFnCall, notModifier); return; } - if (isFirstArgumentIdentifier(matcher, 'NaN')) { - reportPreferToBe(context, 'NaN', matcher); + if (isFirstArgumentIdentifier(jestFnCall, 'NaN')) { + reportPreferToBe(context, 'NaN', jestFnCall); return; } - if (shouldUseToBe(matcher) && matcher.name !== EqualityMatcher.toBe) { - reportPreferToBe(context, '', matcher); + if (shouldUseToBe(jestFnCall) && matcherName !== EqualityMatcher.toBe) { + reportPreferToBe(context, '', jestFnCall); } }, }; diff --git a/src/rules/prefer-to-contain.ts b/src/rules/prefer-to-contain.ts index e36366542..b9e51dbd0 100644 --- a/src/rules/prefer-to-contain.ts +++ b/src/rules/prefer-to-contain.ts @@ -1,47 +1,18 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; import { CallExpressionWithSingleArgument, + EqualityMatcher, KnownCallExpression, - MaybeTypeCast, ModifierName, - ParsedEqualityMatcherCall, - ParsedExpectMatcher, createRule, - followTypeAssertionChain, + getAccessorValue, + getFirstMatcherArg, hasOnlyOneArgument, - isExpectCall, - isParsedEqualityMatcherCall, + isBooleanLiteral, isSupportedAccessor, - parseExpectCall, + parseJestFnCall, } from './utils'; -const isBooleanLiteral = ( - node: TSESTree.Node, -): node is TSESTree.BooleanLiteral => - node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; - -type ParsedBooleanEqualityMatcherCall = ParsedEqualityMatcherCall< - MaybeTypeCast ->; - -/** - * Checks if the given `ParsedExpectMatcher` is a call to one of the equality matchers, - * with a boolean literal as the sole argument. - * - * @example javascript - * toBe(true); - * toEqual(false); - * - * @param {ParsedExpectMatcher} matcher - * - * @return {matcher is ParsedBooleanEqualityMatcher} - */ -const isBooleanEqualityMatcher = ( - matcher: ParsedExpectMatcher, -): matcher is ParsedBooleanEqualityMatcherCall => - isParsedEqualityMatcherCall(matcher) && - isBooleanLiteral(followTypeAssertionChain(matcher.arguments[0])); - type FixableIncludesCallExpression = KnownCallExpression<'includes'> & CallExpressionWithSingleArgument; @@ -59,7 +30,8 @@ const isFixableIncludesCallExpression = ( node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.MemberExpression && isSupportedAccessor(node.callee.property, 'includes') && - hasOnlyOneArgument(node); + hasOnlyOneArgument(node) && + node.arguments[0].type !== AST_NODE_TYPES.SpreadElement; // expect(array.includes()[not.]{toBe,toEqual}() export default createRule({ @@ -81,38 +53,47 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect' || jestFnCall.args.length === 0) { + return; + } + + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { return; } const { - expect: { - arguments: [includesCall], - range: [, expectCallEnd], - }, - matcher, - modifier, - } = parseExpectCall(node); + arguments: [includesCall], + range: [, expectCallEnd], + } = expect; + + const { matcher } = jestFnCall; + const matcherArg = getFirstMatcherArg(jestFnCall); if ( - !matcher || !includesCall || - (modifier && modifier.name !== ModifierName.not) || - !isBooleanEqualityMatcher(matcher) || + matcherArg.type === AST_NODE_TYPES.SpreadElement || + !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || + !isBooleanLiteral(matcherArg) || !isFixableIncludesCallExpression(includesCall) ) { return; } + const hasNot = jestFnCall.modifiers.some( + nod => getAccessorValue(nod) === 'not', + ); + context.report({ fix(fixer) { const sourceCode = context.getSourceCode(); // we need to negate the expectation if the current expected // value is itself negated by the "not" modifier - const addNotModifier = - followTypeAssertionChain(matcher.arguments[0]).value === - !!modifier; + const addNotModifier = matcherArg.value === hasNot; return [ // remove the "includes" call entirely @@ -122,20 +103,20 @@ export default createRule({ ]), // replace the current matcher with "toContain", adding "not" if needed fixer.replaceTextRange( - [expectCallEnd, matcher.node.range[1]], + [expectCallEnd, matcher.parent.range[1]], addNotModifier ? `.${ModifierName.not}.toContain` : '.toContain', ), // replace the matcher argument with the value from the "includes" fixer.replaceText( - matcher.arguments[0], + jestFnCall.args[0], sourceCode.getText(includesCall.arguments[0]), ), ]; }, messageId: 'useToContain', - node: matcher.node.property, + node: matcher, }); }, }; diff --git a/src/rules/prefer-to-have-length.ts b/src/rules/prefer-to-have-length.ts index 420f5c7d3..a39fa47ce 100644 --- a/src/rules/prefer-to-have-length.ts +++ b/src/rules/prefer-to-have-length.ts @@ -1,10 +1,10 @@ import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import { + EqualityMatcher, createRule, - isExpectCall, - isParsedEqualityMatcherCall, + getAccessorValue, isSupportedAccessor, - parseExpectCall, + parseJestFnCall, } from './utils'; export default createRule({ @@ -26,20 +26,23 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { - expect: { - arguments: [argument], - }, - matcher, - } = parseExpectCall(node); + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { + return; + } + + const [argument] = expect.arguments; + const { matcher } = jestFnCall; if ( - !matcher || - !isParsedEqualityMatcherCall(matcher) || + !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || argument?.type !== AST_NODE_TYPES.MemberExpression || !isSupportedAccessor(argument.property, 'length') ) { @@ -56,13 +59,13 @@ export default createRule({ ]), // replace the current matcher with "toHaveLength" fixer.replaceTextRange( - [matcher.node.object.range[1], matcher.node.range[1]], + [matcher.parent.object.range[1], matcher.parent.range[1]], '.toHaveLength', ), ]; }, messageId: 'useToHaveLength', - node: matcher.node.property, + node: matcher, }); }, }; diff --git a/src/rules/require-to-throw-message.ts b/src/rules/require-to-throw-message.ts index 0e0978002..9fdd6707c 100644 --- a/src/rules/require-to-throw-message.ts +++ b/src/rules/require-to-throw-message.ts @@ -1,9 +1,4 @@ -import { - ModifierName, - createRule, - isExpectCall, - parseExpectCall, -} from './utils'; +import { createRule, getAccessorValue, parseJestFnCall } from './utils'; export default createRule({ name: __filename, @@ -23,23 +18,25 @@ export default createRule({ create(context) { return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCall(node, context); + + if (jestFnCall?.type !== 'expect') { return; } - const { matcher, modifier } = parseExpectCall(node); + const { matcher } = jestFnCall; + const matcherName = getAccessorValue(matcher); if ( - matcher?.arguments?.length === 0 && - ['toThrow', 'toThrowError'].includes(matcher.name) && - (!modifier || - !(modifier.name === ModifierName.not || modifier.negation)) + jestFnCall.args.length === 0 && + ['toThrow', 'toThrowError'].includes(matcherName) && + !jestFnCall.modifiers.some(nod => getAccessorValue(nod) === 'not') ) { // Look for `toThrow` calls with no arguments. context.report({ messageId: 'addErrorMessage', - data: { matcherName: matcher.name }, - node: matcher.node.property, + data: { matcherName }, + node: matcher, }); } }, diff --git a/src/rules/unbound-method.ts b/src/rules/unbound-method.ts index dfbb3c868..5298ce014 100644 --- a/src/rules/unbound-method.ts +++ b/src/rules/unbound-method.ts @@ -1,5 +1,10 @@ -import { TSESLint, TSESTree } from '@typescript-eslint/utils'; -import { createRule, isExpectCall, parseExpectCall } from './utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { + createRule, + findTopMostCallExpression, + getAccessorValue, + parseJestFnCall, +} from './utils'; const toThrowMatchers = [ 'toThrow', @@ -8,20 +13,6 @@ const toThrowMatchers = [ 'toThrowErrorMatchingInlineSnapshot', ]; -const isJestExpectToThrowCall = (node: TSESTree.CallExpression) => { - if (!isExpectCall(node)) { - return false; - } - - const { matcher } = parseExpectCall(node); - - if (!matcher) { - return false; - } - - return !toThrowMatchers.includes(matcher.name); -}; - const baseRule = (() => { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -92,21 +83,22 @@ export default createRule({ return {}; } - let inExpectToThrowCall = false; - return { ...baseSelectors, - CallExpression(node: TSESTree.CallExpression): void { - inExpectToThrowCall = isJestExpectToThrowCall(node); - }, - 'CallExpression:exit'(node: TSESTree.CallExpression): void { - if (inExpectToThrowCall && isJestExpectToThrowCall(node)) { - inExpectToThrowCall = false; - } - }, MemberExpression(node: TSESTree.MemberExpression): void { - if (inExpectToThrowCall) { - return; + if (node.parent?.type === AST_NODE_TYPES.CallExpression) { + const jestFnCall = parseJestFnCall( + findTopMostCallExpression(node.parent), + context, + ); + + if (jestFnCall?.type === 'expect') { + const { matcher } = jestFnCall; + + if (!toThrowMatchers.includes(getAccessorValue(matcher))) { + return; + } + } } baseSelectors.MemberExpression?.(node); diff --git a/src/rules/utils/__tests__/parseJestFnCall.test.ts b/src/rules/utils/__tests__/parseJestFnCall.test.ts index fadf9ed87..3ecaa04de 100644 --- a/src/rules/utils/__tests__/parseJestFnCall.test.ts +++ b/src/rules/utils/__tests__/parseJestFnCall.test.ts @@ -64,11 +64,19 @@ const rule = createRule({ const jestFnCall = parseJestFnCall(node, context); if (jestFnCall) { + const sorted = { + // ...jestFnCall, + name: jestFnCall.name, + type: jestFnCall.type, + head: jestFnCall.head, + members: jestFnCall.members, + }; + context.report({ messageId: 'details', node, data: { - data: JSON.stringify(jestFnCall, (key, value) => { + data: JSON.stringify(sorted, (key, value) => { if (isNode(value)) { if (isSupportedAccessor(value)) { return getAccessorValue(value); @@ -97,8 +105,15 @@ interface TestParsedJestFnCall members: string[]; } +// const sortParsedJestFnCallResults = () + const expectedParsedJestFnCallResultData = (result: TestParsedJestFnCall) => ({ - data: JSON.stringify(result), + data: JSON.stringify({ + name: result.name, + type: result.type, + head: result.head, + members: result.members, + }), }); ruleTester.run('nonexistent methods', rule, { @@ -125,6 +140,260 @@ ruleTester.run('nonexistent methods', rule, { invalid: [], }); +ruleTester.run('expect', rule, { + valid: [ + { + code: dedent` + import { expect } from './test-utils'; + + expect(x).toBe(y); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not.resolves.toBe(x); + `, + parserOptions: { sourceType: 'module' }, + }, + // { + // code: dedent` + // import { expect } from '@jest/globals'; + // + // expect(x).not().toBe(x); + // `, + // parserOptions: { sourceType: 'module' }, + // }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).is.toBe(x); + `, + parserOptions: { sourceType: 'module' }, + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect; + expect(x); + expect(x).toBe; + expect(x).not.toBe; + //expect(x).toBe(x).not(); + `, + parserOptions: { sourceType: 'module' }, + }, + ], + invalid: [ + { + code: 'expect(x).toBe(y);', + parserOptions: { sourceType: 'script' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: null, + local: 'expect', + type: 'global', + node: 'expect', + }, + members: ['toBe'], + }), + column: 1, + line: 1, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect.assertions(); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['assertions'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).toBe(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['toBe'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect(x).not.toBe(y); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not', 'toBe'], + }), + column: 1, + line: 3, + }, + ], + }, + { + code: dedent` + import { expect } from '@jest/globals'; + + expect.assertions(); + expect.hasAssertions(); + expect.anything(); + expect.not.arrayContaining(); + `, + parserOptions: { sourceType: 'module' }, + errors: [ + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['assertions'], + }), + column: 1, + line: 3, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['hasAssertions'], + }), + column: 1, + line: 4, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['anything'], + }), + column: 1, + line: 5, + }, + { + messageId: 'details' as const, + data: expectedParsedJestFnCallResultData({ + name: 'expect', + type: 'expect', + head: { + original: 'expect', + local: 'expect', + type: 'import', + node: 'expect', + }, + members: ['not', 'arrayContaining'], + }), + column: 1, + line: 6, + }, + ], + }, + ], +}); + ruleTester.run('esm', rule, { valid: [ { diff --git a/src/rules/utils/index.ts b/src/rules/utils/index.ts index 8ef0baa06..ff192a11c 100644 --- a/src/rules/utils/index.ts +++ b/src/rules/utils/index.ts @@ -3,4 +3,3 @@ export * from './detectJestVersion'; export * from './followTypeAssertionChain'; export * from './misc'; export * from './parseJestFnCall'; -export * from './parseExpectCall'; diff --git a/src/rules/utils/misc.ts b/src/rules/utils/misc.ts index 6533d8872..109a51781 100644 --- a/src/rules/utils/misc.ts +++ b/src/rules/utils/misc.ts @@ -11,7 +11,8 @@ import { getAccessorValue, isSupportedAccessor, } from './accessors'; -import { isTypeOfJestFnCall } from './parseJestFnCall'; +import { followTypeAssertionChain } from './followTypeAssertionChain'; +import { ParsedExpectFnCall, isTypeOfJestFnCall } from './parseJestFnCall'; const REPO_URL = 'https://github.com/jest-community/eslint-plugin-jest'; @@ -53,7 +54,7 @@ interface CalledKnownMemberExpression * Represents a `CallExpression` with a single argument. */ export interface CallExpressionWithSingleArgument< - Argument extends TSESTree.Expression = TSESTree.Expression, + Argument extends TSESTree.CallExpression['arguments'][number] = TSESTree.CallExpression['arguments'][number], > extends TSESTree.CallExpression { arguments: [Argument]; } @@ -90,6 +91,18 @@ export enum HookName { 'afterEach' = 'afterEach', } +export enum ModifierName { + not = 'not', + rejects = 'rejects', + resolves = 'resolves', +} + +export enum EqualityMatcher { + toBe = 'toBe', + toEqual = 'toEqual', + toStrictEqual = 'toStrictEqual', +} + const joinNames = (a: string | null, b: string | null): string | null => a && b ? `${a}.${b}` : null; @@ -154,3 +167,45 @@ export const replaceAccessorFixer = ( node.type === AST_NODE_TYPES.Identifier ? text : `'${text}'`, ); }; + +export const findTopMostCallExpression = ( + node: TSESTree.CallExpression, +): TSESTree.CallExpression => { + let topMostCallExpression = node; + let { parent } = node; + + while (parent) { + if (parent.type === AST_NODE_TYPES.CallExpression) { + topMostCallExpression = parent; + + parent = parent.parent; + + continue; + } + + if (parent.type !== AST_NODE_TYPES.MemberExpression) { + break; + } + + parent = parent.parent; + } + + return topMostCallExpression; +}; + +export const isBooleanLiteral = ( + node: TSESTree.Node, +): node is TSESTree.BooleanLiteral => + node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'; + +export const getFirstMatcherArg = ( + expectFnCall: ParsedExpectFnCall, +): TSESTree.SpreadElement | TSESTree.Expression => { + const [firstArg] = expectFnCall.args; + + if (firstArg.type === AST_NODE_TYPES.SpreadElement) { + return firstArg; + } + + return followTypeAssertionChain(firstArg); +}; diff --git a/src/rules/utils/parseExpectCall.ts b/src/rules/utils/parseExpectCall.ts deleted file mode 100644 index e5b55f51d..000000000 --- a/src/rules/utils/parseExpectCall.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { - AccessorNode, - KnownMemberExpression, - getAccessorValue, - isSupportedAccessor, -} from '../utils'; - -interface ExpectCall extends TSESTree.CallExpression { - callee: AccessorNode<'expect'>; - parent: TSESTree.Node; -} - -/** - * Checks if the given `node` is a valid `ExpectCall`. - * - * In order to be an `ExpectCall`, the `node` must: - * * be a `CallExpression`, - * * have an accessor named 'expect', - * * have a `parent`. - * - * @param {Node} node - * - * @return {node is ExpectCall} - */ -export const isExpectCall = (node: TSESTree.Node): node is ExpectCall => - node.type === AST_NODE_TYPES.CallExpression && - isSupportedAccessor(node.callee, 'expect') && - node.parent !== undefined; - -interface ParsedExpectMember< - Name extends ExpectPropertyName = ExpectPropertyName, - Node extends ExpectMember = ExpectMember, -> { - name: Name; - node: Node; -} - -/** - * Represents a `MemberExpression` that comes after an `ExpectCall`. - */ -interface ExpectMember< - PropertyName extends ExpectPropertyName = ExpectPropertyName, -> extends KnownMemberExpression { - object: ExpectCall | ExpectMember; - parent: TSESTree.Node; -} - -export const isExpectMember = < - Name extends ExpectPropertyName = ExpectPropertyName, ->( - node: TSESTree.Node, - name?: Name, -): node is ExpectMember => - node.type === AST_NODE_TYPES.MemberExpression && - isSupportedAccessor(node.property, name); - -/** - * Represents all the jest matchers. - */ -type MatcherName = string /* & not ModifierName */; -type ExpectPropertyName = ModifierName | MatcherName; - -export type ParsedEqualityMatcherCall< - Argument extends TSESTree.Expression = TSESTree.Expression, - Matcher extends EqualityMatcher = EqualityMatcher, -> = Omit, 'arguments'> & { - parent: TSESTree.CallExpression; - arguments: [Argument]; -}; - -export enum ModifierName { - not = 'not', - rejects = 'rejects', - resolves = 'resolves', -} - -export enum EqualityMatcher { - toBe = 'toBe', - toEqual = 'toEqual', - toStrictEqual = 'toStrictEqual', -} - -export const isParsedEqualityMatcherCall = < - MatcherName extends EqualityMatcher = EqualityMatcher, ->( - matcher: ParsedExpectMatcher, - name?: MatcherName, -): matcher is ParsedEqualityMatcherCall => - (name - ? matcher.name === name - : EqualityMatcher.hasOwnProperty(matcher.name)) && - matcher.arguments !== null && - matcher.arguments.length === 1; - -/** - * Represents a parsed expect matcher, such as `toBe`, `toContain`, and so on. - */ -export interface ParsedExpectMatcher< - Matcher extends MatcherName = MatcherName, - Node extends ExpectMember = ExpectMember, -> extends ParsedExpectMember { - /** - * The arguments being passed to the matcher. - * A value of `null` means the matcher isn't being called. - */ - arguments: TSESTree.CallExpression['arguments'] | null; -} - -type BaseParsedModifier = - ParsedExpectMember; - -type NegatableModifierName = ModifierName.rejects | ModifierName.resolves; -type NotNegatableModifierName = ModifierName.not; - -/** - * Represents a parsed modifier that can be followed by a `not` negation modifier. - */ -interface NegatableParsedModifier< - Modifier extends NegatableModifierName = NegatableModifierName, -> extends BaseParsedModifier { - negation?: ExpectMember; -} - -/** - * Represents a parsed modifier that cannot be followed by a `not` negation modifier. - */ -export interface NotNegatableParsedModifier< - Modifier extends NotNegatableModifierName = NotNegatableModifierName, -> extends BaseParsedModifier { - negation?: never; -} - -export type ParsedExpectModifier = - | NotNegatableParsedModifier - | NegatableParsedModifier; - -interface Expectation { - expect: ExpectNode; - modifier?: ParsedExpectModifier; - matcher?: ParsedExpectMatcher; -} - -const parseExpectMember = ( - expectMember: ExpectMember, -): ParsedExpectMember => ({ - name: getAccessorValue(expectMember.property), - node: expectMember, -}); - -const reparseAsMatcher = ( - parsedMember: ParsedExpectMember, -): ParsedExpectMatcher => ({ - ...parsedMember, - /** - * The arguments being passed to this `Matcher`, if any. - * - * If this matcher isn't called, this will be `null`. - */ - arguments: - parsedMember.node.parent.type === AST_NODE_TYPES.CallExpression - ? parsedMember.node.parent.arguments - : null, -}); - -/** - * Re-parses the given `parsedMember` as a `ParsedExpectModifier`. - * - * If the given `parsedMember` does not have a `name` of a valid `Modifier`, - * an exception will be thrown. - * - * @param {ParsedExpectMember} parsedMember - * - * @return {ParsedExpectModifier} - */ -const reparseMemberAsModifier = ( - parsedMember: ParsedExpectMember, -): ParsedExpectModifier => { - if (isSpecificMember(parsedMember, ModifierName.not)) { - return parsedMember; - } - - /* istanbul ignore if */ - if ( - !isSpecificMember(parsedMember, ModifierName.resolves) && - !isSpecificMember(parsedMember, ModifierName.rejects) - ) { - // ts doesn't think that the ModifierName.not check is the direct inverse as the above two checks - // todo: impossible at runtime, but can't be typed w/o negation support - throw new Error( - `modifier name must be either "${ModifierName.resolves}" or "${ModifierName.rejects}" (got "${parsedMember.name}")`, - ); - } - - const negation = isExpectMember(parsedMember.node.parent, ModifierName.not) - ? parsedMember.node.parent - : undefined; - - return { - ...parsedMember, - negation, - }; -}; - -const isSpecificMember = ( - member: ParsedExpectMember, - specific: Name, -): member is ParsedExpectMember => member.name === specific; - -/** - * Checks if the given `ParsedExpectMember` should be re-parsed as an `ParsedExpectModifier`. - * - * @param {ParsedExpectMember} member - * - * @return {member is ParsedExpectMember} - */ -const shouldBeParsedExpectModifier = ( - member: ParsedExpectMember, -): member is ParsedExpectMember => - ModifierName.hasOwnProperty(member.name); - -export const parseExpectCall = ( - expect: ExpectNode, -): Expectation => { - const expectation: Expectation = { - expect, - }; - - if (!isExpectMember(expect.parent)) { - return expectation; - } - - const parsedMember = parseExpectMember(expect.parent); - - if (!shouldBeParsedExpectModifier(parsedMember)) { - expectation.matcher = reparseAsMatcher(parsedMember); - - return expectation; - } - - const modifier = (expectation.modifier = - reparseMemberAsModifier(parsedMember)); - - const memberNode = modifier.negation || modifier.node; - - if (!isExpectMember(memberNode.parent)) { - return expectation; - } - - expectation.matcher = reparseAsMatcher(parseExpectMember(memberNode.parent)); - - return expectation; -}; diff --git a/src/rules/utils/parseJestFnCall.ts b/src/rules/utils/parseJestFnCall.ts index b1f2c4ccb..c24949df9 100644 --- a/src/rules/utils/parseJestFnCall.ts +++ b/src/rules/utils/parseJestFnCall.ts @@ -3,7 +3,10 @@ import { AccessorNode, DescribeAlias, HookName, + KnownMemberExpression, + ModifierName, TestCaseName, + findTopMostCallExpression, getAccessorValue, getStringValue, isIdentifier, @@ -50,9 +53,9 @@ export interface ResolvedJestFnWithNode extends ResolvedJestFn { type JestFnType = 'hook' | 'describe' | 'test' | 'expect' | 'jest' | 'unknown'; const determineJestFnType = (name: string): JestFnType => { - // if (name === 'expect') { - // return 'expect'; - // } + if (name === 'expect') { + return 'expect'; + } if (name === 'jest') { return 'jest'; @@ -75,7 +78,7 @@ const determineJestFnType = (name: string): JestFnType => { return 'unknown'; }; -export interface ParsedJestFnCall { +interface BaseParsedJestFnCall { /** * The name of the underlying Jest function that is being called. * This is the result of `(head.original ?? head.local)`. @@ -83,9 +86,21 @@ export interface ParsedJestFnCall { name: string; type: JestFnType; head: ResolvedJestFnWithNode; - members: AccessorNode[]; + members: KnownMemberExpressionProperty[]; +} + +interface ParsedGeneralJestFnCall extends BaseParsedJestFnCall { + type: Exclude; } +export interface ParsedExpectFnCall + extends BaseParsedJestFnCall, + ModifiersAndMatcher { + type: 'expect'; +} + +export type ParsedJestFnCall = ParsedGeneralJestFnCall | ParsedExpectFnCall; + const ValidJestFnCallChains = [ 'afterAll', 'afterEach', @@ -170,31 +185,25 @@ export const parseJestFnCall = ( node: TSESTree.CallExpression, context: TSESLint.RuleContext, ): 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 - ) { + const jestFnCall = parseJestFnCallWithReason(node, context); + + if (typeof jestFnCall === 'string') { return null; } + return jestFnCall; +}; + +export const parseJestFnCallWithReason = ( + node: TSESTree.CallExpression, + context: TSESLint.RuleContext, +): ParsedJestFnCall | string | null => { const chain = getNodeChain(node); if (!chain?.length) { return null; } - // check that every link in the chain except the last is a member expression - if ( - chain - .slice(0, chain.length - 1) - .some(nod => nod.parent?.type !== AST_NODE_TYPES.MemberExpression) - ) { - return null; - } - const [first, ...rest] = chain; const lastLink = getAccessorValue(chain[chain.length - 1]); @@ -227,15 +236,145 @@ export const parseJestFnCall = ( const links = [name, ...rest.map(link => getAccessorValue(link))]; - if (name !== 'jest' && !ValidJestFnCallChains.includes(links.join('.'))) { + if ( + name !== 'jest' && + name !== 'expect' && + !ValidJestFnCallChains.includes(links.join('.')) + ) { return null; } - return { + const parsedJestFnCall: Omit = { name, - type: determineJestFnType(name), head: { ...resolved, node: first }, - members: rest, + // every member node must have a member expression as their parent + // in order to be part of the call chain we're parsing + members: rest as KnownMemberExpressionProperty[], + }; + + const type = determineJestFnType(name); + + if (type === 'expect') { + const result = parseJestExpectCall(parsedJestFnCall); + + // if the `expect` call chain is not valid, only report on the topmost node + // since all members in the chain are likely to get flagged for some reason + if ( + typeof result === 'string' && + findTopMostCallExpression(node) !== node + ) { + return null; + } + + if (result === 'matcher-not-found') { + if (node.parent?.type === AST_NODE_TYPES.MemberExpression) { + return 'matcher-not-called'; + } + } + + return result; + } + + // check that every link in the chain except the last is a member expression + if ( + chain + .slice(0, chain.length - 1) + .some(nod => nod.parent?.type !== AST_NODE_TYPES.MemberExpression) + ) { + return null; + } + + // ensure that we're at the "top" of the function call chain otherwise when + // parsing e.g. x().y.z(), we'll incorrectly find & parse "x()" even though + // the full chain is not a valid jest function call chain + if ( + node.parent?.type === AST_NODE_TYPES.CallExpression || + node.parent?.type === AST_NODE_TYPES.MemberExpression + ) { + return null; + } + + return { ...parsedJestFnCall, type }; +}; + +type KnownMemberExpressionProperty = + AccessorNode & { parent: KnownMemberExpression }; + +interface ModifiersAndMatcher { + modifiers: KnownMemberExpressionProperty[]; + matcher: KnownMemberExpressionProperty; + /** The arguments that are being passed to the `matcher` */ + args: TSESTree.CallExpression['arguments']; +} + +const findModifiersAndMatcher = ( + members: KnownMemberExpressionProperty[], +): ModifiersAndMatcher | string => { + const modifiers: KnownMemberExpressionProperty[] = []; + + for (const member of members) { + // check if the member is being called, which means it is the matcher + // (and also the end of the entire "expect" call chain) + if ( + member.parent?.type === AST_NODE_TYPES.MemberExpression && + member.parent.parent?.type === AST_NODE_TYPES.CallExpression + ) { + return { + matcher: member, + args: member.parent.parent.arguments, + modifiers, + }; + } + + // otherwise, it should be a modifier + const name = getAccessorValue(member); + + if (modifiers.length === 0) { + // the first modifier can be any of the three modifiers + if (!ModifierName.hasOwnProperty(name)) { + return 'modifier-unknown'; + } + } else if (modifiers.length === 1) { + // the second modifier can only be "not" + if (name !== ModifierName.not) { + return 'modifier-unknown'; + } + + const firstModifier = getAccessorValue(modifiers[0]); + + // and the first modifier has to be either "resolves" or "rejects" + if ( + firstModifier !== ModifierName.resolves && + firstModifier !== ModifierName.rejects + ) { + return 'modifier-unknown'; + } + } else { + return 'modifier-unknown'; + } + + modifiers.push(member); + } + + // this will only really happen if there are no members + return 'matcher-not-found'; +}; + +const parseJestExpectCall = ( + typelessParsedJestFnCall: Omit, +): ParsedExpectFnCall | string => { + const modifiersAndMatcher = findModifiersAndMatcher( + typelessParsedJestFnCall.members, + ); + + if (typeof modifiersAndMatcher === 'string') { + return modifiersAndMatcher; + } + + return { + ...typelessParsedJestFnCall, + type: 'expect', + ...modifiersAndMatcher, }; }; diff --git a/src/rules/valid-expect-in-promise.ts b/src/rules/valid-expect-in-promise.ts index 9ffc59da6..38a38d1e7 100644 --- a/src/rules/valid-expect-in-promise.ts +++ b/src/rules/valid-expect-in-promise.ts @@ -3,14 +3,13 @@ import { KnownCallExpression, ModifierName, createRule, + findTopMostCallExpression, getAccessorValue, getNodeName, - isExpectCall, isFunction, isIdentifier, isSupportedAccessor, isTypeOfJestFnCall, - parseExpectCall, parseJestFnCall, } from './utils'; @@ -43,31 +42,6 @@ const isPromiseChainCall = ( return false; }; -const findTopMostCallExpression = ( - node: TSESTree.CallExpression, -): TSESTree.CallExpression => { - let topMostCallExpression = node; - let { parent } = node; - - while (parent) { - if (parent.type === AST_NODE_TYPES.CallExpression) { - topMostCallExpression = parent; - - parent = parent.parent; - - continue; - } - - if (parent.type !== AST_NODE_TYPES.MemberExpression) { - break; - } - - parent = parent.parent; - } - - return topMostCallExpression; -}; - const isTestCaseCallWithCallbackArg = ( node: TSESTree.CallExpression, context: TSESLint.RuleContext, @@ -229,6 +203,7 @@ const getLeftMostCallExpression = ( const isValueAwaitedOrReturned = ( identifier: TSESTree.Identifier, body: TSESTree.Statement[], + context: TSESLint.RuleContext, ): boolean => { const { name } = identifier; @@ -251,17 +226,19 @@ const isValueAwaitedOrReturned = ( } const leftMostCall = getLeftMostCallExpression(node.expression); + const jestFnCall = parseJestFnCall(node.expression, context); if ( - isExpectCall(leftMostCall) && + jestFnCall?.type === 'expect' && leftMostCall.arguments.length > 0 && isIdentifier(leftMostCall.arguments[0], name) ) { - const { modifier } = parseExpectCall(leftMostCall); - if ( - modifier?.name === ModifierName.resolves || - modifier?.name === ModifierName.rejects + jestFnCall.members.some(m => { + const v = getAccessorValue(m); + + return v === ModifierName.resolves || v === ModifierName.rejects; + }) ) { return true; } @@ -294,7 +271,7 @@ const isValueAwaitedOrReturned = ( if ( node.type === AST_NODE_TYPES.BlockStatement && - isValueAwaitedOrReturned(identifier, node.body) + isValueAwaitedOrReturned(identifier, node.body, context) ) { return true; } @@ -346,6 +323,7 @@ const isDirectlyWithinTestCaseCall = ( const isVariableAwaitedOrReturned = ( variable: TSESTree.VariableDeclarator, + context: TSESLint.RuleContext, ): boolean => { const body = findFirstBlockBodyUp(variable); @@ -355,7 +333,7 @@ const isVariableAwaitedOrReturned = ( return true; } - return isValueAwaitedOrReturned(variable.id, body); + return isValueAwaitedOrReturned(variable.id, body, context); }; export default createRule({ @@ -406,7 +384,10 @@ export default createRule({ // if we're within a promise chain, and this call expression looks like // an expect call, mark the deepest chain as having an expect call - if (chains.length > 0 && isExpectCall(node)) { + if ( + chains.length > 0 && + isTypeOfJestFnCall(node, context, ['expect']) + ) { chains[0] = true; } }, @@ -448,7 +429,7 @@ export default createRule({ switch (parent.type) { case AST_NODE_TYPES.VariableDeclarator: { - if (isVariableAwaitedOrReturned(parent)) { + if (isVariableAwaitedOrReturned(parent, context)) { return; } @@ -461,6 +442,7 @@ export default createRule({ isValueAwaitedOrReturned( parent.left, findFirstBlockBodyUp(parent), + context, ) ) { return; diff --git a/src/rules/valid-expect.ts b/src/rules/valid-expect.ts index c599e47db..743205ea3 100644 --- a/src/rules/valid-expect.ts +++ b/src/rules/valid-expect.ts @@ -8,10 +8,8 @@ import { ModifierName, createRule, getAccessorValue, - isExpectCall, - isExpectMember, isSupportedAccessor, - parseExpectCall, + parseJestFnCallWithReason, } from './utils'; /** @@ -56,7 +54,8 @@ const getParentIfThenified = (node: TSESTree.Node): TSESTree.Node => { if ( grandParentNode && grandParentNode.type === AST_NODE_TYPES.CallExpression && - isExpectMember(grandParentNode.callee) && + grandParentNode.callee.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(grandParentNode.callee.property) && ['then', 'catch'].includes( getAccessorValue(grandParentNode.callee.property), ) && @@ -91,12 +90,6 @@ const isAcceptableReturnNode = ( ].includes(node.type); }; -const isNoAssertionsParentNode = (node: TSESTree.Node): boolean => - node.type === AST_NODE_TYPES.ExpressionStatement || - (node.type === AST_NODE_TYPES.AwaitExpression && - node.parent !== undefined && - node.parent.type === AST_NODE_TYPES.ExpressionStatement); - const promiseArrayExceptionKey = ({ start, end }: TSESTree.SourceLocation) => `${start.line}:${start.column}-${end.line}:${end.column}`; @@ -129,7 +122,7 @@ export default createRule<[Options], MessageIds>({ messages: { tooManyArgs: 'Expect takes at most {{ amount }} argument{{ s }}.', notEnoughArgs: 'Expect requires at least {{ amount }} argument{{ s }}.', - modifierUnknown: 'Expect has no modifier named "{{ modifierName }}".', + modifierUnknown: 'Expect has an unknown modifier.', matcherNotFound: 'Expect must have a corresponding matcher call.', matcherNotCalled: 'Matchers must be called to assert.', asyncMustBeAwaited: 'Async assertions must be awaited{{ orReturned }}.', @@ -197,39 +190,99 @@ export default createRule<[Options], MessageIds>({ const promiseArrayExceptionExists = (loc: TSESTree.SourceLocation) => arrayExceptions.has(promiseArrayExceptionKey(loc)); + const findTopMostMemberExpression = ( + node: TSESTree.MemberExpression, + ): TSESTree.MemberExpression => { + let topMostMemberExpression = node; + let { parent } = node; + + while (parent) { + if (parent.type !== AST_NODE_TYPES.MemberExpression) { + break; + } + + topMostMemberExpression = parent; + parent = parent.parent; + } + + return topMostMemberExpression; + }; + return { CallExpression(node) { - if (!isExpectCall(node)) { + const jestFnCall = parseJestFnCallWithReason(node, context); + + if (typeof jestFnCall === 'string') { + const reportingNode = + node.parent?.type === AST_NODE_TYPES.MemberExpression + ? findTopMostMemberExpression(node.parent).property + : node; + + if (jestFnCall === 'matcher-not-found') { + context.report({ + messageId: 'matcherNotFound', + node: reportingNode, + }); + + return; + } + + if (jestFnCall === 'matcher-not-called') { + context.report({ + messageId: + isSupportedAccessor(reportingNode) && + ModifierName.hasOwnProperty(getAccessorValue(reportingNode)) + ? 'matcherNotFound' + : 'matcherNotCalled', + node: reportingNode, + }); + } + + if (jestFnCall === 'modifier-unknown') { + context.report({ + messageId: 'modifierUnknown', + node: reportingNode, + }); + + return; + } + + return; + } else if (jestFnCall?.type !== 'expect') { return; } - const { expect, modifier, matcher } = parseExpectCall(node); + const { parent: expect } = jestFnCall.head.node; + + if (expect?.type !== AST_NODE_TYPES.CallExpression) { + return; + } if (expect.arguments.length < minArgs) { - const expectLength = getAccessorValue(expect.callee).length; + const expectLength = getAccessorValue(jestFnCall.head.node).length; const loc: TSESTree.SourceLocation = { start: { - column: node.loc.start.column + expectLength, - line: node.loc.start.line, + column: expect.loc.start.column + expectLength, + line: expect.loc.start.line, }, end: { - column: node.loc.start.column + expectLength + 1, - line: node.loc.start.line, + column: expect.loc.start.column + expectLength + 1, + line: expect.loc.start.line, }, }; context.report({ messageId: 'notEnoughArgs', data: { amount: minArgs, s: minArgs === 1 ? '' : 's' }, - node, + node: expect, loc, }); } if (expect.arguments.length > maxArgs) { const { start } = expect.arguments[maxArgs].loc; - const { end } = expect.arguments[node.arguments.length - 1].loc; + const { end } = expect.arguments[expect.arguments.length - 1].loc; const loc = { start, @@ -242,46 +295,19 @@ export default createRule<[Options], MessageIds>({ context.report({ messageId: 'tooManyArgs', data: { amount: maxArgs, s: maxArgs === 1 ? '' : 's' }, - node, + node: expect, loc, }); } - // something was called on `expect()` - if (!matcher) { - if (modifier) { - context.report({ - messageId: 'matcherNotFound', - node: modifier.node.property, - }); - } - - return; - } + const { matcher } = jestFnCall; - if (isExpectMember(matcher.node.parent)) { - context.report({ - messageId: 'modifierUnknown', - data: { modifierName: matcher.name }, - node: matcher.node.property, - }); - - return; - } - - if (!matcher.arguments) { - context.report({ - messageId: 'matcherNotCalled', - node: matcher.node.property, - }); - } - - const parentNode = matcher.node.parent; + const parentNode = matcher.parent.parent; const shouldBeAwaited = - (modifier && modifier.name !== ModifierName.not) || - asyncMatchers.includes(matcher.name); + jestFnCall.modifiers.some(nod => getAccessorValue(nod) !== 'not') || + asyncMatchers.includes(getAccessorValue(matcher)); - if (!parentNode.parent || !shouldBeAwaited) { + if (!parentNode?.parent || !shouldBeAwaited) { return; } /** @@ -309,9 +335,7 @@ export default createRule<[Options], MessageIds>({ ) { context.report({ loc: finalNode.loc, - data: { - orReturned, - }, + data: { orReturned }, messageId: finalNode === targetNode ? 'asyncMustBeAwaited' @@ -324,13 +348,6 @@ export default createRule<[Options], MessageIds>({ } } }, - - // nothing called on "expect()" - 'CallExpression:exit'(node: TSESTree.CallExpression) { - if (isExpectCall(node) && isNoAssertionsParentNode(node.parent)) { - context.report({ messageId: 'matcherNotFound', node }); - } - }, }; }, });