Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
39 changes: 32 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,42 @@ NS_ASSUME_NONNULL_BEGIN
extern NSString *const FB_SAFARI_APP_NAME;

/**
Retrieve the current alert element
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_alertElementWithSnapshot:(id<FBXCElementSnapshot> _Nullable * _Nullable)snapshotOut;

@return Alert element instance
/**
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_alertElement;
+ (nullable XCUIElement *)fb_elementForSnapshot:(id<FBXCElementSnapshot>)snapshot
Comment thread
Dan-Maor marked this conversation as resolved.
Outdated
underElement:(XCUIElement *)rootElement;

/**
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
185 changes: 126 additions & 59 deletions WebDriverAgentLib/Categories/XCUIApplication+FBAlert.m
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,95 +25,161 @@

@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
+ (nullable XCUIElement *)fb_elementForSnapshot:(id<FBXCElementSnapshot>)snapshot
underElement:(XCUIElement *)rootElement
{
NSPredicate *alertCollectorPredicate = [NSPredicate predicateWithFormat:@"elementType IN {%lu,%lu,%lu}",
XCUIElementTypeAlert, XCUIElementTypeSheet, XCUIElementTypeScrollView];
XCUIElement *alert = [[self descendantsMatchingType:XCUIElementTypeAny]
matchingPredicate:alertCollectorPredicate].allElementsBoundByIndex.firstObject;
if (nil == alert) {
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]
Comment thread
Dan-Maor marked this conversation as resolved.
Outdated
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
// 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<FBXCElementSnapshot> _Nullable * _Nullable)snapshotOut
{
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<XCUIElement *> *candidates = [[self descendantsMatchingType:XCUIElementTypeAny]
matchingPredicate:predicate].allElementsBoundByAccessibilityElement;
if (0 == candidates.count) {
return nil;
}
id<FBXCElementSnapshot> alertSnapshot = alert.fb_cachedSnapshot ?: [alert fb_customSnapshot];

if (alertSnapshot.elementType == XCUIElementTypeAlert) {
return alert;
NSMutableArray<XCUIElement *> *sheets = [NSMutableArray array];
NSMutableArray<XCUIElement *> *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<FBXCElementSnapshot> ancestor = alertSnapshot.parent;
id<FBXCElementSnapshot> sheetSnapshot = sheet.lastSnapshot ?: sheet.fb_cachedSnapshot ?: [sheet fb_customSnapshot];
BOOL isInsidePopover = NO;
id<FBXCElementSnapshot> 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<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 (XCUIElement *scrollView in scrollViews) {
id<FBXCElementSnapshot> scrollViewSnapshot = scrollView.lastSnapshot ?: scrollView.fb_cachedSnapshot ?: [scrollView fb_customSnapshot];
id<FBXCElementSnapshot> 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<FBXCElementSnapshot> 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;
}
}

Expand Down
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