Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/plugins/jest/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
41 changes: 3 additions & 38 deletions internal/plugins/jest/rules/prefer_to_be/prefer_to_be.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,32 +56,14 @@ 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 {
call := matcherCall.AsCallExpression()
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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -145,7 +125,7 @@ var PreferToContainRule = rule.Rule{
return
}

unwrapped := unwrapTypeAssertions(matcherArg)
unwrapped := jestUtils.UnwrapTypeAssertions(matcherArg)
if unwrapped == nil {
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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+"()",
),
Comment on lines +79 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation uses two separate fixes to replace the matcher name and clear the arguments. This can be simplified into a single fix that replaces the entire range from the receiver's end to the end of the call expression. This approach is more robust as it correctly handles potential edge cases like trailing commas or comments within the parentheses, ensuring exact alignment with ESLint's semantics for this rule.

Suggested change
rule.RuleFixReplaceRange(
core.NewTextRange(matcherCall.Arguments.Pos(), matcherCall.Arguments.End()),
"",
),
rule.RuleFixReplaceRange(
core.NewTextRange(replaceStart, matcherParent.End()),
replacementMatcher,
),
rule.RuleFixReplaceRange(
core.NewTextRange(replaceStart, node.End()),
replacementMatcher+"()",
),
References
  1. For linter rules ported from or inspired by ESLint, maintain exact alignment with ESLint's semantics, even for edge cases where a simpler implementation might seem practically equivalent.

)
},
}
},
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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},
},
},
},
)
}
52 changes: 52 additions & 0 deletions internal/plugins/jest/utils/parse_jest_fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading
Loading