1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, unwrapExpression } from "../utils.ts";
5
6const BROWSER_TEST_FILE = /\.browser\.test\.[cm]?[jt]sx?$/u;
7const LIFECYCLE_HOOKS = new Set(["afterAll", "afterEach", "beforeAll", "beforeEach"]);
8const TEST_RUNNERS = new Set(["@effect/vitest", "vite-plus/test", "vitest"]);
9const REACT_ACT_EXPORTS = new Set(["act", "unstable_act"]);
10const ACT_ENVIRONMENT = "IS_REACT_ACT_ENVIRONMENT";
11const RENDER_HELPER = /(?:^|\/)test\/render(?:\.tsx)?$/u;
12const LOCATOR_QUERY = /^getBy/u;
13// Locator methods that dispatch real input through the browser provider.
14const LOCATOR_INPUT = new Set([
15 "clear",
16 "click",
17 "dblClick",
18 "dropTo",
19 "fill",
20 "hover",
21 "selectOptions",
22 "tripleClick",
23 "unhover",
24 "upload",
25]);
26// These leave the locator for a DOM element, whose click() is synthetic and fine inside act.
27const LOCATOR_EXITS = new Set(["element", "elements", "query"]);
28const HELPER = "Render with `await using screen = await render(...)` from apps/web/test/render.tsx";
29
30const MESSAGES = {
31 reactDomClient: `${HELPER}; it owns the React root and unmounts it on dispose.`,
32 reactAct: `${HELPER} and import \`act\` from there; it enables React's act environment only while act runs.`,
33 actEnvironment: `${HELPER}; its act() sets ${ACT_ENVIRONMENT} for exactly as long as it runs.`,
34 actAroundInput:
35 "Do not wrap real input (userEvent or a locator's click/fill) in act; the browser runner flushes React updates after real input. Await the input directly.",
36 lifecycleHook:
37 "Browser tests acquire what they need with `using`/`await using` instead of lifecycle hooks, so nothing outlives the test that created it.",
38};
39
40const stringValue = (node: ESTree.Node | null | undefined) =>
41 node?.type === "Literal" && typeof node.value === "string" ? node.value : undefined;
42
43const importedName = (specifier: ESTree.ImportSpecifier) =>
44 specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
45
46const isFunction = (node: ESTree.Node) =>
47 node.type === "ArrowFunctionExpression" ||
48 node.type === "FunctionExpression" ||
49 node.type === "FunctionDeclaration";
50
51// Walks a.b().c().d back to its root, reporting whether a getBy* query built a locator on the way
52// without leaving it through element()/query().
53const isLocatorChain = (node: ESTree.Node): boolean => {
54 let sawQuery = false;
55 for (let current: ESTree.Node | undefined = node; current;) {
56 if (current.type === "CallExpression") {
57 current = current.callee;
58 continue;
59 }
60 if (current.type !== "MemberExpression") break;
61 const name = Option.getOrUndefined(getPropertyName(current.property));
62 if (name !== undefined && LOCATOR_EXITS.has(name)) return false;
63 if (name !== undefined && LOCATOR_QUERY.test(name)) sawQuery = true;
64 current = current.object;
65 }
66 return sawQuery;
67};
68
69const namesActEnvironment = (node: unknown) => {
70 const target = unwrapExpression(node);
71 if (Option.isNone(target)) return false;
72 if (target.value.type === "Identifier") return target.value.name === ACT_ENVIRONMENT;
73 if (target.value.type !== "MemberExpression") return false;
74 const property = target.value.computed
75 ? Option.fromNullishOr(stringValue(target.value.property))
76 : getPropertyName(target.value.property);
77 return Option.getOrUndefined(property) === ACT_ENVIRONMENT;
78};
79
80export default defineRule({
81 meta: {
82 type: "problem",
83 docs: {
84 description:
85 "Require web component tests to render through the disposable test helper instead of managing React roots, act, or lifecycle hooks by hand.",
86 },
87 },
88 create(context) {
89 const isBrowserTest = BROWSER_TEST_FILE.test(context.filename);
90 // Local names bound to React's act or the render helper's act.
91 const actNames = new Set<string>();
92 const reactNamespaces = new Set<string>();
93 const runnerNamespaces = new Set<string>();
94 const userEventNames = new Set<string>();
95
96 const namespaceMember = (node: ESTree.Node, namespaces: ReadonlySet<string>) =>
97 node.type === "MemberExpression" &&
98 node.object.type === "Identifier" &&
99 namespaces.has(node.object.name)
100 ? Option.getOrUndefined(getPropertyName(node.property))
101 : undefined;
102
103 const isReactNamespaceAct = (callee: ESTree.Node) => {
104 const member = namespaceMember(callee, reactNamespaces);
105 return member !== undefined && REACT_ACT_EXPORTS.has(member);
106 };
107
108 const isActCall = (node: ESTree.Node) =>
109 node.type === "CallExpression" &&
110 ((node.callee.type === "Identifier" && actNames.has(node.callee.name)) ||
111 isReactNamespaceAct(node.callee));
112
113 // A function defined inside act but not passed straight to a call may run after act settles.
114 const isInsideAct = (node: ESTree.Node) => {
115 for (let current = node.parent; current; current = current.parent) {
116 if (isActCall(current)) return true;
117 if (
118 isFunction(current) &&
119 !(
120 current.parent?.type === "CallExpression" &&
121 (current.parent.arguments as ReadonlyArray<ESTree.Node>).includes(current)
122 )
123 ) {
124 return false;
125 }
126 }
127 return false;
128 };
129
130 const isRealInput = (callee: ESTree.MemberExpression) => {
131 if (callee.object.type === "Identifier" && userEventNames.has(callee.object.name)) {
132 return true;
133 }
134 const method = Option.getOrUndefined(getPropertyName(callee.property));
135 return method !== undefined && LOCATOR_INPUT.has(method) && isLocatorChain(callee.object);
136 };
137
138 return {
139 ImportDeclaration(node) {
140 for (const specifier of node.specifiers) {
141 if (specifier.type === "ImportSpecifier" && importedName(specifier) === "userEvent") {
142 userEventNames.add(specifier.local.name);
143 }
144 }
145 if (isBrowserTest && TEST_RUNNERS.has(node.source.value)) {
146 for (const specifier of node.specifiers) {
147 if (specifier.type === "ImportNamespaceSpecifier") {
148 runnerNamespaces.add(specifier.local.name);
149 continue;
150 }
151 if (specifier.type !== "ImportSpecifier") continue;
152 if (LIFECYCLE_HOOKS.has(importedName(specifier))) {
153 context.report({ node: specifier, message: MESSAGES.lifecycleHook });
154 }
155 }
156 return;
157 }
158 if (RENDER_HELPER.test(node.source.value)) {
159 for (const specifier of node.specifiers) {
160 if (specifier.type === "ImportSpecifier" && importedName(specifier) === "act") {
161 actNames.add(specifier.local.name);
162 }
163 }
164 return;
165 }
166 if (node.source.value === "react-dom/client") {
167 context.report({ node: node.source, message: MESSAGES.reactDomClient });
168 return;
169 }
170 if (node.source.value !== "react") return;
171 for (const specifier of node.specifiers) {
172 if (specifier.type !== "ImportSpecifier") {
173 reactNamespaces.add(specifier.local.name);
174 continue;
175 }
176 if (REACT_ACT_EXPORTS.has(importedName(specifier))) {
177 actNames.add(specifier.local.name);
178 context.report({ node: specifier, message: MESSAGES.reactAct });
179 }
180 }
181 },
182 AssignmentExpression(node) {
183 if (namesActEnvironment(node.left)) {
184 context.report({ node: node.left, message: MESSAGES.actEnvironment });
185 }
186 },
187 CallExpression(node) {
188 const callee = unwrapExpression(node.callee);
189 if (Option.isNone(callee)) return;
190 if (callee.value.type === "MemberExpression") {
191 // vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true) leaves the flag on for the whole test.
192 const method = getPropertyName(callee.value.property);
193 if (isReactNamespaceAct(callee.value)) {
194 context.report({ node: node.callee, message: MESSAGES.reactAct });
195 return;
196 }
197 if (isBrowserTest) {
198 const hook = namespaceMember(callee.value, runnerNamespaces);
199 if (hook !== undefined && LIFECYCLE_HOOKS.has(hook)) {
200 context.report({ node: node.callee, message: MESSAGES.lifecycleHook });
201 return;
202 }
203 }
204 if (isRealInput(callee.value) && isInsideAct(node)) {
205 context.report({ node, message: MESSAGES.actAroundInput });
206 return;
207 }
208 if (
209 Option.getOrUndefined(method) === "stubGlobal" &&
210 stringValue(node.arguments[0] as ESTree.Node | undefined) === ACT_ENVIRONMENT
211 ) {
212 context.report({ node, message: MESSAGES.actEnvironment });
213 }
214 return;
215 }
216 if (
217 isBrowserTest &&
218 callee.value.type === "Identifier" &&
219 LIFECYCLE_HOOKS.has(callee.value.name)
220 ) {
221 context.report({ node: node.callee, message: MESSAGES.lifecycleHook });
222 }
223 },
224 };
225 },
226});