diff --git a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h index d9c81ef8c..ec6ada745 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) @@ -16,19 +18,29 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const FB_SAFARI_APP_NAME; /** - Retrieve the current alert element - - @return Alert element instance + 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. + + @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; /** - 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 + snapshot 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 ba86f4bc1..fdbf5f574 100644 --- a/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m +++ b/WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m @@ -8,7 +8,6 @@ #import "XCUIApplication+FBAlert.h" -#import "FBMacros.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCodeCompatibility.h" #import "XCUIElement+FBUtilities.h" @@ -24,95 +23,152 @@ @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; + 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; + if (nil == scrollViewSnapshot) { + return nil; + } + + 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 +// 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 +// 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. +- (nullable XCUIElement *)fb_alertElementWithSnapshot:(id _Nullable * _Nullable)snapshotOut { - NSPredicate *alertCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}", - XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView]; - XCUIElement *alert = [[self descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:alertCollectorPredicate].allElementsBoundByIndex.firstObject; - if (nil == alert) { + 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, costly while the target is JS-blocked inside alert() (~5s per + // hop). + NSArray *candidates = [[self descendantsMatchingType:XCUIElementTypeAny] + matchingPredicate:predicate].allElementsBoundByAccessibilityElement; + if (0 == candidates.count) { return nil; } - id alertSnapshot = alert.fb_cachedSnapshot ?: [alert fb_customSnapshot]; - if (alertSnapshot.elementType == XCUIElementTypeAlert) { - return alert; + NSMutableArray *sheets = [NSMutableArray array]; + NSMutableArray *scrollViews = [NSMutableArray array]; + for (XCUIElement *candidate in candidates) { + switch (candidate.elementType) { + case XCUIElementTypeAlert: + return candidate; + case XCUIElementTypeSheet: + [sheets addObject:candidate]; + break; + case XCUIElementTypeScrollView: + [scrollViews addObject:candidate]; + break; + default: + break; + } } - if (alertSnapshot.elementType == XCUIElementTypeSheet) { - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) { - return alert; + BOOL isPhone = [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone; + 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 ancestor = alertSnapshot.parent; + id sheetSnapshot = sheet.lastSnapshot ?: sheet.fb_cachedSnapshot ?: [sheet fb_customSnapshot]; + BOOL isInsidePopover = NO; + id ancestor = sheetSnapshot.parent; while (nil != ancestor) { if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) { - return nil; + isInsidePopover = YES; + break; } ancestor = ancestor.parent; } - return alert; + if (!isInsidePopover) { + if (NULL != snapshotOut) { + *snapshotOut = sheetSnapshot; + } + return sheet; + } } - if (alertSnapshot.elementType == XCUIElementTypeScrollView) { - id app = [[FBXCElementSnapshotWrapper ensureWrapped:alertSnapshot] 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]; + for (XCUIElement *scrollView in scrollViews) { + 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; + } + // Check alert presence in Safari web view + id safariAlertSnapshot = [self.class fb_findSafariAlertSnapshotInScrollView:scrollViewSnapshot]; + if (nil != safariAlertSnapshot) { + // 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 scrollView; } } 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/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 8e9ec8cda..ddb013fda 100644 --- a/WebDriverAgentLib/FBAlert.h +++ b/WebDriverAgentLib/FBAlert.h @@ -9,7 +9,6 @@ #import @class XCUIApplication; -@class XCUIElement; NS_ASSUME_NONNULL_BEGIN @@ -26,65 +25,79 @@ 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. + + 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; /** - 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. + @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 + 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 - + 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; /** - 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. + @throws FBAlertNotPresentException if no alert is present. + @throws FBAlertActionFailedException if no matching element could be found. */ -- (nullable XCUIElement *)alertElement; +- (void)clickElementMatchingClassChain:(NSString *)classChain; /** - 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. - @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 fc620da05..46fd622c9 100644 --- a/WebDriverAgentLib/FBAlert.m +++ b/WebDriverAgentLib/FBAlert.m @@ -9,21 +9,26 @@ #import "FBAlert.h" #import "FBConfiguration.h" -#import "FBErrorBuilder.h" +#import "FBExceptions.h" #import "FBLogger.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #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" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" @interface FBAlert () @property (nonatomic, strong) XCUIApplication *application; -@property (nonatomic, strong, nullable) XCUIElement *element; +@property (nonatomic, nullable) XCUIElement *cachedAlertElement; +@property (nonatomic) BOOL hasCachedAlertElement; +@property (nonatomic, nullable) id cachedAlertSnapshot; +@property (nonatomic) BOOL hasCachedAlertSnapshot; @end @implementation FBAlert @@ -35,32 +40,81 @@ + (instancetype)alertWithApplication:(XCUIApplication *)application return alert; } -+ (instancetype)alertWithElement:(XCUIElement *)element +// 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 { - FBAlert *alert = [FBAlert new]; - alert.element = element; - alert.application = element.application; - return alert; + 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; } - (BOOL)isPresent { - @try { - if (nil == self.alertElement) { - return NO; + return nil != self.alertElement; +} + ++ (NSArray> *)fb_buttonSnapshotsInSnapshot:(id)snapshot +{ + NSMutableArray> *buttons = [NSMutableArray array]; + [snapshot enumerateDescendantsUsingBlock:^(id descendant) { + if (descendant.elementType == XCUIElementTypeButton) { + [buttons addObject:descendant]; } - [self.alertElement fb_customSnapshot]; - return YES; - } @catch (NSException *) { - return NO; - } + }]; + return buttons.copy; +} + +- (void)fb_raiseNotPresentException __attribute__((noreturn)) +{ + @throw [NSException exceptionWithName:FBAlertNotPresentException + reason:@"No alert is open" + userInfo:nil]; } -- (BOOL)notPresentWithError:(NSError **)error +- (void)fb_raiseActionFailedExceptionWithReason:(NSString *)reason __attribute__((noreturn)) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"No alert is open"] - buildError:error]; + @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 @@ -76,19 +130,19 @@ + (BOOL)isSafariWebAlertWithSnapshot:(id)snapshot - (NSString *)text { - if (!self.isPresent) { + id snapshot = self.alertSnapshot; + 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; if (!(elementType == XCUIElementTypeTextView || elementType == XCUIElementTypeStaticText)) { return; } - + FBXCElementSnapshotWrapper *descendantWrapper = [FBXCElementSnapshotWrapper ensureWrapped:descendant]; if (elementType == XCUIElementTypeStaticText && nil != [descendantWrapper fb_parentMatchingType:XCUIElementTypeButton]) { @@ -112,39 +166,47 @@ - (NSString *)text return [resultText componentsJoinedByString:@"\n"]; } -- (BOOL)typeText:(NSString *)text error:(NSError **)error +- (void)typeText:(NSString *)text { - if (!self.isPresent) { - return [self notPresentWithError:error]; + 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 = [[self.alertElement descendantsMatchingType:XCUIElementTypeAny] - matchingPredicate:textCollectorPredicate].allElementsBoundByIndex; - if (dstFields.count > 1) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The alert contains more than one input field"] - buildError:error]; + 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) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"The alert contains no input fields"] - buildError:error]; + 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 (![dstField fb_typeText:text shouldClear:YES error:&error]) { + [self fb_raiseSetTextFailedExceptionWithReason:error.description]; } - return [dstFields.firstObject fb_typeText:text - shouldClear:YES - error:error]; } - (NSArray *)buttonLabels { - if (!self.isPresent) { + id alertSnapshot = self.alertSnapshot; + 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; @@ -157,19 +219,20 @@ - (NSArray *)buttonLabels return labels.copy; } -- (BOOL)acceptWithError:(NSError **)error +- (void)accept { - if (!self.isPresent) { - return [self notPresentWithError:error]; + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { + [self fb_raiseNotPresentException]; } - 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 - shouldReturnAfterFirstMatch:YES] firstObject]; + acceptButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.acceptAlertButtonSelector + shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { errorReason = ex.reason; } @@ -183,34 +246,50 @@ - (BOOL)acceptWithError:(NSError **)error } } if (nil == acceptButton) { - NSArray *buttons = [self.alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - acceptButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.lastObject - : buttons.firstObject; + // 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) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find accept button for alert: %@", self.alertElement] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find accept button for alert: %@", alertElement]]; } [acceptButton tap]; - return YES; } -- (BOOL)dismissWithError:(NSError **)error +- (void)dismiss { - if (!self.isPresent) { - return [self notPresentWithError:error]; + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { + [self fb_raiseNotPresentException]; } - 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 - shouldReturnAfterFirstMatch:YES] firstObject]; + dismissButton = [[alertElement fb_descendantsMatchingClassChain:FBConfiguration.dismissAlertButtonSelector + shouldReturnAfterFirstMatch:YES] firstObject]; } @catch (NSException *ex) { errorReason = ex.reason; } @@ -224,54 +303,141 @@ - (BOOL)dismissWithError:(NSError **)error } } if (nil == dismissButton) { - NSArray *buttons = [self.alertElement.fb_query - descendantsMatchingType:XCUIElementTypeButton].allElementsBoundByIndex; - dismissButton = (alertSnapshot.elementType == XCUIElementTypeAlert || [self.class isSafariWebAlertWithSnapshot:alertSnapshot]) - ? buttons.firstObject - : buttons.lastObject; + // 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; + } } if (nil == dismissButton) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find dismiss button for alert: %@", self.alertElement] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find dismiss button for alert: %@", alertElement]]; } [dismissButton tap]; - return YES; } -- (BOOL)clickAlertButton:(NSString *)label error:(NSError **)error +- (void)clickAlertButton:(NSString *)label { - if (!self.isPresent) { - return [self notPresentWithError:error]; + XCUIElement *alertElement = self.alertElement; + id alertSnapshot = self.alertSnapshot; + if (nil == alertElement || nil == alertSnapshot) { + [self fb_raiseNotPresentException]; } - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"label == %@", label]; - XCUIElement *requestedButton = [[self.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) { - return [[[FBErrorBuilder builder] - withDescriptionFormat:@"Failed to find button with label '%@' for alert: %@", label, self.alertElement] - buildError:error]; + [self fb_raiseActionFailedExceptionWithReason: + [NSString stringWithFormat:@"Failed to find button with label '%@' for alert: %@", label, alertElement]]; } [requestedButton tap]; - return YES; } -- (XCUIElement *)alertElement +- (void)clickElementMatchingClassChain:(NSString *)classChain +{ + XCUIElement *alertElement = self.alertElement; + if (nil == alertElement) { + [self fb_raiseNotPresentException]; + } + + // 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 { + 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]]; + } + [matchedElement tap]; +} + +// 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_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 { - if (nil == self.element) { + @try { XCUIApplication *systemApp = XCUIApplication.fb_systemApplication; - if ([systemApp fb_isSameAppAs:self.application]) { - self.element = systemApp.fb_alertElement; - } else { - self.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 == self.element) { - self.element = [XCUIApplication fb_limitedAccessPromptAlertElement]; + XCUIApplication *promptApp = XCUIApplication.fb_limitedAccessPromptApplication; + if (nil != promptApp) { + [candidates addObject:promptApp]; } + for (XCUIApplication *candidate in candidates) { + XCUIElement *element = [candidate fb_alertElementWithSnapshot:snapshotOut]; + if (nil != element) { + return element; + } + } + } @catch (NSException *) { + return nil; } - return self.element; + return nil; } @end 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 b8de5edf6..c40ff3672 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. @@ -56,14 +55,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]; - } + [alert clickElementMatchingClassChain:autoClickAlertSelector]; } @catch (NSException *e) { [FBLogger logFmt:@"Could not click at the alert element '%@'. Original error: %@", - autoClickAlertSelector, e.description]; + autoClickAlertSelector, e.reason]; } // This setting has priority over other settings if enabled return; @@ -73,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]; @@ -213,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 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..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" @@ -100,10 +101,13 @@ + (nullable id)pasteboardContentForItem:(NSString *)item break; } - XCUIElement *alertElement = XCUIApplication.fb_systemApplication.fb_alertElement; - if (nil != alertElement) { - FBAlert *alert = [FBAlert alertWithElement:alertElement]; - [alert acceptWithError:nil]; + @try { + [[FBAlert alertWithApplication:XCUIApplication.fb_systemApplication] accept]; + } @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; if (timeElapsed / NSEC_PER_SEC > timeout) { diff --git a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m index 44b99a91c..395d3b3fa 100644 --- a/WebDriverAgentTests/IntegrationTests/FBAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBAlertTests.m @@ -69,60 +69,54 @@ - (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]; - XCTAssertFalse([alert clickAlertButton:@"Invalid" error:nil]); + XCTAssertThrows([[FBAlert alertWithApplication:self.testedApplication] clickAlertButton:@"Invalid"]); [self showApplicationAlert]; - XCTAssertFalse([alert clickAlertButton:@"Invalid" error:nil]); - FBAssertWaitTillBecomesTrue(alert.isPresent); - XCTAssertTrue([alert clickAlertButton:@"Will do" error:nil]); - 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 { - 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,65 +124,52 @@ - (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:@""]; } } -- (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]; - 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 diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index bfdd3dbb9..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" @@ -132,7 +133,14 @@ - (void)goToScrollPageWithCells:(BOOL)showCells - (void)clearAlert { [self.testedApplication fb_waitUntilStable]; - [[FBAlert alertWithApplication:self.testedApplication] dismissWithError:nil]; + @try { + [[FBAlert alertWithApplication:self.testedApplication] dismiss]; + } @catch (NSException *e) { + if (![e.name isEqualToString:FBAlertNotPresentException]) { + @throw e; + } + // 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