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,18 +20,29 @@ 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.' ,
@@ -37,13 +53,52 @@ export function formatReactNativeOverlayWarning(nodes: SnapshotNode[]): string |
3753}
3854
3955export 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 / \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 ) ;
64116 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 ) ;
117+ 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 ) ;
66118 const redBox =
67119 / \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 ) ||
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 ( 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 (
177+ text ,
178+ ) ;
179+ }
180+
181+ function isReactNativeStackFrame ( text : string ) : boolean {
182+ return (
183+ / \b [ \w . $ < > / - ] + \. (?: t s x ? | j s x ? ) : \d + (?: : \d + ) ? \b / . test ( text ) ||
184+ / \b [ \w . $ < > / - ] + \. (?: t s x ? | j s x ? ) \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+ / ^ ( w a r n | w a r n i n g | e r r o r ) : \s + / . test ( label ) ||
195+ / \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 ) ||
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 + o p e n d e b u g g e r \b / . test ( label ) ;
116206}
117207
118208function 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 / ^ d i s m i s s (?: \b | \s | \( ) / i. test ( label ) ;
214+ }
215+
216+ function isCloseLabel ( label : string ) : boolean {
217+ return / ^ c l o s e (?: \b | \s | \( ) / i. test ( label ) ;
120218}
121219
122220function isCloseIconLabel ( label : string ) : boolean {
123221 return [ 'x' , '×' , '✕' , '✖' , '⨯' ] . includes ( label ) ;
124222}
125223
126224function isMinimizeLabel ( label : string ) : boolean {
127- return / ^ m i n i m i [ s z ] e $ / . test ( label ) ;
225+ return / ^ m i n i m i [ s z ] e (?: \b | \s | \( ) / i . test ( label ) ;
128226}
129227
130228function 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+
134250function collectOverlayNodes (
135251 nodes : SnapshotNode [ ] ,
136252 matches : ( label : string ) => boolean ,
@@ -150,11 +266,32 @@ function collectOverlayNodes(
150266}
151267
152268function 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 ( b u t t o n | m e n u i t e m | l i n k ) \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
160297function 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
175313function 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