Skip to content

Commit 1324841

Browse files
committed
fix: harden react native overlay dismissal
1 parent 096785b commit 1324841

9 files changed

Lines changed: 662 additions & 139 deletions

File tree

src/__tests__/runtime-snapshot.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,5 +446,5 @@ function assertReactNativeOverlayWarning(warnings: string[] | undefined) {
446446
assert.equal(warnings?.length, 1);
447447
assert.match(warnings[0] ?? '', /Hint: React Native warning\/error overlay detected/);
448448
assert.match(warnings[0] ?? '', /agent-device react-native dismiss-overlay/);
449-
assert.match(warnings[0] ?? '', /agent-device snapshot -i -c/);
449+
assert.match(warnings[0] ?? '', /verifies the overlay is gone/);
450450
}
Lines changed: 194 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
import { centerOfRect, type Point, type SnapshotNode } from '../../utils/snapshot.ts';
21
import {
3-
hasKnownReactNativeOverlayText,
4-
isReactNativeCollapsedWarningLabel,
5-
isReactNativeOpenDebuggerWarningLabel,
6-
isReactNativeStackFrame,
7-
} from '../../utils/react-native-overlay-signals.ts';
2+
centerOfRect,
3+
type Point,
4+
type RawSnapshotNode,
5+
type Rect,
6+
type SnapshotNode,
7+
} from '../../utils/snapshot.ts';
8+
9+
type ReactNativeOverlayNode = Pick<
10+
RawSnapshotNode,
11+
'index' | 'type' | 'role' | 'subrole' | 'label' | 'value' | 'identifier' | 'rect' | 'hittable'
12+
>;
813

