Skip to content

Commit 1c73577

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

14 files changed

Lines changed: 683 additions & 129 deletions

File tree

src/client-normalizers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags {
321321
reportJunit: options.reportJunit,
322322
findFirst: options.findFirst,
323323
findLast: options.findLast,
324+
verify: options.verify,
324325
networkInclude: options.networkInclude,
325326
batchOnError: options.batchOnError,
326327
batchMaxSteps: options.batchMaxSteps,

src/client-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,7 @@ export type ClipboardCommandResult =
476476

477477
export type ReactNativeCommandOptions = ClientCommandBaseOptions & {
478478
action: 'dismiss-overlay';
479+
verify?: boolean;
479480
};
480481

481482
export type AgentDeviceCommandClient = {
@@ -841,6 +842,7 @@ type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
841842
reportJunit?: string;
842843
findFirst?: boolean;
843844
findLast?: boolean;
845+
verify?: boolean;
844846
networkInclude?: 'summary' | 'headers' | 'body' | 'all';
845847
batchOnError?: 'stop';
846848
batchMaxSteps?: number;

src/commands/cli-grammar/system.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const systemCliReaders = {
3535
'react-native': (positionals, flags) => ({
3636
...commonInputFromFlags(flags),
3737
action: readReactNativeAction(positionals[0]),
38+
verify: flags.verify,
3839
}),
3940
} satisfies Record<string, CliReader>;
4041

src/commands/client-command-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export const clientCommandMetadata = [
151151
}),
152152
defineClientCommandMetadata('react-native', {
153153
action: requiredField(enumField(REACT_NATIVE_ACTION_VALUES)),
154+
verify: booleanField(),
154155
}),
155156
defineClientCommandMetadata('replay', {
156157
path: requiredField(stringField()),

src/commands/react-native/overlay.ts

Lines changed: 193 additions & 55 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,18 +20,29 @@ 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.',
@@ -37,13 +53,52 @@ export function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string |
3753
}
3854

3955
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();
56+
return analyzeReactNativeOverlay(nodes);
57+
}
4658

59+
export function analyzeReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOverlayState {
60+
const facts = collectReactNativeOverlayFacts(nodes);
61+
const safeActions = facts.detected ? buildSafeDismissActions(facts) : [];
62+
const dismissRefs = refsOf(facts.dismissNodes);
63+
const minimizeRefs = refsOf(facts.minimizeNodes);
64+
const collapsedRefs = refsOf(facts.collapsedNodes);
65+
66+
return {
67+
detected: facts.detected,
68+
redBox: facts.redBox,
69+
dismissRefs,
70+
minimizeRefs,
71+
collapsedRefs,
72+
dismissNodes: facts.dismissNodes,
73+
minimizeNodes: facts.minimizeNodes,
74+
collapsedNodes: facts.collapsedNodes,
75+
safeActions,
76+
primaryAction: safeActions[0] ?? null,
77+
};
78+
}
79+
80+
export function resolveReactNativeOverlayDismissTarget(
81+
nodes: SnapshotNode[],
82+
): ReactNativeOverlayDismissTarget | null {
83+
return analyzeReactNativeOverlay(nodes).primaryAction;
84+
}
85+
86+
export function isReactNativeCollapsedWarningWrapperWithVisibleBanner(
87+
node: ReactNativeOverlayNode,
88+
descendants: ReactNativeOverlayNode[],
89+
): boolean {
90+
const nodeLabel = node.label?.trim();
91+
if (!isReactNativeCollapsedWarningLabel(nodeLabel) || !isFullScreenOverlayRect(node.rect)) {
92+
return false;
93+
}
94+
return descendants.some(
95+
(descendant) =>
96+
descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant),
97+
);
98+
}
99+
100+
function collectReactNativeOverlayFacts(nodes: SnapshotNode[]): ReactNativeOverlayFacts {
101+
const text = nodes.map(formatNodeSearchText).join('\n').toLowerCase();
47102
const dismissNodes = collectOverlayNodes(nodes, isDismissControlLabel);
48103
const minimizeNodes = collectOverlayNodes(nodes, isMinimizeLabel);
49104
const collapsedNodes = collectOverlayNodes(
@@ -55,82 +110,143 @@ export function detectReactNativeOverlay(nodes: SnapshotNode[]): ReactNativeOver
55110
nodes,
56111
isReactNativeOpenDebuggerWarningLabel,
57112
);
58-
const dismissRefs = refsOf(dismissNodes);
59-
const minimizeRefs = refsOf(minimizeNodes);
60-
const collapsedRefs = refsOf(collapsedNodes);
61113
const hasReactNativeStackFrame = isReactNativeStackFrame(text);
62114
const hasControllessRedBoxText =
63115
/\buncaught\b/.test(text) && /unable to download asset/.test(text);
64116
const hasOverlayControl =
65-
dismissRefs.length > 0 || minimizeRefs.length > 0 || /\b(reload js|copy stack)\b/.test(text);
117+
dismissNodes.length > 0 || minimizeNodes.length > 0 || /\b(reload js|copy stack)\b/.test(text);
66118
const redBox =
67119
/\b(redbox|runtime error|reload js|copy stack|component stack|call stack)\b/.test(text) ||
68120
hasControllessRedBoxText ||
69121
(hasReactNativeStackFrame && hasOverlayControl);
70122
const detected =
71-
collapsedRefs.length > 0 ||
123+
collapsedNodes.length > 0 ||
72124
openDebuggerWarningNodes.length > 0 ||
73125
hasControllessRedBoxText ||
74126
(hasOverlayControl && (hasKnownReactNativeOverlayText(text) || hasReactNativeStackFrame));
75127
return {
76-
detected,
77-
redBox,
78-
dismissRefs,
79-
minimizeRefs,
80-
collapsedRefs,
81128
dismissNodes,
82129
minimizeNodes,
83130
collapsedNodes,
131+
redBox,
132+
detected,
84133
};
85134
}
86135

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);
136+
function buildSafeDismissActions(
137+
facts: ReactNativeOverlayFacts,
138+
): ReactNativeOverlayDismissTarget[] {
139+
if (facts.redBox) {
140+
const minimize = firstControlNodeWithRect(facts.minimizeNodes);
141+
if (minimize) return [targetFromNode(minimize, 'minimize')];
142+
const dismiss = firstControlNodeWithRect(facts.dismissNodes);
97143
return dismiss
98-
? {
99-
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
100-
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
101-
}
102-
: null;
144+
? [
145+
{
146+
...targetFromNode(dismiss, actionFromDismissNode(dismiss)),
147+
warning: 'RedBox Minimize control was not exposed; used Dismiss fallback',
148+
},
149+
]
150+
: [];
103151
}
104152

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

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

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

122220
function isCloseIconLabel(label: string): boolean {
123221
return ['x', '×', '✕', '✖', '⨯'].includes(label);
124222
}
125223

126224
function isMinimizeLabel(label: string): boolean {
127-
return /^minimi[sz]e$/.test(label);
225+
return /^minimi[sz]e(?:\b|\s|\()/i.test(label);
128226
}
129227

130228
function isLikelyCollapsedWarningControl(node: SnapshotNode): boolean {
131229
return !node.rect || node.rect.height <= 180;
132230
}
133231

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

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

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

160297
function targetFromNode(
@@ -167,14 +304,15 @@ function targetFromNode(
167304
return {
168305
action,
169306
point: centerOfRect(node.rect),
307+
rect: node.rect,
170308
ref: node.ref,
171309
label: readNodeLabel(node),
172310
};
173311
}
174312

175313
function actionFromDismissNode(node: SnapshotNode): ReactNativeOverlayDismissTarget['action'] {
176314
const label = readNodeLabel(node)?.trim().toLowerCase();
177-
if (label === 'dismiss') return 'dismiss';
315+
if (label && isDismissLabel(label)) return 'dismiss';
178316
return 'close';
179317
}
180318

@@ -214,6 +352,6 @@ function clamp(value: number, min: number, max: number): number {
214352
return Math.min(max, Math.max(min, value));
215353
}
216354

217-
function readNodeLabel(node: SnapshotNode): string | undefined {
355+
function readNodeLabel(node: ReactNativeOverlayNode): string | undefined {
218356
return node.label ?? node.value ?? node.identifier;
219357
}

0 commit comments

Comments
 (0)