Skip to content

fix(await-async-utils): false positive when destructuring #722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
136 changes: 97 additions & 39 deletions lib/rules/await-async-utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,59 +50,114 @@ export default createTestingLibraryRule<Options, MessageIds>({
}
}

/*
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 },
});
}
}
},
Expand Down
197 changes: 197 additions & 0 deletions tests/lib/rules/await-async-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,40 @@ ruleTester.run(RULE_NAME, rule, {
test('edge case for no innermost function scope', () => {
const foo = waitFor
})
`,
},
{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('destructuring an async function wrapper & handling it later is valid', () => {
const { user, waitForLoadComplete } = setup();
await waitForLoadComplete();

const myAlias = waitForLoadComplete;
const myOtherAlias = myAlias;
await myAlias();
await myOtherAlias();

const { ...clone } = setup();
await clone.waitForLoadComplete();

const { waitForLoadComplete: myDestructuredAlias } = setup();
await myDestructuredAlias();

const { user, ...rest } = setup();
await rest.waitForLoadComplete();

await setup().waitForLoadComplete();
});
`,
},
]),
Expand Down Expand Up @@ -441,6 +475,7 @@ ruleTester.run(RULE_NAME, rule, {
],
} as const)
),

...ASYNC_UTILS.map(
(asyncUtil) =>
({
Expand All @@ -463,5 +498,167 @@ ruleTester.run(RULE_NAME, rule, {
],
} as const)
),

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from destructed property of async function wrapper is invalid', () => {
const { user, waitForLoadComplete } = setup();
waitForLoadComplete();
});
`,
errors: [
{
line: 14,
column: 11,
messageId: 'asyncUtilWrapper',
data: { name: 'waitForLoadComplete' },
},
],
},

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from assigning async function wrapper is invalid', () => {
const { user, waitForLoadComplete } = setup();
const myAlias = waitForLoadComplete;
myAlias();
});
`,
errors: [
{
line: 15,
column: 11,
messageId: 'asyncUtilWrapper',
data: { name: 'myAlias' },
},
],
},

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from rest element with async wrapper function member is invalid', () => {
const { ...clone } = setup();
clone.waitForLoadComplete();
});
`,
errors: [
{
line: 14,
column: 17,
messageId: 'asyncUtilWrapper',
data: { name: 'waitForLoadComplete' },
},
],
},

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from destructured property alias is invalid', () => {
const { waitForLoadComplete: myAlias } = setup();
myAlias();
});
`,
errors: [
{
line: 14,
column: 11,
messageId: 'asyncUtilWrapper',
data: { name: 'myAlias' },
},
],
},

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from object member with async wrapper value is invalid', () => {
setup().waitForLoadComplete();
});
`,
errors: [
{
line: 13,
column: 19,
messageId: 'asyncUtilWrapper',
data: { name: 'waitForLoadComplete' },
},
],
},

{
code: `
function setup() {
const utils = render(<MyComponent />);

const waitForLoadComplete = () => {
return waitForElementToBeRemoved(screen.queryByTestId('my-test-id'));
};

return { waitForLoadComplete, ...utils };
}

test('unhandled promise from object member with async wrapper value is invalid', () => {
const myAlias = setup().waitForLoadComplete;
myAlias();
});
`,
errors: [
{
line: 14,
column: 11,
messageId: 'asyncUtilWrapper',
data: { name: 'myAlias' },
},
],
},
]),
});