diff --git a/docs/rules/prefer-find-by.md b/docs/rules/prefer-find-by.md index 2a39b131..0431e27b 100644 --- a/docs/rules/prefer-find-by.md +++ b/docs/rules/prefer-find-by.md @@ -41,6 +41,12 @@ const submitButton = await waitFor(() => const submitButton = await waitFor(() => expect(queryByLabel('button', { name: /submit/i })).not.toBeFalsy() ); + +// unnecessary usage of waitFor with findBy*, which already includes waiting logic +await waitFor(async () => { + const button = await findByRole('button', { name: 'Submit' }); + expect(button).toBeInTheDocument(); +}); ``` Examples of **correct** code for this rule: diff --git a/lib/rules/prefer-find-by.ts b/lib/rules/prefer-find-by.ts index 1fc26ebd..5e3f35a7 100644 --- a/lib/rules/prefer-find-by.ts +++ b/lib/rules/prefer-find-by.ts @@ -2,12 +2,15 @@ import { TSESTree, ASTUtils, TSESLint } from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { + getDeepestIdentifierNode, isArrowFunctionExpression, + isBlockStatement, isCallExpression, isMemberExpression, isObjectExpression, isObjectPattern, isProperty, + isVariableDeclaration, } from '../node-utils'; import { getScope, getSourceCode } from '../utils'; @@ -329,20 +332,82 @@ export default createTestingLibraryRule({ } return { - 'AwaitExpression > CallExpression'(node: TSESTree.CallExpression) { + 'AwaitExpression > CallExpression'( + node: TSESTree.CallExpression & { parent: TSESTree.AwaitExpression } + ) { if ( !ASTUtils.isIdentifier(node.callee) || !helpers.isAsyncUtil(node.callee, ['waitFor']) ) { return; } - // ensure the only argument is an arrow function expression - if the arrow function is a block - // we skip it + // ensure the only argument is an arrow function expression const argument = node.arguments[0]; - if ( - !isArrowFunctionExpression(argument) || - !isCallExpression(argument.body) - ) { + + if (!isArrowFunctionExpression(argument)) { + return; + } + + if (isBlockStatement(argument.body) && argument.async) { + const { body } = argument.body; + const declarations = body + .filter(isVariableDeclaration) + ?.flatMap((declaration) => declaration.declarations); + + const findByDeclarator = declarations.find((declaration) => { + if ( + !ASTUtils.isAwaitExpression(declaration.init) || + !isCallExpression(declaration.init.argument) + ) { + return false; + } + + const { callee } = declaration.init.argument; + const node = getDeepestIdentifierNode(callee); + return node ? helpers.isFindQueryVariant(node) : false; + }); + + const init = ASTUtils.isAwaitExpression(findByDeclarator?.init) + ? findByDeclarator.init.argument + : null; + + if (!isCallExpression(init)) { + return; + } + const queryIdentifier = getDeepestIdentifierNode(init.callee); + + // ensure the query is a supported async query like findBy* + if (!queryIdentifier || !helpers.isAsyncQuery(queryIdentifier)) { + return; + } + + const fullQueryMethod = queryIdentifier.name; + const queryMethod = fullQueryMethod.split('By')[1]; + const queryVariant = getFindByQueryVariant(fullQueryMethod); + + reportInvalidUsage(node, { + queryMethod, + queryVariant, + prevQuery: fullQueryMethod, + fix(fixer) { + const { parent: expressionStatement } = node.parent; + const bodyText = sourceCode + .getText(argument.body) + .slice(1, -1) + .trim(); + const { line, column } = expressionStatement.loc.start; + const indent = sourceCode.getLines()[line - 1].slice(0, column); + const newText = bodyText + .split('\n') + .map((line) => line.trim()) + .join(`\n${indent}`); + return fixer.replaceText(expressionStatement, newText); + }, + }); + return; + } + + if (!isCallExpression(argument.body)) { return; } diff --git a/tests/lib/rules/prefer-find-by.test.ts b/tests/lib/rules/prefer-find-by.test.ts index 2f27043e..3521db71 100644 --- a/tests/lib/rules/prefer-find-by.test.ts +++ b/tests/lib/rules/prefer-find-by.test.ts @@ -51,6 +51,17 @@ ruleTester.run(RULE_NAME, rule, { it('tests', async () => { const submitButton = await screen.${queryMethod}('foo') }) + `, + })), + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ + code: ` + import {waitFor} from '${testingFramework}'; + it('tests', async () => { + await waitFor(async () => { + const button = screen.${queryMethod}("button", { name: "Submit" }) + expect(button).toBeInTheDocument() + }) + }) `, })), ...SYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ @@ -164,6 +175,17 @@ ruleTester.run(RULE_NAME, rule, { const { container } = render() await waitFor(() => expect(container.querySelector('baz')).toBeInTheDocument()); }) + `, + }, + { + code: ` + import {waitFor} from '${testingFramework}'; + it('tests', async () => { + await waitFor(async () => { + const button = await foo("button", { name: "Submit" }) + expect(button).toBeInTheDocument() + }) + }) `, }, ]), @@ -689,6 +711,35 @@ ruleTester.run(RULE_NAME, rule, { const button = await screen.${buildFindByMethod( queryMethod )}('Count is: 0', { timeout: 100, interval: 200 }) + `, + })), + ...ASYNC_QUERIES_COMBINATIONS.map((queryMethod) => ({ + code: ` + import {waitFor} from '${testingFramework}'; + it('tests', async () => { + await waitFor(async () => { + const button = await screen.${queryMethod}("button", { name: "Submit" }) + expect(button).toBeInTheDocument() + }) + }) + `, + errors: [ + { + messageId: 'preferFindBy', + data: { + queryVariant: getFindByQueryVariant(queryMethod), + queryMethod: queryMethod.split('By')[1], + prevQuery: queryMethod, + waitForMethodName: 'waitFor', + }, + }, + ], + output: ` + import {waitFor} from '${testingFramework}'; + it('tests', async () => { + const button = await screen.${queryMethod}("button", { name: "Submit" }) + expect(button).toBeInTheDocument() + }) `, })), ]),