Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions WebDriverAgentLib/Categories/XCUIApplication+FBAlert.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

#import <XCTest/XCTest.h>

#import "FBXCElementSnapshot.h"

NS_ASSUME_NONNULL_BEGIN

@interface XCUIApplication (FBAlert)
Expand All @@ -16,19 +18,24 @@ 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<FBXCElementSnapshot>)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
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

Expand Down
164 changes: 102 additions & 62 deletions WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m
Original file line number Diff line number Diff line change
Expand Up @@ -24,99 +24,139 @@

@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<FBXCElementSnapshot>)viewSnapshot
+ (nullable id<FBXCElementSnapshot>)fb_findSafariAlertSnapshotInScrollView:(id<FBXCElementSnapshot>)scrollViewSnapshot
{
CGRect appFrame = viewSnapshot.frame;
NSPredicate *dstViewMatchPredicate = [NSPredicate predicateWithBlock:^BOOL(id<FBXCElementSnapshot> 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;
Comment thread
Dan-Maor marked this conversation as resolved.

__block id<FBXCElementSnapshot> webView = nil;
[scrollViewSnapshot enumerateDescendantsUsingBlock:^(id<FBXCElementSnapshot> 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<FBXCElementSnapshot> snapshot = candidate.fb_cachedSnapshot ?: [candidate fb_customSnapshot];
[snapshot enumerateDescendantsUsingBlock:^(id<FBXCElementSnapshot> 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<FBXCElementSnapshot> candidate = nil;
[webView enumerateDescendantsUsingBlock:^(id<FBXCElementSnapshot> 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<FBXCElementSnapshot> 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
// 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<FBXCElementSnapshot>)fb_findAlertSnapshotInApplicationSnapshot:(id<FBXCElementSnapshot>)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) {
return nil;
}
id<FBXCElementSnapshot> alertSnapshot = alert.fb_cachedSnapshot ?: [alert fb_customSnapshot];

if (alertSnapshot.elementType == XCUIElementTypeAlert) {
return alert;
__block id<FBXCElementSnapshot> alertSnapshot = nil;
NSMutableArray<id<FBXCElementSnapshot>> *sheetSnapshots = [NSMutableArray array];
NSMutableArray<id<FBXCElementSnapshot>> *scrollViewSnapshots = [NSMutableArray array];
[appSnapshot enumerateDescendantsUsingBlock:^(id<FBXCElementSnapshot> descendant) {
if (nil != alertSnapshot) {
return;
}
switch (descendant.elementType) {
case XCUIElementTypeAlert:
alertSnapshot = descendant;
break;
case XCUIElementTypeSheet:
[sheetSnapshots addObject:descendant];
break;
case XCUIElementTypeScrollView:
[scrollViewSnapshots addObject:descendant];
break;
default:
break;
}
}];
if (nil != alertSnapshot) {
return alertSnapshot;
}

if (alertSnapshot.elementType == XCUIElementTypeSheet) {
if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) {
return alert;
BOOL isPhone = [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone;
for (id<FBXCElementSnapshot> 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<FBXCElementSnapshot> ancestor = alertSnapshot.parent;
BOOL isInsidePopover = NO;
id<FBXCElementSnapshot> ancestor = sheet.parent;
while (nil != ancestor) {
if (nil != ancestor.identifier && [ancestor.identifier isEqualToString:@"PopoverDismissRegion"]) {
return nil;
isInsidePopover = YES;
break;
}
ancestor = ancestor.parent;
}
return alert;
if (!isInsidePopover) {
return sheet;
}
}

if (alertSnapshot.elementType == XCUIElementTypeScrollView) {
id<FBXCElementSnapshot> 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 (id<FBXCElementSnapshot> scrollView in scrollViewSnapshots) {
id<FBXCElementSnapshot> 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<FBXCElementSnapshot> safariAlert = [self fb_findSafariAlertSnapshotInScrollView:scrollView];
if (nil != safariAlert) {
return safariAlert;
}
}

return nil;
}

- (nullable id<FBXCElementSnapshot>)fb_alertSnapshot
{
id<FBXCElementSnapshot> appSnapshot = self.fb_cachedSnapshot ?: self.fb_customSnapshot;
if (nil == appSnapshot) {
return nil;
}
return [self.class fb_findAlertSnapshotInApplicationSnapshot:appSnapshot];
}

@end
46 changes: 10 additions & 36 deletions WebDriverAgentLib/Commands/FBAlertViewCommands.m
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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();
}
Expand All @@ -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();
}
Expand All @@ -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
Loading