From 61e30e315a4d16a2e33149c5b2cf9c92de4a9f24 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 15:53:44 +0200 Subject: [PATCH 01/15] fix: Optimize alert operations --- .../Categories/XCUIApplication+FBAlert.h | 17 +++ .../Categories/XCUIApplication+FBAlert.m | 107 ++++++++++++++++++ WebDriverAgentLib/FBAlert.m | 39 +++++-- 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index d9c81ef8c..ac4676fb0 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -8,6 +8,8 @@ #import +#import "FBXCElementSnapshot.h" + NS_ASSUME_NONNULL_BEGIN @interface XCUIApplication (FBAlert) @@ -22,6 +24,21 @@ extern NSString *const FB_SAFARI_APP_NAME; */ - (nullable XCUIElement *)fb_alertElement; +/** + Retrieve the snapshot of the currently displayed alert, if any, using a + single upfront application snapshot and purely in-memory tree traversal for + all subsequent type/candidate checks. This avoids the multiple discrete + accessibility round trips that fb_alertElement's XCUIElementQuery-based + lookup performs, which is significantly more expensive while the + application's main thread is busy (e.g. blocked showing a JS alert in + Safari). Intended for read-only detection/text-extraction use cases + (repeatedly polled while waiting for an atom to complete); callers that need + to interact with (tap) the alert should still use fb_alertElement. + + @return Alert snapshot instance, or nil if no alert is present + */ +- (nullable id)fb_alertSnapshot; + /** Retrieve an alert element hosted by the iOS 18+ limited access permission prompt process. See https://github.com/appium/appium/issues/20591 diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index ba86f4bc1..40351bd81 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -33,6 +33,113 @@ + (nullable XCUIElement *)fb_limitedAccessPromptAlertElement return promptApp.fb_alertElement; } ++ (nullable id)fb_findSafariAlertSnapshotInScrollView:(id)scrollViewSnapshot +{ + CGRect appFrame = scrollViewSnapshot.frame; + + __block id webView = nil; + [scrollViewSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil == webView && nil != descendant.identifier && [descendant.identifier isEqualToString:@"WebView"]) { + webView = descendant; + } + }]; + if (nil == webView) { + return nil; + } + + // Find the first XCUIElementTypeOther which is the grandchild of the web view + // and is horizontally aligned to the center of the screen, and contains one + // to two buttons and at least one text view. + __block id candidate = nil; + [webView enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != candidate || descendant.elementType != XCUIElementTypeOther) { + return; + } + CGRect curFrame = descendant.frame; + if (CGRectEqualToRect(appFrame, curFrame) + || curFrame.origin.x <= 0 + || curFrame.size.width >= appFrame.size.width) { + return; + } + CGFloat possibleCenterX = (appFrame.size.width - curFrame.size.width) / 2; + if (fabs(possibleCenterX - curFrame.origin.x) >= MAX_CENTER_DELTA) { + return; + } + + __block NSUInteger buttonsCount = 0; + __block NSUInteger textViewsCount = 0; + [descendant enumerateDescendantsUsingBlock:^(id innerDescendant) { + XCUIElementType curType = innerDescendant.elementType; + if (curType == XCUIElementTypeButton) { + buttonsCount++; + } else if (curType == XCUIElementTypeTextView) { + textViewsCount++; + } + }]; + if (buttonsCount >= 1 && buttonsCount <= 2 && textViewsCount > 0) { + candidate = descendant; + } + }]; + return candidate; +} + ++ (nullable id)fb_findAlertSnapshotInApplicationSnapshot:(id)appSnapshot +{ + __block id found = nil; + [appSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != found) { + return; + } + XCUIElementType curType = descendant.elementType; + if (curType == XCUIElementTypeAlert || curType == XCUIElementTypeSheet || curType == XCUIElementTypeScrollView) { + found = descendant; + } + }]; + if (nil == found) { + return nil; + } + + if (found.elementType == XCUIElementTypeAlert) { + return found; + } + + if (found.elementType == XCUIElementTypeSheet) { + if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { + return found; + } + + // In case of iPad we want to check if sheet isn't contained by popover. + // In that case we ignore it. + id ancestor = found.parent; + while (nil != ancestor) { + if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { + return nil; + } + ancestor = ancestor.parent; + } + return found; + } + + if (found.elementType == XCUIElementTypeScrollView) { + id app = [[FBXCElementSnapshotWrapper ensureWrapped:found] fb_parentMatchingType:XCUIElementTypeApplication]; + if (nil != app && [app.label isEqualToString:FB_SAFARI_APP_NAME]) { + // Check alert presence in Safari web view + return [self fb_findSafariAlertSnapshotInScrollView:found]; + } + } + + return nil; +} + +- (nullable id)fb_alertSnapshot +{ + id appSnapshot = self.fb_cachedSnapshot ?: [self fb_customSnapshot]; + if (nil == appSnapshot) { + return nil; + } + return [self.class fb_findAlertSnapshotInApplicationSnapshot:appSnapshot]; +} + - (nullable XCUIElement *)fb_alertElementFromSafariWithScrollView:(XCUIElement *)scrollView viewSnapshot:(id)viewSnapshot { diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index fc620da05..462495614 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -24,6 +24,8 @@ @interface FBAlert () @property (nonatomic, strong) XCUIApplication *application; @property (nonatomic, strong, nullable) XCUIElement *element; +@property (nonatomic, strong, nullable) id detectionSnapshot; +@property (nonatomic, assign) BOOL didResolveDetectionSnapshot; @end @implementation FBAlert @@ -46,16 +48,35 @@ + (instancetype)alertWithElement:(XCUIElement *)element - (BOOL)isPresent { @try { - if (nil == self.alertElement) { - return NO; - } - [self.alertElement fb_customSnapshot]; - return YES; + return nil != self.detectionSnapshot; } @catch (NSException *) { return NO; } } +// Read-only detection path (isPresent/text/buttonLabels): resolves a single +// snapshot via fb_alertSnapshot, which takes one upfront application +// snapshot and does all type/candidate matching via in-memory tree +// traversal, instead of the multiple discrete accessibility round trips +// that alertElement's XCUIElementQuery-based resolution performs. This +// matters a lot while the target app's main thread is busy (e.g. blocked +// showing a JS alert in Safari), where each such round trip can cost ~5s. +// Callers that need to interact with (tap) the alert must use alertElement +// instead, since a snapshot cannot be tapped. +- (nullable id)detectionSnapshot +{ + if (!self.didResolveDetectionSnapshot) { + XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; + if ([systemApp fb_isSameAppAs:self.application]) { + self->_detectionSnapshot = systemApp.fb_alertSnapshot; + } else { + self->_detectionSnapshot = systemApp.fb_alertSnapshot ?: self.application.fb_alertSnapshot; + } + self.didResolveDetectionSnapshot = YES; + } + return self->_detectionSnapshot; +} + - (BOOL)notPresentWithError:(NSError **)error { return [[[FBErrorBuilder builder] @@ -76,12 +97,12 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - if (!self.isPresent) { + id snapshot = self.detectionSnapshot; + if (nil == snapshot) { return nil; } NSMutableArray *resultText = [NSMutableArray array]; - id snapshot = self.alertElement.lastSnapshot ?: [self.alertElement fb_customSnapshot]; BOOL isSafariAlert = [self.class isSafariWebAlertWithSnapshot:snapshot]; [snapshot enumerateDescendantsUsingBlock:^(id descendant) { XCUIElementType elementType = descendant.elementType; @@ -139,12 +160,12 @@ - (BOOL)typeText:(NSString *)text error:(NSError **)error - (NSArray *)buttonLabels { - if (!self.isPresent) { + id alertSnapshot = self.detectionSnapshot; + if (nil == alertSnapshot) { return nil; } NSMutableArray *labels = [NSMutableArray array]; - id alertSnapshot = self.alertElement.lastSnapshot ?: [self.alertElement fb_customSnapshot]; [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { if (descendant.elementType != XCUIElementTypeButton) { return; From 851c56d1bff994095b02ea0e21fc549de5e6a25b Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 17:21:00 +0200 Subject: [PATCH 02/15] moar --- .../Categories/XCUIApplication+FBAlert.h | 25 +-- .../Categories/XCUIApplication+FBAlert.m | 114 +---------- WebDriverAgentLib/FBAlert.h | 48 +++-- WebDriverAgentLib/FBAlert.m | 179 ++++++++++++------ WebDriverAgentLib/Routing/FBSession.m | 12 +- WebDriverAgentLib/Utilities/FBAlertsMonitor.m | 20 +- WebDriverAgentLib/Utilities/FBPasteboard.m | 6 +- .../IntegrationTests/FBAlertTests.m | 8 - 8 files changed, 169 insertions(+), 243 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index ac4676fb0..fa2af14db 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -25,27 +25,14 @@ extern NSString *const FB_SAFARI_APP_NAME; - (nullable XCUIElement *)fb_alertElement; /** - Retrieve the snapshot of the currently displayed alert, if any, using a - single upfront application snapshot and purely in-memory tree traversal for - all subsequent type/candidate checks. This avoids the multiple discrete - accessibility round trips that fb_alertElement's XCUIElementQuery-based - lookup performs, which is significantly more expensive while the - application's main thread is busy (e.g. blocked showing a JS alert in - Safari). Intended for read-only detection/text-extraction use cases - (repeatedly polled while waiting for an atom to complete); callers that need - to interact with (tap) the alert should still use fb_alertElement. - - @return Alert snapshot instance, or nil if no alert is present - */ -- (nullable id)fb_alertSnapshot; - -/** - Retrieve an alert element hosted by the iOS 18+ limited access permission prompt - process. See https://github.com/appium/appium/issues/20591 + Retrieve the application hosting the iOS 18+ limited access permission prompt, + cheaply gated on its running state so callers can avoid resolving its alert + element when the prompt process isn't in the foreground. + See https://github.com/appium/appium/issues/20591 - @return Alert element instance if the prompt is present, otherwise nil + @return The prompt application if it is running in the foreground, otherwise nil */ -+ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement; ++ (nullable XCUIApplication *)fb_limitedAccessPromptApplication; @end diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 40351bd81..7f16cee7e 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -24,120 +24,10 @@ @implementation XCUIApplication (FBAlert) -+ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement ++ (nullable XCUIApplication *)fb_limitedAccessPromptApplication { XCUIApplication *promptApp = [[XCUIApplication alloc] initWithBundleIdentifier:FB_LIMITED_ACCESS_PROMPT_BUNDLE_ID]; - if (promptApp.state < XCUIApplicationStateRunningForeground) { - return nil; - } - return promptApp.fb_alertElement; -} - -+ (nullable id)fb_findSafariAlertSnapshotInScrollView:(id)scrollViewSnapshot -{ - CGRect appFrame = scrollViewSnapshot.frame; - - __block id webView = nil; - [scrollViewSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil == webView && nil != descendant.identifier && [descendant.identifier isEqualToString:@"WebView"]) { - webView = descendant; - } - }]; - if (nil == webView) { - return nil; - } - - // Find the first XCUIElementTypeOther which is the grandchild of the web view - // and is horizontally aligned to the center of the screen, and contains one - // to two buttons and at least one text view. - __block id candidate = nil; - [webView enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != candidate || descendant.elementType != XCUIElementTypeOther) { - return; - } - CGRect curFrame = descendant.frame; - if (CGRectEqualToRect(appFrame, curFrame) - || curFrame.origin.x <= 0 - || curFrame.size.width >= appFrame.size.width) { - return; - } - CGFloat possibleCenterX = (appFrame.size.width - curFrame.size.width) / 2; - if (fabs(possibleCenterX - curFrame.origin.x) >= MAX_CENTER_DELTA) { - return; - } - - __block NSUInteger buttonsCount = 0; - __block NSUInteger textViewsCount = 0; - [descendant enumerateDescendantsUsingBlock:^(id innerDescendant) { - XCUIElementType curType = innerDescendant.elementType; - if (curType == XCUIElementTypeButton) { - buttonsCount++; - } else if (curType == XCUIElementTypeTextView) { - textViewsCount++; - } - }]; - if (buttonsCount >= 1 && buttonsCount <= 2 && textViewsCount > 0) { - candidate = descendant; - } - }]; - return candidate; -} - -+ (nullable id)fb_findAlertSnapshotInApplicationSnapshot:(id)appSnapshot -{ - __block id found = nil; - [appSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != found) { - return; - } - XCUIElementType curType = descendant.elementType; - if (curType == XCUIElementTypeAlert || curType == XCUIElementTypeSheet || curType == XCUIElementTypeScrollView) { - found = descendant; - } - }]; - if (nil == found) { - return nil; - } - - if (found.elementType == XCUIElementTypeAlert) { - return found; - } - - if (found.elementType == XCUIElementTypeSheet) { - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { - return found; - } - - // In case of iPad we want to check if sheet isn't contained by popover. - // In that case we ignore it. - id ancestor = found.parent; - while (nil != ancestor) { - if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { - return nil; - } - ancestor = ancestor.parent; - } - return found; - } - - if (found.elementType == XCUIElementTypeScrollView) { - id app = [[FBXCElementSnapshotWrapper ensureWrapped:found] fb_parentMatchingType:XCUIElementTypeApplication]; - if (nil != app && [app.label isEqualToString:FB_SAFARI_APP_NAME]) { - // Check alert presence in Safari web view - return [self fb_findSafariAlertSnapshotInScrollView:found]; - } - } - - return nil; -} - -- (nullable id)fb_alertSnapshot -{ - id appSnapshot = self.fb_cachedSnapshot ?: [self fb_customSnapshot]; - if (nil == appSnapshot) { - return nil; - } - return [self.class fb_findAlertSnapshotInApplicationSnapshot:appSnapshot]; + return promptApp.state < XCUIApplicationStateRunningForeground ? nil : promptApp; } - (nullable XCUIElement *)fb_alertElementFromSafariWithScrollView:(XCUIElement *)scrollView diff --git a/WebDriverAgentLib/FBAlert.h b/WebDriverAgentLib/FBAlert.h index 8e9ec8cda..e74315388 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -9,7 +9,6 @@ #import @class XCUIApplication; -@class XCUIElement; NS_ASSUME_NONNULL_BEGIN @@ -26,29 +25,33 @@ NS_ASSUME_NONNULL_BEGIN + (instancetype)alertWithApplication:(XCUIApplication *)application; /** - Creates alert helper for given application - - @param element The element which represents the alert - */ -+ (instancetype)alertWithElement:(XCUIElement *)element; - -/** - Determines whether alert is present + Determines whether alert is present. + + Not cached: this, text, buttonLabels, acceptWithError:, dismissWithError:, + clickAlertButton:error:, clickElementMatchingClassChain:error:, and + typeText:error: each independently re-resolve the alert against the live + UI on every call. Calling isPresent before one of the others therefore + pays for two resolutions - prefer calling the action directly and handling + its own "not present" error, unless you specifically need to check + presence without acting on it. */ - (BOOL)isPresent; /** - Gets the labels of the buttons visible in the alert + Gets the labels of the buttons visible in the alert. + See isPresent for how presence is resolved. */ - (nullable NSArray *)buttonLabels; /** - Returns alert's title and description separated by new lines + Returns alert's title and description separated by new lines. + See isPresent for how presence is resolved. */ - (nullable NSString *)text; /** - Accepts alert, if present + Accepts alert, if present. + See isPresent for how presence is resolved. @param error If there is an error, upon return contains an NSError object that describes the problem. @return YES if the operation succeeds, otherwise NO. @@ -56,7 +59,8 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)acceptWithError:(NSError **)error; /** - Dismisses alert, if present + Dismisses alert, if present. + See isPresent for how presence is resolved. @param error If there is an error, upon return contains an NSError object that describes the problem. @return YES if the operation succeeds, otherwise NO. @@ -64,8 +68,9 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)dismissWithError:(NSError **)error; /** - Clicks on an alert button, if present - + Clicks on an alert button, if present. + See isPresent for how presence is resolved. + @param label The label of the button on which to click. @param error If there is an error, upon return contains an NSError object that describes the problem. @return YES if the operation suceeds, otherwise NO. @@ -73,12 +78,19 @@ NS_ASSUME_NONNULL_BEGIN - (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error; /** - XCUElement that represents alert + Taps the first descendant of the alert matching the given class chain + selector, if present. + See isPresent for how presence is resolved. + + @param classChain The class chain selector to match against the alert's descendants. + @param error If there is an error, upon return contains an NSError object that describes the problem. + @return YES if the operation succeeds, otherwise NO. */ -- (nullable XCUIElement *)alertElement; +- (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **)error; /** - Types a text into an input inside the alert container, if it is present + Types a text into an input inside the alert container, if it is present. + See isPresent for how presence is resolved. @param text the text to type @param error If there is an error, upon return contains an NSError object that describes the problem. diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 462495614..8a06ef291 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -23,9 +23,36 @@ @interface FBAlert () @property (nonatomic, strong) XCUIApplication *application; -@property (nonatomic, strong, nullable) XCUIElement *element; -@property (nonatomic, strong, nullable) id detectionSnapshot; -@property (nonatomic, assign) BOOL didResolveDetectionSnapshot; + +/** + XCUIElement that represents the alert, resolved via the interactive + XCUIElementQuery-based lookup. isPresent/text/buttonLabels/ + acceptWithError:/dismissWithError:/clickAlertButton:error:/ + clickElementMatchingClassChain:error:/typeText:error: all resolve through + this method as their own presence check. Not cached: every call re-resolves + against the live UI. + */ +- (nullable XCUIElement *)alertElement; + +/** + Retrieve an alert element hosted by the iOS 18+ limited access permission + prompt process. See https://github.com/appium/appium/issues/20591 + + @return Alert element instance if the prompt is present, otherwise nil + */ ++ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement; + +/** + Snapshots an already-resolved alert element, tolerating the element having + gone stale in the (small) window between it being resolved and this call - + fb_customSnapshot throws FBStaleElementException rather than returning nil + in that case, so callers must not assume a nil-coalescing fallback to it is + enough to guard against a missing snapshot. + + @return The element's snapshot, or nil if it could not be taken + */ +- (nullable id)snapshotForAlertElement:(XCUIElement *)element; + @end @implementation FBAlert @@ -37,44 +64,9 @@ + (instancetype)alertWithApplication:(XCUIApplication *)application return alert; } -+ (instancetype)alertWithElement:(XCUIElement *)element -{ - FBAlert *alert = [FBAlert new]; - alert.element = element; - alert.application = element.application; - return alert; -} - - (BOOL)isPresent { - @try { - return nil != self.detectionSnapshot; - } @catch (NSException *) { - return NO; - } -} - -// Read-only detection path (isPresent/text/buttonLabels): resolves a single -// snapshot via fb_alertSnapshot, which takes one upfront application -// snapshot and does all type/candidate matching via in-memory tree -// traversal, instead of the multiple discrete accessibility round trips -// that alertElement's XCUIElementQuery-based resolution performs. This -// matters a lot while the target app's main thread is busy (e.g. blocked -// showing a JS alert in Safari), where each such round trip can cost ~5s. -// Callers that need to interact with (tap) the alert must use alertElement -// instead, since a snapshot cannot be tapped. -- (nullable id)detectionSnapshot -{ - if (!self.didResolveDetectionSnapshot) { - XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; - if ([systemApp fb_isSameAppAs:self.application]) { - self->_detectionSnapshot = systemApp.fb_alertSnapshot; - } else { - self->_detectionSnapshot = systemApp.fb_alertSnapshot ?: self.application.fb_alertSnapshot; - } - self.didResolveDetectionSnapshot = YES; - } - return self->_detectionSnapshot; + return nil != self.alertElement; } - (BOOL)notPresentWithError:(NSError **)error @@ -97,7 +89,11 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - id snapshot = self.detectionSnapshot; + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + return nil; + } + id snapshot = [self snapshotForAlertElement:alertElement]; if (nil == snapshot) { return nil; } @@ -135,13 +131,14 @@ - (NSString *)text - (BOOL)typeText:(NSString *)text error:(NSError **)error { - if (!self.isPresent) { + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { return [self notPresentWithError:error]; } NSPredicate *textCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu}", XCUIElementTypeTextField, XCUIElementTypeSecureTextField]; - NSArray *dstFields = [[self.alertElement descendantsMatchingType:XCUIElementTypeAny] + NSArray *dstFields = [[alertElement descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:textCollectorPredicate].allElementsBoundByIndex; if (dstFields.count > 1) { return [[[FBErrorBuilder builder] @@ -160,7 +157,11 @@ - (BOOL)typeText:(NSString *)text error:(NSError **)error - (NSArray *)buttonLabels { - id alertSnapshot = self.detectionSnapshot; + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + return nil; + } + id alertSnapshot = [self snapshotForAlertElement:alertElement]; if (nil == alertSnapshot) { return nil; } @@ -180,16 +181,20 @@ - (NSArray *)buttonLabels - (BOOL)acceptWithError:(NSError **)error { - if (!self.isPresent) { + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + return [self notPresentWithError:error]; + } + id alertSnapshot = [self snapshotForAlertElement:alertElement]; + if (nil == alertSnapshot) { return [self notPresentWithError:error]; } - id alertSnapshot = self.alertElement.lastSnapshot ?: [self.alertElement fb_customSnapshot]; XCUIElement *acceptButton = nil; if (FBConfiguration.acceptAlertButtonSelector.length) { NSString *errorReason = nil; @try { - acceptButton = [[self.alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector + acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { errorReason = ex.reason; @@ -204,7 +209,7 @@ - (BOOL)acceptWithError:(NSError **)error } } if (nil == acceptButton) { - NSArray *buttons = [self.alertElement.fb_query + NSArray *buttons = [alertElement.fb_query descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) ? buttons.lastObject @@ -212,7 +217,7 @@ - (BOOL)acceptWithError:(NSError **)error } if (nil == acceptButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find accept button for alert: %@", self.alertElement] + withDescriptionFormat:@"Failed to find accept button for alert: %@", alertElement] buildError:error]; } [acceptButton tap]; @@ -221,16 +226,20 @@ - (BOOL)acceptWithError:(NSError **)error - (BOOL)dismissWithError:(NSError **)error { - if (!self.isPresent) { + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + return [self notPresentWithError:error]; + } + id alertSnapshot = [self snapshotForAlertElement:alertElement]; + if (nil == alertSnapshot) { return [self notPresentWithError:error]; } - id alertSnapshot = self.alertElement.lastSnapshot ?: [self.alertElement fb_customSnapshot]; XCUIElement *dismissButton = nil; if (FBConfiguration.dismissAlertButtonSelector.length) { NSString *errorReason = nil; @try { - dismissButton = [[self.alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector + dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { errorReason = ex.reason; @@ -245,7 +254,7 @@ - (BOOL)dismissWithError:(NSError **)error } } if (nil == dismissButton) { - NSArray *buttons = [self.alertElement.fb_query + NSArray *buttons = [alertElement.fb_query descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) ? buttons.firstObject @@ -254,7 +263,7 @@ - (BOOL)dismissWithError:(NSError **)error if (nil == dismissButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find dismiss button for alert: %@", self.alertElement] + withDescriptionFormat:@"Failed to find dismiss button for alert: %@", alertElement] buildError:error]; } [dismissButton tap]; @@ -263,36 +272,80 @@ - (BOOL)dismissWithError:(NSError **)error - (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error { - if (!self.isPresent) { + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { return [self notPresentWithError:error]; } NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; - XCUIElement *requestedButton = [[self.alertElement descendantsMatchingType:XCUIElementTypeButton] + XCUIElement *requestedButton = [[alertElement descendantsMatchingType:XCUIElementTypeButton] matchingPredicate:predicate].allElementsBoundByIndex.firstObject; if (!requestedButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, self.alertElement] + withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, alertElement] buildError:error]; } [requestedButton tap]; return YES; } -- (XCUIElement *)alertElement +- (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **)error { - if (nil == self.element) { + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + return [self notPresentWithError:error]; + } + + XCUIElement *matchedElement = nil; + @try { + matchedElement = [[alertElement fb_descendantsMatchingClassChain:classChain + shouldReturnAfterFirstMatch:YES] firstObject]; + } @catch (NSException *ex) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertElement, ex.reason] + buildError:error]; + } + if (nil == matchedElement) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertElement] + buildError:error]; + } + [matchedElement tap]; + return YES; +} + +- (nullable XCUIElement *)alertElement +{ + @try { XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; + XCUIElement *element; if ([systemApp fb_isSameAppAs:self.application]) { - self.element = systemApp.fb_alertElement; + element = systemApp.fb_alertElement; } else { - self.element = systemApp.fb_alertElement ?: self.application.fb_alertElement; + element = systemApp.fb_alertElement ?: self.application.fb_alertElement; } - if (nil == self.element) { - self.element = [XCUIApplication fb_limitedAccessPromptAlertElement]; + if (nil == element) { + element = [self.class fb_limitedAccessPromptAlertElement]; } + return element; + } @catch (NSException *) { + return nil; + } +} + ++ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement +{ + XCUIApplication *promptApp = XCUIApplication.fb_limitedAccessPromptApplication; + return nil == promptApp ? nil : promptApp.fb_alertElement; +} + +- (nullable id)snapshotForAlertElement:(XCUIElement *)element +{ + @try { + return element.lastSnapshot ?: [element fb_customSnapshot]; + } @catch (NSException *) { + return nil; } - return self.element; } @end diff --git a/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentLib/Routing/FBSession.m index b8de5edf6..171d6684d 100644 --- a/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentLib/Routing/FBSession.m @@ -24,7 +24,6 @@ #import "FBXCTestDaemonsProxy.h" #import "XCUIApplication+FBQuiescence.h" #import "XCUIElement.h" -#import "XCUIElement+FBClassChain.h" /*! The intial value for the default application property. @@ -55,15 +54,10 @@ - (void)didDetectAlert:(FBAlert *)alert { NSString *autoClickAlertSelector = FBConfiguration.autoClickAlertSelector; if ([autoClickAlertSelector length] > 0) { - @try { - NSArray *matches = [alert.alertElement fb_descendantsMatchingClassChain:autoClickAlertSelector - shouldReturnAfterFirstMatch:YES]; - if (matches.count > 0) { - [[matches objectAtIndex:0] tap]; - } - } @catch (NSException *e) { + NSError *error; + if (![alert clickElementMatchingClassChain:autoClickAlertSelector error:&error]) { [FBLogger logFmt:@"Could not click at the alert element '%@'. Original error: %@", - autoClickAlertSelector, e.description]; + autoClickAlertSelector, error.description]; } // This setting has priority over other settings if enabled return; diff --git a/WebDriverAgentLib/Utilities/FBAlertsMonitor.m b/WebDriverAgentLib/Utilities/FBAlertsMonitor.m index 176d4f29f..9566e809d 100644 --- a/WebDriverAgentLib/Utilities/FBAlertsMonitor.m +++ b/WebDriverAgentLib/Utilities/FBAlertsMonitor.m @@ -52,26 +52,28 @@ - (void)scheduleNextTick NSArray *activeApps = XCUIApplication.fb_activeApplications; BOOL didDetectAlert = NO; for (XCUIApplication *activeApp in activeApps) { - XCUIElement *alertElement = nil; @try { - alertElement = activeApp.fb_alertElement; - if (nil != alertElement) { - [delegate didDetectAlert:[FBAlert alertWithElement:alertElement]]; + FBAlert *alert = [FBAlert alertWithApplication:activeApp]; + if (alert.isPresent) { + [delegate didDetectAlert:alert]; + didDetectAlert = YES; } } @catch (NSException *e) { [FBLogger logFmt:@"Got an unexpected exception while monitoring alerts: %@\n%@", e.reason, e.callStackSymbols]; } - if (nil != alertElement) { - didDetectAlert = YES; + if (didDetectAlert) { break; } } if (!didDetectAlert) { @try { - XCUIElement *alertElement = [XCUIApplication fb_limitedAccessPromptAlertElement]; - if (nil != alertElement) { - [delegate didDetectAlert:[FBAlert alertWithElement:alertElement]]; + XCUIApplication *promptApp = XCUIApplication.fb_limitedAccessPromptApplication; + if (nil != promptApp) { + FBAlert *alert = [FBAlert alertWithApplication:promptApp]; + if (alert.isPresent) { + [delegate didDetectAlert:alert]; + } } } @catch (NSException *e) { [FBLogger logFmt:@"Got an unexpected exception while monitoring alerts: %@\n%@", e.reason, e.callStackSymbols]; diff --git a/WebDriverAgentLib/Utilities/FBPasteboard.m b/WebDriverAgentLib/Utilities/FBPasteboard.m index 08e4e9f33..cbc4cd446 100644 --- a/WebDriverAgentLib/Utilities/FBPasteboard.m +++ b/WebDriverAgentLib/Utilities/FBPasteboard.m @@ -100,11 +100,7 @@ + (nullable id)pasteboardContentForItem:(NSString *)item break; } - XCUIElement *alertElement = XCUIApplication.fb_systemApplication.fb_alertElement; - if (nil != alertElement) { - FBAlert *alert = [FBAlert alertWithElement:alertElement]; - [alert acceptWithError:nil]; - } + [[FBAlert alertWithApplication:XCUIApplication.fb_systemApplication] acceptWithError:nil]; uint64_t timeElapsed = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - timeStarted; if (timeElapsed / NSEC_PER_SEC > timeout) { NSString *description = [NSString stringWithFormat:@"Cannot handle pasteboard alert within %@s timeout", @(timeout)]; diff --git a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m index 44b99a91c..244bb2762 100644 --- a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m @@ -151,14 +151,6 @@ - (void)testDismissingAlertWithCustomLocator } } -- (void)testAlertElement -{ - [self showApplicationAlert]; - XCUIElement *alertElement = [FBAlert alertWithApplication:self.testedApplication].alertElement; - XCTAssertTrue(alertElement.exists); - XCTAssertTrue(alertElement.elementType == XCUIElementTypeAlert); -} - - (void)testNotificationAlert { FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; From 9e313e9bfaba9229a2346231545099582e4557b4 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 17:32:19 +0200 Subject: [PATCH 03/15] Final fixes --- .../Categories/XCUIApplication+FBAlert.h | 11 +- .../Categories/XCUIApplication+FBAlert.m | 121 +++++---- WebDriverAgentLib/FBAlert.h | 10 +- WebDriverAgentLib/FBAlert.m | 240 ++++++++++-------- 4 files changed, 217 insertions(+), 165 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index fa2af14db..2f3adb3c5 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -18,16 +18,19 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const FB_SAFARI_APP_NAME; /** - Retrieve the current alert element + Retrieve the snapshot of the currently displayed alert, if any, using a + single upfront application snapshot and purely in-memory tree traversal for + all subsequent type/candidate checks - no further accessibility round trips + are made beyond the one it takes to obtain the application snapshot itself. - @return Alert element instance + @return Alert snapshot instance, or nil if no alert is present */ -- (nullable XCUIElement *)fb_alertElement; +- (nullable id)fb_alertSnapshot; /** Retrieve the application hosting the iOS 18+ limited access permission prompt, cheaply gated on its running state so callers can avoid resolving its alert - element when the prompt process isn't in the foreground. + snapshot when the prompt process isn't in the foreground. See https://github.com/appium/appium/issues/20591 @return The prompt application if it is running in the foreground, otherwise nil diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 7f16cee7e..63cfb98d2 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -30,90 +30,111 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication return promptApp.state < XCUIApplicationStateRunningForeground ? nil : promptApp; } -- (nullable XCUIElement *)fb_alertElementFromSafariWithScrollView:(XCUIElement *)scrollView - viewSnapshot:(id)viewSnapshot ++ (nullable id)fb_findSafariAlertSnapshotInScrollView:(id)scrollViewSnapshot { - CGRect appFrame = viewSnapshot.frame; - NSPredicate *dstViewMatchPredicate = [NSPredicate predicateWithBlock:^BOOL(id snapshot, NSDictionary *bindings) { - CGRect curFrame = snapshot.frame; - if (!CGRectEqualToRect(appFrame, curFrame) - && curFrame.origin.x > 0 && curFrame.size.width < appFrame.size.width) { - CGFloat possibleCenterX = (appFrame.size.width - curFrame.size.width) / 2; - return fabs(possibleCenterX - curFrame.origin.x) < MAX_CENTER_DELTA; + CGRect appFrame = scrollViewSnapshot.frame; + + __block id webView = nil; + [scrollViewSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil == webView && nil != descendant.identifier && [descendant.identifier isEqualToString:@"WebView"]) { + webView = descendant; } - return NO; }]; - NSPredicate *dstViewContainPredicate1 = [NSPredicate predicateWithFormat:@"elementType == %lu", XCUIElementTypeTextView]; - NSPredicate *dstViewContainPredicate2 = [NSPredicate predicateWithFormat:@"elementType == %lu", XCUIElementTypeButton]; - // Find the first XCUIElementTypeOther which is the grandchild of the web view - // and is horizontally aligned to the center of the screen - XCUIElement *candidate = [[[[[[scrollView descendantsMatchingType:XCUIElementTypeAny] - matchingIdentifier:@"WebView"] - descendantsMatchingType:XCUIElementTypeOther] - matchingPredicate:dstViewMatchPredicate] - containingPredicate:dstViewContainPredicate1] - containingPredicate:dstViewContainPredicate2].allElementsBoundByIndex.firstObject; - - if (nil == candidate) { + if (nil == webView) { return nil; } - // ...and contains one to two buttons - // and conatins at least one text view - __block NSUInteger buttonsCount = 0; - __block NSUInteger textViewsCount = 0; - id snapshot = candidate.fb_cachedSnapshot ?: [candidate fb_customSnapshot]; - [snapshot enumerateDescendantsUsingBlock:^(id descendant) { - XCUIElementType curType = descendant.elementType; - if (curType == XCUIElementTypeButton) { - buttonsCount++; - } else if (curType == XCUIElementTypeTextView) { - textViewsCount++; + + // Find the first XCUIElementTypeOther which is the grandchild of the web view + // and is horizontally aligned to the center of the screen, and contains one + // to two buttons and at least one text view. + __block id candidate = nil; + [webView enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != candidate || descendant.elementType != XCUIElementTypeOther) { + return; + } + CGRect curFrame = descendant.frame; + if (CGRectEqualToRect(appFrame, curFrame) + || curFrame.origin.x <= 0 + || curFrame.size.width >= appFrame.size.width) { + return; + } + CGFloat possibleCenterX = (appFrame.size.width - curFrame.size.width) / 2; + if (fabs(possibleCenterX - curFrame.origin.x) >= MAX_CENTER_DELTA) { + return; + } + + __block NSUInteger buttonsCount = 0; + __block NSUInteger textViewsCount = 0; + [descendant enumerateDescendantsUsingBlock:^(id innerDescendant) { + XCUIElementType curType = innerDescendant.elementType; + if (curType == XCUIElementTypeButton) { + buttonsCount++; + } else if (curType == XCUIElementTypeTextView) { + textViewsCount++; + } + }]; + if (buttonsCount >= 1 && buttonsCount <= 2 && textViewsCount > 0) { + candidate = descendant; } }]; - return (buttonsCount >= 1 && buttonsCount <= 2 && textViewsCount > 0) ? candidate : nil; + return candidate; } -- (XCUIElement *)fb_alertElement ++ (nullable id)fb_findAlertSnapshotInApplicationSnapshot:(id)appSnapshot { - NSPredicate *alertCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}", - XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; - XCUIElement *alert = [[self descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:alertCollectorPredicate].allElementsBoundByIndex.firstObject; - if (nil == alert) { + __block id found = nil; + [appSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != found) { + return; + } + XCUIElementType curType = descendant.elementType; + if (curType == XCUIElementTypeAlert || curType == XCUIElementTypeSheet || curType == XCUIElementTypeScrollView) { + found = descendant; + } + }]; + if (nil == found) { return nil; } - id alertSnapshot = alert.fb_cachedSnapshot ?: [alert fb_customSnapshot]; - if (alertSnapshot.elementType == XCUIElementTypeAlert) { - return alert; + if (found.elementType == XCUIElementTypeAlert) { + return found; } - if (alertSnapshot.elementType == XCUIElementTypeSheet) { + if (found.elementType == XCUIElementTypeSheet) { if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { - return alert; + return found; } // In case of iPad we want to check if sheet isn't contained by popover. // In that case we ignore it. - id ancestor = alertSnapshot.parent; + id ancestor = found.parent; while (nil != ancestor) { if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { return nil; } ancestor = ancestor.parent; } - return alert; + return found; } - if (alertSnapshot.elementType == XCUIElementTypeScrollView) { - id app = [[FBXCElementSnapshotWrapper ensureWrapped:alertSnapshot] fb_parentMatchingType:XCUIElementTypeApplication]; + if (found.elementType == XCUIElementTypeScrollView) { + id app = [[FBXCElementSnapshotWrapper ensureWrapped:found] fb_parentMatchingType:XCUIElementTypeApplication]; if (nil != app && [app.label isEqualToString:FB_SAFARI_APP_NAME]) { // Check alert presence in Safari web view - return [self fb_alertElementFromSafariWithScrollView:alert viewSnapshot:alertSnapshot]; + return [self fb_findSafariAlertSnapshotInScrollView:found]; } } return nil; } +- (nullable id)fb_alertSnapshot +{ + id appSnapshot = self.fb_cachedSnapshot ?: [self fb_customSnapshot]; + if (nil == appSnapshot) { + return nil; + } + return [self.class fb_findAlertSnapshotInApplicationSnapshot:appSnapshot]; +} + @end diff --git a/WebDriverAgentLib/FBAlert.h b/WebDriverAgentLib/FBAlert.h index e74315388..2b7f05f94 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -30,10 +30,12 @@ NS_ASSUME_NONNULL_BEGIN Not cached: this, text, buttonLabels, acceptWithError:, dismissWithError:, clickAlertButton:error:, clickElementMatchingClassChain:error:, and typeText:error: each independently re-resolve the alert against the live - UI on every call. Calling isPresent before one of the others therefore - pays for two resolutions - prefer calling the action directly and handling - its own "not present" error, unless you specifically need to check - presence without acting on it. + UI on every call, via a single upfront snapshot rather than an interactive + element lookup - cheap, but still a fresh accessibility round trip per + call. Calling isPresent before one of the others therefore pays for two + snapshot resolutions where one would do - prefer calling the action + directly and handling its own "not present" error, unless you specifically + need to check presence without acting on it. */ - (BOOL)isPresent; diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 8a06ef291..f693a5b58 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -11,48 +11,20 @@ #import "FBConfiguration.h" #import "FBErrorBuilder.h" #import "FBLogger.h" +#import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCodeCompatibility.h" #import "XCUIApplication.h" #import "XCUIApplication+FBAlert.h" #import "XCUIElement+FBClassChain.h" #import "XCUIElement+FBTyping.h" +#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" @interface FBAlert () @property (nonatomic, strong) XCUIApplication *application; - -/** - XCUIElement that represents the alert, resolved via the interactive - XCUIElementQuery-based lookup. isPresent/text/buttonLabels/ - acceptWithError:/dismissWithError:/clickAlertButton:error:/ - clickElementMatchingClassChain:error:/typeText:error: all resolve through - this method as their own presence check. Not cached: every call re-resolves - against the live UI. - */ -- (nullable XCUIElement *)alertElement; - -/** - Retrieve an alert element hosted by the iOS 18+ limited access permission - prompt process. See https://github.com/appium/appium/issues/20591 - - @return Alert element instance if the prompt is present, otherwise nil - */ -+ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement; - -/** - Snapshots an already-resolved alert element, tolerating the element having - gone stale in the (small) window between it being resolved and this call - - fb_customSnapshot throws FBStaleElementException rather than returning nil - in that case, so callers must not assume a nil-coalescing fallback to it is - enough to guard against a missing snapshot. - - @return The element's snapshot, or nil if it could not be taken - */ -- (nullable id)snapshotForAlertElement:(XCUIElement *)element; - @end @implementation FBAlert @@ -66,7 +38,7 @@ + (instancetype)alertWithApplication:(XCUIApplication *)application - (BOOL)isPresent { - return nil != self.alertElement; + return nil != [self alertSnapshotFromApplication:NULL]; } - (BOOL)notPresentWithError:(NSError **)error @@ -89,11 +61,7 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { - return nil; - } - id snapshot = [self snapshotForAlertElement:alertElement]; + id snapshot = [self alertSnapshotFromApplication:NULL]; if (nil == snapshot) { return nil; } @@ -105,7 +73,7 @@ - (NSString *)text if (!(elementType == XCUIElementTypeTextView || elementType == XCUIElementTypeStaticText)) { return; } - + FBXCElementSnapshotWrapper *descendantWrapper = [FBXCElementSnapshotWrapper ensureWrapped:descendant]; if (elementType == XCUIElementTypeStaticText && nil != [descendantWrapper fb_parentMatchingType:XCUIElementTypeButton]) { @@ -131,37 +99,41 @@ - (NSString *)text - (BOOL)typeText:(NSString *)text error:(NSError **)error { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; + if (nil == alertSnapshot) { return [self notPresentWithError:error]; } - NSPredicate *textCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu}", - XCUIElementTypeTextField, XCUIElementTypeSecureTextField]; - NSArray *dstFields = [[alertElement descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:textCollectorPredicate].allElementsBoundByIndex; - if (dstFields.count > 1) { + NSMutableArray> *dstFieldSnapshots = [NSMutableArray array]; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + XCUIElementType elementType = descendant.elementType; + if (elementType == XCUIElementTypeTextField || elementType == XCUIElementTypeSecureTextField) { + [dstFieldSnapshots addObject:descendant]; + } + }]; + if (dstFieldSnapshots.count > 1) { return [[[FBErrorBuilder builder] withDescriptionFormat:@"The alert contains more than one input field"] buildError:error]; } - if (0 == dstFields.count) { + if (0 == dstFieldSnapshots.count) { return [[[FBErrorBuilder builder] withDescriptionFormat:@"The alert contains no input fields"] buildError:error]; } - return [dstFields.firstObject fb_typeText:text - shouldClear:YES - error:error]; + XCUIElement *dstField = [self elementForSnapshot:dstFieldSnapshots.firstObject inApplication:snapshotApplication]; + if (nil == dstField) { + return [self notPresentWithError:error]; + } + return [dstField fb_typeText:text + shouldClear:YES + error:error]; } - (NSArray *)buttonLabels { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { - return nil; - } - id alertSnapshot = [self snapshotForAlertElement:alertElement]; + id alertSnapshot = [self alertSnapshotFromApplication:NULL]; if (nil == alertSnapshot) { return nil; } @@ -179,25 +151,36 @@ - (NSArray *)buttonLabels return labels.copy; } ++ (NSArray> *)buttonSnapshotsInAlertSnapshot:(id)alertSnapshot +{ + NSMutableArray> *buttons = [NSMutableArray array]; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (descendant.elementType == XCUIElementTypeButton) { + [buttons addObject:descendant]; + } + }]; + return buttons; +} + - (BOOL)acceptWithError:(NSError **)error { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { - return [self notPresentWithError:error]; - } - id alertSnapshot = [self snapshotForAlertElement:alertElement]; + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { return [self notPresentWithError:error]; } XCUIElement *acceptButton = nil; if (FBConfiguration.acceptAlertButtonSelector.length) { + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; NSString *errorReason = nil; - @try { - acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector - shouldReturnAfterFirstMatch:YES] firstObject]; - } @catch (NSException *ex) { - errorReason = ex.reason; + if (nil != alertElement) { + @try { + acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector + shouldReturnAfterFirstMatch:YES] firstObject]; + } @catch (NSException *ex) { + errorReason = ex.reason; + } } if (nil == acceptButton) { [FBLogger logFmt:@"Cannot find any match for Accept alert button using the class chain selector '%@'", @@ -209,15 +192,17 @@ - (BOOL)acceptWithError:(NSError **)error } } if (nil == acceptButton) { - NSArray *buttons = [alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; + id acceptButtonSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) ? buttons.lastObject : buttons.firstObject; + if (nil != acceptButtonSnapshot) { + acceptButton = [self elementForSnapshot:acceptButtonSnapshot inApplication:snapshotApplication]; + } } if (nil == acceptButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find accept button for alert: %@", alertElement] + withDescriptionFormat:@"Failed to find accept button for alert: %@", alertSnapshot] buildError:error]; } [acceptButton tap]; @@ -226,23 +211,23 @@ - (BOOL)acceptWithError:(NSError **)error - (BOOL)dismissWithError:(NSError **)error { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { - return [self notPresentWithError:error]; - } - id alertSnapshot = [self snapshotForAlertElement:alertElement]; + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { return [self notPresentWithError:error]; } XCUIElement *dismissButton = nil; if (FBConfiguration.dismissAlertButtonSelector.length) { + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; NSString *errorReason = nil; - @try { - dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector - shouldReturnAfterFirstMatch:YES] firstObject]; - } @catch (NSException *ex) { - errorReason = ex.reason; + if (nil != alertElement) { + @try { + dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector + shouldReturnAfterFirstMatch:YES] firstObject]; + } @catch (NSException *ex) { + errorReason = ex.reason; + } } if (nil == dismissButton) { [FBLogger logFmt:@"Cannot find any match for Dismiss alert button using the class chain selector '%@'", @@ -254,16 +239,18 @@ - (BOOL)dismissWithError:(NSError **)error } } if (nil == dismissButton) { - NSArray *buttons = [alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; + id dismissButtonSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) ? buttons.firstObject : buttons.lastObject; + if (nil != dismissButtonSnapshot) { + dismissButton = [self elementForSnapshot:dismissButtonSnapshot inApplication:snapshotApplication]; + } } if (nil == dismissButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find dismiss button for alert: %@", alertElement] + withDescriptionFormat:@"Failed to find dismiss button for alert: %@", alertSnapshot] buildError:error]; } [dismissButton tap]; @@ -272,17 +259,27 @@ - (BOOL)dismissWithError:(NSError **)error - (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error { - XCUIElement *alertElement = self.alertElement; - if (nil == alertElement) { + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; + if (nil == alertSnapshot) { return [self notPresentWithError:error]; } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; - XCUIElement *requestedButton = [[alertElement descendantsMatchingType:XCUIElementTypeButton] - matchingPredicate:predicate].allElementsBoundByIndex.firstObject; + __block id matchedButtonSnapshot = nil; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { + return; + } + if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { + matchedButtonSnapshot = descendant; + } + }]; + XCUIElement *requestedButton = nil == matchedButtonSnapshot + ? nil + : [self elementForSnapshot:matchedButtonSnapshot inApplication:snapshotApplication]; if (!requestedButton) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, alertElement] + withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, alertSnapshot] buildError:error]; } [requestedButton tap]; @@ -291,7 +288,12 @@ - (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error - (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **)error { - XCUIElement *alertElement = self.alertElement; + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; + if (nil == alertSnapshot) { + return [self notPresentWithError:error]; + } + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; if (nil == alertElement) { return [self notPresentWithError:error]; } @@ -302,47 +304,71 @@ - (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **) shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertElement, ex.reason] + withDescriptionFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertSnapshot, ex.reason] buildError:error]; } if (nil == matchedElement) { return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertElement] + withDescriptionFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertSnapshot] buildError:error]; } [matchedElement tap]; return YES; } -- (nullable XCUIElement *)alertElement +// Single source of truth for alert detection: takes one upfront snapshot per +// candidate application (systemApp, then self.application if different, then +// the iOS 18+ limited access prompt app) and walks it purely in-memory to +// find an alert-shaped descendant - no accessibility round trips beyond the +// snapshot fetch itself. Every public method funnels through this instead of +// the old XCUIElementQuery-based element search, which could cost several +// discrete round trips (one per query stage, worse yet for Safari's nested +// web-alert lookup). Returns the application the snapshot was found in via +// `matchedApplication`, since that is the anchor elementForSnapshot: +// needs - pass NULL if the caller only needs the snapshot. +- (nullable id)alertSnapshotFromApplication:(XCUIApplication * _Nullable * _Nullable)matchedApplication { @try { XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; - XCUIElement *element; - if ([systemApp fb_isSameAppAs:self.application]) { - element = systemApp.fb_alertElement; - } else { - element = systemApp.fb_alertElement ?: self.application.fb_alertElement; + NSMutableArray *candidates = [NSMutableArray arrayWithObject:systemApp]; + if (![systemApp fb_isSameAppAs:self.application]) { + [candidates addObject:self.application]; } - if (nil == element) { - element = [self.class fb_limitedAccessPromptAlertElement]; + XCUIApplication *promptApp = XCUIApplication.fb_limitedAccessPromptApplication; + if (nil != promptApp) { + [candidates addObject:promptApp]; + } + for (XCUIApplication *candidate in candidates) { + id snapshot = candidate.fb_alertSnapshot; + if (nil != snapshot) { + if (NULL != matchedApplication) { + *matchedApplication = candidate; + } + return snapshot; + } } - return element; } @catch (NSException *) { return nil; } + return nil; } -+ (nullable XCUIElement *)fb_limitedAccessPromptAlertElement -{ - XCUIApplication *promptApp = XCUIApplication.fb_limitedAccessPromptApplication; - return nil == promptApp ? nil : promptApp.fb_alertElement; -} - -- (nullable id)snapshotForAlertElement:(XCUIElement *)element +// Resolves the live, tappable element that corresponds to an already-known +// snapshot by matching on its stable uid, instead of re-running a fresh +// attribute/type-based query - a single targeted accessibility round trip +// regardless of how deep the snapshot sits in the tree. +- (nullable XCUIElement *)elementForSnapshot:(id)snapshot + inApplication:(XCUIApplication *)application { + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; + if (nil == uid) { + return nil; + } + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", + FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; @try { - return element.lastSnapshot ?: [element fb_customSnapshot]; + return [[application.fb_query descendantsMatchingType:XCUIElementTypeAny] + matchingPredicate:predicate].allElementsBoundByIndex.firstObject; } @catch (NSException *) { return nil; } From 02edc35133c77a300c84ff53a8429d410bc1c2be Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 18:13:15 +0200 Subject: [PATCH 04/15] final fixes --- .../Categories/XCUIApplication+FBAlert.m | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 63cfb98d2..e76297dfb 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -80,48 +80,70 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication return candidate; } +// Priority matters here: an Alert always wins outright, a Sheet only loses to +// an Alert, and a ScrollView (the Safari web-alert case) is the last resort. +// A single tree walk collecting "whichever of these three types shows up +// first in traversal order" is wrong - a ScrollView or Sheet elsewhere in the +// tree (e.g. springboard's own UI) can sit earlier than the actual Alert and +// permanently shadow it, since only one candidate was ever kept. + (nullable id)fb_findAlertSnapshotInApplicationSnapshot:(id)appSnapshot { - __block id found = nil; + __block id alertSnapshot = nil; + NSMutableArray> *sheetSnapshots = [NSMutableArray array]; + NSMutableArray> *scrollViewSnapshots = [NSMutableArray array]; [appSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != found) { + if (nil != alertSnapshot) { return; } - XCUIElementType curType = descendant.elementType; - if (curType == XCUIElementTypeAlert || curType == XCUIElementTypeSheet || curType == XCUIElementTypeScrollView) { - found = descendant; + switch (descendant.elementType) { + case XCUIElementTypeAlert: + alertSnapshot = descendant; + break; + case XCUIElementTypeSheet: + [sheetSnapshots addObject:descendant]; + break; + case XCUIElementTypeScrollView: + [scrollViewSnapshots addObject:descendant]; + break; + default: + break; } }]; - if (nil == found) { - return nil; - } - - if (found.elementType == XCUIElementTypeAlert) { - return found; + if (nil != alertSnapshot) { + return alertSnapshot; } - if (found.elementType == XCUIElementTypeSheet) { - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { - return found; + BOOL isPhone = [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone; + for (id sheet in sheetSnapshots) { + if (isPhone) { + return sheet; } // In case of iPad we want to check if sheet isn't contained by popover. // In that case we ignore it. - id ancestor = found.parent; + BOOL isInsidePopover = NO; + id ancestor = sheet.parent; while (nil != ancestor) { if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { - return nil; + isInsidePopover = YES; + break; } ancestor = ancestor.parent; } - return found; + if (!isInsidePopover) { + return sheet; + } } - if (found.elementType == XCUIElementTypeScrollView) { - id app = [[FBXCElementSnapshotWrapper ensureWrapped:found] fb_parentMatchingType:XCUIElementTypeApplication]; - if (nil != app && [app.label isEqualToString:FB_SAFARI_APP_NAME]) { - // Check alert presence in Safari web view - return [self fb_findSafariAlertSnapshotInScrollView:found]; + for (id scrollView in scrollViewSnapshots) { + id app = [[FBXCElementSnapshotWrapper ensureWrapped:scrollView] fb_parentMatchingType:XCUIElementTypeApplication]; + if (nil == app || ![app.label isEqualToString:FB_SAFARI_APP_NAME]) { + continue; + } + // Check alert presence in Safari web view + id safariAlert = [self fb_findSafariAlertSnapshotInScrollView:scrollView]; + if (nil != safariAlert) { + return safariAlert; } } @@ -130,7 +152,12 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication - (nullable id)fb_alertSnapshot { - id appSnapshot = self.fb_cachedSnapshot ?: [self fb_customSnapshot]; + id appSnapshot = nil; + @try { + appSnapshot = self.fb_nativeSnapshot; + } @catch (NSException *e) { + appSnapshot = self.fb_customSnapshot; + } if (nil == appSnapshot) { return nil; } From ecc8c8e67e729304eebb98ddcfacaf1b328ea368 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 18:14:28 +0200 Subject: [PATCH 05/15] warning --- WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index e76297dfb..3ce21a8bd 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -155,7 +155,7 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication id appSnapshot = nil; @try { appSnapshot = self.fb_nativeSnapshot; - } @catch (NSException *e) { + } @catch (NSException *) { appSnapshot = self.fb_customSnapshot; } if (nil == appSnapshot) { From 16d90dc5bb1e535a553aed527b248fb6799cf94f Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 18:17:59 +0200 Subject: [PATCH 06/15] tune --- WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 3ce21a8bd..20a9a29ca 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -152,12 +152,7 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication - (nullable id)fb_alertSnapshot { - id appSnapshot = nil; - @try { - appSnapshot = self.fb_nativeSnapshot; - } @catch (NSException *) { - appSnapshot = self.fb_customSnapshot; - } + id appSnapshot = self.fb_cachedSnapshot ?: self.fb_customSnapshot; if (nil == appSnapshot) { return nil; } From 283bec4e1838ee67d27d558aa200dffac94bd3f9 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 20:18:17 +0200 Subject: [PATCH 07/15] moar --- .../Commands/FBAlertViewCommands.m | 46 ++-------- WebDriverAgentLib/FBAlert.h | 48 +++++----- WebDriverAgentLib/FBAlert.m | 92 ++++++++++--------- .../Routing/FBExceptionHandler.m | 9 +- WebDriverAgentLib/Routing/FBExceptions.h | 9 ++ WebDriverAgentLib/Routing/FBExceptions.m | 3 + WebDriverAgentLib/Routing/FBSession.m | 20 ++-- WebDriverAgentLib/Utilities/FBPasteboard.m | 6 +- .../IntegrationTests/FBAlertTests.m | 22 ++--- .../IntegrationTests/FBIntegrationTestCase.m | 6 +- .../IntegrationTests/FBSafariAlertTests.m | 2 +- 11 files changed, 131 insertions(+), 132 deletions(-) diff --git a/WebDriverAgentLib/Commands/FBAlertViewCommands.m b/WebDriverAgentLib/Commands/FBAlertViewCommands.m index 50479936e..c9206c5b5 100644 --- a/WebDriverAgentLib/Commands/FBAlertViewCommands.m +++ b/WebDriverAgentLib/Commands/FBAlertViewCommands.m @@ -54,19 +54,11 @@ + (NSArray *)routes return FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Missing 'value' parameter" traceback:nil]); } FBAlert *alert = [FBAlert alertWithApplication:session.activeApplication]; - if (!alert.isPresent) { - return FBResponseWithStatus([FBCommandStatus noAlertOpenErrorWithMessage:nil - traceback:nil]); - } NSString *textToType = value; if ([value isKindOfClass:[NSArray class]]) { textToType = [value componentsJoinedByString:@""]; } - NSError *error; - if (![alert typeText:textToType error:&error]) { - return FBResponseWithStatus([FBCommandStatus unsupportedOperationErrorWithMessage:error.description - traceback:[NSString stringWithFormat:@"%@", NSThread.callStackSymbols]]); - } + [alert typeText:textToType]; return FBResponseWithOK(); } @@ -75,20 +67,11 @@ + (NSArray *)routes XCUIApplication *application = request.session.activeApplication ?: XCUIApplication.fb_activeApplication; NSString *name = request.arguments[@"name"]; FBAlert *alert = [FBAlert alertWithApplication:application]; - NSError *error; - if (!alert.isPresent) { - return FBResponseWithStatus([FBCommandStatus noAlertOpenErrorWithMessage:nil - traceback:nil]); - } if (name) { - if (![alert clickAlertButton:name error:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description - traceback:[NSString stringWithFormat:@"%@", NSThread.callStackSymbols]]); - } - } else if (![alert acceptWithError:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description - traceback:[NSString stringWithFormat:@"%@", NSThread.callStackSymbols]]); + [alert clickAlertButton:name]; + } else { + [alert accept]; } return FBResponseWithOK(); } @@ -98,20 +81,11 @@ + (NSArray *)routes XCUIApplication *application = request.session.activeApplication ?: XCUIApplication.fb_activeApplication; NSString *name = request.arguments[@"name"]; FBAlert *alert = [FBAlert alertWithApplication:application]; - NSError *error; - - if (!alert.isPresent) { - return FBResponseWithStatus([FBCommandStatus noAlertOpenErrorWithMessage:nil - traceback:nil]); - } + if (name) { - if (![alert clickAlertButton:name error:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description - traceback:[NSString stringWithFormat:@"%@", NSThread.callStackSymbols]]); - } - } else if (![alert dismissWithError:&error]) { - return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description - traceback:[NSString stringWithFormat:@"%@", NSThread.callStackSymbols]]); + [alert clickAlertButton:name]; + } else { + [alert dismiss]; } return FBResponseWithOK(); } @@ -120,11 +94,11 @@ + (NSArray *)routes FBSession *session = request.session; FBAlert *alert = [FBAlert alertWithApplication:session.activeApplication]; - if (!alert.isPresent) { + NSArray *labels = alert.buttonLabels; + if (!labels) { return FBResponseWithStatus([FBCommandStatus noAlertOpenErrorWithMessage:nil traceback:nil]); } - NSArray *labels = alert.buttonLabels; return FBResponseWithObject(labels); } @end diff --git a/WebDriverAgentLib/FBAlert.h b/WebDriverAgentLib/FBAlert.h index 2b7f05f94..aabe42767 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -27,15 +27,15 @@ NS_ASSUME_NONNULL_BEGIN /** Determines whether alert is present. - Not cached: this, text, buttonLabels, acceptWithError:, dismissWithError:, - clickAlertButton:error:, clickElementMatchingClassChain:error:, and - typeText:error: each independently re-resolve the alert against the live - UI on every call, via a single upfront snapshot rather than an interactive - element lookup - cheap, but still a fresh accessibility round trip per - call. Calling isPresent before one of the others therefore pays for two - snapshot resolutions where one would do - prefer calling the action - directly and handling its own "not present" error, unless you specifically - need to check presence without acting on it. + Not cached: this, text, buttonLabels, accept, dismiss, clickAlertButton:, + clickElementMatchingClassChain:, and typeText: each independently + re-resolve the alert against the live UI on every call, via a single + upfront snapshot rather than an interactive element lookup - cheap, but + still a fresh accessibility round trip per call. Calling isPresent before + one of the others therefore pays for two snapshot resolutions where one + would do - prefer calling the action directly and letting it raise + FBAlertNotPresentException, unless you specifically need to check + presence without acting on it. */ - (BOOL)isPresent; @@ -55,29 +55,29 @@ NS_ASSUME_NONNULL_BEGIN Accepts alert, if present. See isPresent for how presence is resolved. - @param error If there is an error, upon return contains an NSError object that describes the problem. - @return YES if the operation succeeds, otherwise NO. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertActionFailedException if the accept button could not be found. */ -- (BOOL)acceptWithError:(NSError **)error; +- (void)accept; /** Dismisses alert, if present. See isPresent for how presence is resolved. - @param error If there is an error, upon return contains an NSError object that describes the problem. - @return YES if the operation succeeds, otherwise NO. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertActionFailedException if the dismiss button could not be found. */ -- (BOOL)dismissWithError:(NSError **)error; +- (void)dismiss; /** Clicks on an alert button, if present. See isPresent for how presence is resolved. @param label The label of the button on which to click. - @param error If there is an error, upon return contains an NSError object that describes the problem. - @return YES if the operation suceeds, otherwise NO. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertActionFailedException if no button with the given label could be found. */ -- (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error; +- (void)clickAlertButton:(NSString *)label; /** Taps the first descendant of the alert matching the given class chain @@ -85,20 +85,20 @@ NS_ASSUME_NONNULL_BEGIN See isPresent for how presence is resolved. @param classChain The class chain selector to match against the alert's descendants. - @param error If there is an error, upon return contains an NSError object that describes the problem. - @return YES if the operation succeeds, otherwise NO. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertActionFailedException if no matching element could be found. */ -- (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **)error; +- (void)clickElementMatchingClassChain:(NSString *)classChain; /** Types a text into an input inside the alert container, if it is present. See isPresent for how presence is resolved. @param text the text to type - @param error If there is an error, upon return contains an NSError object that describes the problem. - @return YES if the operation succeeds, otherwise NO. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertSetTextFailedException if there is no single input field to type into, or typing itself fails. */ -- (BOOL)typeText:(NSString *)text error:(NSError **)error; +- (void)typeText:(NSString *)text; @end diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index f693a5b58..7b33a4797 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -9,7 +9,7 @@ #import "FBAlert.h" #import "FBConfiguration.h" -#import "FBErrorBuilder.h" +#import "FBExceptions.h" #import "FBLogger.h" #import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" @@ -41,11 +41,25 @@ - (BOOL)isPresent return nil != [self alertSnapshotFromApplication:NULL]; } -- (BOOL)notPresentWithError:(NSError **)error +- (void)fb_raiseNotPresentException __attribute__((noreturn)) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"No alert is open"] - buildError:error]; + @throw [NSException exceptionWithName:FBAlertNotPresentException + reason:@"No alert is open" + userInfo:nil]; +} + +- (void)fb_raiseActionFailedExceptionWithReason:(NSString *)reason __attribute__((noreturn)) +{ + @throw [NSException exceptionWithName:FBAlertActionFailedException + reason:reason + userInfo:nil]; +} + +- (void)fb_raiseSetTextFailedExceptionWithReason:(NSString *)reason __attribute__((noreturn)) +{ + @throw [NSException exceptionWithName:FBAlertSetTextFailedException + reason:reason + userInfo:nil]; } + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot @@ -97,12 +111,12 @@ - (NSString *)text return [resultText componentsJoinedByString:@"\n"]; } -- (BOOL)typeText:(NSString *)text error:(NSError **)error +- (void)typeText:(NSString *)text { XCUIApplication *snapshotApplication = nil; id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } NSMutableArray> *dstFieldSnapshots = [NSMutableArray array]; @@ -113,22 +127,19 @@ - (BOOL)typeText:(NSString *)text error:(NSError **)error } }]; if (dstFieldSnapshots.count > 1) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The alert contains more than one input field"] - buildError:error]; + [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains more than one input field"]; } if (0 == dstFieldSnapshots.count) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The alert contains no input fields"] - buildError:error]; + [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains no input fields"]; } XCUIElement *dstField = [self elementForSnapshot:dstFieldSnapshots.firstObject inApplication:snapshotApplication]; if (nil == dstField) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; + } + NSError *error; + if (![dstField fb_typeText:text shouldClear:YES error:&error]) { + [self fb_raiseSetTextFailedExceptionWithReason:error.description]; } - return [dstField fb_typeText:text - shouldClear:YES - error:error]; } - (NSArray *)buttonLabels @@ -162,12 +173,12 @@ - (NSArray *)buttonLabels return buttons; } -- (BOOL)acceptWithError:(NSError **)error +- (void)accept { XCUIApplication *snapshotApplication = nil; id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } XCUIElement *acceptButton = nil; @@ -201,20 +212,18 @@ - (BOOL)acceptWithError:(NSError **)error } } if (nil == acceptButton) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find accept button for alert: %@", alertSnapshot] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find accept button for alert: %@", alertSnapshot]]; } [acceptButton tap]; - return YES; } -- (BOOL)dismissWithError:(NSError **)error +- (void)dismiss { XCUIApplication *snapshotApplication = nil; id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } XCUIElement *dismissButton = nil; @@ -249,20 +258,18 @@ - (BOOL)dismissWithError:(NSError **)error } if (nil == dismissButton) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find dismiss button for alert: %@", alertSnapshot] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find dismiss button for alert: %@", alertSnapshot]]; } [dismissButton tap]; - return YES; } -- (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error +- (void)clickAlertButton:(NSString *)label { XCUIApplication *snapshotApplication = nil; id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } __block id matchedButtonSnapshot = nil; @@ -278,24 +285,22 @@ - (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error ? nil : [self elementForSnapshot:matchedButtonSnapshot inApplication:snapshotApplication]; if (!requestedButton) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, alertSnapshot] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertSnapshot]]; } [requestedButton tap]; - return YES; } -- (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **)error +- (void)clickElementMatchingClassChain:(NSString *)classChain { XCUIApplication *snapshotApplication = nil; id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; if (nil == alertElement) { - return [self notPresentWithError:error]; + [self fb_raiseNotPresentException]; } XCUIElement *matchedElement = nil; @@ -303,17 +308,14 @@ - (BOOL)clickElementMatchingClassChain:(NSString *)classChain error:(NSError **) matchedElement = [[alertElement fb_descendantsMatchingClassChain:classChain shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertSnapshot, ex.reason] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertSnapshot, ex.reason]]; } if (nil == matchedElement) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertSnapshot] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertSnapshot]]; } [matchedElement tap]; - return YES; } // Single source of truth for alert detection: takes one upfront snapshot per diff --git a/WebDriverAgentLib/Routing/FBExceptionHandler.m b/WebDriverAgentLib/Routing/FBExceptionHandler.m index b5e1ec060..1ac777cd5 100644 --- a/WebDriverAgentLib/Routing/FBExceptionHandler.m +++ b/WebDriverAgentLib/Routing/FBExceptionHandler.m @@ -28,9 +28,13 @@ - (void)handleException:(NSException *)exception forResponse:(RouteResponse *)re commandStatus = [FBCommandStatus invalidArgumentErrorWithMessage:exception.reason traceback:traceback]; } else if ([exception.name isEqualToString:FBApplicationCrashedException] - || [exception.name isEqualToString:FBApplicationDeadlockDetectedException]) { + || [exception.name isEqualToString:FBApplicationDeadlockDetectedException] + || [exception.name isEqualToString:FBAlertActionFailedException]) { commandStatus = [FBCommandStatus invalidElementStateErrorWithMessage:exception.reason traceback:traceback]; + } else if ([exception.name isEqualToString:FBAlertSetTextFailedException]) { + commandStatus = [FBCommandStatus unsupportedOperationErrorWithMessage:exception.reason + traceback:traceback]; } else if ([exception.name isEqualToString:FBInvalidXPathException] || [exception.name isEqualToString:FBClassChainQueryParseException]) { commandStatus = [FBCommandStatus invalidSelectorErrorWithMessage:exception.reason @@ -47,6 +51,9 @@ - (void)handleException:(NSException *)exception forResponse:(RouteResponse *)re } else if ([exception.name isEqualToString:FBSessionCreationException]) { commandStatus = [FBCommandStatus sessionNotCreatedError:exception.reason traceback:traceback]; + } else if ([exception.name isEqualToString:FBAlertNotPresentException]) { + commandStatus = [FBCommandStatus noAlertOpenErrorWithMessage:exception.reason + traceback:traceback]; } else { commandStatus = [FBCommandStatus unknownErrorWithMessage:exception.reason traceback:traceback]; diff --git a/WebDriverAgentLib/Routing/FBExceptions.h b/WebDriverAgentLib/Routing/FBExceptions.h index b802da673..9fa76706e 100644 --- a/WebDriverAgentLib/Routing/FBExceptions.h +++ b/WebDriverAgentLib/Routing/FBExceptions.h @@ -57,4 +57,13 @@ extern NSString *const FBApplicationMissingException; /*! Exception used to notify about WDA incompatibility with the current platform version */ extern NSString *const FBIncompatibleWdaException; +/*! Exception used to notify that no alert is currently present */ +extern NSString *const FBAlertNotPresentException; + +/*! Exception used to notify that an alert action (e.g. finding a button to tap) could not complete, although the alert itself is present */ +extern NSString *const FBAlertActionFailedException; + +/*! Exception used to notify that typing text into an alert failed (e.g. no or multiple input fields) */ +extern NSString *const FBAlertSetTextFailedException; + NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Routing/FBExceptions.m b/WebDriverAgentLib/Routing/FBExceptions.m index feb8a4d50..6a0082a22 100644 --- a/WebDriverAgentLib/Routing/FBExceptions.m +++ b/WebDriverAgentLib/Routing/FBExceptions.m @@ -22,3 +22,6 @@ NSString *const FBApplicationCrashedException = @"FBApplicationCrashedException"; NSString *const FBApplicationMissingException = @"FBApplicationMissingException"; NSString *const FBIncompatibleWdaException = @"FBIncompatibleWdaException"; +NSString *const FBAlertNotPresentException = @"FBAlertNotPresentException"; +NSString *const FBAlertActionFailedException = @"FBAlertActionFailedException"; +NSString *const FBAlertSetTextFailedException = @"FBAlertSetTextFailedException"; diff --git a/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentLib/Routing/FBSession.m index 171d6684d..acec5d7b5 100644 --- a/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentLib/Routing/FBSession.m @@ -54,10 +54,11 @@ - (void)didDetectAlert:(FBAlert *)alert { NSString *autoClickAlertSelector = FBConfiguration.autoClickAlertSelector; if ([autoClickAlertSelector length] > 0) { - NSError *error; - if (![alert clickElementMatchingClassChain:autoClickAlertSelector error:&error]) { + @try { + [alert clickElementMatchingClassChain:autoClickAlertSelector]; + } @catch (NSException *e) { [FBLogger logFmt:@"Could not click at the alert element '%@'. Original error: %@", - autoClickAlertSelector, error.description]; + autoClickAlertSelector, e.reason]; } // This setting has priority over other settings if enabled return; @@ -67,14 +68,17 @@ - (void)didDetectAlert:(FBAlert *)alert return; } - NSError *error; if ([self.defaultAlertAction isEqualToString:@"accept"]) { - if (![alert acceptWithError:&error]) { - [FBLogger logFmt:@"Cannot accept the alert. Original error: %@", error.description]; + @try { + [alert accept]; + } @catch (NSException *e) { + [FBLogger logFmt:@"Cannot accept the alert. Original error: %@", e.reason]; } } else if ([self.defaultAlertAction isEqualToString:@"dismiss"]) { - if (![alert dismissWithError:&error]) { - [FBLogger logFmt:@"Cannot dismiss the alert. Original error: %@", error.description]; + @try { + [alert dismiss]; + } @catch (NSException *e) { + [FBLogger logFmt:@"Cannot dismiss the alert. Original error: %@", e.reason]; } } else { [FBLogger logFmt:@"'%@' default alert action is unsupported", self.defaultAlertAction]; diff --git a/WebDriverAgentLib/Utilities/FBPasteboard.m b/WebDriverAgentLib/Utilities/FBPasteboard.m index cbc4cd446..50a515985 100644 --- a/WebDriverAgentLib/Utilities/FBPasteboard.m +++ b/WebDriverAgentLib/Utilities/FBPasteboard.m @@ -100,7 +100,11 @@ + (nullable id)pasteboardContentForItem:(NSString *)item break; } - [[FBAlert alertWithApplication:XCUIApplication.fb_systemApplication] acceptWithError:nil]; + @try { + [[FBAlert alertWithApplication:XCUIApplication.fb_systemApplication] accept]; + } @catch (NSException *) { + // No alert is present on this tick - expected on most iterations of this poll loop + } uint64_t timeElapsed = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - timeStarted; if (timeElapsed / NSEC_PER_SEC > timeout) { NSString *description = [NSString stringWithFormat:@"Cannot handle pasteboard alert within %@s timeout", @(timeout)]; diff --git a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m index 244bb2762..1be635dac 100644 --- a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m @@ -97,32 +97,28 @@ - (void)testAlertLabels - (void)testClickAlertButton { FBAlert* alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertFalse([alert clickAlertButton:@"Invalid" error:nil]); + XCTAssertThrows([alert clickAlertButton:@"Invalid"]); [self showApplicationAlert]; - XCTAssertFalse([alert clickAlertButton:@"Invalid" error:nil]); + XCTAssertThrows([alert clickAlertButton:@"Invalid"]); FBAssertWaitTillBecomesTrue(alert.isPresent); - XCTAssertTrue([alert clickAlertButton:@"Will do" error:nil]); + XCTAssertNoThrow([alert clickAlertButton:@"Will do"]); FBAssertWaitTillBecomesTrue(!alert.isPresent); } - (void)testAcceptingAlert { - NSError *error; [self showApplicationAlert]; - XCTAssertTrue([[FBAlert alertWithApplication:self.testedApplication] acceptWithError:&error]); + XCTAssertNoThrow([[FBAlert alertWithApplication:self.testedApplication] accept]); FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); - XCTAssertNil(error); } - (void)testAcceptingAlertWithCustomLocator { - NSError *error; [self showApplicationAlert]; [FBConfiguration setAcceptAlertButtonSelector:@"**/XCUIElementTypeButton[-1]"]; @try { - XCTAssertTrue([[FBAlert alertWithApplication:self.testedApplication] acceptWithError:&error]); + XCTAssertNoThrow([[FBAlert alertWithApplication:self.testedApplication] accept]); FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); - XCTAssertNil(error); } @finally { [FBConfiguration setAcceptAlertButtonSelector:@""]; } @@ -130,22 +126,18 @@ - (void)testAcceptingAlertWithCustomLocator - (void)testDismissingAlert { - NSError *error; [self showApplicationAlert]; - XCTAssertTrue([[FBAlert alertWithApplication:self.testedApplication] dismissWithError:&error]); + XCTAssertNoThrow([[FBAlert alertWithApplication:self.testedApplication] dismiss]); FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); - XCTAssertNil(error); } - (void)testDismissingAlertWithCustomLocator { - NSError *error; [self showApplicationAlert]; [FBConfiguration setDismissAlertButtonSelector:@"**/XCUIElementTypeButton[-1]"]; @try { - XCTAssertTrue([[FBAlert alertWithApplication:self.testedApplication] dismissWithError:&error]); + XCTAssertNoThrow([[FBAlert alertWithApplication:self.testedApplication] dismiss]); FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); - XCTAssertNil(error); } @finally { [FBConfiguration setDismissAlertButtonSelector:@""]; } diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index bfdd3dbb9..01343ea41 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -132,7 +132,11 @@ - (void)goToScrollPageWithCells:(BOOL)showCells - (void)clearAlert { [self.testedApplication fb_waitUntilStable]; - [[FBAlert alertWithApplication:self.testedApplication] dismissWithError:nil]; + @try { + [[FBAlert alertWithApplication:self.testedApplication] dismiss]; + } @catch (NSException *) { + // No alert is present, nothing to clear + } [self.testedApplication fb_waitUntilStable]; FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); } diff --git a/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m index a9c6ddc5d..0e31eb13f 100644 --- a/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m @@ -67,7 +67,7 @@ - (void)disabled_testCanHandleSafariInputPrompt XCTAssertEqualObjects(buttonLabels.firstObject, @"Close"); XCTAssertNotNil([self.safariApp fb_descendantsMatchingXPathQuery:@"//XCUIElementTypeButton[@label='Close']" shouldReturnAfterFirstMatch:YES].firstObject); - XCTAssertTrue([alert acceptWithError:nil]); + XCTAssertNoThrow([alert accept]); } @end From 08d58266459cd685739055d4594829a9a635cd31 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 31 Jul 2026 20:35:46 +0200 Subject: [PATCH 08/15] moar --- WebDriverAgentLib/FBAlert.m | 87 ++++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 7b33a4797..d1ba6a680 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -173,6 +173,60 @@ - (NSArray *)buttonLabels return buttons; } +// On iOS < 18, some system alerts (e.g. certain permission prompts) nest +// their buttons deep enough that a single snapshot taken from the +// application root exceeds FBConfiguration.snapshotMaxDepth before it +// reaches them, even though the same snapshot easily reaches the alert's +// own type/text near the top of the tree. Resolving the alert element live +// and querying buttons from it gives that query its own fresh depth +// budget, starting from the alert itself instead of the application root - +// matching how the previous XCUIElementQuery-based implementation behaved. +- (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot + inApplication:(XCUIApplication *)application + preferLast:(BOOL)preferLast +{ + if (@available(iOS 18.0, *)) { + NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; + id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; + return nil == buttonSnapshot ? nil : [self elementForSnapshot:buttonSnapshot inApplication:application]; + } + + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; + if (nil == alertElement) { + return nil; + } + NSArray *buttons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + return preferLast ? buttons.lastObject : buttons.firstObject; +} + +// See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this +// branches on iOS version. +- (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot + inApplication:(XCUIApplication *)application + matchingLabel:(NSString *)label +{ + if (@available(iOS 18.0, *)) { + __block id matchedButtonSnapshot = nil; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { + return; + } + if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { + matchedButtonSnapshot = descendant; + } + }]; + return nil == matchedButtonSnapshot ? nil : [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; + } + + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; + if (nil == alertElement) { + return nil; + } + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; + return [[alertElement descendantsMatchingType:XCUIElementTypeButton] + matchingPredicate:predicate].allElementsBoundByIndex.firstObject; +} + - (void)accept { XCUIApplication *snapshotApplication = nil; @@ -203,13 +257,8 @@ - (void)accept } } if (nil == acceptButton) { - NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; - id acceptButtonSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.lastObject - : buttons.firstObject; - if (nil != acceptButtonSnapshot) { - acceptButton = [self elementForSnapshot:acceptButtonSnapshot inApplication:snapshotApplication]; - } + BOOL preferLast = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]); + acceptButton = [self fb_buttonInAlertSnapshot:alertSnapshot inApplication:snapshotApplication preferLast:preferLast]; } if (nil == acceptButton) { [self fb_raiseActionFailedExceptionWithReason: @@ -248,13 +297,8 @@ - (void)dismiss } } if (nil == dismissButton) { - NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; - id dismissButtonSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.firstObject - : buttons.lastObject; - if (nil != dismissButtonSnapshot) { - dismissButton = [self elementForSnapshot:dismissButtonSnapshot inApplication:snapshotApplication]; - } + BOOL preferLast = !(alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]); + dismissButton = [self fb_buttonInAlertSnapshot:alertSnapshot inApplication:snapshotApplication preferLast:preferLast]; } if (nil == dismissButton) { @@ -272,18 +316,9 @@ - (void)clickAlertButton:(NSString *)label [self fb_raiseNotPresentException]; } - __block id matchedButtonSnapshot = nil; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { - return; - } - if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { - matchedButtonSnapshot = descendant; - } - }]; - XCUIElement *requestedButton = nil == matchedButtonSnapshot - ? nil - : [self elementForSnapshot:matchedButtonSnapshot inApplication:snapshotApplication]; + XCUIElement *requestedButton = [self fb_buttonInAlertSnapshot:alertSnapshot + inApplication:snapshotApplication + matchingLabel:label]; if (!requestedButton) { [self fb_raiseActionFailedExceptionWithReason: [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertSnapshot]]; From 39131af2bc1f9e9f1c95f2f37334f00ff304552a Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 1 Aug 2026 07:41:36 +0200 Subject: [PATCH 09/15] Fall back to live element queries when snapshot button search finds none Some system alerts nest buttons deep enough to exceed FBConfiguration.snapshotMaxDepth from the application root, causing the in-memory snapshot walk to miss them regardless of iOS version. Try the cheap snapshot walk first, then fall back to resolving the live alert element and querying it directly, which gets a fresh depth budget. Also address Copilot review comments on PR #1193: the @catch blocks in FBIntegrationTestCase and FBPasteboard were swallowing all exceptions instead of only the expected "no alert open" case, which could mask real dismiss/accept failures. Co-Authored-By: Claude Sonnet 5 --- WebDriverAgentLib/FBAlert.m | 69 ++++++++++++------- WebDriverAgentLib/Utilities/FBPasteboard.m | 6 +- .../IntegrationTests/FBIntegrationTestCase.m | 6 +- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index d1ba6a680..6f296982c 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -144,7 +144,8 @@ - (void)typeText:(NSString *)text - (NSArray *)buttonLabels { - id alertSnapshot = [self alertSnapshotFromApplication:NULL]; + XCUIApplication *snapshotApplication = nil; + id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; if (nil == alertSnapshot) { return nil; } @@ -159,6 +160,23 @@ - (NSArray *)buttonLabels [labels addObject:[NSString stringWithFormat:@"%@", label]]; } }]; + if (labels.count > 0) { + return labels.copy; + } + + // See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this + // falls back to a live query when the snapshot walk finds nothing. + XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; + if (nil == alertElement) { + return labels.copy; + } + NSArray *liveButtons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + for (XCUIElement *button in liveButtons) { + NSString *label = button.label; + if (nil != label) { + [labels addObject:label]; + } + } return labels.copy; } @@ -173,49 +191,52 @@ - (NSArray *)buttonLabels return buttons; } -// On iOS < 18, some system alerts (e.g. certain permission prompts) nest +// Some system alerts (e.g. certain permission prompts on iOS < 18) nest // their buttons deep enough that a single snapshot taken from the // application root exceeds FBConfiguration.snapshotMaxDepth before it // reaches them, even though the same snapshot easily reaches the alert's -// own type/text near the top of the tree. Resolving the alert element live -// and querying buttons from it gives that query its own fresh depth -// budget, starting from the alert itself instead of the application root - -// matching how the previous XCUIElementQuery-based implementation behaved. +// own type/text near the top of the tree. Try the cheap in-memory snapshot +// walk first; if it finds no buttons, fall back to resolving the alert +// element live and querying buttons from it, which gives that query its +// own fresh depth budget starting from the alert itself instead of the +// application root - matching how the previous XCUIElementQuery-based +// implementation behaved. This keeps the common case to a single AX round +// trip while staying safe against the depth-budget edge case. - (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot inApplication:(XCUIApplication *)application preferLast:(BOOL)preferLast { - if (@available(iOS 18.0, *)) { - NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; - id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; - return nil == buttonSnapshot ? nil : [self elementForSnapshot:buttonSnapshot inApplication:application]; + NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; + id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; + if (nil != buttonSnapshot) { + return [self elementForSnapshot:buttonSnapshot inApplication:application]; } XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; if (nil == alertElement) { return nil; } - NSArray *buttons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - return preferLast ? buttons.lastObject : buttons.firstObject; + NSArray *liveButtons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + return preferLast ? liveButtons.lastObject : liveButtons.firstObject; } // See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this -// branches on iOS version. +// falls back to a live query when the snapshot walk finds nothing. - (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot inApplication:(XCUIApplication *)application matchingLabel:(NSString *)label { - if (@available(iOS 18.0, *)) { - __block id matchedButtonSnapshot = nil; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { - return; - } - if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { - matchedButtonSnapshot = descendant; - } - }]; - return nil == matchedButtonSnapshot ? nil : [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; + __block id matchedButtonSnapshot = nil; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { + return; + } + if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { + matchedButtonSnapshot = descendant; + } + }]; + if (nil != matchedButtonSnapshot) { + return [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; } XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; diff --git a/WebDriverAgentLib/Utilities/FBPasteboard.m b/WebDriverAgentLib/Utilities/FBPasteboard.m index 50a515985..4bc33f8ae 100644 --- a/WebDriverAgentLib/Utilities/FBPasteboard.m +++ b/WebDriverAgentLib/Utilities/FBPasteboard.m @@ -11,6 +11,7 @@ #import #import "FBAlert.h" #import "FBErrorBuilder.h" +#import "FBExceptions.h" #import "FBMacros.h" #import "XCUIApplication+FBHelpers.h" #import "XCUIApplication+FBAlert.h" @@ -102,7 +103,10 @@ + (nullable id)pasteboardContentForItem:(NSString *)item @try { [[FBAlert alertWithApplication:XCUIApplication.fb_systemApplication] accept]; - } @catch (NSException *) { + } @catch (NSException *e) { + if (![e.name isEqualToString:FBAlertNotPresentException]) { + @throw e; + } // No alert is present on this tick - expected on most iterations of this poll loop } uint64_t timeElapsed = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - timeStarted; diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index 01343ea41..8a42bf5a5 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -10,6 +10,7 @@ #import "FBAlert.h" #import "FBTestMacros.h" +#import "FBExceptions.h" #import "FBIntegrationTestCase.h" #import "FBConfiguration.h" #import "FBMacros.h" @@ -134,7 +135,10 @@ - (void)clearAlert [self.testedApplication fb_waitUntilStable]; @try { [[FBAlert alertWithApplication:self.testedApplication] dismiss]; - } @catch (NSException *) { + } @catch (NSException *e) { + if (![e.name isEqualToString:FBAlertNotPresentException]) { + @throw e; + } // No alert is present, nothing to clear } [self.testedApplication fb_waitUntilStable]; From 86671b2d7aa1424d49bd3816887aaaa10437d853 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 1 Aug 2026 08:51:17 +0200 Subject: [PATCH 10/15] Restore iOS < 18 version gate for button snapshot search CI showed the "always try the snapshot walk first" change regressed iphone/ipad Min_Xcode (iOS 17.5): FBAlertTests.testNotificationAlert started failing deterministically (4/4 retries) on both device types, while the prior commit with @available(iOS 18.0, *) gating passed cleanly. Root cause: on iOS < 18, sibling buttons in a system alert can sit at different snapshot depths, so a depth-truncated snapshot walk doesn't reliably come back empty - it can return a partial, wrong button set instead. That silently picks the wrong button to tap, which is unsafe. Restore the iOS 18.0 gate so iOS < 18 always resolves the live element and queries buttons from it (the previously verified-safe path), while keeping the "fall back to a live query if the snapshot walk finds nothing" safety net for iOS 18+, where it's known safe. Co-Authored-By: Claude Sonnet 5 --- WebDriverAgentLib/FBAlert.m | 79 +++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 6f296982c..4b9281ea8 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -151,21 +151,24 @@ - (NSArray *)buttonLabels } NSMutableArray *labels = [NSMutableArray array]; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (descendant.elementType != XCUIElementTypeButton) { - return; - } - NSString *label = [FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel; - if (nil != label) { - [labels addObject:[NSString stringWithFormat:@"%@", label]]; + if (@available(iOS 18.0, *)) { + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (descendant.elementType != XCUIElementTypeButton) { + return; + } + NSString *label = [FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel; + if (nil != label) { + [labels addObject:[NSString stringWithFormat:@"%@", label]]; + } + }]; + if (labels.count > 0) { + return labels.copy; } - }]; - if (labels.count > 0) { - return labels.copy; } // See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this - // falls back to a live query when the snapshot walk finds nothing. + // falls back to a live query on iOS < 18, or when the snapshot walk + // finds nothing on iOS 18+. XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; if (nil == alertElement) { return labels.copy; @@ -191,25 +194,31 @@ - (NSArray *)buttonLabels return buttons; } -// Some system alerts (e.g. certain permission prompts on iOS < 18) nest +// On iOS < 18, some system alerts (e.g. certain permission prompts) nest // their buttons deep enough that a single snapshot taken from the // application root exceeds FBConfiguration.snapshotMaxDepth before it // reaches them, even though the same snapshot easily reaches the alert's -// own type/text near the top of the tree. Try the cheap in-memory snapshot -// walk first; if it finds no buttons, fall back to resolving the alert -// element live and querying buttons from it, which gives that query its -// own fresh depth budget starting from the alert itself instead of the -// application root - matching how the previous XCUIElementQuery-based -// implementation behaved. This keeps the common case to a single AX round -// trip while staying safe against the depth-budget edge case. +// own type/text near the top of the tree - and critically, sibling buttons +// can sit at different depths, so the snapshot walk there is not simply +// "empty or complete": it can silently return a partial, wrong button set +// instead of failing cleanly. That is unsafe for choosing which button to +// tap, so iOS < 18 always resolves the alert element live and queries +// buttons from it, which gives that query its own fresh depth budget +// starting from the alert itself instead of the application root - +// matching how the previous XCUIElementQuery-based implementation +// behaved. On iOS 18+, where this has been verified safe, the cheap +// in-memory snapshot walk is tried first and only falls back to the live +// query if it finds no buttons at all. - (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot inApplication:(XCUIApplication *)application preferLast:(BOOL)preferLast { - NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; - id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; - if (nil != buttonSnapshot) { - return [self elementForSnapshot:buttonSnapshot inApplication:application]; + if (@available(iOS 18.0, *)) { + NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; + id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; + if (nil != buttonSnapshot) { + return [self elementForSnapshot:buttonSnapshot inApplication:application]; + } } XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; @@ -221,22 +230,24 @@ - (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)aler } // See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this -// falls back to a live query when the snapshot walk finds nothing. +// branches on iOS version. - (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot inApplication:(XCUIApplication *)application matchingLabel:(NSString *)label { - __block id matchedButtonSnapshot = nil; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { - return; - } - if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { - matchedButtonSnapshot = descendant; + if (@available(iOS 18.0, *)) { + __block id matchedButtonSnapshot = nil; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { + return; + } + if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { + matchedButtonSnapshot = descendant; + } + }]; + if (nil != matchedButtonSnapshot) { + return [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; } - }]; - if (nil != matchedButtonSnapshot) { - return [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; } XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; From 93506188fcf46e9a21224c43ca3ea9e7fe542d3d Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sun, 2 Aug 2026 20:41:51 +0200 Subject: [PATCH 11/15] moar --- .../Categories/XCUIApplication+FBAlert.h | 13 +- .../Categories/XCUIApplication+FBAlert.m | 100 ++++--- WebDriverAgentLib/FBAlert.h | 13 +- WebDriverAgentLib/FBAlert.m | 280 +++++------------- 4 files changed, 149 insertions(+), 257 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index 2f3adb3c5..7f10ea253 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -18,14 +18,15 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const FB_SAFARI_APP_NAME; /** - Retrieve the snapshot of the currently displayed alert, if any, using a - single upfront application snapshot and purely in-memory tree traversal for - all subsequent type/candidate checks - no further accessibility round trips - are made beyond the one it takes to obtain the application snapshot itself. + Retrieve the currently displayed alert element, if any, using a single + predicate-filtered query (Alert, Sheet, or ScrollView type) instead of + snapshotting and walking the whole application tree - the cost stays + proportional to the number of matching elements rather than the + size/depth of the whole app. - @return Alert snapshot instance, or nil if no alert is present + @return Alert element instance, or nil if no alert is present */ -- (nullable id)fb_alertSnapshot; +- (nullable XCUIElement *)fb_alertElement; /** Retrieve the application hosting the iOS 18+ limited access permission prompt, diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 20a9a29ca..161444358 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -11,6 +11,7 @@ #import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCodeCompatibility.h" +#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #define MAX_CENTER_DELTA 10.0 @@ -80,49 +81,80 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication return candidate; } -// Priority matters here: an Alert always wins outright, a Sheet only loses to -// an Alert, and a ScrollView (the Safari web-alert case) is the last resort. -// A single tree walk collecting "whichever of these three types shows up -// first in traversal order" is wrong - a ScrollView or Sheet elsewhere in the -// tree (e.g. springboard's own UI) can sit earlier than the actual Alert and -// permanently shadow it, since only one candidate was ever kept. -+ (nullable id)fb_findAlertSnapshotInApplicationSnapshot:(id)appSnapshot +// Resolves the live element that corresponds to an already-known snapshot +// found somewhere under rootElement, by matching on its stable uid, instead +// of re-running a fresh attribute/type-based query - a single targeted +// accessibility round trip regardless of how deep the snapshot sits. ++ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot + underElement:(XCUIElement *)rootElement { - __block id alertSnapshot = nil; - NSMutableArray> *sheetSnapshots = [NSMutableArray array]; - NSMutableArray> *scrollViewSnapshots = [NSMutableArray array]; - [appSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != alertSnapshot) { - return; - } - switch (descendant.elementType) { + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; + if (nil == uid) { + return nil; + } + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", + FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; + return [[rootElement.fb_query descendantsMatchingType:XCUIElementTypeAny] + matchingPredicate:predicate].allElementsBoundByIndex.firstObject; +} + +// Resolving a query (matchingPredicate:/allElementsBoundByIndex) is itself +// as expensive as taking a snapshot - it has to walk/resolve the matching +// subtree either way. So this issues exactly ONE such query, matching all +// three candidate types at once, instead of one query per type: querying +// Alert, then Sheet, then ScrollView separately would pay that cost up to +// three times over, which is worse than a single whole-app snapshot in the +// common case where no alert is present at all (the query comes back +// empty on the very first attempt). Priority is then resolved in memory +// over the (typically 0-1 element) result: an Alert always wins outright, +// a Sheet only loses to an Alert, and a ScrollView (the Safari web-alert +// case) is the last resort. Per-candidate ancestor/subtree checks (the +// iPad popover check, the Safari web-alert walk) only run for candidates +// that actually matched, not for every possible type. +// +// Note: matchingSnapshotsWithError: (resolving the query directly to +// snapshots, skipping live XCUIElement resolution) looked like a further +// win on paper, but broke alert detection outright in practice - it does +// not behave the same way fb_uniqueSnapshotWithError: does for a broad +// tree-search query like this one. Stick to allElementsBoundByIndex here. +- (nullable XCUIElement *)fb_alertElement +{ + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}", + XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; + NSArray *candidates = [[self descendantsMatchingType:XCUIElementTypeAny] + matchingPredicate:predicate].allElementsBoundByIndex; + if (0 == candidates.count) { + return nil; + } + + NSMutableArray *sheets = [NSMutableArray array]; + NSMutableArray *scrollViews = [NSMutableArray array]; + for (XCUIElement *candidate in candidates) { + switch (candidate.elementType) { case XCUIElementTypeAlert: - alertSnapshot = descendant; - break; + return candidate; case XCUIElementTypeSheet: - [sheetSnapshots addObject:descendant]; + [sheets addObject:candidate]; break; case XCUIElementTypeScrollView: - [scrollViewSnapshots addObject:descendant]; + [scrollViews addObject:candidate]; break; default: break; } - }]; - if (nil != alertSnapshot) { - return alertSnapshot; } BOOL isPhone = [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone; - for (id sheet in sheetSnapshots) { + for (XCUIElement *sheet in sheets) { if (isPhone) { return sheet; } // In case of iPad we want to check if sheet isn't contained by popover. // In that case we ignore it. + id sheetSnapshot = sheet.lastSnapshot ?: [sheet fb_customSnapshot]; BOOL isInsidePopover = NO; - id ancestor = sheet.parent; + id ancestor = sheetSnapshot.parent; while (nil != ancestor) { if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { isInsidePopover = YES; @@ -135,28 +167,20 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication } } - for (id scrollView in scrollViewSnapshots) { - id app = [[FBXCElementSnapshotWrapper ensureWrapped:scrollView] fb_parentMatchingType:XCUIElementTypeApplication]; + for (XCUIElement *scrollView in scrollViews) { + id scrollViewSnapshot = scrollView.lastSnapshot ?: [scrollView fb_customSnapshot]; + id app = [[FBXCElementSnapshotWrapper ensureWrapped:scrollViewSnapshot] fb_parentMatchingType:XCUIElementTypeApplication]; if (nil == app || ![app.label isEqualToString:FB_SAFARI_APP_NAME]) { continue; } // Check alert presence in Safari web view - id safariAlert = [self fb_findSafariAlertSnapshotInScrollView:scrollView]; - if (nil != safariAlert) { - return safariAlert; + id safariAlertSnapshot = [self.class fb_findSafariAlertSnapshotInScrollView:scrollViewSnapshot]; + if (nil != safariAlertSnapshot) { + return [self.class fb_elementForSnapshot:safariAlertSnapshot underElement:scrollView]; } } return nil; } -- (nullable id)fb_alertSnapshot -{ - id appSnapshot = self.fb_cachedSnapshot ?: self.fb_customSnapshot; - if (nil == appSnapshot) { - return nil; - } - return [self.class fb_findAlertSnapshotInApplicationSnapshot:appSnapshot]; -} - @end diff --git a/WebDriverAgentLib/FBAlert.h b/WebDriverAgentLib/FBAlert.h index aabe42767..775ba80ac 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -29,13 +29,12 @@ NS_ASSUME_NONNULL_BEGIN Not cached: this, text, buttonLabels, accept, dismiss, clickAlertButton:, clickElementMatchingClassChain:, and typeText: each independently - re-resolve the alert against the live UI on every call, via a single - upfront snapshot rather than an interactive element lookup - cheap, but - still a fresh accessibility round trip per call. Calling isPresent before - one of the others therefore pays for two snapshot resolutions where one - would do - prefer calling the action directly and letting it raise - FBAlertNotPresentException, unless you specifically need to check - presence without acting on it. + re-resolve the alert against the live UI on every call via a single + predicate-filtered query - cheap, but still a fresh accessibility round + trip per call. Calling isPresent before one of the others therefore pays + for two resolutions where one would do - prefer calling the action + directly and letting it raise FBAlertNotPresentException, unless you + specifically need to check presence without acting on it. */ - (BOOL)isPresent; diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 4b9281ea8..2aad7a944 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -11,14 +11,12 @@ #import "FBConfiguration.h" #import "FBExceptions.h" #import "FBLogger.h" -#import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCodeCompatibility.h" #import "XCUIApplication.h" #import "XCUIApplication+FBAlert.h" #import "XCUIElement+FBClassChain.h" #import "XCUIElement+FBTyping.h" -#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" @@ -38,7 +36,11 @@ + (instancetype)alertWithApplication:(XCUIApplication *)application - (BOOL)isPresent { - return nil != [self alertSnapshotFromApplication:NULL]; + @try { + return nil != [self alertElementFromApplication]; + } @catch (NSException *) { + return NO; + } } - (void)fb_raiseNotPresentException __attribute__((noreturn)) @@ -75,11 +77,12 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - id snapshot = [self alertSnapshotFromApplication:NULL]; - if (nil == snapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { return nil; } + id snapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; NSMutableArray *resultText = [NSMutableArray array]; BOOL isSafariAlert = [self.class isSafariWebAlertWithSnapshot:snapshot]; [snapshot enumerateDescendantsUsingBlock:^(id descendant) { @@ -113,171 +116,64 @@ - (NSString *)text - (void)typeText:(NSString *)text { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { [self fb_raiseNotPresentException]; } - NSMutableArray> *dstFieldSnapshots = [NSMutableArray array]; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - XCUIElementType elementType = descendant.elementType; - if (elementType == XCUIElementTypeTextField || elementType == XCUIElementTypeSecureTextField) { - [dstFieldSnapshots addObject:descendant]; - } - }]; - if (dstFieldSnapshots.count > 1) { + NSPredicate *textCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu}", + XCUIElementTypeTextField, XCUIElementTypeSecureTextField]; + NSArray *dstFields = [[alertElement descendantsMatchingType:XCUIElementTypeAny] + matchingPredicate:textCollectorPredicate].allElementsBoundByIndex; + if (dstFields.count > 1) { [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains more than one input field"]; } - if (0 == dstFieldSnapshots.count) { + if (0 == dstFields.count) { [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains no input fields"]; } - XCUIElement *dstField = [self elementForSnapshot:dstFieldSnapshots.firstObject inApplication:snapshotApplication]; - if (nil == dstField) { - [self fb_raiseNotPresentException]; - } NSError *error; - if (![dstField fb_typeText:text shouldClear:YES error:&error]) { + if (![dstFields.firstObject fb_typeText:text shouldClear:YES error:&error]) { [self fb_raiseSetTextFailedExceptionWithReason:error.description]; } } - (NSArray *)buttonLabels { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { return nil; } NSMutableArray *labels = [NSMutableArray array]; - if (@available(iOS 18.0, *)) { - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (descendant.elementType != XCUIElementTypeButton) { - return; - } - NSString *label = [FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel; - if (nil != label) { - [labels addObject:[NSString stringWithFormat:@"%@", label]]; - } - }]; - if (labels.count > 0) { - return labels.copy; + id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (descendant.elementType != XCUIElementTypeButton) { + return; } - } - - // See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this - // falls back to a live query on iOS < 18, or when the snapshot walk - // finds nothing on iOS 18+. - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; - if (nil == alertElement) { - return labels.copy; - } - NSArray *liveButtons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - for (XCUIElement *button in liveButtons) { - NSString *label = button.label; + NSString *label = [FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel; if (nil != label) { - [labels addObject:label]; - } - } - return labels.copy; -} - -+ (NSArray> *)buttonSnapshotsInAlertSnapshot:(id)alertSnapshot -{ - NSMutableArray> *buttons = [NSMutableArray array]; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (descendant.elementType == XCUIElementTypeButton) { - [buttons addObject:descendant]; + [labels addObject:[NSString stringWithFormat:@"%@", label]]; } }]; - return buttons; -} - -// On iOS < 18, some system alerts (e.g. certain permission prompts) nest -// their buttons deep enough that a single snapshot taken from the -// application root exceeds FBConfiguration.snapshotMaxDepth before it -// reaches them, even though the same snapshot easily reaches the alert's -// own type/text near the top of the tree - and critically, sibling buttons -// can sit at different depths, so the snapshot walk there is not simply -// "empty or complete": it can silently return a partial, wrong button set -// instead of failing cleanly. That is unsafe for choosing which button to -// tap, so iOS < 18 always resolves the alert element live and queries -// buttons from it, which gives that query its own fresh depth budget -// starting from the alert itself instead of the application root - -// matching how the previous XCUIElementQuery-based implementation -// behaved. On iOS 18+, where this has been verified safe, the cheap -// in-memory snapshot walk is tried first and only falls back to the live -// query if it finds no buttons at all. -- (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot - inApplication:(XCUIApplication *)application - preferLast:(BOOL)preferLast -{ - if (@available(iOS 18.0, *)) { - NSArray> *buttons = [self.class buttonSnapshotsInAlertSnapshot:alertSnapshot]; - id buttonSnapshot = preferLast ? buttons.lastObject : buttons.firstObject; - if (nil != buttonSnapshot) { - return [self elementForSnapshot:buttonSnapshot inApplication:application]; - } - } - - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; - if (nil == alertElement) { - return nil; - } - NSArray *liveButtons = [alertElement descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - return preferLast ? liveButtons.lastObject : liveButtons.firstObject; -} - -// See fb_buttonInAlertSnapshot:inApplication:preferLast: for why this -// branches on iOS version. -- (nullable XCUIElement *)fb_buttonInAlertSnapshot:(id)alertSnapshot - inApplication:(XCUIApplication *)application - matchingLabel:(NSString *)label -{ - if (@available(iOS 18.0, *)) { - __block id matchedButtonSnapshot = nil; - [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { - if (nil != matchedButtonSnapshot || descendant.elementType != XCUIElementTypeButton) { - return; - } - if ([[FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel isEqualToString:label]) { - matchedButtonSnapshot = descendant; - } - }]; - if (nil != matchedButtonSnapshot) { - return [self elementForSnapshot:matchedButtonSnapshot inApplication:application]; - } - } - - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:application]; - if (nil == alertElement) { - return nil; - } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; - return [[alertElement descendantsMatchingType:XCUIElementTypeButton] - matchingPredicate:predicate].allElementsBoundByIndex.firstObject; + return labels.copy; } - (void)accept { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { [self fb_raiseNotPresentException]; } + id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; XCUIElement *acceptButton = nil; if (FBConfiguration.acceptAlertButtonSelector.length) { - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; NSString *errorReason = nil; - if (nil != alertElement) { - @try { - acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector + @try { + acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector shouldReturnAfterFirstMatch:YES] firstObject]; - } @catch (NSException *ex) { - errorReason = ex.reason; - } + } @catch (NSException *ex) { + errorReason = ex.reason; } if (nil == acceptButton) { [FBLogger logFmt:@"Cannot find any match for Accept alert button using the class chain selector '%@'", @@ -289,35 +185,35 @@ - (void)accept } } if (nil == acceptButton) { - BOOL preferLast = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]); - acceptButton = [self fb_buttonInAlertSnapshot:alertSnapshot inApplication:snapshotApplication preferLast:preferLast]; + NSArray *buttons = [alertElement.fb_query + descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttons.lastObject + : buttons.firstObject; } if (nil == acceptButton) { [self fb_raiseActionFailedExceptionWithReason: - [NSString stringWithFormat:@"Failed to find accept button for alert: %@", alertSnapshot]]; + [NSString stringWithFormat:@"Failed to find accept button for alert: %@", alertElement]]; } [acceptButton tap]; } - (void)dismiss { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { [self fb_raiseNotPresentException]; } + id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; XCUIElement *dismissButton = nil; if (FBConfiguration.dismissAlertButtonSelector.length) { - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; NSString *errorReason = nil; - if (nil != alertElement) { - @try { - dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector + @try { + dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector shouldReturnAfterFirstMatch:YES] firstObject]; - } @catch (NSException *ex) { - errorReason = ex.reason; - } + } @catch (NSException *ex) { + errorReason = ex.reason; } if (nil == dismissButton) { [FBLogger logFmt:@"Cannot find any match for Dismiss alert button using the class chain selector '%@'", @@ -329,43 +225,40 @@ - (void)dismiss } } if (nil == dismissButton) { - BOOL preferLast = !(alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]); - dismissButton = [self fb_buttonInAlertSnapshot:alertSnapshot inApplication:snapshotApplication preferLast:preferLast]; + NSArray *buttons = [alertElement.fb_query + descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttons.firstObject + : buttons.lastObject; } if (nil == dismissButton) { [self fb_raiseActionFailedExceptionWithReason: - [NSString stringWithFormat:@"Failed to find dismiss button for alert: %@", alertSnapshot]]; + [NSString stringWithFormat:@"Failed to find dismiss button for alert: %@", alertElement]]; } [dismissButton tap]; } - (void)clickAlertButton:(NSString *)label { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { + XCUIElement *alertElement = [self alertElementFromApplication]; + if (nil == alertElement) { [self fb_raiseNotPresentException]; } - XCUIElement *requestedButton = [self fb_buttonInAlertSnapshot:alertSnapshot - inApplication:snapshotApplication - matchingLabel:label]; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; + XCUIElement *requestedButton = [[alertElement descendantsMatchingType:XCUIElementTypeButton] + matchingPredicate:predicate].allElementsBoundByIndex.firstObject; if (!requestedButton) { [self fb_raiseActionFailedExceptionWithReason: - [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertSnapshot]]; + [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertElement]]; } [requestedButton tap]; } - (void)clickElementMatchingClassChain:(NSString *)classChain { - XCUIApplication *snapshotApplication = nil; - id alertSnapshot = [self alertSnapshotFromApplication:&snapshotApplication]; - if (nil == alertSnapshot) { - [self fb_raiseNotPresentException]; - } - XCUIElement *alertElement = [self elementForSnapshot:alertSnapshot inApplication:snapshotApplication]; + XCUIElement *alertElement = [self alertElementFromApplication]; if (nil == alertElement) { [self fb_raiseNotPresentException]; } @@ -376,26 +269,25 @@ - (void)clickElementMatchingClassChain:(NSString *)classChain shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { [self fb_raiseActionFailedExceptionWithReason: - [NSString stringWithFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertSnapshot, ex.reason]]; + [NSString stringWithFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertElement, ex.reason]]; } if (nil == matchedElement) { [self fb_raiseActionFailedExceptionWithReason: - [NSString stringWithFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertSnapshot]]; + [NSString stringWithFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertElement]]; } [matchedElement tap]; } -// Single source of truth for alert detection: takes one upfront snapshot per -// candidate application (systemApp, then self.application if different, then -// the iOS 18+ limited access prompt app) and walks it purely in-memory to -// find an alert-shaped descendant - no accessibility round trips beyond the -// snapshot fetch itself. Every public method funnels through this instead of -// the old XCUIElementQuery-based element search, which could cost several -// discrete round trips (one per query stage, worse yet for Safari's nested -// web-alert lookup). Returns the application the snapshot was found in via -// `matchedApplication`, since that is the anchor elementForSnapshot: -// needs - pass NULL if the caller only needs the snapshot. -- (nullable id)alertSnapshotFromApplication:(XCUIApplication * _Nullable * _Nullable)matchedApplication +// Single source of truth for alert detection: checks each candidate +// application (systemApp, then self.application if different, then the iOS +// 18+ limited access prompt app) via targeted, predicate-filtered live +// queries (see XCUIApplication.fb_alertElement) instead of snapshotting and +// walking the whole application tree - the cost stays proportional to the +// number of alert-shaped elements rather than the size/depth of the app, +// which matters a lot for deeply nested view hierarchies. Every public +// method funnels through this. No caching: each call re-resolves fresh so +// a stale/replaced alert element is never reused across calls. +- (nullable XCUIElement *)alertElementFromApplication { @try { XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; @@ -408,12 +300,9 @@ - (void)clickElementMatchingClassChain:(NSString *)classChain [candidates addObject:promptApp]; } for (XCUIApplication *candidate in candidates) { - id snapshot = candidate.fb_alertSnapshot; - if (nil != snapshot) { - if (NULL != matchedApplication) { - *matchedApplication = candidate; - } - return snapshot; + XCUIElement *element = candidate.fb_alertElement; + if (nil != element) { + return element; } } } @catch (NSException *) { @@ -422,25 +311,4 @@ - (void)clickElementMatchingClassChain:(NSString *)classChain return nil; } -// Resolves the live, tappable element that corresponds to an already-known -// snapshot by matching on its stable uid, instead of re-running a fresh -// attribute/type-based query - a single targeted accessibility round trip -// regardless of how deep the snapshot sits in the tree. -- (nullable XCUIElement *)elementForSnapshot:(id)snapshot - inApplication:(XCUIApplication *)application -{ - NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; - if (nil == uid) { - return nil; - } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", - FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; - @try { - return [[application.fb_query descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:predicate].allElementsBoundByIndex.firstObject; - } @catch (NSException *) { - return nil; - } -} - @end From d0e6fcb7988ac8112174429c82484d1f58f74a8e Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sun, 2 Aug 2026 21:48:39 +0200 Subject: [PATCH 12/15] final optimizations --- .../Categories/XCUIApplication+FBAlert.h | 19 +- .../Categories/XCUIApplication+FBAlert.m | 27 ++- WebDriverAgentLib/FBAlert.h | 16 +- WebDriverAgentLib/FBAlert.m | 175 +++++++++++++----- .../IntegrationTests/FBAlertTests.m | 59 +++--- 5 files changed, 191 insertions(+), 105 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index 7f10ea253..8c6839013 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -24,9 +24,26 @@ extern NSString *const FB_SAFARI_APP_NAME; proportional to the number of matching elements rather than the size/depth of the whole app. + @param snapshotOut On return, set to the element's snapshot if resolving it + already required taking one as a side effect (the iPad sheet popover check, + or the Safari web-alert scan) - left untouched if no snapshot was taken, so + callers should seed it with nil beforehand. Pass NULL if not needed. @return Alert element instance, or nil if no alert is present */ -- (nullable XCUIElement *)fb_alertElement; +- (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _Nullable * _Nullable)snapshotOut; + +/** + Resolves the live element that corresponds to an already-known snapshot + found somewhere under rootElement, by matching on its stable uid, instead + of re-running a fresh attribute/type-based query - a single targeted + accessibility round trip regardless of how deep the snapshot sits. + + @param snapshot The snapshot to resolve a live element for + @param rootElement The element to scope the uid lookup query to + @return The live element matching the snapshot's uid, or nil if it could not be resolved + */ ++ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot + underElement:(XCUIElement *)rootElement; /** Retrieve the application hosting the iOS 18+ limited access permission prompt, diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 161444358..b207595f9 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -81,10 +81,6 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication return candidate; } -// Resolves the live element that corresponds to an already-known snapshot -// found somewhere under rootElement, by matching on its stable uid, instead -// of re-running a fresh attribute/type-based query - a single targeted -// accessibility round trip regardless of how deep the snapshot sits. + (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot underElement:(XCUIElement *)rootElement { @@ -98,7 +94,7 @@ + (nullable XCUIElement *)fb_elementForSnapshot:(id)snapsho matchingPredicate:predicate].allElementsBoundByIndex.firstObject; } -// Resolving a query (matchingPredicate:/allElementsBoundByIndex) is itself +// Resolving a query (e.g. allElementsBoundByIndex) is itself // as expensive as taking a snapshot - it has to walk/resolve the matching // subtree either way. So this issues exactly ONE such query, matching all // three candidate types at once, instead of one query per type: querying @@ -111,13 +107,7 @@ + (nullable XCUIElement *)fb_elementForSnapshot:(id)snapsho // case) is the last resort. Per-candidate ancestor/subtree checks (the // iPad popover check, the Safari web-alert walk) only run for candidates // that actually matched, not for every possible type. -// -// Note: matchingSnapshotsWithError: (resolving the query directly to -// snapshots, skipping live XCUIElement resolution) looked like a further -// win on paper, but broke alert detection outright in practice - it does -// not behave the same way fb_uniqueSnapshotWithError: does for a broad -// tree-search query like this one. Stick to allElementsBoundByIndex here. -- (nullable XCUIElement *)fb_alertElement +- (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _Nullable * _Nullable)snapshotOut { NSPredicate *predicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}", XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; @@ -152,7 +142,7 @@ - (nullable XCUIElement *)fb_alertElement // In case of iPad we want to check if sheet isn't contained by popover. // In that case we ignore it. - id sheetSnapshot = sheet.lastSnapshot ?: [sheet fb_customSnapshot]; + id sheetSnapshot = sheet.lastSnapshot ?: sheet.fb_cachedSnapshot ?: [sheet fb_customSnapshot]; BOOL isInsidePopover = NO; id ancestor = sheetSnapshot.parent; while (nil != ancestor) { @@ -163,12 +153,15 @@ - (nullable XCUIElement *)fb_alertElement ancestor = ancestor.parent; } if (!isInsidePopover) { + if (NULL != snapshotOut) { + *snapshotOut = sheetSnapshot; + } return sheet; } } for (XCUIElement *scrollView in scrollViews) { - id scrollViewSnapshot = scrollView.lastSnapshot ?: [scrollView fb_customSnapshot]; + id scrollViewSnapshot = scrollView.lastSnapshot ?: scrollView.fb_cachedSnapshot ?: [scrollView fb_customSnapshot]; id app = [[FBXCElementSnapshotWrapper ensureWrapped:scrollViewSnapshot] fb_parentMatchingType:XCUIElementTypeApplication]; if (nil == app || ![app.label isEqualToString:FB_SAFARI_APP_NAME]) { continue; @@ -176,7 +169,11 @@ - (nullable XCUIElement *)fb_alertElement // Check alert presence in Safari web view id safariAlertSnapshot = [self.class fb_findSafariAlertSnapshotInScrollView:scrollViewSnapshot]; if (nil != safariAlertSnapshot) { - return [self.class fb_elementForSnapshot:safariAlertSnapshot underElement:scrollView]; + XCUIElement *resolved = [self.class fb_elementForSnapshot:safariAlertSnapshot underElement:scrollView]; + if (nil != resolved && NULL != snapshotOut) { + *snapshotOut = safariAlertSnapshot; + } + return resolved; } } diff --git a/WebDriverAgentLib/FBAlert.h b/WebDriverAgentLib/FBAlert.h index 775ba80ac..ddb013fda 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -27,14 +27,14 @@ NS_ASSUME_NONNULL_BEGIN /** Determines whether alert is present. - Not cached: this, text, buttonLabels, accept, dismiss, clickAlertButton:, - clickElementMatchingClassChain:, and typeText: each independently - re-resolve the alert against the live UI on every call via a single - predicate-filtered query - cheap, but still a fresh accessibility round - trip per call. Calling isPresent before one of the others therefore pays - for two resolutions where one would do - prefer calling the action - directly and letting it raise FBAlertNotPresentException, unless you - specifically need to check presence without acting on it. + An FBAlert instance resolves the alert (and, if found, its snapshot) at + most once, lazily, on the first call to isPresent, text, buttonLabels, + accept, dismiss, clickAlertButton:, clickElementMatchingClassChain:, or + typeText: - every subsequent call on the same instance reuses that result + rather than re-querying the live UI. This makes an isPresent check + immediately followed by an action (e.g. the auto-accept flow) act on the + exact same alert it just observed. Create a fresh FBAlert instance (via + alertWithApplication:) to observe the current UI state again. */ - (BOOL)isPresent; diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 2aad7a944..5da0b4922 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -23,6 +23,10 @@ @interface FBAlert () @property (nonatomic, strong) XCUIApplication *application; +@property (nonatomic, nullable) XCUIElement *cachedAlertElement; +@property (nonatomic) BOOL hasCachedAlertElement; +@property (nonatomic, nullable) id cachedAlertSnapshot; +@property (nonatomic) BOOL hasCachedAlertSnapshot; @end @implementation FBAlert @@ -34,13 +38,60 @@ + (instancetype)alertWithApplication:(XCUIApplication *)application return alert; } -- (BOOL)isPresent +// Resolved (and, if found, snapshotted) at most once per instance and then +// reused for every subsequent call - see FBAlert.h. This is what makes an +// isPresent check followed by an action (the FBAlertsMonitor/FBSession +// auto-accept flow) act on the exact same alert it just observed, instead of +// re-resolving against a UI that may have changed in between. +- (nullable XCUIElement *)alertElement { - @try { - return nil != [self alertElementFromApplication]; - } @catch (NSException *) { - return NO; + if (!self.hasCachedAlertElement) { + id snapshot = nil; + self.cachedAlertElement = [self alertElementFromApplication:&snapshot]; + self.hasCachedAlertElement = YES; + if (nil != snapshot) { + self.cachedAlertSnapshot = snapshot; + self.hasCachedAlertSnapshot = YES; + } } + return self.cachedAlertElement; +} + +// The alert element's own subtree snapshot, taken once. All read-only +// accessors (text, buttonLabels) and all element lookups (buttons, input +// fields) work off this single snapshot in memory - only tapping/typing into +// a specific already-located element pays for one more, narrowly targeted +// accessibility round trip (see XCUIApplication.fb_elementForSnapshot:underElement:). +// fb_cachedSnapshot is tried before fb_customSnapshot: it can reconstruct +// the element's snapshot from the detection query's already-fetched +// rootElementSnapshot without a further round trip (verified empirically - +// see XCUIApplication+FBAlert.m). +- (nullable id)alertSnapshot +{ + if (!self.hasCachedAlertSnapshot) { + XCUIElement *alertElement = self.alertElement; + self.cachedAlertSnapshot = nil == alertElement + ? nil + : (alertElement.lastSnapshot ?: alertElement.fb_cachedSnapshot ?: [alertElement fb_customSnapshot]); + self.hasCachedAlertSnapshot = YES; + } + return self.cachedAlertSnapshot; +} + ++ (NSArray> *)fb_buttonSnapshotsInSnapshot:(id)snapshot +{ + NSMutableArray> *buttons = [NSMutableArray array]; + [snapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (descendant.elementType == XCUIElementTypeButton) { + [buttons addObject:descendant]; + } + }]; + return buttons.copy; +} + +- (BOOL)isPresent +{ + return nil != self.alertElement; } - (void)fb_raiseNotPresentException __attribute__((noreturn)) @@ -77,12 +128,11 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + id snapshot = self.alertSnapshot; + if (nil == snapshot) { return nil; } - id snapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; NSMutableArray *resultText = [NSMutableArray array]; BOOL isSafariAlert = [self.class isSafariWebAlertWithSnapshot:snapshot]; [snapshot enumerateDescendantsUsingBlock:^(id descendant) { @@ -116,36 +166,45 @@ - (NSString *)text - (void)typeText:(NSString *)text { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { [self fb_raiseNotPresentException]; } - NSPredicate *textCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu}", - XCUIElementTypeTextField, XCUIElementTypeSecureTextField]; - NSArray *dstFields = [[alertElement descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:textCollectorPredicate].allElementsBoundByIndex; - if (dstFields.count > 1) { + NSMutableArray> *dstFieldSnapshots = [NSMutableArray array]; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + XCUIElementType elementType = descendant.elementType; + if (elementType == XCUIElementTypeTextField || elementType == XCUIElementTypeSecureTextField) { + [dstFieldSnapshots addObject:descendant]; + } + }]; + if (dstFieldSnapshots.count > 1) { [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains more than one input field"]; } - if (0 == dstFields.count) { + id dstFieldSnapshot = dstFieldSnapshots.firstObject; + if (nil == dstFieldSnapshot) { [self fb_raiseSetTextFailedExceptionWithReason:@"The alert contains no input fields"]; } + XCUIElement *dstField = [XCUIApplication fb_elementForSnapshot:dstFieldSnapshot + underElement:alertElement]; + if (nil == dstField) { + [self fb_raiseSetTextFailedExceptionWithReason:@"Failed to resolve the input field element"]; + } NSError *error; - if (![dstFields.firstObject fb_typeText:text shouldClear:YES error:&error]) { + if (![dstField fb_typeText:text shouldClear:YES error:&error]) { [self fb_raiseSetTextFailedExceptionWithReason:error.description]; } } - (NSArray *)buttonLabels { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + id alertSnapshot = self.alertSnapshot; + if (nil == alertSnapshot) { return nil; } NSMutableArray *labels = [NSMutableArray array]; - id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { if (descendant.elementType != XCUIElementTypeButton) { return; @@ -160,12 +219,12 @@ - (NSArray *)buttonLabels - (void)accept { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { [self fb_raiseNotPresentException]; } - id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; XCUIElement *acceptButton = nil; if (FBConfiguration.acceptAlertButtonSelector.length) { NSString *errorReason = nil; @@ -185,11 +244,13 @@ - (void)accept } } if (nil == acceptButton) { - NSArray *buttons = [alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.lastObject - : buttons.firstObject; + NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; + id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttonSnapshots.lastObject + : buttonSnapshots.firstObject; + if (nil != chosenSnapshot) { + acceptButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + } } if (nil == acceptButton) { [self fb_raiseActionFailedExceptionWithReason: @@ -200,12 +261,12 @@ - (void)accept - (void)dismiss { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { [self fb_raiseNotPresentException]; } - id alertSnapshot = alertElement.lastSnapshot ?: [alertElement fb_customSnapshot]; XCUIElement *dismissButton = nil; if (FBConfiguration.dismissAlertButtonSelector.length) { NSString *errorReason = nil; @@ -225,11 +286,13 @@ - (void)dismiss } } if (nil == dismissButton) { - NSArray *buttons = [alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.firstObject - : buttons.lastObject; + NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; + id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttonSnapshots.firstObject + : buttonSnapshots.lastObject; + if (nil != chosenSnapshot) { + dismissButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + } } if (nil == dismissButton) { @@ -241,14 +304,25 @@ - (void)dismiss - (void)clickAlertButton:(NSString *)label { - XCUIElement *alertElement = [self alertElementFromApplication]; - if (nil == alertElement) { + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { [self fb_raiseNotPresentException]; } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; - XCUIElement *requestedButton = [[alertElement descendantsMatchingType:XCUIElementTypeButton] - matchingPredicate:predicate].allElementsBoundByIndex.firstObject; + __block id matchedSnapshot = nil; + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (nil != matchedSnapshot || descendant.elementType != XCUIElementTypeButton) { + return; + } + NSString *btnLabel = [FBXCElementSnapshotWrapper ensureWrapped:descendant].wdLabel; + if (nil != btnLabel && [btnLabel isEqualToString:label]) { + matchedSnapshot = descendant; + } + }]; + XCUIElement *requestedButton = nil == matchedSnapshot + ? nil + : [XCUIApplication fb_elementForSnapshot:matchedSnapshot underElement:alertElement]; if (!requestedButton) { [self fb_raiseActionFailedExceptionWithReason: [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertElement]]; @@ -258,7 +332,7 @@ - (void)clickAlertButton:(NSString *)label - (void)clickElementMatchingClassChain:(NSString *)classChain { - XCUIElement *alertElement = [self alertElementFromApplication]; + XCUIElement *alertElement = self.alertElement; if (nil == alertElement) { [self fb_raiseNotPresentException]; } @@ -281,13 +355,14 @@ - (void)clickElementMatchingClassChain:(NSString *)classChain // Single source of truth for alert detection: checks each candidate // application (systemApp, then self.application if different, then the iOS // 18+ limited access prompt app) via targeted, predicate-filtered live -// queries (see XCUIApplication.fb_alertElement) instead of snapshotting and -// walking the whole application tree - the cost stays proportional to the -// number of alert-shaped elements rather than the size/depth of the app, -// which matters a lot for deeply nested view hierarchies. Every public -// method funnels through this. No caching: each call re-resolves fresh so -// a stale/replaced alert element is never reused across calls. -- (nullable XCUIElement *)alertElementFromApplication +// queries (see XCUIApplication.fb_alertElementWithSnapshot:) instead of +// snapshotting and walking the whole application tree - the cost stays +// proportional to the number of alert-shaped elements rather than the +// size/depth of the app, which matters a lot for deeply nested view +// hierarchies. Only ever invoked once per instance, by the alertElement +// getter above, which caches both the element and (if one comes back) the +// snapshot obtained as a side effect of resolving it. +- (nullable XCUIElement *)alertElementFromApplication:(id _Nullable *)snapshotOut { @try { XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; @@ -300,7 +375,7 @@ - (nullable XCUIElement *)alertElementFromApplication [candidates addObject:promptApp]; } for (XCUIApplication *candidate in candidates) { - XCUIElement *element = candidate.fb_alertElement; + XCUIElement *element = [candidate fb_alertElementWithSnapshot:snapshotOut]; if (nil != element) { return element; } diff --git a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m index 1be635dac..395d3b3fa 100644 --- a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m @@ -69,40 +69,38 @@ - (void)showApplicationSheet - (void)testAlertPresence { - FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertFalse(alert.isPresent); + XCTAssertFalse([FBAlert alertWithApplication:self.testedApplication].isPresent); [self showApplicationAlert]; - XCTAssertTrue(alert.isPresent); + XCTAssertTrue([FBAlert alertWithApplication:self.testedApplication].isPresent); } - (void)testAlertText { - FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertNil(alert.text); + XCTAssertNil([FBAlert alertWithApplication:self.testedApplication].text); [self showApplicationAlert]; - XCTAssertTrue([alert.text containsString:@"Magic"]); - XCTAssertTrue([alert.text containsString:@"Should read"]); + NSString *text = [FBAlert alertWithApplication:self.testedApplication].text; + XCTAssertTrue([text containsString:@"Magic"]); + XCTAssertTrue([text containsString:@"Should read"]); } - (void)testAlertLabels { - FBAlert* alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertNil(alert.buttonLabels); + XCTAssertNil([FBAlert alertWithApplication:self.testedApplication].buttonLabels); [self showApplicationAlert]; - XCTAssertNotNil(alert.buttonLabels); - XCTAssertEqual(1, alert.buttonLabels.count); - XCTAssertEqualObjects(@"Will do", alert.buttonLabels[0]); + NSArray *labels = [FBAlert alertWithApplication:self.testedApplication].buttonLabels; + XCTAssertNotNil(labels); + XCTAssertEqual(1, labels.count); + XCTAssertEqualObjects(@"Will do", labels[0]); } - (void)testClickAlertButton { - FBAlert* alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertThrows([alert clickAlertButton:@"Invalid"]); + XCTAssertThrows([[FBAlert alertWithApplication:self.testedApplication] clickAlertButton:@"Invalid"]); [self showApplicationAlert]; - XCTAssertThrows([alert clickAlertButton:@"Invalid"]); - FBAssertWaitTillBecomesTrue(alert.isPresent); - XCTAssertNoThrow([alert clickAlertButton:@"Will do"]); - FBAssertWaitTillBecomesTrue(!alert.isPresent); + XCTAssertThrows([[FBAlert alertWithApplication:self.testedApplication] clickAlertButton:@"Invalid"]); + FBAssertWaitTillBecomesTrue([FBAlert alertWithApplication:self.testedApplication].isPresent); + XCTAssertNoThrow([[FBAlert alertWithApplication:self.testedApplication] clickAlertButton:@"Will do"]); + FBAssertWaitTillBecomesTrue(![FBAlert alertWithApplication:self.testedApplication].isPresent); } - (void)testAcceptingAlert @@ -145,34 +143,33 @@ - (void)testDismissingAlertWithCustomLocator - (void)testNotificationAlert { - FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertNil(alert.text); + XCTAssertNil([FBAlert alertWithApplication:self.testedApplication].text); [self.testedApplication.buttons[@"Create Notification Alert"] tap]; - FBAssertWaitTillBecomesTrue(alert.isPresent); + FBAssertWaitTillBecomesTrue([FBAlert alertWithApplication:self.testedApplication].isPresent); - XCTAssertTrue([alert.text containsString:@"Would Like to Send You Notifications"]); - XCTAssertTrue([alert.text containsString:@"Notifications may include"]); + NSString *text = [FBAlert alertWithApplication:self.testedApplication].text; + XCTAssertTrue([text containsString:@"Would Like to Send You Notifications"]); + XCTAssertTrue([text containsString:@"Notifications may include"]); } - (void)testCameraRollAlert { - FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertNil(alert.text); + XCTAssertNil([FBAlert alertWithApplication:self.testedApplication].text); [self.testedApplication.buttons[@"Create Camera Roll Alert"] tap]; - FBAssertWaitTillBecomesTrue(alert.isPresent); + FBAssertWaitTillBecomesTrue([FBAlert alertWithApplication:self.testedApplication].isPresent); } - (void)testGPSAccessAlert { - FBAlert *alert = [FBAlert alertWithApplication:self.testedApplication]; - XCTAssertNil(alert.text); + XCTAssertNil([FBAlert alertWithApplication:self.testedApplication].text); [self.testedApplication.buttons[@"Create GPS access Alert"] tap]; - FBAssertWaitTillBecomesTrue(alert.isPresent); + FBAssertWaitTillBecomesTrue([FBAlert alertWithApplication:self.testedApplication].isPresent); - XCTAssertTrue([alert.text containsString:@"location"]); - XCTAssertTrue([alert.text containsString:@"Yo Yo"]); + NSString *text = [FBAlert alertWithApplication:self.testedApplication].text; + XCTAssertTrue([text containsString:@"location"]); + XCTAssertTrue([text containsString:@"Yo Yo"]); } @end From 06a27485006c40dfde068e63892d3c6c3adc3c76 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Mon, 3 Aug 2026 08:48:22 +0200 Subject: [PATCH 13/15] more fixes --- .../Categories/XCUIApplication+FBAlert.m | 15 +++++--- WebDriverAgentLib/FBAlert.m | 35 +++++++++++++++++-- WebDriverAgentLib/Routing/FBSession.m | 4 +-- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index b207595f9..56dd0b755 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -111,8 +111,13 @@ - (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _ { NSPredicate *predicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}", XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; + // allElementsBoundByAccessibilityElement resolves all matches in one + // round trip; allElementsBoundByIndex pays one extra round trip per + // match, which gets expensive while the target app is JS-blocked inside + // alert() (~5s per extra hop). Bound explicitly rather than via + // fb_allMatches, which would defer to the boundElementsByIndex setting. NSArray *candidates = [[self descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:predicate].allElementsBoundByIndex; + matchingPredicate:predicate].allElementsBoundByAccessibilityElement; if (0 == candidates.count) { return nil; } @@ -169,11 +174,13 @@ - (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _ // Check alert presence in Safari web view id safariAlertSnapshot = [self.class fb_findSafariAlertSnapshotInScrollView:scrollViewSnapshot]; if (nil != safariAlertSnapshot) { - XCUIElement *resolved = [self.class fb_elementForSnapshot:safariAlertSnapshot underElement:scrollView]; - if (nil != resolved && NULL != snapshotOut) { + // Not resolving safariAlertSnapshot to a live element here (another + // round trip) - scrollView is already live and is a valid ancestor + // for callers to resolve buttons/fields from later. + if (NULL != snapshotOut) { *snapshotOut = safariAlertSnapshot; } - return resolved; + return scrollView; } } diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 5da0b4922..e957d2e88 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -17,6 +17,7 @@ #import "XCUIApplication+FBAlert.h" #import "XCUIElement+FBClassChain.h" #import "XCUIElement+FBTyping.h" +#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" @@ -337,14 +338,42 @@ - (void)clickElementMatchingClassChain:(NSString *)classChain [self fb_raiseNotPresentException]; } - XCUIElement *matchedElement = nil; + // For a Safari web alert, alertElement is the containing scrollView, not + // the alert's own div (see fb_alertElementWithSnapshot:), so a classChain + // query from it could also match unrelated page content. Constrain + // matches to descendants of alertSnapshot by uid when available. + NSSet *alertSnapshotUids = nil; + id alertSnapshot = self.alertSnapshot; + if (nil != alertSnapshot) { + NSMutableSet *uids = [NSMutableSet set]; + NSString *rootUid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:alertSnapshot]; + if (nil != rootUid) { + [uids addObject:rootUid]; + } + [alertSnapshot enumerateDescendantsUsingBlock:^(id descendant) { + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:descendant]; + if (nil != uid) { + [uids addObject:uid]; + } + }]; + alertSnapshotUids = uids; + } + + NSArray *matches = nil; @try { - matchedElement = [[alertElement fb_descendantsMatchingClassChain:classChain - shouldReturnAfterFirstMatch:YES] firstObject]; + matches = [alertElement fb_descendantsMatchingClassChain:classChain + shouldReturnAfterFirstMatch:NO]; } @catch (NSException *ex) { [self fb_raiseActionFailedExceptionWithReason: [NSString stringWithFormat:@"Failed to match class chain selector '%@' for alert: %@. Original error: %@", classChain, alertElement, ex.reason]]; } + XCUIElement *matchedElement = nil; + for (XCUIElement *match in matches) { + if (nil == alertSnapshotUids || [alertSnapshotUids containsObject:match.fb_uid]) { + matchedElement = match; + break; + } + } if (nil == matchedElement) { [self fb_raiseActionFailedExceptionWithReason: [NSString stringWithFormat:@"Failed to find any element matching class chain selector '%@' for alert: %@", classChain, alertElement]]; diff --git a/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentLib/Routing/FBSession.m index acec5d7b5..c40ff3672 100644 --- a/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentLib/Routing/FBSession.m @@ -58,7 +58,7 @@ - (void)didDetectAlert:(FBAlert *)alert [alert clickElementMatchingClassChain:autoClickAlertSelector]; } @catch (NSException *e) { [FBLogger logFmt:@"Could not click at the alert element '%@'. Original error: %@", - autoClickAlertSelector, e.reason]; + autoClickAlertSelector, e.reason]; } // This setting has priority over other settings if enabled return; @@ -211,7 +211,7 @@ - (XCUIApplication *)activeApplication XCUIApplicationState testedAppState = self.testedApplication.state; if (testedAppState >= XCUIApplicationStateRunningForeground) { NSPredicate *searchPredicate = [NSPredicate predicateWithFormat:@"%K == %@ OR %K IN {%@, %@}", - @"elementType", @(XCUIElementTypeAlert), + @"elementType", @(XCUIElementTypeAlert), // To look for `SBTransientOverlayWindow` elements. See https://github.com/appium/WebDriverAgent/pull/946 @"identifier", @"SBTransientOverlayWindow", // To look for 'criticalAlertSetting' elements https://developer.apple.com/documentation/usernotifications/unnotificationsettings/criticalalertsetting From 3d95d4d63c38ca83b95a0f225dc258935ec53673 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Mon, 3 Aug 2026 10:20:16 +0200 Subject: [PATCH 14/15] Fix iOS < 18 accept/dismiss picking the wrong alert button buttonSnapshotsInSnapshot's in-memory tree-order walk doesn't always match a live XCUIElementQuery's ordering. Confirmed via manual testing against a real iOS 17.5 simulator: on the system location-permission alert, that mismatch put the non-dismissing "Precise: On/Off" toggle first among the "buttons", so accept/dismiss silently tapped it instead of a real action button and left the alert stuck open - which then cascaded into every subsequent test in the run (matches the CI failure pattern on ipad/iphone_int_test_1_Min_Xcode). Not reproduced on iOS 18+, so accept/dismiss keep the cheaper snapshot walk there and only fall back to a live descendantsMatchingType: query below iOS 18, mirroring how master's pre-rework code resolved alert buttons. Verified on freshly erased iOS 17.5 and iOS 27 simulators: FBAlertTests passes 11/11 on both. Co-Authored-By: Claude Sonnet 5 --- .../Categories/XCUIApplication+FBAlert.m | 5 +- WebDriverAgentLib/FBAlert.m | 58 +++++++++++++------ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 56dd0b755..6d844268a 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -113,9 +113,8 @@ - (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _ XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; // allElementsBoundByAccessibilityElement resolves all matches in one // round trip; allElementsBoundByIndex pays one extra round trip per - // match, which gets expensive while the target app is JS-blocked inside - // alert() (~5s per extra hop). Bound explicitly rather than via - // fb_allMatches, which would defer to the boundElementsByIndex setting. + // match, costly while the target is JS-blocked inside alert() (~5s per + // hop). NSArray *candidates = [[self descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:predicate].allElementsBoundByAccessibilityElement; if (0 == candidates.count) { diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index e957d2e88..3f3ca815f 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -79,6 +79,11 @@ - (nullable XCUIElement *)alertElement return self.cachedAlertSnapshot; } +- (BOOL)isPresent +{ + return nil != self.alertElement; +} + + (NSArray> *)fb_buttonSnapshotsInSnapshot:(id)snapshot { NSMutableArray> *buttons = [NSMutableArray array]; @@ -90,11 +95,6 @@ - (nullable XCUIElement *)alertElement return buttons.copy; } -- (BOOL)isPresent -{ - return nil != self.alertElement; -} - - (void)fb_raiseNotPresentException __attribute__((noreturn)) { @throw [NSException exceptionWithName:FBAlertNotPresentException @@ -245,12 +245,27 @@ - (void)accept } } if (nil == acceptButton) { - NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; - id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttonSnapshots.lastObject - : buttonSnapshots.firstObject; - if (nil != chosenSnapshot) { - acceptButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + // buttonSnapshotsInSnapshot's tree-order walk doesn't always match a + // live query's ordering: on the system location-permission alert (iOS + // 17.5, confirmed via manual testing), that mismatch put a + // non-dismissing "Precise: On/Off" toggle first, so accept/dismiss + // silently tapped the wrong control and left the alert on screen. Not + // reproduced on iOS 18+, so keep the cheaper snapshot walk there and + // only pay for a live query below iOS 18. + if (@available(iOS 18.0, *)) { + NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; + id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttonSnapshots.lastObject + : buttonSnapshots.firstObject; + if (nil != chosenSnapshot) { + acceptButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + } + } else { + NSArray *buttons = [alertElement.fb_query + descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttons.lastObject + : buttons.firstObject; } } if (nil == acceptButton) { @@ -287,12 +302,21 @@ - (void)dismiss } } if (nil == dismissButton) { - NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; - id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttonSnapshots.firstObject - : buttonSnapshots.lastObject; - if (nil != chosenSnapshot) { - dismissButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + // See the matching comment in accept. + if (@available(iOS 18.0, *)) { + NSArray> *buttonSnapshots = [self.class fb_buttonSnapshotsInSnapshot:alertSnapshot]; + id chosenSnapshot = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttonSnapshots.firstObject + : buttonSnapshots.lastObject; + if (nil != chosenSnapshot) { + dismissButton = [XCUIApplication fb_elementForSnapshot:chosenSnapshot underElement:alertElement]; + } + } else { + NSArray *buttons = [alertElement.fb_query + descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; + dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) + ? buttons.firstObject + : buttons.lastObject; } } From 81f3e5680999b8466be21cd4b613b8c5b0184189 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Mon, 3 Aug 2026 15:57:50 +0200 Subject: [PATCH 15/15] Address PR review comments from Dan-Maor - Move fb_elementForSnapshot:underElement: out of XCUIApplication+FBAlert into XCUIApplication+FBHelpers, since it's a generic snapshot-to-live- element resolver with no alert-specific logic. - In that method, filter by the snapshot's own elementType instead of XCUIElementTypeAny, letting the query narrow down before the uid predicate is applied (~150ms/~12% faster on a dismiss action per Dan-Maor's measurement). - Add an explicit nil-guard for scrollViewSnapshot at the top of fb_findSafariAlertSnapshotInScrollView: - ObjC's nil-messaging already made this safe in practice, but the guard documents the invariant instead of leaving it implicit. Verified: FBAlertTests (11/11) on a freshly erased iOS 27 simulator, and the real appium-xcuitest-driver safari-alerts e2e suite (5/5, 1 correctly skipped) against WDA rebuilt from this source. Co-Authored-By: Claude Sonnet 5 --- .../Categories/XCUIApplication+FBAlert.h | 13 ------------- .../Categories/XCUIApplication+FBAlert.m | 19 ++++--------------- .../Categories/XCUIApplication+FBHelpers.h | 15 +++++++++++++++ .../Categories/XCUIApplication+FBHelpers.m | 16 ++++++++++++++++ WebDriverAgentLib/FBAlert.m | 1 + 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index 8c6839013..ec6ada745 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h @@ -32,19 +32,6 @@ extern NSString *const FB_SAFARI_APP_NAME; */ - (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _Nullable * _Nullable)snapshotOut; -/** - Resolves the live element that corresponds to an already-known snapshot - found somewhere under rootElement, by matching on its stable uid, instead - of re-running a fresh attribute/type-based query - a single targeted - accessibility round trip regardless of how deep the snapshot sits. - - @param snapshot The snapshot to resolve a live element for - @param rootElement The element to scope the uid lookup query to - @return The live element matching the snapshot's uid, or nil if it could not be resolved - */ -+ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot - underElement:(XCUIElement *)rootElement; - /** Retrieve the application hosting the iOS 18+ limited access permission prompt, cheaply gated on its running state so callers can avoid resolving its alert diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m index 6d844268a..fdbf5f574 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -8,10 +8,8 @@ #import "XCUIApplication+FBAlert.h" -#import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCodeCompatibility.h" -#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #define MAX_CENTER_DELTA 10.0 @@ -33,6 +31,10 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication + (nullable id)fb_findSafariAlertSnapshotInScrollView:(id)scrollViewSnapshot { + if (nil == scrollViewSnapshot) { + return nil; + } + CGRect appFrame = scrollViewSnapshot.frame; __block id webView = nil; @@ -81,19 +83,6 @@ + (nullable XCUIApplication *)fb_limitedAccessPromptApplication return candidate; } -+ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot - underElement:(XCUIElement *)rootElement -{ - NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; - if (nil == uid) { - return nil; - } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", - FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; - return [[rootElement.fb_query descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:predicate].allElementsBoundByIndex.firstObject; -} - // Resolving a query (e.g. allElementsBoundByIndex) is itself // as expensive as taking a snapshot - it has to walk/resolve the matching // subtree either way. So this issues exactly ONE such query, matching all diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.h b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.h index f5d358ff9..6a0bd751c 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.h +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.h @@ -8,6 +8,8 @@ #import +#import "FBXCElementSnapshot.h" + @class XCElementSnapshot; @protocol FBXCAccessibilityElement; @class FBXMLGenerationOptions; @@ -166,6 +168,19 @@ NS_ASSUME_NONNULL_BEGIN */ - (BOOL)fb_isSameAppAs:(nullable XCUIApplication *)otherApp; +/** + Resolves the live element that corresponds to an already-known snapshot + found somewhere under rootElement, by matching on its stable uid, instead + of re-running a fresh attribute/type-based query - a single targeted + accessibility round trip regardless of how deep the snapshot sits. + + @param snapshot The snapshot to resolve a live element for + @param rootElement The element to scope the uid lookup query to + @return The live element matching the snapshot's uid, or nil if it could not be resolved + */ ++ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot + underElement:(XCUIElement *)rootElement; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m index 454288831..a42ae9403 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBHelpers.m @@ -33,6 +33,7 @@ #import "XCUIElement.h" #import "XCUIElement+FBCaching.h" #import "XCUIElement+FBIsVisible.h" +#import "XCUIElement+FBUID.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" #import "XCUIElementQuery.h" @@ -653,4 +654,19 @@ - (BOOL)fb_isSameAppAs:(nullable XCUIApplication *)otherApp return self == otherApp || [self.bundleID isEqualToString:(NSString *)otherApp.bundleID]; } ++ (nullable XCUIElement *)fb_elementForSnapshot:(id)snapshot + underElement:(XCUIElement *)rootElement +{ + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; + if (nil == uid) { + return nil; + } + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", + FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; + // Filtering by the snapshot's own type (instead of XCUIElementTypeAny) lets + // the query narrow down before the uid predicate is even applied. + return [[rootElement.fb_query descendantsMatchingType:snapshot.elementType] + matchingPredicate:predicate].allElementsBoundByIndex.firstObject; +} + @end diff --git a/WebDriverAgentLib/FBAlert.m b/WebDriverAgentLib/FBAlert.m index 3f3ca815f..46fd622c9 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -15,6 +15,7 @@ #import "FBXCodeCompatibility.h" #import "XCUIApplication.h" #import "XCUIApplication+FBAlert.h" +#import "XCUIApplication+FBHelpers.h" #import "XCUIElement+FBClassChain.h" #import "XCUIElement+FBTyping.h" #import "XCUIElement+FBUID.h"