Skip to content
Source
Sign inSign up

no-polling-loop-in-tests.ts

194 lines7.4 KB

1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3​
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5​
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
8const COUNTER_OPERATORS = new Set(["<", "<=", ">", ">="]);
9const TIMERS = new Set(["setImmediate", "setTimeout"]);
10const MESSAGE =
11 "Polling loop in a test. Spinning on `yieldNow`/`sleep` is scheduler- and load-dependent. Await the `Deferred`, `Queue.take`, `Stream` element, `drain()`, or `expect.poll` that marks the milestone.";
12​
13type LoopNode = ESTree.ForStatement | ESTree.WhileStatement | ESTree.DoWhileStatement;
14​
15const isNode = (value: unknown): value is ESTree.Node =>
16 typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
17​
18// Visits `node` and its descendants, skipping nested functions: a wait or exit
19// inside a callback runs on the callback's schedule, not the loop's.
20const someDescendant = (node: unknown, match: (node: ESTree.Node) => boolean): boolean => {
21 if (Array.isArray(node)) return node.some((child) => someDescendant(child, match));
22 if (!isNode(node)) return false;
23 if (match(node)) return true;
24 if (FUNCTIONS.has(node.type)) return false;
25 for (const [key, value] of Object.entries(node)) {
26 if (key === "parent") continue;
27 if (someDescendant(value, match)) return true;
28 }
29 return false;
30};
31​
32const memberName = (node: unknown): Option.Option<readonly [string, string]> => {
33 const expression = unwrapExpression(node);
34 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
35 return Option.none();
36 }
37 const object = unwrapExpression(expression.value.object);
38 const property = getPropertyName(expression.value.property);
39 if (Option.isNone(object) || object.value.type !== "Identifier" || Option.isNone(property)) {
40 return Option.none();
41 }
42 return Option.some([object.value.name, property.value] as const);
43};
44​
45const isMember = (node: unknown, object: string, property: string): boolean =>
46 Option.match(memberName(node), {
47 onNone: () => false,
48 onSome: ([name, key]) => name === object && key === property,
49 });
50​
51const isTimerReference = (node: unknown): boolean => {
52 const expression = unwrapExpression(node);
53 return (
54 Option.isSome(expression) &&
55 expression.value.type === "Identifier" &&
56 TIMERS.has(expression.value.name)
57 );
58};
59​
60// `Promise.resolve()`, or a `new Promise` whose executor schedules a timer.
61const isTickPromise = (node: unknown): boolean => {
62 const expression = unwrapExpression(node);
63 if (Option.isNone(expression)) return false;
64 const value = expression.value;
65 if (value.type === "CallExpression") return isMember(value.callee, "Promise", "resolve");
66 if (value.type !== "NewExpression" || !isIdentifier(Option.some(value.callee), "Promise")) {
67 return false;
68 }
69 const [executor] = value.arguments;
70 if (isTimerReference(executor)) return true;
71 if (!isNode(executor) || !FUNCTIONS.has(executor.type)) return false;
72 return someDescendant(
73 (executor as ESTree.ArrowFunctionExpression).body,
74 (child) => child.type === "CallExpression" && isTimerReference(child.callee),
75 );
76};
77​
78const thunkReturnsTick = (node: unknown): boolean => {
79 if (!isNode(node) || node.type !== "ArrowFunctionExpression") return false;
80 const body = node.body;
81 if (body.type !== "BlockStatement") return isTickPromise(body);
82 const last = body.body.at(-1);
83 return last?.type === "ReturnStatement" && isTickPromise(last.argument);
84};
85​
86const isWait = (node: ESTree.Node): boolean => {
87 if (node.type === "MemberExpression") return isMember(node, "Effect", "yieldNow");
88 if (node.type === "AwaitExpression") return isTickPromise(node.argument);
89 if (node.type === "NewExpression") return isTickPromise(node);
90 if (node.type !== "CallExpression") return false;
91 if (isMember(node.callee, "Effect", "sleep")) return true;
92 if (isIdentifier(unwrapExpression(node.callee), "setImmediate")) return true;
93 if (isMember(node.callee, "Effect", "promise")) return thunkReturnsTick(node.arguments[0]);
94 return false;
95};
96​
97const hasConditionalExit = (body: ESTree.Node): boolean =>
98 someDescendant(
99 body,
100 (node) =>
101 node.type === "IfStatement" &&
102 someDescendant(
103 [node.consequent, node.alternate],
104 (child) =>
105 child.type === "ReturnStatement" ||
106 child.type === "BreakStatement" ||
107 child.type === "ThrowStatement",
108 ),
109 );
110​
111const isNumericBound = (node: unknown): boolean => {
112 const expression = unwrapExpression(node);
113 if (Option.isNone(expression)) return false;
114 const value = expression.value;
115 return (
116 value.type === "Identifier" || (value.type === "Literal" && typeof value.value === "number")
117 );
118};
119​
120const updatesCounter = (node: unknown, name: string): boolean =>
121 someDescendant(
122 node,
123 (child) =>
124 (child.type === "UpdateExpression" && isIdentifier(unwrapExpression(child.argument), name)) ||
125 (child.type === "AssignmentExpression" &&
126 (child.operator === "+=" || child.operator === "-=") &&
127 isIdentifier(unwrapExpression(child.left), name)),
128 );
129​
130// A bare counter (`i < 3`, `attempt < max`) that the loop itself advances
131// bounds the loop without asking anything about the system under test.
132// `events.length < 1` or a counter nobody increments is a predicate.
133const isCounterTest = (loop: LoopNode): boolean => {
134 if (loop.test == null) return false;
135 const expression = unwrapExpression(loop.test);
136 if (Option.isNone(expression) || expression.value.type !== "BinaryExpression") return false;
137 if (!COUNTER_OPERATORS.has(expression.value.operator)) return false;
138 const { left, right } = expression.value;
139 const advances = [loop.body, loop.type === "ForStatement" ? loop.update : null];
140 return [
141 [left, right],
142 [right, left],
143 ].some(([counter, bound]) => {
144 const name = unwrapExpression(counter);
145 return (
146 Option.isSome(name) &&
147 name.value.type === "Identifier" &&
148 isNumericBound(bound) &&
149 updatesCounter(advances, name.value.name)
150 );
151 });
152};
153​
154const isTrueLiteral = (test: ESTree.Expression): boolean => {
155 const expression = unwrapExpression(test);
156 return (
157 Option.isSome(expression) &&
158 expression.value.type === "Literal" &&
159 expression.value.value === true
160 );
161};
162​
163// A loop polls when a predicate decides whether it continues: a conditional exit in the body,
164// or a test that is neither `true` nor a counter the loop advances. A fixed-count flush is
165// not polling, so code after the loop is ignored.
166const hasPredicateExit = (loop: LoopNode): boolean =>
167 hasConditionalExit(loop.body) ||
168 (loop.test != null && !isTrueLiteral(loop.test) && !isCounterTest(loop));
169​
170export default defineRule({
171 meta: {
172 type: "problem",
173 docs: {
174 description:
175 "Disallow loops in tests that spin on yieldNow/sleep/timer ticks until a predicate holds; await the milestone instead.",
176 },
177 schema: [],
178 },
179 create(context) {
180 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
181​
182 const check = (loop: LoopNode) => {
183 if (!someDescendant(loop.body, isWait)) return;
184 if (!hasPredicateExit(loop)) return;
185 context.report({ node: loop, message: MESSAGE });
186 };
187​
188 return {
189 ForStatement: check,
190 WhileStatement: check,
191 DoWhileStatement: check,
192 };
193 },
194});