diff --git a/docs/rules/require-local-test-context-for-concurrent-snapshots.md b/docs/rules/require-local-test-context-for-concurrent-snapshots.md new file mode 100644 index 0000000..23feef7 --- /dev/null +++ b/docs/rules/require-local-test-context-for-concurrent-snapshots.md @@ -0,0 +1,33 @@ +# Require local Test Context for concurrent snapshot tests (`vitest/require-local-test-context-for-concurrent-snapshots`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +test.concurrent('myLogic', () => { + expect(true).toMatchSnapshot(); +}) + +describe.concurrent('something', () => { + test('myLogic', () => { + expect(true).toMatchInlineSnapshot(); + }) +}) +``` + +Examples of **correct** code for this rule: + +```js +test.concurrent('myLogic', ({ expect }) => { + expect(true).toMatchSnapshot(); +}) + +test.concurrent('myLogic', (context) => { + context.expect(true).toMatchSnapshot(); +} +``` diff --git a/src/index.ts b/src/index.ts index 839c3fd..1da62a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import validDescribeCallback, { RULE_NAME as validDescribeCallbackName } from '. import requireTopLevelDescribe, { RULE_NAME as requireTopLevelDescribeName } from './rules/require-top-level-describe' import requireToThrowMessage, { RULE_NAME as requireToThrowMessageName } from './rules/require-to-throw-message' import requireHook, { RULE_NAME as requireHookName } from './rules/require-hook' +import requireLocalTestContextForConcurrentSnapshots, { RULE_NAME as requireLocalTestContextForConcurrentSnapshotsName } from './rules/require-local-test-context-for-concurrent-snapshots' import preferTodo, { RULE_NAME as preferTodoName } from './rules/prefer-todo' import preferSpyOn, { RULE_NAME as preferSpyOnName } from './rules/prefer-spy-on' import preferComparisonMatcher, { RULE_NAME as preferComparisonMatcherName } from './rules/prefer-comparison-matcher' @@ -98,6 +99,7 @@ const allRules = { [requireTopLevelDescribeName]: 'warn', [requireToThrowMessageName]: 'warn', [requireHookName]: 'warn', + [requireLocalTestContextForConcurrentSnapshotsName]: 'warn', [preferTodoName]: 'warn', [preferSpyOnName]: 'warn', [preferComparisonMatcherName]: 'warn', @@ -112,7 +114,8 @@ const recommended = { [noCommentedOutTestsName]: 'error', [validTitleName]: 'error', [validExpectName]: 'error', - [validDescribeCallbackName]: 'error' + [validDescribeCallbackName]: 'error', + [requireLocalTestContextForConcurrentSnapshotsName]: 'error', } export default { @@ -156,6 +159,7 @@ export default { [preferEachName]: preferEach, [preferHooksOnTopName]: preferHooksOnTop, [preferHooksInOrderName]: preferHooksInOrder, + [requireLocalTestContextForConcurrentSnapshotsName]: requireLocalTestContextForConcurrentSnapshots, [preferMockPromiseShortHandName]: preferMockPromiseShorthand, [preferSnapshotHintName]: preferSnapshotHint, [validDescribeCallbackName]: validDescribeCallback, diff --git a/src/rules/require-local-test-context-for-concurrent-snapshots.ts b/src/rules/require-local-test-context-for-concurrent-snapshots.ts new file mode 100644 index 0000000..67b7ca0 --- /dev/null +++ b/src/rules/require-local-test-context-for-concurrent-snapshots.ts @@ -0,0 +1,56 @@ +import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils"; +import { createEslintRule, getNodeName, isSupportedAccessor } from "../utils"; +import { isTypeOfVitestFnCall } from "../utils/parseVitestFnCall"; + +export const RULE_NAME = "require-local-test-context-for-concurrent-snapshots"; + +export default createEslintRule({ + name: RULE_NAME, + meta: { + docs: { + description: "Require local Test Context for concurrent snapshot tests", + recommended: "error", + }, + messages: { + requireLocalTestContext: "Use local Test Context instead", + }, + type: "problem", + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + const isNotAnAssertion = !isTypeOfVitestFnCall(node, context, ['expect']) + if (isNotAnAssertion) return; + + const isNotASnapshotAssertion = ![ + 'toMatchSnapshot', + 'toMatchInlineSnapshot' + ].includes(node.callee.property.name); + + if (isNotASnapshotAssertion) return; + + const isInsideSequentialDescribeOrTest = !context.getAncestors().some((ancestor) => { + if (ancestor.type !== AST_NODE_TYPES.CallExpression) return false; + + const isNotInsideDescribeOrTest = !isTypeOfVitestFnCall(ancestor, context, ["describe", "test"]); + if (isNotInsideDescribeOrTest) return false; + + const isTestRunningConcurrently = + ancestor.callee.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(ancestor.callee.property, "concurrent"); + + return isTestRunningConcurrently + }); + + if (isInsideSequentialDescribeOrTest) return; + + context.report({ + node, + messageId: "requireLocalTestContext" + }) + }, + }; + }, +}); diff --git a/tests/require-local-test-context-for-concurrent-snapshots.test.ts b/tests/require-local-test-context-for-concurrent-snapshots.test.ts new file mode 100644 index 0000000..758595c --- /dev/null +++ b/tests/require-local-test-context-for-concurrent-snapshots.test.ts @@ -0,0 +1,58 @@ +import rule, { RULE_NAME } from '../src/rules/require-local-test-context-for-concurrent-snapshots' +import { ruleTester } from "./ruleTester"; + +ruleTester.run(RULE_NAME, rule, { + valid: [ + 'it("something", () => { expect(true).toBe(true) })', + 'it.concurrent("something", () => { expect(true).toBe(true) })', + 'it("something", () => { expect(1).toMatchSnapshot() })', + 'it.concurrent("something", ({ expect }) => { expect(1).toMatchSnapshot() })', + 'it.concurrent("something", ({ expect }) => { expect(1).toMatchInlineSnapshot("1") })', + 'describe.concurrent("something", () => { it("something", () => { expect(true).toBe(true) }) })', + 'describe.concurrent("something", () => { it("something", ({ expect }) => { expect(1).toMatchSnapshot() }) })', + 'describe.concurrent("something", () => { it("something", ({ expect }) => { expect(1).toMatchInlineSnapshot() }) })', + 'describe("something", () => { it("something", ({ expect }) => { expect(1).toMatchInlineSnapshot() }) })', + 'describe("something", () => { it("something", (context) => { expect(1).toMatchInlineSnapshot() }) })', + 'describe("something", () => { it("something", (context) => { context.expect(1).toMatchInlineSnapshot() }) })', + 'describe("something", () => { it("something", (context) => { expect(1).toMatchInlineSnapshot() }) })', + 'it.concurrent("something", (context) => { context.expect(1).toMatchSnapshot() })', + ], + invalid: [ + { + code: 'it.concurrent("should fail", () => { expect(true).toMatchSnapshot() })', + errors: [{ messageId: 'requireLocalTestContext' }] + }, + { + code: 'it.concurrent("should fail", () => { expect(true).toMatchInlineSnapshot("true") })', + errors: [{ messageId: 'requireLocalTestContext' }] + }, + { + code: 'describe.concurrent("failing", () => { it("should fail", () => { expect(true).toMatchSnapshot() }) })', + errors: [{ messageId: 'requireLocalTestContext' }] + }, + { + code: 'describe.concurrent("failing", () => { it("should fail", () => { expect(true).toMatchInlineSnapshot("true") }) })', + errors: [{ messageId: 'requireLocalTestContext' }] + }, + { + code: 'it.concurrent("something", (context) => { expect(true).toMatchSnapshot() })', + errors: [{ messageId: 'requireLocalTestContext' }] + }, + { + code: `it.concurrent("something", () => { + expect(true).toMatchSnapshot(); + + expect(true).toMatchSnapshot(); + })`, + errors: [{ messageId: 'requireLocalTestContext' }, { messageId: 'requireLocalTestContext' }] + }, + { + code: `it.concurrent("something", () => { + expect(true).toBe(true); + + expect(true).toMatchSnapshot(); + })`, + errors: [{ messageId: 'requireLocalTestContext' }] + }, + ], +})