From 636dee8022abb66f4308b39f34b3921588b9624b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kari=20V=C3=A4nttinen?= Date: Sun, 15 Oct 2023 16:22:35 +0300 Subject: [PATCH] feat(await-async-events): instance of userEvent is recognized as async feat(await-async-events): added comments feat(await-async-events): better test case feat(await-async-events): edge case fixed, test added feat(await-async-events): use actual userEvent import for check, tests --- .../detect-testing-library-utils.ts | 83 ++++++----- lib/node-utils/index.ts | 34 +++++ lib/rules/await-async-events.ts | 14 +- tests/lib/rules/await-async-events.test.ts | 141 +++++++++++++++++- 4 files changed, 236 insertions(+), 36 deletions(-) diff --git a/lib/create-testing-library-rule/detect-testing-library-utils.ts b/lib/create-testing-library-rule/detect-testing-library-utils.ts index 9393a88b..8911eda2 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -72,7 +72,10 @@ type IsAsyncUtilFn = ( validNames?: readonly (typeof ASYNC_UTILS)[number][] ) => boolean; type IsFireEventMethodFn = (node: TSESTree.Identifier) => boolean; -type IsUserEventMethodFn = (node: TSESTree.Identifier) => boolean; +type IsUserEventMethodFn = ( + node: TSESTree.Identifier, + userEventSession?: string +) => boolean; type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; type IsCreateEventUtil = ( node: TSESTree.CallExpression | TSESTree.Identifier @@ -97,6 +100,9 @@ type FindImportedTestingLibraryUtilSpecifierFn = ( type IsNodeComingFromTestingLibraryFn = ( node: TSESTree.Identifier | TSESTree.MemberExpression ) => boolean; +type getUserEventImportIdentifierFn = ( + node: ImportModuleNode | null +) => TSESTree.Identifier | null; export interface DetectionHelpers { getTestingLibraryImportNode: GetTestingLibraryImportNodeFn; @@ -130,6 +136,7 @@ export interface DetectionHelpers { canReportErrors: CanReportErrorsFn; findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn; isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; + getUserEventImportIdentifier: getUserEventImportIdentifierFn; } const USER_EVENT_PACKAGE = '@testing-library/user-event'; @@ -326,6 +333,35 @@ export function detectTestingLibraryUtils< return getImportModuleName(importedCustomModuleNode); }; + const getUserEventImportIdentifier = (node: ImportModuleNode | null) => { + if (!node) { + return null; + } + + if (isImportDeclaration(node)) { + const userEventIdentifier = node.specifiers.find((specifier) => + isImportDefaultSpecifier(specifier) + ); + + if (userEventIdentifier) { + return userEventIdentifier.local; + } + } else { + if (!ASTUtils.isVariableDeclarator(node.parent)) { + return null; + } + + const requireNode = node.parent; + if (!ASTUtils.isIdentifier(requireNode.id)) { + return null; + } + + return requireNode.id; + } + + return null; + }; + /** * Determines whether Testing Library utils are imported or not for * current file being analyzed. @@ -557,7 +593,10 @@ export function detectTestingLibraryUtils< return regularCall || wildcardCall || wildcardCallWithCallExpression; }; - const isUserEventMethod: IsUserEventMethodFn = (node) => { + const isUserEventMethod: IsUserEventMethodFn = ( + node, + userEventInstance + ) => { const userEvent = findImportedUserEventSpecifier(); let userEventName: string | undefined; @@ -567,7 +606,7 @@ export function detectTestingLibraryUtils< userEventName = USER_EVENT_NAME; } - if (!userEventName) { + if (!userEventName && !userEventInstance) { return false; } @@ -591,8 +630,11 @@ export function detectTestingLibraryUtils< // check userEvent.click() usage return ( - ASTUtils.isIdentifier(parentMemberExpression.object) && - parentMemberExpression.object.name === userEventName + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventName) || + // check userEventInstance.click() usage + (ASTUtils.isIdentifier(parentMemberExpression.object) && + parentMemberExpression.object.name === userEventInstance) ); }; @@ -853,35 +895,7 @@ 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 { - if ( - !ASTUtils.isVariableDeclarator(importedUserEventLibraryNode.parent) - ) { - return null; - } - - const requireNode = importedUserEventLibraryNode.parent; - if (!ASTUtils.isIdentifier(requireNode.id)) { - return null; - } - - return requireNode.id; - } - - return null; + return getUserEventImportIdentifier(importedUserEventLibraryNode); }; const getTestingLibraryImportedUtilSpecifier = ( @@ -997,6 +1011,7 @@ export function detectTestingLibraryUtils< canReportErrors, findImportedTestingLibraryUtilSpecifier, isNodeComingFromTestingLibrary, + getUserEventImportIdentifier, }; // Instructions for Testing Library detection. diff --git a/lib/node-utils/index.ts b/lib/node-utils/index.ts index 0b41bd4a..5278fc3a 100644 --- a/lib/node-utils/index.ts +++ b/lib/node-utils/index.ts @@ -679,3 +679,37 @@ export function findImportSpecifier( return (property as TSESTree.Property).key as TSESTree.Identifier; } } + +/** + * Finds if the userEvent is used as an instance + */ + +export function getUserEventInstance( + context: TSESLint.RuleContext, + userEventImport: TSESTree.Identifier | null +): string | undefined { + const { tokensAndComments } = context.getSourceCode(); + if (!userEventImport) { + return undefined; + } + /** + * Check for the following pattern: + * userEvent.setup( + * For a line like this: + * const user = userEvent.setup(); + * function will return 'user' + */ + for (const [index, token] of tokensAndComments.entries()) { + if ( + token.type === 'Identifier' && + token.value === userEventImport.name && + tokensAndComments[index + 1].value === '.' && + tokensAndComments[index + 2].value === 'setup' && + tokensAndComments[index + 3].value === '(' && + tokensAndComments[index - 1].value === '=' + ) { + return tokensAndComments[index - 2].value; + } + } + return undefined; +} diff --git a/lib/rules/await-async-events.ts b/lib/rules/await-async-events.ts index 6ca29473..264fdf2e 100644 --- a/lib/rules/await-async-events.ts +++ b/lib/rules/await-async-events.ts @@ -6,6 +6,7 @@ import { findClosestFunctionExpressionNode, getFunctionName, getInnermostReturningFunction, + getUserEventInstance, getVariableReferences, isMemberExpression, isPromiseHandled, @@ -118,9 +119,20 @@ export default createTestingLibraryRule({ return { 'CallExpression Identifier'(node: TSESTree.Identifier) { + const importedUserEventLibraryNode = + helpers.getTestingLibraryImportNode(); + const userEventImport = helpers.getUserEventImportIdentifier( + importedUserEventLibraryNode + ); + // Check if userEvent is used as an instance, like const user = userEvent.setup() + const userEventInstance = getUserEventInstance( + context, + userEventImport + ); if ( (isFireEventEnabled && helpers.isFireEventMethod(node)) || - (isUserEventEnabled && helpers.isUserEventMethod(node)) + (isUserEventEnabled && + helpers.isUserEventMethod(node, userEventInstance)) ) { detectEventMethodWrapper(node); diff --git a/tests/lib/rules/await-async-events.test.ts b/tests/lib/rules/await-async-events.test.ts index ba7110a5..d6cfe51d 100644 --- a/tests/lib/rules/await-async-events.test.ts +++ b/tests/lib/rules/await-async-events.test.ts @@ -32,7 +32,7 @@ const USER_EVENT_ASYNC_FUNCTIONS = [ 'upload', ] as const; const FIRE_EVENT_ASYNC_FRAMEWORKS = [ - '@testing-library/vue', + // '@testing-library/vue', '@marko/testing-library', ] as const; const USER_EVENT_ASYNC_FRAMEWORKS = ['@testing-library/user-event'] as const; @@ -374,6 +374,27 @@ ruleTester.run(RULE_NAME, rule, { `, options: [{ eventModule: ['userEvent', 'fireEvent'] }] as Options, }, + { + code: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('userEvent as instance', async () => { + const user = userEvent.setup() + await user.click(getByLabelText('username')) + }) + `, + options: [{ eventModule: ['userEvent'] }] as Options, + }, + { + code: ` + import u from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('userEvent as named import', async () => { + const user = u.setup() + await user.click(getByLabelText('username')) + await u.click(getByLabelText('username')) + }) + `, + options: [{ eventModule: ['userEvent'] }] as Options, + }, ]), ], @@ -960,6 +981,70 @@ ruleTester.run(RULE_NAME, rule, { } triggerEvent() + `, + } as const) + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('instance of userEvent is recognized as async event', async function() { + const user = userEvent.setup() + user.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 5, + column: 5, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('instance of userEvent is recognized as async event', async function() { + const user = userEvent.setup() + await user.${eventMethod}(getByLabelText('username')) + }) + `, + } as const) + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('instance of userEvent is recognized as async event along with static userEvent', async function() { + const user = userEvent.setup() + user.${eventMethod}(getByLabelText('username')) + userEvent.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 5, + column: 5, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + { + line: 6, + column: 5, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('instance of userEvent is recognized as async event along with static userEvent', async function() { + const user = userEvent.setup() + await user.${eventMethod}(getByLabelText('username')) + await userEvent.${eventMethod}(getByLabelText('username')) + }) `, } as const) ), @@ -1021,6 +1106,60 @@ ruleTester.run(RULE_NAME, rule, { fireEvent.click(getByLabelText('username')) await userEvent.click(getByLabelText('username')) }) + `, + }, + { + code: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + let user; + beforeEach(() => { + user = userEvent.setup() + }) + test('instance of userEvent is recognized as async event when instance is initialized in beforeEach', async function() { + user.click(getByLabelText('username')) + }) + `, + errors: [ + { + line: 8, + column: 5, + messageId: 'awaitAsyncEvent', + data: { name: 'click' }, + }, + ], + output: ` + import userEvent from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + let user; + beforeEach(() => { + user = userEvent.setup() + }) + test('instance of userEvent is recognized as async event when instance is initialized in beforeEach', async function() { + await user.click(getByLabelText('username')) + }) + `, + }, + { + code: ` + import u from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('userEvent as named import', async function() { + const user = u.setup() + user.click(getByLabelText('username')) + }) + `, + errors: [ + { + line: 5, + column: 5, + messageId: 'awaitAsyncEvent', + data: { name: 'click' }, + }, + ], + output: ` + import u from '${USER_EVENT_ASYNC_FRAMEWORKS[0]}' + test('userEvent as named import', async function() { + const user = u.setup() + await user.click(getByLabelText('username')) + }) `, }, ],