diff --git a/README.md b/README.md index 3f1cf488..056fcf55 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ module.exports = [ | :------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ | :-- | | [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-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][] | | | +| [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][] | | | | [no-await-sync-queries](docs/rules/no-await-sync-queries.md) | Disallow unnecessary `await` for sync queries | ![badge-angular][] ![badge-dom][] ![badge-marko][] ![badge-react][] ![badge-svelte][] ![badge-vue][] | | 🔧 | diff --git a/docs/rules/await-async-utils.md b/docs/rules/await-async-utils.md index ac22b228..482ba3b8 100644 --- a/docs/rules/await-async-utils.md +++ b/docs/rules/await-async-utils.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 utils are handled properly. diff --git a/lib/rules/await-async-queries.ts b/lib/rules/await-async-queries.ts index 5963c451..f71b0447 100644 --- a/lib/rules/await-async-queries.ts +++ b/lib/rules/await-async-queries.ts @@ -11,6 +11,7 @@ import { isMemberExpression, isPromiseHandled, } from '../node-utils'; +import { addAsyncToFunctionFix } from '../utils/add-async-to-function-fix'; import type { TSESTree } from '@typescript-eslint/utils'; @@ -164,20 +165,11 @@ export default createTestingLibraryRule({ ); } - 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; - - ruleFixes.push( - fixer.insertTextBefore(functionExpression, 'async ') - ); - } - - return ruleFixes; + return addAsyncToFunctionFix( + fixer, + IdentifierNodeFixer, + functionExpression + ); }, }); } diff --git a/lib/rules/await-async-utils.ts b/lib/rules/await-async-utils.ts index ed6d21e0..d8324dd3 100644 --- a/lib/rules/await-async-utils.ts +++ b/lib/rules/await-async-utils.ts @@ -3,17 +3,20 @@ import { ASTUtils } from '@typescript-eslint/utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; import { findClosestCallExpressionNode, + findClosestFunctionExpressionNode, getDeepestIdentifierNode, getFunctionName, getInnermostReturningFunction, + getReferenceNode, getVariableReferences, isCallExpression, isObjectPattern, isPromiseHandled, isProperty, } from '../node-utils'; +import { addAsyncToFunctionFix } from '../utils/add-async-to-function-fix'; -import type { TSESTree } from '@typescript-eslint/utils'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; export const RULE_NAME = 'await-async-utils'; export type MessageIds = 'asyncUtilWrapper' | 'awaitAsyncUtil'; @@ -40,6 +43,7 @@ export default createTestingLibraryRule({ 'Promise returned from {{ name }} wrapper over async util must be handled', }, schema: [], + fixable: 'code', }, defaultOptions: [], @@ -91,6 +95,13 @@ export default createTestingLibraryRule({ } } + function insertAwaitBeforeNode( + fixer: TSESLint.RuleFixer, + node: TSESTree.Node + ) { + return fixer.insertTextBefore(node, 'await '); + } + /* Either we report a direct usage of an async util or a usage of a wrapper around an async util @@ -155,6 +166,7 @@ export default createTestingLibraryRule({ context, closestCallExpression.parent ); + const functionExpression = findClosestFunctionExpressionNode(node); if (references.length === 0) { if (!isPromiseHandled(callExpressionIdentifier)) { @@ -164,6 +176,17 @@ export default createTestingLibraryRule({ data: { name: callExpressionIdentifier.name, }, + fix: (fixer) => { + const referenceNode = getReferenceNode( + callExpressionIdentifier + ); + const awaitFix = insertAwaitBeforeNode(fixer, referenceNode); + return addAsyncToFunctionFix( + fixer, + awaitFix, + functionExpression + ); + }, }); } } else { @@ -176,6 +199,14 @@ export default createTestingLibraryRule({ data: { name: callExpressionIdentifier.name, }, + fix: (fixer) => { + const awaitFix = insertAwaitBeforeNode(fixer, referenceNode); + return addAsyncToFunctionFix( + fixer, + awaitFix, + functionExpression + ); + }, }); return; } diff --git a/lib/utils/add-async-to-function-fix.ts b/lib/utils/add-async-to-function-fix.ts new file mode 100644 index 00000000..abfb4dcf --- /dev/null +++ b/lib/utils/add-async-to-function-fix.ts @@ -0,0 +1,22 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + +export const addAsyncToFunctionFix = ( + fixer: TSESLint.RuleFixer, + ruleFix: TSESLint.RuleFix, + functionExpression: + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | null +) => { + if (functionExpression && !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 [ruleFix, fixer.insertTextBefore(functionExpression, 'async ')]; + } + return ruleFix; +}; diff --git a/tests/lib/rules/await-async-utils.test.ts b/tests/lib/rules/await-async-utils.test.ts index fc14eda6..3a791b94 100644 --- a/tests/lib/rules/await-async-utils.test.ts +++ b/tests/lib/rules/await-async-utils.test.ts @@ -416,6 +416,13 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('${asyncUtil} util not waited is invalid', async () => { + doSomethingElse(); + await ${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -433,6 +440,13 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('${asyncUtil} util not waited is invalid', async () => { + doSomethingElse(); + const el = await ${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -450,6 +464,13 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import * as asyncUtil from '${testingFramework}'; + test('asyncUtil.${asyncUtil} util not handled is invalid', async () => { + doSomethingElse(); + await asyncUtil.${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -467,6 +488,13 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('${asyncUtil} util promise saved not handled is invalid', async () => { + doSomethingElse(); + const aPromise = await ${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -491,6 +519,14 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('several ${asyncUtil} utils not handled are invalid', async () => { + const aPromise = ${asyncUtil}(() => getByLabelText('username')); + doSomethingElse(await aPromise); + await ${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -515,6 +551,14 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from '${testingFramework}'; + test('unhandled expression that evaluates to promise is invalid', async () => { + const aPromise = ${asyncUtil}(() => getByLabelText('username')); + doSomethingElse(await aPromise); + await ${asyncUtil}(() => getByLabelText('email')); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -537,6 +581,18 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForSomethingAsync' }, }, ], + output: ` + import { ${asyncUtil}, render } from '${testingFramework}'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('unhandled promise from function wrapping ${asyncUtil} util is invalid', async () => { + render() + await waitForSomethingAsync() + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -560,6 +616,19 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForSomethingAsync' }, }, ], + output: ` + import { ${asyncUtil}, render } from '${testingFramework}'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('unhandled promise in variable declaration from function wrapping ${asyncUtil} util is invalid', async () => { + render() + const result = waitForSomethingAsync() + expect(await result).toBe('foo') + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -579,6 +648,15 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import { ${asyncUtil} } from 'some-other-library'; // rather than ${testingFramework} + test( + 'aggressive reporting - util "${asyncUtil}" which is not related to testing library is invalid', + async () => { + doSomethingElse(); + await ${asyncUtil}(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -601,6 +679,18 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForSomethingAsync' }, }, ], + output: ` + import { ${asyncUtil}, render } from '${testingFramework}'; + + function waitForSomethingAsync() { + return ${asyncUtil}(() => somethingAsync()) + } + + test('unhandled promise from function wrapping ${asyncUtil} util is invalid', async () => { + render() + const el = await waitForSomethingAsync() + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ @@ -621,6 +711,15 @@ ruleTester.run(RULE_NAME, rule, { data: { name: asyncUtil }, }, ], + output: ` + import * as asyncUtils from 'some-other-library'; // rather than ${testingFramework} + test( + 'aggressive reporting - util "asyncUtils.${asyncUtil}" which is not related to testing library is invalid', + async () => { + doSomethingElse(); + await asyncUtils.${asyncUtil}(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -648,6 +747,23 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForAsyncUtil' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + const { user, waitForAsyncUtil } = setup(); + await waitForAsyncUtil(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -676,6 +792,24 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'myAlias' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + const { user, waitForAsyncUtil } = setup(); + const myAlias = waitForAsyncUtil; + await myAlias(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -703,6 +837,23 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForAsyncUtil' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + const { ...clone } = setup(); + await clone.waitForAsyncUtil(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -730,6 +881,23 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'myAlias' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + const { waitForAsyncUtil: myAlias } = setup(); + await myAlias(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -756,6 +924,22 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'waitForAsyncUtil' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + await setup().waitForAsyncUtil(); + }); + `, })), ...ASYNC_UTILS.map((asyncUtil) => ({ code: ` @@ -783,6 +967,23 @@ ruleTester.run(RULE_NAME, rule, { data: { name: 'myAlias' }, }, ], + output: ` + // implicitly using ${testingFramework} + 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', async () => { + const myAlias = setup().waitForAsyncUtil; + await myAlias(); + }); + `, })), ]), }); diff --git a/tests/lib/utils/add-async-to-function-fix.test.ts b/tests/lib/utils/add-async-to-function-fix.test.ts new file mode 100644 index 00000000..c13dc155 --- /dev/null +++ b/tests/lib/utils/add-async-to-function-fix.test.ts @@ -0,0 +1,119 @@ +import { createTestingLibraryRule } from '../../../lib/create-testing-library-rule'; +import { addAsyncToFunctionFix } from '../../../lib/utils/add-async-to-function-fix'; +import { createRuleTester } from '../test-utils'; + +import type { TSESTree } from '@typescript-eslint/utils'; + +type MessageIds = 'alwaysAsync'; + +const rule = createTestingLibraryRule<[], MessageIds>({ + name: __filename, + meta: { + docs: { + recommendedConfig: { + dom: 'error', + angular: 'error', + react: 'error', + vue: 'error', + svelte: 'error', + marko: 'error', + }, + description: 'Fake rule for testing addAsyncToFunctionFix', + }, + messages: { alwaysAsync: 'Function should be async' }, + schema: [], + fixable: 'code', + type: 'problem', + }, + defaultOptions: [], + create(context) { + const reportIfNotAsync = ( + node: + | TSESTree.FunctionExpression + | TSESTree.FunctionDeclaration + | TSESTree.ArrowFunctionExpression + ) => { + if (!node.async) { + context.report({ + node, + messageId: 'alwaysAsync', + fix(fixer) { + return addAsyncToFunctionFix( + fixer, + fixer.insertTextBefore(node, ''), + node + ); + }, + }); + } + }; + return { + FunctionExpression: reportIfNotAsync, + ArrowFunctionExpression: reportIfNotAsync, + FunctionDeclaration: reportIfNotAsync, + }; + }, +}); + +const ruleTester = createRuleTester(); + +ruleTester.run(addAsyncToFunctionFix.name, rule, { + valid: [ + { code: 'async function foo() {}' }, + { code: 'const bar = async function() {}' }, + { + code: ` + async function foo(a, b) { + return a + b; + }`, + }, + ], + invalid: [ + { + code: 'const bar = function() {}', + output: 'const bar = async function() {}', + errors: [{ messageId: 'alwaysAsync' }], + }, + { + code: 'const bar = () => {}', + output: 'const bar = async () => {}', + errors: [{ messageId: 'alwaysAsync' }], + }, + { + code: ` + function foo(a, b) { + return a + b; + }`, + output: ` + async function foo(a, b) { + return a + b; + }`, + errors: [{ messageId: 'alwaysAsync', line: 2 }], + }, + { + code: ` + const bar = async function() {} + const foo = function() {} + `, + output: ` + const bar = async function() {} + const foo = async function() {} + `, + errors: [{ messageId: 'alwaysAsync', line: 3 }], + }, + { + code: ` + const bar = function() {} + const foo = function() {} + `, + output: ` + const bar = async function() {} + const foo = async function() {} + `, + errors: [ + { messageId: 'alwaysAsync', line: 2 }, + { messageId: 'alwaysAsync', line: 3 }, + ], + }, + ], +});