diff --git a/lib/rules/await-async-utils.ts b/lib/rules/await-async-utils.ts index dd805a06..9f545eba 100644 --- a/lib/rules/await-async-utils.ts +++ b/lib/rules/await-async-utils.ts @@ -1,12 +1,15 @@ -import { TSESTree } from '@typescript-eslint/utils'; +import { TSESTree, ASTUtils } from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { findClosestCallExpressionNode, + getDeepestIdentifierNode, getFunctionName, getInnermostReturningFunction, getVariableReferences, + isObjectPattern, isPromiseHandled, + isProperty, } from '../node-utils'; export const RULE_NAME = 'await-async-utils'; @@ -47,59 +50,114 @@ export default createTestingLibraryRule({ } } + /* + Example: + `const { myAsyncWrapper: myRenamedValue } = someObject`; + Detects `myRenamedValue` and adds it to the known async wrapper names. + */ + function detectDestructuredAsyncUtilWrapperAliases( + node: TSESTree.ObjectPattern + ) { + for (const property of node.properties) { + if (!isProperty(property)) { + continue; + } + + if ( + !ASTUtils.isIdentifier(property.key) || + !ASTUtils.isIdentifier(property.value) + ) { + continue; + } + + if (functionWrappersNames.includes(property.key.name)) { + const isDestructuredAsyncWrapperPropertyRenamed = + property.key.name !== property.value.name; + + if (isDestructuredAsyncWrapperPropertyRenamed) { + functionWrappersNames.push(property.value.name); + } + } + } + } + + /* + Either we report a direct usage of an async util or a usage of a wrapper + around an async util + */ + const getMessageId = (node: TSESTree.Identifier): MessageIds => { + if (helpers.isAsyncUtil(node)) { + return 'awaitAsyncUtil'; + } + + return 'asyncUtilWrapper'; + }; + return { + VariableDeclarator(node: TSESTree.VariableDeclarator) { + if (isObjectPattern(node.id)) { + detectDestructuredAsyncUtilWrapperAliases(node.id); + return; + } + + const isAssigningKnownAsyncFunctionWrapper = + ASTUtils.isIdentifier(node.id) && + node.init !== null && + functionWrappersNames.includes( + getDeepestIdentifierNode(node.init)?.name ?? '' + ); + + if (isAssigningKnownAsyncFunctionWrapper) { + functionWrappersNames.push((node.id as TSESTree.Identifier).name); + } + }, 'CallExpression Identifier'(node: TSESTree.Identifier) { + const isAsyncUtilOrKnownAliasAroundIt = + helpers.isAsyncUtil(node) || + functionWrappersNames.includes(node.name); + if (!isAsyncUtilOrKnownAliasAroundIt) { + return; + } + + // detect async query used within wrapper function for later analysis if (helpers.isAsyncUtil(node)) { - // detect async query used within wrapper function for later analysis detectAsyncUtilWrapper(node); + } - const closestCallExpression = findClosestCallExpressionNode( - node, - true - ); + const closestCallExpression = findClosestCallExpressionNode(node, true); - if (!closestCallExpression?.parent) { - return; - } + if (!closestCallExpression?.parent) { + return; + } - const references = getVariableReferences( - context, - closestCallExpression.parent - ); + const references = getVariableReferences( + context, + closestCallExpression.parent + ); - if (references.length === 0) { - if (!isPromiseHandled(node)) { + if (references.length === 0) { + if (!isPromiseHandled(node)) { + context.report({ + node, + messageId: getMessageId(node), + data: { + name: node.name, + }, + }); + } + } else { + for (const reference of references) { + const referenceNode = reference.identifier as TSESTree.Identifier; + if (!isPromiseHandled(referenceNode)) { context.report({ node, - messageId: 'awaitAsyncUtil', + messageId: getMessageId(node), data: { name: node.name, }, }); + return; } - } else { - for (const reference of references) { - const referenceNode = reference.identifier as TSESTree.Identifier; - if (!isPromiseHandled(referenceNode)) { - context.report({ - node, - messageId: 'awaitAsyncUtil', - data: { - name: node.name, - }, - }); - return; - } - } - } - } else if (functionWrappersNames.includes(node.name)) { - // check async queries used within a wrapper previously detected - if (!isPromiseHandled(node)) { - context.report({ - node, - messageId: 'asyncUtilWrapper', - data: { name: node.name }, - }); } } }, diff --git a/tests/lib/rules/await-async-utils.test.ts b/tests/lib/rules/await-async-utils.test.ts index 68d44d9f..65248eac 100644 --- a/tests/lib/rules/await-async-utils.test.ts +++ b/tests/lib/rules/await-async-utils.test.ts @@ -260,6 +260,40 @@ ruleTester.run(RULE_NAME, rule, { }) `, }, + ...ASYNC_UTILS.map((asyncUtil) => ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('destructuring an async function wrapper & handling it later is valid', () => { + const { user, waitForAsyncUtil } = setup(); + await waitForAsyncUtil(); + + const myAlias = waitForAsyncUtil; + const myOtherAlias = myAlias; + await myAlias(); + await myOtherAlias(); + + const { ...clone } = setup(); + await clone.waitForAsyncUtil(); + + const { waitForAsyncUtil: myDestructuredAlias } = setup(); + await myDestructuredAlias(); + + const { user, ...rest } = setup(); + await rest.waitForAsyncUtil(); + + await setup().waitForAsyncUtil(); + }); + `, + })), ]), invalid: SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => [ ...ASYNC_UTILS.map( @@ -441,6 +475,7 @@ ruleTester.run(RULE_NAME, rule, { ], } as const) ), + ...ASYNC_UTILS.map( (asyncUtil) => ({ @@ -463,5 +498,179 @@ ruleTester.run(RULE_NAME, rule, { ], } as const) ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + const { user, waitForAsyncUtil } = setup(); + waitForAsyncUtil(); + }); + `, + errors: [ + { + line: 14, + column: 11, + messageId: 'asyncUtilWrapper', + data: { name: 'waitForAsyncUtil' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + const { user, waitForAsyncUtil } = setup(); + const myAlias = waitForAsyncUtil; + myAlias(); + }); + `, + errors: [ + { + line: 15, + column: 11, + messageId: 'asyncUtilWrapper', + data: { name: 'myAlias' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + const { ...clone } = setup(); + clone.waitForAsyncUtil(); + }); + `, + errors: [ + { + line: 14, + column: 17, + messageId: 'asyncUtilWrapper', + data: { name: 'waitForAsyncUtil' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + const { waitForAsyncUtil: myAlias } = setup(); + myAlias(); + }); + `, + errors: [ + { + line: 14, + column: 11, + messageId: 'asyncUtilWrapper', + data: { name: 'myAlias' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + setup().waitForAsyncUtil(); + }); + `, + errors: [ + { + line: 13, + column: 19, + messageId: 'asyncUtilWrapper', + data: { name: 'waitForAsyncUtil' }, + }, + ], + } as const) + ), + ...ASYNC_UTILS.map( + (asyncUtil) => + ({ + code: ` + function setup() { + const utils = render(); + + const waitForAsyncUtil = () => { + return ${asyncUtil}(screen.queryByTestId('my-test-id')); + }; + + return { waitForAsyncUtil, ...utils }; + } + + test('unhandled promise from destructed property of async function wrapper is invalid', () => { + const myAlias = setup().waitForAsyncUtil; + myAlias(); + }); + `, + errors: [ + { + line: 14, + column: 11, + messageId: 'asyncUtilWrapper', + data: { name: 'myAlias' }, + }, + ], + } as const) + ), ]), });