# Files changed · PR \#22561 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22561/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[test\(web\): ban frame and transition waits in browser tests](https://git.cafe/pingdotgg/t3code/pull/22561.md)

Version: 21

Head commit: 85eb3371170ef9e423a1d38d1de99f35a6d6a0ac

Base commit: c925428cd27cae9711e90562e6094bc9b6401a6b

Comparison base: c925428cd27cae9711e90562e6094bc9b6401a6b

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## oxlint-plugin-t3code/index.ts

Status: modified; +2; −0

```diff
@@ -2,6 +2,7 @@
 
 import namespaceNodeImports from "./rules/namespace-node-imports.ts";
 import noDomTestEnvironment from "./rules/no-dom-test-environment.ts";
+import noFrameWaitsInBrowserTests from "./rules/no-frame-waits-in-browser-tests.ts";
 import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
 import noHermesUnsupportedApis from "./rules/no-hermes-unsupported-apis.ts";
 import noHooklessUseFunction from "./rules/no-hookless-use-function.ts";
@@ -32,6 +33,7 @@
   rules: {
     "namespace-node-imports": namespaceNodeImports,
     "no-dom-test-environment": noDomTestEnvironment,
+    "no-frame-waits-in-browser-tests": noFrameWaitsInBrowserTests,
     "no-global-process-runtime": noGlobalProcessRuntime,
     "no-hermes-unsupported-apis": noHermesUnsupportedApis,
     "no-hookless-use-function": noHooklessUseFunction,
```

## oxlint-plugin-t3code/rules/no-frame-waits-in-browser-tests.test.ts

Status: added; +96; −0

```diff
@@ -1,0 +1,96 @@
+import { describe } from "@effect/vitest";
+
+import { createOxlintRuleHarness } from "../test/utils.ts";
+
+const browserTest = createOxlintRuleHarness("t3code/no-frame-waits-in-browser-tests", {
+  filename: "Widget.browser.test.tsx",
+});
+const unitTest = createOxlintRuleHarness("t3code/no-frame-waits-in-browser-tests", {
+  filename: "widget.test.tsx",
+});
+
+describe("t3code/no-frame-waits-in-browser-tests", () => {
+  browserTest.valid(
+    "allows retrying assertions and the full fake timer set",
+    [
+      'it("debounces", async () => {',
+      "  await using screen = await render(null);",
+      "  using _timers = fakeTimers();",
+      "  vi.advanceTimersByTime(200);",
+      '  await expect.element(screen.getByRole("button")).toBeVisible();',
+      "  await vi.waitFor(() => expect(onSave).toHaveBeenCalled());",
+      "});",
+    ].join("\n"),
+  );
+  browserTest.valid(
+    "allows requestAnimationFrame inside mocks",
+    [
+      'vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {',
+      "  callback(0);",
+      "  return 0;",
+      "});",
+      "const frame = vi.fn((callback) => requestAnimationFrame(callback));",
+      'vi.stubGlobal("requestAnimationFrame", (callback) => window.requestAnimationFrame(callback));',
+      'stubGlobal("requestAnimationFrame", (callback) => requestAnimationFrame(callback));',
+    ].join("\n"),
+  );
+  browserTest.valid(
+    "allows timers outside a promise executor and other listeners",
+    [
+      "const id = setTimeout(() => undefined, 10);",
+      'panel.addEventListener("click", () => undefined);',
+      "const deferred = new Promise((resolve) => (finish = resolve));",
+    ].join("\n"),
+  );
+
+  browserTest.invalid(
+    "rejects a nextFrame helper",
+    "const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));",
+  );
+  browserTest.invalid(
+    "rejects requestAnimationFrame as the executor",
+    "await new Promise(requestAnimationFrame);",
+  );
+  browserTest.invalid(
+    "rejects window.requestAnimationFrame",
+    "window.requestAnimationFrame(() => undefined);",
+  );
+  browserTest.valid(
+    "allows a pass-through frame executor inside a mock implementation",
+    "const paint = vi.fn(() => new Promise(requestAnimationFrame));",
+  );
+  browserTest.invalid(
+    "rejects setTimeout passed straight through as the promise executor",
+    "await new Promise(setTimeout);",
+  );
+  browserTest.invalid(
+    "rejects setTimeout inside a promise executor",
+    "await new Promise((resolve) => setTimeout(resolve, 50));",
+  );
+  browserTest.invalid(
+    "rejects setInterval inside a promise executor",
+    "await new Promise(function (resolve) { window.setInterval(resolve, 50); });",
+  );
+  browserTest.invalid(
+    "rejects transitionend listeners",
+    'panel.addEventListener("transitionend", () => resolve(), { once: true });',
+  );
+  browserTest.invalid(
+    "rejects animationend listeners",
+    'panel.addEventListener("animationend", () => resolve());',
+  );
+  browserTest.invalid("rejects vi.useFakeTimers", "vi.useFakeTimers();");
+  browserTest.invalid(
+    "rejects a partial fakeTimers set",
+    'const timers = fakeTimers({ toFake: ["setTimeout", "clearTimeout"] });',
+  );
+  browserTest.invalid(
+    "rejects a partial vi.useFakeTimers set",
+    'vi.useFakeTimers({ toFake: ["setTimeout"] });',
+  );
+
+  unitTest.valid(
+    "ignores files that are not browser tests",
+    ["vi.useFakeTimers();", "await new Promise(requestAnimationFrame);"].join("\n"),
+  );
+});
```

