Skip to content

feat(rule): add no-await-sync-events rule #240

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Oct 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an extra correct example for userEvent.type without await and delay please?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

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: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to include more tests for checking userEvent and fireEvent non-related to Testing Library are not reported so they are valid with await operator.

There is an ongoing refactor for v4 of the plugin which actually will check that for all rules out of the box, so I don't know if you prefer to wait for that version to be released.

Copy link
Contributor Author

@J-Huang J-Huang Oct 26, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Belco90
Do you mean how about if userEvent or fireEvent are user named objects, it should not apply the rule? Or, if extra functions are added to those objects, for those cases, await may be valid?
In order to ensure that, it has to check if the property name in userEvent or fireEvent is one of the expected event names, such as click, type, tab and so on. Even those methods can be overridden. Am I misunderstanding the question?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The former, so we don't report const declared by the user with the same name. As mentioned, the internal refactor for v4 will pass some helpers to all rules to check this generically, so I'm not sure if you prefer to wait for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification.
will refactor the code by then if needed.

// 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' },],
}
],
});