Skip to content

Commit ebf5717

Browse files
StyleShitveritem
andauthored
feat: add rule require-awaited-expect-poll (#817)
* feat: add rule `require-awaited-expect-poll` Closes #496 * cleanup * cleanup * chore: fix linter --------- Co-authored-by: Verite Mugabo <mugaboverite@gmail.com>
1 parent b6ed3b9 commit ebf5717

File tree

6 files changed

+370
-1
lines changed

6 files changed

+370
-1
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export default defineConfig({
252252
| [prefer-to-have-length](docs/rules/prefer-to-have-length.md) | enforce using toHaveLength() | | 🌐 | | 🔧 | | | |
253253
| [prefer-todo](docs/rules/prefer-todo.md) | enforce using `test.todo` | | 🌐 | | 🔧 | | | |
254254
| [prefer-vi-mocked](docs/rules/prefer-vi-mocked.md) | require `vi.mocked()` over `fn as Mock` | | 🌐 | | 🔧 | | 💭 | |
255+
| [require-awaited-expect-poll](docs/rules/require-awaited-expect-poll.md) | ensure that every `expect.poll` call is awaited | | | | | | | |
255256
| [require-hook](docs/rules/require-hook.md) | require setup and teardown to be within a hook | | 🌐 | | | | | |
256257
| [require-local-test-context-for-concurrent-snapshots](docs/rules/require-local-test-context-for-concurrent-snapshots.md) | require local Test Context for concurrent snapshot tests || 🌐 | | | | | |
257258
| [require-mock-type-parameters](docs/rules/require-mock-type-parameters.md) | enforce using type parameters with vitest mock functions | | 🌐 | | 🔧 | | | |
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Ensure that every `expect.poll` call is awaited (`vitest/require-awaited-expect-poll`)
2+
3+
<!-- end auto-generated rule header -->
4+
5+
### Rule Details
6+
7+
This rule ensures that promises returned by `expect.poll` & `expect.element` calls are handled properly.
8+
9+
Examples of **incorrect** code for this rule:
10+
11+
```js
12+
test('element exists', () => {
13+
asyncInjectElement()
14+
15+
expect.poll(() => document.querySelector('.element')).toBeInTheDocument()
16+
})
17+
```
18+
19+
Examples of **correct** code for this rule:
20+
21+
```js
22+
test('element exists', async () => {
23+
asyncInjectElement()
24+
25+
await expect
26+
.poll(() => document.querySelector('.element'))
27+
.toBeInTheDocument()
28+
})
29+
```
30+
31+
```js
32+
test('element exists', () => {
33+
asyncInjectElement()
34+
35+
return expect
36+
.poll(() => document.querySelector('.element'))
37+
.toBeInTheDocument()
38+
})
39+
```

src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import preferToContain from './prefer-to-contain'
6464
import preferToHaveLength from './prefer-to-have-length'
6565
import preferTodo from './prefer-todo'
6666
import preferViMocked from './prefer-vi-mocked'
67+
import requireAwaitedExpectPoll from './require-awaited-expect-poll'
6768
import requireHook from './require-hook'
6869
import requireLocalTestContextForConcurrentSnapshots from './require-local-test-context-for-concurrent-snapshots'
6970
import requireMockTypeParameters from './require-mock-type-parameters'
@@ -141,6 +142,7 @@ export const rules = {
141142
'prefer-to-have-length': preferToHaveLength,
142143
'prefer-todo': preferTodo,
143144
'prefer-vi-mocked': preferViMocked,
145+
'require-awaited-expect-poll': requireAwaitedExpectPoll,
144146
'require-hook': requireHook,
145147
'require-local-test-context-for-concurrent-snapshots':
146148
requireLocalTestContextForConcurrentSnapshots,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'
2+
import { AccessorNode, createEslintRule, getAccessorValue } from '../utils'
3+
import {
4+
KnownMemberExpressionProperty,
5+
parseVitestFnCall,
6+
} from '../utils/parse-vitest-fn-call'
7+
8+
export const RULE_NAME = 'require-awaited-expect-poll'
9+
export type MESSAGE_ID = 'notAwaited'
10+
export type Options = []
11+
12+
export default createEslintRule<Options, MESSAGE_ID>({
13+
name: RULE_NAME,
14+
meta: {
15+
docs: {
16+
requiresTypeChecking: false,
17+
recommended: false,
18+
description: 'ensure that every `expect.poll` call is awaited',
19+
},
20+
messages: {
21+
notAwaited: '`{{ method }}` calls should be awaited',
22+
},
23+
type: 'problem',
24+
schema: [],
25+
},
26+
defaultOptions: [],
27+
create(context) {
28+
const reported = new Set<TSESTree.Node>()
29+
30+
return {
31+
CallExpression(node) {
32+
const vitestFnCall = parseVitestFnCall(node, context)
33+
34+
if (
35+
vitestFnCall?.type !== 'expect' ||
36+
!vitestFnCall.members.length ||
37+
!memberRequiresAwait(vitestFnCall.members[0])
38+
) {
39+
return
40+
}
41+
42+
const nodeToReport = vitestFnCall.members[0].parent
43+
44+
if (reported.has(nodeToReport)) {
45+
return
46+
}
47+
48+
const topMostNode = skipSequenceExpressions(
49+
skipMatchersAndModifiers(vitestFnCall.head.node),
50+
)
51+
52+
const isHandled =
53+
topMostNode.parent?.type === AST_NODE_TYPES.AwaitExpression ||
54+
topMostNode.parent?.type === AST_NODE_TYPES.ReturnStatement
55+
56+
if (isHandled) {
57+
return
58+
}
59+
60+
context.report({
61+
node: nodeToReport,
62+
messageId: 'notAwaited',
63+
data: {
64+
method: `expect.${getAccessorValue(vitestFnCall.members[0])}`,
65+
},
66+
})
67+
68+
reported.add(nodeToReport)
69+
},
70+
}
71+
},
72+
})
73+
74+
const awaitedMembers = ['poll', 'element']
75+
76+
function memberRequiresAwait(member: KnownMemberExpressionProperty): boolean {
77+
return awaitedMembers.includes(getAccessorValue(member))
78+
}
79+
80+
function skipMatchersAndModifiers(node: AccessorNode): TSESTree.Node {
81+
let currentNode: TSESTree.Node = node
82+
83+
while (
84+
currentNode.parent.type === AST_NODE_TYPES.MemberExpression ||
85+
currentNode.parent.type === AST_NODE_TYPES.CallExpression
86+
) {
87+
currentNode = currentNode.parent
88+
}
89+
90+
return currentNode
91+
}
92+
93+
function skipSequenceExpressions(node: TSESTree.Node): TSESTree.Node {
94+
let currentNode: TSESTree.Node = node
95+
96+
while (
97+
currentNode.parent?.type === AST_NODE_TYPES.SequenceExpression &&
98+
currentNode.parent.expressions.at(-1) === currentNode
99+
) {
100+
currentNode = currentNode.parent
101+
}
102+
103+
return currentNode
104+
}

src/utils/parse-vitest-fn-call.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface KnownMemberExpression<Name extends string = string>
4545
property: AccessorNode<Name>
4646
}
4747

48-
type KnownMemberExpressionProperty<Specifies extends string = string> =
48+
export type KnownMemberExpressionProperty<Specifies extends string = string> =
4949
AccessorNode<Specifies> & { parent: KnownMemberExpression<Specifies> }
5050

5151
interface ModifiersAndMatcher {

0 commit comments

Comments
 (0)