/** * Copyright (c) 2015-present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. */ #import "XCUIApplication+FBHelpers.h" #import "FBActiveAppDetectionPoint.h" #import "FBElementTypeTransformer.h" #import "FBKeyboard.h" #import "FBLogger.h" #import "FBExceptions.h" #import "FBMacros.h" #import "FBMathUtils.h" #import "FBRunLoopSpinner.h" #import "FBXCodeCompatibility.h" #import "FBXPath.h" #import "FBXCAccessibilityElement.h" #import "FBXCTestDaemonsProxy.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "FBXCAXClientProxy.h" #import "FBXMLGenerationOptions.h" #import "XCTestManager_ManagerInterface-Protocol.h" #import "XCTestPrivateSymbols.h" #import "XCTRunnerDaemonSession.h" #import "XCUIApplication.h" #import "XCUIApplicationImpl.h" #import "XCUIApplicationProcess.h" #import "XCUIDevice+FBHelpers.h" #import "XCUIElement.h" #import "XCUIElement+FBCaching.h" #import "XCUIElement+FBIsVisible.h" #import "XCUIElement+FBUtilities.h" #import "XCUIElement+FBWebDriverAttributes.h" #import "XCUIElementQuery.h" #import "FBElementHelpers.h" static NSString* const FBUnknownBundleId = @"unknown"; static NSString* const FBExclusionAttributeFrame = @"frame"; static NSString* const FBExclusionAttributeEnabled = @"enabled"; static NSString* const FBExclusionAttributeVisible = @"visible"; static NSString* const FBExclusionAttributeAccessible = @"accessible"; static NSString* const FBExclusionAttributeFocused = @"focused"; static NSString* const FBExclusionAttributePlaceholderValue = @"placeholderValue"; static NSString* const FBExclusionAttributeNativeFrame = @"nativeFrame"; static NSString* const FBExclusionAttributeTraits = @"traits"; static NSString* const FBExclusionAttributeMinValue = @"minValue"; static NSString* const FBExclusionAttributeMaxValue = @"maxValue"; _Nullable id extractIssueProperty(id issue, NSString *propertyName) { SEL selector = NSSelectorFromString(propertyName); NSMethodSignature *methodSignature = [issue methodSignatureForSelector:selector]; if (nil == methodSignature) { return nil; } NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; [invocation setSelector:selector]; [invocation invokeWithTarget:issue]; id __unsafe_unretained result; [invocation getReturnValue:&result]; return result; } NSDictionary *auditTypeNamesToValues(void) { static dispatch_once_t onceToken; static NSDictionary *result; dispatch_once(&onceToken, ^{ // https://developer.apple.com/documentation/xctest/xcuiaccessibilityaudittype?language=objc result = @{ @"XCUIAccessibilityAuditTypeAction": @(1UL << 32), @"XCUIAccessibilityAuditTypeAll": @(~0UL), @"XCUIAccessibilityAuditTypeContrast": @(1UL << 0), @"XCUIAccessibilityAuditTypeDynamicType": @(1UL << 16), @"XCUIAccessibilityAuditTypeElementDetection": @(1UL << 1), @"XCUIAccessibilityAuditTypeHitRegion": @(1UL << 2), @"XCUIAccessibilityAuditTypeParentChild": @(1UL << 33), @"XCUIAccessibilityAuditTypeSufficientElementDescription": @(1UL << 3), @"XCUIAccessibilityAuditTypeTextClipped": @(1UL << 17), @"XCUIAccessibilityAuditTypeTrait": @(1UL << 18), }; }); return result; } NSDictionary *auditTypeValuesToNames(void) { static dispatch_once_t onceToken; static NSDictionary *result; dispatch_once(&onceToken, ^{ NSMutableDictionary *inverted = [NSMutableDictionary new]; [auditTypeNamesToValues() enumerateKeysAndObjectsUsingBlock:^(NSString* key, NSNumber *value, BOOL *stop) { inverted[value] = key; }]; result = inverted.copy; }); return result; } NSDictionary *customExclusionAttributesMap(void) { static dispatch_once_t onceToken; static NSDictionary *result; dispatch_once(&onceToken, ^{ result = @{ FBExclusionAttributeVisible: FB_XCAXAIsVisibleAttributeName, FBExclusionAttributeAccessible: FB_XCAXAIsElementAttributeName, }; }); return result; } @implementation XCUIApplication (FBHelpers) - (BOOL)fb_waitForAppElement:(NSTimeInterval)timeout { __block BOOL canDetectAxElement = YES; int currentProcessIdentifier = [self.accessibilityElement processIdentifier]; BOOL result = [[[FBRunLoopSpinner new] timeout:timeout] spinUntilTrue:^BOOL{ id currentAppElement = FBActiveAppDetectionPoint.sharedInstance.axElement; canDetectAxElement = nil != currentAppElement; if (!canDetectAxElement) { return YES; } return currentAppElement.processIdentifier == currentProcessIdentifier; }]; return canDetectAxElement ? result : [self waitForExistenceWithTimeout:timeout]; } + (NSArray *> *)fb_appsInfoWithAxElements:(NSArray> *)axElements { NSMutableArray *> *result = [NSMutableArray array]; id proxy = [FBXCTestDaemonsProxy testRunnerProxy]; for (id axElement in axElements) { NSMutableDictionary *appInfo = [NSMutableDictionary dictionary]; pid_t pid = axElement.processIdentifier; appInfo[@"pid"] = @(pid); __block NSString *bundleId = nil; dispatch_semaphore_t sem = dispatch_semaphore_create(0); [proxy _XCT_requestBundleIDForPID:pid reply:^(NSString *bundleID, NSError *error) { if (nil == error) { bundleId = bundleID; } else { [FBLogger logFmt:@"Cannot request the bundle ID for process ID %@: %@", @(pid), error.description]; } dispatch_semaphore_signal(sem); }]; dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC))); appInfo[@"bundleId"] = bundleId ?: FBUnknownBundleId; [result addObject:appInfo.copy]; } return result.copy; } + (NSArray *> *)fb_activeAppsInfo { return [self fb_appsInfoWithAxElements:[FBXCAXClientProxy.sharedClient activeApplications]]; } - (BOOL)fb_deactivateWithDuration:(NSTimeInterval)duration error:(NSError **)error { if(![[XCUIDevice sharedDevice] fb_goToHomescreenWithError:error]) { return NO; } [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:MAX(duration, .0)]]; [self activate]; return YES; } - (NSDictionary *)fb_tree { return [self fb_tree:nil]; } - (NSDictionary *)fb_tree:(nullable NSSet *)excludedAttributes { id snapshot = [self fb_standardSnapshot]; return [self.class dictionaryForElement:snapshot recursive:YES excludedAttributes:excludedAttributes]; } - (NSDictionary *)fb_accessibilityTree { id snapshot = [self fb_standardSnapshot]; return [self.class accessibilityInfoForElement:snapshot]; } + (NSDictionary *)dictionaryForElement:(id)snapshot recursive:(BOOL)recursive excludedAttributes:(nullable NSSet *)excludedAttributes { NSMutableDictionary *info = [[NSMutableDictionary alloc] init]; info[@"type"] = [FBElementTypeTransformer shortStringWithElementType:snapshot.elementType]; info[@"rawIdentifier"] = FBValueOrNull([snapshot.identifier isEqual:@""] ? nil : snapshot.identifier); FBXCElementSnapshotWrapper *wrappedSnapshot = [FBXCElementSnapshotWrapper ensureWrapped:snapshot]; info[@"name"] = FBValueOrNull(wrappedSnapshot.wdName); info[@"value"] = FBValueOrNull(wrappedSnapshot.wdValue); info[@"label"] = FBValueOrNull(wrappedSnapshot.wdLabel); info[@"rect"] = wrappedSnapshot.wdRect; NSDictionary *attributeBlocks = [self fb_attributeBlockMapForWrappedSnapshot:wrappedSnapshot]; NSSet *nonPrefixedKeys = [NSSet setWithObjects: FBExclusionAttributeFrame, FBExclusionAttributePlaceholderValue, FBExclusionAttributeNativeFrame, FBExclusionAttributeTraits, FBExclusionAttributeMinValue, FBExclusionAttributeMaxValue, nil]; for (NSString *key in attributeBlocks) { if (excludedAttributes == nil || ![excludedAttributes containsObject:key]) { NSString *value = ((NSString * (^)(void))attributeBlocks[key])(); if ([nonPrefixedKeys containsObject:key]) { info[key] = value; } else { info[[NSString stringWithFormat:@"is%@", [key capitalizedString]]] = value; } } } if (!recursive) { return info.copy; } NSArray *childElements = snapshot.children; if ([childElements count]) { info[@"children"] = [[NSMutableArray alloc] init]; for (id childSnapshot in childElements) { @autoreleasepool { [info[@"children"] addObject:[self dictionaryForElement:childSnapshot recursive:YES excludedAttributes:excludedAttributes]]; } } } return info; } // Helper used by `dictionaryForElement:` to assemble attribute value blocks, // including both common attributes and conditionally included ones like placeholderValue. + (NSDictionary *)fb_attributeBlockMapForWrappedSnapshot:(FBXCElementSnapshotWrapper *)wrappedSnapshot { // Base attributes common to every element NSMutableDictionary *blocks = [@{ FBExclusionAttributeFrame: ^{ return NSStringFromCGRect(wrappedSnapshot.wdFrame); }, FBExclusionAttributeNativeFrame: ^{ return NSStringFromCGRect(wrappedSnapshot.wdNativeFrame); }, FBExclusionAttributeEnabled: ^{ return [@([wrappedSnapshot isWDEnabled]) stringValue]; }, FBExclusionAttributeVisible: ^{ return [@([wrappedSnapshot isWDVisible]) stringValue]; }, FBExclusionAttributeAccessible: ^{ return [@([wrappedSnapshot isWDAccessible]) stringValue]; }, FBExclusionAttributeFocused: ^{ return [@([wrappedSnapshot isWDFocused]) stringValue]; }, FBExclusionAttributeTraits: ^{ return wrappedSnapshot.wdTraits; } } mutableCopy]; XCUIElementType elementType = wrappedSnapshot.elementType; // Text-input placeholder (only for elements that support inner text) if (FBDoesElementSupportInnerText(elementType)) { blocks[FBExclusionAttributePlaceholderValue] = ^{ return (NSString *)FBValueOrNull(wrappedSnapshot.wdPlaceholderValue); }; } // Only for elements that support min/max value if (FBDoesElementSupportMinMaxValue(elementType)) { blocks[FBExclusionAttributeMinValue] = ^{ return wrappedSnapshot.wdMinValue; }; blocks[FBExclusionAttributeMaxValue] = ^{ return wrappedSnapshot.wdMaxValue; }; } return [blocks copy]; } + (NSDictionary *)accessibilityInfoForElement:(id)snapshot { FBXCElementSnapshotWrapper *wrappedSnapshot = [FBXCElementSnapshotWrapper ensureWrapped:snapshot]; BOOL isAccessible = [wrappedSnapshot isWDAccessible]; BOOL isVisible = [wrappedSnapshot isWDVisible]; NSMutableDictionary *info = [[NSMutableDictionary alloc] init]; if (isAccessible) { if (isVisible) { info[@"value"] = FBValueOrNull(wrappedSnapshot.wdValue); info[@"label"] = FBValueOrNull(wrappedSnapshot.wdLabel); } } else { NSMutableArray *children = [[NSMutableArray alloc] init]; for (id childSnapshot in snapshot.children) { @autoreleasepool { NSDictionary *childInfo = [self accessibilityInfoForElement:childSnapshot]; if ([childInfo count]) { [children addObject: childInfo]; } } } if ([children count]) { info[@"children"] = [children copy]; } } if ([info count]) { info[@"type"] = [FBElementTypeTransformer shortStringWithElementType:snapshot.elementType]; info[@"rawIdentifier"] = FBValueOrNull([snapshot.identifier isEqual:@""] ? nil : snapshot.identifier); info[@"name"] = FBValueOrNull(wrappedSnapshot.wdName); } else { return nil; } return info; } - (NSString *)fb_xmlRepresentation { return [self fb_xmlRepresentationWithOptions:nil]; } - (NSString *)fb_xmlRepresentationWithOptions:(FBXMLGenerationOptions *)options { return [FBXPath xmlStringWithRootElement:self options:options]; } - (NSString *)fb_descriptionRepresentation { NSMutableArray *childrenDescriptions = [NSMutableArray array]; for (XCUIElement *child in [self.fb_query childrenMatchingType:XCUIElementTypeAny].allElementsBoundByIndex) { [childrenDescriptions addObject:child.debugDescription]; } // debugDescription property of XCUIApplication instance shows descendants addresses in memory // instead of the actual information about them, however the representation works properly // for all descendant elements return (0 == childrenDescriptions.count) ? self.debugDescription : [childrenDescriptions componentsJoinedByString:@"\n\n"]; } - (XCUIElement *)fb_activeElement { return [[[self.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:[NSPredicate predicateWithFormat:@"hasKeyboardFocus == YES"]] fb_firstMatch]; } #if TARGET_OS_TV - (XCUIElement *)fb_focusedElement { return [[[self.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:[NSPredicate predicateWithFormat:@"hasFocus == true"]] fb_firstMatch]; } #endif - (BOOL)fb_dismissKeyboardWithKeyNames:(nullable NSArray *)keyNames error:(NSError **)error { BOOL (^isKeyboardInvisible)(void) = ^BOOL(void) { return ![FBKeyboard waitUntilVisibleForApplication:self timeout:0 error:nil]; }; if (isKeyboardInvisible()) { // Short circuit if the keyboard is not visible return YES; } #if TARGET_OS_TV [[XCUIRemote sharedRemote] pressButton:XCUIRemoteButtonMenu]; #else NSArray *(^findMatchingKeys)(NSPredicate *) = ^NSArray *(NSPredicate * predicate) { NSPredicate *keysPredicate = [NSPredicate predicateWithFormat:@"elementType == %@", @(XCUIElementTypeKey)]; XCUIElementQuery *parentView = [[self.keyboard descendantsMatchingType:XCUIElementTypeOther] containingPredicate:keysPredicate]; return [[parentView childrenMatchingType:XCUIElementTypeAny] matchingPredicate:predicate].allElementsBoundByIndex; }; if (nil != keyNames && keyNames.count > 0) { NSPredicate *searchPredicate = [NSPredicate predicateWithBlock:^BOOL(id snapshot, NSDictionary *bindings) { if (snapshot.elementType != XCUIElementTypeKey && snapshot.elementType != XCUIElementTypeButton) { return NO; } return (nil != snapshot.identifier && [keyNames containsObject:snapshot.identifier]) || (nil != snapshot.label && [keyNames containsObject:snapshot.label]); }]; NSArray *matchedKeys = findMatchingKeys(searchPredicate); if (matchedKeys.count > 0) { for (XCUIElement *matchedKey in matchedKeys) { if (!matchedKey.exists) { continue; } [matchedKey tap]; if (isKeyboardInvisible()) { return YES; } } } } if ([UIDevice.currentDevice userInterfaceIdiom] == UIUserInterfaceIdiomPad) { NSPredicate *searchPredicate = [NSPredicate predicateWithFormat:@"elementType IN %@", @[@(XCUIElementTypeKey), @(XCUIElementTypeButton)]]; NSArray *matchedKeys = findMatchingKeys(searchPredicate); if (matchedKeys.count > 0) { [matchedKeys[matchedKeys.count - 1] tap]; } } #endif NSString *errorDescription = @"Did not know how to dismiss the keyboard. Try to dismiss it in the way supported by your application under test."; return [[[[FBRunLoopSpinner new] timeout:3] timeoutErrorMessage:errorDescription] spinUntilTrue:isKeyboardInvisible error:error]; } - (NSArray *> *)fb_performAccessibilityAuditWithAuditTypesSet:(NSSet *)auditTypes error:(NSError **)error; { uint64_t numTypes = 0; NSDictionary *namesMap = auditTypeNamesToValues(); for (NSString *value in auditTypes) { NSNumber *typeValue = namesMap[value]; if (nil == typeValue) { NSString *reason = [NSString stringWithFormat:@"Audit type value '%@' is not known. Only the following audit types are supported: %@", value, namesMap.allKeys]; @throw [NSException exceptionWithName:FBInvalidArgumentException reason:reason userInfo:@{}]; } numTypes |= [typeValue unsignedLongLongValue]; } return [self fb_performAccessibilityAuditWithAuditTypes:numTypes error:error]; } - (NSArray *> *)fb_performAccessibilityAuditWithAuditTypes:(uint64_t)auditTypes error:(NSError **)error; { SEL selector = NSSelectorFromString(@"performAccessibilityAuditWithAuditTypes:issueHandler:error:"); if (![self respondsToSelector:selector]) { [[[FBErrorBuilder alloc] withDescription:@"Accessibility audit is only supported since iOS 17/Xcode 15"] buildError:error]; return nil; } // These custom attributes could take too long to fetch, thus excluded NSSet *customAttributesToExclude = [NSSet setWithArray:[customExclusionAttributesMap() allKeys]]; NSMutableArray *resultArray = [NSMutableArray array]; NSMethodSignature *methodSignature = [self methodSignatureForSelector:selector]; NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; [invocation setSelector:selector]; [invocation setArgument:&auditTypes atIndex:2]; BOOL (^issueHandler)(id) = ^BOOL(id issue) { @autoreleasepool { NSString *auditType = @""; NSDictionary *valuesToNamesMap = auditTypeValuesToNames(); NSNumber *auditTypeValue = [issue valueForKey:@"auditType"]; if (nil != auditTypeValue) { auditType = valuesToNamesMap[auditTypeValue] ?: [auditTypeValue stringValue]; } id extractedElement = extractIssueProperty(issue, @"element"); id elementSnapshot = [extractedElement fb_cachedSnapshot] ?: [extractedElement fb_standardSnapshot]; NSDictionary *elementAttributes = elementSnapshot ? [self.class dictionaryForElement:elementSnapshot recursive:NO excludedAttributes:customAttributesToExclude] : @{}; [resultArray addObject:@{ @"detailedDescription": extractIssueProperty(issue, @"detailedDescription") ?: @"", @"compactDescription": extractIssueProperty(issue, @"compactDescription") ?: @"", @"auditType": auditType, @"element": [extractedElement description] ?: @"", @"elementDescription": [extractedElement debugDescription] ?: @"", @"elementAttributes": elementAttributes ?: @{}, }]; return YES; } }; [invocation setArgument:&issueHandler atIndex:3]; [invocation setArgument:&error atIndex:4]; [invocation invokeWithTarget:self]; BOOL isSuccessful; [invocation getReturnValue:&isSuccessful]; return isSuccessful ? resultArray.copy : nil; } + (instancetype)fb_activeApplication { return [self fb_activeApplicationWithDefaultBundleId:nil]; } + (NSArray *)fb_activeApplications { NSArray> *activeApplicationElements = [FBXCAXClientProxy.sharedClient activeApplications]; NSMutableArray *result = [NSMutableArray array]; if (activeApplicationElements.count > 0) { for (id applicationElement in activeApplicationElements) { XCUIApplication *app = [XCUIApplication fb_applicationWithPID:applicationElement.processIdentifier]; if (nil != app) { [result addObject:app]; } } } return result.count > 0 ? result.copy : @[self.class.fb_systemApplication]; } + (instancetype)fb_activeApplicationWithDefaultBundleId:(nullable NSString *)bundleId { NSArray> *activeApplicationElements = [FBXCAXClientProxy.sharedClient activeApplications]; id activeApplicationElement = nil; id currentElement = nil; if (nil != bundleId) { currentElement = FBActiveAppDetectionPoint.sharedInstance.axElement; if (nil != currentElement) { NSArray *appInfos = [self fb_appsInfoWithAxElements:@[currentElement]]; [FBLogger logFmt:@"Detected on-screen application: %@", appInfos.firstObject[@"bundleId"]]; if ([[appInfos.firstObject objectForKey:@"bundleId"] isEqualToString:(id)bundleId]) { activeApplicationElement = currentElement; } } } if (nil == activeApplicationElement && activeApplicationElements.count > 1) { if (nil != bundleId) { NSArray *appInfos = [self fb_appsInfoWithAxElements:activeApplicationElements]; NSMutableArray *bundleIds = [NSMutableArray array]; for (NSDictionary *appInfo in appInfos) { [bundleIds addObject:(NSString *)appInfo[@"bundleId"]]; } [FBLogger logFmt:@"Detected system active application(s): %@", bundleIds]; // Try to select the desired application first for (NSUInteger appIdx = 0; appIdx < appInfos.count; appIdx++) { if ([[[appInfos objectAtIndex:appIdx] objectForKey:@"bundleId"] isEqualToString:(id)bundleId]) { activeApplicationElement = [activeApplicationElements objectAtIndex:appIdx]; break; } } } // Fall back to the "normal" algorithm if the desired application is either // not set or is not active if (nil == activeApplicationElement) { if (nil == currentElement) { currentElement = FBActiveAppDetectionPoint.sharedInstance.axElement; } if (nil == currentElement) { [FBLogger log:@"Cannot precisely detect the current application. Will use the system's recently active one"]; if (nil == bundleId) { [FBLogger log:@"Consider changing the 'defaultActiveApplication' setting to the bundle identifier of the desired application under test"]; } } else { for (id appElement in activeApplicationElements) { if (appElement.processIdentifier == currentElement.processIdentifier) { activeApplicationElement = appElement; break; } } } } } if (nil != activeApplicationElement) { XCUIApplication *application = [XCUIApplication fb_applicationWithPID:activeApplicationElement.processIdentifier]; if (nil != application) { return application; } [FBLogger log:@"Cannot translate the active process identifier into an application object"]; } if (activeApplicationElements.count > 0) { [FBLogger logFmt:@"Getting the most recent active application (out of %@ total items)", @(activeApplicationElements.count)]; for (id appElement in activeApplicationElements) { XCUIApplication *application = [XCUIApplication fb_applicationWithPID:appElement.processIdentifier]; if (nil != application) { return application; } } } [FBLogger log:@"Cannot retrieve any active applications. Assuming the system application is the active one"]; return [self fb_systemApplication]; } + (instancetype)fb_systemApplication { return [self fb_applicationWithPID: [[FBXCAXClientProxy.sharedClient systemApplication] processIdentifier]]; } + (instancetype)fb_applicationWithPID:(pid_t)processID { return [FBXCAXClientProxy.sharedClient monitoredApplicationWithProcessIdentifier:processID]; } + (BOOL)fb_switchToSystemApplicationWithError:(NSError **)error { XCUIApplication *systemApp = self.fb_systemApplication; @try { if (systemApp.running) { [systemApp activate]; } else { [systemApp launch]; } } @catch (NSException *e) { return [[[FBErrorBuilder alloc] withDescription:nil == e ? @"Cannot open the home screen" : e.reason] buildError:error]; } return YES; } - (BOOL)fb_isSameAppAs:(nullable XCUIApplication *)otherApp { if (nil == otherApp) { return NO; } return self == otherApp || [self.bundleID isEqualToString:(NSString *)otherApp.bundleID]; } @end