diff --git a/lib/node-utils/index.ts b/lib/node-utils/index.ts index 0b41bd4a..0f748c5c 100644 --- a/lib/node-utils/index.ts +++ b/lib/node-utils/index.ts @@ -222,40 +222,63 @@ export function isPromiseHandled(nodeIdentifier: TSESTree.Identifier): boolean { nodeIdentifier, true ); + const callRootExpression = + closestCallExpressionNode == null + ? null + : getRootExpression(closestCallExpressionNode); - const suspiciousNodes = [nodeIdentifier, closestCallExpressionNode].filter( - Boolean + const suspiciousNodes = [nodeIdentifier, callRootExpression].filter( + (node): node is NonNullable => node != null ); - for (const node of suspiciousNodes) { - if (!node?.parent) { - continue; - } - if (ASTUtils.isAwaitExpression(node.parent)) { - return true; - } - + return suspiciousNodes.some((node) => { + if (!node.parent) return false; + if (ASTUtils.isAwaitExpression(node.parent)) return true; if ( isArrowFunctionExpression(node.parent) || isReturnStatement(node.parent) - ) { - return true; - } - - if (hasClosestExpectResolvesRejects(node.parent)) { - return true; - } - - if (hasChainedThen(node)) { + ) return true; - } + if (hasClosestExpectResolvesRejects(node.parent)) return true; + if (hasChainedThen(node)) return true; + if (isPromisesArrayResolved(node)) return true; + }); +} - if (isPromisesArrayResolved(node)) { - return true; +/** + * For an expression in a parent that evaluates to the expression or another child returns the parent node recursively. + */ +function getRootExpression( + expression: TSESTree.Expression +): TSESTree.Expression { + const { parent } = expression; + if (parent == null) return expression; + switch (parent.type) { + case AST_NODE_TYPES.ConditionalExpression: + return getRootExpression(parent); + case AST_NODE_TYPES.LogicalExpression: { + let rootExpression; + switch (parent.operator) { + case '??': + case '||': + rootExpression = getRootExpression(parent); + break; + case '&&': + rootExpression = + parent.right === expression + ? getRootExpression(parent) + : expression; + break; + } + return rootExpression ?? expression; } + case AST_NODE_TYPES.SequenceExpression: + return parent.expressions[parent.expressions.length - 1] === expression + ? getRootExpression(parent) + : expression; + default: + return expression; } - - return false; } export function getVariableReferences( diff --git a/tests/lib/rules/await-async-events.test.ts b/tests/lib/rules/await-async-events.test.ts index ba7110a5..894f7f92 100644 --- a/tests/lib/rules/await-async-events.test.ts +++ b/tests/lib/rules/await-async-events.test.ts @@ -311,6 +311,19 @@ ruleTester.run(RULE_NAME, rule, { await triggerEvent() }) + `, + options: [{ eventModule: 'userEvent' }] as const, + })), + ...USER_EVENT_ASYNC_FUNCTIONS.map((eventMethod) => ({ + code: ` + import userEvent from '${testingFramework}' + test('await expression that evaluates to promise is valid', async () => { + await (null, userEvent.${eventMethod}(getByLabelText('username'))); + await (condition ? null : userEvent.${eventMethod}(getByLabelText('username'))); + await (condition && userEvent.${eventMethod}(getByLabelText('username'))); + await (userEvent.${eventMethod}(getByLabelText('username')) || userEvent.${eventMethod}(getByLabelText('username'))); + await (userEvent.${eventMethod}(getByLabelText('username')) ?? userEvent.${eventMethod}(getByLabelText('username'))); + }) `, options: [{ eventModule: 'userEvent' }] as const, })), @@ -960,6 +973,92 @@ ruleTester.run(RULE_NAME, rule, { } triggerEvent() + `, + } as const) + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('unhandled expression that evaluates to promise is invalid', () => { + condition ? null : (null, true && userEvent.${eventMethod}(getByLabelText('username'))); + }); + `, + errors: [ + { + line: 4, + column: 38, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('unhandled expression that evaluates to promise is invalid', async () => { + condition ? null : (null, true && await userEvent.${eventMethod}(getByLabelText('username'))); + }); + `, + } as const) + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('handled AND expression with left promise is invalid', async () => { + await (userEvent.${eventMethod}(getByLabelText('username')) && userEvent.${eventMethod}(getByLabelText('username'))); + }); + `, + errors: [ + { + line: 4, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('handled AND expression with left promise is invalid', async () => { + await (await userEvent.${eventMethod}(getByLabelText('username')) && userEvent.${eventMethod}(getByLabelText('username'))); + }); + `, + } as const) + ), + ...USER_EVENT_ASYNC_FUNCTIONS.map( + (eventMethod) => + ({ + code: ` + import userEvent from '${testingFramework}' + test('voided promise is invalid', async () => { + await void userEvent.${eventMethod}(getByLabelText('username')); + await (userEvent.${eventMethod}(getByLabelText('username')), null); + }); + `, + errors: [ + { + line: 4, + column: 15, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + { + line: 5, + column: 11, + messageId: 'awaitAsyncEvent', + data: { name: eventMethod }, + }, + ], + options: [{ eventModule: 'userEvent' }], + output: ` + import userEvent from '${testingFramework}' + test('voided promise is invalid', async () => { + await void await userEvent.${eventMethod}(getByLabelText('username')); + await (await userEvent.${eventMethod}(getByLabelText('username')), null); + }); `, } as const) ), diff --git a/tests/lib/rules/await-async-utils.test.ts b/tests/lib/rules/await-async-utils.test.ts index 7eb211bb..e91517b3 100644 --- a/tests/lib/rules/await-async-utils.test.ts +++ b/tests/lib/rules/await-async-utils.test.ts @@ -418,6 +418,33 @@ ruleTester.run(RULE_NAME, rule, { doSomethingElse(aPromise); ${asyncUtil}(() => getByLabelText('email')); }); + `, + errors: [ + { + line: 4, + column: 28, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + { + line: 6, + column: 11, + messageId: 'awaitAsyncUtil', + data: { name: asyncUtil }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('unhandled expression that evaluates to promise is invalid', () => { + const aPromise = ${asyncUtil}(() => getByLabelText('username')); + doSomethingElse(aPromise); + ${asyncUtil}(() => getByLabelText('email')); + }); `, errors: [ {