From 8753599d6c9e4baad654bde053d6d29537d4e416 Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Tue, 25 Nov 2025 12:19:05 +0800 Subject: [PATCH 1/2] fix: issue 1800 --- src/rules/__tests__/unbound-method.test.ts | 16 ++++++++++++++++ src/rules/utils/parseJestFnCall.ts | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/rules/__tests__/unbound-method.test.ts b/src/rules/__tests__/unbound-method.test.ts index 036ec9234..c7040c7af 100644 --- a/src/rules/__tests__/unbound-method.test.ts +++ b/src/rules/__tests__/unbound-method.test.ts @@ -49,6 +49,14 @@ const ConsoleClassAndVariableCode = dedent` const console = new Console(); `; +const ServiceClassAndMethodCode = dedent` + class Service { + method() {} + } + + const service = new Service(); +`; + const toThrowMatchers = [ 'toThrow', 'toThrowError', @@ -73,6 +81,13 @@ const validTestCases: string[] = [ 'expect(() => Promise.resolve().then(console.log)).not.toThrow();', ...toThrowMatchers.map(matcher => `expect(console.log).not.${matcher}();`), ...toThrowMatchers.map(matcher => `expect(console.log).${matcher}();`), + // Issue #1800: jest.mocked().mock.calls should be allowed + ...[ + 'const parameter = jest.mocked(service.method).mock.calls[0][0];', + 'const calls = jest.mocked(service.method).mock.calls;', + 'const lastCall = jest.mocked(service.method).mock.calls[0];', + 'const mockedMethod = jest.mocked(service.method); const parameter = mockedMethod.mock.calls[0][0];', + ].map(code => [ServiceClassAndMethodCode, code].join('\n')), ]; const invalidTestCases: Array> = [ @@ -235,6 +250,7 @@ const arith = { ${code} `; } + function addContainsMethodsClassInvalid( code: string[], ): Array> { diff --git a/src/rules/utils/parseJestFnCall.ts b/src/rules/utils/parseJestFnCall.ts index c74ea1426..643e34737 100644 --- a/src/rules/utils/parseJestFnCall.ts +++ b/src/rules/utils/parseJestFnCall.ts @@ -338,8 +338,9 @@ const parseJestFnCallWithReasonInner = ( // 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 + lastLink !== 'mocked' && + (node.parent?.type === AST_NODE_TYPES.CallExpression || + node.parent?.type === AST_NODE_TYPES.MemberExpression) ) { return null; } From ec0caef1a4c3b8b6a618228216f9614a26a6cc6d Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Tue, 25 Nov 2025 21:56:47 +0800 Subject: [PATCH 2/2] fix: issue 1800 --- src/rules/__tests__/unbound-method.test.ts | 2 +- src/rules/unbound-method.ts | 108 +++++++++++++++++++++ src/rules/utils/parseJestFnCall.ts | 5 +- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/src/rules/__tests__/unbound-method.test.ts b/src/rules/__tests__/unbound-method.test.ts index c7040c7af..47593bca2 100644 --- a/src/rules/__tests__/unbound-method.test.ts +++ b/src/rules/__tests__/unbound-method.test.ts @@ -81,7 +81,7 @@ const validTestCases: string[] = [ 'expect(() => Promise.resolve().then(console.log)).not.toThrow();', ...toThrowMatchers.map(matcher => `expect(console.log).not.${matcher}();`), ...toThrowMatchers.map(matcher => `expect(console.log).${matcher}();`), - // Issue #1800: jest.mocked().mock.calls should be allowed + // https://github.com/jest-community/eslint-plugin-jest/issues/1800 ...[ 'const parameter = jest.mocked(service.method).mock.calls[0][0];', 'const calls = jest.mocked(service.method).mock.calls;', diff --git a/src/rules/unbound-method.ts b/src/rules/unbound-method.ts index ddb7587e4..6e5736c8b 100644 --- a/src/rules/unbound-method.ts +++ b/src/rules/unbound-method.ts @@ -8,6 +8,7 @@ import { findTopMostCallExpression, getAccessorValue, isIdentifier, + isSupportedAccessor, parseJestFnCall, } from './utils'; @@ -73,9 +74,116 @@ export default createRule({ return {}; } + /** + * Checks if a MemberExpression is an argument to a `jest.mocked()` call. + * This handles cases like `jest.mocked(service.method)` where `service.method` + * should not be flagged as an unbound method. + */ + const isArgumentToJestMocked = ( + node: TSESTree.MemberExpression, + ): boolean => { + // Check if the immediate parent is a CallExpression + if (node.parent?.type !== AST_NODE_TYPES.CallExpression) { + return false; + } + + const parentCall = node.parent; + + // Check if this node is an argument to the call + if (!parentCall.arguments.some(arg => arg === node)) { + return false; + } + + // Check if the call is jest.mocked() by examining the callee + if ( + parentCall.callee.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(parentCall.callee.object) && + isSupportedAccessor(parentCall.callee.property) + ) { + const objectName = getAccessorValue(parentCall.callee.object); + const propertyName = getAccessorValue(parentCall.callee.property); + + if (objectName === 'jest' && propertyName === 'mocked') { + return true; + } + } + + // Also try using parseJestFnCall as a fallback + const jestFnCall = parseJestFnCall( + findTopMostCallExpression(parentCall), + context, + ); + + return ( + jestFnCall?.type === 'jest' && + jestFnCall.members.length >= 1 && + isIdentifier(jestFnCall.members[0], 'mocked') + ); + }; + + /** + * Checks if a MemberExpression is accessing `.mock` property on a + * `jest.mocked()` result. This handles cases like: + * - `jest.mocked(service.method).mock.calls[0][0]` + * - `jest.mocked(service.method).mock.calls` + */ + const isAccessingMockProperty = ( + node: TSESTree.MemberExpression, + ): boolean => { + // Check if we're accessing `.mock` property + if (!isSupportedAccessor(node.property)) { + return false; + } + + const propertyName = getAccessorValue(node.property); + + if (propertyName !== 'mock') { + return false; + } + + // Traverse up the chain to find if the object is a jest.mocked() call + let current: TSESTree.Node = node.object; + + while (current) { + if (current.type === AST_NODE_TYPES.CallExpression) { + const jestFnCall = parseJestFnCall( + findTopMostCallExpression(current), + context, + ); + + if ( + jestFnCall?.type === 'jest' && + jestFnCall.members.length >= 1 && + isIdentifier(jestFnCall.members[0], 'mocked') + ) { + return true; + } + } + + // Continue traversing up if the current node is part of a member chain + if (current.type === AST_NODE_TYPES.MemberExpression) { + current = current.object; + } else { + break; + } + } + + return false; + }; + return { ...baseSelectors, MemberExpression(node: TSESTree.MemberExpression): void { + // Check if this MemberExpression is an argument to jest.mocked() + if (isArgumentToJestMocked(node)) { + return; + } + + // Check if accessing .mock property on jest.mocked() result + if (isAccessingMockProperty(node)) { + return; + } + if (node.parent?.type === AST_NODE_TYPES.CallExpression) { const jestFnCall = parseJestFnCall( findTopMostCallExpression(node.parent), diff --git a/src/rules/utils/parseJestFnCall.ts b/src/rules/utils/parseJestFnCall.ts index 643e34737..c74ea1426 100644 --- a/src/rules/utils/parseJestFnCall.ts +++ b/src/rules/utils/parseJestFnCall.ts @@ -338,9 +338,8 @@ const parseJestFnCallWithReasonInner = ( // 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 ( - lastLink !== 'mocked' && - (node.parent?.type === AST_NODE_TYPES.CallExpression || - node.parent?.type === AST_NODE_TYPES.MemberExpression) + node.parent?.type === AST_NODE_TYPES.CallExpression || + node.parent?.type === AST_NODE_TYPES.MemberExpression ) { return null; }