diff --git a/README.md b/README.md index f438182c..1bd131a8 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ To enable this configuration use the `extends` property in your | [testing-library/no-node-access](docs/rules/no-node-access.md) | Disallow direct Node access | ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [testing-library/no-promise-in-fire-event](docs/rules/no-promise-in-fire-event.md) | Disallow the use of promises passed to a `fireEvent` method | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [testing-library/no-render-in-setup](docs/rules/no-render-in-setup.md) | Disallow the use of `render` in setup functions | | | +| [testing-library/no-unnecessary-act](docs/rules/no-unnecessary-act.md) | Disallow wrapping Testing Library utils or empty callbacks in `act` | | | | [testing-library/no-wait-for-empty-callback](docs/rules/no-wait-for-empty-callback.md) | Disallow empty callbacks for `waitFor` and `waitForElementToBeRemoved` | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | | [testing-library/no-wait-for-multiple-assertions](docs/rules/no-wait-for-multiple-assertions.md) | Disallow the use of multiple expect inside `waitFor` | | | | [testing-library/no-wait-for-side-effects](docs/rules/no-wait-for-side-effects.md) | Disallow the use of side effects inside `waitFor` | | | @@ -200,8 +201,8 @@ To enable this configuration use the `extends` property in your | [testing-library/prefer-explicit-assert](docs/rules/prefer-explicit-assert.md) | Suggest using explicit assertions rather than just `getBy*` queries | | | | [testing-library/prefer-find-by](docs/rules/prefer-find-by.md) | Suggest using `findBy*` methods instead of the `waitFor` + `getBy` queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | ![fixable-badge][] | | [testing-library/prefer-presence-queries](docs/rules/prefer-presence-queries.md) | Enforce specific queries when checking element is present or not | | | -| [testing-library/prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | | | | [testing-library/prefer-screen-queries](docs/rules/prefer-screen-queries.md) | Suggest using screen while using queries | ![dom-badge][] ![angular-badge][] ![react-badge][] ![vue-badge][] | | +| [testing-library/prefer-user-event](docs/rules/prefer-user-event.md) | Suggest using `userEvent` library instead of `fireEvent` for simulating user interaction | | | | [testing-library/prefer-wait-for](docs/rules/prefer-wait-for.md) | Use `waitFor` instead of deprecated wait methods | | ![fixable-badge][] | | [testing-library/render-result-naming-convention](docs/rules/render-result-naming-convention.md) | Enforce a valid naming for return value from `render` | ![angular-badge][] ![react-badge][] ![vue-badge][] | | diff --git a/docs/rules/no-unnecessary-act.md b/docs/rules/no-unnecessary-act.md new file mode 100644 index 00000000..065b6c10 --- /dev/null +++ b/docs/rules/no-unnecessary-act.md @@ -0,0 +1,94 @@ +# Disallow wrapping Testing Library utils or empty callbacks in `act` (`testing-library/no-unnecessary-act`) + +> ⚠️ The `act` method is only available on the following Testing Library packages: +> +> - `@testing-library/react` (supported by this plugin) +> - `@testing-library/preact` (not supported yet by this plugin) +> - `@testing-library/svelte` (not supported yet by this plugin) + +## Rule Details + +This rule aims to avoid the usage of `act` to wrap Testing Library utils just to silence "not wrapped in act(...)" warnings. + +All Testing Library utils are already wrapped in `act`. Most of the time, if you're seeing an `act` warning, it's not just something to be silenced, but it's actually telling you that something unexpected is happening in your test. + +Additionally, wrapping empty callbacks in `act` is also an incorrect way of silencing "not wrapped in act(...)" warnings. + +Code violations reported by this rule will pinpoint those unnecessary `act`, helping to understand when `act` actually is necessary. + +Example of **incorrect** code for this rule: + +```js +// ❌ wrapping things related to Testing Library in `act` is incorrect +import { + act, + render, + screen, + waitFor, + fireEvent, +} from '@testing-library/react'; +// ^ act imported from 'react-dom/test-utils' will be reported too +import userEvent from '@testing-library/user-event'; + +// ... + +act(() => { + render(); +}); + +await act(async () => waitFor(() => {})); + +act(() => screen.getByRole('button')); + +act(() => { + fireEvent.click(element); +}); + +act(() => { + userEvent.click(element); +}); +``` + +```js +// ❌ wrapping empty callbacks in `act` is incorrect +import { act } from '@testing-library/react'; +// ^ act imported from 'react-dom/test-utils' will be reported too +import userEvent from '@testing-library/user-event'; + +// ... + +act(() => {}); + +await act(async () => {}); +``` + +Examples of **correct** code for this rule: + +```js +// ✅ wrapping things not related to Testing Library in `act` is correct +import { act } from '@testing-library/react'; +import { stuffThatDoesNotUseRTL } from 'somwhere-else'; + +// ... + +act(() => { + stuffThatDoesNotUseRTL(); +}); +``` + +```js +// ✅ wrapping both things related and not related to Testing Library in `act` is correct +import { act, screen } from '@testing-library/react'; +import { stuffThatDoesNotUseRTL } from 'somwhere-else'; + +await act(async () => { + await screen.findByRole('button'); + stuffThatDoesNotUseRTL(); +}); +``` + +## Further Reading + +- [Inspiration for this rule](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#wrapping-things-in-act-unnecessarily) +- [Fix the "not wrapped in act(...)" warning](https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning) +- [About React Testing Library `act`](https://testing-library.com/docs/react-testing-library/api/#act) diff --git a/lib/create-testing-library-rule/detect-testing-library-utils.ts b/lib/create-testing-library-rule/detect-testing-library-utils.ts index 3d6662be..6a85322f 100644 --- a/lib/create-testing-library-rule/detect-testing-library-utils.ts +++ b/lib/create-testing-library-rule/detect-testing-library-utils.ts @@ -5,6 +5,8 @@ import { } from '@typescript-eslint/experimental-utils'; import { + findClosestVariableDeclaratorNode, + findImportSpecifier, getAssertNodeInfo, getDeepestIdentifierNode, getImportModuleName, @@ -12,14 +14,12 @@ import { getReferenceNode, hasImportMatch, ImportModuleNode, + isCallExpression, isImportDeclaration, isImportDefaultSpecifier, - isImportNamespaceSpecifier, isImportSpecifier, isLiteral, isMemberExpression, - isObjectPattern, - isProperty, } from '../node-utils'; import { ABSENCE_MATCHERS, @@ -83,7 +83,7 @@ type IsDebugUtilFn = (identifierNode: TSESTree.Identifier) => boolean; type IsPresenceAssertFn = (node: TSESTree.MemberExpression) => boolean; type IsAbsenceAssertFn = (node: TSESTree.MemberExpression) => boolean; type CanReportErrorsFn = () => boolean; -type FindImportedUtilSpecifierFn = ( +type FindImportedTestingLibraryUtilSpecifierFn = ( specifierName: string ) => TSESTree.ImportClause | TSESTree.Identifier | undefined; type IsNodeComingFromTestingLibraryFn = ( @@ -96,6 +96,7 @@ export interface DetectionHelpers { getTestingLibraryImportName: GetTestingLibraryImportNameFn; getCustomModuleImportName: GetCustomModuleImportNameFn; isTestingLibraryImported: IsTestingLibraryImportedFn; + isTestingLibraryUtil: (node: TSESTree.Identifier) => boolean; isGetQueryVariant: IsGetQueryVariantFn; isQueryQueryVariant: IsQueryQueryVariantFn; isFindQueryVariant: IsFindQueryVariantFn; @@ -112,14 +113,16 @@ export interface DetectionHelpers { isRenderUtil: IsRenderUtilFn; isRenderVariableDeclarator: IsRenderVariableDeclaratorFn; isDebugUtil: IsDebugUtilFn; + isActUtil: (node: TSESTree.Identifier) => boolean; isPresenceAssert: IsPresenceAssertFn; isAbsenceAssert: IsAbsenceAssertFn; canReportErrors: CanReportErrorsFn; - findImportedUtilSpecifier: FindImportedUtilSpecifierFn; + findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn; isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn; } const USER_EVENT_PACKAGE = '@testing-library/user-event'; +const REACT_DOM_TEST_UTILS_PACKAGE = 'react-dom/test-utils'; const FIRE_EVENT_NAME = 'fireEvent'; const USER_EVENT_NAME = 'userEvent'; const RENDER_NAME = 'render'; @@ -153,6 +156,7 @@ export function detectTestingLibraryUtils< let importedTestingLibraryNode: ImportModuleNode | null = null; let importedCustomModuleNode: ImportModuleNode | null = null; let importedUserEventLibraryNode: ImportModuleNode | null = null; + let importedReactDomTestUtilsNode: ImportModuleNode | null = null; // Init options based on shared ESLint settings const customModuleSetting = @@ -172,9 +176,9 @@ export function detectTestingLibraryUtils< * - it's imported from valid Testing Library module (depends on aggressive * reporting) */ - function isTestingLibraryUtil( + function isPotentialTestingLibraryFunction( node: TSESTree.Identifier, - isUtilCallback: ( + isPotentialFunctionCallback: ( identifierNodeName: string, originalNodeName?: string ) => boolean @@ -190,7 +194,7 @@ export function detectTestingLibraryUtils< return false; } - const importedUtilSpecifier = getImportedUtilSpecifier( + const importedUtilSpecifier = getTestingLibraryImportedUtilSpecifier( referenceNodeIdentifier ); @@ -200,7 +204,7 @@ export function detectTestingLibraryUtils< ? importedUtilSpecifier.imported.name : undefined; - if (!isUtilCallback(node.name, originalNodeName)) { + if (!isPotentialFunctionCallback(node.name, originalNodeName)) { return false; } @@ -412,7 +416,7 @@ export function detectTestingLibraryUtils< * coming from Testing Library will be considered as valid. */ const isAsyncUtil: IsAsyncUtilFn = (node, validNames = ASYNC_UTILS) => { - return isTestingLibraryUtil( + return isPotentialTestingLibraryFunction( node, (identifierNodeName, originalNodeName) => { return ( @@ -430,7 +434,7 @@ export function detectTestingLibraryUtils< * Not to be confused with {@link isFireEventMethod} */ const isFireEventUtil = (node: TSESTree.Identifier): boolean => { - return isTestingLibraryUtil( + return isPotentialTestingLibraryFunction( node, (identifierNodeName, originalNodeName) => { return [identifierNodeName, originalNodeName].includes('fireEvent'); @@ -464,7 +468,9 @@ export function detectTestingLibraryUtils< * Determines whether a given node is fireEvent method or not */ const isFireEventMethod: IsFireEventMethodFn = (node) => { - const fireEventUtil = findImportedUtilSpecifier(FIRE_EVENT_NAME); + const fireEventUtil = findImportedTestingLibraryUtilSpecifier( + FIRE_EVENT_NAME + ); let fireEventUtilName: string | undefined; if (fireEventUtil) { @@ -570,17 +576,21 @@ export function detectTestingLibraryUtils< * only those nodes coming from Testing Library will be considered as valid. */ const isRenderUtil: IsRenderUtilFn = (node) => - isTestingLibraryUtil(node, (identifierNodeName, originalNodeName) => { - if (isAggressiveRenderReportingEnabled()) { - return identifierNodeName.toLowerCase().includes(RENDER_NAME); - } + isPotentialTestingLibraryFunction( + node, + (identifierNodeName, originalNodeName) => { + if (isAggressiveRenderReportingEnabled()) { + return identifierNodeName.toLowerCase().includes(RENDER_NAME); + } - return [RENDER_NAME, ...getCustomRenders()].some( - (validRenderName) => - validRenderName === identifierNodeName || - (Boolean(originalNodeName) && validRenderName === originalNodeName) - ); - }); + return [RENDER_NAME, ...getCustomRenders()].some( + (validRenderName) => + validRenderName === identifierNodeName || + (Boolean(originalNodeName) && + validRenderName === originalNodeName) + ); + } + ); const isRenderVariableDeclarator: IsRenderVariableDeclaratorFn = (node) => { if (!node.init) { @@ -603,7 +613,7 @@ export function detectTestingLibraryUtils< return ( !isBuiltInConsole && - isTestingLibraryUtil( + isPotentialTestingLibraryFunction( identifierNode, (identifierNodeName, originalNodeName) => { return [identifierNodeName, originalNodeName] @@ -614,6 +624,91 @@ export function detectTestingLibraryUtils< ); }; + /** + * Determines whether a given node is some reportable `act` util. + * + * An `act` is reportable if some of these conditions is met: + * - it's related to Testing Library module (this depends on Aggressive Reporting) + * - it's related to React DOM Test Utils + */ + const isActUtil = (node: TSESTree.Identifier): boolean => { + const isTestingLibraryAct = isPotentialTestingLibraryFunction( + node, + (identifierNodeName, originalNodeName) => { + return [identifierNodeName, originalNodeName] + .filter(Boolean) + .includes('act'); + } + ); + + const isReactDomTestUtilsAct = (() => { + if (!importedReactDomTestUtilsNode) { + return false; + } + const referenceNode = getReferenceNode(node); + const referenceNodeIdentifier = getPropertyIdentifierNode( + referenceNode + ); + if (!referenceNodeIdentifier) { + return false; + } + + const importedUtilSpecifier = findImportSpecifier( + node.name, + importedReactDomTestUtilsNode + ); + if (!importedUtilSpecifier) { + return false; + } + + const importDeclaration = (() => { + if (isImportDeclaration(importedUtilSpecifier.parent)) { + return importedUtilSpecifier.parent; + } + + const variableDeclarator = findClosestVariableDeclaratorNode( + importedUtilSpecifier + ); + + if (isCallExpression(variableDeclarator?.init)) { + return variableDeclarator?.init; + } + + return undefined; + })(); + if (!importDeclaration) { + return false; + } + + const importDeclarationName = getImportModuleName(importDeclaration); + if (!importDeclarationName) { + return false; + } + + if (importDeclarationName !== REACT_DOM_TEST_UTILS_PACKAGE) { + return false; + } + + return hasImportMatch( + importedUtilSpecifier, + referenceNodeIdentifier.name + ); + })(); + + return isTestingLibraryAct || isReactDomTestUtilsAct; + }; + + const isTestingLibraryUtil = (node: TSESTree.Identifier): boolean => { + return ( + isAsyncUtil(node) || + isQuery(node) || + isRenderUtil(node) || + isFireEventMethod(node) || + isUserEventMethod(node) || + isActUtil(node) + ); + }; + /** * Determines whether a given MemberExpression node is a presence assert * @@ -653,60 +748,18 @@ export function detectTestingLibraryUtils< }; /** - * Gets a string and verifies if it was imported/required by Testing Library - * related module. + * Finds the import util specifier related to Testing Library for a given name. */ - const findImportedUtilSpecifier: FindImportedUtilSpecifierFn = ( + const findImportedTestingLibraryUtilSpecifier: FindImportedTestingLibraryUtilSpecifierFn = ( specifierName - ) => { + ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { const node = getCustomModuleImportNode() ?? getTestingLibraryImportNode(); if (!node) { return undefined; } - if (isImportDeclaration(node)) { - const namedExport = node.specifiers.find((n) => { - return ( - isImportSpecifier(n) && - [n.imported.name, n.local.name].includes(specifierName) - ); - }); - - // it is "import { foo [as alias] } from 'baz'"" - if (namedExport) { - return namedExport; - } - - // it could be "import * as rtl from 'baz'" - return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); - } else { - if (!ASTUtils.isVariableDeclarator(node.parent)) { - return undefined; - } - const requireNode = node.parent; - - if (ASTUtils.isIdentifier(requireNode.id)) { - // this is const rtl = require('foo') - return requireNode.id; - } - - // this should be const { something } = require('foo') - if (!isObjectPattern(requireNode.id)) { - return undefined; - } - - const property = requireNode.id.properties.find( - (n) => - isProperty(n) && - ASTUtils.isIdentifier(n.key) && - n.key.name === specifierName - ); - if (!property) { - return undefined; - } - return (property as TSESTree.Property).key as TSESTree.Identifier; - } + return findImportSpecifier(specifierName, node); }; const findImportedUserEventSpecifier: () => TSESTree.Identifier | null = () => { @@ -740,7 +793,7 @@ export function detectTestingLibraryUtils< return null; }; - const getImportedUtilSpecifier = ( + const getTestingLibraryImportedUtilSpecifier = ( node: TSESTree.MemberExpression | TSESTree.Identifier ): TSESTree.ImportClause | TSESTree.Identifier | undefined => { const identifierName: string | undefined = getPropertyIdentifierNode(node) @@ -750,7 +803,7 @@ export function detectTestingLibraryUtils< return undefined; } - return findImportedUtilSpecifier(identifierName); + return findImportedTestingLibraryUtilSpecifier(identifierName); }; /** @@ -769,12 +822,43 @@ export function detectTestingLibraryUtils< const isNodeComingFromTestingLibrary: IsNodeComingFromTestingLibraryFn = ( node ) => { - const importNode = getImportedUtilSpecifier(node); + const importNode = getTestingLibraryImportedUtilSpecifier(node); if (!importNode) { return false; } + const referenceNode = getReferenceNode(node); + const referenceNodeIdentifier = getPropertyIdentifierNode(referenceNode); + if (!referenceNodeIdentifier) { + return false; + } + + const importDeclaration = (() => { + if (isImportDeclaration(importNode.parent)) { + return importNode.parent; + } + + const variableDeclarator = findClosestVariableDeclaratorNode( + importNode + ); + + if (isCallExpression(variableDeclarator?.init)) { + return variableDeclarator?.init; + } + + return undefined; + })(); + + if (!importDeclaration) { + return false; + } + + const importDeclarationName = getImportModuleName(importDeclaration); + if (!importDeclarationName) { + return false; + } + const identifierName: string | undefined = getPropertyIdentifierNode(node) ?.name; @@ -782,7 +866,13 @@ export function detectTestingLibraryUtils< return false; } - return hasImportMatch(importNode, identifierName); + const hasImportElementMatch = hasImportMatch(importNode, identifierName); + const hasImportModuleMatch = + /testing-library/g.test(importDeclarationName) || + (typeof customModuleSetting === 'string' && + importDeclarationName.endsWith(customModuleSetting)); + + return hasImportElementMatch && hasImportModuleMatch; }; const helpers: DetectionHelpers = { @@ -791,6 +881,7 @@ export function detectTestingLibraryUtils< getTestingLibraryImportName, getCustomModuleImportName, isTestingLibraryImported, + isTestingLibraryUtil, isGetQueryVariant, isQueryQueryVariant, isFindQueryVariant, @@ -807,10 +898,11 @@ export function detectTestingLibraryUtils< isRenderUtil, isRenderVariableDeclarator, isDebugUtil, + isActUtil, isPresenceAssert, isAbsenceAssert, canReportErrors, - findImportedUtilSpecifier, + findImportedTestingLibraryUtilSpecifier, isNodeComingFromTestingLibrary, }; @@ -824,11 +916,14 @@ export function detectTestingLibraryUtils< * parts of the file. */ ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (typeof node.source.value !== 'string') { + return; + } // check only if testing library import not found yet so we avoid // to override importedTestingLibraryNode after it's found if ( !importedTestingLibraryNode && - /testing-library/g.test(node.source.value as string) + /testing-library/g.test(node.source.value) ) { importedTestingLibraryNode = node; } @@ -839,7 +934,7 @@ export function detectTestingLibraryUtils< if ( customModule && !importedCustomModuleNode && - String(node.source.value).endsWith(customModule) + node.source.value.endsWith(customModule) ) { importedCustomModuleNode = node; } @@ -848,10 +943,19 @@ export function detectTestingLibraryUtils< // to override importedUserEventLibraryNode after it's found if ( !importedUserEventLibraryNode && - String(node.source.value) === USER_EVENT_PACKAGE + node.source.value === USER_EVENT_PACKAGE ) { importedUserEventLibraryNode = node; } + + // check only if react-dom/test-utils import not found yet so we avoid + // to override importedReactDomTestUtilsNode after it's found + if ( + !importedUserEventLibraryNode && + node.source.value === REACT_DOM_TEST_UTILS_PACKAGE + ) { + importedReactDomTestUtilsNode = node; + } }, // Check if Testing Library related modules are loaded with required. @@ -898,6 +1002,18 @@ export function detectTestingLibraryUtils< ) { importedUserEventLibraryNode = callExpression; } + + if ( + !importedReactDomTestUtilsNode && + args.some( + (arg) => + isLiteral(arg) && + typeof arg.value === 'string' && + arg.value === REACT_DOM_TEST_UTILS_PACKAGE + ) + ) { + importedReactDomTestUtilsNode = callExpression; + } }, }; diff --git a/lib/node-utils/index.ts b/lib/node-utils/index.ts index caa94108..62e7dc3d 100644 --- a/lib/node-utils/index.ts +++ b/lib/node-utils/index.ts @@ -10,13 +10,19 @@ import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslin import { isArrayExpression, isArrowFunctionExpression, + isAssignmentExpression, isBlockStatement, isCallExpression, isExpressionStatement, isImportDeclaration, + isImportNamespaceSpecifier, + isImportSpecifier, isLiteral, isMemberExpression, + isObjectPattern, + isProperty, isReturnStatement, + isVariableDeclaration, } from './is-node-of-type'; export * from './is-node-of-type'; @@ -76,6 +82,23 @@ export function findClosestCallExpressionNode( return findClosestCallExpressionNode(node.parent, shouldRestrictInnerScope); } +export function findClosestVariableDeclaratorNode( + node: TSESTree.Node | undefined +): TSESTree.VariableDeclarator | null { + if (!node) { + return null; + } + + if (ASTUtils.isVariableDeclarator(node)) { + return node; + } + + return findClosestVariableDeclaratorNode(node.parent); +} + +/** + * TODO: remove this one in favor of {@link findClosestCallExpressionNode} + */ export function findClosestCallNode( node: TSESTree.Node, name: string @@ -510,3 +533,115 @@ export function hasImportMatch( return importNode.local.name === identifierName; } + +export function getStatementCallExpression( + statement: TSESTree.Statement +): TSESTree.CallExpression | undefined { + if (isExpressionStatement(statement)) { + const { expression } = statement; + if (isCallExpression(expression)) { + return expression; + } + + if ( + ASTUtils.isAwaitExpression(expression) && + isCallExpression(expression.argument) + ) { + return expression.argument; + } + + if (isAssignmentExpression(expression)) { + if (isCallExpression(expression.right)) { + return expression.right; + } + + if ( + ASTUtils.isAwaitExpression(expression.right) && + isCallExpression(expression.right.argument) + ) { + return expression.right.argument; + } + } + } + + if (isReturnStatement(statement) && isCallExpression(statement.argument)) { + return statement.argument; + } + + if (isVariableDeclaration(statement)) { + for (const declaration of statement.declarations) { + if (isCallExpression(declaration.init)) { + return declaration.init; + } + } + } + return undefined; +} + +/** + * Determines whether a given function node is considered as empty function or not. + * + * A function is considered empty if its body is empty. + * + * Note that comments don't affect the check. + * + * If node given is not a function, `false` will be returned. + */ +export function isEmptyFunction(node: TSESTree.Node): boolean | undefined { + if (ASTUtils.isFunction(node) && isBlockStatement(node.body)) { + return node.body.body.length === 0; + } + + return false; +} + +/** + * Finds the import specifier matching a given name for a given import module node. + */ +export function findImportSpecifier( + specifierName: string, + node: ImportModuleNode +): TSESTree.ImportClause | TSESTree.Identifier | undefined { + if (isImportDeclaration(node)) { + const namedExport = node.specifiers.find((n) => { + return ( + isImportSpecifier(n) && + [n.imported.name, n.local.name].includes(specifierName) + ); + }); + + // it is "import { foo [as alias] } from 'baz'" + if (namedExport) { + return namedExport; + } + + // it could be "import * as rtl from 'baz'" + return node.specifiers.find((n) => isImportNamespaceSpecifier(n)); + } else { + if (!ASTUtils.isVariableDeclarator(node.parent)) { + return undefined; + } + const requireNode = node.parent; + + if (ASTUtils.isIdentifier(requireNode.id)) { + // this is const rtl = require('foo') + return requireNode.id; + } + + // this should be const { something } = require('foo') + if (!isObjectPattern(requireNode.id)) { + return undefined; + } + + const property = requireNode.id.properties.find( + (n) => + isProperty(n) && + ASTUtils.isIdentifier(n.key) && + n.key.name === specifierName + ); + if (!property) { + return undefined; + } + return (property as TSESTree.Property).key as TSESTree.Identifier; + } +} diff --git a/lib/rules/no-unnecessary-act.ts b/lib/rules/no-unnecessary-act.ts new file mode 100644 index 00000000..152dd226 --- /dev/null +++ b/lib/rules/no-unnecessary-act.ts @@ -0,0 +1,141 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createTestingLibraryRule } from '../create-testing-library-rule'; +import { + getDeepestIdentifierNode, + getStatementCallExpression, + isEmptyFunction, +} from '../node-utils'; + +export const RULE_NAME = 'no-unnecessary-act'; +export type MessageIds = + | 'noUnnecessaryActTestingLibraryUtil' + | 'noUnnecessaryActEmptyFunction'; + +export default createTestingLibraryRule<[], MessageIds>({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: + 'Disallow wrapping Testing Library utils or empty callbacks in `act`', + category: 'Possible Errors', + recommendedConfig: { + dom: false, + angular: false, + react: false, + vue: false, + }, + }, + messages: { + noUnnecessaryActTestingLibraryUtil: + 'Avoid wrapping Testing Library util calls in `act`', + noUnnecessaryActEmptyFunction: 'Avoid wrapping empty function in `act`', + }, + schema: [], + }, + defaultOptions: [], + + create(context, _, helpers) { + /** + * Determines whether some call is non Testing Library related for a given list of statements. + */ + function hasSomeNonTestingLibraryCall( + statements: TSESTree.Statement[] + ): boolean { + return statements.some((statement) => { + const callExpression = getStatementCallExpression(statement); + + if (!callExpression) { + return false; + } + + const identifier = getDeepestIdentifierNode(callExpression); + + if (!identifier) { + return false; + } + + return !helpers.isTestingLibraryUtil(identifier); + }); + } + + function checkNoUnnecessaryActFromBlockStatement( + blockStatementNode: TSESTree.BlockStatement + ) { + const functionNode = blockStatementNode?.parent as + | TSESTree.FunctionExpression + | TSESTree.ArrowFunctionExpression + | undefined; + const callExpressionNode = functionNode?.parent as + | TSESTree.CallExpression + | undefined; + + if (!callExpressionNode || !functionNode) { + return; + } + + const identifierNode = getDeepestIdentifierNode(callExpressionNode); + if (!identifierNode) { + return; + } + + if (!helpers.isActUtil(identifierNode)) { + return; + } + + if (isEmptyFunction(functionNode)) { + context.report({ + node: identifierNode, + messageId: 'noUnnecessaryActEmptyFunction', + }); + } else if (!hasSomeNonTestingLibraryCall(blockStatementNode.body)) { + context.report({ + node: identifierNode, + messageId: 'noUnnecessaryActTestingLibraryUtil', + }); + } + } + + function checkNoUnnecessaryActFromImplicitReturn( + node: TSESTree.CallExpression + ) { + const nodeIdentifier = getDeepestIdentifierNode(node); + + if (!nodeIdentifier) { + return; + } + + const parentCallExpression = node?.parent?.parent as + | TSESTree.CallExpression + | undefined; + + if (!parentCallExpression) { + return; + } + + const identifierNode = getDeepestIdentifierNode(parentCallExpression); + if (!identifierNode) { + return; + } + + if (!helpers.isActUtil(identifierNode)) { + return; + } + + if (!helpers.isTestingLibraryUtil(nodeIdentifier)) { + return; + } + + context.report({ + node: identifierNode, + messageId: 'noUnnecessaryActTestingLibraryUtil', + }); + } + + return { + 'CallExpression > ArrowFunctionExpression > BlockStatement': checkNoUnnecessaryActFromBlockStatement, + 'CallExpression > FunctionExpression > BlockStatement': checkNoUnnecessaryActFromBlockStatement, + 'CallExpression > ArrowFunctionExpression > CallExpression': checkNoUnnecessaryActFromImplicitReturn, + }; + }, +}); diff --git a/lib/rules/no-wait-for-empty-callback.ts b/lib/rules/no-wait-for-empty-callback.ts index 97e2ac64..0ca71193 100644 --- a/lib/rules/no-wait-for-empty-callback.ts +++ b/lib/rules/no-wait-for-empty-callback.ts @@ -1,8 +1,8 @@ import { ASTUtils, TSESTree } from '@typescript-eslint/experimental-utils'; import { getPropertyIdentifierNode, - isBlockStatement, isCallExpression, + isEmptyFunction, } from '../node-utils'; import { createTestingLibraryRule } from '../create-testing-library-rule'; @@ -57,8 +57,7 @@ export default createTestingLibraryRule({ } if ( - isBlockStatement(node.body) && - node.body.body.length === 0 && + isEmptyFunction(node) && isCallExpression(node.parent) && ASTUtils.isIdentifier(node.parent.callee) ) { diff --git a/tests/index.test.ts b/tests/index.test.ts index 2f9808cb..2ba1adc7 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -6,7 +6,7 @@ import plugin from '../lib'; const generateConfigs = () => exec(`npm run generate:configs`); -const numberOfRules = 24; +const numberOfRules = 25; const ruleNames = Object.keys(plugin.rules); // eslint-disable-next-line jest/expect-expect diff --git a/tests/lib/rules/no-unnecessary-act.test.ts b/tests/lib/rules/no-unnecessary-act.test.ts new file mode 100644 index 00000000..b5bb7992 --- /dev/null +++ b/tests/lib/rules/no-unnecessary-act.test.ts @@ -0,0 +1,803 @@ +import { createRuleTester } from '../test-utils'; +import rule, { RULE_NAME } from '../../../lib/rules/no-unnecessary-act'; + +const ruleTester = createRuleTester(); + +/** + * - AGR stands for Aggressive Reporting + * - RTL stands for React Testing Library (@testing-library/react) + * - RTU stands for React Test Utils (react-dom/test-utils) + */ +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: `// case: RTL act wrapping non-RTL calls + import { act } from '@testing-library/react' + + test('valid case', async () => { + act(() => { + stuffThatDoesNotUseRTL(); + }); + + act(function() { + a = stuffThatDoesNotUseRTL(); + }); + + act(function() { + a = await stuffThatDoesNotUseRTL(); + }); + + await act(async () => { + await stuffThatDoesNotUseRTL(); + }); + + act(function() { + stuffThatDoesNotUseRTL(); + const a = foo(); + }); + + act(function() { + return stuffThatDoesNotUseRTL(); + }); + + act(() => stuffThatDoesNotUseRTL()); + + act(() => stuffThatDoesNotUseRTL()).then(() => {}) + act(stuffThatDoesNotUseRTL().then(() => {})) + }); + `, + }, + { + code: `// case: RTU act wrapping non-RTL + import { act } from 'react-dom/test-utils' + + test('valid case', async () => { + act(() => { + stuffThatDoesNotUseRTL(); + }); + + await act(async () => { + stuffThatDoesNotUseRTL(); + }); + + act(function() { + stuffThatDoesNotUseRTL(); + }); + + act(function() { + return stuffThatDoesNotUseRTL(); + }); + + act(() => stuffThatDoesNotUseRTL()); + }); + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `// case: RTL act wrapping non-RTL - AGR disabled + import { act } from '@testing-library/react' + import { waitFor } from 'somewhere-else' + + test('valid case', async () => { + act(() => { + waitFor(); + }); + + await act(async () => { + waitFor(); + }); + + act(function() { + waitFor(); + }); + + act(function() { + return waitFor(); + }); + + act(() => waitFor()); + }); + `, + }, + { + code: `// case: RTL act wrapping both RTL and non-RTL calls + import { act, render, waitFor } from '@testing-library/react' + + test('valid case', async () => { + act(() => { + render(element); + stuffThatDoesNotUseRTL(); + }); + + await act(async () => { + waitFor(); + stuffThatDoesNotUseRTL(); + }); + + act(function() { + waitFor(); + stuffThatDoesNotUseRTL(); + }); + }); + `, + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `// case: non-RTL act wrapping RTL - AGR disabled + import { act } from 'somewhere-else' + import { waitFor } from '@testing-library/react' + + test('valid case', async () => { + act(() => { + waitFor(); + }); + + await act(async () => { + waitFor(); + }); + + act(function() { + waitFor(); + }); + + act(function() { + return waitFor(); + }); + + act(() => waitFor()); + + act(() => {}) + await act(async () => {}) + act(function() {}) + }); + `, + }, + ], + invalid: [ + // cases for act related to React Testing Library + { + code: `// case: RTL act wrapping RTL calls - callbacks with body (BlockStatement) + import { act, fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from '@testing-library/react' + import userEvent from '@testing-library/user-event' + + test('invalid case', async () => { + act(() => { + fireEvent.click(el); + }); + + await act(async () => { + waitFor(() => {}); + }); + + await act(async () => { + waitForElementToBeRemoved(el); + }); + + act(function() { + const blah = screen.getByText('blah'); + }); + + act(function() { + render(something); + }); + + await act(() => { + const button = findByRole('button') + }); + + act(() => { + userEvent.click(el) + }); + + act(() => { + waitFor(); + const element = screen.getByText('blah'); + userEvent.click(element) + }); + }); + `, + errors: [ + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 10, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 14, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 18, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 22, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 26, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 30, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 34, + column: 9, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `// case: RTL act wrapping RTL calls - callbacks with body (BlockStatement) - AGR disabled + import { act, fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from 'test-utils' + import userEvent from '@testing-library/user-event' + + test('invalid case', async () => { + act(() => { + fireEvent.click(el); + }); + + await act(async () => { + waitFor(() => {}); + }); + + await act(async () => { + waitForElementToBeRemoved(el); + }); + + act(function() { + const blah = screen.getByText('blah'); + }); + + act(function() { + render(something); + }); + + await act(() => { + const button = findByRole('button') + }); + + act(() => { + userEvent.click(el) + }); + + act(() => { + waitFor(); + const element = screen.getByText('blah'); + userEvent.click(element) + }); + }); + `, + errors: [ + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 10, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 14, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 18, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 22, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 26, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 30, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 34, + column: 9, + }, + ], + }, + { + code: `// case: RTL act wrapping RTL calls - callbacks with return + import { act, fireEvent, screen, render, waitFor } from '@testing-library/react' + import userEvent from '@testing-library/user-event' + + test('invalid case', async () => { + act(() => fireEvent.click(el)) + act(() => screen.getByText('blah')) + act(() => findByRole('button')) + act(() => userEvent.click(el)) + await act(async () => userEvent.type('hi', el)) + act(() => render(foo)) + await act(async () => render(fo)) + act(() => waitFor(() => {})) + await act(async () => waitFor(() => {})) + + act(function () { + return fireEvent.click(el); + }); + act(function () { + return screen.getByText('blah'); + }); + act(function () { + return findByRole('button'); + }); + act(function () { + return userEvent.click(el); + }); + await act(async function () { + return userEvent.type('hi', el); + }); + act(function () { + return render(foo); + }); + await act(async function () { + return render(fo); + }); + act(function () { + return waitFor(() => {}); + }); + await act(async function () { + return waitFor(() => {}); + }); + act(async function () { + return waitFor(() => {}); + }).then(() => {}) + }); + `, + errors: [ + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 6, column: 9 }, + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 }, + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 8, column: 9 }, + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 9, column: 9 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 10, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 11, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 12, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 13, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 14, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 16, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 19, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 22, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 25, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 28, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 31, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 34, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 37, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 40, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 43, + column: 9, + }, + ], + }, + { + code: `// case: RTL act wrapping empty callback + import { act } from '@testing-library/react' + + test('invalid case', async () => { + await act(async () => {}) + act(() => {}) + await act(async function () {}) + act(function () {}) + }) + `, + errors: [ + { messageId: 'noUnnecessaryActEmptyFunction', line: 5, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 6, column: 9 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'test-utils', + }, + code: `// case: RTL act wrapping empty callback - require version + const { act } = require('@testing-library/react'); + + test('invalid case', async () => { + await act(async () => {}) + act(() => {}) + await act(async function () {}) + act(function () {}) + act(function () {}).then(() => {}) + }) + `, + errors: [ + { messageId: 'noUnnecessaryActEmptyFunction', line: 5, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 6, column: 9 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 9 }, + ], + }, + + // cases for act related to React Test Utils + { + settings: { + 'testing-library/utils-module': 'custom-testing-module', + }, + code: `// case: RTU act wrapping RTL calls - callbacks with body (BlockStatement) + import { act } from 'react-dom/test-utils'; + import { fireEvent, screen, render, waitFor, waitForElementToBeRemoved } from 'custom-testing-module' + import userEvent from '@testing-library/user-event' + + test('invalid case', async () => { + act(() => { + fireEvent.click(el); + }); + + await act(async () => { + waitFor(() => {}); + }); + + await act(async () => { + waitForElementToBeRemoved(el); + }); + + act(function() { + const blah = screen.getByText('blah'); + }); + + act(function() { + render(something); + }); + + await act(() => { + const button = findByRole('button') + }); + + act(() => { + userEvent.click(el) + }); + + act(() => { + waitFor(); + const element = screen.getByText('blah'); + userEvent.click(element) + }); + }); + `, + errors: [ + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 11, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 15, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 19, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 23, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 27, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 31, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 35, + column: 9, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'custom-testing-module', + }, + code: `// case: RTU act wrapping RTL calls - callbacks with return + import { act } from 'react-dom/test-utils'; + import { fireEvent, screen, render, waitFor } from 'custom-testing-module' + import userEvent from '@testing-library/user-event' + + test('invalid case', async () => { + act(() => fireEvent.click(el)) + act(() => screen.getByText('blah')) + act(() => findByRole('button')) + act(() => userEvent.click(el)) + await act(async () => userEvent.type('hi', el)) + act(() => render(foo)) + await act(async () => render(fo)) + act(() => waitFor(() => {})) + await act(async () => waitFor(() => {})) + + act(function () { + return fireEvent.click(el); + }); + act(function () { + return screen.getByText('blah'); + }); + act(function () { + return findByRole('button'); + }); + act(function () { + return userEvent.click(el); + }); + await act(async function () { + return userEvent.type('hi', el); + }); + act(function () { + return render(foo); + }); + await act(async function () { + return render(fo); + }); + act(function () { + return waitFor(() => {}); + }); + await act(async function () { + return waitFor(() => {}); + }); + }); + `, + errors: [ + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 7, column: 9 }, + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 8, column: 9 }, + { messageId: 'noUnnecessaryActTestingLibraryUtil', line: 9, column: 9 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 10, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 11, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 12, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 13, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 14, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 15, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 17, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 20, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 23, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 26, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 29, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 32, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 35, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 38, + column: 9, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 41, + column: 15, + }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'off', + }, + code: `// case: RTU act wrapping empty callback + import { act } from 'react-dom/test-utils'; + import { render } from '@testing-library/react' + + test('invalid case', async () => { + render(element); + await act(async () => {}); + act(() => {}); + await act(async function () {}); + act(function () {}); + }); + `, + errors: [ + { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 10, column: 9 }, + ], + }, + { + settings: { + 'testing-library/utils-module': 'off', + }, + code: `// case: RTU act wrapping empty callback - require version + const { act } = require('react-dom/test-utils'); + const { render } = require('@testing-library/react'); + + test('invalid case', async () => { + render(element); + await act(async () => {}); + act(() => {}); + await act(async function () {}); + act(function () {}); + }) + `, + errors: [ + { messageId: 'noUnnecessaryActEmptyFunction', line: 7, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 9 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 9, column: 15 }, + { messageId: 'noUnnecessaryActEmptyFunction', line: 10, column: 9 }, + ], + }, + + { + settings: { + 'testing-library/utils-module': 'custom-testing-module', + 'testing-library/custom-renders': 'off', + }, + code: `// case: mixed scenarios - AGR disabled + import * as ReactTestUtils from 'react-dom/test-utils'; + import { act as renamedAct, fireEvent, screen as renamedScreen, render, waitFor } from 'custom-testing-module' + import userEvent from '@testing-library/user-event' + import { act, waitForElementToBeRemoved } from 'somewhere-else' + + test('invalid case', async () => { + ReactTestUtils.act(() => {}) + await ReactTestUtils.act(() => render()) + await renamedAct(async () => waitFor()) + renamedAct(function() { renamedScreen.findByRole('button') }) + + // these are valid + await renamedAct(() => waitForElementToBeRemoved(element)) + act(() => {}) + await act(async () => { userEvent.click(element) }) + act(function() { return renamedScreen.getByText('foo') }) + }); + `, + errors: [ + { messageId: 'noUnnecessaryActEmptyFunction', line: 8, column: 24 }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 9, + column: 30, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 10, + column: 15, + }, + { + messageId: 'noUnnecessaryActTestingLibraryUtil', + line: 11, + column: 9, + }, + ], + }, + ], +});