Skip to content
Source
Sign inSign up

no-frame-waits-in-browser-tests.ts

150 lines6.0 KB

1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3​
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5​
6const BROWSER_TEST_FILE_PATTERN = /\.browser\.test\.[cm]?[jt]sx?$/u;
7const GLOBAL_OBJECTS = new Set(["globalThis", "self", "window"]);
8const TIMER_FUNCTIONS = new Set(["setInterval", "setTimeout"]);
9const TRANSITION_EVENTS = new Set(["animationend", "transitionend"]);
10const MOCK_FACTORIES = new Set(["fn", "spyOn", "stubGlobal"]);
11const MOCK_IMPLEMENTATIONS = new Set(["mockImplementation", "mockImplementationOnce"]);
12const USE_FAKE_TIMERS = new Set(["useFakeTimers"]);
13const FUNCTIONS = new Set(["ArrowFunctionExpression", "FunctionExpression"]);
14​
15const MESSAGE =
16 "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.";
17​
18/** Name of a bare global (`requestAnimationFrame`) or one read off `window`/`globalThis`/`self`. */
19const globalName = (node: unknown): Option.Option<string> => {
20 const expression = unwrapExpression(node);
21 if (Option.isNone(expression)) return Option.none();
22 if (expression.value.type === "Identifier") return Option.some(expression.value.name);
23 if (expression.value.type !== "MemberExpression") return Option.none();
24 const object = unwrapExpression(expression.value.object);
25 if (Option.isNone(object) || object.value.type !== "Identifier") return Option.none();
26 if (!GLOBAL_OBJECTS.has(object.value.name)) return Option.none();
27 return getPropertyName(expression.value.property);
28};
29​
30const isViCall = (callee: unknown, names: ReadonlySet<string>): boolean => {
31 const expression = unwrapExpression(callee);
32 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") return false;
33 const property = getPropertyName(expression.value.property);
34 return (
35 isIdentifier(unwrapExpression(expression.value.object), "vi") &&
36 Option.isSome(property) &&
37 names.has(property.value)
38 );
39};
40​
41const isMockingCall = (node: ESTree.CallExpression): boolean => {
42 if (isViCall(node.callee, MOCK_FACTORIES)) return true;
43 const callee = unwrapExpression(node.callee);
44 if (isIdentifier(callee, "stubGlobal")) return true;
45 if (Option.isNone(callee) || callee.value.type !== "MemberExpression") return false;
46 const property = getPropertyName(callee.value.property);
47 return Option.isSome(property) && MOCK_IMPLEMENTATIONS.has(property.value);
48};
49​
50const EXECUTOR_WAITS = new Set(["requestAnimationFrame", "setTimeout", "setInterval"]);
51​
52const isPromiseConstructor = (node: ESTree.Node | null | undefined): node is ESTree.NewExpression =>
53 node?.type === "NewExpression" && isIdentifier(unwrapExpression(node.callee), "Promise");
54​
55// A frame call inside a mock is the fake's implementation, not the test waiting on a frame.
56const isInsideMock = (node: ESTree.Node): boolean => {
57 let child: ESTree.Node = node;
58 let current = node.parent;
59 while (current) {
60 if (current.type === "CallExpression" && current.callee !== child && isMockingCall(current)) {
61 return true;
62 }
63 child = current;
64 current = current.parent;
65 }
66 return false;
67};
68​
69const isInsidePromiseExecutor = (node: ESTree.Node): boolean => {
70 let current = node.parent;
71 while (current) {
72 if (
73 FUNCTIONS.has(current.type) &&
74 isPromiseConstructor(current.parent) &&
75 current.parent.arguments[0] === current
76 ) {
77 return true;
78 }
79 current = current.parent;
80 }
81 return false;
82};
83​
84const hasToFakeOption = (argument: unknown): boolean => {
85 const expression = unwrapExpression(argument);
86 if (Option.isNone(expression) || expression.value.type !== "ObjectExpression") return false;
87 return expression.value.properties.some((property) => {
88 if (property.type !== "Property") return false;
89 const key = getPropertyName(property.key);
90 return Option.isSome(key) && key.value === "toFake";
91 });
92};
93​
94const isTransitionEventListener = (node: ESTree.CallExpression): boolean => {
95 const callee = unwrapExpression(node.callee);
96 if (Option.isNone(callee) || callee.value.type !== "MemberExpression") return false;
97 const method = getPropertyName(callee.value.property);
98 if (Option.isNone(method) || method.value !== "addEventListener") return false;
99 const eventName = unwrapExpression(node.arguments[0]);
100 return (
101 Option.isSome(eventName) &&
102 eventName.value.type === "Literal" &&
103 typeof eventName.value.value === "string" &&
104 TRANSITION_EVENTS.has(eventName.value.value)
105 );
106};
107​
108const isFrameOrTimerWait = (node: ESTree.CallExpression): boolean => {
109 const name = globalName(node.callee);
110 if (Option.isNone(name)) return false;
111 if (name.value === "requestAnimationFrame") return !isInsideMock(node);
112 if (TIMER_FUNCTIONS.has(name.value)) return isInsidePromiseExecutor(node);
113 if (name.value === "fakeTimers") return hasToFakeOption(node.arguments[0]);
114 return false;
115};
116​
117export default defineRule({
118 meta: {
119 type: "problem",
120 docs: {
121 description:
122 "Disallow waiting on animation frames, transitions, or real timers in browser tests, and partial fake-timer sets.",
123 },
124 },
125 create(context) {
126 if (!BROWSER_TEST_FILE_PATTERN.test(context.filename)) return {};
127​
128 return {
129 NewExpression(node) {
130 if (!isPromiseConstructor(node) || isInsideMock(node)) return;
131 const executor = node.arguments[0];
132 // `new Promise(setTimeout)` resolves on the next macrotask; `new Promise(requestAnimationFrame)`
133 // on the next frame. Both are the same wait with the function passed straight through.
134 const name = globalName(executor);
135 if (executor && Option.isSome(name) && EXECUTOR_WAITS.has(name.value)) {
136 context.report({ node: executor, message: MESSAGE });
137 }
138 },
139 CallExpression(node) {
140 if (
141 isFrameOrTimerWait(node) ||
142 isViCall(node.callee, USE_FAKE_TIMERS) ||
143 isTransitionEventListener(node)
144 ) {
145 context.report({ node: node.callee, message: MESSAGE });
146 }
147 },
148 };
149 },
150});