diff --git a/README.md b/README.md index 36f8fe0..bbe5ee6 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ To use the all configuration, extend it in your `.eslintrc` file: | [prefer-to-be-falsy](docs/rules/prefer-to-be-falsy.md) | Suggest using toBeFalsy() | 🌐 | 🔧 | | | [prefer-to-be-object](docs/rules/prefer-to-be-object.md) | Prefer toBeObject() | 🌐 | 🔧 | | | [prefer-to-be-truthy](docs/rules/prefer-to-be-truthy.md) | Suggest using `toBeTruthy` | 🌐 | 🔧 | | +| [prefer-to-contain](docs/rules/prefer-to-contain.md) | Prefer using toContain() | 🌐 | 🔧 | | | [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | Suggest using toHaveLength() | 🌐 | 🔧 | | | [prefer-todo](docs/rules/prefer-todo.md) | Suggest using `test.todo` | 🌐 | 🔧 | | | [preferMockPromiseShorthand](docs/rules/preferMockPromiseShorthand.md) | Prefer mock resolved/rejected shorthands for promises | 🌐 | 🔧 | | diff --git a/docs/rules/prefer-to-contain.md b/docs/rules/prefer-to-contain.md new file mode 100644 index 0000000..4b511a5 --- /dev/null +++ b/docs/rules/prefer-to-contain.md @@ -0,0 +1,27 @@ +# Prefer using toContain() (`vitest/prefer-to-contain`) + +⚠️ This rule _warns_ in the 🌐 `all` config. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + + +This rule triggers a warning if `toBe()`, `toEqual()` or `toStrickEqual()` is used to assert object inclusion in an array. + + +The following patterns are considered warnings: + + +```ts +expect(a.includes(b)).toBe(true); +expect(a.includes(b)).toEqual(true); +expect(a.includes(b)).toStrictEqual(true); +``` + + +The following patterns are not considered warnings: + +```ts +expect(a).toContain(b); +``` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a507244..8525f70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,9 +43,10 @@ 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 preferSpyOn, { RULE_NAME as preferSpyOnName } from './rules/prefer-spy-on' 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' +import preferToContain, { RULE_NAME as preferToContainName } from './rules/prefer-to-contain' const createConfig = (rules: Record) => ({ plugins: ['vitest'], @@ -96,9 +97,11 @@ const allRules = { [requireTopLevelDescribeName]: 'warn', [requireToThrowMessageName]: 'warn', [requireHookName]: 'warn', + [preferTodoName]: 'warn', [preferSpyOnName]: 'warn', [preferTodoName]: 'warn', - [preferComparisonMatcherName]: 'warn' + [preferComparisonMatcherName]: 'warn', + [preferToContainName]: 'warn' } const recommended = { @@ -158,9 +161,11 @@ export default { [requireTopLevelDescribeName]: requireTopLevelDescribe, [requireToThrowMessageName]: requireToThrowMessage, [requireHookName]: requireHook, + [preferTodoName]: preferTodo, [preferSpyOnName]: preferSpyOn, [preferTodoName]: preferTodo, - [preferComparisonMatcherName]: preferComparisonMatcher + [preferComparisonMatcherName]: preferComparisonMatcher, + [preferToContainName]: preferToContain }, configs: { all: createConfig(allRules), diff --git a/src/rules/prefer-to-contain.test.ts b/src/rules/prefer-to-contain.test.ts new file mode 100644 index 0000000..03b62ae --- /dev/null +++ b/src/rules/prefer-to-contain.test.ts @@ -0,0 +1,195 @@ +import { describe, test } from 'vitest' +import ruleTester from '../utils/tester' +import rule, { RULE_NAME } from './prefer-to-contain' + +describe(RULE_NAME, () => { + test(RULE_NAME, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + // 'expect.hasAssertions', + // 'expect.hasAssertions()', + // 'expect.assertions(1)', + // 'expect().toBe(false);', + // 'expect(a).toContain(b);', + // 'expect(a.name).toBe(\'b\');', + // 'expect(a).toBe(true);', + // 'expect(a).toEqual(b)', + // 'expect(a.test(c)).toEqual(b)', + // 'expect(a.includes(b)).toEqual()', + // 'expect(a.includes(b)).toEqual("test")', + // 'expect(a.includes(b)).toBe("test")', + // 'expect(a.includes()).toEqual()', + // 'expect(a.includes()).toEqual(true)', + // 'expect(a.includes(b,c)).toBe(true)', + // 'expect([{a:1}]).toContain({a:1})', + // 'expect([1].includes(1)).toEqual', + // 'expect([1].includes).toEqual', + // 'expect([1].includes).not', + // 'expect(a.test(b)).resolves.toEqual(true)', + // 'expect(a.test(b)).resolves.not.toEqual(true)', + // 'expect(a).not.toContain(b)', + // 'expect(a.includes(...[])).toBe(true)', + // 'expect(a.includes(b)).toBe(...true)', + // 'expect(a);' + ], + invalid: [ + { + code: 'expect(a.includes(b)).toEqual(true);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b,),).toEqual(true,);', + output: 'expect(a,).toContain(b,);', + parserOptions: { ecmaVersion: 2017 }, + errors: [{ messageId: 'useToContain', column: 25, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b)).toEqual(true);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 26, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b))[\'toEqual\'](true);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 26, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b)).toEqual(false);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 26, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b)).not.toEqual(false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 30, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b))[\'not\'].toEqual(false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect(a[\'includes\'](b))[\'not\'][\'toEqual\'](false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect(a.includes(b)).toEqual(false);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toEqual(false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toEqual(true);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.includes(b)).toBe(true);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b)).toBe(false);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toBe(false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toBe(true);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.includes(b)).toStrictEqual(true);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b)).toStrictEqual(false);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 23, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toStrictEqual(false);', + output: 'expect(a).toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.includes(b)).not.toStrictEqual(true);', + output: 'expect(a).not.toContain(b);', + errors: [{ messageId: 'useToContain', column: 27, line: 1 }] + }, + { + code: 'expect(a.test(t).includes(b.test(p))).toEqual(true);', + output: 'expect(a.test(t)).toContain(b.test(p));', + errors: [{ messageId: 'useToContain', column: 39, line: 1 }] + }, + { + code: 'expect(a.test(t).includes(b.test(p))).toEqual(false);', + output: 'expect(a.test(t)).not.toContain(b.test(p));', + errors: [{ messageId: 'useToContain', column: 39, line: 1 }] + }, + { + code: 'expect(a.test(t).includes(b.test(p))).not.toEqual(true);', + output: 'expect(a.test(t)).not.toContain(b.test(p));', + errors: [{ messageId: 'useToContain', column: 43, line: 1 }] + }, + { + code: 'expect(a.test(t).includes(b.test(p))).not.toEqual(false);', + output: 'expect(a.test(t)).toContain(b.test(p));', + errors: [{ messageId: 'useToContain', column: 43, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).toBe(true);', + output: 'expect([{a:1}]).toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).toBe(false);', + output: 'expect([{a:1}]).not.toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).not.toBe(true);', + output: 'expect([{a:1}]).not.toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 37, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).not.toBe(false);', + output: 'expect([{a:1}]).toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 37, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).toStrictEqual(true);', + output: 'expect([{a:1}]).toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).toStrictEqual(false);', + output: 'expect([{a:1}]).not.toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 33, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).not.toStrictEqual(true);', + output: 'expect([{a:1}]).not.toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 37, line: 1 }] + }, + { + code: 'expect([{a:1}].includes({a:1})).not.toStrictEqual(false);', + output: 'expect([{a:1}]).toContain({a:1});', + errors: [{ messageId: 'useToContain', column: 37, line: 1 }] + } + ] + }) + }) +}) diff --git a/src/rules/prefer-to-contain.ts b/src/rules/prefer-to-contain.ts new file mode 100644 index 0000000..1d9d671 --- /dev/null +++ b/src/rules/prefer-to-contain.ts @@ -0,0 +1,94 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' +import { KnownCallExpression, createEslintRule, getAccessorValue, isSupportedAccessor } from '../utils' +import { hasOnlyOneArgument, isBooleanLiteral } from '../utils/msc' +import { getFirstMatcherArg, parseVitestFnCall } from '../utils/parseVitestFnCall' +import { CallExpressionWithSingleArgument, EqualityMatcher, ModifierName } from '../utils/types' + +export const RULE_NAME = 'prefer-to-contain' +type MESSAGE_IDS = 'useToContain'; +type Options = [] + +type FixableIncludesCallExpression = KnownCallExpression<'includes'> & + CallExpressionWithSingleArgument; + +const isFixableIncludesCallExpression = (node: TSESTree.Node): node is FixableIncludesCallExpression => + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(node.callee.property, 'includes') && + hasOnlyOneArgument(node) && + node.arguments[0].type !== AST_NODE_TYPES.SpreadElement + +export default createEslintRule({ + name: RULE_NAME, + meta: { + docs: { + description: 'Prefer using toContain()', + recommended: 'warn' + }, + messages: { + useToContain: 'Use toContain() instead' + }, + fixable: 'code', + type: 'suggestion', + schema: [] + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + const vitestFnCall = parseVitestFnCall(node, context) + + if (vitestFnCall?.type !== 'expect' || vitestFnCall.args.length === 0) + return + + const { parent: expect } = vitestFnCall.head.node + + if (expect?.type !== AST_NODE_TYPES.CallExpression) return + + const { + arguments: [includesCall], + range: [, expectCallEnd] + } = expect + + const { matcher } = vitestFnCall + const matcherArg = getFirstMatcherArg(vitestFnCall) + + if ( + !includesCall || + matcherArg.type === AST_NODE_TYPES.SpreadElement || + // eslint-disable-next-line no-prototype-builtins + !EqualityMatcher.hasOwnProperty(getAccessorValue(matcher)) || + !isBooleanLiteral(matcherArg) || + !isFixableIncludesCallExpression(includesCall)) + return + + const hasNot = vitestFnCall.modifiers.some(nod => getAccessorValue(nod) === 'not') + + context.report({ + fix(fixer) { + const sourceCode = context.getSourceCode() + + const addNotModifier = matcherArg.value === hasNot + + return [ + fixer.removeRange([ + includesCall.callee.property.range[0] - 1, + includesCall.range[1] + ]), + fixer.replaceTextRange([expectCallEnd, matcher.parent.range[1]], + addNotModifier + ? `.${ModifierName.not}.toContain` + : '.toContain'), + fixer.replaceText( + vitestFnCall.args[0], + sourceCode.getText(includesCall.arguments[0]) + ) + ] + }, + messageId: 'useToContain', + node: matcher + }) + } + } + } +}) diff --git a/src/utils/index.ts b/src/utils/index.ts index 08e08b4..9e4eaa5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,8 @@ +// MIT License +// Copyright (c) 2018 Jonathan Kim +// Imported from https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/utils/accessors.ts#L6 // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable no-use-before-define */ -// Imported from https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/utils/accessors.ts#L6 import { TSESLint, AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils' import { KnownMemberExpression, ParsedExpectVitestFnCall } from './parseVitestFnCall'