diff --git a/internal/plugins/jest/all.go b/internal/plugins/jest/all.go index 12bba95dd..fab87c6bc 100644 --- a/internal/plugins/jest/all.go +++ b/internal/plugins/jest/all.go @@ -17,6 +17,7 @@ import ( "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_strict_equal" "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_to_be" "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_to_contain" + "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_to_have_been_called" "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_to_have_length" "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_todo" "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/valid_describe_callback" @@ -42,6 +43,7 @@ func GetAllRules() []rule.Rule { prefer_strict_equal.PreferStrictEqualRule, prefer_to_be.PreferToBeRule, prefer_to_contain.PreferToContainRule, + prefer_to_have_been_called.PreferToHaveBeenCalledRule, prefer_to_have_length.PreferToHaveLengthRule, prefer_todo.PreferTodoRule, valid_describe_callback.ValidDescribeCallbackRule, diff --git a/internal/plugins/jest/rules/prefer_to_be/prefer_to_be.go b/internal/plugins/jest/rules/prefer_to_be/prefer_to_be.go index 4bf1be9f1..2baed67c6 100644 --- a/internal/plugins/jest/rules/prefer_to_be/prefer_to_be.go +++ b/internal/plugins/jest/rules/prefer_to_be/prefer_to_be.go @@ -56,24 +56,6 @@ const ( preferKindDefined ) -// unwrapTypeAssertions strips parentheses and TypeScript type assertions so -// the resulting node matches what the upstream rule inspects at runtime. -func unwrapTypeAssertions(node *ast.Node) *ast.Node { - for node != nil { - switch node.Kind { - case ast.KindParenthesizedExpression: - node = node.AsParenthesizedExpression().Expression - case ast.KindAsExpression: - node = node.AsAsExpression().Expression - case ast.KindTypeAssertionExpression: - node = node.AsTypeAssertion().Expression - default: - return node - } - } - return node -} - // firstMatcherArgument returns expect(...).matcher(arg0)’s first argument // after peeling type assertions; nil if the matcher call has no arguments. func firstMatcherArgument(matcherCall *ast.Node) *ast.Node { @@ -81,7 +63,7 @@ func firstMatcherArgument(matcherCall *ast.Node) *ast.Node { if call == nil || call.Arguments == nil || len(call.Arguments.Nodes) == 0 { return nil } - return unwrapTypeAssertions(call.Arguments.Nodes[0]) + return utils.UnwrapBasicTypeAssertions(call.Arguments.Nodes[0]) } func isNullLiteral(arg *ast.Node) bool { @@ -102,7 +84,7 @@ func shouldUseToBe(arg *ast.Node) bool { if arg.Kind == ast.KindPrefixUnaryExpression { unary := arg.AsPrefixUnaryExpression() if unary.Operator == ast.KindMinusToken { - arg = unwrapTypeAssertions(unary.Operand) + arg = utils.UnwrapBasicTypeAssertions(unary.Operand) } } if arg == nil { @@ -123,29 +105,12 @@ func shouldUseToBe(arg *ast.Node) bool { } } -func modifierReceiverParent(modEntry utils.ParsedJestFnMemberEntry) (*ast.Node, *ast.Node) { - if modEntry.Node == nil || modEntry.Node.Parent == nil { - return nil, nil - } - parent := modEntry.Node.Parent - switch parent.Kind { - case ast.KindPropertyAccessExpression: - p := parent.AsPropertyAccessExpression() - return p.Expression, parent - case ast.KindElementAccessExpression: - p := parent.AsElementAccessExpression() - return p.Expression, parent - default: - return nil, nil - } -} - func appendRemoveNotModifierFix(fixes []rule.RuleFix, jestFnCall *utils.ParsedJestFnCall) []rule.RuleFix { for _, modEntry := range jestFnCall.ModifierEntries { if modEntry.Name != "not" || modEntry.Node == nil { continue } - receiver, parent := modifierReceiverParent(modEntry) + receiver, parent := utils.GetAccessorReceiverAndParent(&modEntry) if receiver == nil || parent == nil { continue } diff --git a/internal/plugins/jest/rules/prefer_to_contain/prefer_to_contain.go b/internal/plugins/jest/rules/prefer_to_contain/prefer_to_contain.go index 040d3f79b..cfe074907 100644 --- a/internal/plugins/jest/rules/prefer_to_contain/prefer_to_contain.go +++ b/internal/plugins/jest/rules/prefer_to_contain/prefer_to_contain.go @@ -20,26 +20,6 @@ func buildUseToContainErrorMessage() rule.RuleMessage { } } -func unwrapTypeAssertions(node *ast.Node) *ast.Node { - for node != nil { - switch node.Kind { - case ast.KindParenthesizedExpression: - node = node.AsParenthesizedExpression().Expression - case ast.KindAsExpression: - node = node.AsAsExpression().Expression - case ast.KindTypeAssertionExpression: - node = node.AsTypeAssertion().Expression - case ast.KindNonNullExpression: - node = node.AsNonNullExpression().Expression - case ast.KindSatisfiesExpression: - node = node.AsSatisfiesExpression().Expression - default: - return node - } - } - return node -} - func getIncludesCalleeName(callee *ast.Node) (receiver *ast.Node, ok bool) { if callee == nil { return nil, false @@ -145,7 +125,7 @@ var PreferToContainRule = rule.Rule{ return } - unwrapped := unwrapTypeAssertions(matcherArg) + unwrapped := jestUtils.UnwrapTypeAssertions(matcherArg) if unwrapped == nil { return } diff --git a/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.go b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.go new file mode 100644 index 000000000..aa6517283 --- /dev/null +++ b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.go @@ -0,0 +1,87 @@ +package prefer_to_have_been_called + +import ( + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/core" + jestUtils "github.com/web-infra-dev/rslint/internal/plugins/jest/utils" + "github.com/web-infra-dev/rslint/internal/rule" +) + +// Message Builders + +func buildPreferMatcherErrorMessage() rule.RuleMessage { + return rule.RuleMessage{ + Id: "preferMatcher", + Description: "Use `toHaveBeenCalled`", + } +} + +func isZeroLiteral(node *ast.Node) bool { + node = jestUtils.UnwrapBasicTypeAssertions(node) + if node == nil { + return false + } + + return node.Kind == ast.KindNumericLiteral && node.AsNumericLiteral().Text == "0" +} + +var PreferToHaveBeenCalledRule = rule.Rule{ + Name: "jest/prefer-to-have-been-called", + Run: func(ctx rule.RuleContext, options any) rule.RuleListeners { + return rule.RuleListeners{ + ast.KindCallExpression: func(node *ast.Node) { + jestFnCall := jestUtils.ParseJestFnCall(node, ctx) + if jestFnCall == nil || + jestFnCall.Kind != jestUtils.JestFnTypeExpect || + (jestFnCall.Matcher != "toBeCalledTimes" && jestFnCall.Matcher != "toHaveBeenCalledTimes") { + return + } + + matcherCall := node.AsCallExpression() + if matcherCall == nil || matcherCall.Arguments == nil || len(matcherCall.Arguments.Nodes) == 0 { + return + } + + if !isZeroLiteral(matcherCall.Arguments.Nodes[0]) { + return + } + + matcherReceiver, matcherParent := jestUtils.GetAccessorReceiverAndParent(jestFnCall.MatcherEntry) + if matcherParent == nil || matcherReceiver == nil { + return + } + + var notModifier *jestUtils.ParsedJestFnMemberEntry + for i := range jestFnCall.ModifierEntries { + if jestFnCall.ModifierEntries[i].Name == "not" { + notModifier = &jestFnCall.ModifierEntries[i] + break + } + } + + replaceStart := matcherReceiver.End() + if notModifier != nil { + notReceiver, _ := jestUtils.GetAccessorReceiverAndParent(notModifier) + if notReceiver == nil { + return + } + replaceStart = notReceiver.End() + } + + replacementMatcher := ".not.toHaveBeenCalled" + if notModifier != nil { + replacementMatcher = ".toHaveBeenCalled" + } + + ctx.ReportNodeWithFixes( + jestFnCall.MatcherEntry.Node, + buildPreferMatcherErrorMessage(), + rule.RuleFixReplaceRange( + core.NewTextRange(replaceStart, node.End()), + replacementMatcher+"()", + ), + ) + }, + } + }, +} diff --git a/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.md b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.md new file mode 100644 index 000000000..09a7b3bb5 --- /dev/null +++ b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called.md @@ -0,0 +1,30 @@ +# prefer-to-have-been-called + +## Rule Details + +In order to have a better failure message, `toHaveBeenCalled()` and +`not.toHaveBeenCalled()` should be used when asserting that a mock has or has +not been called. + +This rule triggers a warning if `toHaveBeenCalledTimes` or `toBeCalledTimes` is +used to assert that a mock has or has not been called zero times. + +Examples of **incorrect** code for this rule: + +```js +expect(method).toHaveBeenCalledTimes(0); + +expect(method).not.toHaveBeenCalledTimes(0); +``` + +Examples of **correct** code for this rule: + +```js +expect(method).not.toHaveBeenCalled(); + +expect(method).toHaveBeenCalled(); +``` + +## Original Documentation + +- [jest/prefer-to-have-been-called](https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/prefer-to-have-been-called.md) diff --git a/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called_test.go b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called_test.go new file mode 100644 index 000000000..64b3c339b --- /dev/null +++ b/internal/plugins/jest/rules/prefer_to_have_been_called/prefer_to_have_been_called_test.go @@ -0,0 +1,91 @@ +package prefer_to_have_been_called_test + +import ( + "testing" + + "github.com/web-infra-dev/rslint/internal/plugins/jest/fixtures" + "github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_to_have_been_called" + "github.com/web-infra-dev/rslint/internal/rule_tester" +) + +func TestPreferToHaveBeenCalledRule(t *testing.T) { + rule_tester.RunRuleTester( + fixtures.GetRootDir(), + "tsconfig.json", + t, + &prefer_to_have_been_called.PreferToHaveBeenCalledRule, + []rule_tester.ValidTestCase{ + {Code: `expect(method.mock.calls).toHaveLength;`}, + {Code: `expect(method.mock.calls).toHaveLength(0);`}, + {Code: `expect(method).toHaveBeenCalledTimes(1)`}, + {Code: `expect(method).not.toHaveBeenCalledTimes(x)`}, + {Code: `expect(method).not.toHaveBeenCalledTimes(1)`}, + {Code: `expect(method).not.toHaveBeenCalledTimes(...x)`}, + {Code: `expect(a);`}, + {Code: `expect(method).not.resolves.toHaveBeenCalledTimes(0);`}, + {Code: `expect(method).toBeCalledTimes(0!);`}, + {Code: `expect(method).toBeCalledTimes(0 satisfies number);`}, + {Code: `expect(method).toBe([])`}, + {Code: `expect(fn.mock.calls).toEqual([])`}, + {Code: `expect(fn.mock.calls).toContain(1, 2, 3)`}, + }, + []rule_tester.InvalidTestCase{ + { + Code: `expect(method).toBeCalledTimes(0);`, + Output: []string{`expect(method).not.toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 16}, + }, + }, + { + Code: `expect(method).not.toBeCalledTimes(0);`, + Output: []string{`expect(method).toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 20}, + }, + }, + { + Code: `expect(method).toHaveBeenCalledTimes(0);`, + Output: []string{`expect(method).not.toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 16}, + }, + }, + { + Code: `expect(method).not.toHaveBeenCalledTimes(0);`, + Output: []string{`expect(method).toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 20}, + }, + }, + { + Code: `expect(method).not.toHaveBeenCalledTimes(0, 1, 2);`, + Output: []string{`expect(method).toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 20}, + }, + }, + { + Code: `expect(method).resolves.toHaveBeenCalledTimes(0);`, + Output: []string{`expect(method).resolves.not.toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 25}, + }, + }, + { + Code: `expect(method).rejects.not.toHaveBeenCalledTimes(0);`, + Output: []string{`expect(method).rejects.toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 28}, + }, + }, + { + Code: `expect(method).toBeCalledTimes(0 as number);`, + Output: []string{`expect(method).not.toHaveBeenCalled();`}, + Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "preferMatcher", Line: 1, Column: 16}, + }, + }, + }, + ) +} diff --git a/internal/plugins/jest/utils/parse_jest_fn.go b/internal/plugins/jest/utils/parse_jest_fn.go index 886762c04..415c49d16 100644 --- a/internal/plugins/jest/utils/parse_jest_fn.go +++ b/internal/plugins/jest/utils/parse_jest_fn.go @@ -343,3 +343,55 @@ func isValidJestCall(name string, members []string) bool { _, ok := VALID_JEST_FN_CALL_CHAINS[chain] return ok } + +func UnwrapBasicTypeAssertions(node *ast.Node) *ast.Node { + for node != nil { + switch node.Kind { + case ast.KindParenthesizedExpression: + node = node.AsParenthesizedExpression().Expression + case ast.KindAsExpression: + node = node.AsAsExpression().Expression + case ast.KindTypeAssertionExpression: + node = node.AsTypeAssertion().Expression + default: + return node + } + } + return node +} + +func UnwrapTypeAssertions(node *ast.Node) *ast.Node { + for node != nil { + switch node.Kind { + case ast.KindParenthesizedExpression: + node = node.AsParenthesizedExpression().Expression + case ast.KindAsExpression: + node = node.AsAsExpression().Expression + case ast.KindTypeAssertionExpression: + node = node.AsTypeAssertion().Expression + case ast.KindNonNullExpression: + node = node.AsNonNullExpression().Expression + case ast.KindSatisfiesExpression: + node = node.AsSatisfiesExpression().Expression + default: + return node + } + } + return node +} + +func GetAccessorReceiverAndParent(entry *ParsedJestFnMemberEntry) (*ast.Node, *ast.Node) { + if entry == nil || entry.Node == nil || entry.Node.Parent == nil { + return nil, nil + } + + parent := entry.Node.Parent + switch parent.Kind { + case ast.KindPropertyAccessExpression: + return parent.AsPropertyAccessExpression().Expression, parent + case ast.KindElementAccessExpression: + return parent.AsElementAccessExpression().Expression, parent + default: + return nil, nil + } +} diff --git a/packages/rslint-test-tools/rstest.config.mts b/packages/rslint-test-tools/rstest.config.mts index 8fb689fb6..b622af437 100644 --- a/packages/rslint-test-tools/rstest.config.mts +++ b/packages/rslint-test-tools/rstest.config.mts @@ -441,6 +441,7 @@ export default defineConfig({ './tests/eslint-plugin-jest/rules/prefer-strict-equal.test.ts', './tests/eslint-plugin-jest/rules/prefer-to-be.test.ts', './tests/eslint-plugin-jest/rules/prefer-to-contain.test.ts', + './tests/eslint-plugin-jest/rules/prefer-to-have-been-called.test.ts', './tests/eslint-plugin-jest/rules/prefer-to-have-length.test.ts', './tests/eslint-plugin-jest/rules/prefer-todo.test.ts', './tests/eslint-plugin-jest/rules/valid-describe-callback.test.ts', diff --git a/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-to-have-been-called.test.ts b/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-to-have-been-called.test.ts new file mode 100644 index 000000000..1492f9de3 --- /dev/null +++ b/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-to-have-been-called.test.ts @@ -0,0 +1,65 @@ +import { RuleTester } from '../rule-tester'; + +const ruleTester = new RuleTester(); + +ruleTester.run('prefer-to-have-been-called', {} as never, { + valid: [ + { code: 'expect(method.mock.calls).toHaveLength;' }, + { code: 'expect(method.mock.calls).toHaveLength(0);' }, + { code: 'expect(method).toHaveBeenCalledTimes(1)' }, + { code: 'expect(method).not.toHaveBeenCalledTimes(x)' }, + { code: 'expect(method).not.toHaveBeenCalledTimes(1)' }, + { code: 'expect(method).not.toHaveBeenCalledTimes(...x)' }, + { code: 'expect(a);' }, + { code: 'expect(method).not.resolves.toHaveBeenCalledTimes(0);' }, + { code: 'expect(method).toBeCalledTimes(0!);' }, + { code: 'expect(method).toBeCalledTimes(0 satisfies number);' }, + { code: 'expect(method).toBe([])' }, + { code: 'expect(fn.mock.calls).toEqual([])' }, + { code: 'expect(fn.mock.calls).toContain(1, 2, 3)' }, + ], + invalid: [ + { + code: 'expect(method).toBeCalledTimes(0);', + output: 'expect(method).not.toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 16, line: 1 }], + }, + { + code: 'expect(method).not.toBeCalledTimes(0);', + output: 'expect(method).toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 20, line: 1 }], + }, + { + code: 'expect(method).toHaveBeenCalledTimes(0);', + output: 'expect(method).not.toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 16, line: 1 }], + }, + { + code: 'expect(method).not.toHaveBeenCalledTimes(0);', + output: 'expect(method).toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 20, line: 1 }], + }, + { + code: 'expect(method).not.toHaveBeenCalledTimes(0, 1, 2);', + output: 'expect(method).toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 20, line: 1 }], + }, + + { + code: 'expect(method).resolves.toHaveBeenCalledTimes(0);', + output: 'expect(method).resolves.not.toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 25, line: 1 }], + }, + { + code: 'expect(method).rejects.not.toHaveBeenCalledTimes(0);', + output: 'expect(method).rejects.toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 28, line: 1 }], + }, + + { + code: 'expect(method).toBeCalledTimes(0 as number);', + output: 'expect(method).not.toHaveBeenCalled();', + errors: [{ messageId: 'preferMatcher', column: 16, line: 1 }], + }, + ], +});