Skip to content

Commit b6f8e0c

Browse files
committed
chore: refactor tree-walking to respect function scope boundaries
1 parent 1d678b2 commit b6f8e0c

1 file changed

Lines changed: 54 additions & 80 deletions

File tree

src/rules/no-unsettled-absence-query.ts

Lines changed: 54 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isArrowFunctionExpression,
99
isBlockStatement,
1010
isCallExpression,
11+
isFunctionDeclaration,
1112
isFunctionExpression,
1213
isMemberExpression,
1314
} from '../node-utils';
@@ -18,10 +19,54 @@ const RULE_NAME = 'no-unsettled-absence-query';
1819
export type MessageIds = 'noUnsettledAbsenceQuery';
1920
export type Options = [];
2021

21-
// Matchers that indicate absence when negated, beyond those already
22-
// covered by helpers.isAbsenceAssert() (which handles PRESENCE_MATCHERS).
2322
const NEGATED_ABSENCE_MATCHERS = ['toBeVisible'];
2423

24+
function isNestedFunction(node: TSESTree.Node): boolean {
25+
return (
26+
isArrowFunctionExpression(node) ||
27+
isFunctionExpression(node) ||
28+
isFunctionDeclaration(node)
29+
);
30+
}
31+
32+
function containsNode(
33+
node: TSESTree.Node,
34+
predicate: (n: TSESTree.Node) => boolean
35+
): boolean {
36+
if (predicate(node)) {
37+
return true;
38+
}
39+
40+
if (isNestedFunction(node)) {
41+
return false;
42+
}
43+
44+
for (const key of Object.keys(node)) {
45+
if (key === 'parent') continue;
46+
const child = (node as unknown as Record<string, unknown>)[key];
47+
if (child && typeof child === 'object') {
48+
if (Array.isArray(child)) {
49+
for (const item of child) {
50+
if (
51+
item &&
52+
typeof item === 'object' &&
53+
'type' in item &&
54+
containsNode(item as TSESTree.Node, predicate)
55+
) {
56+
return true;
57+
}
58+
}
59+
} else if (
60+
'type' in child &&
61+
containsNode(child as TSESTree.Node, predicate)
62+
) {
63+
return true;
64+
}
65+
}
66+
}
67+
return false;
68+
}
69+
2570
export default createTestingLibraryRule<Options, MessageIds>({
2671
name: RULE_NAME,
2772
meta: {
@@ -62,12 +107,6 @@ export default createTestingLibraryRule<Options, MessageIds>({
62107
);
63108
}
64109

65-
/**
66-
* Determines whether a node is inside a callback passed to an async
67-
* Testing Library utility (e.g. waitFor). Absence assertions inside
68-
* these callbacks are always flagged because they can pass on the first
69-
* invocation before the component has settled.
70-
*/
71110
function isInsideAsyncUtilCallback(node: TSESTree.Node): boolean {
72111
let current: TSESTree.Node | undefined = node.parent;
73112

@@ -121,82 +160,23 @@ export default createTestingLibraryRule<Options, MessageIds>({
121160
return null;
122161
}
123162

124-
function containsAwaitExpression(node: TSESTree.Node): boolean {
125-
if (ASTUtils.isAwaitExpression(node)) {
126-
return true;
127-
}
128-
129-
for (const key of Object.keys(node)) {
130-
if (key === 'parent') continue;
131-
const child = (node as unknown as Record<string, unknown>)[key];
132-
if (child && typeof child === 'object') {
133-
if (Array.isArray(child)) {
134-
for (const item of child) {
135-
if (
136-
item &&
137-
typeof item === 'object' &&
138-
'type' in item &&
139-
containsAwaitExpression(item as TSESTree.Node)
140-
) {
141-
return true;
142-
}
143-
}
144-
} else if (
145-
'type' in child &&
146-
containsAwaitExpression(child as TSESTree.Node)
147-
) {
148-
return true;
149-
}
150-
}
151-
}
152-
return false;
153-
}
154-
155-
function containsGetQueryCall(node: TSESTree.Node): boolean {
156-
if (ASTUtils.isIdentifier(node) && helpers.isGetQueryVariant(node)) {
157-
return true;
158-
}
159-
160-
for (const key of Object.keys(node)) {
161-
if (key === 'parent') continue;
162-
const child = (node as unknown as Record<string, unknown>)[key];
163-
if (child && typeof child === 'object') {
164-
if (Array.isArray(child)) {
165-
for (const item of child) {
166-
if (
167-
item &&
168-
typeof item === 'object' &&
169-
'type' in item &&
170-
containsGetQueryCall(item as TSESTree.Node)
171-
) {
172-
return true;
173-
}
174-
}
175-
} else if (
176-
'type' in child &&
177-
containsGetQueryCall(child as TSESTree.Node)
178-
) {
179-
return true;
180-
}
181-
}
182-
}
183-
return false;
184-
}
185-
186163
function hasSettlingExpression(statement: TSESTree.Statement): boolean {
187-
return (
188-
containsAwaitExpression(statement) || containsGetQueryCall(statement)
164+
const hasAwait = containsNode(statement, (n) =>
165+
ASTUtils.isAwaitExpression(n)
166+
);
167+
const hasGetQuery = containsNode(
168+
statement,
169+
(n) => ASTUtils.isIdentifier(n) && helpers.isGetQueryVariant(n)
189170
);
171+
return hasAwait || hasGetQuery;
190172
}
191173

192174
return {
193175
'CallExpression Identifier'(node: TSESTree.Identifier) {
194-
// Only interested in queryBy* / queryAllBy* variants
195176
if (!helpers.isQueryQueryVariant(node)) {
196177
return;
197178
}
198179

199-
// Must be inside an expect() call
200180
const expectCallNode = findClosestCallNode(node, 'expect');
201181
if (
202182
!expectCallNode?.parent ||
@@ -205,14 +185,10 @@ export default createTestingLibraryRule<Options, MessageIds>({
205185
return;
206186
}
207187

208-
// Must be an absence assertion
209188
if (!isAbsenceAssertion(expectCallNode.parent)) {
210189
return;
211190
}
212191

213-
// Absence assertions inside async util callbacks (e.g. waitFor) are
214-
// always flagged - they pass on the first invocation before the
215-
// component has settled.
216192
if (isInsideAsyncUtilCallback(node)) {
217193
context.report({
218194
node,
@@ -222,8 +198,6 @@ export default createTestingLibraryRule<Options, MessageIds>({
222198
return;
223199
}
224200

225-
// Find the enclosing function body and determine whether a settling
226-
// expression appears on any preceding statement.
227201
const functionBody = findEnclosingFunctionBody(node);
228202
if (!functionBody) {
229203
return;

0 commit comments

Comments
 (0)