diff --git a/internal/plugins/jest/all.go b/internal/plugins/jest/all.go index b1846f0553..c07b101b64 100644 --- a/internal/plugins/jest/all.go +++ b/internal/plugins/jest/all.go @@ -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" @@ -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, diff --git a/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go new file mode 100644 index 0000000000..45f379fc64 --- /dev/null +++ b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go @@ -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"}} + }, + } + }, +}) diff --git a/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md new file mode 100644 index 0000000000..ff1f71604a --- /dev/null +++ b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md @@ -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) diff --git a/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go new file mode 100644 index 0000000000..246996e5f4 --- /dev/null +++ b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go @@ -0,0 +1,281 @@ +// Rule-specific branch lock-ins, real-user cases, and AST/provenance extras. +// The complete upstream suites live in prefer_snapshot_hint_upstream_test.go. +// N/A: private matcher names, JSX containers, fixes and suggestions. +package prefer_snapshot_hint + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/shim/ast" + "github.com/microsoft/TypeScript/tsc/shim/core" + "github.com/microsoft/TypeScript/tsc/shim/tspath" + "github.com/web-infra-dev/rslint/internal/linter" + "github.com/web-infra-dev/rslint/internal/plugins/jest/fixtures" + lintprogram "github.com/web-infra-dev/rslint/internal/program" + "github.com/web-infra-dev/rslint/internal/rule" + "github.com/web-infra-dev/rslint/internal/rule_tester" + "github.com/web-infra-dev/rslint/internal/utils" +) + +func TestPreferSnapshotHintExtras(t *testing.T) { + rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &PreferSnapshotHintRule, []rule_tester.ValidTestCase{ + // ---- Dimension 4: ESTree method values are function expressions ---- + {Code: "const checks = { first() { expect(value).toMatchSnapshot(); }, second() { expect(other).toMatchSnapshot(); } };", Options: []any{"multi"}}, + {Code: "class Checks { constructor() { expect(value).toMatchSnapshot(); } second() { expect(other).toMatchSnapshot(); } }", Options: []any{"multi"}}, + {Code: "const checks = { get value() { expect(value).toMatchSnapshot(); }, set value(next) { expect(next).toMatchSnapshot(); } };", Options: []any{"multi"}}, + + // Locks in missingHint: throw one argument; match two arguments + {Code: "expect(run).toThrowErrorMatchingSnapshot(hint); expect(value).toMatchSnapshot(properties, hint);", Options: []any{"always"}}, + // Locks in missingHint: string and static template + {Code: "expect(value).toMatchSnapshot(\"\"); expect(value).toMatchSnapshot(`snapshot`);", Options: []any{"always"}}, + // Locks in missingHint: excess arguments with first string + {Code: "expect(value).toMatchSnapshot(\"hint\", extra, more);", Options: []any{"always"}}, + // Locks in create: non-assertion, non-snapshot, dynamic accessor, shadow + {Code: "other(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "expect(value);", Options: []any{"always"}}, + {Code: "expect(value).toMatchInlineSnapshot(); expect(run).toThrowErrorMatchingInlineSnapshot();", Options: []any{"always"}}, + + {Code: "expect(value).toMatchSnapshot;", Options: []any{"always"}}, + {Code: "expect(value)[matcher]();", Options: []any{"always"}}, + {Code: "expect(value)[`to${name}`]();", Options: []any{"always"}}, + {Code: "expect(value)[123]();", Options: []any{"always"}}, + {Code: "function helper(expect) { expect(value).toMatchSnapshot(); }", Options: []any{"always"}}, + // ---- Dimension 4: parenthesized hint ---- + {Code: "expect(value).toMatchSnapshot((\"hint\"));", Options: []any{"always"}}, + // ---- Dimension 4: TS assertion-chain wrappers remain parser boundaries ---- + {Code: "expect(value)!.toMatchSnapshot();", Options: []any{"always"}}, + {Code: "(expect(value) as Assertion).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "(expect(value) satisfies Assertion).toMatchSnapshot();", Options: []any{"always"}}, + // Locks in multi: single snapshot + {Code: "expect(value).toMatchSnapshot();"}, + // Locks in multi: separate function expressions + {Code: "const first = function() { expect(value).toMatchSnapshot(); }; const second = () => { expect(other).toMatchSnapshot(); };", Options: []any{"multi"}}, + // ---- Real-user: jest-community/eslint-plugin-jest#1068, primitive and array hints ---- + {Code: "test(\"compiler errors\", async () => { const errors = await compile(); expect(errors.length).toMatchSnapshot(\"error count\"); expect(errors).toMatchSnapshot(\"errors\"); });", Options: []any{"multi"}}, + // ---- Real-user: jest-community/eslint-plugin-jest#1074, sibling test groups ---- + {Code: "describe(\"compiler\", () => { it(\"count\", () => { expect(count).toMatchSnapshot(); }); it(\"details\", () => { expect(errors).toMatchSnapshot(\"errors\"); expect(warnings).toMatchSnapshot(\"warnings\"); }); });", Options: []any{"multi"}}, + // Locks in registration: no callback keeps enter/exit balanced + {Code: "test.todo(\"later\"); test(\"single\", () => { expect(value).toMatchSnapshot(); });", Options: []any{"multi"}}, + // Renamed registration resets scope + {Code: "import { test as check } from \"@jest/globals\"; describe(\"suite\", () => { check(\"one\", () => { expect(value).toMatchSnapshot(); }); check(\"two\", () => { expect(value).toMatchSnapshot(); }); });", Options: []any{"multi"}}, + // ---- Real-user: named callbacks registered as distinct tests keep distinct groups ---- + {Code: "const register = () => { const first = () => expect('first').toMatchSnapshot(); test('first', first); const second = () => expect('second').toMatchSnapshot(); test('second', second); }; describe('suite', register);", Options: []any{"multi"}}, + {Code: "const callback = () => expect('test').toMatchSnapshot(); test('case', callback); function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + {Code: "test('case', callback); function callback() { expect('test').toMatchSnapshot(); } function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + // A TypeScript overload set resolves to its unique implementation body. + {Code: "test('case', callback); function callback(): void; function callback() { expect('test').toMatchSnapshot(); } function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + // Runtime-transparent TypeScript wrappers preserve callback identity. + {Code: "describe('suite', () => { const a = () => expect(1).toMatchSnapshot(); const b = () => expect(2).toMatchSnapshot(); test('a', a as () => void); test('b', b as () => void); });", Options: []any{"multi"}}, + {Code: "describe('suite', () => { const a = () => expect(1).toMatchSnapshot(); const b = () => expect(2).toMatchSnapshot(); test('a', a!); test('b', b satisfies () => void); });", Options: []any{"multi"}}, + // Promise continuations do not duplicate a hinted snapshot matcher. + {Code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').then(() => {});", Options: []any{"always"}}, + {Code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').catch(() => {});", Options: []any{"always"}}, + {Code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').finally(() => {});", Options: []any{"always"}}, + {Code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot().then(() => {});", Options: []any{"multi"}}, + }, []rule_tester.InvalidTestCase{ + // The upstream parser treats this static-looking call as an expect matcher. + {Code: "expect.toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{{MessageId: "missingHint", Line: 1, Column: 8, EndLine: 1, EndColumn: 23}}}, + // Locks in missingHint: non-string single argument + {Code: "expect(value).toMatchSnapshot({});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(hint);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(null);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(undefined);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(123);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(true);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(/hint/);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(...hints);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // Locks in missingHint: zero or excess arguments + {Code: "expect(value).toThrowErrorMatchingSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 43}, + }}, + {Code: "expect(value).toThrowErrorMatchingSnapshot(\"hint\", true);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 43}, + }}, + {Code: "expect(value).toMatchSnapshot({}, \"hint\", 3);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // ---- Dimension 4: parentheses, optional call, computed key, types, trivia, Unicode ---- + {Code: "(expect(value)).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 17, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect?.(value)?.toMatchSnapshot?.();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 18, EndLine: 1, EndColumn: 33}, + }}, + {Code: "expect(value)[\"toMatchSnapshot\"]();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect(value)[`toMatchSnapshot`]();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value) /* comment */ .toMatchSnapshot(/* properties */ {});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 30, EndLine: 1, EndColumn: 45}, + }}, + {Code: "// 中文\nexpect(\"用户\").toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 14, EndLine: 2, EndColumn: 29}, + }}, + {Code: "expect(value).\n toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 3, EndLine: 2, EndColumn: 18}, + }}, + // ---- Dimension 4: TS hint wrapper is not a string literal ---- + {Code: "expect(value).toMatchSnapshot(\"hint\" as const);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // ---- Dimension 4: default mode and source-file flush ---- + {Code: "expect(a).toMatchSnapshot(); expect(b).toMatchSnapshot();", Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 11, EndLine: 1, EndColumn: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 40, EndLine: 1, EndColumn: 55}, + }}, + // Locks in multi: nested helpers contribute to enclosing expression + {Code: "const body = () => { const inner = function() { expect(value).toMatchSnapshot(); }; expect(other).toMatchSnapshot(); };", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 63, EndLine: 1, EndColumn: 78}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 99, EndLine: 1, EndColumn: 114}, + }}, + // Locks in multi: function declarations do not create expression boundaries + {Code: "function first() { expect(value).toMatchSnapshot(); } function second() { expect(other).toMatchSnapshot(); }", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 34, EndLine: 1, EndColumn: 49}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 89, EndLine: 1, EndColumn: 104}, + }}, + // Registration isolation preserves the enclosing function's own group. + {Code: "const helper = () => {\n expect('before').toMatchSnapshot();\n test('inner', () => {\n expect('inner').toMatchSnapshot();\n });\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 6, Column: 19, EndLine: 6, EndColumn: 34}, + }}, + // A reassigned binding does not make its stale initializer a test callback. + {Code: "const outer = () => {\n expect('before').toMatchSnapshot();\n let callback = () => {\n expect('stale').toMatchSnapshot();\n };\n callback = () => {};\n test('case', callback);\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 21, EndLine: 4, EndColumn: 36}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 19, EndLine: 8, EndColumn: 34}, + }}, + // Same source name in different blocks resolves by binding identity. + {Code: "const outer = () => {\n expect('before').toMatchSnapshot();\n { const callback = () => expect('one').toMatchSnapshot(); test('one', callback); }\n { const callback = () => expect('two').toMatchSnapshot(); test('two', callback); }\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 5, Column: 19, EndLine: 5, EndColumn: 34}, + }}, + // The real matcher remains without a hint when followed by a continuation. + {Code: "await expect(Promise.resolve(1))\n .resolves.toMatchSnapshot()\n .then(() => {});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13, EndLine: 2, EndColumn: 28}, + }}, + // Locks in registration: parameterized tests + {Code: "test.each([1, 2])(\"row\", value => { expect(value).toMatchSnapshot(); expect(value).toThrowErrorMatchingSnapshot(); });", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 51, EndLine: 1, EndColumn: 66}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 84, EndLine: 1, EndColumn: 112}, + }}, + // Locks in registration: tagged template + {Code: "test.each`value\n${1}`(\"row\", value => { expect(value).toMatchSnapshot(); expect(value).toMatchSnapshot(); });", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 39, EndLine: 2, EndColumn: 54}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 72, EndLine: 2, EndColumn: 87}, + }}, + // Named and renamed expect imports + {Code: "import { expect as check } from \"@jest/globals\"; check(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 63, EndLine: 1, EndColumn: 78}, + }}, + // Locks in upstream: interpolated hint is not a static string + {Code: "expect(value).toMatchSnapshot(`state ${name}`);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // Locks in upstream: not is still checked + {Code: "expect(value).not.toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 19, EndLine: 1, EndColumn: 34}, + }}, + }) +} + +// Binding resolution must work without a TypeChecker as well as in the main suite. +func TestPreferSnapshotHintSourceOnly(t *testing.T) { + for _, code := range []string{ + `expect(value).toMatchSnapshot();`, + `import { expect as check } from "@jest/globals"; check(value).toMatchSnapshot();`, + `expect(value).toMatchSnapshot(); function helper(expect) { expect(other).toMatchSnapshot(); }`, + } { + t.Run(code, func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, code, []any{"always"}, 1) + }) + } + t.Run("registered named callbacks have separate groups", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +const register = () => { + const first = () => expect('first').toMatchSnapshot(); + test('first', first); + const second = () => expect('second').toMatchSnapshot(); + test('second', second); +}; +describe('suite', register);`, []any{"multi"}, 0) + }) + t.Run("hoisted registered callback has its own group", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +test('case', callback); +function callback() { expect('test').toMatchSnapshot(); } +function helper() { expect('helper').toMatchSnapshot(); }`, []any{"multi"}, 0) + }) + t.Run("overloaded registered callback has its own group", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +test('case', callback); +function callback(): void; +function callback() { expect('test').toMatchSnapshot(); } +function helper() { expect('helper').toMatchSnapshot(); }`, []any{"multi"}, 0) + }) + t.Run("registered callback unwraps TypeScript expressions", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +describe('suite', () => { + const a = () => expect(1).toMatchSnapshot(); + const b = () => expect(2).toMatchSnapshot(); + test('a', a as () => void); + test('b', b satisfies () => void); +});`, []any{"multi"}, 0) + }) +} + +func runPreferSnapshotHintSourceOnly(t *testing.T, code string, options []any, want int) { + t.Helper() + root := fixtures.GetRootDir() + name := tspath.ResolvePath(root.Dir, "snapshot-hint-source-only.ts") + host := utils.CreateCompilerHost(root.Dir, utils.NewOverlayVFS(root.FS, map[string]string{name: code})) + program, err := lintprogram.NewFromRoots(lintprogram.RootOptions{RootFileNames: []string{name}, Host: host, CompilerOptions: &core.CompilerOptions{Module: core.ModuleKindESNext}, SingleThreaded: true}) + if err != nil { + t.Fatal(err) + } + if program.CanProvideTypeChecker(program.SourceFiles()[0]) { + t.Fatal("expected source-only program") + } + plan, err := linter.PrepareLintPlan(linter.PrepareLintPlanOptions{ + Programs: []*lintprogram.Program{program}, TargetsByProgram: [][]string{{name}}, SingleThreaded: true, + GetRulesForFile: func(*ast.SourceFile) []rule.ConfiguredRule { + return []rule.ConfiguredRule{{Name: PreferSnapshotHintRule.Name, Severity: rule.SeverityError, Run: func(ctx rule.RuleContext) rule.RuleListeners { return PreferSnapshotHintRule.Run(ctx, options) }}} + }, + }) + if err != nil { + t.Fatal(err) + } + count := 0 + _, err = linter.RunLinter(linter.RunLinterOptions{LintPlan: plan, SingleThreaded: true, Consumer: rule.DiagnosticConsumer{Report: func(d rule.RuleDiagnostic) { + count++ + if got := code[d.Range.Pos():d.Range.End()]; got != "toMatchSnapshot" { + t.Errorf("unexpected diagnostic range: %q", got) + } + }}}) + if err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("got %d diagnostics, want %d", count, want) + } +} diff --git a/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go new file mode 100644 index 0000000000..7abe0611bf --- /dev/null +++ b/internal/plugins/jest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go @@ -0,0 +1,167 @@ +// Complete jest upstream suite; additions live in prefer_snapshot_hint_extras_test.go. +package prefer_snapshot_hint + +import ( + "testing" + + "github.com/web-infra-dev/rslint/internal/plugins/jest/fixtures" + "github.com/web-infra-dev/rslint/internal/rule_tester" +) + +func TestPreferSnapshotHintUpstream0(t *testing.T) { + rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &PreferSnapshotHintRule, []rule_tester.ValidTestCase{ + {Code: "expect(something).toStrictEqual(somethingElse);", Options: []any{"always"}}, + {Code: "a().toEqual('b')", Options: []any{"always"}}, + {Code: "expect(a);", Options: []any{"always"}}, + {Code: "expect(1).toMatchSnapshot({}, \"my snapshot\");", Options: []any{"always"}}, + {Code: "expect(1).toMatchSnapshot(\"my snapshot\");", Options: []any{"always"}}, + {Code: "expect(1).toMatchSnapshot(`my snapshot`);", Options: []any{"always"}}, + {Code: "const x = {};\nexpect(1).toMatchSnapshot(x, \"my snapshot\");", Options: []any{"always"}}, + {Code: "expect(1).toThrowErrorMatchingSnapshot(\"my snapshot\");", Options: []any{"always"}}, + {Code: "expect(1).toMatchInlineSnapshot();", Options: []any{"always"}}, + {Code: "expect(1).toThrowErrorMatchingInlineSnapshot();", Options: []any{"always"}}, + }, []rule_tester.InvalidTestCase{ + {Code: "expect(1).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 11}, + }}, + {Code: "expect(1).toMatchSnapshot({});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 11}, + }}, + {Code: "const x = \"we can't know if this is a string or not\";\nexpect(1).toMatchSnapshot(x);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 11}, + }}, + {Code: "expect(1).toThrowErrorMatchingSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 11}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot(\"my error\");\n});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + }}, + {Code: "const expectSnapshot = value => {\n expect(value).toMatchSnapshot();\n};", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 17}, + }}, + {Code: "const expectSnapshot = value => {\n expect(value).toThrowErrorMatchingSnapshot();\n};", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 17}, + }}, + {Code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 15}, + }}, + {Code: "const x = \"snapshot\";\nexpect(1).toMatchSnapshot(`my ${x}`);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 11}, + }}, + }) +} +func TestPreferSnapshotHintUpstream1(t *testing.T) { + rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &PreferSnapshotHintRule, []rule_tester.ValidTestCase{ + {Code: "expect(something).toStrictEqual(somethingElse);", Options: []any{"multi"}}, + {Code: "a().toEqual('b')", Options: []any{"multi"}}, + {Code: "expect(a);", Options: []any{"multi"}}, + {Code: "expect(1).toMatchSnapshot({}, \"my snapshot\");", Options: []any{"multi"}}, + {Code: "expect(1).toThrowErrorMatchingSnapshot(\"my snapshot\");", Options: []any{"multi"}}, + {Code: "expect(1).toMatchSnapshot({});", Options: []any{"multi"}}, + {Code: "expect(1).toThrowErrorMatchingSnapshot();", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot(undefined, 'my first snapshot');\n});", Options: []any{"multi"}}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot('this is a hint, all by itself');\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(2).toThrowErrorMatchingSnapshot();\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(2).toThrowErrorMatchingSnapshot();\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchInlineSnapshot();\n});\n\nit('is false', () => {\n expect(1).toMatchInlineSnapshot();\n expect(1).toMatchInlineSnapshot();\n expect(1).toThrowErrorMatchingInlineSnapshot();\n});", Options: []any{"multi"}}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}}, + {Code: "import { it as itIs } from '@jest/globals';\n\nit('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nitIs('false', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n expect(anotherValue).toMatchSnapshot();\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n};\n\nexpect(1).toMatchSnapshot();", Options: []any{"multi"}}, + }, []rule_tester.InvalidTestCase{ + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toThrowErrorMatchingSnapshot();\n expect(2).toMatchSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot({});\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n {\n expect(2).toMatchSnapshot({});\n }\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 15}, + }}, + {Code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n { expect(2).toMatchSnapshot(); }\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 15}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 15}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot({}, 'my second snapshot');\n expect(2).toMatchSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 13}, + }}, + {Code: "it('is true', () => {\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot(null, 'my third snapshot');\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 13}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 13}, + }}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 7, Column: 15}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 15}, + }}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 7, Column: 15}, + }}, + {Code: "describe('my tests', () => {\n describe('more tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 9, Column: 15}, + }}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n describe('more tests', () => {\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 17}, + }}, + {Code: "import { describe as context, it as itIs } from '@jest/globals';\n\ndescribe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n context('more tests', () => {\n itIs('false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 10, Column: 17}, + }}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n expect(value).toMatchSnapshot();\n\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n };\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toThrowErrorMatchingSnapshot(snapshotHint);\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 17}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 5, Column: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 9, Column: 21}, + }}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n expect(value).toMatchSnapshot();\n\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toMatchSnapshot(null, snapshotHint);\n };\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 17}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 5, Column: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 23}, + }}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toMatchSnapshot(null, snapshotHint);\n };\n\n expect(value).toThrowErrorMatchingSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 6, Column: 23}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 10, Column: 17}, + }}, + {Code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toMatchSnapshot();\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 17}, + }}, + {Code: "const myReusableTestBody = value => {\n expect(value).toMatchSnapshot();\n};\n\nexpect(1).toMatchSnapshot();\nexpect(1).toThrowErrorMatchingSnapshot();", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 5, Column: 11}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 6, Column: 11}, + }}, + }) +} diff --git a/internal/plugins/jest/utils/call_analysis.go b/internal/plugins/jest/utils/call_analysis.go index 836583733e..82b516ecaf 100644 --- a/internal/plugins/jest/utils/call_analysis.go +++ b/internal/plugins/jest/utils/call_analysis.go @@ -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 @@ -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 + } + } + 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 @@ -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) } } diff --git a/internal/plugins/jest/utils/parse_jest_fn.go b/internal/plugins/jest/utils/parse_jest_fn.go index 4de99eb9ec..f8002f5b08 100644 --- a/internal/plugins/jest/utils/parse_jest_fn.go +++ b/internal/plugins/jest/utils/parse_jest_fn.go @@ -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{} } @@ -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} @@ -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 } diff --git a/internal/plugins/jest/utils/test_callback.go b/internal/plugins/jest/utils/test_callback.go index 3638e3a0a7..56b98ec17a 100644 --- a/internal/plugins/jest/utils/test_callback.go +++ b/internal/plugins/jest/utils/test_callback.go @@ -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 { @@ -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{} } @@ -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, diff --git a/internal/plugins/rstest/all.go b/internal/plugins/rstest/all.go index 1625a53ad2..cc882ddec2 100644 --- a/internal/plugins/rstest/all.go +++ b/internal/plugins/rstest/all.go @@ -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" @@ -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, diff --git a/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go new file mode 100644 index 0000000000..11676ae72a --- /dev/null +++ b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.go @@ -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 + }, + } + }, +}) diff --git a/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md new file mode 100644 index 0000000000..e0883a8f2d --- /dev/null +++ b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint.md @@ -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. | diff --git a/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go new file mode 100644 index 0000000000..cd2160908c --- /dev/null +++ b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_extras_test.go @@ -0,0 +1,334 @@ +// Rule-specific branch lock-ins, real-user cases, and AST/provenance extras. +// The complete upstream suites live in prefer_snapshot_hint_upstream_test.go. +// N/A: private matcher names, JSX containers, fixes and suggestions. +package prefer_snapshot_hint + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/shim/ast" + "github.com/microsoft/TypeScript/tsc/shim/core" + "github.com/microsoft/TypeScript/tsc/shim/tspath" + "github.com/web-infra-dev/rslint/internal/linter" + "github.com/web-infra-dev/rslint/internal/plugins/rstest/fixtures" + lintprogram "github.com/web-infra-dev/rslint/internal/program" + "github.com/web-infra-dev/rslint/internal/rule" + "github.com/web-infra-dev/rslint/internal/rule_tester" + "github.com/web-infra-dev/rslint/internal/utils" +) + +func TestPreferSnapshotHintExtras(t *testing.T) { + rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &PreferSnapshotHintRule, []rule_tester.ValidTestCase{ + // ---- Dimension 4: ESTree method values are function expressions ---- + {Code: "const checks = { first() { expect(value).toMatchSnapshot(); }, second() { expect(other).toMatchSnapshot(); } };", Options: []any{"multi"}}, + {Code: "class Checks { constructor() { expect(value).toMatchSnapshot(); } second() { expect(other).toMatchSnapshot(); } }", Options: []any{"multi"}}, + {Code: "const checks = { get value() { expect(value).toMatchSnapshot(); }, set value(next) { expect(next).toMatchSnapshot(); } };", Options: []any{"multi"}}, + + // Locks in missingHint: throw one argument; match two arguments + {Code: "expect(run).toThrowErrorMatchingSnapshot(hint); expect(value).toMatchSnapshot(properties, hint);", Options: []any{"always"}}, + // Locks in missingHint: string and static template + {Code: "expect(value).toMatchSnapshot(\"\"); expect(value).toMatchSnapshot(`snapshot`);", Options: []any{"always"}}, + // Locks in missingHint: excess arguments with first string + {Code: "expect(value).toMatchSnapshot(\"hint\", extra, more);", Options: []any{"always"}}, + // Locks in create: non-assertion, non-snapshot, dynamic accessor, shadow + {Code: "other(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "expect(value);", Options: []any{"always"}}, + {Code: "expect(value).toMatchInlineSnapshot(); expect(run).toThrowErrorMatchingInlineSnapshot();", Options: []any{"always"}}, + {Code: "expect.toMatchSnapshot();", Options: []any{"always"}}, + {Code: "expect(value).toMatchSnapshot;", Options: []any{"always"}}, + {Code: "expect(value)[matcher]();", Options: []any{"always"}}, + {Code: "expect(value)[`to${name}`]();", Options: []any{"always"}}, + {Code: "expect(value)[123]();", Options: []any{"always"}}, + {Code: "function helper(expect) { expect(value).toMatchSnapshot(); }", Options: []any{"always"}}, + // ---- Dimension 4: parenthesized hint ---- + {Code: "expect(value).toMatchSnapshot((\"hint\"));", Options: []any{"always"}}, + // ---- Dimension 4: TS assertion-chain wrappers remain parser boundaries ---- + {Code: "expect(value)!.toMatchSnapshot();", Options: []any{"always"}}, + {Code: "(expect(value) as Assertion).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "(expect(value) satisfies Assertion).toMatchSnapshot();", Options: []any{"always"}}, + // Locks in multi: single snapshot + {Code: "expect(value).toMatchSnapshot();"}, + // Locks in multi: separate function expressions + {Code: "const first = function() { expect(value).toMatchSnapshot(); }; const second = () => { expect(other).toMatchSnapshot(); };", Options: []any{"multi"}}, + // ---- Real-user: jest-community/eslint-plugin-jest#1068, primitive and array hints ---- + {Code: "test(\"compiler errors\", async () => { const errors = await compile(); expect(errors.length).toMatchSnapshot(\"error count\"); expect(errors).toMatchSnapshot(\"errors\"); });", Options: []any{"multi"}}, + // ---- Real-user: jest-community/eslint-plugin-jest#1074, sibling test groups ---- + {Code: "describe(\"compiler\", () => { it(\"count\", () => { expect(count).toMatchSnapshot(); }); it(\"details\", () => { expect(errors).toMatchSnapshot(\"errors\"); expect(warnings).toMatchSnapshot(\"warnings\"); }); });", Options: []any{"multi"}}, + // Locks in registration: no callback keeps enter/exit balanced + {Code: "test.todo(\"later\"); test(\"single\", () => { expect(value).toMatchSnapshot(); });", Options: []any{"multi"}}, + // Renamed registration resets scope + {Code: "import { test as check } from \"@rstest/core\"; describe(\"suite\", () => { check(\"one\", () => { expect(value).toMatchSnapshot(); }); check(\"two\", () => { expect(value).toMatchSnapshot(); }); });", Options: []any{"multi"}}, + // Runtime string hint extension + {Code: "expect(value).toMatchSnapshot(`state ${name}`);", Options: []any{"always"}}, + // Runtime rejects polling and negated snapshots + {Code: "expect.poll(() => value).toMatchSnapshot(); expect(value).not.toMatchSnapshot(); expect.element(locator).toMatchSnapshot();", Options: []any{"always"}}, + // File snapshot path is not a hint + {Code: "expect(value).toMatchFileSnapshot(\"state.txt\");", Options: []any{"always"}}, + // Provenance excludes foreign, written, and type-only bindings + {Code: "import { expect } from \"vitest\"; expect(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "import { expect } from \"@jest/globals\"; expect(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "import { expect } from \"@playwright/test\"; expect(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "const { expect } = require(\"@rstest/core\"); expect = other; expect(value).toMatchSnapshot();", Options: []any{"always"}}, + {Code: "import type { expect as check } from \"@rstest/core\"; check(value).toMatchSnapshot();", Options: []any{"always"}}, + // Rstest conditional registrations isolate snapshots + {Code: "describe(\"suite\", () => { test.runIf(enabled)(\"one\", () => { expect(value).toMatchSnapshot(); }); test.skipIf(disabled)(\"two\", () => { expect(value).toMatchSnapshot(); }); });", Options: []any{"multi"}}, + // ---- Real-user: named callbacks registered as distinct tests keep distinct groups ---- + {Code: "const register = () => { const first = () => expect('first').toMatchSnapshot(); test('first', first); const second = () => expect('second').toMatchSnapshot(); test('second', second); }; describe('suite', register);", Options: []any{"multi"}}, + {Code: "const callback = () => expect('test').toMatchSnapshot(); test('case', callback); function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + {Code: "test('case', callback); function callback() { expect('test').toMatchSnapshot(); } function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + // A TypeScript overload set resolves to its unique implementation body. + {Code: "test('case', callback); function callback(): void; function callback() { expect('test').toMatchSnapshot(); } function helper() { expect('helper').toMatchSnapshot(); }", Options: []any{"multi"}}, + // A negated snapshot later in a Chai chain is excluded independently. + {Code: "expect('x').toBe('x').and.not.toMatchSnapshot();", Options: []any{"always"}}, + {Code: "expect('x').not.toBe('y').and.toMatchSnapshot();", Options: []any{"always"}}, + {Code: "expect('x').toBe('x').and.not.toMatchSnapshot().and.toMatchSnapshot();", Options: []any{"always"}}, + }, []rule_tester.InvalidTestCase{ + // Locks in missingHint: non-string single argument + {Code: "expect(value).toMatchSnapshot({});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(hint);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(null);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(undefined);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(123);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(true);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(/hint/);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value).toMatchSnapshot(...hints);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // Locks in missingHint: zero or excess arguments + {Code: "expect(value).toThrowErrorMatchingSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 43}, + }}, + {Code: "expect(value).toThrowErrorMatchingSnapshot(\"hint\", true);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 43}, + }}, + {Code: "expect(value).toMatchSnapshot({}, \"hint\", 3);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // ---- Dimension 4: parentheses, optional call, computed key, types, trivia, Unicode ---- + {Code: "(expect(value)).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 17, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect?.(value)?.toMatchSnapshot?.();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 18, EndLine: 1, EndColumn: 33}, + }}, + {Code: "expect(value)[\"toMatchSnapshot\"]();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect(value)[`toMatchSnapshot`]();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 32}, + }}, + {Code: "expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + {Code: "expect(value) /* comment */ .toMatchSnapshot(/* properties */ {});", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 30, EndLine: 1, EndColumn: 45}, + }}, + {Code: "// 中文\nexpect(\"用户\").toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 14, EndLine: 2, EndColumn: 29}, + }}, + {Code: "expect(value).\n toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 3, EndLine: 2, EndColumn: 18}, + }}, + // ---- Dimension 4: TS hint wrapper is not a string literal ---- + {Code: "expect(value).toMatchSnapshot(\"hint\" as const);", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + // ---- Dimension 4: default mode and source-file flush ---- + {Code: "expect(a).toMatchSnapshot(); expect(b).toMatchSnapshot();", Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 11, EndLine: 1, EndColumn: 26}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 40, EndLine: 1, EndColumn: 55}, + }}, + // Locks in multi: nested helpers contribute to enclosing expression + {Code: "const body = () => { const inner = function() { expect(value).toMatchSnapshot(); }; expect(other).toMatchSnapshot(); };", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 63, EndLine: 1, EndColumn: 78}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 99, EndLine: 1, EndColumn: 114}, + }}, + // Locks in multi: function declarations do not create expression boundaries + {Code: "function first() { expect(value).toMatchSnapshot(); } function second() { expect(other).toMatchSnapshot(); }", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 34, EndLine: 1, EndColumn: 49}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 89, EndLine: 1, EndColumn: 104}, + }}, + // Registration isolation preserves the enclosing function's own group. + {Code: "const helper = () => {\n expect('before').toMatchSnapshot();\n test('inner', () => {\n expect('inner').toMatchSnapshot();\n });\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 6, Column: 19, EndLine: 6, EndColumn: 34}, + }}, + // A reassigned binding does not make its stale initializer a test callback. + {Code: "const outer = () => {\n expect('before').toMatchSnapshot();\n let callback = () => {\n expect('stale').toMatchSnapshot();\n };\n callback = () => {};\n test('case', callback);\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 21, EndLine: 4, EndColumn: 36}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 19, EndLine: 8, EndColumn: 34}, + }}, + // Same source name in different blocks resolves by binding identity. + {Code: "const outer = () => {\n expect('before').toMatchSnapshot();\n { const callback = () => expect('one').toMatchSnapshot(); test('one', callback); }\n { const callback = () => expect('two').toMatchSnapshot(); test('two', callback); }\n expect('after').toMatchSnapshot();\n};", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20, EndLine: 2, EndColumn: 35}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 5, Column: 19, EndLine: 5, EndColumn: 34}, + }}, + // Locks in registration: parameterized tests + {Code: "test.each([1, 2])(\"row\", value => { expect(value).toMatchSnapshot(); expect(value).toThrowErrorMatchingSnapshot(); });", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 51, EndLine: 1, EndColumn: 66}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 84, EndLine: 1, EndColumn: 112}, + }}, + // Locks in registration: tagged template + {Code: "test.each`value\n${1}`(\"row\", value => { expect(value).toMatchSnapshot(); expect(value).toMatchSnapshot(); });", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 39, EndLine: 2, EndColumn: 54}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 72, EndLine: 2, EndColumn: 87}, + }}, + // Named and renamed expect imports + {Code: "import { expect as check } from \"@rstest/core\"; check(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 62, EndLine: 1, EndColumn: 77}, + }}, + // Rstest API: provenance, TestContext, soft, promises and Chai chains + {Code: "import * as rt from \"@rstest/core\"; rt.expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 54, EndLine: 1, EndColumn: 69}, + }}, + {Code: "const { expect: check } = require(\"@rstest/core\"); check(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 65, EndLine: 1, EndColumn: 80}, + }}, + {Code: "const rt = require(\"@rstest/core\"); rt.expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 54, EndLine: 1, EndColumn: 69}, + }}, + {Code: "import { expect } from \"rstack/test\"; expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 53, EndLine: 1, EndColumn: 68}, + }}, + {Code: "import.meta.rstest.expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 34, EndLine: 1, EndColumn: 49}, + }}, + {Code: "const { expect } = import.meta.rstest; expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 54, EndLine: 1, EndColumn: 69}, + }}, + {Code: "import { expect } from \"@rstest/playwright\"; expect(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 60, EndLine: 1, EndColumn: 75}, + }}, + {Code: "test(\"context\", ({ expect: check }) => { check(value).toMatchSnapshot(); });", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 55, EndLine: 1, EndColumn: 70}, + }}, + {Code: "test(\"context\", ctx => { ctx.expect(value).toMatchSnapshot(); });", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 44, EndLine: 1, EndColumn: 59}, + }}, + {Code: "test.for([1,2])(\"rows\", (row, {expect}) => { expect(row).toMatchSnapshot(); });", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 58, EndLine: 1, EndColumn: 73}, + }}, + {Code: "const custom = test.extend({value: 1}); custom(\"fixture\", ({expect, value}) => { expect(value).toMatchSnapshot(); });", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 96, EndLine: 1, EndColumn: 111}, + }}, + {Code: "expect.soft(value).toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 20, EndLine: 1, EndColumn: 35}, + }}, + {Code: "await expect(promise).resolves.toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 32, EndLine: 1, EndColumn: 47}, + }}, + {Code: "await expect(promise).rejects.toThrowErrorMatchingSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 31, EndLine: 1, EndColumn: 59}, + }}, + {Code: "expect(value).to.be.a(\"string\").and.matchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 37, EndLine: 1, EndColumn: 50}, + }}, + {Code: "expect(value).matchSnapshot().and.toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 28}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 35, EndLine: 1, EndColumn: 50}, + }}, + {Code: "expect(value).matchSnapshot().and.toMatchSnapshot(\"named\");", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 28}, + }}, + // A valid snapshot before a later negated one remains independently checked. + {Code: "expect(value).toMatchSnapshot().and.not.toMatchSnapshot();", Options: []any{"always"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 1, Column: 15, EndLine: 1, EndColumn: 30}, + }}, + }) +} + +// Binding resolution must work without a TypeChecker as well as in the main suite. +func TestPreferSnapshotHintSourceOnly(t *testing.T) { + for _, code := range []string{ + `expect(value).toMatchSnapshot();`, + `import * as rt from "@rstest/core"; rt.expect(value).toMatchSnapshot();`, + `const { expect: check } = require("@rstest/core"); check(value).toMatchSnapshot();`, + `const rt = require("@rstest/core"); rt.expect(value).toMatchSnapshot();`, + `import { expect } from "rstack/test"; expect(value).toMatchSnapshot();`, + `const { expect } = import.meta.rstest; expect(value).toMatchSnapshot();`, + `test("context", ({ expect: check }) => { check(value).toMatchSnapshot(); });`, + `test("context", ctx => { ctx.expect(value).toMatchSnapshot(); });`, + `import type { expect } from "@rstest/core"; expect(value).toMatchSnapshot();`, + + `import { expect as check } from "@rstest/core"; check(value).toMatchSnapshot();`, + `expect(value).toMatchSnapshot(); function helper(expect) { expect(other).toMatchSnapshot(); }`, + } { + t.Run(code, func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, code, []any{"always"}, 1) + }) + } + t.Run("registered named callbacks have separate groups", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +const register = () => { + const first = () => expect('first').toMatchSnapshot(); + test('first', first); + const second = () => expect('second').toMatchSnapshot(); + test('second', second); +}; +describe('suite', register);`, []any{"multi"}, 0) + }) + t.Run("hoisted registered callback has its own group", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +test('case', callback); +function callback() { expect('test').toMatchSnapshot(); } +function helper() { expect('helper').toMatchSnapshot(); }`, []any{"multi"}, 0) + }) + t.Run("overloaded registered callback has its own group", func(t *testing.T) { + runPreferSnapshotHintSourceOnly(t, ` +test('case', callback); +function callback(): void; +function callback() { expect('test').toMatchSnapshot(); } +function helper() { expect('helper').toMatchSnapshot(); }`, []any{"multi"}, 0) + }) +} + +func runPreferSnapshotHintSourceOnly(t *testing.T, code string, options []any, want int) { + t.Helper() + root := fixtures.GetRootDir() + name := tspath.ResolvePath(root.Dir, "snapshot-hint-source-only.ts") + host := utils.CreateCompilerHost(root.Dir, utils.NewOverlayVFS(root.FS, map[string]string{name: code})) + program, err := lintprogram.NewFromRoots(lintprogram.RootOptions{RootFileNames: []string{name}, Host: host, CompilerOptions: &core.CompilerOptions{Module: core.ModuleKindESNext}, SingleThreaded: true}) + if err != nil { + t.Fatal(err) + } + if program.CanProvideTypeChecker(program.SourceFiles()[0]) { + t.Fatal("expected source-only program") + } + plan, err := linter.PrepareLintPlan(linter.PrepareLintPlanOptions{ + Programs: []*lintprogram.Program{program}, TargetsByProgram: [][]string{{name}}, SingleThreaded: true, + GetRulesForFile: func(*ast.SourceFile) []rule.ConfiguredRule { + return []rule.ConfiguredRule{{Name: PreferSnapshotHintRule.Name, Severity: rule.SeverityError, Run: func(ctx rule.RuleContext) rule.RuleListeners { return PreferSnapshotHintRule.Run(ctx, options) }}} + }, + }) + if err != nil { + t.Fatal(err) + } + count := 0 + _, err = linter.RunLinter(linter.RunLinterOptions{LintPlan: plan, SingleThreaded: true, Consumer: rule.DiagnosticConsumer{Report: func(d rule.RuleDiagnostic) { + count++ + if got := code[d.Range.Pos():d.Range.End()]; got != "toMatchSnapshot" { + t.Errorf("unexpected diagnostic range: %q", got) + } + }}}) + if err != nil { + t.Fatal(err) + } + if count != want { + t.Fatalf("got %d diagnostics, want %d", count, want) + } +} diff --git a/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go new file mode 100644 index 0000000000..6080d06a88 --- /dev/null +++ b/internal/plugins/rstest/rules/prefer_snapshot_hint/prefer_snapshot_hint_upstream_test.go @@ -0,0 +1,88 @@ +// Complete vitest upstream suite; additions live in prefer_snapshot_hint_extras_test.go. +package prefer_snapshot_hint + +import ( + "testing" + + "github.com/web-infra-dev/rslint/internal/plugins/rstest/fixtures" + "github.com/web-infra-dev/rslint/internal/rule_tester" +) + +func TestPreferSnapshotHintUpstream0(t *testing.T) { + rule_tester.RunRuleTester(fixtures.GetRootDir(), "tsconfig.json", t, &PreferSnapshotHintRule, []rule_tester.ValidTestCase{ + {Code: "expect(something).toStrictEqual(somethingElse);", Options: []any{"multi"}}, + {Code: "a().toEqual('b')", Options: []any{"multi"}}, + {Code: "expect(a);", Options: []any{"multi"}}, + {Code: "expect(1).toMatchSnapshot({}, \"my snapshot\");", Options: []any{"multi"}}, + {Code: "expect(1).toThrowErrorMatchingSnapshot(\"my snapshot\");", Options: []any{"multi"}}, + {Code: "expect(1).toMatchSnapshot({});", Options: []any{"multi"}}, + {Code: "expect(1).toThrowErrorMatchingSnapshot();", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchSnapshot(undefined, 'my first snapshot');\n });\n ", Options: []any{"multi"}}, + {Code: "\n describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot('this is a hint, all by itself');\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchInlineSnapshot();\n });\n \n it('is false', () => {\n expect(1).toMatchInlineSnapshot();\n expect(1).toMatchInlineSnapshot();\n expect(1).toThrowErrorMatchingInlineSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(1).toMatchSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n \n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n };\n \n it('my test', () => {\n expect(1).toMatchSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n expect(anotherValue).toMatchSnapshot();\n };\n \n it('my test', () => {\n expect(1).toMatchSnapshot();\n });\n ", Options: []any{"multi"}}, + {Code: "\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n \n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n };\n \n expect(1).toMatchSnapshot();\n ", Options: []any{"multi"}}, + }, []rule_tester.InvalidTestCase{ + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 17}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 17}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toThrowErrorMatchingSnapshot();\n expect(2).toMatchSnapshot();\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot({});\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n {\n expect(2).toMatchSnapshot({});\n }\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 18}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 17}, + }}, + {Code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n { expect(2).toMatchSnapshot(); }\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 20}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 20}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n });", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot({}, 'my second snapshot');\n expect(2).toMatchSnapshot();\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 4, Column: 19}, + }}, + {Code: "it('is true', () => {\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot(null, 'my third snapshot');\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 2, Column: 19}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 3, Column: 19}, + }}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 7, Column: 18}, + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 8, Column: 18}, + }}, + {Code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 7, Column: 18}, + }}, + {Code: "describe('my tests', () => {\n describe('more tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n ", Options: []any{"multi"}, Errors: []rule_tester.InvalidTestCaseError{ + {MessageId: "missingHint", Message: "You should provide a hint for this snapshot", Line: 9, Column: 18}, + }}, + }) +} diff --git a/internal/plugins/rstest/utils/call_analysis.go b/internal/plugins/rstest/utils/call_analysis.go index 4462d64d1a..35b2384537 100644 --- a/internal/plugins/rstest/utils/call_analysis.go +++ b/internal/plugins/rstest/utils/call_analysis.go @@ -51,12 +51,14 @@ type RstestCallAnalysis struct { // callbackBindings memoizes the function a same-file callback binding // denotes. Negative results are cached too: checking whether a binding is // written requires asking the per-file reference index for every use. - callbackBindings map[*ast.Symbol]rstestCallbackInfo - callbacks RstestTestCallbacks - callbacksOK bool - ownership map[*ast.Node][]rstestCallbackRegistration - ownershipOK bool - hasTests bool + callbackBindings map[*ast.Symbol]rstestCallbackInfo + callbacks RstestTestCallbacks + callbacksOK bool + ownership map[*ast.Node][]rstestCallbackRegistration + ownershipOK bool + registrationCallbacks map[*ast.Node]bool + registrationCallbacksOK bool + hasTests bool } type rstestCallAnalysisFileCacheKey struct{} @@ -424,6 +426,22 @@ func (analysis *RstestCallAnalysis) callbackOwnership() map[*ast.Node][]rstestCa return analysis.ownership } +// RegistrationCallbacks returns every function that a test or describe +// registration invokes as its callback. The ownership index is already cached +// by file; callers receive only the function identities they need. +func (analysis *RstestCallAnalysis) RegistrationCallbacks() map[*ast.Node]bool { + if analysis.registrationCallbacksOK { + return analysis.registrationCallbacks + } + callbacks := make(map[*ast.Node]bool, len(analysis.callbackOwnership())) + for function := range analysis.callbackOwnership() { + callbacks[function] = true + } + analysis.registrationCallbacks = callbacks + analysis.registrationCallbacksOK = true + return analysis.registrationCallbacks +} + // isFnCallCandidate reports whether syntax and local aliases permit any Rstest // registration kind at node. func (analysis *RstestCallAnalysis) isFnCallCandidate(node *ast.Node) bool { diff --git a/internal/plugins/rstest/utils/parse_rstest_expect.go b/internal/plugins/rstest/utils/parse_rstest_expect.go index a7bffa08d7..b13b6f75ae 100644 --- a/internal/plugins/rstest/utils/parse_rstest_expect.go +++ b/internal/plugins/rstest/utils/parse_rstest_expect.go @@ -74,6 +74,9 @@ type ParsedRstestExpectMatcher struct { Name string Entry ParsedRstestFnMemberEntry Kind RstestExpectMatcherKind + // Negated reports whether Chai's persistent `not` flag is active when this + // matcher runs. Modifiers can appear between matchers in one assertion. + Negated bool } // ParsedRstestExpectCall describes one Rstest expect call. @@ -671,14 +674,16 @@ func findRstestExpectModifiersAndMatchers( } matchers := make([]ParsedRstestExpectMatcher, 0, len(chains)-matcherIndex) + negated := notCount > 0 for i := matcherIndex; i < len(chains); i++ { chain := chains[i] switch chain.Kind { case rstestExpectChainMatcher: matchers = append(matchers, ParsedRstestExpectMatcher{ - Name: chain.Entry.Name, - Entry: chain.Entry, - Kind: chain.MatcherKind, + Name: chain.Entry.Name, + Entry: chain.Entry, + Kind: chain.MatcherKind, + Negated: negated, }) case rstestExpectChainLanguage: if chain.Entry.Call != nil { @@ -687,6 +692,9 @@ func findRstestExpectModifiersAndMatchers( case rstestExpectChainModifier: // Chai permits modifiers between assertions in a multi-matcher // chain, e.g. .a("string").that.does.not.contain("x"). + if chain.Entry.Name == "not" { + negated = true + } case rstestExpectChainUnknown: // A member the chain grammar does not recognise ends the // assertion: everything after it reads a property of the diff --git a/internal/plugins/rstest/utils/parse_rstest_expect_test.go b/internal/plugins/rstest/utils/parse_rstest_expect_test.go index 74636370f0..623e3e8369 100644 --- a/internal/plugins/rstest/utils/parse_rstest_expect_test.go +++ b/internal/plugins/rstest/utils/parse_rstest_expect_test.go @@ -54,6 +54,14 @@ func describeParsedExpectChain(parsed *rstestUtils.ParsedRstestExpectCall) strin ) } +func describeMatcherNegation(parsed *rstestUtils.ParsedRstestExpectCall) string { + matchers := make([]string, len(parsed.Matchers)) + for i, matcher := range parsed.Matchers { + matchers[i] = fmt.Sprintf("%s:%t", matcher.Name, matcher.Negated) + } + return strings.Join(matchers, " ") +} + func expectExpressionKind(node *ast.Node) string { if node == nil { return "nil" @@ -107,6 +115,43 @@ var expectChainParseProbe = rule.Rule{ }, } +var expectMatcherNegationProbe = rule.Rule{ + Name: "rstest/expect-matcher-negation-probe", + RequiresTypeInfo: true, + Run: func(ctx rule.RuleContext, _ []any) rule.RuleListeners { + analysis := rstestUtils.GetRstestCallAnalysis(ctx) + return rule.RuleListeners{ + ast.KindCallExpression: func(node *ast.Node) { + parsed := analysis.ParseExpectCall(node) + if parsed != nil { + ctx.ReportNode(node, probeMessage("parsedExpect", describeMatcherNegation(parsed))) + } + }, + } + }, +} + +func TestParseRstestExpectCallTracksMatcherNegation(t *testing.T) { + rule_tester.RunRuleTester( + fixtures.GetRootDir(), "tsconfig.json", t, &expectMatcherNegationProbe, + []rule_tester.ValidTestCase{}, + []rule_tester.InvalidTestCase{ + { + Code: `expect("hello").to.be.a("string").that.does.not.contain("world");`, + Errors: parsedExpectError("a:false contain:true"), + }, + { + Code: `expect(value).not.toBe(expected).and.toMatchSnapshot();`, + Errors: parsedExpectError("toBe:true toMatchSnapshot:true"), + }, + { + Code: `expect(value).toBe(expected).and.toMatchSnapshot();`, + Errors: parsedExpectError("toBe:false toMatchSnapshot:false"), + }, + }, + ) +} + func parsedExpectError(message string) []rule_tester.InvalidTestCaseError { return []rule_tester.InvalidTestCaseError{{MessageId: "parsedExpect", Message: message}} } diff --git a/internal/plugins/rstest/utils/test_callback.go b/internal/plugins/rstest/utils/test_callback.go index 8832b172bd..dd0643dcd8 100644 --- a/internal/plugins/rstest/utils/test_callback.go +++ b/internal/plugins/rstest/utils/test_callback.go @@ -230,7 +230,7 @@ func resolveRstestCallbackBinding( symbol *ast.Symbol, name string, ) rstestCallbackInfo { - function := testFramework.LocalFunctionBinding(analysis.ctx.SourceFile, analysis.ctx.Refs, symbol) + function := testFramework.LocalFunctionImplementation(analysis.ctx.SourceFile, analysis.ctx.Refs, symbol) if function == nil { return rstestCallbackInfo{} } diff --git a/internal/utils/test_framework/callback_binding.go b/internal/utils/test_framework/callback_binding.go index 4bc76fb976..d5a8d6eeb6 100644 --- a/internal/utils/test_framework/callback_binding.go +++ b/internal/utils/test_framework/callback_binding.go @@ -24,14 +24,10 @@ func LocalFunctionBinding( return nil } declaration := symbol.Declarations[0] - if declaration == nil || ast.GetSourceFileOfNode(declaration) != sourceFile { + if declaration == nil || ast.GetSourceFileOfNode(declaration) != sourceFile || + localFunctionBindingIsWritten(refs, symbol) { return nil } - for _, reference := range refs.References(symbol) { - if internalUtils.IsWriteReference(reference) { - return nil - } - } switch declaration.Kind { case ast.KindFunctionDeclaration: return declaration @@ -43,3 +39,57 @@ func LocalFunctionBinding( } return nil } + +// LocalFunctionImplementation returns the unique same-file implementation a +// callback symbol always denotes. Bodyless overload signatures are ignored; +// mutable bindings and ambiguous runtime declarations are rejected. +func LocalFunctionImplementation( + sourceFile *ast.SourceFile, + refs *rule.RefStore, + symbol *ast.Symbol, +) *ast.Node { + if refs == nil || symbol == nil { + return nil + } + if localFunctionBindingIsWritten(refs, symbol) { + return nil + } + + var implementation *ast.Node + for _, declaration := range symbol.Declarations { + if declaration == nil || ast.GetSourceFileOfNode(declaration) != sourceFile { + return nil + } + var candidate *ast.Node + switch declaration.Kind { + case ast.KindFunctionDeclaration: + if declaration.Body() == nil { + continue + } + candidate = declaration + case ast.KindVariableDeclaration: + candidate = internalUtils.SkipAssertionsAndParens( + declaration.AsVariableDeclaration().Initializer, + ) + if candidate == nil || !ast.IsFunctionExpressionOrArrowFunction(candidate) { + return nil + } + default: + return nil + } + if implementation != nil { + return nil + } + implementation = candidate + } + return implementation +} + +func localFunctionBindingIsWritten(refs *rule.RefStore, symbol *ast.Symbol) bool { + for _, reference := range refs.References(symbol) { + if internalUtils.IsWriteReference(reference) { + return true + } + } + return false +} diff --git a/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.go b/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.go new file mode 100644 index 0000000000..e5b42af542 --- /dev/null +++ b/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.go @@ -0,0 +1,153 @@ +// Package prefer_snapshot_hint shares snapshot hint policy and lexical grouping +// between the Jest and Rstest adapters. +package prefer_snapshot_hint + +import ( + _ "embed" + + "github.com/microsoft/TypeScript/tsc/shim/ast" + "github.com/web-infra-dev/rslint/internal/rule" +) + +// Snapshot contains only the framework-neutral parts of a snapshot assertion. +type Snapshot struct { + Matcher *ast.Node + Args []*ast.Node + // Properties distinguishes the (properties?, hint?) overload from (hint?). + Properties bool +} + +type Runtime struct { + IsRegistration func(*ast.Node) bool + RegistrationCallbacks map[*ast.Node]bool + Snapshots func(*ast.Node) []Snapshot +} + +type Config struct { + Name string + Prepare func(rule.RuleContext) Runtime + // Rstest accepts interpolated strings as hints, since they are always strings. + AllowInterpolatedHints bool +} + +func missingHint(snapshot Snapshot, allowInterpolated bool) bool { + if len(snapshot.Args) == 0 { + return true + } + if !snapshot.Properties { + return len(snapshot.Args) != 1 + } + if len(snapshot.Args) == 2 { + return false + } + arg := ast.SkipParentheses(snapshot.Args[0]) + return arg == nil || (arg.Kind != ast.KindStringLiteral && + arg.Kind != ast.KindNoSubstitutionTemplateLiteral && + (!allowInterpolated || arg.Kind != ast.KindTemplateExpression)) +} + +//go:embed prefer_snapshot_hint.schema.json +var schemaJSON []byte + +var schema = rule.NewSchema(schemaJSON) + +func NewRule(config Config) rule.Rule { + return rule.Rule{ + Name: config.Name, + Schema: schema, + Run: func(ctx rule.RuleContext, options []any) rule.RuleListeners { + always := len(options) > 0 && options[0] == "always" + runtime := config.Prepare(ctx) + type groupState struct { + snapshots []Snapshot + depth int + } + var snapshots []Snapshot + var groups []groupState + depth := 0 + flush := func() { + if always || len(snapshots) > 1 { + for _, snapshot := range snapshots { + if missingHint(snapshot, config.AllowInterpolatedHints) { + ctx.ReportNode(snapshot.Matcher, rule.RuleMessage{ + Id: "missingHint", + Description: "You should provide a hint for this snapshot", + }) + } + } + } + snapshots = nil + } + enterGroup := func(groupDepth int) { + groups = append(groups, groupState{snapshots: snapshots, depth: depth}) + snapshots = nil + depth = groupDepth + } + exitGroup := func() { + flush() + last := len(groups) - 1 + snapshots = groups[last].snapshots + depth = groups[last].depth + groups = groups[:last] + } + enter := func(node *ast.Node) { + if runtime.RegistrationCallbacks[node] { + enterGroup(1) + return + } + depth++ + } + enterRegistered := func(node *ast.Node) { + if runtime.RegistrationCallbacks[node] { + enterGroup(1) + } + } + exitRegistered := func(node *ast.Node) { + if runtime.RegistrationCallbacks[node] { + exitGroup() + } + } + exit := func(node *ast.Node) { + if runtime.RegistrationCallbacks[node] { + exitGroup() + return + } + depth-- + if always || depth == 0 { + flush() + } + } + return rule.RuleListeners{ + ast.KindFunctionExpression: enter, + ast.KindArrowFunction: enter, + rule.ListenerOnExit(ast.KindFunctionExpression): exit, + rule.ListenerOnExit(ast.KindArrowFunction): exit, + // ESTree represents method, constructor and accessor bodies as + // FunctionExpressions; tsgo gives them their own node kinds. + ast.KindMethodDeclaration: enter, + ast.KindConstructor: enter, + ast.KindGetAccessor: enter, + ast.KindSetAccessor: enter, + ast.KindFunctionDeclaration: enterRegistered, + rule.ListenerOnExit(ast.KindMethodDeclaration): exit, + rule.ListenerOnExit(ast.KindConstructor): exit, + rule.ListenerOnExit(ast.KindGetAccessor): exit, + rule.ListenerOnExit(ast.KindSetAccessor): exit, + rule.ListenerOnExit(ast.KindFunctionDeclaration): exitRegistered, + ast.KindCallExpression: func(node *ast.Node) { + if runtime.IsRegistration(node) { + enterGroup(0) + return + } + snapshots = append(snapshots, runtime.Snapshots(node)...) + }, + rule.ListenerOnExit(ast.KindCallExpression): func(node *ast.Node) { + if runtime.IsRegistration(node) { + exitGroup() + } + }, + rule.ListenerOnExit(ast.KindEndOfFile): func(*ast.Node) { flush() }, + } + }, + } +} diff --git a/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.schema.json b/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.schema.json new file mode 100644 index 0000000000..9ede97f6ce --- /dev/null +++ b/internal/utils/test_framework/rules/prefer_snapshot_hint/prefer_snapshot_hint.schema.json @@ -0,0 +1,5 @@ +{ + "type": "array", + "items": [{ "type": "string", "enum": ["always", "multi"] }], + "maxItems": 1 +} diff --git a/packages/rslint-test-tools/rstack.config.mts b/packages/rslint-test-tools/rstack.config.mts index 0b72e67437..b42d18bd09 100644 --- a/packages/rslint-test-tools/rstack.config.mts +++ b/packages/rslint-test-tools/rstack.config.mts @@ -694,6 +694,7 @@ define.test({ './tests/eslint-plugin-jest/rules/prefer-jest-mocked.test.ts', './tests/eslint-plugin-jest/rules/prefer-spy-on.test.ts', './tests/eslint-plugin-jest/rules/prefer-strict-equal.test.ts', + './tests/eslint-plugin-jest/rules/prefer-snapshot-hint.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-times.test.ts', @@ -770,6 +771,7 @@ define.test({ './tests/rstest/rules/prefer-spy-on.test.ts', './tests/rstest/rules/prefer-strict-boolean-matchers.test.ts', './tests/rstest/rules/prefer-strict-equal.test.ts', + './tests/rstest/rules/prefer-snapshot-hint.test.ts', './tests/rstest/rules/prefer-to-be.test.ts', './tests/rstest/rules/prefer-to-be-falsy.test.ts', './tests/rstest/rules/prefer-to-be-truthy.test.ts', diff --git a/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-snapshot-hint.test.ts b/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-snapshot-hint.test.ts new file mode 100644 index 0000000000..bcfde9145f --- /dev/null +++ b/packages/rslint-test-tools/tests/eslint-plugin-jest/rules/prefer-snapshot-hint.test.ts @@ -0,0 +1,637 @@ +import { RuleTester } from '../rule-tester'; +const ruleTester = new RuleTester(); +ruleTester.run('prefer-snapshot-hint', {} as never, { + valid: [ + { + code: 'expect(something).toStrictEqual(somethingElse);', + options: ['always'], + }, + { + code: "a().toEqual('b')", + options: ['always'], + }, + { + code: 'expect(a);', + options: ['always'], + }, + { + code: 'expect(1).toMatchSnapshot({}, "my snapshot");', + options: ['always'], + }, + { + code: 'expect(1).toMatchSnapshot("my snapshot");', + options: ['always'], + }, + { + code: 'expect(1).toMatchSnapshot(`my snapshot`);', + options: ['always'], + }, + { + code: 'const x = {};\nexpect(1).toMatchSnapshot(x, "my snapshot");', + options: ['always'], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot("my snapshot");', + options: ['always'], + }, + { + code: 'expect(1).toMatchInlineSnapshot();', + options: ['always'], + }, + { + code: 'expect(1).toThrowErrorMatchingInlineSnapshot();', + options: ['always'], + }, + ], + invalid: [ + { + code: 'expect(1).toMatchSnapshot();', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 1, + }, + ], + }, + { + code: 'expect(1).toMatchSnapshot({});', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 1, + }, + ], + }, + { + code: 'const x = "we can\'t know if this is a string or not";\nexpect(1).toMatchSnapshot(x);', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 2, + }, + ], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot();', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 1, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n});", + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: 'it(\'is true\', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot("my error");\n});', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + ], + }, + { + code: 'const expectSnapshot = value => {\n expect(value).toMatchSnapshot();\n};', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 2, + }, + ], + }, + { + code: 'const expectSnapshot = value => {\n expect(value).toThrowErrorMatchingSnapshot();\n};', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n});", + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 15, + line: 2, + }, + ], + }, + { + code: 'const x = "snapshot";\nexpect(1).toMatchSnapshot(`my ${x}`);', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 2, + }, + ], + }, + ], +}); +ruleTester.run('prefer-snapshot-hint', {} as never, { + valid: [ + { + code: 'expect(something).toStrictEqual(somethingElse);', + options: ['multi'], + }, + { + code: "a().toEqual('b')", + options: ['multi'], + }, + { + code: 'expect(a);', + options: ['multi'], + }, + { + code: 'expect(1).toMatchSnapshot({}, "my snapshot");', + options: ['multi'], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot("my snapshot");', + options: ['multi'], + }, + { + code: 'expect(1).toMatchSnapshot({});', + options: ['multi'], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot();', + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot(undefined, 'my first snapshot');\n});", + options: ['multi'], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot('this is a hint, all by itself');\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(2).toThrowErrorMatchingSnapshot();\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(2).toThrowErrorMatchingSnapshot();\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchInlineSnapshot();\n});\n\nit('is false', () => {\n expect(1).toMatchInlineSnapshot();\n expect(1).toMatchInlineSnapshot();\n expect(1).toThrowErrorMatchingInlineSnapshot();\n});", + options: ['multi'], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nit('is false', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + }, + { + code: "import { it as itIs } from '@jest/globals';\n\nit('is true', () => {\n expect(1).toMatchSnapshot();\n});\n\nitIs('false', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + }, + { + code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + }, + { + code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n expect(anotherValue).toMatchSnapshot();\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + }, + { + code: 'const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toBe(1);\n};\n\nexpect(1).toMatchSnapshot();', + options: ['multi'], + }, + { + code: "const register = () => {\n const first = () => expect('first').toMatchSnapshot();\n test('first', first);\n const second = () => expect('second').toMatchSnapshot();\n test('second', second);\n};\ndescribe('suite', register);", + options: ['multi'], + }, + { + code: "test('case', callback);\nfunction callback(): void;\nfunction callback() { expect('test').toMatchSnapshot(); }\nfunction helper() { expect('helper').toMatchSnapshot(); }", + options: ['multi'], + }, + { + code: "describe('suite', () => {\n const a = () => expect(1).toMatchSnapshot();\n const b = () => expect(2).toMatchSnapshot();\n test('a', a as () => void);\n test('b', b satisfies () => void);\n});", + options: ['multi'], + }, + { + code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').then(() => {});", + options: ['always'], + }, + { + code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').catch(() => {});", + options: ['always'], + }, + { + code: "await expect(Promise.resolve(1)).resolves.toMatchSnapshot('named').finally(() => {});", + options: ['always'], + }, + { + code: 'await expect(Promise.resolve(1)).resolves.toMatchSnapshot().then(() => {});', + options: ['multi'], + }, + ], + invalid: [ + { + code: 'await expect(Promise.resolve(1))\n .resolves.toMatchSnapshot()\n .then(() => {});', + options: ['always'], + errors: [ + { + messageId: 'missingHint', + line: 2, + column: 13, + }, + ], + }, + { + code: "const outer = () => {\n expect('before').toMatchSnapshot();\n let callback = () => { expect('stale').toMatchSnapshot(); };\n callback = () => {};\n test('case', callback);\n expect('after').toMatchSnapshot();\n};", + options: ['multi'], + errors: 3, + }, + { + code: "const outer = () => {\n expect('before').toMatchSnapshot();\n { const callback = () => expect('one').toMatchSnapshot(); test('one', callback); }\n { const callback = () => expect('two').toMatchSnapshot(); test('two', callback); }\n expect('after').toMatchSnapshot();\n};", + options: ['multi'], + errors: 2, + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toThrowErrorMatchingSnapshot();\n expect(2).toMatchSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot({});\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n {\n expect(2).toMatchSnapshot({});\n }\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 15, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n { expect(2).toMatchSnapshot(); }\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 15, + line: 2, + }, + { + messageId: 'missingHint', + column: 15, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + { + messageId: 'missingHint', + column: 13, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot({}, 'my second snapshot');\n expect(2).toMatchSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot(null, 'my third snapshot');\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 13, + line: 2, + }, + { + messageId: 'missingHint', + column: 13, + line: 3, + }, + ], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 15, + line: 7, + }, + { + messageId: 'missingHint', + column: 15, + line: 8, + }, + ], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 15, + line: 7, + }, + ], + }, + { + code: "describe('my tests', () => {\n describe('more tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n });\n\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 15, + line: 9, + }, + ], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n describe('more tests', () => {\n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 8, + }, + ], + }, + { + code: "import { describe as context, it as itIs } from '@jest/globals';\n\ndescribe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n\n context('more tests', () => {\n itIs('false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 10, + }, + ], + }, + { + code: 'const myReusableTestBody = (value, snapshotHint) => {\n expect(value).toMatchSnapshot();\n\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n };\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toThrowErrorMatchingSnapshot(snapshotHint);\n};', + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 2, + }, + { + messageId: 'missingHint', + column: 26, + line: 5, + }, + { + messageId: 'missingHint', + column: 21, + line: 9, + }, + ], + }, + { + code: 'const myReusableTestBody = (value, snapshotHint) => {\n expect(value).toMatchSnapshot();\n\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toMatchSnapshot(null, snapshotHint);\n };\n};', + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 2, + }, + { + messageId: 'missingHint', + column: 26, + line: 5, + }, + { + messageId: 'missingHint', + column: 23, + line: 8, + }, + ], + }, + { + code: 'const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n expect(value + 1).toMatchSnapshot(null);\n expect(value + 2).toMatchSnapshot(null, snapshotHint);\n };\n\n expect(value).toThrowErrorMatchingSnapshot();\n};', + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 26, + line: 3, + }, + { + messageId: 'missingHint', + column: 23, + line: 6, + }, + { + messageId: 'missingHint', + column: 17, + line: 10, + }, + ], + }, + { + code: "const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n\n expect(value).toBe(1);\n };\n\n expect(value).toMatchSnapshot();\n};\n\nit('my test', () => {\n expect(1).toMatchSnapshot();\n});", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 26, + line: 3, + }, + { + messageId: 'missingHint', + column: 17, + line: 8, + }, + ], + }, + { + code: 'const myReusableTestBody = value => {\n expect(value).toMatchSnapshot();\n};\n\nexpect(1).toMatchSnapshot();\nexpect(1).toThrowErrorMatchingSnapshot();', + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 11, + line: 5, + }, + { + messageId: 'missingHint', + column: 11, + line: 6, + }, + ], + }, + ], +}); diff --git a/packages/rslint-test-tools/tests/rstest/rules/prefer-snapshot-hint.test.ts b/packages/rslint-test-tools/tests/rstest/rules/prefer-snapshot-hint.test.ts new file mode 100644 index 0000000000..ade2964852 --- /dev/null +++ b/packages/rslint-test-tools/tests/rstest/rules/prefer-snapshot-hint.test.ts @@ -0,0 +1,317 @@ +import { RuleTester } from '../rule-tester'; +const ruleTester = new RuleTester(); +ruleTester.run('prefer-snapshot-hint', {} as never, { + valid: [ + { + code: 'expect(something).toStrictEqual(somethingElse);', + options: ['multi'], + }, + { + code: "a().toEqual('b')", + options: ['multi'], + }, + { + code: 'expect(a);', + options: ['multi'], + }, + { + code: 'expect(1).toMatchSnapshot({}, "my snapshot");', + options: ['multi'], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot("my snapshot");', + options: ['multi'], + }, + { + code: 'expect(1).toMatchSnapshot({});', + options: ['multi'], + }, + { + code: 'expect(1).toThrowErrorMatchingSnapshot();', + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchSnapshot(undefined, 'my first snapshot');\n });\n ", + options: ['multi'], + }, + { + code: "\n describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot('this is a hint, all by itself');\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot('this is a hint');\n expect(2).toMatchSnapshot('and so is this');\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(1).toStrictEqual(1);\n expect(1).toStrictEqual(2);\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchInlineSnapshot();\n });\n \n it('is false', () => {\n expect(1).toMatchInlineSnapshot();\n expect(1).toMatchInlineSnapshot();\n expect(1).toThrowErrorMatchingInlineSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(1).toMatchSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n \n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n };\n \n it('my test', () => {\n expect(1).toMatchSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: "\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n expect(anotherValue).toMatchSnapshot();\n };\n \n it('my test', () => {\n expect(1).toMatchSnapshot();\n });\n ", + options: ['multi'], + }, + { + code: '\n const myReusableTestBody = (value, snapshotHint) => {\n const innerFn = anotherValue => {\n expect(anotherValue).toMatchSnapshot();\n \n expect(value).toBe(1);\n };\n \n expect(value).toBe(1);\n };\n \n expect(1).toMatchSnapshot();\n ', + options: ['multi'], + }, + { + code: "const register = () => {\n const first = () => expect('first').toMatchSnapshot();\n test('first', first);\n const second = () => expect('second').toMatchSnapshot();\n test('second', second);\n};\ndescribe('suite', register);", + options: ['multi'], + }, + { + code: "expect('x').toBe('x').and.not.toMatchSnapshot();", + options: ['always'], + }, + { + code: "test('case', callback);\nfunction callback(): void;\nfunction callback() { expect('test').toMatchSnapshot(); }\nfunction helper() { expect('helper').toMatchSnapshot(); }", + options: ['multi'], + }, + ], + invalid: [ + { + code: "const outer = () => {\n expect('before').toMatchSnapshot();\n let callback = () => { expect('stale').toMatchSnapshot(); };\n callback = () => {};\n test('case', callback);\n expect('after').toMatchSnapshot();\n};", + options: ['multi'], + errors: 3, + }, + { + code: "const outer = () => {\n expect('before').toMatchSnapshot();\n { const callback = () => expect('one').toMatchSnapshot(); test('one', callback); }\n { const callback = () => expect('two').toMatchSnapshot(); test('two', callback); }\n expect('after').toMatchSnapshot();\n};", + options: ['multi'], + errors: 2, + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 17, + line: 2, + }, + { + messageId: 'missingHint', + column: 17, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toThrowErrorMatchingSnapshot();\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toThrowErrorMatchingSnapshot();\n expect(2).toMatchSnapshot();\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot({});\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n {\n expect(2).toMatchSnapshot({});\n }\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 18, + line: 2, + }, + { + messageId: 'missingHint', + column: 17, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n { expect(1).toMatchSnapshot(); }\n { expect(2).toMatchSnapshot(); }\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 20, + line: 2, + }, + { + messageId: 'missingHint', + column: 20, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot();\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({});\n expect(2).toMatchSnapshot(undefined, 'my second snapshot');\n });", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + ], + }, + { + code: "it('is true', () => {\n expect(1).toMatchSnapshot({}, 'my first snapshot');\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + { + messageId: 'missingHint', + column: 19, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot({}, 'my second snapshot');\n expect(2).toMatchSnapshot();\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + { + messageId: 'missingHint', + column: 19, + line: 4, + }, + ], + }, + { + code: "it('is true', () => {\n expect(2).toMatchSnapshot(undefined);\n expect(2).toMatchSnapshot();\n expect(1).toMatchSnapshot(null, 'my third snapshot');\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 19, + line: 2, + }, + { + messageId: 'missingHint', + column: 19, + line: 3, + }, + ], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot();\n });\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 18, + line: 7, + }, + { + messageId: 'missingHint', + column: 18, + line: 8, + }, + ], + }, + { + code: "describe('my tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 18, + line: 7, + }, + ], + }, + { + code: "describe('my tests', () => {\n describe('more tests', () => {\n it('is true', () => {\n expect(1).toMatchSnapshot();\n });\n });\n \n it('is false', () => {\n expect(2).toMatchSnapshot();\n expect(2).toMatchSnapshot('hello world');\n });\n });\n ", + options: ['multi'], + errors: [ + { + messageId: 'missingHint', + column: 18, + line: 9, + }, + ], + }, + ], +});