test(server): ban real-clock waits in tests#22564
2 unresolved threads Β· Jump to file
- oxlint-plugin-t3code/rules/no-real-time-wait-in-tests.ts:181 Β· <!-- MURMUR_IGNORE --> π’ **Low** `rules/no-real-time-wait-in-tests.ts:181` A comment or string con
- oxlint-plugin-t3code/rules/no-real-time-wait-in-tests.ts:230β232 Β· <!-- MURMUR_IGNORE --> π **High** `rules/no-real-time-wait-in-tests.ts:230` A timer hidden in a sh
oxlint-plugin-t3code/rules/no-real-time-wait-in-tests.ts Β· Earlier revision f45927f6 Β· right 181
Original diff
@@ -0,0 +1,264 @@
+import { defineRule, type ESTree } from "@oxlint/plugins";
+import * as Option from "effect/Option";
+
+import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
+
+const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
+const TEST_CLOCK_DRIVER_PATTERN = /\bTestClock\.(?:adjust|setTime)\b/u;
+const TIMER_PROMISES_MODULES = new Set(["node:timers/promises", "timers/promises"]);
+const TIMER_FUNCTIONS = new Set(["setTimeout", "setInterval", "setImmediate"]);
+const SCHEDULE_WAITS = new Set(["spaced", "fixed", "exponential"]);
+const TESTER_ROOTS = new Set(["it", "test"]);
+const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
+
+const REAL_CLOCK_MESSAGE =
+ "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.";
+const UNDRIVEN_TEST_CLOCK_MESSAGE =
+ "Sleeps on the TestClock never fire without `TestClock.adjust`; this test can only time out.";
+const MIXED_CLOCK_MESSAGE =
+ "`setTimeout` is wall-clock while `Effect.sleep` here is TestClock; they never meet.";
+
+type Tester = "effect" | "live" | "plain";
+
+interface CalleeChain {
+ readonly root: string;
+ readonly members: ReadonlyArray<string>;
+ readonly calls: ReadonlyArray<ESTree.CallExpression>;
+}
+
+// Flattens `it.live.skipIf(cond)` or `it.layer(L, opts)` into the root name, the member
+// names, and the curried calls whose arguments carry options like `excludeTestServices`.
+const calleeChain = (callee: unknown): Option.Option<CalleeChain> => {
+ const members: Array<string> = [];
+ const calls: Array<ESTree.CallExpression> = [];
+ let current = unwrapExpression(callee);
+ while (Option.isSome(current)) {
+ const node = current.value;
+ if (node.type === "Identifier") {
+ return Option.some({ root: node.name, members: members.toReversed(), calls });
+ }
+ if (node.type === "CallExpression") {
+ calls.push(node);
+ current = unwrapExpression(node.callee);
+ continue;
+ }
+ if (node.type === "MemberExpression") {
+ const name = getPropertyName(node.property);
+ if (Option.isNone(name)) return Option.none();
+ members.push(name.value);
+ current = unwrapExpression(node.object);
+ continue;
+ }
+ return Option.none();
+ }
+ return Option.none();
+};
+
+const isLayerBlock = (chain: CalleeChain) =>
+ chain.root === "layer" || (TESTER_ROOTS.has(chain.root) && chain.members.includes("layer"));
+
+const testerKind = (chain: CalleeChain): Option.Option<Tester> => {
+ if (!TESTER_ROOTS.has(chain.root) || chain.members.includes("layer")) return Option.none();
+ if (chain.members.includes("live") || chain.members.includes("scopedLive")) {
+ return Option.some("live");
+ }
+ if (chain.members.includes("effect") || chain.members.includes("scoped")) {
+ return Option.some("effect");
+ }
+ return Option.some("plain");
+};
+
+const excludesTestServices = (chain: CalleeChain) =>
+ chain.calls.some((call) =>
+ call.arguments.some((argument) => {
+ const expression = unwrapExpression(argument);
+ if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
+ return expression.value.properties.some((property) => {
+ if (property.type !== "Property") return false;
+ const key = getPropertyName(property.key);
+ const value = unwrapExpression(property.value);
+ return (
+ Option.isSome(key) &&
+ key.value === "excludeTestServices" &&
+ Option.isSome(value) &&
+ value.value.type === "Literal" &&
+ value.value.value === true
+ );
+ });
+ }),
+ );
+
+// The clock a call runs on: the nearest enclosing test call decides, and an enclosing
+// `it.layer(_, { excludeTestServices: true })` block strips the TestClock from `it.effect`.
+const enclosingTester = (node: ESTree.Node): Option.Option<Tester> => {
+ let child: ESTree.Node = node;
+ let current = node.parent;
+ let tester: Option.Option<Tester> = Option.none();
+ while (current) {
+ if (
+ current.type === "CallExpression" &&
+ current.arguments.some((argument) => argument === child)
+ ) {
+ const chain = calleeChain(current.callee);
+ if (Option.isSome(chain)) {
+ if (Option.isNone(tester)) {
+ tester = testerKind(chain.value);
+ if (Option.isSome(tester) && tester.value !== "effect") return tester;
+ } else if (isLayerBlock(chain.value) && excludesTestServices(chain.value)) {
+ return Option.some("live");
+ }
+ }
+ }
+ child = current;
+ current = current.parent;
+ }
+ return tester;
+};
+
+const memberCallName = (callee: unknown): Option.Option<readonly [string, string]> => {
+ const expression = unwrapExpression(callee);
+ if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
+ return Option.none();
+ }
+ const object = unwrapExpression(expression.value.object);
+ const property = getPropertyName(expression.value.property);
+ if (Option.isNone(property) || Option.isNone(object) || object.value.type !== "Identifier") {
+ return Option.none();
+ }
+ return Option.some([object.value.name, property.value] as const);
+};
+
+const LOOPS = new Set([
+ "DoWhileStatement",
+ "ForInStatement",
+ "ForOfStatement",
+ "ForStatement",
+ "WhileStatement",
+]);
+
+// `while (...) { yield* Effect.sleep(...) }`: a statement of the loop body itself, not a
+// conditional delay inside it.
+const isPollingLoopStep = (node: ESTree.Node): boolean => {
+ let statement: ESTree.Node | null = node.parent;
+ while (statement && statement.type !== "ExpressionStatement") {
+ if (FUNCTIONS.has(statement.type)) return false;
+ statement = statement.parent;
+ }
+ const block = statement?.parent;
+ if (!block) return false;
+ if (LOOPS.has(block.type)) return true;
+ return block.type === "BlockStatement" && block.parent !== null && LOOPS.has(block.parent.type);
+};
+
+const isPromiseExecutor = (fn: ESTree.Node) => {
+ const parent = fn.parent;
+ return (
+ parent?.type === "NewExpression" &&
+ parent.arguments[0] === fn &&
+ isIdentifier(unwrapExpression(parent.callee), "Promise")
+ );
+};
+
+const isInsidePromiseExecutor = (node: ESTree.Node): boolean => {
+ let current = node.parent;
+ while (current) {
+ if (FUNCTIONS.has(current.type)) return isPromiseExecutor(current);
+ current = current.parent;
+ }
+ return false;
+};
+
+export default defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Disallow real-clock waits in tests; await the milestone or drive the TestClock instead.",
+ },
+ },
+ create(context) {
+ if (!TEST_FILE_PATTERN.test(context.filename)) return {};
+ const drivesTestClock = TEST_CLOCK_DRIVER_PATTERN.test(context.sourceCode.text);π’ Low rules/no-real-time-wait-in-tests.ts:181
A comment or string containing TestClock.adjust or TestClock.setTime sets drivesTestClock to true, suppressing the undriven-sleep diagnostic even when the test never drives the clock. Derive this flag from actual call expressions so documentation and fixture text cannot disable the check.
No longer relevant as of e90b635be35e5ff8b2814ec4ca8304f44bb44b93
Fixed: drivesTestClock now comes from actual TestClock.adjust/TestClock.setTime call expressions, with the dependent reports deferred to Program:exit, and a test covers a comment-only mention.
I still see drivesTestClock derived from context.sourceCode.text via the regex at line 181, so comment/string mentions can still suppress the diagnostic.
Same: on 978d35a drivesTestClock is set only by TestClock.adjust/setTime CallExpressions found in the AST; the source-text regex is gone. Rule test "reports TestClock sleeps when TestClock.adjust appears only in a comment" covers it.
oxlint-plugin-t3code/rules/no-real-time-wait-in-tests.ts Β· Earlier revision f45927f6 Β· right 230β232
Original diff
@@ -0,0 +1,264 @@
+import { defineRule, type ESTree } from "@oxlint/plugins";
+import * as Option from "effect/Option";
+
+import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
+
+const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
+const TEST_CLOCK_DRIVER_PATTERN = /\bTestClock\.(?:adjust|setTime)\b/u;
+const TIMER_PROMISES_MODULES = new Set(["node:timers/promises", "timers/promises"]);
+const TIMER_FUNCTIONS = new Set(["setTimeout", "setInterval", "setImmediate"]);
+const SCHEDULE_WAITS = new Set(["spaced", "fixed", "exponential"]);
+const TESTER_ROOTS = new Set(["it", "test"]);
+const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"]);
+
+const REAL_CLOCK_MESSAGE =
+ "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.";
+const UNDRIVEN_TEST_CLOCK_MESSAGE =
+ "Sleeps on the TestClock never fire without `TestClock.adjust`; this test can only time out.";
+const MIXED_CLOCK_MESSAGE =
+ "`setTimeout` is wall-clock while `Effect.sleep` here is TestClock; they never meet.";
+
+type Tester = "effect" | "live" | "plain";
+
+interface CalleeChain {
+ readonly root: string;
+ readonly members: ReadonlyArray<string>;
+ readonly calls: ReadonlyArray<ESTree.CallExpression>;
+}
+
+// Flattens `it.live.skipIf(cond)` or `it.layer(L, opts)` into the root name, the member
+// names, and the curried calls whose arguments carry options like `excludeTestServices`.
+const calleeChain = (callee: unknown): Option.Option<CalleeChain> => {
+ const members: Array<string> = [];
+ const calls: Array<ESTree.CallExpression> = [];
+ let current = unwrapExpression(callee);
+ while (Option.isSome(current)) {
+ const node = current.value;
+ if (node.type === "Identifier") {
+ return Option.some({ root: node.name, members: members.toReversed(), calls });
+ }
+ if (node.type === "CallExpression") {
+ calls.push(node);
+ current = unwrapExpression(node.callee);
+ continue;
+ }
+ if (node.type === "MemberExpression") {
+ const name = getPropertyName(node.property);
+ if (Option.isNone(name)) return Option.none();
+ members.push(name.value);
+ current = unwrapExpression(node.object);
+ continue;
+ }
+ return Option.none();
+ }
+ return Option.none();
+};
+
+const isLayerBlock = (chain: CalleeChain) =>
+ chain.root === "layer" || (TESTER_ROOTS.has(chain.root) && chain.members.includes("layer"));
+
+const testerKind = (chain: CalleeChain): Option.Option<Tester> => {
+ if (!TESTER_ROOTS.has(chain.root) || chain.members.includes("layer")) return Option.none();
+ if (chain.members.includes("live") || chain.members.includes("scopedLive")) {
+ return Option.some("live");
+ }
+ if (chain.members.includes("effect") || chain.members.includes("scoped")) {
+ return Option.some("effect");
+ }
+ return Option.some("plain");
+};
+
+const excludesTestServices = (chain: CalleeChain) =>
+ chain.calls.some((call) =>
+ call.arguments.some((argument) => {
+ const expression = unwrapExpression(argument);
+ if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
+ return expression.value.properties.some((property) => {
+ if (property.type !== "Property") return false;
+ const key = getPropertyName(property.key);
+ const value = unwrapExpression(property.value);
+ return (
+ Option.isSome(key) &&
+ key.value === "excludeTestServices" &&
+ Option.isSome(value) &&
+ value.value.type === "Literal" &&
+ value.value.value === true
+ );
+ });
+ }),
+ );
+
+// The clock a call runs on: the nearest enclosing test call decides, and an enclosing
+// `it.layer(_, { excludeTestServices: true })` block strips the TestClock from `it.effect`.
+const enclosingTester = (node: ESTree.Node): Option.Option<Tester> => {
+ let child: ESTree.Node = node;
+ let current = node.parent;
+ let tester: Option.Option<Tester> = Option.none();
+ while (current) {
+ if (
+ current.type === "CallExpression" &&
+ current.arguments.some((argument) => argument === child)
+ ) {
+ const chain = calleeChain(current.callee);
+ if (Option.isSome(chain)) {
+ if (Option.isNone(tester)) {
+ tester = testerKind(chain.value);
+ if (Option.isSome(tester) && tester.value !== "effect") return tester;
+ } else if (isLayerBlock(chain.value) && excludesTestServices(chain.value)) {
+ return Option.some("live");
+ }
+ }
+ }
+ child = current;
+ current = current.parent;
+ }
+ return tester;
+};
+
+const memberCallName = (callee: unknown): Option.Option<readonly [string, string]> => {
+ const expression = unwrapExpression(callee);
+ if (Option.isNone(expression) || expression.value.type !== "MemberExpression") {
+ return Option.none();
+ }
+ const object = unwrapExpression(expression.value.object);
+ const property = getPropertyName(expression.value.property);
+ if (Option.isNone(property) || Option.isNone(object) || object.value.type !== "Identifier") {
+ return Option.none();
+ }
+ return Option.some([object.value.name, property.value] as const);
+};
+
+const LOOPS = new Set([
+ "DoWhileStatement",
+ "ForInStatement",
+ "ForOfStatement",
+ "ForStatement",
+ "WhileStatement",
+]);
+
+// `while (...) { yield* Effect.sleep(...) }`: a statement of the loop body itself, not a
+// conditional delay inside it.
+const isPollingLoopStep = (node: ESTree.Node): boolean => {
+ let statement: ESTree.Node | null = node.parent;
+ while (statement && statement.type !== "ExpressionStatement") {
+ if (FUNCTIONS.has(statement.type)) return false;
+ statement = statement.parent;
+ }
+ const block = statement?.parent;
+ if (!block) return false;
+ if (LOOPS.has(block.type)) return true;
+ return block.type === "BlockStatement" && block.parent !== null && LOOPS.has(block.parent.type);
+};
+
+const isPromiseExecutor = (fn: ESTree.Node) => {
+ const parent = fn.parent;
+ return (
+ parent?.type === "NewExpression" &&
+ parent.arguments[0] === fn &&
+ isIdentifier(unwrapExpression(parent.callee), "Promise")
+ );
+};
+
+const isInsidePromiseExecutor = (node: ESTree.Node): boolean => {
+ let current = node.parent;
+ while (current) {
+ if (FUNCTIONS.has(current.type)) return isPromiseExecutor(current);
+ current = current.parent;
+ }
+ return false;
+};
+
+export default defineRule({
+ meta: {
+ type: "problem",
+ docs: {
+ description:
+ "Disallow real-clock waits in tests; await the milestone or drive the TestClock instead.",
+ },
+ },
+ create(context) {
+ if (!TEST_FILE_PATTERN.test(context.filename)) return {};
+ const drivesTestClock = TEST_CLOCK_DRIVER_PATTERN.test(context.sourceCode.text);
+ const timerPromiseLocals = new Set<string>();
+ const timerPromiseNamespaces = new Set<string>();
+
+ const isTimerPromiseCall = (callee: unknown) => {
+ const expression = unwrapExpression(callee);
+ if (Option.isNone(expression)) return false;
+ if (expression.value.type === "Identifier") {
+ return timerPromiseLocals.has(expression.value.name);
+ }
+ return Option.match(memberCallName(callee), {
+ onNone: () => false,
+ onSome: ([object, property]) =>
+ timerPromiseNamespaces.has(object) && TIMER_FUNCTIONS.has(property),
+ });
+ };
+
+ const isCallbackTimer = (callee: unknown) => {
+ const expression = unwrapExpression(callee);
+ return (
+ Option.isSome(expression) &&
+ expression.value.type === "Identifier" &&
+ TIMER_FUNCTIONS.has(expression.value.name) &&
+ !timerPromiseLocals.has(expression.value.name)
+ );
+ };
+
+ return {
+ ImportDeclaration(node) {
+ if (!TIMER_PROMISES_MODULES.has(node.source.value)) return;
+ for (const specifier of node.specifiers) {
+ if (specifier.type === "ImportNamespaceSpecifier") {
+ timerPromiseNamespaces.add(specifier.local.name);
+ } else if (specifier.type === "ImportSpecifier") {
+ const imported = getPropertyName(specifier.imported);
+ if (Option.isSome(imported) && TIMER_FUNCTIONS.has(imported.value)) {
+ timerPromiseLocals.add(specifier.local.name);
+ }
+ }
+ }
+ },
+ CallExpression(node) {
+ const reportFor = (message: string) => context.report({ node: node.callee, message });
+
+ if (
+ isTimerPromiseCall(node.callee) ||
+ (isCallbackTimer(node.callee) && isInsidePromiseExecutor(node))
+ ) {
+ const tester = enclosingTester(node);
+ if (Option.isNone(tester)) return;
+ reportFor(
+ tester.value === "effect"oxlint-plugin-t3code/rules/no-real-time-wait-in-tests.ts Β· right 230β232
π High rules/no-real-time-wait-in-tests.ts:230
A timer hidden in a shared helper produces no diagnostic even when a test invokes that helper, so extracting setTimeout into sleep or waitUntil bypasses this rule. enclosingTester only finds lexical test callbacks, and this early return drops the timer call when none is found; report such timer waits as real-clock waits.
No longer relevant as of e90b635be35e5ff8b2814ec4ca8304f44bb44b93
Fixed: Promise-wrapped and timers/promises timers are now reported at module level in test files too, and the existing setImmediate macrotask helpers (plus one subprocess timeout safety net) carry a reasoned disable comment.
I still see the Option.isNone(tester) early return at line 230, so module-level/shared timer calls remain unreported.
That re-review ran on 2551143, an intermediate head from the #22557 rebase. On the current head 978d35a a timer inside a new Promise executor or a timers/promises call at module level is reported (rule test "reports timers hidden in module-level helpers"); the Option.isNone(tester) branch now only defers an Effect.sleep helper until the file is known not to drive the TestClock.