Repository: wix/Detox
Pull request #3753 (2022-12-11 16:29:57)

fix(ios): detect topmost view if it is a direct subview of window.
Fixes https://github.com/wix/Detox/issues/3728.


Patch:
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
index d838edf92..12c39c107 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
@@ -394,21 +394,25 @@ DTX_DIRECT_MEMBERS
 
 	CGPoint absPoint = [self calcAbsPointFromLocalPoint:viewPoint];
 
-	UIViewController * _Nullable topMostViewController = [self _topMostViewControllerAtPoint:absPoint];
-	if (!topMostViewController) {
-		if (error) {
-			NSString *description = [NSString stringWithFormat:@"Failed to interact with the screen "
-															 "at point: %@.", NSStringFromCGPoint(viewPoint)];
-			*error = [NSError
-								errorWithDomain:@"Detox" code:0
-								userInfo:@{NSLocalizedDescriptionKey:description}];
+	UIView * _Nullable visibleContainer = [self _topMostViewOverlayAtPoint:absPoint];
+
+	if (!visibleContainer) {
+		UIViewController * _Nullable topMostViewController = [self _topMostViewControllerAtPoint:absPoint];
+		if (!topMostViewController) {
+			if (error) {
+				NSString *description = [NSString stringWithFormat:@"Failed to interact with the screen "
+																 "at point: %@.", NSStringFromCGPoint(viewPoint)];
+				*error = [NSError
+									errorWithDomain:@"Detox" code:0
+									userInfo:@{NSLocalizedDescriptionKey:description}];
+			}
+
+			return NO;
 		}
 
-		return NO;
+		visibleContainer = topMostViewController.view;
 	}
 
-	UIView *visibleContainer = topMostViewController.view;
-
 	if ([self isDescendantOfView:visibleContainer]) {
 		return [self _canHitFromView:self atAbsPoint:absPoint error:error];
 	}
@@ -421,6 +425,40 @@ DTX_DIRECT_MEMBERS
 	return [self _canHitFromView:visibleContainer atAbsPoint:absPoint error:error];
 }
 
+- (nullable UIView *)_topMostViewOverlayAtPoint:(CGPoint)point {
+	UIWindow * _Nullable topMostWindow = [UIWindow dtx_topMostWindowAtPoint:point];
+	if (!topMostWindow) {
+		return nil;
+	}
+
+	NSArray<UIView *> *viewsAtPoint =
+			[topMostWindow.subviews filteredArrayUsingPredicate:[NSPredicate
+					predicateWithBlock:^BOOL(UIView *view,
+																	 NSDictionary<NSString *, id> * _Nullable __unused bindings) {
+		if (!CGRectContainsPoint(view.frame, point)) {
+			return NO;
+		}
+
+		if (![view isVisibleAroundPoint:point]) {
+			return NO;
+		}
+
+		UIView * _Nullable hit = [view hitTest:point withEvent:nil];
+		if (!hit) {
+			// The point lies completely outside the view's hierarchy.
+			return NO;
+		}
+
+		return YES;
+	}]];
+
+	if (!viewsAtPoint.count) {
+		return nil;
+	}
+
+	return viewsAtPoint.lastObject;
+}
+
 - (BOOL)isVisibleAroundPoint:(CGPoint)point {
 	return [self _isVisibleAroundPoint:point error:nil];
 }


Test patch:
diff --git a/detox/test/e2e/35.overlay.test.js b/detox/test/e2e/35.overlay.test.js
index 3274762fc..25e52f0b6 100644
--- a/detox/test/e2e/35.overlay.test.js
+++ b/detox/test/e2e/35.overlay.test.js
@@ -3,7 +3,8 @@ const custom = require('./utils/custom-it');
 
 describe(':ios: Overlay', () => {
   let showAlertButton;
-  let showOverlayButton;
+  let showOverlayWindowButton;
+  let showOverlayViewButton;
   let verticalScrollView;
 
   beforeEach(async () => {
@@ -12,8 +13,11 @@ describe(':ios: Overlay', () => {
 
     await element(by.text('Overlay')).tap();
 
-    showOverlayButton = await element(by.id('ShowOverlayButton'));
-    await expect(showOverlayButton).toBeVisible();
+    showOverlayWindowButton = await element(by.id('ShowOverlayWindowButton'));
+    await expect(showOverlayWindowButton).toBeVisible();
+
+    showOverlayViewButton = await element(by.id('ShowOverlayViewButton'));
+    await expect(showOverlayViewButton).toBeVisible();
 
     showAlertButton = await element(by.id('ShowDismissibleAlertButton'));
     await expect(showAlertButton).toBeVisible();
@@ -24,7 +28,7 @@ describe(':ios: Overlay', () => {
   describe('default behaviour', () => {
     it('should be able to scroll elements', async () => {
       await verticalScrollView.scrollTo('bottom');
-      await expect(showOverlayButton).not.toBeVisible();
+      await expect(showOverlayWindowButton).not.toBeVisible();
     });
   });
 
@@ -38,7 +42,7 @@ describe(':ios: Overlay', () => {
 
     describe('when shown', () => {
       it('should not be able to tap on elements', async () => {
-        await expectToThrow(() => showOverlayButton.tap());
+        await expectToThrow(() => showOverlayWindowButton.tap());
       });
 
       it('should not be able to scroll elements', async () => {
@@ -52,12 +56,12 @@ describe(':ios: Overlay', () => {
       });
 
       custom.it.withFailureIf.iOSWithRNLessThan67('should be able to tap on elements', async () => {
-        await showOverlayButton.tap();
+        await showOverlayWindowButton.tap();
       });
 
       custom.it.withFailureIf.iOSWithRNLessThan67('should be able to scroll elements', async () => {
         await verticalScrollView.scrollTo('bottom');
-        await expect(showOverlayButton).not.toBeVisible();
+        await expect(showOverlayWindowButton).not.toBeVisible();
       });
     });
   });
@@ -65,11 +69,11 @@ describe(':ios: Overlay', () => {
   describe('overlay window', () => {
     describe('when shown', () => {
       beforeEach(async () => {
-        await showOverlayButton.tap();
+        await showOverlayWindowButton.tap();
       });
 
       it('should not be able to tap on elements', async () => {
-        await expectToThrow(() => showOverlayButton.tap());
+        await expectToThrow(() => showOverlayWindowButton.tap());
       });
 
       it('should not be able to scroll elements', async () => {
@@ -77,4 +81,25 @@ describe(':ios: Overlay', () => {
       });
     });
   });
+
+  describe('overlay view', () => {
+    describe('when shown', () => {
+      beforeEach(async () => {
+        await showOverlayViewButton.tap();
+      });
+
+      it('should be hittable', async () => {
+        const overlayView = await element(by.id('OverlayView'));
+        await overlayView.tap();
+      });
+
+      it('should not be able to tap on elements', async () => {
+        await expectToThrow(() => showOverlayViewButton.tap());
+      });
+
+      it('should not be able to scroll elements', async () => {
+        await expectToThrow(() => showOverlayViewButton.scrollTo('bottom'));
+      });
+    });
+  });
 });
diff --git a/detox/test/ios/example/NativeModule.m b/detox/test/ios/example/NativeModule.m
index 4c9dfdac6..08f07b45f 100644
--- a/detox/test/ios/example/NativeModule.m
+++ b/detox/test/ios/example/NativeModule.m
@@ -42,7 +42,7 @@ RCT_EXPORT_METHOD(switchToNativeRoot)
     [label sizeToFit];
     [[newRoot view] addSubview:label];
     label.center = newRoot.view.center;
-    
+
     id<UIApplicationDelegate> delegate = [[UIApplication sharedApplication] delegate];
     [[delegate window]setRootViewController:newRoot];
     [[delegate window] makeKeyAndVisible];
@@ -54,27 +54,27 @@ RCT_EXPORT_METHOD(switchToMultipleReactRoots)
   dispatch_async(dispatch_get_main_queue(), ^{
     id<UIApplicationDelegate> delegate = [[UIApplication sharedApplication] delegate];
     RCTBridge* bridge = ((RCTRootView*)delegate.window.rootViewController.view).bridge;
-    
+
     UIViewController* newRoot = [UIViewController new];
     newRoot.view = [[RCTRootView alloc]initWithBridge:bridge moduleName:@"example" initialProperties:nil];
     newRoot.tabBarItem.title = @"1";
-    
-    
+
+
     UIViewController* newRoot2 = [UIViewController new];
     newRoot2.view = [[RCTRootView alloc]initWithBridge:bridge moduleName:@"example" initialProperties:nil];
     newRoot2.tabBarItem.title = @"2";
-    
+
     UIViewController* newRoot3 = [UIViewController new];
     newRoot3.view = [[RCTRootView alloc]initWithBridge:bridge moduleName:@"example" initialProperties:nil];
     newRoot3.tabBarItem.title = @"3";
-    
+
     UIViewController* newRoot4 = [UIViewController new];
     newRoot4.view = [[RCTRootView alloc]initWithBridge:bridge moduleName:@"example" initialProperties:nil];
     newRoot4.tabBarItem.title = @"4";
-    
+
     UITabBarController* tbc = [UITabBarController new];
     tbc.viewControllers = @[newRoot, newRoot2, newRoot3, newRoot4];
-    
+
     [[delegate window]setRootViewController:tbc];
     [[delegate window] makeKeyAndVisible];
   });
@@ -93,6 +93,7 @@ RCT_EXPORT_METHOD(presentOverlayWindow) {
     dispatch_async(dispatch_get_main_queue(), ^{
         CGRect screenBounds = UIScreen.mainScreen.bounds;
         overlayWindow = [[UIWindow alloc] initWithFrame:screenBounds];
+        overlayWindow.accessibilityIdentifier = @"OverlayWindow";
 
         [overlayWindow setWindowLevel:UIWindowLevelStatusBar];
         [overlayWindow setHidden:NO];
@@ -101,4 +102,18 @@ RCT_EXPORT_METHOD(presentOverlayWindow) {
     });
 }
 
+RCT_EXPORT_METHOD(presentOverlayView) {
+    static UIView *overlayView;
+
+    dispatch_async(dispatch_get_main_queue(), ^{
+        CGRect screenBounds = UIScreen.mainScreen.bounds;
+        overlayView = [[UIView alloc] initWithFrame:screenBounds];
+        overlayView.userInteractionEnabled = YES;
+        overlayView.accessibilityIdentifier = @"OverlayView";
+
+        UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow;
+        [keyWindow addSubview:overlayView];
+    });
+}
+
 @end
diff --git a/detox/test/src/Screens/OverlayScreen.js b/detox/test/src/Screens/OverlayScreen.js
index 6f0227c27..beb94ed94 100644
--- a/detox/test/src/Screens/OverlayScreen.js
+++ b/detox/test/src/Screens/OverlayScreen.js
@@ -24,8 +24,12 @@ export default class OverlayScreen extends Component {
           </Text>
         </TouchableOpacity>
 
-        <TouchableOpacity onPress={() => NativeModule.presentOverlayWindow()} style={styles.button} testID='ShowOverlayButton'>
-          <Text style={styles.text}>Show Overlay</Text>
+        <TouchableOpacity onPress={() => NativeModule.presentOverlayWindow()} style={styles.button} testID='ShowOverlayWindowButton'>
+          <Text style={styles.text}>Show Overlay Window</Text>
+        </TouchableOpacity>
+
+        <TouchableOpacity onPress={() => NativeModule.presentOverlayView()} style={styles.button} testID='ShowOverlayViewButton'>
+          <Text style={styles.text}>Show Overlay View</Text>
         </TouchableOpacity>
 
         <View style={styles.item}><Text style={styles.itemText}>Text1</Text></View>