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 f8799e78..a140a74f 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -81,7 +81,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, + userEventSetupVars?: Set +) => boolean; type IsRenderUtilFn = (node: TSESTree.Identifier) => boolean; type IsCreateEventUtil = ( node: TSESTree.CallExpression | TSESTree.Identifier @@ -563,7 +566,10 @@ export function detectTestingLibraryUtils< return regularCall || wildcardCall || wildcardCallWithCallExpression; }; - const isUserEventMethod: IsUserEventMethodFn = (node) => { + const isUserEventMethod: IsUserEventMethodFn = ( + node, + userEventSetupVars + ) => { const userEvent = findImportedUserEventSpecifier(); let userEventName: string | undefined; @@ -573,10 +579,6 @@ export function detectTestingLibraryUtils< userEventName = USER_EVENT_NAME; } - if (!userEventName) { - return false; - } - const parentMemberExpression: TSESTree.MemberExpression | undefined = node.parent && isMemberExpression(node.parent) ? node.parent @@ -588,18 +590,33 @@ export function detectTestingLibraryUtils< // make sure that given node it's not userEvent object itself if ( - [userEventName, USER_EVENT_NAME].includes(node.name) || + (userEventName && + [userEventName, USER_EVENT_NAME].includes(node.name)) || (ASTUtils.isIdentifier(parentMemberExpression.object) && parentMemberExpression.object.name === node.name) ) { return false; } - // check userEvent.click() usage - return ( + // check userEvent.click() usage (imported identifier) + if ( + userEventName && ASTUtils.isIdentifier(parentMemberExpression.object) && parentMemberExpression.object.name === userEventName - ); + ) { + return true; + } + + // check user.click() usage where user is a variable from userEvent.setup() + if ( + userEventSetupVars && + ASTUtils.isIdentifier(parentMemberExpression.object) && + userEventSetupVars.has(parentMemberExpression.object.name) + ) { + return true; + } + + return false; }; /** diff --git a/lib/rules/await-async-events.ts b/lib/rules/await-async-events.ts index a5a5b4e7..0d2a3174 100644 --- a/lib/rules/await-async-events.ts +++ b/lib/rules/await-async-events.ts @@ -1,4 +1,4 @@ -import { ASTUtils } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, ASTUtils } from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { @@ -83,6 +83,12 @@ export default createTestingLibraryRule({ create(context, [options], helpers) { const functionWrappersNames: string[] = []; + // Track variables assigned from userEvent.setup() (directly or via destructuring) + const userEventSetupVars = new Set(); + + // Track functions that return userEvent.setup() instances and their property names + const setupFunctions = new Map>(); + function reportUnhandledNode({ node, closestCallExpression, @@ -112,6 +118,17 @@ export default createTestingLibraryRule({ } } + function isUserEventSetupCall(node: TSESTree.Node): boolean { + return ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name === USER_EVENT_NAME && + node.callee.property.type === AST_NODE_TYPES.Identifier && + node.callee.property.name === USER_EVENT_SETUP_FUNCTION_NAME + ); + } + const eventModules = typeof options.eventModule === 'string' ? [options.eventModule] @@ -120,10 +137,88 @@ export default createTestingLibraryRule({ const isUserEventEnabled = eventModules.includes(USER_EVENT_NAME); return { + // Track variables assigned from userEvent.setup() and destructuring from setup functions + VariableDeclarator(node: TSESTree.VariableDeclarator) { + if (!isUserEventEnabled) return; + + // Direct assignment: const user = userEvent.setup(); + if ( + node.init && + isUserEventSetupCall(node.init) && + node.id.type === AST_NODE_TYPES.Identifier + ) { + userEventSetupVars.add(node.id.name); + } + + // Destructuring: const { user, myUser: alias } = setup(...) + if ( + node.id.type === AST_NODE_TYPES.ObjectPattern && + node.init && + node.init.type === AST_NODE_TYPES.CallExpression && + node.init.callee.type === AST_NODE_TYPES.Identifier + ) { + const functionName = node.init.callee.name; + const setupProps = setupFunctions.get(functionName); + + if (setupProps) { + for (const prop of node.id.properties) { + if ( + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier && + setupProps.has(prop.key.name) && + prop.value.type === AST_NODE_TYPES.Identifier + ) { + userEventSetupVars.add(prop.value.name); + } + } + } + } + }, + + // Track functions that return { ...: userEvent.setup(), ... } + ReturnStatement(node: TSESTree.ReturnStatement) { + if ( + !isUserEventEnabled || + !node.argument || + node.argument.type !== AST_NODE_TYPES.ObjectExpression + ) { + return; + } + + const setupProps = new Set(); + for (const prop of node.argument.properties) { + if ( + prop.type === AST_NODE_TYPES.Property && + prop.key.type === AST_NODE_TYPES.Identifier + ) { + // Direct: foo: userEvent.setup() + if (isUserEventSetupCall(prop.value)) { + setupProps.add(prop.key.name); + } + // Indirect: foo: u, where u is a userEvent.setup() var + else if ( + prop.value.type === AST_NODE_TYPES.Identifier && + userEventSetupVars.has(prop.value.name) + ) { + setupProps.add(prop.key.name); + } + } + } + + if (setupProps.size > 0) { + const functionNode = findClosestFunctionExpressionNode(node); + if (functionNode) { + const functionName = getFunctionName(functionNode); + setupFunctions.set(functionName, setupProps); + } + } + }, + 'CallExpression Identifier'(node: TSESTree.Identifier) { if ( (isFireEventEnabled && helpers.isFireEventMethod(node)) || - (isUserEventEnabled && helpers.isUserEventMethod(node)) + (isUserEventEnabled && + helpers.isUserEventMethod(node, userEventSetupVars)) ) { if (node.name === USER_EVENT_SETUP_FUNCTION_NAME) { return; diff --git a/tests/lib/rules/await-async-events.test.ts b/tests/lib/rules/await-async-events.test.ts index bbf837e6..38894f5a 100644 --- a/tests/lib/rules/await-async-events.test.ts +++ b/tests/lib/rules/await-async-events.test.ts @@ -1208,6 +1208,178 @@ ruleTester.run(RULE_NAME, rule, { `, }) as const ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('unhandled promise from event method called from userEvent.setup() return value is invalid', () => { + const user = userEvent.setup(); + user.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 5, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('unhandled promise from event method called from userEvent.setup() return value is invalid', async () => { + const user = userEvent.setup(); + await user.${eventMethod}(getByLabelText('username')) + }) + `, + }) as const + ), + // This covers the example in the docs: + // https://testing-library.com/docs/user-event/intro#writing-tests-with-userevent + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('unhandled promise from event method called from destructured custom setup function is invalid', () => { + function customSetup(jsx) { + return { + user: userEvent.setup(), + ...render(jsx) + } + } + const { user } = customSetup(); + user.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 11, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('unhandled promise from event method called from destructured custom setup function is invalid', async () => { + function customSetup(jsx) { + return { + user: userEvent.setup(), + ...render(jsx) + } + } + const { user } = customSetup(); + await user.${eventMethod}(getByLabelText('username')) + }) + `, + }) as const + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('unhandled promise from aliased event method called from destructured custom setup function is invalid', () => { + function customSetup(jsx) { + return { + foo: userEvent.setup(), + bar: userEvent.setup(), + ...render(jsx) + } + } + const { foo, bar: myUser } = customSetup(); + myUser.${eventMethod}(getByLabelText('username')) + foo.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 12, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + { + line: 13, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('unhandled promise from aliased event method called from destructured custom setup function is invalid', async () => { + function customSetup(jsx) { + return { + foo: userEvent.setup(), + bar: userEvent.setup(), + ...render(jsx) + } + } + const { foo, bar: myUser } = customSetup(); + await myUser.${eventMethod}(getByLabelText('username')) + await foo.${eventMethod}(getByLabelText('username')) + }) + `, + }) as const + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('unhandled promise from setup reference in custom setup function is invalid', () => { + function customSetup(jsx) { + const u = userEvent.setup() + return { + foo: u, + bar: u, + ...render(jsx) + } + } + const { foo, bar: myUser } = customSetup(); + myUser.${eventMethod}(getByLabelText('username')) + foo.${eventMethod}(getByLabelText('username')) + }) + `, + errors: [ + { + line: 13, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + { + line: 14, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('unhandled promise from setup reference in custom setup function is invalid', async () => { + function customSetup(jsx) { + const u = userEvent.setup() + return { + foo: u, + bar: u, + ...render(jsx) + } + } + const { foo, bar: myUser } = customSetup(); + await myUser.${eventMethod}(getByLabelText('username')) + await foo.${eventMethod}(getByLabelText('username')) + }) + `, + }) as const + ), ]), { code: `