914
export type ReactNativeOverlayState = {
1015
detected: boolean;
@@ -15,35 +20,84 @@ export type ReactNativeOverlayState = {
1520
dismissNodes: SnapshotNode[];
1621
minimizeNodes: SnapshotNode[];
1722
collapsedNodes: SnapshotNode[];
23+
safeActions: ReactNativeOverlayDismissTarget[];
24+
primaryAction: ReactNativeOverlayDismissTarget | null;
1825
};
1926

2027
export type ReactNativeOverlayDismissTarget = {
2128
action: 'close' | 'dismiss' | 'minimize' | 'close-collapsed-banner';
2229
point: Point;
30+
rect?: Rect;
2331
ref?: string;
2432
label?: string;
2533
warning?: string;
2634
};
2735

36+
type ReactNativeOverlayFacts = {
37+
dismissNodes: SnapshotNode[];
38+
minimizeNodes: SnapshotNode[];
39+
collapsedNodes: SnapshotNode[];
40+
redBox: boolean;
41+
detected: boolean;
42+
};
43+
2844
export function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string | undefined {
29-
const overlay = detectReactNativeOverlay(nodes);
45+
const overlay = analyzeReactNativeOverlay(nodes);
3046
if (!overlay.detected) return undefined;
3147
return [
3248
'Hint: React Native warning/error overlay detected. It overlays part of the app and should be handled before interacting.',
3349
'Run: agent-device react-native dismiss-overlay',
34-
'Then run: agent-device snapshot -i -c',
35-
'Use refs from the new snapshot.',
50+
'The command verifies the overlay is gone. Run agent-device snapshot -i -c afterward only when you need fresh refs for the next action.',
3651
].join('\n');
3752
}
3853

3954
export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState {
40-
const text = nodes
41-
.map((node) =>
42-
[node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' '),
43-
)
44-
.join('\n')
45-
.toLowerCase();
55+
return analyzeReactNativeOverlay(nodes);
56+
}
4657

58+
export function analyzeReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState {
59+
const facts = collectReactNativeOverlayFacts(nodes);
60+
const safeActions = facts.detected ? buildSafeDismissActions(facts) : [];
61+
const dismissRefs = refsOf(facts.dismissNodes);
62+
const minimizeRefs = refsOf(facts.minimizeNodes);
63+
const collapsedRefs = refsOf(facts.collapsedNodes);
64+
65+
return {
66+
detected: facts.detected,
67+
redBox: facts.redBox,
68+
dismissRefs,
69+
minimizeRefs,
70+
collapsedRefs,
71+
dismissNodes: facts.dismissNodes,
72+
minimizeNodes: facts.minimizeNodes,
73+
collapsedNodes: facts.collapsedNodes,
74+
safeActions,
75+
primaryAction: safeActions[0] ?? null,
76+
};
77+
}
78+
79+
export function resolveReactNativeOverlayDismissTarget(
80+
nodes: SnapshotNode[],
81+
): ReactNativeOverlayDismissTarget | null {
82+
return analyzeReactNativeOverlay(nodes).primaryAction;
83+
}
84+
85+
export function isReactNativeCollapsedWarningWrapperWithVisibleBanner(
86+
node: ReactNativeOverlayNode,
87+
descendants: ReactNativeOverlayNode[],
88+
): boolean {
89+
const nodeLabel = node.label?.trim();
90+
if (!isReactNativeCollapsedWarningLabel(nodeLabel) || !isFullScreenOverlayRect(node.rect)) {
91+
return false;
92+
}
93+
return descendants.some(
94+
(descendant) =>
95+
descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant),
96+
);
97+
}
98+
99+
function collectReactNativeOverlayFacts(nodes: SnapshotNode[]): ReactNativeOverlayFacts {
100+
const text = nodes.map(formatNodeSearchText).join('\n').toLowerCase();
47101
const dismissNodes = collectOverlayNodes(nodes, isDismissControlLabel);
48102
const minimizeNodes = collectOverlayNodes(nodes, isMinimizeLabel);
49103
const collapsedNodes = collectOverlayNodes(
@@ -55,82 +109,143 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver
55109
nodes,
56110
isReactNativeOpenDebuggerWarningLabel,
57111
);
58-
const dismissRefs = refsOf(dismissNodes);
59-
const minimizeRefs = refsOf(minimizeNodes);
60-
const collapsedRefs = refsOf(collapsedNodes);
61112
const hasReactNativeStackFrame = isReactNativeStackFrame(text);
62113
const hasControllessRedBoxText =
63114
/\buncaught\b/.test(text) && /unable to download asset/.test(text);
64115
const hasOverlayControl =
65-
dismissRefs.length > 0 || minimizeRefs.length > 0 || /\b(reload js|copy stack)\b/.test(text);
116+
dismissNodes.length > 0 || minimizeNodes.length > 0 || /\b(reload js|copy stack)\b/.test(text);
66117
const redBox =
67118
/\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/.test(text) ||
68119
hasControllessRedBoxText ||
69120
(hasReactNativeStackFrame && hasOverlayControl);
70121
const detected =
71-
collapsedRefs.length > 0 ||
122+
collapsedNodes.length > 0 ||
72123
openDebuggerWarningNodes.length > 0 ||
73124
hasControllessRedBoxText ||
74125
(hasOverlayControl && (hasKnownReactNativeOverlayText(text) || hasReactNativeStackFrame));
75126
return {
76-
detected,
77-
redBox,
78-
dismissRefs,
79-
minimizeRefs,
80-
collapsedRefs,
81127
dismissNodes,
82128
minimizeNodes,
83129
collapsedNodes,
130+
redBox,
131+
detected,
84132
};
85133
}
86134

