Skip to content
Merged
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 @@ -49,6 +49,7 @@ import (
"github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_lowercase_title"
"github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_mock_promise_shorthand"
"github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_mock_return_shorthand"
"github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_snapshot_hint"
"github.com/web-infra-dev/rslint/internal/plugins/jest/rules/prefer_spy_on"
"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"
Expand Down Expand Up @@ -119,6 +120,7 @@ func GetAllRules() []rule.Rule {
prefer_mock_return_shorthand.PreferMockReturnShorthandRule,
prefer_spy_on.PreferSpyOnRule,
prefer_strict_equal.PreferStrictEqualRule,
prefer_snapshot_hint.PreferSnapshotHintRule,
prefer_to_be.PreferToBeRule,
prefer_to_contain.PreferToContainRule,
prefer_to_have_been_called_times.PreferToHaveBeenCalledTimesRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package prefer_snapshot_hint

import (
"github.com/microsoft/TypeScript/tsc/shim/ast"
"github.com/web-infra-dev/rslint/internal/plugins/jest/utils"
"github.com/web-infra-dev/rslint/internal/rule"
shared "github.com/web-infra-dev/rslint/internal/utils/test_framework/rules/prefer_snapshot_hint"
)

var PreferSnapshotHintRule = shared.NewRule(shared.Config{
Name: "jest/prefer-snapshot-hint",
Prepare: func(ctx rule.RuleContext) shared.Runtime {
analysis := utils.GetJestCallAnalysis(ctx)
return shared.Runtime{
RegistrationCallbacks: analysis.RegistrationCallbacks(),
IsRegistration: func(node *ast.Node) bool {
parsed := analysis.ParseFnCall(node)
return parsed != nil && (parsed.Kind == utils.JestFnTypeTest || parsed.Kind == utils.JestFnTypeDescribe)
},
Snapshots: func(node *ast.Node) []shared.Snapshot {
parsed := analysis.ParseExpectCall(node)
if parsed == nil || parsed.MatcherEntry == nil ||
(parsed.Matcher != "toMatchSnapshot" && parsed.Matcher != "toThrowErrorMatchingSnapshot") {
return nil
}
matcherCall := parsed.MatcherEntry.Call
if matcherCall == nil || matcherCall != node {
return nil
}
return []shared.Snapshot{{Matcher: parsed.MatcherEntry.Node, Args: matcherCall.Arguments(), Properties: parsed.Matcher == "toMatchSnapshot"}}
},
}
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# prefer-snapshot-hint

## Rule Details

Prefer including a descriptive hint with external snapshots. Hints make snapshots easier to identify during review and updates.

The rule checks `toMatchSnapshot` and `toThrowErrorMatchingSnapshot`. Inline snapshots are ignored. The default `"multi"` mode requires hints in groups containing multiple external snapshot assertions; `"always"` requires a hint on every external snapshot assertion. Function expressions, arrow functions, methods, and accessors establish groups that include nested helper expressions, while test and suite registrations reset the grouping boundary. Function declarations do not establish separate groups.

For `toMatchSnapshot`, a single string literal or template without interpolation counts as a hint, and two arguments count as properties plus a hint. A single variable or interpolated template is reported, regardless of its type. `toThrowErrorMatchingSnapshot` accepts a single hint argument. This rule does not require type information or provide fixes.

## Incorrect

```js
test('cart totals', () => {
expect(cart.subtotal).toMatchSnapshot();
expect(cart.total).toMatchSnapshot();
});
```

## Correct

```js
test('cart totals', () => {
expect(cart.subtotal).toMatchSnapshot('before tax');
expect(cart.total).toMatchSnapshot('after tax');
});
```

## Options

```json
{
"jest/prefer-snapshot-hint": ["error", "always"]
}
```

## Original Documentation

- [eslint-plugin-jest: prefer-snapshot-hint](https://github.com/jest-community/eslint-plugin-jest/blob/v29.16.6/docs/rules/prefer-snapshot-hint.md)
- [Source code](https://github.com/jest-community/eslint-plugin-jest/blob/v29.16.6/src/rules/prefer-snapshot-hint.ts)

Large diffs are not rendered by default.

Large diffs are not rendered by default.

48 changes: 38 additions & 10 deletions internal/plugins/jest/utils/call_analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@ type jestCallParseResult struct {
// larger Rstest analysis has framework-specific provenance and execution-mode
// responsibilities that do not belong here.
type JestCallAnalysis struct {
ctx rule.RuleContext
fnCalls map[*ast.Node]jestCallParseResult
calls []*ast.Node
functions map[string]*ast.Node
indexed bool
callbackInfos map[*ast.Node]jestCallbackInfo
callbacks JestTestCallbacks
callbacksOK bool
ctx rule.RuleContext
fnCalls map[*ast.Node]jestCallParseResult
calls []*ast.Node
functions map[string]*ast.Node
indexed bool
callbackInfos map[*ast.Node]jestCallbackInfo
callbacks JestTestCallbacks
callbacksOK bool
registrationCallbacks map[*ast.Node]bool
registrationCallbacksOK bool
}

// GetJestCallAnalysis returns the analysis shared by every Jest rule
Expand Down Expand Up @@ -128,6 +130,33 @@ func (analysis *JestCallAnalysis) Callbacks() JestTestCallbacks {
return analysis.callbacks
}

// RegistrationCallbacks returns every function that a test or describe
// registration invokes as its callback. It includes inline and named
// callbacks, and reuses the analysis's cached call and function indexes.
func (analysis *JestCallAnalysis) RegistrationCallbacks() map[*ast.Node]bool {
if analysis.registrationCallbacksOK {
return analysis.registrationCallbacks
}
callbacks := map[*ast.Node]bool{}
analysis.indexSourceFile()
for _, node := range analysis.calls {
parsed := analysis.ParseFnCall(node)
if parsed == nil || (parsed.Kind != JestFnTypeTest && parsed.Kind != JestFnTypeDescribe) {
continue
}
info := analysis.testCallbackInfo(node)
if info.functionNode == nil && analysis.ctx.Refs == nil {
info.functionNode = analysis.fallbackCallbackFunction(info.name)
}
if info.functionNode != nil {
callbacks[info.functionNode] = true
Comment thread
elecmonkey marked this conversation as resolved.
}
}
analysis.registrationCallbacks = callbacks
analysis.registrationCallbacksOK = true
return analysis.registrationCallbacks
}

func (analysis *JestCallAnalysis) testCallbackInfo(node *ast.Node) jestCallbackInfo {
if info, ok := analysis.callbackInfos[node]; ok {
return info
Expand Down Expand Up @@ -177,8 +206,7 @@ func (analysis *JestCallAnalysis) indexSourceFile() {
if declaration != nil && declaration.Name() != nil &&
declaration.Name().Kind == ast.KindIdentifier &&
declaration.Initializer != nil {
initializer := ast.SkipParentheses(declaration.Initializer)
if ast.IsFunctionExpressionOrArrowFunction(initializer) {
if initializer := testCallbackInitializerFunction(declaration.Initializer); initializer != nil {
analysis.recordFunction(declaration.Name().Text(), initializer)
}
}
Expand Down
10 changes: 8 additions & 2 deletions internal/plugins/jest/utils/parse_jest_fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ func resolveNamedTestCallback(ctx rule.RuleContext, callExpr *ast.CallExpression
return jestCallbackInfo{}
}

callback := ast.SkipParentheses(callExpr.Arguments.Nodes[1])
callback := jestTestCallbackArgument(callExpr)
if callback == nil || ast.IsFunctionExpressionOrArrowFunction(callback) {
return jestCallbackInfo{}
}
Expand All @@ -395,6 +395,12 @@ func resolveNamedTestCallback(ctx rule.RuleContext, callExpr *ast.CallExpression
}

name := callback.AsIdentifier().Text
if ctx.Refs != nil {
return jestCallbackInfo{
functionNode: testFramework.LocalFunctionImplementation(ctx.SourceFile, ctx.Refs, ctx.Refs.Resolve(callback)),
name: name,
}
}
decl := internalUtils.GetDeclaration(ctx.TypeChecker, callback)
if decl == nil {
return jestCallbackInfo{name: name}
Expand Down Expand Up @@ -425,7 +431,7 @@ func testCallbackInitializerFunction(initializer *ast.Node) *ast.Node {
if initializer == nil {
return nil
}
init := ast.SkipParentheses(initializer)
init := internalUtils.SkipAssertionsAndParens(initializer)
if ast.IsFunctionExpressionOrArrowFunction(init) {
return init
}
Expand Down
13 changes: 9 additions & 4 deletions internal/plugins/jest/utils/test_callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/microsoft/TypeScript/tsc/shim/ast"
"github.com/web-infra-dev/rslint/internal/rule"
internalUtils "github.com/web-infra-dev/rslint/internal/utils"
)

type JestTestCallbacks struct {
Expand Down Expand Up @@ -57,10 +58,7 @@ func resolveJestTestCallback(
ctx rule.RuleContext,
call *ast.CallExpression,
) jestCallbackInfo {
if call == nil || call.Arguments == nil || len(call.Arguments.Nodes) < 2 {
return jestCallbackInfo{}
}
callback := ast.SkipParentheses(call.Arguments.Nodes[1])
callback := jestTestCallbackArgument(call)
if callback == nil {
return jestCallbackInfo{}
}
Expand All @@ -70,6 +68,13 @@ func resolveJestTestCallback(
return resolveNamedTestCallback(ctx, call)
}

func jestTestCallbackArgument(call *ast.CallExpression) *ast.Node {
if call == nil || call.Arguments == nil || len(call.Arguments.Nodes) < 2 {
return nil
}
return internalUtils.SkipAssertionsAndParens(call.Arguments.Nodes[1])
}

func isDoneAmbiguousJestCallback(
callNode *ast.Node,
parsed *ParsedJestFnCall,
Expand Down
2 changes: 2 additions & 0 deletions internal/plugins/rstest/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import (
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_mock_promise_shorthand"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_mock_return_shorthand"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_rs_mocked"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_snapshot_hint"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_spy_on"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_strict_boolean_matchers"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/rules/prefer_strict_equal"
Expand Down Expand Up @@ -144,6 +145,7 @@ func GetAllRules() []rule.Rule {
prefer_mock_promise_shorthand.PreferMockPromiseShorthandRule,
prefer_mock_return_shorthand.PreferMockReturnShorthandRule,
prefer_rs_mocked.PreferRsMockedRule,
prefer_snapshot_hint.PreferSnapshotHintRule,
prefer_spy_on.PreferSpyOnRule,
prefer_strict_boolean_matchers.PreferStrictBooleanMatchersRule,
prefer_strict_equal.PreferStrictEqualRule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package prefer_snapshot_hint

import (
"github.com/microsoft/TypeScript/tsc/shim/ast"
"github.com/web-infra-dev/rslint/internal/plugins/rstest/utils"
"github.com/web-infra-dev/rslint/internal/rule"
shared "github.com/web-infra-dev/rslint/internal/utils/test_framework/rules/prefer_snapshot_hint"
)

var PreferSnapshotHintRule = shared.NewRule(shared.Config{
Name: "rstest/prefer-snapshot-hint",
AllowInterpolatedHints: true,
Prepare: func(ctx rule.RuleContext) shared.Runtime {
analysis := utils.GetRstestCallAnalysis(ctx)
return shared.Runtime{
RegistrationCallbacks: analysis.RegistrationCallbacks(),
IsRegistration: func(node *ast.Node) bool {
parsed := analysis.ParseFnCall(node)
return parsed != nil && (parsed.Kind == utils.RstestFnTypeTest || parsed.Kind == utils.RstestFnTypeDescribe)
},
Snapshots: func(node *ast.Node) []shared.Snapshot {
parsed := analysis.ParseExpectCall(node)
if parsed == nil || parsed.Head == nil ||
parsed.Entry == utils.RstestExpectEntryPoll || parsed.Entry == utils.RstestExpectEntryElement {
return nil
}
var snapshots []shared.Snapshot
for _, matcher := range parsed.Matchers {
if matcher.Negated || matcher.Entry.Call == nil ||
(matcher.Name != "toMatchSnapshot" && matcher.Name != "matchSnapshot" && matcher.Name != "toThrowErrorMatchingSnapshot") {
continue
}
snapshots = append(snapshots, shared.Snapshot{
Matcher: matcher.Entry.Node,
Args: matcher.Entry.Call.Arguments(),
Properties: matcher.Name != "toThrowErrorMatchingSnapshot",
})
}
return snapshots
},
}
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# prefer-snapshot-hint

## Rule Details

Prefer descriptive hints for external snapshots so their names explain what each snapshot captures. By default, hints are required when a group contains more than one external snapshot assertion. See [Rstest snapshot naming](https://rstest.rs/guide/basic/snapshot#use-descriptive-snapshot-names).

The rule checks `toMatchSnapshot`, its `matchSnapshot` alias, and `toThrowErrorMatchingSnapshot`, including Chai assertion chains, `expect.soft`, and `resolves`/`rejects`. It recognizes globals, named and renamed imports, namespace imports, CommonJS imports, `rstack/test`, `import.meta.rstest`, and test-context `expect`, including parameterized and extended tests. The Rstest assertions exposed by `@rstest/playwright` are also checked. Locally shadowed or reassigned assertion bindings are ignored. Static bracket access and optional calls are recognized. Inline snapshots, file snapshots, static `expect` methods, negated snapshots, and `expect.poll`/`expect.element` chains are excluded. An assertion wrapped with `as`, `satisfies`, or a non-null assertion before the matcher access is not inspected.

Groups follow function expressions, arrow functions, methods, and accessors, including nested helper expressions. Each test or suite registration starts a new grouping boundary. Function declarations do not create a separate group, and helper calls are not followed to count runtime assertions. A hinted snapshot still contributes to its group's count.

For `toMatchSnapshot` and `matchSnapshot`, a single string literal or template literal counts as a hint; two arguments count as properties plus a hint. A single variable or other expression is treated as properties, even if its type is `string`. `toThrowErrorMatchingSnapshot` accepts one hint argument. The rule does not validate hint contents or argument types and does not require type information.

## Incorrect

```ts
import { expect, test } from '@rstest/core';

test('cart totals', () => {
expect(cart.subtotal).toMatchSnapshot();
expect(cart.total).toMatchSnapshot();
});
```

## Correct

```ts
import { expect, test } from '@rstest/core';

test('cart totals', () => {
expect(cart.subtotal).toMatchSnapshot('before tax');
expect(cart.total).toMatchSnapshot('after tax');
});
```

## Options

```json
{
"rstest/prefer-snapshot-hint": ["error", "always"]
}
```

| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| mode | string | `"multi"` | Use `"always"` to require hints for every external snapshot, or `"multi"` for groups with multiple external snapshots. |
Loading
Loading