Skip to content

Commit

Permalink
feat: updated (#122)
Browse files Browse the repository at this point in the history
  • Loading branch information
veritem committed Mar 26, 2023
1 parent c68028d commit cb2353b
Show file tree
Hide file tree
Showing 10 changed files with 264 additions and 20 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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 || 🔧 | |

Expand Down
13 changes: 13 additions & 0 deletions 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).

<!-- end auto-generated rule header -->
```js
expectTypeOf({}).not.toBeInstanceOf(Object);

// should be
expectTypeOf({}).not.toBeObject();
```
7 changes: 5 additions & 2 deletions src/index.ts
Expand Up @@ -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<string, string>) => ({
plugins: ['vitest'],
Expand Down Expand Up @@ -63,7 +64,8 @@ const allRules = {
[noTestPrefixesName]: 'warn',
[noTestReturnStatementName]: 'warn',
[preferCalledWithName]: 'warn',
[preferToBeFalseName]: 'warn'
[preferToBeFalseName]: 'warn',
[preferToBeObjectName]: 'warn'
}

const recommended = {
Expand Down Expand Up @@ -106,7 +108,8 @@ export default {
[preferCalledWithName]: preferCalledWith,
[validTitleName]: validTitle,
[validExpectName]: validExpect,
[preferToBeFalseName]: preferToBeFalse
[preferToBeFalseName]: preferToBeFalse,
[preferToBeObjectName]: preferToBeObject
},
configs: {
all: createConfig(allRules),
Expand Down
72 changes: 72 additions & 0 deletions 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 }]
}
]
})
})
})
99 changes: 99 additions & 0 deletions 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<Options, MESSAGE_IDS>({
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
}
})
}
}
}
})
6 changes: 1 addition & 5 deletions 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, {
Expand Down
15 changes: 14 additions & 1 deletion src/utils/index.ts
Expand Up @@ -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`)

Expand Down Expand Up @@ -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]])
Expand All @@ -173,3 +175,14 @@ export interface KnownCallExpression<Name extends string = string>
extends TSESTree.CallExpression {
callee: CalledKnownMemberExpression<Name>;
}

export const isParsedInstanceOfMatcherCall = (
expectFnCall: ParsedExpectVitestFnCall,
classArg?: string
) => {
return (
getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' &&
expectFnCall.args.length === 1 &&
isSupportedAccessor(expectFnCall.args[0], classArg)
)
}
36 changes: 36 additions & 0 deletions 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)

0 comments on commit cb2353b

Please sign in to comment.