Skip to content

Commit

Permalink
feat: add no-await-sync-events rule (#240)
Browse files Browse the repository at this point in the history
* 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 <jhuang0@walmartlabs.com>
  • Loading branch information
J-Huang and jhuang committed Oct 29, 2020
1 parent f78720d commit 2cc2263
Show file tree
Hide file tree
Showing 6 changed files with 301 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ To enable this configuration use the `extends` property in your
| [await-async-utils](docs/rules/await-async-utils.md) | Enforce async utils to be awaited properly | ![recommended-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [await-fire-event](docs/rules/await-fire-event.md) | Enforce async fire event methods to be awaited | ![vue-badge][] | |
| [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensure `data-testid` values match a provided regex. | | |
| [no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | | |
| [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][] | |
| [no-debug](docs/rules/no-debug.md) | Disallow the use of `debug` | ![angular-badge][] ![react-badge][] ![vue-badge][] | |
| [no-dom-import](docs/rules/no-dom-import.md) | Disallow importing from DOM Testing Library | ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] |
Expand Down
68 changes: 68 additions & 0 deletions docs/rules/no-await-sync-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Disallow unnecessary `await` for sync events (no-await-sync-events)

Ensure that sync events are not awaited unnecessarily.

## Rule Details

Functions in the event object provided by Testing Library, including
fireEvent and userEvent, do NOT return Promise, with an exception of
`userEvent.type`, which delays the promise resolve only if [`delay`
option](https://github.com/testing-library/user-event#typeelement-text-options) is specified.
Some examples are:

- `fireEvent.click`
- `fireEvent.select`
- `userEvent.tab`
- `userEvent.hover`

This rule aims to prevent users from waiting for those function calls.

Examples of **incorrect** code for this rule:

```js
const foo = async () => {
// ...
await fireEvent.click(button);
// ...
};

const bar = () => {
// ...
await userEvent.tab();
// ...
};

const baz = () => {
// ...
await userEvent.type(textInput, 'abc');
// ...
};
```

Examples of **correct** code for this rule:

```js
const foo = () => {
// ...
fireEvent.click(button);
// ...
};

const bar = () => {
// ...
userEvent.tab();
// ...
};

const baz = () => {
// await userEvent.type only with delay option
await userEvent.type(textInput, 'abc', {delay: 1000});
userEvent.type(textInput, '123');
// ...
};
```

## Notes

There is another rule `await-fire-event`, which is only in Vue Testing
Library. Please do not confuse with this rule.
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import awaitAsyncQuery from './rules/await-async-query';
import awaitAsyncUtils from './rules/await-async-utils';
import awaitFireEvent from './rules/await-fire-event';
import consistentDataTestid from './rules/consistent-data-testid';
import noAwaitSyncEvents from './rules/no-await-sync-events';
import noAwaitSyncQuery from './rules/no-await-sync-query';
import noDebug from './rules/no-debug';
import noDomImport from './rules/no-dom-import';
Expand All @@ -20,6 +21,7 @@ const rules = {
'await-async-utils': awaitAsyncUtils,
'await-fire-event': awaitFireEvent,
'consistent-data-testid': consistentDataTestid,
'no-await-sync-events': noAwaitSyncEvents,
'no-await-sync-query': noAwaitSyncQuery,
'no-debug': noDebug,
'no-dom-import': noDomImport,
Expand Down
59 changes: 59 additions & 0 deletions lib/rules/no-await-sync-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';
import { getDocsUrl, SYNC_EVENTS } from '../utils';
import { isObjectExpression, isProperty, isIdentifier } from '../node-utils';
export const RULE_NAME = 'no-await-sync-events';
export type MessageIds = 'noAwaitSyncEvents';
type Options = [];

const SYNC_EVENTS_REGEXP = new RegExp(`^(${SYNC_EVENTS.join('|')})$`);
export default ESLintUtils.RuleCreator(getDocsUrl)<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'problem',
docs: {
description: 'Disallow unnecessary `await` for sync events',
category: 'Best Practices',
recommended: 'error',
},
messages: {
noAwaitSyncEvents: '`{{ name }}` does not need `await` operator',
},
fixable: null,
schema: [],
},
defaultOptions: [],

create(context) {
// userEvent.type() is an exception, which returns a
// Promise. But it is only necessary to wait when delay
// option is specified. So this rule has a special exception
// for the case await userEvent.type(element, 'abc', {delay: 1234})
return {
[`AwaitExpression > CallExpression > MemberExpression > Identifier[name=${SYNC_EVENTS_REGEXP}]`](
node: TSESTree.Identifier
) {
const memberExpression = node.parent as TSESTree.MemberExpression;
const methodNode = memberExpression.property as TSESTree.Identifier;
const callExpression = memberExpression.parent as TSESTree.CallExpression;
const withDelay = callExpression.arguments.length >= 3 &&
isObjectExpression(callExpression.arguments[2]) &&
callExpression.arguments[2].properties.some(
property =>
isProperty(property) &&
isIdentifier(property.key) &&
property.key.name === 'delay'
);

if (!(node.name === 'userEvent' && methodNode.name === 'type' && withDelay)) {
context.report({
node: methodNode,
messageId: 'noAwaitSyncEvents',
data: {
name: `${node.name}.${methodNode.name}`,
},
});
}
},
};
},
});
6 changes: 6 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const ASYNC_UTILS = [
'waitForDomChange',
];

const SYNC_EVENTS = [
'fireEvent',
'userEvent',
];

const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll'];

const PRESENCE_MATCHERS = ['toBeInTheDocument', 'toBeTruthy', 'toBeDefined'];
Expand All @@ -78,6 +83,7 @@ export {
ASYNC_QUERIES_COMBINATIONS,
ALL_QUERIES_COMBINATIONS,
ASYNC_UTILS,
SYNC_EVENTS,
TESTING_FRAMEWORK_SETUP_HOOKS,
LIBRARY_MODULES,
PRESENCE_MATCHERS,
Expand Down
165 changes: 165 additions & 0 deletions tests/lib/rules/no-await-sync-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { createRuleTester } from '../test-utils';
import rule, { RULE_NAME } from '../../../lib/rules/no-await-sync-events';
import { SYNC_EVENTS } from '../../../lib/utils';

const ruleTester = createRuleTester();

const fireEventFunctions = [
'copy',
'cut',
'paste',
'compositionEnd',
'compositionStart',
'compositionUpdate',
'keyDown',
'keyPress',
'keyUp',
'focus',
'blur',
'focusIn',
'focusOut',
'change',
'input',
'invalid',
'submit',
'reset',
'click',
'contextMenu',
'dblClick',
'drag',
'dragEnd',
'dragEnter',
'dragExit',
'dragLeave',
'dragOver',
'dragStart',
'drop',
'mouseDown',
'mouseEnter',
'mouseLeave',
'mouseMove',
'mouseOut',
'mouseOver',
'mouseUp',
'popState',
'select',
'touchCancel',
'touchEnd',
'touchMove',
'touchStart',
'scroll',
'wheel',
'abort',
'canPlay',
'canPlayThrough',
'durationChange',
'emptied',
'encrypted',
'ended',
'loadedData',
'loadedMetadata',
'loadStart',
'pause',
'play',
'playing',
'progress',
'rateChange',
'seeked',
'seeking',
'stalled',
'suspend',
'timeUpdate',
'volumeChange',
'waiting',
'load',
'error',
'animationStart',
'animationEnd',
'animationIteration',
'transitionEnd',
'doubleClick',
'pointerOver',
'pointerEnter',
'pointerDown',
'pointerMove',
'pointerUp',
'pointerCancel',
'pointerOut',
'pointerLeave',
'gotPointerCapture',
'lostPointerCapture',
];
const userEventFunctions = [
'clear',
'click',
'dblClick',
'selectOptions',
'deselectOptions',
'upload',
// 'type',
'tab',
'paste',
'hover',
'unhover',
];
let eventFunctions: string[] = [];
SYNC_EVENTS.forEach(event => {
switch (event) {
case 'fireEvent':
eventFunctions = eventFunctions.concat(fireEventFunctions.map((f: string): string => `${event}.${f}`));
break;
case 'userEvent':
eventFunctions = eventFunctions.concat(userEventFunctions.map((f: string): string => `${event}.${f}`));
break;
default:
eventFunctions.push(`${event}.anyFunc`);
}
});

ruleTester.run(RULE_NAME, rule, {
valid: [
// sync events without await are valid
// userEvent.type() is an exception
...eventFunctions.map(func => ({
code: `() => {
${func}('foo')
}
`,
})),
{
code: `() => {
userEvent.type('foo')
}
`,
},
{
code: `() => {
await userEvent.type('foo', 'bar', {delay: 1234})
}
`,
},
],

invalid: [
// sync events with await operator are not valid
...eventFunctions.map(func => ({
code: `
import { fireEvent } from '@testing-library/framework';
import userEvent from '@testing-library/user-event';
test('should report sync event awaited', async() => {
await ${func}('foo');
});
`,
errors: [{ line: 5, messageId: 'noAwaitSyncEvents' },],
})),
{
code: `
import userEvent from '@testing-library/user-event';
test('should report sync event awaited', async() => {
await userEvent.type('foo', 'bar', {hello: 1234});
});
`,
errors: [{ line: 4, messageId: 'noAwaitSyncEvents' },],
}
],
});

0 comments on commit 2cc2263

Please sign in to comment.