Skip to content
Source
Sign inSign up

no-real-time-wait-in-tests.ts

277 lines10.2 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 TEST_CLOCK_DRIVERS = new Set(["adjust", "setTime"]);
8const TIMER_PROMISES_MODULES = new Set(["node:timers/promises", "timers/promises"]);
9const TIMER_FUNCTIONS = new Set(["setTimeout", "setInterval", "setImmediate"]);
10const SCHEDULE_WAITS = new Set(["spaced", "fixed", "exponential"]);
11const TESTER_ROOTS = new Set(["it", "test"]);
12const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
13​
14const REAL_CLOCK_MESSAGE =
15 "Real-clock wait in a test. Await the `Deferred`/`Queue`/event that marks the milestone, or run under `it.effect` and `TestClock.adjust`. `it.live` is for subprocess/network fixtures, not for sleeping.";
16const UNDRIVEN_TEST_CLOCK_MESSAGE =
17 "Sleeps on the TestClock never fire without `TestClock.adjust`; this test can only time out.";
18const MIXED_CLOCK_MESSAGE =
19 "`setTimeout` is wall-clock while `Effect.sleep` here is TestClock; they never meet.";
20​
21type Tester = "effect" | "live" | "plain";
22​
23interface CalleeChain {
24 readonly root: string;
25 readonly members: ReadonlyArray<string>;
26 readonly calls: ReadonlyArray<ESTree.CallExpression>;
28​
29// Flattens `it.live.skipIf(cond)` or `it.layer(L, opts)` into the root name, the member
30// names, and the curried calls whose arguments carry options like `excludeTestServices`.
31const calleeChain = (callee: unknown): Option.Option<CalleeChain> => {
32 const members: Array<string> = [];
33 const calls: Array<ESTree.CallExpression> = [];
34 let current = unwrapExpression(callee);
35 while (Option.isSome(current)) {
36 const node = current.value;
37 if (node.type === "Identifier") {
38 return Option.some({ root: node.name, members: members.toReversed(), calls });
39 }
40 if (node.type === "CallExpression") {
41 calls.push(node);
42 current = unwrapExpression(node.callee);
43 continue;
44 }
45 if (node.type === "MemberExpression") {
46 const name = getPropertyName(node.property);
47 if (Option.isNone(name)) return Option.none();
48 members.push(name.value);
49 current = unwrapExpression(node.object);
50 continue;
51 }
52 return Option.none();
53 }
54 return Option.none();
55};
56​
57const isLayerBlock = (chain: CalleeChain) =>
58 chain.root === "layer" || (TESTER_ROOTS.has(chain.root) && chain.members.includes("layer"));
59​
60const testerKind = (chain: CalleeChain): Option.Option<Tester> => {
61 if (!TESTER_ROOTS.has(chain.root) || chain.members.includes("layer")) return Option.none();
62 if (chain.members.includes("live") || chain.members.includes("scopedLive")) {
63 return Option.some("live");
64 }
65 if (chain.members.includes("effect") || chain.members.includes("scoped")) {
66 return Option.some("effect");
67 }
68 return Option.some("plain");
69};
70​
71const excludesTestServices = (chain: CalleeChain) =>
72 chain.calls.some((call) =>
73 call.arguments.some((argument) => {
74 const expression = unwrapExpression(argument);
75 if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
76 return expression.value.properties.some((property) => {
77 if (property.type !== "Property") return false;
78 const key = getPropertyName(property.key);
79 const value = unwrapExpression(property.value);
80 return (
81 Option.isSome(key) &&
82 key.value === "excludeTestServices" &&
83 Option.isSome(value) &&
84 value.value.type === "Literal" &&
85 value.value.value === true
86 );
87 });
88 }),
89 );
90​
91// The clock a call runs on: the nearest enclosing test call decides, and an enclosing
92// `it.layer(_, { excludeTestServices: true })` block strips the TestClock from `it.effect`.
93const enclosingTester = (node: ESTree.Node): Option.Option<Tester> => {
94 let child: ESTree.Node = node;
95 let current = node.parent;
96 let tester: Option.Option<Tester> = Option.none();
97 while (current) {
98 if (
99 current.type === "CallExpression" &&
100 current.arguments.some((argument) => argument === child)
101 ) {
102 const chain = calleeChain(current.callee);
103 if (Option.isSome(chain)) {
104 if (Option.isNone(tester)) {
105 tester = testerKind(chain.value);
106 if (Option.isSome(tester) && tester.value !== "effect") return tester;
107 } else if (isLayerBlock(chain.value) && excludesTestServices(chain.value)) {
108 return Option.some("live");
109 }
110 }
111 }
112 child = current;
113 current = current.parent;
114 }
115 return tester;
116};
117​
118const memberCallName = (callee: unknown): Option.Option<readonly [string, string]> => {
119 const expression = unwrapExpression(callee);
120 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
121 return Option.none();
122 }
123 const object = unwrapExpression(expression.value.object);
124 const property = getPropertyName(expression.value.property);
125 if (Option.isNone(property) || Option.isNone(object) || object.value.type !== "Identifier") {
126 return Option.none();
127 }
128 return Option.some([object.value.name, property.value] as const);
129};
130​
131const LOOPS = new Set([
132 "DoWhileStatement",
133 "ForInStatement",
134 "ForOfStatement",
135 "ForStatement",
136 "WhileStatement",
137]);
138​
139// `while (...) { yield* Effect.sleep(...) }`: a statement of the loop body itself, not a
140// conditional delay inside it.
141const isPollingLoopStep = (node: ESTree.Node): boolean => {
142 let statement: ESTree.Node | null = node.parent;
143 while (statement && statement.type !== "ExpressionStatement") {
144 if (FUNCTIONS.has(statement.type)) return false;
145 statement = statement.parent;
146 }
147 const block = statement?.parent;
148 if (!block) return false;
149 if (LOOPS.has(block.type)) return true;
150 return block.type === "BlockStatement" && block.parent !== null && LOOPS.has(block.parent.type);
151};
152​
153const isPromiseExecutor = (fn: ESTree.Node) => {
154 const parent = fn.parent;
155 return (
156 parent?.type === "NewExpression" &&
157 parent.arguments[0] === fn &&
158 isIdentifier(unwrapExpression(parent.callee), "Promise")
159 );
160};
161​
162const isInsidePromiseExecutor = (node: ESTree.Node): boolean => {
163 let current = node.parent;
164 while (current) {
165 if (FUNCTIONS.has(current.type)) return isPromiseExecutor(current);
166 current = current.parent;
167 }
168 return false;
169};
170​
171export default defineRule({
172 meta: {
173 type: "problem",
174 docs: {
175 description:
176 "Disallow real-clock waits in tests; await the milestone or drive the TestClock instead.",
177 },
178 },
179 create(context) {
180 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
181 let drivesTestClock = false;
182 // Whether the file drives the TestClock is only known after the whole file is walked.
183 const untilDrivenReports: Array<{ node: ESTree.Node; message: string }> = [];
184 const timerPromiseLocals = new Set<string>();
185 const timerPromiseNamespaces = new Set<string>();
186​
187 const isTimerPromiseCall = (callee: unknown) => {
188 const expression = unwrapExpression(callee);
189 if (Option.isNone(expression)) return false;
190 if (expression.value.type === "Identifier") {
191 return timerPromiseLocals.has(expression.value.name);
192 }
193 return Option.match(memberCallName(callee), {
194 onNone: () => false,
195 onSome: ([object, property]) =>
196 timerPromiseNamespaces.has(object) && TIMER_FUNCTIONS.has(property),
197 });
198 };
199​
200 const isCallbackTimer = (callee: unknown) => {
201 const expression = unwrapExpression(callee);
202 return (
203 Option.isSome(expression) &&
204 expression.value.type === "Identifier" &&
205 TIMER_FUNCTIONS.has(expression.value.name) &&
206 !timerPromiseLocals.has(expression.value.name)
207 );
208 };
209​
210 return {
211 ImportDeclaration(node) {
212 if (!TIMER_PROMISES_MODULES.has(node.source.value)) return;
213 for (const specifier of node.specifiers) {
214 if (specifier.type === "ImportNamespaceSpecifier") {
215 timerPromiseNamespaces.add(specifier.local.name);
216 } else if (specifier.type === "ImportSpecifier") {
217 const imported = getPropertyName(specifier.imported);
218 if (Option.isSome(imported) && TIMER_FUNCTIONS.has(imported.value)) {
219 timerPromiseLocals.add(specifier.local.name);
220 }
221 }
222 }
223 },
224 CallExpression(node) {
225 const reportFor = (message: string) => context.report({ node: node.callee, message });
226​
227 if (
228 isTimerPromiseCall(node.callee) ||
229 (isCallbackTimer(node.callee) && isInsidePromiseExecutor(node))
230 ) {
231 const tester = enclosingTester(node);
232 reportFor(
233 Option.isSome(tester) && tester.value === "effect"
234 ? `${REAL_CLOCK_MESSAGE} ${MIXED_CLOCK_MESSAGE}`
235 : REAL_CLOCK_MESSAGE,
236 );
237 return;
238 }
239​
240 const name = memberCallName(node.callee);
241 if (Option.isNone(name)) return;
242 const [object, property] = name.value;
243 if (object === "TestClock" && TEST_CLOCK_DRIVERS.has(property)) {
244 drivesTestClock = true;
245 return;
246 }
247 const isSleep = object === "Effect" && property === "sleep";
248 const isWait =
249 isSleep ||
250 (object === "Effect" && property === "delay") ||
251 (object === "Schedule" && SCHEDULE_WAITS.has(property));
252 if (!isWait) return;
253​
254 const tester = enclosingTester(node);
255 if (Option.isNone(tester)) {
256 // A shared helper can serve TestClock tests, so only report it when nothing in
257 // the file drives the TestClock or it polls in a loop, which only a real clock ends.
258 if (isPollingLoopStep(node)) {
259 reportFor(REAL_CLOCK_MESSAGE);
260 } else {
261 untilDrivenReports.push({ node: node.callee, message: REAL_CLOCK_MESSAGE });
262 }
263 return;
264 }
265 if (tester.value !== "effect") {
266 reportFor(REAL_CLOCK_MESSAGE);
267 } else if (isSleep) {
268 untilDrivenReports.push({ node: node.callee, message: UNDRIVEN_TEST_CLOCK_MESSAGE });
269 }
270 },
271 "Program:exit"() {
272 if (drivesTestClock) return;
273 for (const report of untilDrivenReports) context.report(report);
274 },
275 };
276 },
277});