## oxlint-plugin-t3code/rules/no-frame-waits-in-browser-tests.ts

Status: added; +150; −0

```diff
@@ -1,0 +1,150 @@
+import { defineRule, type ESTree } from "@oxlint/plugins";
+import * as Option from "effect/Option";
+
+import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
+
+const BROWSER_TEST_FILE_PATTERN = /\.browser\.test\.[cm]?[jt]sx?$/u;
+const GLOBAL_OBJECTS = new Set(["globalThis", "self", "window"]);
+const TIMER_FUNCTIONS = new Set(["setInterval", "setTimeout"]);
+const TRANSITION_EVENTS = new Set(["animationend", "transitionend"]);
+const MOCK_FACTORIES = new Set(["fn", "spyOn", "stubGlobal"]);
+const MOCK_IMPLEMENTATIONS = new Set(["mockImplementation", "mockImplementationOnce"]);
+const USE_FAKE_TIMERS = new Set(["useFakeTimers"]);
+const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression"]);
+
+const MESSAGE =
+  "Frame and transition waits race the renderer. Assert with `expect.element`/`vi.waitFor` (auto-retry), or install `fakeTimers()` (full set) and advance the timer the component sets.";
+
+/** Name of a bare global (`requestAnimationFrame`) or one read off `window`/`globalThis`/`self`. */
+const globalName = (node: unknown): Option.Option<string> => {
+  const expression = unwrapExpression(node);
+  if (Option.isNone(expression)) return Option.none();
+  if (expression.value.type === "Identifier") return Option.some(expression.value.name);
+  if (expression.value.type !== "MemberExpression") return Option.none();
+  const object = unwrapExpression(expression.value.object);
+  if (Option.isNone(object) || object.value.type !== "Identifier") return Option.none();
+  if (!GLOBAL_OBJECTS.has(object.value.name)) return Option.none();
+  return getPropertyName(expression.value.property);
+};
+
+const isViCall = (callee: unknown, names: ReadonlySet<string>): boolean => {
+  const expression = unwrapExpression(callee);
+  if (Option.isNone(expression) || expression.value.type !== "MemberExpression") return false;
+  const property = getPropertyName(expression.value.property);
+  return (
+    isIdentifier(unwrapExpression(expression.value.object), "vi") &&
+    Option.isSome(property) &&
+    names.has(property.value)
+  );
+};
+
+const isMockingCall = (node: ESTree.CallExpression): boolean => {
+  if (isViCall(node.callee, MOCK_FACTORIES)) return true;
+  const callee = unwrapExpression(node.callee);
+  if (isIdentifier(callee, "stubGlobal")) return true;
+  if (Option.isNone(callee) || callee.value.type !== "MemberExpression") return false;
+  const property = getPropertyName(callee.value.property);
+  return Option.isSome(property) && MOCK_IMPLEMENTATIONS.has(property.value);
+};
+
+const EXECUTOR_WAITS = new Set(["requestAnimationFrame", "setTimeout", "setInterval"]);
+
+const isPromiseConstructor = (node: ESTree.Node | null | undefined): node is ESTree.NewExpression =>
+  node?.type === "NewExpression" && isIdentifier(unwrapExpression(node.callee), "Promise");
+
+// A frame call inside a mock is the fake's implementation, not the test waiting on a frame.
+const isInsideMock = (node: ESTree.Node): boolean => {
+  let child: ESTree.Node = node;
+  let current = node.parent;
+  while (current) {
+    if (current.type === "CallExpression" && current.callee !== child && isMockingCall(current)) {
+      return true;
+    }
+    child = current;
+    current = current.parent;
+  }
+  return false;
+};
+
+const isInsidePromiseExecutor = (node: ESTree.Node): boolean => {
+  let current = node.parent;
+  while (current) {
+    if (
+      FUNCTIONS.has(current.type) &&
+      isPromiseConstructor(current.parent) &&
+      current.parent.arguments[0] === current
+    ) {
+      return true;
+    }
+    current = current.parent;
+  }
+  return false;
+};
+
+const hasToFakeOption = (argument: unknown): boolean => {
+  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);
+    return Option.isSome(key) && key.value === "toFake";
+  });
+};
+
+const isTransitionEventListener = (node: ESTree.CallExpression): boolean => {
+  const callee = unwrapExpression(node.callee);
+  if (Option.isNone(callee) || callee.value.type !== "MemberExpression") return false;
+  const method = getPropertyName(callee.value.property);
+  if (Option.isNone(method) || method.value !== "addEventListener") return false;
+  const eventName = unwrapExpression(node.arguments[0]);
+  return (
+    Option.isSome(eventName) &&
+    eventName.value.type === "Literal" &&
+    typeof eventName.value.value === "string" &&
+    TRANSITION_EVENTS.has(eventName.value.value)
+  );
+};
+
+const isFrameOrTimerWait = (node: ESTree.CallExpression): boolean => {
+  const name = globalName(node.callee);
+  if (Option.isNone(name)) return false;
+  if (name.value === "requestAnimationFrame") return !isInsideMock(node);
+  if (TIMER_FUNCTIONS.has(name.value)) return isInsidePromiseExecutor(node);
+  if (name.value === "fakeTimers") return hasToFakeOption(node.arguments[0]);
+  return false;
+};
+
+export default defineRule({
+  meta: {
+    type: "problem",
+    docs: {
+      description:
+        "Disallow waiting on animation frames, transitions, or real timers in browser tests, and partial fake-timer sets.",
+    },
+  },
+  create(context) {
+    if (!BROWSER_TEST_FILE_PATTERN.test(context.filename)) return {};
+
+    return {
+      NewExpression(node) {
+        if (!isPromiseConstructor(node) || isInsideMock(node)) return;
+        const executor = node.arguments[0];
+        // `new Promise(setTimeout)` resolves on the next macrotask; `new Promise(requestAnimationFrame)`
+        // on the next frame. Both are the same wait with the function passed straight through.
+        const name = globalName(executor);
+        if (executor && Option.isSome(name) && EXECUTOR_WAITS.has(name.value)) {
+          context.report({ node: executor, message: MESSAGE });
+        }
+      },
+      CallExpression(node) {
+        if (
+          isFrameOrTimerWait(node) ||
+          isViCall(node.callee, USE_FAKE_TIMERS) ||
+          isTransitionEventListener(node)
+        ) {
+          context.report({ node: node.callee, message: MESSAGE });
+        }
+      },
+    };
+  },
+});
```

## vite.config.ts

Status: modified; +4; −0

```diff
@@ -212,6 +212,10 @@
         rules: { "t3code/require-disposable-render": "error" },
       },
       {
+        files: ["apps/web/src/**/*.browser.test.{ts,tsx}"],
+        rules: { "t3code/no-frame-waits-in-browser-tests": "error" },
+      },
+      {
         files: ["apps/**/src/**", "packages/**/src/**"],
         excludeFiles: DOM_TEST_ENVIRONMENT_ALLOWLIST,
         rules: { "t3code/no-dom-test-environment": "error" },
```
