From a3ab48f320512015e88d3d2b1d9ac4272033dd53 Mon Sep 17 00:00:00 2001 From: Valy Pat Date: Wed, 21 Dec 2022 14:11:53 -0500 Subject: [PATCH] Await skeletons (#1) * ast utils * no unawaited skeletons * fixes - add testId schema - better wording in reported message * fixes - hide internal functions - add comments Co-authored-by: Valentyn Patsera --- src/index.js | 2 + src/noUnawaitedSkeletons/index.js | 1 + .../noUnawaitedSkeletons.js | 119 ++++++++++++++++++ src/utils/ast/astUtils.js | 28 +++++ src/utils/ast/index.js | 1 + src/utils/index.js | 1 + 6 files changed, 152 insertions(+) create mode 100644 src/noUnawaitedSkeletons/index.js create mode 100644 src/noUnawaitedSkeletons/noUnawaitedSkeletons.js create mode 100644 src/utils/ast/astUtils.js create mode 100644 src/utils/ast/index.js diff --git a/src/index.js b/src/index.js index 8544b70..ada0522 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,7 @@ import { circularDependency } from "./circularDependency"; import { crossReference } from "./crossReference"; import { gqlObjects, gqlOperationName } from "./gqlRules"; import { noRenamedTranslationImport } from "./noRenamedTranslationImport"; +import { noUnawaitedSkeletons } from "./noUnawaitedSkeletons"; import { oneTranslationImport } from "./oneTranslationImport"; const rules = { @@ -11,6 +12,7 @@ const rules = { "gql-operation-name": gqlOperationName, "cross-reference": crossReference, "circular-dependency": circularDependency, + "no-unawaited-skeletons": noUnawaitedSkeletons, }; export { rules }; diff --git a/src/noUnawaitedSkeletons/index.js b/src/noUnawaitedSkeletons/index.js new file mode 100644 index 0000000..464e308 --- /dev/null +++ b/src/noUnawaitedSkeletons/index.js @@ -0,0 +1 @@ +export * as noUnawaitedSkeletons from "./noUnawaitedSkeletons"; diff --git a/src/noUnawaitedSkeletons/noUnawaitedSkeletons.js b/src/noUnawaitedSkeletons/noUnawaitedSkeletons.js new file mode 100644 index 0000000..b119af7 --- /dev/null +++ b/src/noUnawaitedSkeletons/noUnawaitedSkeletons.js @@ -0,0 +1,119 @@ +import { isCalleName, isCallExpression, isExpect, isLiteral, findByPaths } from "../utils"; + +const meta = { + type: "problem", + docs: { + category: "code", + description: + "Enforces skeletons to be wrapped into `waitFor` to avoid `should be wrapped in act(...) error`", + }, + hasSuggestions: false, + fixable: false, + schema: [ + { + type: "object", + properties: { + testIds: { + type: "array", + items: { + type: "string", + }, + }, + }, + required: ["testIds"], + additionalProperties: false, + }, + ], +}; + +const isWaitFor = node => isCalleName(node, "waitFor"); + +/** + * @description the below constant array is for the following code examples + * [0] + * await waitFor(() => { + * expect(screen.queryByTestId("aviary-skeleton")).not.toBeInTheDocument(); + * }); + * [1] + * await waitFor(() => { + * expect(screen.getByTestId("aviary-skeleton")).toBeInTheDocument(); + * }); + * [2] + * await waitFor(() => expect(screen.queryAllByTestId("aviary-skeleton")).toHaveLength(0)); + * [3] + * expect(screen.queryByTestId("aviary-skeleton")).not.toBeInTheDocument() + */ + +const WAIT_FOR_PATHS = [ + [ + "CallExpression", + "MemberExpression", + "MemberExpression", + "CallExpression", + "ExpressionStatement", + "BlockStatement", + "ArrowFunctionExpression", + "CallExpression", + // "AwaitExpression", if we want to check for `await waitFor` instead of just `waitFor` + ], + [ + "CallExpression", + "MemberExpression", + "CallExpression", + "ExpressionStatement", + "BlockStatement", + "ArrowFunctionExpression", + "CallExpression", + // "AwaitExpression", + ], + [ + "CallExpression", + "MemberExpression", + "CallExpression", + "ArrowFunctionExpression", + "CallExpression", + // "AwaitExpression", + ], + [ + "CallExpression", + "MemberExpression", + "MemberExpression", + "CallExpression", + "ArrowFunctionExpression", + "CallExpression", + // "AwaitExpression", + ], +]; + +const create = context => { + const [{ testIds }] = context.options; + return { + CallExpression: node => { + if (!isExpect(node)) return; + + const [call] = node.arguments; + if (!isCallExpression(call)) return; + + const [literal] = call.arguments; + if (!isLiteral(literal)) return; + + const { value } = literal; + if (!testIds.includes(value)) return; + /** + * Taking expect(...) call as the base + * Going up by any of the paths the found node should be `waitFor` expressions + * Report if it is not. + * */ + const callExpression = findByPaths(WAIT_FOR_PATHS, node); + if (isWaitFor(callExpression)) return; + + context.report({ + node, + message: + "Checks for loading state should be wrapped in a `waitFor` block to prevent act warnings", + }); + }, + }; +}; + +export { meta, create }; diff --git a/src/utils/ast/astUtils.js b/src/utils/ast/astUtils.js new file mode 100644 index 0000000..82ae313 --- /dev/null +++ b/src/utils/ast/astUtils.js @@ -0,0 +1,28 @@ +// types +const isType = (node, type) => node?.type === type; +const isCallExpression = node => isType(node, "CallExpression"); +const isLiteral = node => isType(node, "Literal"); +const isAwaitExpression = node => isType(node, "AwaitExpression"); + +// calee +const isCalleName = (node, name) => node?.callee?.name === name; +const isExpect = node => isCalleName(node, "expect"); + +// finder +const findNode = (path, tree) => { + if (!tree || !path) return null; + const current = path.shift(); + if (!isType(tree, current)) return null; + else if (path.length === 0) return tree; + return findNode(path, tree.parent); +}; + +const findByPaths = (paths, tree) => { + for (const path of paths) { + const found = findNode([...path], tree); + if (found) return found; + } + return null; +}; + +export { isCallExpression, isLiteral, isAwaitExpression, isCalleName, isExpect, findByPaths }; diff --git a/src/utils/ast/index.js b/src/utils/ast/index.js new file mode 100644 index 0000000..af80470 --- /dev/null +++ b/src/utils/ast/index.js @@ -0,0 +1 @@ +export * from "./astUtils"; diff --git a/src/utils/index.js b/src/utils/index.js index 54e49e8..f4c3d71 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -1,2 +1,3 @@ export * from "./isTranslationSource"; export * from "./relativePathToFile"; +export * from "./ast";