Skip to content

Commit 2cc2263

Browse files
J-Huangjhuang
and
jhuang
authored
feat: add no-await-sync-events rule (#240)
* feat(rule): add no-await-sync-events rule * feat(rule): address feedback * feat(rule): update doc * Update no-await-sync-events.md * feat(rule): clean doc * feat(rule): clean doc Co-authored-by: jhuang <[email protected]>
1 parent f78720d commit 2cc2263

File tree

6 files changed

+301
-0
lines changed

6 files changed

+301
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ To enable this configuration use the `extends` property in your
137137
| [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
138138
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
139139
| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | |
140+
| [no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | | |
140141
| [no-await-sync-query](docs/rules/no-await-sync-query.md) | Disallow unnecessary `await` for sync queries | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
141142
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
142143
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |

docs/rules/no-await-sync-events.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Disallow unnecessary `await` for sync events (no-await-sync-events)
2+
3+
Ensure that sync events are not awaited unnecessarily.
4+
5+
## Rule Details
6+
7+
Functions in the event object provided by Testing Library, including
8+
fireEvent and userEvent, do NOT return Promise, with an exception of
9+
`userEvent.type`, which delays the promise resolve only if [`delay`
10+
option](https://github.com/testing-library/user-event#typeelement-text-options) is specified.
11+
Some examples are:
12+
13+
- `fireEvent.click`
14+
- `fireEvent.select`
15+
- `userEvent.tab`
16+
- `userEvent.hover`
17+
18+
This rule aims to prevent users from waiting for those function calls.
19+
20+
Examples of **incorrect** code for this rule:
21+
22+
```js
23+
const foo = async () => {
24+
// ...
25+
await fireEvent.click(button);
26+
// ...
27+
};
28+
29+
const bar = () => {
30+
// ...
31+
await userEvent.tab();
32+
// ...
33+
};
34+
35+
const baz = () => {
36+
// ...
37+
await userEvent.type(textInput, 'abc');
38+
// ...
39+
};
40+
```
41+
42+
Examples of **correct** code for this rule:
43+
44+
```js
45+
const foo = () => {
46+
// ...
47+
fireEvent.click(button);
48+
// ...
49+
};
50+
51+
const bar = () => {
52+
// ...
53+
userEvent.tab();
54+
// ...
55+
};
56+
57+
const baz = () => {
58+
// await userEvent.type only with delay option
59+
await userEvent.type(textInput, 'abc', {delay: 1000});
60+
userEvent.type(textInput, '123');
61+
// ...
62+
};
63+
```
64+
65+
## Notes
66+
67+
There is another rule `await-fire-event`, which is only in Vue Testing
68+
Library. Please do not confuse with this rule.

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import awaitAsyncQuery from './rules/await-async-query';
22
import awaitAsyncUtils from './rules/await-async-utils';
33
import awaitFireEvent from './rules/await-fire-event';
44
import consistentDataTestid from './rules/consistent-data-testid';
5+
import noAwaitSyncEvents from './rules/no-await-sync-events';
56
import noAwaitSyncQuery from './rules/no-await-sync-query';
67
import noDebug from './rules/no-debug';
78
import noDomImport from './rules/no-dom-import';
@@ -20,6 +21,7 @@ const rules = {
2021
'await-async-utils': awaitAsyncUtils,
2122
'await-fire-event': awaitFireEvent,
2223
'consistent-data-testid': consistentDataTestid,
24+
'no-await-sync-events': noAwaitSyncEvents,
2325
'no-await-sync-query': noAwaitSyncQuery,
2426
'no-debug': noDebug,
2527
'no-dom-import': noDomImport,

lib/rules/no-await-sync-events.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
2+
import { getDocsUrl, SYNC_EVENTS } from '../utils';
3+
import { isObjectExpression, isProperty, isIdentifier } from '../node-utils';
4+
export const RULE_NAME = 'no-await-sync-events';
5+
export type MessageIds = 'noAwaitSyncEvents';
6+
type Options = [];
7+
8+
const SYNC_EVENTS_REGEXP = new RegExp(`^(${SYNC_EVENTS.join('|')})$`);
9+
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
10+
name: RULE_NAME,
11+
meta: {
12+
type: 'problem',
13+
docs: {
14+
description: 'Disallow unnecessary `await` for sync events',
15+
category: 'Best Practices',
16+
recommended: 'error',
17+
},
18+
messages: {
19+
noAwaitSyncEvents: '`{{ name }}` does not need `await` operator',
20+
},
21+
fixable: null,
22+
schema: [],
23+
},
24+
defaultOptions: [],
25+
26+
create(context) {
27+
// userEvent.type() is an exception, which returns a
28+
// Promise. But it is only necessary to wait when delay
29+
// option is specified. So this rule has a special exception
30+
// for the case await userEvent.type(element, 'abc', {delay: 1234})
31+
return {
32+
[`AwaitExpression > CallExpression > MemberExpression > Identifier[name=${SYNC_EVENTS_REGEXP}]`](
33+
node: TSESTree.Identifier
34+
) {
35+
const memberExpression = node.parent as TSESTree.MemberExpression;
36+
const methodNode = memberExpression.property as TSESTree.Identifier;
37+
const callExpression = memberExpression.parent as TSESTree.CallExpression;
38+
const withDelay = callExpression.arguments.length >= 3 &&
39+
isObjectExpression(callExpression.arguments[2]) &&
40+
callExpression.arguments[2].properties.some(
41+
property =>
42+
isProperty(property) &&
43+
isIdentifier(property.key) &&
44+
property.key.name === 'delay'
45+
);
46+
47+
if (!(node.name === 'userEvent' && methodNode.name === 'type' && withDelay)) {
48+
context.report({
49+
node: methodNode,
50+
messageId: 'noAwaitSyncEvents',
51+
data: {
52+
name: `${node.name}.${methodNode.name}`,
53+
},
54+
});
55+
}
56+
},
57+
};
58+
},
59+
});

lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ const ASYNC_UTILS = [
6363
'waitForDomChange',
6464
];
6565

66+
const SYNC_EVENTS = [
67+
'fireEvent',
68+
'userEvent',
69+
];
70+
6671
const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll'];
6772

6873
const PRESENCE_MATCHERS = ['toBeInTheDocument', 'toBeTruthy', 'toBeDefined'];
@@ -78,6 +83,7 @@ export {
7883
ASYNC_QUERIES_COMBINATIONS,
7984
ALL_QUERIES_COMBINATIONS,
8085
ASYNC_UTILS,
86+
SYNC_EVENTS,
8187
TESTING_FRAMEWORK_SETUP_HOOKS,
8288
LIBRARY_MODULES,
8389
PRESENCE_MATCHERS,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { createRuleTester } from '../test-utils';
2+
import rule, { RULE_NAME } from '../../../lib/rules/no-await-sync-events';
3+
import { SYNC_EVENTS } from '../../../lib/utils';
4+
5+
const ruleTester = createRuleTester();
6+
7+
const fireEventFunctions = [
8+
'copy',
9+
'cut',
10+
'paste',
11+
'compositionEnd',
12+
'compositionStart',
13+
'compositionUpdate',
14+
'keyDown',
15+
'keyPress',
16+
'keyUp',
17+
'focus',
18+
'blur',
19+
'focusIn',
20+
'focusOut',
21+
'change',
22+
'input',
23+
'invalid',
24+
'submit',
25+
'reset',
26+
'click',
27+
'contextMenu',
28+
'dblClick',
29+
'drag',
30+
'dragEnd',
31+
'dragEnter',
32+
'dragExit',
33+
'dragLeave',
34+
'dragOver',
35+
'dragStart',
36+
'drop',
37+
'mouseDown',
38+
'mouseEnter',
39+
'mouseLeave',
40+
'mouseMove',
41+
'mouseOut',
42+
'mouseOver',
43+
'mouseUp',
44+
'popState',
45+
'select',
46+
'touchCancel',
47+
'touchEnd',
48+
'touchMove',
49+
'touchStart',
50+
'scroll',
51+
'wheel',
52+
'abort',
53+
'canPlay',
54+
'canPlayThrough',
55+
'durationChange',
56+
'emptied',
57+
'encrypted',
58+
'ended',
59+
'loadedData',
60+
'loadedMetadata',
61+
'loadStart',
62+
'pause',
63+
'play',
64+
'playing',
65+
'progress',
66+
'rateChange',
67+
'seeked',
68+
'seeking',
69+
'stalled',
70+
'suspend',
71+
'timeUpdate',
72+
'volumeChange',
73+
'waiting',
74+
'load',
75+
'error',
76+
'animationStart',
77+
'animationEnd',
78+
'animationIteration',
79+
'transitionEnd',
80+
'doubleClick',
81+
'pointerOver',
82+
'pointerEnter',
83+
'pointerDown',
84+
'pointerMove',
85+
'pointerUp',
86+
'pointerCancel',
87+
'pointerOut',
88+
'pointerLeave',
89+
'gotPointerCapture',
90+
'lostPointerCapture',
91+
];
92+
const userEventFunctions = [
93+
'clear',
94+
'click',
95+
'dblClick',
96+
'selectOptions',
97+
'deselectOptions',
98+
'upload',
99+
// 'type',
100+
'tab',
101+
'paste',
102+
'hover',
103+
'unhover',
104+
];
105+
let eventFunctions: string[] = [];
106+
SYNC_EVENTS.forEach(event => {
107+
switch (event) {
108+
case 'fireEvent':
109+
eventFunctions = eventFunctions.concat(fireEventFunctions.map((f: string): string => `${event}.${f}`));
110+
break;
111+
case 'userEvent':
112+
eventFunctions = eventFunctions.concat(userEventFunctions.map((f: string): string => `${event}.${f}`));
113+
break;
114+
default:
115+
eventFunctions.push(`${event}.anyFunc`);
116+
}
117+
});
118+
119+
ruleTester.run(RULE_NAME, rule, {
120+
valid: [
121+
// sync events without await are valid
122+
// userEvent.type() is an exception
123+
...eventFunctions.map(func => ({
124+
code: `() => {
125+
${func}('foo')
126+
}
127+
`,
128+
})),
129+
{
130+
code: `() => {
131+
userEvent.type('foo')
132+
}
133+
`,
134+
},
135+
{
136+
code: `() => {
137+
await userEvent.type('foo', 'bar', {delay: 1234})
138+
}
139+
`,
140+
},
141+
],
142+
143+
invalid: [
144+
// sync events with await operator are not valid
145+
...eventFunctions.map(func => ({
146+
code: `
147+
import { fireEvent } from '@testing-library/framework';
148+
import userEvent from '@testing-library/user-event';
149+
test('should report sync event awaited', async() => {
150+
await ${func}('foo');
151+
});
152+
`,
153+
errors: [{ line: 5, messageId: 'noAwaitSyncEvents' },],
154+
})),
155+
{
156+
code: `
157+
import userEvent from '@testing-library/user-event';
158+
test('should report sync event awaited', async() => {
159+
await userEvent.type('foo', 'bar', {hello: 1234});
160+
});
161+
`,
162+
errors: [{ line: 4, messageId: 'noAwaitSyncEvents' },],
163+
}
164+
],
165+
});

0 commit comments

Comments
 (0)