1- import { centerOfRect , type Point , type SnapshotNode } from '../../utils/snapshot.ts' ;
21import {
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
914export 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
2027export 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+
2844export 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
3954export 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 / \b u n c a u g h t \b / . test ( text ) && / u n a b l e t o d o w n l o a d a s s e t / . test ( text ) ;
64115 const hasOverlayControl =
65- dismissRefs . length > 0 || minimizeRefs . length > 0 || / \b ( r e l o a d j s | c o p y s t a c k ) \b / . test ( text ) ;
116+ dismissNodes . length > 0 || minimizeNodes . length > 0 || / \b ( r e l o a d j s | c o p y s t a c k ) \b / . test ( text ) ;
66117 const redBox =
67118 / \b ( r e d b o x | r u n t i m e e r r o r | r e l o a d j s | c o p y s t a c k | c o m p o n e n t s t a c k | c a l l s t a c k ) \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 ( l o g b o x | r e d b o x | r e l o a d j s | c o p y s t a c k | c o m p o n e n t s t a c k | c a l l s t a c k | r u n t i m e e r r o r | o p e n d e b u g g e r t o v i e w w a r n i n g s ) \b / . test (
176+ text ,
177+ ) ;
178+ }
179+
180+ function isReactNativeStackFrame ( text : string ) : boolean {
181+ return (
182+ / \b [ \w . $ < > / - ] + \. (?: t s x ? | j s x ? ) : \d + (?: : \d + ) ? \b / . test ( text ) ||
183+ / \b [ \w . $ < > / - ] + \. (?: t s x ? | j s x ? ) \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+ / ^ ( w a r n | w a r n i n g | e r r o r ) : \s + / . test ( label ) ||
194+ / \b (?: p o s s i b l e \s + ) ? u n h a n d l e d (?: p r o m i s e ) ? r e j e c t i o n \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 + o p e n d e b u g g e r \b / . test ( label ) ;
116205}
117206
118207function 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 / ^ d i s m i s s (?: \b | \s | \( ) / i. test ( label ) ;
213+ }
214+
215+ function isCloseLabel ( label : string ) : boolean {
216+ return / ^ c l o s e (?: \b | \s | \( ) / i. test ( label ) ;
120217}
121218
122219function isCloseIconLabel ( label : string ) : boolean {
123220 return [ 'x' , '×' , '✕' , '✖' , '⨯' ] . includes ( label ) ;
124221}
125222
126223function isMinimizeLabel ( label : string ) : boolean {
127- return / ^ m i n i m i [ s z ] e $ / . test ( label ) ;
224+ return / ^ m i n i m i [ s z ] e (?: \b | \s | \( ) / i . test ( label ) ;
128225}
129226
130227function 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+
134249function collectOverlayNodes (
135250 nodes : SnapshotNode [ ] ,
136251 matches : ( label : string ) => boolean ,
@@ -150,11 +265,32 @@ function collectOverlayNodes(
150265}
151266
152267function 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 ( b u t t o n | m e n u i t e m | l i n k ) \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
160296function 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
175312function 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