From 9e78f1bcbfa05a9c044f41aa7f24095c6ed8b52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 17:50:58 +0100 Subject: [PATCH 01/10] docs: update rule description --- docs/rules/no-await-sync-events.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/rules/no-await-sync-events.md b/docs/rules/no-await-sync-events.md index 9eb61548..df95afd4 100644 --- a/docs/rules/no-await-sync-events.md +++ b/docs/rules/no-await-sync-events.md @@ -1,14 +1,15 @@ # Disallow unnecessary `await` for sync events (no-await-sync-events) -Ensure that sync events are not awaited unnecessarily. +Ensure that sync simulated 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` +Methods for simulating events in Testing Library ecosystem -`fireEvent` and `userEvent`- +do NOT return any Promise, with an exception of +`userEvent.type` and `userEvent.keyboard`, which delays the promise resolve only if [`delay` option](https://github.com/testing-library/user-event#typeelement-text-options) is specified. -Some examples are: + +Some examples of simulating events not returning any Promise are: - `fireEvent.click` - `fireEvent.select` @@ -26,15 +27,16 @@ const foo = async () => { // ... }; -const bar = () => { +const bar = async () => { // ... await userEvent.tab(); // ... }; -const baz = () => { +const baz = async () => { // ... await userEvent.type(textInput, 'abc'); + await userEvent.keyboard('abc'); // ... }; ``` @@ -54,10 +56,14 @@ const bar = () => { // ... }; -const baz = () => { +const baz = async () => { // await userEvent.type only with delay option - await userEvent.type(textInput, 'abc', {delay: 1000}); + await userEvent.type(textInput, 'abc', { delay: 1000 }); userEvent.type(textInput, '123'); + + // same for userEvent.keyboard + await userEvent.keyboard(textInput, 'abc', { delay: 1000 }); + userEvent.keyboard('123'); // ... }; ``` From a765a99cc38183ded5e8ad5cdfad21dc488dced2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:04:06 +0100 Subject: [PATCH 02/10] test: improve current cases --- lib/rules/no-await-sync-events.ts | 4 +- lib/utils.ts | 4 +- tests/lib/rules/no-await-sync-events.test.ts | 49 +++++++++++++------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/lib/rules/no-await-sync-events.ts b/lib/rules/no-await-sync-events.ts index 55c92e3c..e0720489 100644 --- a/lib/rules/no-await-sync-events.ts +++ b/lib/rules/no-await-sync-events.ts @@ -3,13 +3,13 @@ import { ESLintUtils, TSESTree, } from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, SYNC_EVENTS } from '../utils'; +import { getDocsUrl, EVENTS_SIMULATORS } from '../utils'; import { isObjectExpression, isProperty } 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('|')})$`); +const SYNC_EVENTS_REGEXP = new RegExp(`^(${EVENTS_SIMULATORS.join('|')})$`); export default ESLintUtils.RuleCreator(getDocsUrl)({ name: RULE_NAME, meta: { diff --git a/lib/utils.ts b/lib/utils.ts index 1e4f0064..71850468 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -63,7 +63,7 @@ const ASYNC_UTILS = [ 'waitForDomChange', ] as const; -const SYNC_EVENTS = ['fireEvent', 'userEvent']; +const EVENTS_SIMULATORS = ['fireEvent', 'userEvent'] as const; const TESTING_FRAMEWORK_SETUP_HOOKS = ['beforeEach', 'beforeAll']; @@ -116,7 +116,7 @@ export { ASYNC_QUERIES_COMBINATIONS, ALL_QUERIES_COMBINATIONS, ASYNC_UTILS, - SYNC_EVENTS, + EVENTS_SIMULATORS, TESTING_FRAMEWORK_SETUP_HOOKS, LIBRARY_MODULES, PROPERTIES_RETURNING_NODES, diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index 744fafc1..f12d29ff 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -1,6 +1,6 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/no-await-sync-events'; -import { SYNC_EVENTS } from '../../../lib/utils'; +import { EVENTS_SIMULATORS } from '../../../lib/utils'; const ruleTester = createRuleTester(); @@ -97,34 +97,36 @@ const userEventFunctions = [ 'deselectOptions', 'upload', // 'type', + // 'keyboard', 'tab', 'paste', 'hover', 'unhover', ]; -let eventFunctions: string[] = []; -SYNC_EVENTS.forEach((event) => { - switch (event) { + +let syncEventFunctions: string[] = []; +for (const simulatorObj of EVENTS_SIMULATORS) { + switch (simulatorObj) { case 'fireEvent': - eventFunctions = eventFunctions.concat( - fireEventFunctions.map((f: string): string => `${event}.${f}`) + syncEventFunctions = syncEventFunctions.concat( + fireEventFunctions.map((f: string): string => `${simulatorObj}.${f}`) ); break; case 'userEvent': - eventFunctions = eventFunctions.concat( - userEventFunctions.map((f: string): string => `${event}.${f}`) + syncEventFunctions = syncEventFunctions.concat( + userEventFunctions.map((f: string): string => `${simulatorObj}.${f}`) ); break; default: - eventFunctions.push(`${event}.anyFunc`); + syncEventFunctions.push(`${simulatorObj}.anyFunc`); } -}); +} ruleTester.run(RULE_NAME, rule, { valid: [ // sync events without await are valid - // userEvent.type() is an exception - ...eventFunctions.map((func) => ({ + // userEvent.type() and userEvent.keyboard() are exceptions + ...syncEventFunctions.map((func) => ({ code: `() => { ${func}('foo') } @@ -152,7 +154,7 @@ ruleTester.run(RULE_NAME, rule, { invalid: [ // sync events with await operator are not valid - ...eventFunctions.map((func) => ({ + ...syncEventFunctions.map((func) => ({ code: ` import { fireEvent } from '@testing-library/framework'; import userEvent from '@testing-library/user-event'; @@ -165,9 +167,9 @@ ruleTester.run(RULE_NAME, rule, { { code: ` import userEvent from '@testing-library/user-event'; - test('should report sync event awaited', async() => { - await userEvent.type('foo', 'bar', {hello: 1234}); - await userEvent.keyboard('foo', {hello: 1234}); + test('should report async events without delay awaited', async() => { + await userEvent.type('foo', 'bar'); + await userEvent.keyboard('foo'); }); `, errors: [ @@ -175,5 +177,20 @@ ruleTester.run(RULE_NAME, rule, { { line: 5, messageId: 'noAwaitSyncEvents' }, ], }, + // TODO: make sure this case is covered + /* eslint-disable jest/no-commented-out-tests */ + // { + // code: ` + // import userEvent from '@testing-library/user-event'; + // test('should report async events with 0 delay awaited', async() => { + // await userEvent.type('foo', 'bar', { delay: 0 }); + // await userEvent.keyboard('foo', { delay: 0 }); + // }); + // `, + // errors: [ + // { line: 4, messageId: 'noAwaitSyncEvents' }, + // { line: 5, messageId: 'noAwaitSyncEvents' }, + // ], + // }, ], }); From 21a98dada30549a80366364a24273cbc11db5dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:08:31 +0100 Subject: [PATCH 03/10] refactor: use new rule creator --- lib/rules/no-await-sync-events.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/rules/no-await-sync-events.ts b/lib/rules/no-await-sync-events.ts index e0720489..dbdf6f3c 100644 --- a/lib/rules/no-await-sync-events.ts +++ b/lib/rules/no-await-sync-events.ts @@ -1,16 +1,15 @@ -import { - ASTUtils, - ESLintUtils, - TSESTree, -} from '@typescript-eslint/experimental-utils'; -import { getDocsUrl, EVENTS_SIMULATORS } from '../utils'; +import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; +import { EVENTS_SIMULATORS } from '../utils'; import { isObjectExpression, isProperty } from '../node-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; + export const RULE_NAME = 'no-await-sync-events'; export type MessageIds = 'noAwaitSyncEvents'; type Options = []; const SYNC_EVENTS_REGEXP = new RegExp(`^(${EVENTS_SIMULATORS.join('|')})$`); -export default ESLintUtils.RuleCreator(getDocsUrl)({ + +export default createTestingLibraryRule({ name: RULE_NAME, meta: { type: 'problem', @@ -20,7 +19,8 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ recommended: 'error', }, messages: { - noAwaitSyncEvents: '`{{ name }}` does not need `await` operator', + noAwaitSyncEvents: + '`{{ name }}` is sync and does not need `await` operator', }, fixable: null, schema: [], @@ -28,10 +28,12 @@ export default ESLintUtils.RuleCreator(getDocsUrl)({ 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}) + // userEvent.type() and userEvent.keyboard() are exceptions, which returns a + // Promise. But it is only necessary to wait when delay option other than 0 + // is specified. So this rule has a special exception + // for the case await: + // - userEvent.type(element, 'abc', {delay: 1234}) + // - userEvent.keyboard('abc', {delay: 1234}) return { [`AwaitExpression > CallExpression > MemberExpression > Identifier[name=${SYNC_EVENTS_REGEXP}]`]( node: TSESTree.Identifier From 99d6ca0c7578c373eb4b5b42bc3112883adc859b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:13:40 +0100 Subject: [PATCH 04/10] feat: avoid reporting type and keyboard with 0 delay --- lib/rules/no-await-sync-events.ts | 33 +++++++++++--------- tests/lib/rules/no-await-sync-events.test.ts | 28 ++++++++--------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/lib/rules/no-await-sync-events.ts b/lib/rules/no-await-sync-events.ts index dbdf6f3c..242917d8 100644 --- a/lib/rules/no-await-sync-events.ts +++ b/lib/rules/no-await-sync-events.ts @@ -1,6 +1,6 @@ import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { EVENTS_SIMULATORS } from '../utils'; -import { isObjectExpression, isProperty } from '../node-utils'; +import { isLiteral, isObjectExpression, isProperty } from '../node-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-await-sync-events'; @@ -9,6 +9,8 @@ type Options = []; const SYNC_EVENTS_REGEXP = new RegExp(`^(${EVENTS_SIMULATORS.join('|')})$`); +const USER_EVENT_ASYNC_EXCEPTIONS: string[] = ['type', 'keyboard']; + export default createTestingLibraryRule({ name: RULE_NAME, meta: { @@ -43,30 +45,33 @@ export default createTestingLibraryRule({ const callExpression = memberExpression.parent as TSESTree.CallExpression; const lastArg = callExpression.arguments[callExpression.arguments.length - 1]; + const withDelay = isObjectExpression(lastArg) && lastArg.properties.some( (property) => isProperty(property) && ASTUtils.isIdentifier(property.key) && - property.key.name === 'delay' + property.key.name === 'delay' && + isLiteral(property.value) && + property.value.value > 0 ); if ( - !( - node.name === 'userEvent' && - ['type', 'keyboard'].includes(methodNode.name) && - withDelay - ) + node.name === 'userEvent' && + USER_EVENT_ASYNC_EXCEPTIONS.includes(methodNode.name) && + withDelay ) { - context.report({ - node: methodNode, - messageId: 'noAwaitSyncEvents', - data: { - name: `${node.name}.${methodNode.name}`, - }, - }); + return; } + + context.report({ + node: methodNode, + messageId: 'noAwaitSyncEvents', + data: { + name: `${node.name}.${methodNode.name}`, + }, + }); }, }; }, diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index f12d29ff..bcf5a568 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -177,20 +177,18 @@ ruleTester.run(RULE_NAME, rule, { { line: 5, messageId: 'noAwaitSyncEvents' }, ], }, - // TODO: make sure this case is covered - /* eslint-disable jest/no-commented-out-tests */ - // { - // code: ` - // import userEvent from '@testing-library/user-event'; - // test('should report async events with 0 delay awaited', async() => { - // await userEvent.type('foo', 'bar', { delay: 0 }); - // await userEvent.keyboard('foo', { delay: 0 }); - // }); - // `, - // errors: [ - // { line: 4, messageId: 'noAwaitSyncEvents' }, - // { line: 5, messageId: 'noAwaitSyncEvents' }, - // ], - // }, + { + code: ` + import userEvent from '@testing-library/user-event'; + test('should report async events with 0 delay awaited', async() => { + await userEvent.type('foo', 'bar', { delay: 0 }); + await userEvent.keyboard('foo', { delay: 0 }); + }); + `, + errors: [ + { line: 4, messageId: 'noAwaitSyncEvents' }, + { line: 5, messageId: 'noAwaitSyncEvents' }, + ], + }, ], }); From 6e8a80337347fbc7a9a9d042a58b33d445206fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:35:03 +0100 Subject: [PATCH 05/10] refactor: use new helpers for detection --- lib/detect-testing-library-utils.ts | 7 +++ lib/rules/no-await-sync-events.ts | 51 ++++++++++++-------- tests/lib/rules/no-await-sync-events.test.ts | 2 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts index cc5686fa..5b07cf45 100644 --- a/lib/detect-testing-library-utils.ts +++ b/lib/detect-testing-library-utils.ts @@ -67,6 +67,7 @@ type IsAsyncUtilFn = ( validNames?: readonly typeof ASYNC_UTILS[number][] ) => boolean; type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; type IsRenderVariableDeclaratorFn = ( node: TSESTree.VariableDeclarator @@ -99,6 +100,7 @@ export interface DetectionHelpers { isFireEventUtil: (node: TSESTree.Identifier) => boolean; isUserEventUtil: (node: TSESTree.Identifier) => boolean; isFireEventMethod: IsFireEventMethodFn; + isUserEventMethod: IsUserEventMethodFn; isRenderUtil: IsRenderUtilFn; isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; isDebugUtil: IsDebugUtilFn; @@ -406,6 +408,10 @@ export function detectTestingLibraryUtils< return isTestingLibrarySimulateEventUtil(node, 'fireEvent'); }; + const isUserEventMethod: IsUserEventMethodFn = (node) => { + return isTestingLibrarySimulateEventUtil(node, 'userEvent'); + }; + /** * Determines whether a given node is a valid render util or not. * @@ -607,6 +613,7 @@ export function detectTestingLibraryUtils< isFireEventUtil, isUserEventUtil, isFireEventMethod, + isUserEventMethod, isRenderUtil, isRenderVariableDeclarator, isDebugUtil, diff --git a/lib/rules/no-await-sync-events.ts b/lib/rules/no-await-sync-events.ts index 242917d8..07081c46 100644 --- a/lib/rules/no-await-sync-events.ts +++ b/lib/rules/no-await-sync-events.ts @@ -1,14 +1,17 @@ import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; -import { EVENTS_SIMULATORS } from '../utils'; -import { isLiteral, isObjectExpression, isProperty } from '../node-utils'; +import { + getDeepestIdentifierNode, + getPropertyIdentifierNode, + isLiteral, + isObjectExpression, + isProperty, +} from '../node-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; export const RULE_NAME = 'no-await-sync-events'; export type MessageIds = 'noAwaitSyncEvents'; type Options = []; -const SYNC_EVENTS_REGEXP = new RegExp(`^(${EVENTS_SIMULATORS.join('|')})$`); - const USER_EVENT_ASYNC_EXCEPTIONS: string[] = ['type', 'keyboard']; export default createTestingLibraryRule({ @@ -29,24 +32,27 @@ export default createTestingLibraryRule({ }, defaultOptions: [], - create(context) { + create(context, _, helpers) { // userEvent.type() and userEvent.keyboard() are exceptions, which returns a // Promise. But it is only necessary to wait when delay option other than 0 - // is specified. So this rule has a special exception - // for the case await: + // is specified. So this rule has a special exception for the case await: // - userEvent.type(element, 'abc', {delay: 1234}) // - userEvent.keyboard('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 lastArg = - callExpression.arguments[callExpression.arguments.length - 1]; + 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { + const simulateEventFunctionIdentifier = getDeepestIdentifierNode(node); + + const isSimulateEventMethod = + helpers.isUserEventMethod(simulateEventFunctionIdentifier) || + helpers.isFireEventMethod(simulateEventFunctionIdentifier); - const withDelay = + if (!isSimulateEventMethod) { + return; + } + + const lastArg = node.arguments[node.arguments.length - 1]; + + const hasDelay = isObjectExpression(lastArg) && lastArg.properties.some( (property) => @@ -57,19 +63,22 @@ export default createTestingLibraryRule({ property.value.value > 0 ); + const simulateEventFunctionName = simulateEventFunctionIdentifier.name; + if ( - node.name === 'userEvent' && - USER_EVENT_ASYNC_EXCEPTIONS.includes(methodNode.name) && - withDelay + USER_EVENT_ASYNC_EXCEPTIONS.includes(simulateEventFunctionName) && + hasDelay ) { return; } context.report({ - node: methodNode, + node, messageId: 'noAwaitSyncEvents', data: { - name: `${node.name}.${methodNode.name}`, + name: `${ + getPropertyIdentifierNode(node).name + }.${simulateEventFunctionName}`, }, }); }, diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index bcf5a568..ba1a09e2 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -158,7 +158,7 @@ ruleTester.run(RULE_NAME, rule, { code: ` import { fireEvent } from '@testing-library/framework'; import userEvent from '@testing-library/user-event'; - test('should report sync event awaited', async() => { + test('should report ${func} sync event awaited', async() => { await ${func}('foo'); }); `, From 2e337529c88375fd6f4bf83651a72892434fd580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:46:38 +0100 Subject: [PATCH 06/10] test: split fire and user events cases --- tests/lib/rules/no-await-sync-events.test.ts | 66 ++++++++++---------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index ba1a09e2..55a34cd9 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -1,10 +1,9 @@ import { createRuleTester } from '../test-utils'; import rule, { RULE_NAME } from '../../../lib/rules/no-await-sync-events'; -import { EVENTS_SIMULATORS } from '../../../lib/utils'; const ruleTester = createRuleTester(); -const fireEventFunctions = [ +const FIRE_EVENT_FUNCTIONS = [ 'copy', 'cut', 'paste', @@ -89,7 +88,7 @@ const fireEventFunctions = [ 'gotPointerCapture', 'lostPointerCapture', ]; -const userEventFunctions = [ +const USER_EVENT_SYNC_FUNCTIONS = [ 'clear', 'click', 'dblClick', @@ -104,43 +103,37 @@ const userEventFunctions = [ 'unhover', ]; -let syncEventFunctions: string[] = []; -for (const simulatorObj of EVENTS_SIMULATORS) { - switch (simulatorObj) { - case 'fireEvent': - syncEventFunctions = syncEventFunctions.concat( - fireEventFunctions.map((f: string): string => `${simulatorObj}.${f}`) - ); - break; - case 'userEvent': - syncEventFunctions = syncEventFunctions.concat( - userEventFunctions.map((f: string): string => `${simulatorObj}.${f}`) - ); - break; - default: - syncEventFunctions.push(`${simulatorObj}.anyFunc`); - } -} - ruleTester.run(RULE_NAME, rule, { valid: [ - // sync events without await are valid - // userEvent.type() and userEvent.keyboard() are exceptions - ...syncEventFunctions.map((func) => ({ + // sync fireEvents methods without await are valid + ...FIRE_EVENT_FUNCTIONS.map((func) => ({ code: `() => { - ${func}('foo') + fireEvent.${func}('foo') } `, })), + // sync userEvent methods without await are valid + ...USER_EVENT_SYNC_FUNCTIONS.map((func) => ({ + code: `() => { + userEvent.${func}('foo') + } + `, + })), + { + code: `() => { + userEvent.type(element, 'foo') + } + `, + }, { code: `() => { - userEvent.type('foo') + userEvent.keyboard('foo') } `, }, { code: `() => { - await userEvent.type('foo', 'bar', {delay: 1234}) + await userEvent.type(element, 'bar', {delay: 1234}) } `, }, @@ -153,16 +146,25 @@ ruleTester.run(RULE_NAME, rule, { ], invalid: [ - // sync events with await operator are not valid - ...syncEventFunctions.map((func) => ({ + // sync fireEvent methods with await operator are not valid + ...FIRE_EVENT_FUNCTIONS.map((func) => ({ code: ` import { fireEvent } from '@testing-library/framework'; + test('should report fireEvent.${func} sync event awaited', async() => { + await fireEvent.${func}('foo'); + }); + `, + errors: [{ line: 4, messageId: 'noAwaitSyncEvents' }], + })), + // sync userEvent sync methods with await operator are not valid + ...USER_EVENT_SYNC_FUNCTIONS.map((func) => ({ + code: ` import userEvent from '@testing-library/user-event'; - test('should report ${func} sync event awaited', async() => { - await ${func}('foo'); + test('should report userEvent.${func} sync event awaited', async() => { + await userEvent.${func}('foo'); }); `, - errors: [{ line: 5, messageId: 'noAwaitSyncEvents' }], + errors: [{ line: 4, messageId: 'noAwaitSyncEvents' }], })), { code: ` From ded9ce719ee9a48dedf0bd7aedfb730d984f39fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 18:47:57 +0100 Subject: [PATCH 07/10] test: improve errors location asserts --- tests/lib/rules/no-await-sync-events.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index 55a34cd9..c3e0e82a 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -154,7 +154,7 @@ ruleTester.run(RULE_NAME, rule, { await fireEvent.${func}('foo'); }); `, - errors: [{ line: 4, messageId: 'noAwaitSyncEvents' }], + errors: [{ line: 4, column: 17, messageId: 'noAwaitSyncEvents' }], })), // sync userEvent sync methods with await operator are not valid ...USER_EVENT_SYNC_FUNCTIONS.map((func) => ({ @@ -164,7 +164,7 @@ ruleTester.run(RULE_NAME, rule, { await userEvent.${func}('foo'); }); `, - errors: [{ line: 4, messageId: 'noAwaitSyncEvents' }], + errors: [{ line: 4, column: 17, messageId: 'noAwaitSyncEvents' }], })), { code: ` @@ -175,8 +175,8 @@ ruleTester.run(RULE_NAME, rule, { }); `, errors: [ - { line: 4, messageId: 'noAwaitSyncEvents' }, - { line: 5, messageId: 'noAwaitSyncEvents' }, + { line: 4, column: 17, messageId: 'noAwaitSyncEvents' }, + { line: 5, column: 17, messageId: 'noAwaitSyncEvents' }, ], }, { @@ -188,8 +188,8 @@ ruleTester.run(RULE_NAME, rule, { }); `, errors: [ - { line: 4, messageId: 'noAwaitSyncEvents' }, - { line: 5, messageId: 'noAwaitSyncEvents' }, + { line: 4, column: 17, messageId: 'noAwaitSyncEvents' }, + { line: 5, column: 17, messageId: 'noAwaitSyncEvents' }, ], }, ], From 116298c46782ca2bb161bdc0af60e23fb82b318c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 19:49:36 +0100 Subject: [PATCH 08/10] feat: detect user-event import properly --- lib/detect-testing-library-utils.ts | 197 ++++++++++++------- tests/lib/rules/no-await-sync-events.test.ts | 41 ++++ 2 files changed, 172 insertions(+), 66 deletions(-) diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts index 5b07cf45..2e795eb6 100644 --- a/lib/detect-testing-library-utils.ts +++ b/lib/detect-testing-library-utils.ts @@ -12,6 +12,7 @@ import { hasImportMatch, ImportModuleNode, isImportDeclaration, + isImportDefaultSpecifier, isImportNamespaceSpecifier, isImportSpecifier, isLiteral, @@ -20,9 +21,9 @@ import { } from './node-utils'; import { ABSENCE_MATCHERS, + ALL_QUERIES_COMBINATIONS, ASYNC_UTILS, PRESENCE_MATCHERS, - ALL_QUERIES_COMBINATIONS, } from './utils'; export type TestingLibrarySettings = { @@ -111,6 +112,9 @@ export interface DetectionHelpers { isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; } +const USER_EVENT_PACKAGE = '@testing-library/user-event'; +const FIRE_EVENT_NAME = 'fireEvent'; +const USER_EVENT_NAME = 'userEvent'; const RENDER_NAME = 'render'; /** @@ -127,6 +131,7 @@ export function detectTestingLibraryUtils< ): TSESLint.RuleListener => { let importedTestingLibraryNode: ImportModuleNode | null = null; let importedCustomModuleNode: ImportModuleNode | null = null; + let importedUserEventLibraryNode: ImportModuleNode | null = null; // Init options based on shared ESLint settings const customModule = context.settings['testing-library/utils-module']; @@ -176,69 +181,6 @@ export function detectTestingLibraryUtils< return isNodeComingFromTestingLibrary(referenceNodeIdentifier); } - /** - * Determines whether a given node is a simulate event util related to - * Testing Library or not. - * - * In order to determine this, the node must match: - * - indicated simulate event name: fireEvent or userEvent - * - imported from valid Testing Library module (depends on Aggressive - * Reporting) - * - */ - function isTestingLibrarySimulateEventUtil( - node: TSESTree.Identifier, - utilName: 'fireEvent' | 'userEvent' - ): boolean { - const simulateEventUtil = findImportedUtilSpecifier(utilName); - let simulateEventUtilName: string | undefined; - - if (simulateEventUtil) { - simulateEventUtilName = ASTUtils.isIdentifier(simulateEventUtil) - ? simulateEventUtil.name - : simulateEventUtil.local.name; - } else if (isAggressiveModuleReportingEnabled()) { - simulateEventUtilName = utilName; - } - - if (!simulateEventUtilName) { - return false; - } - - const parentMemberExpression: - | TSESTree.MemberExpression - | undefined = isMemberExpression(node.parent) ? node.parent : undefined; - - if (!parentMemberExpression) { - return false; - } - - // make sure that given node it's not fireEvent/userEvent object itself - if ( - [simulateEventUtilName, utilName].includes(node.name) || - (ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === node.name) - ) { - return false; - } - - // check fireEvent.click()/userEvent.click() usage - const regularCall = - ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === simulateEventUtilName; - - // check testingLibraryUtils.fireEvent.click() or - // testingLibraryUtils.userEvent.click() usage - const wildcardCall = - isMemberExpression(parentMemberExpression.object) && - ASTUtils.isIdentifier(parentMemberExpression.object.object) && - parentMemberExpression.object.object.name === simulateEventUtilName && - ASTUtils.isIdentifier(parentMemberExpression.object.property) && - parentMemberExpression.object.property.name === utilName; - - return regularCall || wildcardCall; - } - /** * Determines whether aggressive module reporting is enabled or not. * @@ -405,11 +347,90 @@ export function detectTestingLibraryUtils< * Determines whether a given node is fireEvent method or not */ const isFireEventMethod: IsFireEventMethodFn = (node) => { - return isTestingLibrarySimulateEventUtil(node, 'fireEvent'); + const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + let fireEventUtilName: string | undefined; + + if (fireEventUtil) { + fireEventUtilName = ASTUtils.isIdentifier(fireEventUtil) + ? fireEventUtil.name + : fireEventUtil.local.name; + } else if (isAggressiveModuleReportingEnabled()) { + fireEventUtilName = FIRE_EVENT_NAME; + } + + if (!fireEventUtilName) { + return false; + } + + const parentMemberExpression: + | TSESTree.MemberExpression + | undefined = isMemberExpression(node.parent) ? node.parent : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not fireEvent object itself + if ( + [fireEventUtilName, FIRE_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check fireEvent.click() usage + const regularCall = + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === fireEventUtilName; + + // check testingLibraryUtils.fireEvent.click() usage + const wildcardCall = + isMemberExpression(parentMemberExpression.object) && + ASTUtils.isIdentifier(parentMemberExpression.object.object) && + parentMemberExpression.object.object.name === fireEventUtilName && + ASTUtils.isIdentifier(parentMemberExpression.object.property) && + parentMemberExpression.object.property.name === FIRE_EVENT_NAME; + + return regularCall || wildcardCall; }; const isUserEventMethod: IsUserEventMethodFn = (node) => { - return isTestingLibrarySimulateEventUtil(node, 'userEvent'); + const userEvent = findImportedUserEventSpecifier(); + let userEventName: string | undefined; + + if (userEvent) { + userEventName = userEvent.name; + } else if (isAggressiveModuleReportingEnabled()) { + userEventName = USER_EVENT_NAME; + } + + if (!userEventName) { + return false; + } + + const parentMemberExpression: + | TSESTree.MemberExpression + | undefined = isMemberExpression(node.parent) ? node.parent : undefined; + + if (!parentMemberExpression) { + return false; + } + + // make sure that given node it's not userEvent object itself + if ( + [userEventName, USER_EVENT_NAME].includes(node.name) || + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === node.name) + ) { + return false; + } + + // check userEvent.click() usage + return ( + ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventName + ); }; /** @@ -559,6 +580,29 @@ export function detectTestingLibraryUtils< } }; + const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { + if (!importedUserEventLibraryNode) { + return null; + } + + if (isImportDeclaration(importedUserEventLibraryNode)) { + const userEventIdentifier = importedUserEventLibraryNode.specifiers.find( + (specifier) => isImportDefaultSpecifier(specifier) + ); + + if (userEventIdentifier) { + return userEventIdentifier.local; + } + } else { + const requireNode = importedUserEventLibraryNode.parent as TSESTree.VariableDeclarator; + if (ASTUtils.isIdentifier(requireNode.id)) { + return requireNode.id; + } + } + + return null; + }; + const getImportedUtilSpecifier = ( node: TSESTree.MemberExpression | TSESTree.Identifier ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { @@ -651,6 +695,15 @@ export function detectTestingLibraryUtils< ) { importedCustomModuleNode = node; } + + // check only if user-event import not found yet so we avoid + // to override importedUserEventLibraryNode after it's found + if ( + !importedUserEventLibraryNode && + String(node.source.value) === USER_EVENT_PACKAGE + ) { + importedUserEventLibraryNode = node; + } }, // Check if Testing Library related modules are loaded with required. @@ -683,6 +736,18 @@ export function detectTestingLibraryUtils< ) { importedCustomModuleNode = callExpression; } + + if ( + !importedCustomModuleNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value === USER_EVENT_PACKAGE + ) + ) { + importedUserEventLibraryNode = callExpression; + } }, }; diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index c3e0e82a..e5fcd20d 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -143,6 +143,29 @@ ruleTester.run(RULE_NAME, rule, { } `, }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent } from 'somewhere-else'; + test('should not report fireEvent.click() not related to Testing Library', async() => { + await fireEvent.click('foo'); + }); + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent as renamedFireEvent } from 'somewhere-else'; + import renamedUserEvent from '@testing-library/user-event'; + import { fireEvent, userEvent } from 'somewhere-else' + + test('should not report unused renamed methods', async() => { + await fireEvent.click('foo'); + await userEvent.type('foo', 'bar', { delay: 5 }); + await userEvent.keyboard('foo', { delay: 5 }); + }); + `, + }, ], invalid: [ @@ -192,5 +215,23 @@ ruleTester.run(RULE_NAME, rule, { { line: 5, column: 17, messageId: 'noAwaitSyncEvents' }, ], }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { fireEvent as renamedFireEvent } from 'test-utils'; + import renamedUserEvent from '@testing-library/user-event'; + + test('should report renamed invalid cases with Aggressive Reporting disabled', async() => { + await renamedFireEvent.click('foo'); + await renamedUserEvent.type('foo', 'bar', { delay: 0 }); + await renamedUserEvent.keyboard('foo', { delay: 0 }); + }); + `, + errors: [ + { line: 6, column: 17, messageId: 'noAwaitSyncEvents' }, + { line: 7, column: 17, messageId: 'noAwaitSyncEvents' }, + { line: 8, column: 17, messageId: 'noAwaitSyncEvents' }, + ], + }, ], }); From 7a266123b18db428bb7e5b52bac7253ef423f36d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 20:10:59 +0100 Subject: [PATCH 09/10] test: add cases for increasing coverage up to 100% --- lib/detect-testing-library-utils.ts | 4 +- tests/create-testing-library-rule.test.ts | 78 +++++++++++++++++++++++ tests/fake-rule.ts | 7 +- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/detect-testing-library-utils.ts b/lib/detect-testing-library-utils.ts index 2e795eb6..63ea41b1 100644 --- a/lib/detect-testing-library-utils.ts +++ b/lib/detect-testing-library-utils.ts @@ -595,9 +595,7 @@ export function detectTestingLibraryUtils< } } else { const requireNode = importedUserEventLibraryNode.parent as TSESTree.VariableDeclarator; - if (ASTUtils.isIdentifier(requireNode.id)) { - return requireNode.id; - } + return requireNode.id as TSESTree.Identifier; } return null; diff --git a/tests/create-testing-library-rule.test.ts b/tests/create-testing-library-rule.test.ts index 3fba6546..39158e9c 100644 --- a/tests/create-testing-library-rule.test.ts +++ b/tests/create-testing-library-rule.test.ts @@ -71,6 +71,36 @@ ruleTester.run(RULE_NAME, rule, { `, }, + // Test Cases for user-event imports + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import userEvent from 'somewhere-else' + userEvent.click(element) + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import '@testing-library/user-event' + userEvent.click() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import { click } from '@testing-library/user-event' + userEvent.click() + `, + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import * as incorrect from '@testing-library/user-event' + userEvent.click() + `, + }, + // Test Cases for renders { code: ` @@ -430,6 +460,54 @@ ruleTester.run(RULE_NAME, rule, { errors: [{ line: 3, column: 7, messageId: 'fakeError' }], }, + // Test Cases for user-event imports + { + code: ` + import userEvent from 'somewhere-else' + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import userEvent from '@testing-library/user-event' + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + import renamed from '@testing-library/user-event' + renamed.click(element) + `, + errors: [{ line: 3, column: 15, messageId: 'userEventError' }], + }, + { + code: ` + const userEvent = require('somewhere-else') + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + const userEvent = require('@testing-library/user-event') + userEvent.click(element) + `, + errors: [{ line: 3, column: 17, messageId: 'userEventError' }], + }, + { + settings: { 'testing-library/utils-module': 'test-utils' }, + code: ` + const renamed = require('@testing-library/user-event') + renamed.click(element) + `, + errors: [{ line: 3, column: 15, messageId: 'userEventError' }], + }, + // Test Cases for renders { code: ` diff --git a/tests/fake-rule.ts b/tests/fake-rule.ts index ece0ccbd..38513a4e 100644 --- a/tests/fake-rule.ts +++ b/tests/fake-rule.ts @@ -15,6 +15,7 @@ type MessageIds = | 'queryByError' | 'findByError' | 'customQueryError' + | 'userEventError' | 'presenceAssertError' | 'absenceAssertError'; @@ -36,6 +37,7 @@ export default createTestingLibraryRule({ queryByError: 'some error related to queryBy reported', findByError: 'some error related to findBy reported', customQueryError: 'some error related to a customQuery reported', + userEventError: 'some error related to userEvent reported', presenceAssertError: 'some error related to presence assert reported', absenceAssertError: 'some error related to absence assert reported', }, @@ -59,6 +61,10 @@ export default createTestingLibraryRule({ }); } + if (helpers.isUserEventMethod(node)) { + return context.report({ node, messageId: 'userEventError' }); + } + // force queries to be reported if (helpers.isCustomQuery(node)) { return context.report({ node, messageId: 'customQueryError' }); @@ -90,7 +96,6 @@ export default createTestingLibraryRule({ const reportImportDeclaration = (node: TSESTree.ImportDeclaration) => { // This is just to check that defining an `ImportDeclaration` doesn't // override `ImportDeclaration` from `detectTestingLibraryUtils` - if (node.source.value === 'report-me') { context.report({ node, messageId: 'fakeError' }); } From 1dd113a885edb9b92ab8676295250b47e5f699ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mario=20Beltr=C3=A1n=20Alarc=C3=B3n?= Date: Sat, 27 Mar 2021 20:15:12 +0100 Subject: [PATCH 10/10] test: assert error message data --- tests/lib/rules/no-await-sync-events.test.ts | 67 +++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/tests/lib/rules/no-await-sync-events.test.ts b/tests/lib/rules/no-await-sync-events.test.ts index e5fcd20d..5289b9b4 100644 --- a/tests/lib/rules/no-await-sync-events.test.ts +++ b/tests/lib/rules/no-await-sync-events.test.ts @@ -177,7 +177,14 @@ ruleTester.run(RULE_NAME, rule, { await fireEvent.${func}('foo'); }); `, - errors: [{ line: 4, column: 17, messageId: 'noAwaitSyncEvents' }], + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: `fireEvent.${func}` }, + }, + ], })), // sync userEvent sync methods with await operator are not valid ...USER_EVENT_SYNC_FUNCTIONS.map((func) => ({ @@ -187,7 +194,14 @@ ruleTester.run(RULE_NAME, rule, { await userEvent.${func}('foo'); }); `, - errors: [{ line: 4, column: 17, messageId: 'noAwaitSyncEvents' }], + errors: [ + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: `userEvent.${func}` }, + }, + ], })), { code: ` @@ -198,8 +212,18 @@ ruleTester.run(RULE_NAME, rule, { }); `, errors: [ - { line: 4, column: 17, messageId: 'noAwaitSyncEvents' }, - { line: 5, column: 17, messageId: 'noAwaitSyncEvents' }, + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.type' }, + }, + { + line: 5, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.keyboard' }, + }, ], }, { @@ -211,8 +235,18 @@ ruleTester.run(RULE_NAME, rule, { }); `, errors: [ - { line: 4, column: 17, messageId: 'noAwaitSyncEvents' }, - { line: 5, column: 17, messageId: 'noAwaitSyncEvents' }, + { + line: 4, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.type' }, + }, + { + line: 5, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'userEvent.keyboard' }, + }, ], }, { @@ -228,9 +262,24 @@ ruleTester.run(RULE_NAME, rule, { }); `, errors: [ - { line: 6, column: 17, messageId: 'noAwaitSyncEvents' }, - { line: 7, column: 17, messageId: 'noAwaitSyncEvents' }, - { line: 8, column: 17, messageId: 'noAwaitSyncEvents' }, + { + line: 6, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedFireEvent.click' }, + }, + { + line: 7, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedUserEvent.type' }, + }, + { + line: 8, + column: 17, + messageId: 'noAwaitSyncEvents', + data: { name: 'renamedUserEvent.keyboard' }, + }, ], }, ],