From ac998040e8b38bc59515070ce44f23a429441e07 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sun, 7 Sep 2025 14:21:48 +0900 Subject: [PATCH 1/5] feat: create a fixer --- lib/rules/await-async-queries.ts | 64 ++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/lib/rules/await-async-queries.ts b/lib/rules/await-async-queries.ts index a2befb2a..ba679369 100644 --- a/lib/rules/await-async-queries.ts +++ b/lib/rules/await-async-queries.ts @@ -3,10 +3,12 @@ import { ASTUtils, TSESTree } from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { findClosestCallExpressionNode, + findClosestFunctionExpressionNode, getDeepestIdentifierNode, getFunctionName, getInnermostReturningFunction, getVariableReferences, + isMemberExpression, isPromiseHandled, } from '../node-utils'; @@ -35,6 +37,7 @@ export default createTestingLibraryRule({ asyncQueryWrapper: 'promise returned from `{{ name }}` wrapper over async query must be handled', }, + fixable: 'code', schema: [], }, defaultOptions: [], @@ -83,6 +86,18 @@ export default createTestingLibraryRule({ node: identifierNode, messageId: 'awaitAsyncQuery', data: { name: identifierNode.name }, + fix: (fixer) => { + if ( + isMemberExpression(identifierNode.parent) && + ASTUtils.isIdentifier(identifierNode.parent.object) + ) { + return fixer.insertTextBefore( + identifierNode.parent, + 'await ' + ); + } + return fixer.insertTextBefore(identifierNode, 'await '); + }, }); return; } @@ -100,6 +115,10 @@ export default createTestingLibraryRule({ node: identifierNode, messageId: 'awaitAsyncQuery', data: { name: identifierNode.name }, + fix: (fixer) => + references.map((ref) => + fixer.insertTextBefore(ref.identifier, 'await ') + ), }); return; } @@ -113,6 +132,51 @@ export default createTestingLibraryRule({ node: identifierNode, messageId: 'asyncQueryWrapper', data: { name: identifierNode.name }, + fix: (fixer) => { + const functionExpression = + findClosestFunctionExpressionNode(node); + + if (!functionExpression) return null; + + let IdentifierNodeFixer; + if (isMemberExpression(identifierNode.parent)) { + /** + * If the wrapper is a property of an object, + * add 'await' before the object, e.g.: + * const obj = { wrapper: () => screen.findByText(/foo/i) }; + * await obj.wrapper(); + */ + IdentifierNodeFixer = fixer.insertTextBefore( + identifierNode.parent, + 'await ' + ); + } else { + /** + * Add 'await' before the wrapper function, e.g.: + * const wrapper = () => screen.findByText(/foo/i); + * await wrapper(); + */ + IdentifierNodeFixer = fixer.insertTextBefore( + identifierNode, + 'await ' + ); + } + + if (functionExpression.async) { + return IdentifierNodeFixer; + } else { + /** + * Mutate the actual node so if other nodes exist in this + * function expression body they don't also try to fix it. + */ + functionExpression.async = true; + + return [ + IdentifierNodeFixer, + fixer.insertTextBefore(functionExpression, 'async '), + ]; + } + }, }); } }, From 80f1b66a38f252940fce2dc36dd3c2cf54213c35 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sun, 7 Sep 2025 14:21:58 +0900 Subject: [PATCH 2/5] test: add tests --- tests/lib/rules/await-async-queries.test.ts | 257 +++++++++++++------- 1 file changed, 173 insertions(+), 84 deletions(-) diff --git a/tests/lib/rules/await-async-queries.test.ts b/tests/lib/rules/await-async-queries.test.ts index 5c190b2c..3ba6c3c8 100644 --- a/tests/lib/rules/await-async-queries.test.ts +++ b/tests/lib/rules/await-async-queries.test.ts @@ -1,6 +1,10 @@ +import { InvalidTestCase, ValidTestCase } from '@typescript-eslint/rule-tester'; import { TSESLint } from '@typescript-eslint/utils'; -import rule, { RULE_NAME } from '../../../lib/rules/await-async-queries'; +import rule, { + RULE_NAME, + MessageIds, +} from '../../../lib/rules/await-async-queries'; import { ASYNC_QUERIES_COMBINATIONS, ASYNC_QUERIES_VARIANTS, @@ -11,6 +15,9 @@ import { createRuleTester } from '../test-utils'; const ruleTester = createRuleTester(); +type RuleValidTestCase = ValidTestCase<[]>; +type RuleInvalidTestCase = InvalidTestCase; + const SUPPORTED_TESTING_FRAMEWORKS = [ '@testing-library/dom', '@testing-library/angular', @@ -306,10 +313,8 @@ ruleTester.run(RULE_NAME, rule, { })), // handled promise assigned to variable returned from async query wrapper is valid - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` const queryWrapper = () => { return screen.${query}('foo') } @@ -319,8 +324,7 @@ ruleTester.run(RULE_NAME, rule, { expect(element).toBeVisible() }) `, - }) as const - ), + })), // non-matching query is valid ` @@ -413,10 +417,8 @@ ruleTester.run(RULE_NAME, rule, { invalid: [ ...SUPPORTED_TESTING_FRAMEWORKS.flatMap((testingFramework) => - ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: `// async queries without await operator or then method are not valid + ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: `// async queries without await operator or then method are not valid import { render } from '${testingFramework}' test("An example test", async () => { @@ -424,34 +426,43 @@ ruleTester.run(RULE_NAME, rule, { const foo = ${query}('foo') }); `, - errors: [{ messageId: 'awaitAsyncQuery', line: 6, column: 21 }], - }) as const - ) + errors: [{ messageId: 'awaitAsyncQuery', line: 6, column: 21 }], + output: `// async queries without await operator or then method are not valid + import { render } from '${testingFramework}' + + test("An example test", async () => { + doSomething() + const foo = await ${query}('foo') + }); + `, + })) ), - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: `// async screen queries without await operator or then method are not valid + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: `// async screen queries without await operator or then method are not valid import { render } from '@testing-library/react' test("An example test", async () => { screen.${query}('foo') }); `, - errors: [ - { - messageId: 'awaitAsyncQuery', - line: 5, - column: 16, - data: { name: query }, - }, - ], - }) as const - ), - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 5, + column: 16, + data: { name: query }, + }, + ], + output: `// async screen queries without await operator or then method are not valid + import { render } from '@testing-library/react' + + test("An example test", async () => { + await screen.${query}('foo') + }); + `, + })), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` import { render } from '@testing-library/react' test("An example test", async () => { @@ -459,20 +470,25 @@ ruleTester.run(RULE_NAME, rule, { const foo = ${query}('foo') }); `, - errors: [ - { - messageId: 'awaitAsyncQuery', - line: 6, - column: 21, - data: { name: query }, - }, - ], - }) as const - ), - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 6, + column: 21, + data: { name: query }, + }, + ], + output: ` + import { render } from '@testing-library/react' + + test("An example test", async () => { + doSomething() + const foo = await ${query}('foo') + }); + `, + })), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` import { render } from '@testing-library/react' test("An example test", async () => { @@ -481,37 +497,47 @@ ruleTester.run(RULE_NAME, rule, { expect(foo).toHaveAttribute('src', 'bar'); }); `, - errors: [ - { - messageId: 'awaitAsyncQuery', - line: 5, - column: 21, - data: { name: query }, - }, - ], - }) as const - ), + errors: [ + { + messageId: 'awaitAsyncQuery', + line: 5, + column: 21, + data: { name: query }, + }, + ], + output: ` + import { render } from '@testing-library/react' + + test("An example test", async () => { + const foo = ${query}('foo') + expect(await foo).toBeInTheDocument() + expect(await foo).toHaveAttribute('src', 'bar'); + }); + `, + })), // unresolved async queries are not valid (aggressive reporting) - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` import { render } from "another-library" test('An example test', async () => { const example = ${query}("my example") }) `, - errors: [{ messageId: 'awaitAsyncQuery', line: 5, column: 27 }], - }) as const - ), + errors: [{ messageId: 'awaitAsyncQuery', line: 5, column: 27 }], + output: ` + import { render } from "another-library" + + test('An example test', async () => { + const example = await ${query}("my example") + }) + `, + })), // unhandled promise from async query function wrapper is invalid - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` function queryWrapper() { doSomethingElse(); @@ -526,14 +552,26 @@ ruleTester.run(RULE_NAME, rule, { const element = await queryWrapper() }) `, - errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], - }) as const - ), + errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], + output: ` + function queryWrapper() { + doSomethingElse(); + + return screen.${query}('foo') + } + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + `, + })), // unhandled promise from async query arrow function wrapper is invalid - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` const queryWrapper = () => { doSomethingElse(); @@ -548,14 +586,26 @@ ruleTester.run(RULE_NAME, rule, { const element = await queryWrapper() }) `, - errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], - }) as const - ), + errors: [{ messageId: 'asyncQueryWrapper', line: 9, column: 27 }], + output: ` + const queryWrapper = () => { + doSomethingElse(); + + return ${query}('foo') + } + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + `, + })), // unhandled promise implicitly returned from async query arrow function wrapper is invalid - ...ALL_ASYNC_COMBINATIONS_TO_TEST.map( - (query) => - ({ - code: ` + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` const queryWrapper = () => screen.${query}('foo') test("An invalid example test", () => { @@ -566,9 +616,19 @@ ruleTester.run(RULE_NAME, rule, { const element = await queryWrapper() }) `, - errors: [{ messageId: 'asyncQueryWrapper', line: 5, column: 27 }], - }) as const - ), + errors: [{ messageId: 'asyncQueryWrapper', line: 5, column: 27 }], + output: ` + const queryWrapper = () => screen.${query}('foo') + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + + test("An invalid example test", async () => { + const element = await queryWrapper() + }) + `, + })), // unhandled promise from custom query matching custom-queries setting is invalid { @@ -581,6 +641,11 @@ ruleTester.run(RULE_NAME, rule, { }) `, errors: [{ messageId: 'awaitAsyncQuery', line: 3, column: 25 }], + output: ` + test('An invalid example test', () => { + const element = await findByIcon('search') + }) + `, }, { @@ -609,6 +674,30 @@ ruleTester.run(RULE_NAME, rule, { }) `, errors: [{ messageId: 'asyncQueryWrapper', line: 19, column: 34 }], + output: `// similar to issue #359 but forcing an error in no-awaited wrapper + import { render, screen } from 'mocks/test-utils' + import userEvent from '@testing-library/user-event' + + const testData = { + name: 'John Doe', + email: 'john@doe.com', + password: 'extremeSecret', + } + + const selectors = { + username: () => screen.findByRole('textbox', { name: /username/i }), + email: () => screen.findByRole('textbox', { name: /e-mail/i }), + password: () => screen.findByLabelText(/password/i), + } + + test('this is a valid case', async () => { + render() + userEvent.type(await selectors.username(), testData.name) // <-- unhandled here + userEvent.type(await selectors.email(), testData.email) + userEvent.type(await selectors.password(), testData.password) + // ... + }) + `, }, ], }); From 87c536e4dc36f23132de115cb3e6f11f876d3cda Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sun, 7 Sep 2025 14:23:07 +0900 Subject: [PATCH 3/5] docs: update docs --- README.md | 2 +- docs/rules/await-async-queries.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c6649ae..b74697af 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ module.exports = [ | Name                            | Description | 💼 | ⚠️ | 🔧 | | :------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ | :-- | | [await-async-events](docs/rules/await-async-events.md) | Enforce promises from async event methods are handled | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | 🔧 | -| [await-async-queries](docs/rules/await-async-queries.md) | Enforce promises from async queries to be handled | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | | +| [await-async-queries](docs/rules/await-async-queries.md) | Enforce promises from async queries to be handled | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | 🔧 | | [await-async-utils](docs/rules/await-async-utils.md) | Enforce promises from async utils to be awaited properly | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | | | [consistent-data-testid](docs/rules/consistent-data-testid.md) | Ensures consistent usage of `data-testid` | | | | | [no-await-sync-events](docs/rules/no-await-sync-events.md) | Disallow unnecessary `await` for sync events | ![badge-angular][] ![badge-dom][] ![badge-react][] | | | diff --git a/docs/rules/await-async-queries.md b/docs/rules/await-async-queries.md index d0d41a3e..08be6c8b 100644 --- a/docs/rules/await-async-queries.md +++ b/docs/rules/await-async-queries.md @@ -2,6 +2,8 @@ 💼 This rule is enabled in the following configs: `angular`, `dom`, `marko`, `react`, `svelte`, `vue`. +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + Ensure that promises returned by async queries are handled properly. From eff99bd7360a635290ef2f3fb64e2f5c4e54aec1 Mon Sep 17 00:00:00 2001 From: Hasegawa-Yukihiro Date: Sun, 7 Sep 2025 14:39:24 +0900 Subject: [PATCH 4/5] test: add tests for the return value cases of render --- tests/lib/rules/await-async-queries.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/lib/rules/await-async-queries.test.ts b/tests/lib/rules/await-async-queries.test.ts index 3ba6c3c8..5ab58d75 100644 --- a/tests/lib/rules/await-async-queries.test.ts +++ b/tests/lib/rules/await-async-queries.test.ts @@ -491,6 +491,25 @@ ruleTester.run(RULE_NAME, rule, { code: ` import { render } from '@testing-library/react' + test("An example test", async () => { + const view = render() + const foo = view.${query}('foo') + }); + `, + errors: [{ messageId: 'awaitAsyncQuery', line: 6, column: 26 }], + output: ` + import { render } from '@testing-library/react' + + test("An example test", async () => { + const view = render() + const foo = await view.${query}('foo') + }); + `, + })), + ...ALL_ASYNC_COMBINATIONS_TO_TEST.map((query) => ({ + code: ` + import { render } from '@testing-library/react' + test("An example test", async () => { const foo = ${query}('foo') expect(foo).toBeInTheDocument() From 834375b7dd0e63a222e013e9e10614822128f92a Mon Sep 17 00:00:00 2001 From: Yukihiro Hasegawa <49516827+y-hsgw@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:17:04 +0900 Subject: [PATCH 5/5] Update lib/rules/await-async-queries.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mario Beltrán Signed-off-by: Yukihiro Hasegawa <49516827+y-hsgw@users.noreply.github.com> --- lib/rules/await-async-queries.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/rules/await-async-queries.ts b/lib/rules/await-async-queries.ts index dfc68621..5963c451 100644 --- a/lib/rules/await-async-queries.ts +++ b/lib/rules/await-async-queries.ts @@ -164,20 +164,20 @@ export default createTestingLibraryRule({ ); } - if (functionExpression.async) { - return IdentifierNodeFixer; - } else { + const ruleFixes = [IdentifierNodeFixer]; + if (!functionExpression.async) { /** * Mutate the actual node so if other nodes exist in this * function expression body they don't also try to fix it. */ functionExpression.async = true; - return [ - IdentifierNodeFixer, - fixer.insertTextBefore(functionExpression, 'async '), - ]; + ruleFixes.push( + fixer.insertTextBefore(functionExpression, 'async ') + ); } + + return ruleFixes; }, }); }