87-
export function resolveReactNativeOverlayDismissTarget(
88-
nodes: SnapshotNode[],
89-
): ReactNativeOverlayDismissTarget | null {
90-
const overlay = detectReactNativeOverlay(nodes);
91-
if (!overlay.detected) return null;
92-
93-
if (overlay.redBox) {
94-
const minimize = firstNodeWithRect(overlay.minimizeNodes);
95-
if (minimize) return targetFromNode(minimize, 'minimize');
96-
const dismiss = firstNodeWithRect(overlay.dismissNodes);
135+
function buildSafeDismissActions(
136+
facts: ReactNativeOverlayFacts,
137+
): ReactNativeOverlayDismissTarget[] {
138+
if (facts.redBox) {
139+
const minimize = firstControlNodeWithRect(facts.minimizeNodes);
140+
if (minimize) return [targetFromNode(minimize, 'minimize')];
141+
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
97142
return dismiss
98-
? {
99-
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
100-
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
101-
}
102-
: null;
143+
? [
144+
{
145+
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
146+
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
147+
},
148+
]
149+
: [];
103150
}
104151

105-
const dismiss = firstNodeWithRect(overlay.dismissNodes);
106-
if (dismiss) return targetFromNode(dismiss, actionFromDismissNode(dismiss));
152+
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
153+
if (dismiss) return [targetFromNode(dismiss, actionFromDismissNode(dismiss))];
107154

108-
const collapsed = chooseCollapsedWarningNode(overlay.collapsedNodes);
109-
if (!collapsed?.rect) return null;
110-
return {
111-
action: 'close-collapsed-banner',
112-
point: collapsedBannerClosePoint(collapsed),
113-
ref: collapsed.ref,
114-
label: readNodeLabel(collapsed),
115-
};
155+
const collapsed = chooseCollapsedWarningNode(
156+
facts.collapsedNodes.filter(isSafeCollapsedWarningCoordinateFallback),
157+
);
158+
if (!collapsed?.rect) return [];
159+
return [
160+
{
161+
action: 'close-collapsed-banner',
162+
point: collapsedBannerClosePoint(collapsed),
163+
rect: collapsed.rect,
164+
ref: collapsed.ref,
165+
label: readNodeLabel(collapsed),
166+
},
167+
];
168+
}
169+
170+
function formatNodeSearchText(node: SnapshotNode): string {
171+
return [node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' ');
172+
}
173+
174+
function hasKnownReactNativeOverlayText(text: string): boolean {
175+
return /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/.test(
176+
text,
177+
);
178+
}
179+
180+
function isReactNativeStackFrame(text: string): boolean {
181+
return (
182+
/\b[\w.$<>/-]+\.(?:tsx?|jsx?):\d+(?::\d+)?\b/.test(text) ||
183+
/\b[\w.$<>/-]+\.(?:tsx?|jsx?)\s+\(\d+:\d+\)/.test(text)
184+
);
185+
}
186+
187+
function isReactNativeCollapsedWarningLabel(rawLabel: string | undefined): boolean {
188+
const label = rawLabel?.trim().toLowerCase();
189+
if (!label) return false;
190+
return (
191+
label.includes('open debugger to view warnings') ||
192+
/^!,\s+/.test(label) ||
193+
/^(warn|warning|error):\s+/.test(label) ||
194+
/\b(?:possible\s+)?unhandled (?:promise )?rejection\b/.test(label) ||
195+
label.includes('getsnapshot should be cached to avoid an infinite loop') ||
196+
label.includes('unique "key" prop') ||
197+
label.includes("unique 'key' prop") ||
198+
label.includes('virtualizedlists should never be nested') ||
199+
label.includes('failed prop type')
200+
);
201+
}
202+
203+
function isReactNativeOpenDebuggerWarningLabel(label: string): boolean {
204+
return label.includes('open debugger to view warnings') || /^!,\s+open debugger\b/.test(label);
116205
}
117206

118207
function isDismissControlLabel(label: string): boolean {
119-
return label === 'dismiss' || label === 'close' || isCloseIconLabel(label);
208+
return isDismissLabel(label) || isCloseLabel(label) || isCloseIconLabel(label);
209+
}
210+
211+
function isDismissLabel(label: string): boolean {
212+
return /^dismiss(?:\b|\s|\()/i.test(label);
213+
}
214+
215+
function isCloseLabel(label: string): boolean {
216+
return /^close(?:\b|\s|\()/i.test(label);
120217
}
121218

122219
function isCloseIconLabel(label: string): boolean {
123220
return ['x', '×', '✕', '✖', '⨯'].includes(label);
124221
}
125222

126223
function isMinimizeLabel(label: string): boolean {
127-
return /^minimi[sz]e$/.test(label);
224+
return /^minimi[sz]e(?:\b|\s|\()/i.test(label);
128225
}
129226

130227
function isLikelyCollapsedWarningControl(node: SnapshotNode): boolean {
131228
return !node.rect || node.rect.height <= 180;
132229
}
133230

231+
function isSafeCollapsedWarningCoordinateFallback(node: SnapshotNode): boolean {
232+
const label = readNodeLabel(node);
233+
return (
234+
isReactNativeOpenDebuggerWarningLabel(label?.trim().toLowerCase() ?? '') &&
235+
isReactNativeCollapsedWarningBanner(node)
236+
);
237+
}
238+
239+
function isFullScreenOverlayRect(rect: RawSnapshotNode['rect']): boolean {
240+
if (!rect) return false;
241+
return rect.x <= 1 && rect.y <= 1 && rect.width >= 300 && rect.height >= 600;
242+
}
243+
244+
function isReactNativeCollapsedWarningBanner(node: ReactNativeOverlayNode): boolean {
245+
if (!node.rect) return false;
246+
return node.rect.width >= 120 && node.rect.height >= 36 && node.rect.height <= 180;
247+
}
248+
134249
function collectOverlayNodes(
135250
nodes: SnapshotNode[],
136251
matches: (label: string) => boolean,
@@ -150,11 +265,32 @@ function collectOverlayNodes(
150265
}
151266

152267
function refsOf(nodes: SnapshotNode[]): string[] {
153-
return nodes.map((node) => node.ref);
268+
return Array.from(new Set(nodes.map((node) => node.ref)));
269+
}
270+
271+
function firstControlNodeWithRect(nodes: SnapshotNode[]): SnapshotNode | null {
272+
const withRect = nodes.filter((node) => node.rect);
273+
if (withRect.length === 0) return null;
274+
return (
275+
withRect.sort((a, b) => {
276+
const aSemanticControl = isSemanticControlNode(a) ? 1 : 0;
277+
const bSemanticControl = isSemanticControlNode(b) ? 1 : 0;
278+
if (aSemanticControl !== bSemanticControl) return bSemanticControl - aSemanticControl;
279+
const aHittable = a.hittable === true ? 1 : 0;
280+
const bHittable = b.hittable === true ? 1 : 0;
281+
if (aHittable !== bHittable) return bHittable - aHittable;
282+
return rectArea(a.rect) - rectArea(b.rect);
283+
})[0] ?? null
284+
);
285+
}
286+
287+
function isSemanticControlNode(node: ReactNativeOverlayNode): boolean {
288+
const roleText = [node.type, node.role, node.subrole].join(' ').toLowerCase();
289+
return /\b(button|menuitem|link)\b/.test(roleText);
154290
}
155291

156-
function firstNodeWithRect(nodes: SnapshotNode[]): SnapshotNode | null {
157-
return nodes.find((node) => node.rect) ?? null;
292+
function rectArea(rect: Rect | undefined): number {
293+
return rect ? rect.width * rect.height : Number.POSITIVE_INFINITY;
158294
}
159295

160296
function targetFromNode(
@@ -167,14 +303,15 @@ function targetFromNode(
167303
return {
168304
action,
169305
point: centerOfRect(node.rect),
306+
rect: node.rect,
170307
ref: node.ref,
171308
label: readNodeLabel(node),
172309
};
173310
}
174311

175312
function actionFromDismissNode(node: SnapshotNode): ReactNativeOverlayDismissTarget['action'] {
176313
const label = readNodeLabel(node)?.trim().toLowerCase();
177-
if (label === 'dismiss') return 'dismiss';
314+
if (label && isDismissLabel(label)) return 'dismiss';
178315
return 'close';
179316
}
180317

@@ -214,6 +351,6 @@ function clamp(value: number, min: number, max: number): number {
214351
return Math.min(max, Math.max(min, value));
215352
}
216353

217-
function readNodeLabel(node: SnapshotNode): string | undefined {
354+
function readNodeLabel(node: ReactNativeOverlayNode): string | undefined {
218355
return node.label ?? node.value ?? node.identifier;
219356
}

0 commit comments

Comments
 (0)