Skip to content

Commit cb2353b

Browse files
authoredMar 26, 2023
feat: updated (#122)
1 parent c68028d commit cb2353b

10 files changed

+264
-20
lines changed
 

‎README.md

+1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ To use the all configuration, extend it in your `.eslintrc` file:
104104
| [prefer-lowercase-title](docs/rules/prefer-lowercase-title.md) | Enforce lowercase titles | 🌐 | 🔧 | |
105105
| [prefer-to-be](docs/rules/prefer-to-be.md) | Suggest using toBe() || 🔧 | |
106106
| [prefer-to-be-false](docs/rules/prefer-to-be-false.md) | Suggest using toBeFalsy() | 🌐 | 🔧 | |
107+
| [prefer-to-be-object](docs/rules/prefer-to-be-object.md) | Prefer toBeObject() | 🌐 | 🔧 | |
107108
| [valid-expect](docs/rules/valid-expect.md) | Enforce valid `expect()` usage || | |
108109
| [valid-title](docs/rules/valid-title.md) | Enforce valid titles || 🔧 | |
109110

‎docs/rules/prefer-to-be-object.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Prefer toBeObject() (`vitest/prefer-to-be-object`)
2+
3+
⚠️ This rule _warns_ in the 🌐 `all` config.
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
```js
9+
expectTypeOf({}).not.toBeInstanceOf(Object);
10+
11+
// should be
12+
expectTypeOf({}).not.toBeObject();
13+
```

‎src/index.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import preferCalledWith, { RULE_NAME as preferCalledWithName } from './rules/pre
2828
import validTitle, { RULE_NAME as validTitleName } from './rules/valid-title'
2929
import validExpect, { RULE_NAME as validExpectName } from './rules/valid-expect'
3030
import preferToBeFalse, { RULE_NAME as preferToBeFalseName } from './rules/prefer-to-be-false'
31+
import preferToBeObject, { RULE_NAME as preferToBeObjectName } from './rules/prefer-to-be-object'
3132

3233
const createConfig = (rules: Record<string, string>) => ({
3334
plugins: ['vitest'],
@@ -63,7 +64,8 @@ const allRules = {
6364
[noTestPrefixesName]: 'warn',
6465
[noTestReturnStatementName]: 'warn',
6566
[preferCalledWithName]: 'warn',
66-
[preferToBeFalseName]: 'warn'
67+
[preferToBeFalseName]: 'warn',
68+
[preferToBeObjectName]: 'warn'
6769
}
6870

6971
const recommended = {
@@ -106,7 +108,8 @@ export default {
106108
[preferCalledWithName]: preferCalledWith,
107109
[validTitleName]: validTitle,
108110
[validExpectName]: validExpect,
109-
[preferToBeFalseName]: preferToBeFalse
111+
[preferToBeFalseName]: preferToBeFalse,
112+
[preferToBeObjectName]: preferToBeObject
110113
},
111114
configs: {
112115
all: createConfig(allRules),

‎src/rules/prefer-to-be-object.test.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { test, describe } from 'vitest'
2+
import ruleTester from '../utils/tester'
3+
import rule, { RULE_NAME } from './prefer-to-be-object'
4+
5+
const messageId = 'preferToBeObject'
6+
7+
describe(RULE_NAME, () => {
8+
test(RULE_NAME, () => {
9+
ruleTester.run(RULE_NAME, rule, {
10+
valid: [
11+
'expectTypeOf.hasAssertions',
12+
'expectTypeOf.hasAssertions()',
13+
'expectTypeOf',
14+
'expectTypeOf().not',
15+
'expectTypeOf().toBe',
16+
'expectTypeOf().toBe(true)',
17+
'expectTypeOf({}).toBe(true)',
18+
'expectTypeOf({}).toBeObject()',
19+
'expectTypeOf({}).not.toBeObject()',
20+
'expectTypeOf([] instanceof Array).not.toBeObject()',
21+
'expectTypeOf({}).not.toBeInstanceOf(Array)'
22+
],
23+
invalid: [
24+
{
25+
code: 'expectTypeOf(({} instanceof Object)).toBeTruthy();',
26+
output: 'expectTypeOf(({})).toBeObject();',
27+
errors: [{ messageId: 'preferToBeObject', column: 38, line: 1 }]
28+
},
29+
{
30+
code: 'expectTypeOf({} instanceof Object).toBeTruthy();',
31+
output: 'expectTypeOf({}).toBeObject();',
32+
errors: [{ messageId, column: 36, line: 1 }]
33+
},
34+
{
35+
code: 'expectTypeOf({} instanceof Object).not.toBeTruthy();',
36+
output: 'expectTypeOf({}).not.toBeObject();',
37+
errors: [{ messageId, column: 40, line: 1 }]
38+
},
39+
{
40+
code: 'expectTypeOf({} instanceof Object).toBeFalsy();',
41+
output: 'expectTypeOf({}).not.toBeObject();',
42+
errors: [{ messageId, column: 36, line: 1 }]
43+
},
44+
{
45+
code: 'expectTypeOf({} instanceof Object).not.toBeFalsy();',
46+
output: 'expectTypeOf({}).toBeObject();',
47+
errors: [{ messageId, column: 40, line: 1 }]
48+
},
49+
{
50+
code: 'expectTypeOf({}).toBeInstanceOf(Object);',
51+
output: 'expectTypeOf({}).toBeObject();',
52+
errors: [{ messageId, column: 18, line: 1 }]
53+
},
54+
{
55+
code: 'expectTypeOf({}).not.toBeInstanceOf(Object);',
56+
output: 'expectTypeOf({}).not.toBeObject();',
57+
errors: [{ messageId, column: 22, line: 1 }]
58+
},
59+
{
60+
code: 'expectTypeOf(requestValues()).resolves.toBeInstanceOf(Object);',
61+
output: 'expectTypeOf(requestValues()).resolves.toBeObject();',
62+
errors: [{ messageId, column: 40, line: 1 }]
63+
},
64+
{
65+
code: 'expectTypeOf(queryApi()).resolves.not.toBeInstanceOf(Object);',
66+
output: 'expectTypeOf(queryApi()).resolves.not.toBeObject();',
67+
errors: [{ messageId, column: 39, line: 1 }]
68+
}
69+
]
70+
})
71+
})
72+
})

‎src/rules/prefer-to-be-object.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
2+
import { createEslintRule, getAccessorValue, isParsedInstanceOfMatcherCall } from '../utils'
3+
import { isBooleanEqualityMatcher, isInstanceOfBinaryExpression } from '../utils/msc'
4+
import { followTypeAssertionChain, parseVitestFnCall } from '../utils/parseVitestFnCall'
5+
6+
export const RULE_NAME = 'prefer-to-be-object'
7+
export type MESSAGE_IDS = 'preferToBeObject';
8+
export type Options = []
9+
10+
export default createEslintRule<Options, MESSAGE_IDS>({
11+
name: RULE_NAME,
12+
meta: {
13+
type: 'suggestion',
14+
docs: {
15+
description: 'Prefer toBeObject()',
16+
recommended: 'error'
17+
},
18+
fixable: 'code',
19+
messages: {
20+
preferToBeObject: 'Prefer toBeObject() to test if a value is an object.'
21+
},
22+
schema: []
23+
},
24+
defaultOptions: [],
25+
create(context) {
26+
return {
27+
CallExpression(node) {
28+
const vitestFnCall = parseVitestFnCall(node, context)
29+
30+
if (vitestFnCall?.type !== 'expectTypeOf')
31+
return
32+
33+
if (isParsedInstanceOfMatcherCall(vitestFnCall, 'Object')) {
34+
context.report({
35+
node: vitestFnCall.matcher,
36+
messageId: 'preferToBeObject',
37+
fix: fixer => [
38+
fixer.replaceTextRange(
39+
[
40+
vitestFnCall.matcher.range[0],
41+
vitestFnCall.matcher.range[1] + '(Object)'.length
42+
],
43+
'toBeObject()'
44+
)
45+
]
46+
})
47+
return
48+
}
49+
50+
const { parent: expectTypeOf } = vitestFnCall.head.node
51+
52+
if (expectTypeOf?.type !== AST_NODE_TYPES.CallExpression)
53+
return
54+
55+
const [expectTypeOfArgs] = expectTypeOf.arguments
56+
57+
if (!expectTypeOfArgs ||
58+
!isBooleanEqualityMatcher(vitestFnCall) ||
59+
!isInstanceOfBinaryExpression(expectTypeOfArgs, 'Object'))
60+
return
61+
62+
context.report({
63+
node: vitestFnCall.matcher,
64+
messageId: 'preferToBeObject',
65+
fix(fixer) {
66+
const fixes = [
67+
fixer.replaceText(vitestFnCall.matcher, 'toBeObject'),
68+
fixer.removeRange([expectTypeOfArgs.left.range[1], expectTypeOfArgs.range[1]])
69+
]
70+
71+
let invertCondition = getAccessorValue(vitestFnCall.matcher) === 'toBeFalsy'
72+
73+
if (vitestFnCall.args.length) {
74+
const [matcherArg] = vitestFnCall.args
75+
76+
fixes.push(fixer.remove(matcherArg))
77+
78+
invertCondition = matcherArg.type === AST_NODE_TYPES.Literal &&
79+
followTypeAssertionChain(matcherArg).value === false
80+
}
81+
82+
if (invertCondition) {
83+
const notModifier = vitestFnCall.modifiers.find(node => getAccessorValue(node) === 'not')
84+
85+
fixes.push(notModifier
86+
? fixer.removeRange([
87+
notModifier.range[0] - 1,
88+
notModifier.range[1]
89+
])
90+
: fixer.insertTextBefore(vitestFnCall.matcher, 'not.')
91+
)
92+
}
93+
return fixes
94+
}
95+
})
96+
}
97+
}
98+
}
99+
})

‎src/rules/valid-title.test.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import { TSESLint } from '@typescript-eslint/utils'
21
import { describe, it } from 'vitest'
2+
import ruleTester from '../utils/tester'
33
import rule, { RULE_NAME } from './valid-title'
44

5-
const ruleTester = new TSESLint.RuleTester({
6-
parser: require.resolve('@typescript-eslint/parser')
7-
})
8-
95
describe(RULE_NAME, () => {
106
it(`${RULE_NAME} - disallowed option`, () => {
117
ruleTester.run(RULE_NAME, rule, {

‎src/utils/index.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/* eslint-disable no-use-before-define */
33
// Imported from https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/utils/accessors.ts#L6
44
import { TSESLint, AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'
5-
import { KnownMemberExpression } from './parseVitestFnCall'
5+
import { KnownMemberExpression, ParsedExpectVitestFnCall } from './parseVitestFnCall'
66

77
export const createEslintRule = ESLintUtils.RuleCreator((ruleName) => `https://github.com/veritem/eslint-plugin-vitest/blob/main/docs/rules/${ruleName}.md`)
88

@@ -156,9 +156,11 @@ export const removeExtraArgumentsFixer = (
156156

157157
const sourceCode = context.getSourceCode()
158158

159+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
159160
let tokenAfterLastParam = sourceCode.getTokenAfter(lastArg)!
160161

161162
if (tokenAfterLastParam.value === ',')
163+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
162164
tokenAfterLastParam = sourceCode.getTokenAfter(tokenAfterLastParam)!
163165

164166
return fixer.removeRange([firstArg.range[0], tokenAfterLastParam.range[0]])
@@ -173,3 +175,14 @@ export interface KnownCallExpression<Name extends string = string>
173175
extends TSESTree.CallExpression {
174176
callee: CalledKnownMemberExpression<Name>;
175177
}
178+
179+
export const isParsedInstanceOfMatcherCall = (
180+
expectFnCall: ParsedExpectVitestFnCall,
181+
classArg?: string
182+
) => {
183+
return (
184+
getAccessorValue(expectFnCall.matcher) === 'toBeInstanceOf' &&
185+
expectFnCall.args.length === 1 &&
186+
isSupportedAccessor(expectFnCall.args[0], classArg)
187+
)
188+
}

‎src/utils/msc.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'
2+
import { getFirstMatcherArg, ParsedExpectVitestFnCall } from './parseVitestFnCall'
3+
import { EqualityMatcher } from './types'
4+
import { getAccessorValue, isSupportedAccessor } from '.'
5+
6+
export const isBooleanLiteral = (node: TSESTree.Node): node is TSESTree.BooleanLiteral =>
7+
node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean'
8+
9+
/**
10+
* Checks if the given `ParsedExpectMatcher` is either a call to one of the equality matchers,
11+
* with a boolean` literal as the sole argument, *or* is a call to `toBeTruthy` or `toBeFalsy`.
12+
*/
13+
export const isBooleanEqualityMatcher = (
14+
expectFnCall: ParsedExpectVitestFnCall
15+
): boolean => {
16+
const matcherName = getAccessorValue(expectFnCall.matcher)
17+
18+
if (['toBeTruthy', 'toBeFalsy'].includes(matcherName))
19+
return true
20+
21+
if (expectFnCall.args.length !== 1)
22+
return false
23+
24+
const arg = getFirstMatcherArg(expectFnCall)
25+
26+
// eslint-disable-next-line no-prototype-builtins
27+
return EqualityMatcher.hasOwnProperty(matcherName) && isBooleanLiteral(arg)
28+
}
29+
30+
export const isInstanceOfBinaryExpression = (
31+
node: TSESTree.Node,
32+
className: string
33+
): node is TSESTree.BinaryExpression =>
34+
node.type === AST_NODE_TYPES.BinaryExpression &&
35+
node.operator === 'instanceof' &&
36+
isSupportedAccessor(node.right, className)

‎src/utils/parseVitestFnCall.ts

+16-12
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type VitestFnType =
6464
| 'unknown'
6565
| 'hook'
6666
| 'vi'
67+
| 'expectTypeOf'
6768

6869
interface ResolvedVitestFn {
6970
original: string | null,
@@ -95,7 +96,7 @@ interface ModifiersAndMatcher {
9596
args: TSESTree.CallExpression['arguments'];
9697
}
9798

98-
interface BaseParsedJestFnCall {
99+
interface BaseParsedVitestFnCall {
99100
/**
100101
* The name of the underlying Vitest function that is being called.
101102
* This is the result of `(head.original ?? head.local)`.
@@ -106,12 +107,12 @@ interface BaseParsedJestFnCall {
106107
members: KnownMemberExpressionProperty[];
107108
}
108109

109-
interface ParsedGeneralVitestFnCall extends BaseParsedJestFnCall {
110-
type: Exclude<VitestFnType, 'expect'>
110+
interface ParsedGeneralVitestFnCall extends BaseParsedVitestFnCall {
111+
type: Exclude<VitestFnType, 'expect'> & Exclude<VitestFnType, 'expectTypeOf'>
111112
}
112113

113-
export interface ParsedExpectVitestFnCall extends BaseParsedJestFnCall, ModifiersAndMatcher {
114-
type: 'expect'
114+
export interface ParsedExpectVitestFnCall extends BaseParsedVitestFnCall, ModifiersAndMatcher {
115+
type: 'expect' | 'expectTypeOf'
115116
}
116117

117118
export type ParsedVitestFnCall = ParsedGeneralVitestFnCall | ParsedExpectVitestFnCall
@@ -128,7 +129,7 @@ export const isTypeOfVitestFnCall = (
128129
export const parseVitestFnCall = (
129130
node: TSESTree.CallExpression,
130131
context: TSESLint.RuleContext<string, unknown[]>
131-
) => {
132+
): ParsedVitestFnCall | null => {
132133
const vitestFnCall = parseVitestFnCallWithReason(node, context)
133134

134135
if (typeof vitestFnCall === 'string')
@@ -145,7 +146,7 @@ const parseVitestFnCallCache = new WeakMap<
145146
export const parseVitestFnCallWithReason = (
146147
node: TSESTree.CallExpression,
147148
context: TSESLint.RuleContext<string, unknown[]>
148-
) => {
149+
): ParsedVitestFnCall | string | null => {
149150
let parsedVistestFnCall = parseVitestFnCallCache.get(node)
150151

151152
if (parsedVistestFnCall)
@@ -162,6 +163,9 @@ const determineVitestFnType = (name: string): VitestFnType => {
162163
if (name === 'expect')
163164
return 'expect'
164165

166+
if (name === 'expectTypeOf')
167+
return 'expectTypeOf'
168+
165169
if (name === 'vi')
166170
return 'vi'
167171

@@ -231,15 +235,15 @@ const findModifiersAndMatcher = (
231235
return 'matcher-not-found'
232236
}
233237

234-
const parseVitestExpectCall = (typelessParsedVitestFnCall: Omit<ParsedVitestFnCall, 'type'>): ParsedExpectVitestFnCall | string => {
238+
const parseVitestExpectCall = (typelessParsedVitestFnCall: Omit<ParsedVitestFnCall, 'type'>, type: 'expect' | 'expectTypeOf'): ParsedExpectVitestFnCall | string => {
235239
const modifiersMatcher = findModifiersAndMatcher(typelessParsedVitestFnCall.members)
236240

237241
if (typeof modifiersMatcher === 'string')
238242
return modifiersMatcher
239243

240244
return {
241245
...typelessParsedVitestFnCall,
242-
type: 'expect',
246+
type,
243247
...modifiersMatcher
244248
}
245249
}
@@ -300,7 +304,7 @@ const parseVistestFnCallWithReasonInner = (
300304

301305
const links = [name, ...rest.map(getAccessorValue)]
302306

303-
if (name !== 'vi' && name !== 'expect' && !ValidVitestFnCallChains.includes(links.join('.')))
307+
if (name !== 'vi' && name !== 'expect' && name !== 'expectTypeOf' && !ValidVitestFnCallChains.includes(links.join('.')))
304308
return null
305309

306310
const parsedVitestFnCall: Omit<ParsedVitestFnCall, 'type'> = {
@@ -311,8 +315,8 @@ const parseVistestFnCallWithReasonInner = (
311315

312316
const type = determineVitestFnType(name)
313317

314-
if (type === 'expect') {
315-
const result = parseVitestExpectCall(parsedVitestFnCall)
318+
if (type === 'expect' || type === 'expectTypeOf') {
319+
const result = parseVitestExpectCall(parsedVitestFnCall, type)
316320

317321
if (typeof result === 'string' && findTopMostCallExpression(node) !== node)
318322
return null

‎src/utils/tester.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { TSESLint } from '@typescript-eslint/utils'
2+
3+
const ruleTester = new TSESLint.RuleTester({
4+
parser: require.resolve('@typescript-eslint/parser')
5+
})
6+
7+
export default ruleTester

0 commit comments

Comments
 (0)