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 fd5a4973..2b83fce8 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -79,7 +79,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 7ac69ede..b9afb933 100644 --- a/lib/rules/await-async-events.ts +++ b/lib/rules/await-async-events.ts @@ -1,4 +1,9 @@ -import { ASTUtils, TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { + AST_NODE_TYPES, + ASTUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { @@ -81,6 +86,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, @@ -110,6 +121,32 @@ 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 + ); + } + + function findFunctionName(node: TSESTree.Node): string | null { + let current: TSESTree.Node | undefined = node; + while (current) { + if ( + current.type === AST_NODE_TYPES.FunctionDeclaration || + current.type === AST_NODE_TYPES.FunctionExpression || + current.type === AST_NODE_TYPES.ArrowFunctionExpression + ) { + return getFunctionName(current); + } + current = current.parent; + } + return null; + } + const eventModules = typeof options.eventModule === 'string' ? [options.eventModule] @@ -118,10 +155,87 @@ 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 functionName = findFunctionName(node); + if (functionName) { + 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 01de4d16..231623b7 100644 --- a/tests/lib/rules/await-async-events.test.ts +++ b/tests/lib/rules/await-async-events.test.ts @@ -1189,6 +1189,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: `