core/quality/eslint/no-async-test-without-await.eslint.rule.ts
core/quality/eslint/no-async-test-without-await.eslint.rule.ts is a file in GovLab Quality. 162 lines of code and 26 definitions.
import type { Rule, SourceCode } from "eslint";
import { govlabMeta } from "#core/factories/eslint.factory";
const SAFE_VALUE_TYPES = new Set([
"Literal",
"ObjectExpression",
"ArrayExpression",
"Identifier",
"TemplateLiteral",
"MemberExpression",
]);
const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
const isRecord = function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
};
const nodeType = function nodeType(node: Record<string, unknown>): string {
return typeof node["type"] === "string" ? node["type"] : "";
};
const isTestFile = function isTestFile(filename: string): boolean {
const unix = filename.split("\\").join("/");
return unix.includes("/tests/") || unix.includes(".test.") || unix.includes(".spec.");
};
const childRecords = function* childRecords(value: unknown): Generator<Record<string, unknown>> {
const items = Array.isArray(value) ? value : [value];
for (const item of items) {
if (isRecord(item)) {
yield item;
}
}
};
const eachChild = function* eachChild(node: Record<string, unknown>): Generator<Record<string, unknown>> {
for (const key of Object.keys(node)) {
if (key !== "parent") {
yield* childRecords(node[key]);
}
}
};
const hasOwnAwait = function hasOwnAwait(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const node = value;
const type = nodeType(node);
if (type === "AwaitExpression") {
return true;
}
if (type === "ForOfStatement" && node["await"] === true) {
return true;
}
for (const child of eachChild(node)) {
if (!FUNCTION_TYPES.has(nodeType(child)) && hasOwnAwait(child)) {
return true;
}
}
return false;
};
const collectOwnReturnArgs = function collectOwnReturnArgs(node: Record<string, unknown>): unknown[] {
if (nodeType(node) === "ReturnStatement") {
return [node["argument"]];
}
const out: unknown[] = [];
for (const child of eachChild(node)) {
if (!FUNCTION_TYPES.has(nodeType(child))) {
out.push(...collectOwnReturnArgs(child));
}
}
return out;
};
const isSafeReturn = function isSafeReturn(value: unknown): boolean {
if (!isRecord(value)) {
return true;
}
const type = nodeType(value);
if (SAFE_VALUE_TYPES.has(type)) {
return true;
}
if (type === "LogicalExpression") {
return isSafeReturn(value["left"]) && isSafeReturn(value["right"]);
}
if (type === "ConditionalExpression") {
return isSafeReturn(value["consequent"]) && isSafeReturn(value["alternate"]);
}
return false;
};
const bodyReturnsOnlySafe = function bodyReturnsOnlySafe(body: unknown): boolean {
if (!isRecord(body)) {
return false;
}
if (nodeType(body) === "BlockStatement") {
return collectOwnReturnArgs(body).every(isSafeReturn);
}
return isSafeReturn(body);
};
type FnNode = Extract<Rule.Node, { type: "ArrowFunctionExpression" | "FunctionDeclaration" | "FunctionExpression" }>;
interface StubFixCtx {
source: SourceCode;
node: Rule.Node;
body: FnNode["body"];
}
const buildStubFix = function buildStubFix(fixer: Rule.RuleFixer, ctx: StubFixCtx): Rule.Fix | null {
const { source, node, body } = ctx;
if (body.type === "BlockStatement") {
const brace = source.getFirstToken(body);
return brace === null ? null : fixer.insertTextAfter(brace, " await Promise.resolve();");
}
const arrow = source.getTokenBefore(body, { filter: (token): boolean => token.value === "=>" });
const lastToken = source.getLastToken(node);
if (arrow === null || lastToken === null) {
return null;
}
return fixer.replaceTextRange(
[arrow.range[1], lastToken.range[1]],
` { await Promise.resolve(); return (${source.getText(body)}); }`,
);
};
const checkAsyncStub = function checkAsyncStub(context: Rule.RuleContext, node: Rule.Node): void {
if (
node.type !== "FunctionDeclaration" &&
node.type !== "FunctionExpression" &&
node.type !== "ArrowFunctionExpression"
) {
return;
}
if (node.async !== true || node.generator === true) {
return;
}
if (!isTestFile(context.filename)) {
return;
}
const { body } = node;
if (hasOwnAwait(body) || !bodyReturnsOnlySafe(body)) {
return;
}
context.report({
fix: (fixer: Rule.RuleFixer): Rule.Fix | null =>
buildStubFix(fixer, { body, node, source: context.sourceCode }),
messageId: "asyncStubNeedsAwait",
node,
});
};
export default {
create(context: Rule.RuleContext): Rule.RuleListener {
return Object.fromEntries([
[
":function",
(node: Rule.Node): void => {
checkAsyncStub(context, node);
},
],
]);
},
meta: govlabMeta({
canonical: ["test-quality"],
description:
"An async function in a test file that contains no await and returns only a synchronous value is normalized to await a resolved promise as its first action. A body that returns or delegates to a call expression is left untouched, since it may already return a promise",
fixable: "code",
messages: {
asyncStubNeedsAwait:
"Async test function with no await returning a synchronous value. Await a resolved promise as the first statement, or drop async and return a resolved promise.",
},
ruleId: "no_async_test_without_await",
}),
} satisfies Rule.RuleModule;