diff --git a/README.md b/README.md index 8396ffb..be7f0df 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ To use the all configuration, extend it in your `.eslintrc` file: | [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase titles | 🌐 | 🔧 | | | [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using toBe() | ✅ | 🔧 | | | [prefer-to-be-false](docs/rules/prefer-to-be-false.md) | Suggest using toBeFalsy() | 🌐 | 🔧 | | +| [prefer-to-be-object](docs/rules/prefer-to-be-object.md) | Prefer toBeObject() | 🌐 | 🔧 | | | [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage | ✅ | | | | [valid-title](docs/rules/valid-title.md) | Enforce valid titles | ✅ | 🔧 | | diff --git a/docs/rules/prefer-to-be-object.md b/docs/rules/prefer-to-be-object.md new file mode 100644 index 0000000..9129030 --- /dev/null +++ b/docs/rules/prefer-to-be-object.md @@ -0,0 +1,13 @@ +# Prefer toBeObject() (`vitest/prefer-to-be-object`) + +⚠️ 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). + + +```js +expectTypeOf({}).not.toBeInstanceOf(Object); + +// should be +expectTypeOf({}).not.toBeObject(); +``` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 14f5cea..4ffffd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import preferCalledWith, { RULE_NAME as preferCalledWithName } from './rules/pre import validTitle, { RULE_NAME as validTitleName } from './rules/valid-title' import validExpect, { RULE_NAME as validExpectName } from './rules/valid-expect' import preferToBeFalse, { RULE_NAME as preferToBeFalseName } from './rules/prefer-to-be-false' +import preferToBeObject, { RULE_NAME as preferToBeObjectName } from './rules/prefer-to-be-object' const createConfig = (rules: Record) => ({ plugins: ['vitest'], @@ -63,7 +64,8 @@ const allRules = { [noTestPrefixesName]: 'warn', [noTestReturnStatementName]: 'warn', [preferCalledWithName]: 'warn', - [preferToBeFalseName]: 'warn' + [preferToBeFalseName]: 'warn', + [preferToBeObjectName]: 'warn' } const recommended = { @@ -106,7 +108,8 @@ export default { [preferCalledWithName]: preferCalledWith, [validTitleName]: validTitle, [validExpectName]: validExpect, - [preferToBeFalseName]: preferToBeFalse + [preferToBeFalseName]: preferToBeFalse, + [preferToBeObjectName]: preferToBeObject }, configs: { all: createConfig(allRules), diff --git a/src/rules/prefer-to-be-object.test.ts b/src/rules/prefer-to-be-object.test.ts new file mode 100644 index 0000000..10a0b77 --- /dev/null +++ b/src/rules/prefer-to-be-object.test.ts @@ -0,0 +1,72 @@ +import { test, describe } from 'vitest' +import ruleTester from '../utils/tester' +import rule, { RULE_NAME } from './prefer-to-be-object' + +const messageId = 'preferToBeObject' + +describe(RULE_NAME, () => { + test(RULE_NAME, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + 'expectTypeOf.hasAssertions', + 'expectTypeOf.hasAssertions()', + 'expectTypeOf', + 'expectTypeOf().not', + 'expectTypeOf().toBe', + 'expectTypeOf().toBe(true)', + 'expectTypeOf({}).toBe(true)', + 'expectTypeOf({}).toBeObject()', + 'expectTypeOf({}).not.toBeObject()', + 'expectTypeOf([] instanceof Array).not.toBeObject()', + 'expectTypeOf({}).not.toBeInstanceOf(Array)' + ], + invalid: [ + { + code: 'expectTypeOf(({} instanceof Object)).toBeTruthy();', + output: 'expectTypeOf(({})).toBeObject();', + errors: [{ messageId: 'preferToBeObject', column: 38, line: 1 }] + }, + { + code: 'expectTypeOf({} instanceof Object).toBeTruthy();', + output: 'expectTypeOf({}).toBeObject();', + errors: [{ messageId, column: 36, line: 1 }] + }, + { + code: 'expectTypeOf({} instanceof Object).not.toBeTruthy();', + output: 'expectTypeOf({}).not.toBeObject();', + errors: [{ messageId, column: 40, line: 1 }] + }, + { + code: 'expectTypeOf({} instanceof Object).toBeFalsy();', + output: 'expectTypeOf({}).not.toBeObject();', + errors: [{ messageId, column: 36, line: 1 }] + }, + { + code: 'expectTypeOf({} instanceof Object).not.toBeFalsy();', + output: 'expectTypeOf({}).toBeObject();', + errors: [{ messageId, column: 40, line: 1 }] + }, + { + code: 'expectTypeOf({}).toBeInstanceOf(Object);', + output: 'expectTypeOf({}).toBeObject();', + errors: [{ messageId, column: 18, line: 1 }] + }, + { + code: 'expectTypeOf({}).not.toBeInstanceOf(Object);', + output: 'expectTypeOf({}).not.toBeObject();', + errors: [{ messageId, column: 22, line: 1 }] + }, + { + code: 'expectTypeOf(requestValues()).resolves.toBeInstanceOf(Object);', + output: 'expectTypeOf(requestValues()).resolves.toBeObject();', + errors: [{ messageId, column: 40, line: 1 }] + }, + { + code: 'expectTypeOf(queryApi()).resolves.not.toBeInstanceOf(Object);', + output: 'expectTypeOf(queryApi()).resolves.not.toBeObject();', + errors: [{ messageId, column: 39, line: 1 }] + } + ] + }) + }) +}) diff --git a/src/rules/prefer-to-be-object.ts b/src/rules/prefer-to-be-object.ts new file mode 100644 index 0000000..3150260 --- /dev/null +++ b/src/rules/prefer-to-be-object.ts @@ -0,0 +1,99 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils' +import { createEslintRule, getAccessorValue, isParsedInstanceOfMatcherCall } from '../utils' +import { isBooleanEqualityMatcher, isInstanceOfBinaryExpression } from '../utils/msc' +import { followTypeAssertionChain, parseVitestFnCall } from '../utils/parseVitestFnCall' + +export const RULE_NAME = 'prefer-to-be-object' +export type MESSAGE_IDS = 'preferToBeObject'; +export type Options = [] + +export default createEslintRule({ + name: RULE_NAME, + meta: { + type: 'suggestion', + docs: { + description: 'Prefer toBeObject()', + recommended: 'error' + }, + fixable: 'code', + messages: { + preferToBeObject: 'Prefer toBeObject() to test if a value is an object.' + }, + schema: [] + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + const vitestFnCall = parseVitestFnCall(node, context) + + if (vitestFnCall?.type !== 'expectTypeOf') + return + + if (isParsedInstanceOfMatcherCall(vitestFnCall, 'Object')) { + context.report({ + node: vitestFnCall.matcher, + messageId: 'preferToBeObject', + fix: fixer => [ + fixer.replaceTextRange( + [ + vitestFnCall.matcher.range[0], + vitestFnCall.matcher.range[1] + '(Object)'.length + ], + 'toBeObject()' + ) + ] + }) + return + } + + const { parent: expectTypeOf } = vitestFnCall.head.node + + if (expectTypeOf?.type !== AST_NODE_TYPES.CallExpression) + return + + const [expectTypeOfArgs] = expectTypeOf.arguments + + if (!expectTypeOfArgs || + !isBooleanEqualityMatcher(vitestFnCall) || + !isInstanceOfBinaryExpression(expectTypeOfArgs, 'Object')) + return + + context.report({ + node: vitestFnCall.matcher, + messageId: 'preferToBeObject', + fix(fixer) { + const fixes = [ + fixer.replaceText(vitestFnCall.matcher, 'toBeObject'), + fixer.removeRange([expectTypeOfArgs.left.range[1], expectTypeOfArgs.range[1]]) + ] + + let invertCondition = getAccessorValue(vitestFnCall.matcher) === 'toBeFalsy' + + if (vitestFnCall.args.length) { + const [matcherArg] = vitestFnCall.args + + fixes.push(fixer.remove(matcherArg)) + + invertCondition = matcherArg.type === AST_NODE_TYPES.Literal && + followTypeAssertionChain(matcherArg).value === false + } + + if (invertCondition) { + const notModifier = vitestFnCall.modifiers.find(node => getAccessorValue(node) === 'not') + + fixes.push(notModifier + ? fixer.removeRange([ + notModifier.range[0] - 1, + notModifier.range[1] + ]) + : fixer.insertTextBefore(vitestFnCall.matcher, 'not.') + ) + } + return fixes + } + }) + } + } + } +}) diff --git a/src/rules/valid-title.test.ts b/src/rules/valid-title.test.ts index 23b89f5..7ecd098 100644 --- a/src/rules/valid-title.test.ts +++ b/src/rules/valid-title.test.ts @@ -1,11 +1,7 @@ -import { TSESLint } from '@typescript-eslint/utils' import { describe, it } from 'vitest' +import ruleTester from '../utils/tester' import rule, { RULE_NAME } from './valid-title' -const ruleTester = new TSESLint.RuleTester({ - parser: require.resolve('@typescript-eslint/parser') -}) - describe(RULE_NAME, () => { it(`${RULE_NAME} - disallowed option`, () => { ruleTester.run(RULE_NAME, rule, { diff --git a/src/utils/index.ts b/src/utils/index.ts index f17ede9..08e08b4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,7 @@ /* 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 } from './parseVitestFnCall' +import { KnownMemberExpression, ParsedExpectVitestFnCall } from './parseVitestFnCall' export const createEslintRule = ESLintUtils.RuleCreator((ruleName) => `https://github.com/veritem/eslint-plugin-vitest/blob/main/docs/rules/${ruleName}.md`) @@ -156,9 +156,11 @@ export const removeExtraArgumentsFixer = ( const sourceCode = context.getSourceCode() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion let tokenAfterLastParam = sourceCode.getTokenAfter(lastArg)! if (tokenAfterLastParam.value === ',') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion tokenAfterLastParam = sourceCode.getTokenAfter(tokenAfterLastParam)! return fixer.removeRange([firstArg.range[0], tokenAfterLastParam.range[0]]) @@ -173,3 +175,14 @@ export interface KnownCallExpression extends TSESTree.CallExpression { callee: CalledKnownMemberExpression; } + +export const isParsedInstanceOfMatcherCall = ( + expectFnCall: ParsedExpectVitestFnCall, + classArg?: string +) => { + return ( + getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' && + expectFnCall.args.length === 1 && + isSupportedAccessor(expectFnCall.args[0], classArg) + ) +} diff --git a/src/utils/msc.ts b/src/utils/msc.ts new file mode 100644 index 0000000..3c7f511 --- /dev/null +++ b/src/utils/msc.ts @@ -0,0 +1,36 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' +import { getFirstMatcherArg, ParsedExpectVitestFnCall } from './parseVitestFnCall' +import { EqualityMatcher } from './types' +import { getAccessorValue, isSupportedAccessor } from '.' + +export const isBooleanLiteral = (node: TSESTree.Node): node is TSESTree.BooleanLiteral => + node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean' + +/** + * Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers, + * with a boolean` literal as the sole argument, *or* is a call to `toBeTruthy` or `toBeFalsy`. + */ +export const isBooleanEqualityMatcher = ( + expectFnCall: ParsedExpectVitestFnCall +): boolean => { + const matcherName = getAccessorValue(expectFnCall.matcher) + + if (['toBeTruthy', 'toBeFalsy'].includes(matcherName)) + return true + + if (expectFnCall.args.length !== 1) + return false + + const arg = getFirstMatcherArg(expectFnCall) + + // eslint-disable-next-line no-prototype-builtins + return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg) +} + +export const isInstanceOfBinaryExpression = ( + node: TSESTree.Node, + className: string +): node is TSESTree.BinaryExpression => + node.type === AST_NODE_TYPES.BinaryExpression && + node.operator === 'instanceof' && + isSupportedAccessor(node.right, className) diff --git a/src/utils/parseVitestFnCall.ts b/src/utils/parseVitestFnCall.ts index 716ebb6..09e867f 100644 --- a/src/utils/parseVitestFnCall.ts +++ b/src/utils/parseVitestFnCall.ts @@ -64,6 +64,7 @@ export type VitestFnType = | 'unknown' | 'hook' | 'vi' + | 'expectTypeOf' interface ResolvedVitestFn { original: string | null, @@ -95,7 +96,7 @@ interface ModifiersAndMatcher { args: TSESTree.CallExpression['arguments']; } -interface BaseParsedJestFnCall { +interface BaseParsedVitestFnCall { /** * The name of the underlying Vitest function that is being called. * This is the result of `(head.original ?? head.local)`. @@ -106,12 +107,12 @@ interface BaseParsedJestFnCall { members: KnownMemberExpressionProperty[]; } -interface ParsedGeneralVitestFnCall extends BaseParsedJestFnCall { - type: Exclude +interface ParsedGeneralVitestFnCall extends BaseParsedVitestFnCall { + type: Exclude & Exclude } -export interface ParsedExpectVitestFnCall extends BaseParsedJestFnCall, ModifiersAndMatcher { - type: 'expect' +export interface ParsedExpectVitestFnCall extends BaseParsedVitestFnCall, ModifiersAndMatcher { + type: 'expect' | 'expectTypeOf' } export type ParsedVitestFnCall = ParsedGeneralVitestFnCall | ParsedExpectVitestFnCall @@ -128,7 +129,7 @@ export const isTypeOfVitestFnCall = ( export const parseVitestFnCall = ( node: TSESTree.CallExpression, context: TSESLint.RuleContext -) => { +): ParsedVitestFnCall | null => { const vitestFnCall = parseVitestFnCallWithReason(node, context) if (typeof vitestFnCall === 'string') @@ -145,7 +146,7 @@ const parseVitestFnCallCache = new WeakMap< export const parseVitestFnCallWithReason = ( node: TSESTree.CallExpression, context: TSESLint.RuleContext -) => { +): ParsedVitestFnCall | string | null => { let parsedVistestFnCall = parseVitestFnCallCache.get(node) if (parsedVistestFnCall) @@ -162,6 +163,9 @@ const determineVitestFnType = (name: string): VitestFnType => { if (name === 'expect') return 'expect' + if (name === 'expectTypeOf') + return 'expectTypeOf' + if (name === 'vi') return 'vi' @@ -231,7 +235,7 @@ const findModifiersAndMatcher = ( return 'matcher-not-found' } -const parseVitestExpectCall = (typelessParsedVitestFnCall: Omit): ParsedExpectVitestFnCall | string => { +const parseVitestExpectCall = (typelessParsedVitestFnCall: Omit, type: 'expect' | 'expectTypeOf'): ParsedExpectVitestFnCall | string => { const modifiersMatcher = findModifiersAndMatcher(typelessParsedVitestFnCall.members) if (typeof modifiersMatcher === 'string') @@ -239,7 +243,7 @@ const parseVitestExpectCall = (typelessParsedVitestFnCall: Omit = { @@ -311,8 +315,8 @@ const parseVistestFnCallWithReasonInner = ( const type = determineVitestFnType(name) - if (type === 'expect') { - const result = parseVitestExpectCall(parsedVitestFnCall) + if (type === 'expect' || type === 'expectTypeOf') { + const result = parseVitestExpectCall(parsedVitestFnCall, type) if (typeof result === 'string' && findTopMostCallExpression(node) !== node) return null diff --git a/src/utils/tester.ts b/src/utils/tester.ts new file mode 100644 index 0000000..57fa65b --- /dev/null +++ b/src/utils/tester.ts @@ -0,0 +1,7 @@ +import { TSESLint } from '@typescript-eslint/utils' + +const ruleTester = new TSESLint.RuleTester({ + parser: require.resolve('@typescript-eslint/parser') +}) + +export default ruleTester