XCTestBootstrap/Utility/FBXCTestResultBundleParser.m (699 lines of code) (raw):

/* * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ #import "FBXCTestResultBundleParser.h" int const XCTestOperationTimeoutSecs = 120; NS_ASSUME_NONNULL_BEGIN #pragma mark STATIC FUNCTIONS static inline id readFromDict(NSDictionary *dict, NSString *key, Class klass) { id val = dict[key]; NSCAssert(val, @"%@ is not present in dict", key); val = [val isKindOfClass:klass] ? val : nil; NSCAssert(val, @"%@ is not a %@", key, klass); return val; } static inline NSNumber *readNumberFromDict(NSDictionary *dict, NSString *key) { return readFromDict(dict, key, NSNumber.class); } static inline double readDoubleFromDict(NSDictionary *dict, NSString *key) { NSNumber *number = readNumberFromDict(dict, key); return [number doubleValue]; } static inline NSString *readStringFromDict(NSDictionary *dict, NSString *key) { return readFromDict(dict, key, NSString.class); } static inline NSArray *readArrayFromDict(NSDictionary *dict, NSString *key) { return readFromDict(dict, key, NSArray.class); } static inline NSArray *unwrapValues(NSDictionary<NSString *, NSObject *> *wrapped) { @try { return readFromDict(wrapped, @"_values", NSArray.class); } @catch (id e) { return nil; } } static inline id unwrapValue(NSDictionary<NSString *, NSObject *> *wrapped) { @try { return readFromDict(wrapped, @"_value", NSObject.class); } @catch (id e) { return nil; } } static NSArray *accessAndUnwrapValues(NSDictionary<NSString *, NSDictionary<NSString *, NSObject *> *> *dict, NSString *key, id<FBControlCoreLogger> logger) { NSDictionary<NSString *, NSObject *> *wrapped = dict[key]; if (wrapped != nil) { NSArray *unwrapped = unwrapValues(wrapped); if (unwrapped == nil) { [logger logFormat:@"Failed to unwrap values for %@ from %@", key, [FBCollectionInformation oneLineDescriptionFromArray:[wrapped allKeys]]]; } return unwrapped; } else { [logger logFormat:@"%@ does not exist inside %@", key, [FBCollectionInformation oneLineDescriptionFromArray:[dict allKeys]]]; return nil; } } static id accessAndUnwrapValue(NSDictionary<NSString *, NSDictionary<NSString *, NSObject *> *> *dict, NSString *key, id<FBControlCoreLogger> logger) { NSDictionary<NSString *, NSObject *> *wrapped = dict[key]; if (wrapped != nil) { id unwrapped = unwrapValue(wrapped); if (unwrapped == nil) { [logger logFormat:@"Failed to unwrap value for %@ from %@", key, [FBCollectionInformation oneLineDescriptionFromArray:[wrapped allKeys]]]; } return unwrapped; } else { [logger logFormat:@"%@ does not exist inside %@", key, [FBCollectionInformation oneLineDescriptionFromArray:[dict allKeys]]]; return nil; } } static inline NSDate *dateFromString(NSString *date) { // @lint-ignore FBOBJCDISCOURAGEDFUNCTION NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss.SSSZ"]; return [dateFormatter dateFromString:date]; } @implementation FBXCTestResultBundleParser #pragma mark PUBLIC + (FBFuture<NSNull *> *)parse:(NSString *)resultBundlePath target:(id<FBiOSTarget>)target reporter:(id<FBXCTestReporter>)reporter logger:(id<FBControlCoreLogger>)logger { [logger logFormat:@"Parsing the result bundle %@", resultBundlePath]; NSString *testSummariesPath = [resultBundlePath stringByAppendingPathComponent:@"TestSummaries.plist"]; NSDictionary<NSString *, NSArray *> *results = [NSDictionary dictionaryWithContentsOfFile:testSummariesPath]; NSString *resultBundleInfoPath = [resultBundlePath stringByAppendingPathComponent:@"Info.plist"]; NSDictionary<NSString *, NSObject *> *bundleInfo = [NSDictionary dictionaryWithContentsOfFile:resultBundleInfoPath]; id bundleFormatVersion =[bundleInfo valueForKey:@"version"]; if (results) { [self reportResults:results reporter:reporter]; [logger logFormat:@"ResultBundlePath: %@", resultBundlePath]; return FBFuture.empty; } else if (bundleFormatVersion && [bundleFormatVersion isKindOfClass:NSDictionary.class]) { NSNumber *majorVersion = readNumberFromDict(bundleFormatVersion, @"major"); NSNumber *minorVersion = readNumberFromDict(bundleFormatVersion, @"minor"); [logger logFormat:@"Test result bundle format version: %@.%@", majorVersion, minorVersion]; return [[FBXCTestResultToolOperation getJSONFrom:resultBundlePath forId:nil queue:target.workQueue logger:logger] onQueue:target.workQueue fmap:^(NSDictionary<NSString *, NSDictionary<NSString *, id> *> *actionsInvocationRecord) { NSDictionary<NSString *, NSArray *> *actions = actionsInvocationRecord[@"actions"]; NSArray<NSString *> *ids = [self parseActions:actions logger:logger]; NSMutableArray<FBFuture *> *operations = NSMutableArray.array; for (NSString *bundleObjectId in ids) { FBFuture *operation = [[FBXCTestResultToolOperation getJSONFrom:resultBundlePath forId:bundleObjectId queue:target.workQueue logger:logger] onQueue:target.workQueue doOnResolved:^void (NSDictionary<NSString *, NSDictionary<NSString *, id> *> *xcresults) { [logger logFormat:@"Parsing summaries for id %@", bundleObjectId]; NSArray<NSDictionary *> *summaries = accessAndUnwrapValues(xcresults, @"summaries", logger); [self reportSummaries:summaries reporter:reporter queue:target.asyncQueue resultBundlePath:resultBundlePath logger:logger]; [logger logFormat:@"Done parsing summaries for id %@", bundleObjectId]; }]; [operations addObject:operation]; } return [FBFuture futureWithFutures:operations]; }]; } else { [reporter testPlanDidFailWithMessage:@"No test results were produced"]; return FBFuture.empty; } } #pragma mark Private: Legacy XCTest Result Parsing + (void)reportResults:(NSDictionary<NSString *, NSArray *> *)results reporter:(id<FBXCTestReporter>)reporter { NSAssert([results isKindOfClass:NSDictionary.class], @"Test results not a NSDictionary"); NSArray<NSDictionary *> *testTargets = results[@"TestableSummaries"]; [self reportTargetTests:testTargets reporter:reporter]; } + (void)reportTargetTests:(NSArray<NSDictionary *> *)targetTests reporter:(id<FBXCTestReporter>)reporter { NSAssert(targetTests, @"targetTests is nil"); NSAssert([targetTests isKindOfClass:NSArray.class], @"targetTests not a NSArray"); for (NSDictionary<NSString *, NSObject *> *targetTest in targetTests) { [self reportTargetTest:targetTest reporter:reporter]; } } + (void)reportTargetTest:(NSDictionary<NSString *, NSObject *> *)targetTest reporter:(id<FBXCTestReporter>)reporter { NSAssert(targetTest, @"targetTest is nil"); NSAssert([targetTest isKindOfClass:NSDictionary.class], @"targetTest not a NSDictionary"); NSString *testBundleName = readStringFromDict(targetTest, @"TestName"); NSArray<NSDictionary *> *selectedTests = (NSArray<NSDictionary *> *)targetTest[@"Tests"]; [self reportSelectedTests:selectedTests testBundleName:testBundleName reporter:reporter]; } + (void)reportSelectedTests:(NSArray<NSDictionary *> *)selectedTests testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(selectedTests, @"selectedTests is nil"); NSAssert([selectedTests isKindOfClass:NSArray.class], @"selectedTests not a NSArray"); for (NSDictionary *selectedTest in selectedTests) { [self reportSelectedTest:selectedTest testBundleName:testBundleName reporter:reporter]; } } + (void)reportSelectedTest:(NSDictionary<NSString *, NSObject *> *)selectedTest testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(selectedTest, @"selectedTest is nil"); NSAssert([selectedTest isKindOfClass:NSDictionary.class], @"selectedTest not a NSDictionary"); NSArray<NSDictionary *> *testTargetXctests = (NSArray<NSDictionary *> *)selectedTest[@"Subtests"]; [self reportTestTargetXctests:testTargetXctests testBundleName:testBundleName reporter:reporter]; } + (void)reportTestTargetXctests:(NSArray<NSDictionary *> *)testTargetXctests testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testTargetXctests, @"testTargetXctests is nil"); NSAssert([testTargetXctests isKindOfClass:NSArray.class], @"testTargetXctests not a NSArray"); for (NSDictionary *testTargetXctest in testTargetXctests) { [self reportTestTargetXctest:testTargetXctest testBundleName:testBundleName reporter:reporter]; } } + (void)reportTestTargetXctest:(NSDictionary<NSString *, NSObject *> *)testTargetXctest testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testTargetXctest, @"selectedTest is nil"); NSAssert([testTargetXctest isKindOfClass:NSDictionary.class], @"testTargetXctest not a NSDictionary"); NSArray *testClasses = (NSArray *)testTargetXctest[@"Subtests"]; [self reportTestClasses:testClasses testBundleName:testBundleName reporter:reporter]; } + (void)reportTestClasses:(NSArray<NSDictionary *> *)testClasses testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testClasses, @"selectedTest is nil"); NSAssert([testClasses isKindOfClass:NSArray.class], @"testClasses not a NSArray"); for (NSDictionary *testClass in testClasses) { [self reportTestClass:testClass testBundleName:testBundleName reporter:reporter]; } } + (void)reportTestClass:(NSDictionary<NSString *, NSObject *> *)testClass testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testClass, @"selectedTest is nil"); NSAssert([testClass isKindOfClass:NSDictionary.class], @"testClass not a NSDictionary"); NSString *testClassName = readStringFromDict(testClass, @"TestIdentifier"); NSArray<NSDictionary *> *testMethods = (NSArray<NSDictionary *> *)testClass[@"Subtests"]; [self reportTestMethods:testMethods testBundleName:testBundleName testClassName:testClassName reporter:reporter]; } + (void)reportTestMethods:(NSArray<NSDictionary *> *)testMethods testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testMethods, @"testMethods is nil"); NSAssert([testMethods isKindOfClass:NSArray.class], @"testMethods not a NSArray"); for (NSDictionary *testMethod in testMethods) { [self reportTestMethod:testMethod testBundleName:testBundleName testClassName:testClassName reporter:reporter]; } } + (void)reportTestMethod:(NSDictionary<NSString *, NSObject *> *)testMethod testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName reporter:(id<FBXCTestReporter>)reporter { NSAssert(testMethod, @"testMethod is nil"); NSAssert([testMethod isKindOfClass:NSDictionary.class], @"testMethod not a NSDictionary"); NSString *testStatus = readStringFromDict(testMethod, @"TestStatus"); NSString *testMethodName = readStringFromDict(testMethod, @"TestIdentifier"); NSNumber *duration = readNumberFromDict(testMethod, @"Duration"); FBTestReportStatus status = FBTestReportStatusUnknown; if ([testStatus isEqualToString:@"Success"]) { status = FBTestReportStatusPassed; } if ([testStatus isEqualToString:@"Failure"]) { status = FBTestReportStatusFailed; } NSArray *activitySummaries = readArrayFromDict(testMethod, @"ActivitySummaries"); NSMutableArray *logs = [self buildTestLogLegacy:activitySummaries testBundleName:testBundleName testClassName:testClassName testMethodName:testMethodName testPassed:status == FBTestReportStatusPassed duration:[duration doubleValue]]; [reporter testCaseDidStartForTestClass:testClassName method:testMethodName]; if (status == FBTestReportStatusFailed) { NSArray *failureSummaries = readArrayFromDict(testMethod, @"FailureSummaries"); [reporter testCaseDidFailForTestClass:testClassName method:testMethodName withMessage:[self buildErrorMessageLegacy:failureSummaries] file:nil line:0]; } [reporter testCaseDidFinishForTestClass:testClassName method:testMethodName withStatus:status duration:[duration doubleValue] logs:[logs copy]]; } + (NSMutableArray<NSString *> *)buildTestLogLegacy:(NSArray<NSDictionary *> *)activitySummaries testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName testMethodName:(NSString *)testMethodName testPassed:(BOOL)testPassed duration:(double)duration { NSMutableArray *logs = [NSMutableArray array]; NSString *testCaseFullName = [NSString stringWithFormat:@"-[%@.%@ %@]", testBundleName, testClassName, testMethodName]; [logs addObject:[NSString stringWithFormat:@"Test Case '%@' started.", testCaseFullName]]; double testStartTimeInterval = 0; BOOL startTimeSet = NO; for (NSDictionary *activitySummary in activitySummaries) { if (!startTimeSet) { testStartTimeInterval = readDoubleFromDict(activitySummary, @"StartTimeInterval"); startTimeSet = YES; } NSString *activityType = readStringFromDict(activitySummary, @"ActivityType"); if ([activityType isEqualToString:@"com.apple.dt.xctest.activity-type.internal"]) { [self addTestLogsFromLegacyActivitySummary:activitySummary logs:logs testStartTimeInterval:testStartTimeInterval indent:0]; } } [logs addObject:[NSString stringWithFormat:@"Test Case '%@' %@ in %.3f seconds", testCaseFullName, testPassed ? @"passed" : @"failed", duration]]; return logs; } + (void)addTestLogsFromLegacyActivitySummary:(NSDictionary *)activitySummary logs:(NSMutableArray<NSString *> *)logs testStartTimeInterval:(double)testStartTimeInterval indent:(NSUInteger)indent { NSAssert([activitySummary isKindOfClass:NSDictionary.class], @"activitySummary not a NSDictionary"); NSString *message = readStringFromDict(activitySummary, @"Title"); double startTimeInterval = readDoubleFromDict(activitySummary, @"StartTimeInterval"); double elapsed = startTimeInterval - testStartTimeInterval; NSString *indentString = [@"" stringByPaddingToLength:1 + indent * 4 withString:@" " startingAtIndex:0]; NSString *log = [NSString stringWithFormat:@" t = %8.2fs%@%@", elapsed, indentString, message]; [logs addObject:log]; NSArray<NSDictionary *> *subActivities = (NSArray<NSDictionary *> *) activitySummary[@"SubActivities"]; if (!subActivities) { return; } NSAssert([subActivities isKindOfClass:NSArray.class], @"subActivities is not a NSArray"); for (NSDictionary *subActivity in subActivities) { [self addTestLogsFromLegacyActivitySummary:subActivity logs:logs testStartTimeInterval:testStartTimeInterval indent:indent + 1]; } } + (NSString *)buildErrorMessageLegacy:(NSArray<NSDictionary *> *)failureSummmaries { NSMutableArray *messages = [NSMutableArray array]; for (NSDictionary *failureSummary in failureSummmaries) { NSAssert([failureSummary isKindOfClass:NSDictionary.class], @"failureSummary is not a NSDictionary"); [messages addObject:readStringFromDict(failureSummary, @"Message")]; } return [messages componentsJoinedByString:@"\n"]; } #pragma mark Private: Xcode 11+ XCTest Result Parsing + (NSArray<NSString *> *)parseActions:(NSDictionary<NSString *, NSObject *> *)actions logger:(id<FBControlCoreLogger>)logger { NSAssert(actions, @"Test actions is nil"); NSAssert([actions isKindOfClass:NSDictionary.class], @"Test actions not a NSDictionary"); NSArray<NSDictionary *> *actionValues = unwrapValues(actions); NSAssert(actionValues, @"action values is nil"); NSMutableArray<NSString *> *ids = [[NSMutableArray alloc] init]; for (NSDictionary<NSString *, NSDictionary *> *action in actionValues) { [ids addObject:[self parseAction:action logger:logger]]; } return ids; } + (NSString *)parseAction:(NSDictionary<NSString *, NSDictionary *> *)action logger:(id<FBControlCoreLogger>)logger { NSAssert(action, @"action is nil"); NSAssert([action isKindOfClass:NSDictionary.class], @"action not a NSDictionary"); NSDictionary<NSString *, NSDictionary *> *actionResult = action[@"actionResult"]; return [self parseActionResult:actionResult logger:logger]; } + (NSString *)parseActionResult:(NSDictionary<NSString *, NSDictionary *> *)actionResult logger:(id<FBControlCoreLogger>)logger { NSAssert(actionResult, @"action result is nil"); NSAssert([actionResult isKindOfClass:NSDictionary.class], @"action result not a NSDictionary"); NSDictionary<NSString *, NSDictionary *> *testsRef = actionResult[@"testsRef"]; return [self parseTestsRef:testsRef logger:logger]; } + (NSString *)parseTestsRef:(NSDictionary<NSString *, NSDictionary *> *)testsRef logger:(id<FBControlCoreLogger>)logger { NSAssert(testsRef, @"tests ref is nil"); NSAssert([testsRef isKindOfClass:NSDictionary.class], @"tests ref not a NSDictionary"); return (NSString *)accessAndUnwrapValue(testsRef, @"id", logger); } + (void)reportSummaries:(NSArray<NSDictionary *> *)summaries reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(summaries, @"Test summaries have no value"); NSAssert([summaries isKindOfClass:NSArray.class], @"Test summary values not a NSArray"); for (NSDictionary<NSString *, NSDictionary *> *summary in summaries) { [self reportResults:summary reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportResults:(NSDictionary<NSString *, NSDictionary *> *)results reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert([results isKindOfClass:NSDictionary.class], @"Test results not a NSDictionary"); NSArray<NSDictionary *> *testTargets = accessAndUnwrapValues(results, @"testableSummaries", logger); [self reportTargetTests:testTargets reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } + (void)reportTargetTests:(NSArray<NSDictionary *> *)targetTests reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(targetTests, @"targetTests is nil"); NSAssert([targetTests isKindOfClass:NSArray.class], @"targetTests not a NSArray"); for (NSDictionary<NSString *, NSDictionary *> *targetTest in targetTests) { [self reportTargetTest:targetTest reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportTargetTest:(NSDictionary<NSString *, NSDictionary *> *)targetTest reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(targetTest, @"targetTest is nil"); NSAssert([targetTest isKindOfClass:NSDictionary.class], @"targetTest not a NSDictionary"); NSString *testBundleName = (NSString *)accessAndUnwrapValue(targetTest, @"targetName", logger); NSArray<NSDictionary *> *selectedTests = accessAndUnwrapValues(targetTest, @"tests", logger); if (selectedTests != nil) { [self reportSelectedTests:selectedTests testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } else { [logger log:@"Test failed and no test results found in the bundle"]; NSArray *failureSummaries = accessAndUnwrapValues(targetTest, @"failureSummaries", logger); [reporter testCaseDidFailForTestClass:@"" method:@"" withMessage:[self buildErrorMessage:failureSummaries logger:logger] file:nil line:0]; } } + (void)reportSelectedTests:(NSArray<NSDictionary *> *)selectedTests testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(selectedTests, @"selectedTests is nil"); NSAssert([selectedTests isKindOfClass:NSArray.class], @"selectedTests not a NSArray"); for (NSDictionary *selectedTest in selectedTests) { [self reportSelectedTest:selectedTest testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportSelectedTest:(NSDictionary<NSString *, NSDictionary *> *)selectedTest testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(selectedTest, @"selectedTest is nil"); NSAssert([selectedTest isKindOfClass:NSDictionary.class], @"selectedTest not a NSDictionary"); NSArray<NSDictionary *> *testTargetXctests = accessAndUnwrapValues(selectedTest, @"subtests", logger); if (testTargetXctests != nil) { [self reportTestTargetXctests:testTargetXctests testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } else { [logger log:@"Test failed and no target test results found in the bundle"]; [reporter testCaseDidFailForTestClass:@"" method:@"" withMessage:@"" file:nil line:0]; } } + (void)reportTestTargetXctests:(NSArray<NSDictionary *> *)testTargetXctests testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testTargetXctests, @"testTargetXctests is nil"); NSAssert([testTargetXctests isKindOfClass:NSArray.class], @"testTargetXctests not a NSArray"); for (NSDictionary *testTargetXctest in testTargetXctests) { [self reportTestTargetXctest:testTargetXctest testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportTestTargetXctest:(NSDictionary<NSString *, NSDictionary *> *)testTargetXctest testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testTargetXctest, @"selectedTest is nil"); NSAssert([testTargetXctest isKindOfClass:NSDictionary.class], @"testTargetXctest not a NSDictionary"); NSArray *testClasses = accessAndUnwrapValues(testTargetXctest, @"subtests", logger); if (testClasses != nil) { [self reportTestClasses:testClasses testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } else { [logger log:@"Test failed and no test class results found in the bundle"]; [reporter testCaseDidFailForTestClass:@"" method:@"" withMessage:@"" file:nil line:0]; } } + (void)reportTestClasses:(NSArray<NSDictionary *> *)testClasses testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testClasses, @"selectedTest is nil"); NSAssert([testClasses isKindOfClass:NSArray.class], @"testClasses not a NSArray"); for (NSDictionary *testClass in testClasses) { [self reportTestClass:testClass testBundleName:testBundleName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportTestClass:(NSDictionary<NSString *, NSDictionary *> *)testClass testBundleName:(NSString *)testBundleName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testClass, @"selectedTest is nil"); NSAssert([testClass isKindOfClass:NSDictionary.class], @"testClass not a NSDictionary"); NSString *testClassName = (NSString *)accessAndUnwrapValue(testClass, @"identifier", logger); NSArray<NSDictionary *> *testMethods = accessAndUnwrapValues(testClass, @"subtests", logger); if (testMethods != nil) { [self reportTestMethods:testMethods testBundleName:testBundleName testClassName:testClassName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } else { [logger logFormat:@"Test failed for %@ and no test method results found", testClassName]; [reporter testCaseDidFailForTestClass:testClassName method:@"" withMessage:@"" file:nil line:0]; } } + (void)reportTestMethods:(NSArray<NSDictionary *> *)testMethods testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testMethods, @"testMethods is nil"); NSAssert([testMethods isKindOfClass:NSArray.class], @"testMethods not a NSArray"); for (NSDictionary *testMethod in testMethods) { [self reportTestMethod:testMethod testBundleName:testBundleName testClassName:testClassName reporter:reporter queue:queue resultBundlePath:resultBundlePath logger:logger]; } } + (void)reportTestMethod:(NSDictionary<NSString *, NSDictionary *> *)testMethod testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName reporter:(id<FBXCTestReporter>)reporter queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSAssert(testMethod, @"testMethod is nil"); NSAssert([testMethod isKindOfClass:NSDictionary.class], @"testMethod not a NSDictionary"); NSString *testStatus = (NSString *)accessAndUnwrapValue(testMethod, @"testStatus", logger); NSString *testMethodIdentifier = (NSString *)accessAndUnwrapValue(testMethod, @"identifier", logger); NSNumber *duration = (NSNumber *)accessAndUnwrapValue(testMethod, @"duration", logger); FBTestReportStatus status = FBTestReportStatusUnknown; if ([testStatus isEqualToString:@"Success"]) { status = FBTestReportStatusPassed; } if ([testStatus isEqualToString:@"Failure"]) { status = FBTestReportStatusFailed; } [reporter testCaseDidStartForTestClass:testClassName method:testMethodIdentifier]; NSDictionary<NSString *, NSDictionary *> *summaryRef = testMethod[@"summaryRef"]; NSAssert(summaryRef, @"Summary reference is nil"); NSAssert([summaryRef isKindOfClass:NSDictionary.class], @"Summary reference not a NSDictionary"); NSString *summaryRefId = (NSString *)accessAndUnwrapValue(summaryRef, @"id", logger); if (summaryRefId != nil) { [[[FBXCTestResultToolOperation getJSONFrom:resultBundlePath forId:summaryRefId queue:queue logger:logger] onQueue:queue doOnResolved:^(NSDictionary<NSString *, NSDictionary<NSString *, id> *> *actionTestSummary) { if (status == FBTestReportStatusFailed) { NSArray *failureSummaries = accessAndUnwrapValues(actionTestSummary, @"failureSummaries", logger); [reporter testCaseDidFailForTestClass:testClassName method:testMethodIdentifier withMessage:[self buildErrorMessage:failureSummaries logger:logger] file:nil line:0]; } NSArray<NSDictionary *> *performanceMetrics = accessAndUnwrapValues(actionTestSummary, @"performanceMetrics", logger); if (performanceMetrics != nil) { NSString *testMethodName = (NSString *)accessAndUnwrapValue(testMethod, @"name", logger); NSString *suffix = @"()"; if ([testMethodName hasSuffix:suffix]) { testMethodName = [testMethodName substringToIndex:[testMethodName length] - [suffix length]]; } [self savePerformanceMetrics:performanceMetrics toTestResultBundle:resultBundlePath forTestTarget:testBundleName testClass:testClassName testMethod:testMethodName logger:logger]; } NSArray<NSDictionary *> *activitySummaries = accessAndUnwrapValues(actionTestSummary, @"activitySummaries", logger); [self extractScreenshotsFromActivities:activitySummaries queue:queue resultBundlePath:resultBundlePath logger:logger]; NSMutableArray *logs = [self buildTestLog:activitySummaries testBundleName:testBundleName testClassName:testClassName testMethodName:testMethodIdentifier testPassed:status == FBTestReportStatusPassed duration:[duration doubleValue] logger:logger]; [reporter testCaseDidFinishForTestClass:testClassName method:testMethodIdentifier withStatus:status duration:[duration doubleValue] logs:[logs copy]]; }] awaitWithTimeout:XCTestOperationTimeoutSecs error:nil]; } } + (NSMutableArray<NSString *> *)buildTestLog:(NSArray<NSDictionary *> *)activitySummaries testBundleName:(NSString *)testBundleName testClassName:(NSString *)testClassName testMethodName:(NSString *)testMethodName testPassed:(BOOL)testPassed duration:(double)duration logger:(id<FBControlCoreLogger>)logger { NSMutableArray *logs = [NSMutableArray array]; NSString *testCaseFullName = [NSString stringWithFormat:@"-[%@.%@ %@]", testBundleName, testClassName, testMethodName]; [logs addObject:[NSString stringWithFormat:@"Test Case '%@' started.", testCaseFullName]]; double testStartTimeInterval = 0; BOOL startTimeSet = NO; for (NSDictionary *activitySummary in activitySummaries) { if (!startTimeSet) { NSDate *date = dateFromString((NSString *)accessAndUnwrapValue(activitySummary, @"start", logger)); testStartTimeInterval = [date timeIntervalSince1970]; startTimeSet = YES; } NSString *activityType = (NSString *)accessAndUnwrapValue(activitySummary, @"activityType", logger); if ([activityType isEqualToString:@"com.apple.dt.xctest.activity-type.internal"]) { [self addTestLogsFromActivitySummary:activitySummary logs:logs testStartTimeInterval:testStartTimeInterval indent:0 logger:logger]; } } [logs addObject:[NSString stringWithFormat:@"Test Case '%@' %@ in %.3f seconds", testCaseFullName, testPassed ? @"passed" : @"failed", duration]]; return logs; } + (void)addTestLogsFromActivitySummary:(NSDictionary *)activitySummary logs:(NSMutableArray<NSString *> *)logs testStartTimeInterval:(double)testStartTimeInterval indent:(NSUInteger)indent logger:(id<FBControlCoreLogger>)logger { NSAssert([activitySummary isKindOfClass:NSDictionary.class], @"activitySummary not a NSDictionary"); NSString *message = (NSString *)accessAndUnwrapValue(activitySummary, @"title", logger); NSDate *date = dateFromString((NSString *)accessAndUnwrapValue(activitySummary, @"start", logger)); double startTimeInterval = [date timeIntervalSince1970]; double elapsed = startTimeInterval - testStartTimeInterval; NSString *indentString = [@"" stringByPaddingToLength:1 + indent * 4 withString:@" " startingAtIndex:0]; NSString *log = [NSString stringWithFormat:@" t = %8.2fs%@%@", elapsed, indentString, message]; [logs addObject:log]; NSDictionary<NSString *, NSObject *> *wrappedSubActivities = activitySummary[@"subactivities"]; if (!wrappedSubActivities) { return; } NSArray<NSDictionary *> *subActivities = unwrapValues(wrappedSubActivities); NSAssert([subActivities isKindOfClass:NSArray.class], @"subActivities is not a NSArray"); for (NSDictionary *subActivity in subActivities) { [self addTestLogsFromActivitySummary:subActivity logs:logs testStartTimeInterval:testStartTimeInterval indent:indent + 1 logger:logger]; } } + (void)extractScreenshotsFromActivities:(NSArray<NSDictionary *> *)activities queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { // Extract all screenshots to the "Attachments" folder just as in the legacy test result bundle NSError *error = nil; NSString *screenshotsPath = [self ensureSubdirectory:@"Attachments" insideResultBundle:resultBundlePath error:&error]; if (error != nil) { [logger logFormat:@"Failed to ensure attachments directory %@", error]; return; } for (NSDictionary *activity in activities) { if (activity[@"attachments"]) { NSArray<NSDictionary *> *attachments = accessAndUnwrapValues(activity, @"attachments", logger); [self extractScreenshotsFromAttachments:attachments to:screenshotsPath queue:queue resultBundlePath:resultBundlePath logger:logger]; } if (activity[@"subactivities"]) { NSArray<NSDictionary *> *subactivities = accessAndUnwrapValues(activity, @"subactivities", logger); [self extractScreenshotsFromActivities:subactivities queue:queue resultBundlePath:resultBundlePath logger:logger]; } } } + (NSString *)ensureSubdirectory:(NSString *)subdirectory insideResultBundle:(NSString *)resultBundlePath error:(NSError **)error { NSFileManager *fileManager = NSFileManager.defaultManager; NSString *subdirectoryFullPath = [resultBundlePath stringByAppendingPathComponent:subdirectory]; BOOL isDirectory = NO; if ([fileManager fileExistsAtPath:subdirectoryFullPath isDirectory:&isDirectory]) { if (!isDirectory) { return [[FBControlCoreError describeFormat:@"%@ is not a directory", subdirectoryFullPath] fail:error]; } } else { if (![fileManager createDirectoryAtPath:subdirectoryFullPath withIntermediateDirectories:NO attributes:nil error:error]) { return [[FBControlCoreError describeFormat:@"Failed to create directory at %@", subdirectoryFullPath] fail:error]; } } return subdirectoryFullPath; } + (void)extractScreenshotsFromAttachments:(NSArray<NSDictionary *> *)attachments to:(NSString *)destination queue:(dispatch_queue_t)queue resultBundlePath:(NSString *)resultBundlePath logger:(id<FBControlCoreLogger>)logger { NSError *error = nil; NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"^Screenshot_.*" options:0 error:&error]; NSAssert(regex, @"Screenshot filename regex failed to compile %@", error); for (NSDictionary<NSString *, NSDictionary *> *attachment in attachments) { if (attachment[@"filename"]) { NSString *filename = (NSString *)accessAndUnwrapValue(attachment, @"filename", logger); NSTextCheckingResult *matchResult = [regex firstMatchInString:filename options:0 range:NSMakeRange(0, [filename length])]; if (attachment[@"payloadRef"] && matchResult) { NSString *timestamp = (NSString *)accessAndUnwrapValue(attachment, @"timestamp", logger); filename = [[filename stringByDeletingPathExtension] stringByAppendingPathExtension:@"jpg"]; NSString *exportPath = [destination stringByAppendingPathComponent:[NSString stringWithFormat:@"%@_%@", timestamp, filename]]; NSDictionary<NSString *, NSDictionary *> *payloadRef = attachment[@"payloadRef"]; NSAssert(payloadRef, @"Screenshot payload reference is empty"); NSString *screenshotId = (NSString *)accessAndUnwrapValue(payloadRef, @"id", logger); NSString *screenshotType = (NSString *)accessAndUnwrapValue(attachment, @"uniformTypeIdentifier", logger); [[FBXCTestResultToolOperation exportJPEGFrom:resultBundlePath to:exportPath forId:screenshotId type:screenshotType queue:queue logger:logger] awaitWithTimeout:XCTestOperationTimeoutSecs error:nil]; } } } } + (void)savePerformanceMetrics:(NSArray<NSDictionary *> *)performanceMetrics toTestResultBundle:(NSString *)resultBundlePath forTestTarget:(NSString *)testTarget testClass:(NSString *)testClass testMethod:(NSString *)testMethod logger:(id<FBControlCoreLogger>)logger { NSMutableArray *metrics = [NSMutableArray new]; for (NSDictionary<NSString *, NSDictionary *> *performanceMetric in performanceMetrics) { NSString *metricName = accessAndUnwrapValue(performanceMetric, @"displayName", logger); NSString *metricUnit = accessAndUnwrapValue(performanceMetric, @"unitOfMeasurement", logger); NSString *metricIdentifier = accessAndUnwrapValue(performanceMetric, @"identifier", logger); NSArray *metricMeasurements = accessAndUnwrapValues(performanceMetric, @"measurements", logger); NSMutableArray<NSNumber *> *measurements = [NSMutableArray new]; for (NSDictionary *metricMeasurement in metricMeasurements) { [measurements addObject:(NSNumber *)unwrapValue(metricMeasurement)]; } NSDictionary *metric = [[NSDictionary alloc] initWithObjectsAndKeys: metricName, @"name", metricUnit, @"unit", metricIdentifier, @"identifier", measurements, @"measurements", nil]; [metrics addObject:metric]; } if (![NSJSONSerialization isValidJSONObject:metrics]) { [logger log:@"Not saving performance metrics as they're not valid json"]; return; } NSError *error = nil; NSData *json = [NSJSONSerialization dataWithJSONObject:metrics options:NSJSONWritingPrettyPrinted error:&error]; if (error != nil) { [logger logFormat:@"Failed to serilize performance metrics %@ with error %@", metrics, error]; return; } if (json == nil) { return; } NSString *performanceMetricsDirectory = [self ensureSubdirectory:@"Metrics" insideResultBundle:resultBundlePath error:&error]; if (error != nil) { [logger logFormat:@"Failed to ensure performance metrics directory %@", error]; return; } NSString *metricFilePath = [performanceMetricsDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"%@_%@_%@.json", testTarget, testClass, testMethod]]; [json writeToFile:metricFilePath atomically:YES]; } + (NSString *)buildErrorMessage:(NSArray<NSDictionary *> *)failureSummmaries logger:(id<FBControlCoreLogger>)logger { NSMutableArray *messages = [NSMutableArray array]; for (NSDictionary *failureSummary in failureSummmaries) { NSAssert([failureSummary isKindOfClass:NSDictionary.class], @"failureSummary is not a NSDictionary"); [messages addObject:(NSString *)accessAndUnwrapValue(failureSummary, @"message", logger)]; } return [messages componentsJoinedByString:@"\n"]; } @end NS_ASSUME_NONNULL_END