idb_companion/Utility/FBIDBXCTestReporter.mm (430 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 "FBIDBXCTestReporter.h" #import "FBXCTestReporterConfiguration.h" #import <XCTestBootstrap/XCTestBootstrap.h> @interface FBIDBXCTestReporter () @property (nonatomic, assign, readwrite) grpc::ServerWriter<idb::XctestRunResponse> *writer; @property (nonatomic, strong, readonly) dispatch_queue_t queue; @property (nonatomic, strong, readonly) id<FBControlCoreLogger> logger; @property (nonatomic, strong, readonly) FBMutableFuture<NSNumber *> *reportingTerminatedMutable; @property (nonatomic, strong, readonly) FBMutableFuture<NSNull *> *processUnderTestExitedMutable; @property (nonatomic, nullable, copy, readwrite) NSString *currentBundleName; @property (nonatomic, nullable, copy, readwrite) NSString *currentTestClass; @property (nonatomic, nullable, copy, readwrite) NSString *currentTestMethod; @property (nonatomic, strong, readonly) NSMutableArray<FBActivityRecord *> *currentActivityRecords; @property (nonatomic, assign, readwrite) idb::XctestRunResponse_TestRunInfo_TestRunFailureInfo failureInfo; @end @implementation FBIDBXCTestReporter #pragma mark Initializer - (instancetype)initWithResponseWriter:(grpc::ServerWriter<idb::XctestRunResponse> *)writer queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger { self = [super init]; if (!self) { return nil; } _writer = writer; _queue = queue; _logger = logger; _configuration = [[FBXCTestReporterConfiguration alloc] initWithResultBundlePath:nil coverageConfiguration:nil logDirectoryPath:nil binariesPaths:nil reportAttachments:NO]; _currentActivityRecords = NSMutableArray.array; _reportingTerminatedMutable = FBMutableFuture.future; _processUnderTestExitedMutable = FBMutableFuture.future; return self; } #pragma mark Properties - (FBFuture<NSNumber *> *)reportingTerminated { return self.reportingTerminatedMutable; } #pragma mark FBXCTestReporter - (void)testCaseDidStartForTestClass:(NSString *)testClass method:(NSString *)method { self.currentTestClass = testClass; self.currentTestMethod = method; } - (void)testPlanDidFailWithMessage:(NSString *)message { const idb::XctestRunResponse response = [self responseForCrashMessage:message]; [self writeResponse:response]; } - (void)testCaseDidFailForTestClass:(NSString *)testClass method:(NSString *)method withMessage:(NSString *)message file:(NSString *)file line:(NSUInteger)line { // testCaseDidFinishForTestClass will be called immediately after this call, this makes sure we attach the failure info to it. if (([testClass isEqualToString:self.currentTestClass] && [method isEqualToString:self.currentTestMethod]) == NO) { [self.logger logFormat:@"Got failure info for %@/%@ but the current known executing test is %@/%@. Ignoring it", testClass, method, self.currentTestClass, self.currentTestMethod]; return; } self.failureInfo = [self failureInfoWithMessage:message file:file line:line]; } - (void)testCaseDidFinishForTestClass:(NSString *)testClass method:(NSString *)method withStatus:(FBTestReportStatus)status duration:(NSTimeInterval)duration logs:(NSArray<NSString *> *)logs { const idb::XctestRunResponse_TestRunInfo info = [self runInfoForTestClass:testClass method:method withStatus:status duration:duration logs:logs]; [self writeTestRunInfo:info]; } - (void)testCase:(NSString *)testClass method:(NSString *)method didFinishActivity:(FBActivityRecord *)activity { [self.currentActivityRecords addObject:activity]; } - (void)testSuite:(NSString *)testSuite didStartAt:(NSString *)startTime { @synchronized (self) { self.currentBundleName = testSuite; } } - (void)didCrashDuringTest:(NSError *)error { const idb::XctestRunResponse response = [self responseForCrashMessage:error.localizedDescription]; [self writeResponse:response]; } - (void)testHadOutput:(NSString *)output { const idb::XctestRunResponse response = [self responseForLogOutput:@[output]]; [self writeResponse:response]; } - (void)handleExternalEvent:(NSString *)event { const idb::XctestRunResponse response = [self responseForLogOutput:@[event]]; [self writeResponse:response]; } - (void)didFinishExecutingTestPlan { const idb::XctestRunResponse response = [self responseForNormalTestTermination]; [self writeResponse:response]; } - (void)processUnderTestDidExit { [self.processUnderTestExitedMutable resolveWithResult:NSNull.null]; } #pragma mark FBXCTestReporter (Unused) - (BOOL)printReportWithError:(NSError **)error { return NO; } - (void)processWaitingForDebuggerWithProcessIdentifier:(pid_t)pid { [self.logger.info logFormat:@"Tests waiting for debugger. To debug run: lldb -p %d", pid]; idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_RUNNING); idb::DebuggerInfo *debugger_info = response.mutable_debugger(); debugger_info->set_pid(pid); [self writeResponse:response]; } - (void)didBeginExecutingTestPlan { } - (void)finishedWithSummary:(FBTestManagerResultSummary *)summary { // didFinishExecutingTestPlan should be used to signify completion instead } #pragma mark FBDataConsumer - (void)consumeData:(NSData *)data { idb::XctestRunResponse response = [self responseForLogData:data]; [self writeResponse:response]; } - (void)consumeEndOfFile { } #pragma mark Private - (const idb::XctestRunResponse_TestRunInfo)runInfoForTestClass:(NSString *)testClass method:(NSString *)method withStatus:(FBTestReportStatus)status duration:(NSTimeInterval)duration logs:(NSArray<NSString *> *)logs { idb::XctestRunResponse_TestRunInfo info; info.set_bundle_name(self.currentBundleName.UTF8String ?: ""); info.set_class_name(testClass.UTF8String ?: ""); info.set_method_name(method.UTF8String ?: ""); info.set_duration(duration); info.mutable_failure_info()->CopyFrom(self.failureInfo); switch (status) { case FBTestReportStatusPassed: info.set_status(idb::XctestRunResponse_TestRunInfo_Status_PASSED); break; case FBTestReportStatusFailed: info.set_status(idb::XctestRunResponse_TestRunInfo_Status_FAILED); break; default: break; } for (NSString *log in logs) { info.add_logs(log.UTF8String ?: ""); } NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"start" ascending:YES]; [self.currentActivityRecords sortUsingDescriptors:@[sortDescriptor]]; NSMutableArray<FBActivityRecord *> *stackedActivities = [NSMutableArray array]; while (self.currentActivityRecords.count) { FBActivityRecord *activity = self.currentActivityRecords[0]; [self.currentActivityRecords removeObjectAtIndex:0]; [self populateSubactivities:activity remaining:self.currentActivityRecords]; [stackedActivities addObject:activity]; } for (FBActivityRecord *activity in stackedActivities) { [self translateActivity:activity activityOut:info.add_activitylogs()]; } [self resetCurrentTestState]; return info; } - (void)populateSubactivities:(FBActivityRecord *)root remaining:(NSMutableArray<FBActivityRecord *> *)remaining { while (remaining.count && root.start.timeIntervalSince1970 <= remaining[0].start.timeIntervalSince1970 && root.finish.timeIntervalSince1970 >= remaining[0].finish.timeIntervalSince1970) { FBActivityRecord *sub = remaining[0]; [remaining removeObjectAtIndex:0]; [self populateSubactivities:sub remaining:remaining]; [root.subactivities addObject:sub]; } } - (void)translateActivity:(FBActivityRecord *)activity activityOut:(idb::XctestRunResponse_TestRunInfo_TestActivity *)activityOut { activityOut->set_title(activity.title.UTF8String ?: ""); activityOut->set_duration(activity.duration); activityOut->set_uuid(activity.uuid.UUIDString.UTF8String ?: ""); activityOut->set_activity_type(activity.activityType.UTF8String ?: ""); activityOut->set_start(activity.start.timeIntervalSince1970); activityOut->set_finish(activity.finish.timeIntervalSince1970); activityOut->set_name(activity.name.UTF8String ?: ""); if (self.configuration.reportAttachments) { for (FBAttachment *attachment in activity.attachments) { idb::XctestRunResponse_TestRunInfo_TestAttachment *attachmentOut = activityOut->add_attachments(); attachmentOut->set_payload(attachment.payload.bytes, attachment.payload.length); attachmentOut->set_name(attachment.name.UTF8String ?: ""); attachmentOut->set_timestamp(attachment.timestamp.timeIntervalSince1970); attachmentOut->set_uniform_type_identifier(attachment.uniformTypeIdentifier.UTF8String ?: ""); } } for (FBActivityRecord *subActitvity in activity.subactivities) { idb::XctestRunResponse_TestRunInfo_TestActivity *subactivityOut = activityOut->add_sub_activities(); [self translateActivity:subActitvity activityOut:subactivityOut]; } } - (const idb::XctestRunResponse_TestRunInfo_TestRunFailureInfo)failureInfoWithMessage:(NSString *)message file:(NSString *)file line:(NSUInteger)line { idb::XctestRunResponse_TestRunInfo_TestRunFailureInfo failureInfo; failureInfo.set_failure_message(message.UTF8String ?: ""); failureInfo.set_file(file.UTF8String ?: ""); failureInfo.set_line(line); return failureInfo; } - (const idb::XctestRunResponse)responseForLogOutput:(NSArray<NSString *> *)logOutput { idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_RUNNING); for (NSString *log in logOutput) { NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"Assertion failed: (.*), function (.*), file (.*), line (\\d+)." options:NSRegularExpressionCaseInsensitive error:nil]; NSTextCheckingResult *result = [regex firstMatchInString:log options:0 range:NSMakeRange(0, [log length])]; if (result) { self.failureInfo = [self failureInfoWithMessage:[log substringWithRange:[result rangeAtIndex:1]] file:[log substringWithRange:[result rangeAtIndex:3]] line:[[log substringWithRange:[result rangeAtIndex:4]] integerValue]]; } response.add_log_output(log.UTF8String ?: ""); } return response; } - (const idb::XctestRunResponse)responseForLogData:(NSData *)data { idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_RUNNING); response.add_log_output((char *) data.bytes, data.length); return response; } - (const idb::XctestRunResponse)responseForCrashMessage:(NSString *)message { idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_TERMINATED_ABNORMALLY); idb::XctestRunResponse_TestRunInfo *info = response.add_results(); info->set_bundle_name(self.currentBundleName.UTF8String ?: ""); info->set_class_name(self.currentTestClass.UTF8String ?: ""); info->set_method_name(self.currentTestMethod.UTF8String ?: ""); info->mutable_failure_info()->CopyFrom(self.failureInfo); info->mutable_failure_info()->set_failure_message(message.UTF8String); info->set_status(idb::XctestRunResponse_TestRunInfo_Status_CRASHED); [self resetCurrentTestState]; return response; } - (const idb::XctestRunResponse)responseForNormalTestTermination { idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_TERMINATED_NORMALLY); return response; } - (void)resetCurrentTestState { [self.currentActivityRecords removeAllObjects]; self.failureInfo = idb::XctestRunResponse_TestRunInfo_TestRunFailureInfo(); self.currentTestMethod = nil; self.currentTestClass = nil; } - (void)writeTestRunInfo:(const idb::XctestRunResponse_TestRunInfo &)info { idb::XctestRunResponse response; response.set_status(idb::XctestRunResponse_Status_RUNNING); response.add_results()->CopyFrom(info); [self writeResponse:response]; } - (void)writeResponse:(const idb::XctestRunResponse &)response { // If there's a result bundle and this is the last message, then append the result bundle. switch (response.status()) { case idb::XctestRunResponse_Status_TERMINATED_NORMALLY: case idb::XctestRunResponse_Status_TERMINATED_ABNORMALLY: [self insertFinalDataThenWriteResponse:response]; return; default: break; } [self writeResponseFinal:response]; } - (void)insertFinalDataThenWriteResponse:(const idb::XctestRunResponse &)response { // This method can make changes to the response object, however the reference is `const` so // it's necessary to make a copy of the object and use the copy throughout this method. // As the changes to the request (copy) will effectivelly happen inside blocks the reference to the copy // needs to be declared as __block otherwise the (reference to the) copy object will be destroyed // (together with the stack frame) before the blocks try to change it, causing memory access errors. __block idb::XctestRunResponse responseCopy; responseCopy.CopyFrom(response); NSMutableArray<FBFuture<NSNull *> *> *futures = [NSMutableArray array]; if (self.configuration.resultBundlePath) { [futures addObject:[[self getResultsBundle] onQueue:self.queue chain:^FBFuture<NSNull *> *(FBFuture<NSData *> *future) { NSData *data = future.result; if (data) { idb::Payload *payload = responseCopy.mutable_result_bundle(); payload->set_data(data.bytes, data.length); } else { [self.logger.info logFormat:@"Failed to create result bundle %@", future]; } return [FBFuture futureWithResult:NSNull.null]; }]]; } if (self.configuration.coverageConfiguration.coverageDirectory) { [futures addObject:[[[self getCoverageResponseData] onQueue:self.queue map:^NSNull *(NSData *coverageResponseData) { if (coverageResponseData) { idb::Payload *payload = responseCopy.mutable_code_coverage_data(); payload->set_data(coverageResponseData.bytes, coverageResponseData.length); } return NSNull.null; }] onQueue:self.queue handleError:^FBFuture<NSNull *> *(NSError *error) { [self.logger.info logFormat:@"Failed to get coverage data: %@", error.localizedDescription]; return FBFuture.empty; }]]; } if (self.configuration.logDirectoryPath) { [futures addObject:[[self getLogDirectoryData] onQueue:self.queue chain:^FBFuture<NSNull *> *(FBFuture<NSData *> *future) { NSData *data = future.result; if (data) { idb::Payload *payload = responseCopy.mutable_log_directory(); payload->set_data(data.bytes, data.length); } else { [self.logger.info logFormat:@"Failed to get log drectory: %@", future.error.localizedDescription]; } return [FBFuture futureWithResult:NSNull.null]; }]]; } if (futures.count == 0) { [self writeResponseFinal:responseCopy]; return; } [[FBFuture futureWithFutures:futures] onQueue:self.queue map:^NSNull *(id _) { [self writeResponseFinal:responseCopy]; return NSNull.null; }]; } - (void)writeResponseFinal:(const idb::XctestRunResponse &)response { @synchronized (self) { // Break out if the terminating condition happens twice. if (self.reportingTerminated.hasCompleted || self.writer == nil) { [self.logger.error log:@"writeResponse called, but the last response has already been written!!"]; return; } self.writer->Write(response); // Update the terminal future to signify that reporting is done. switch (response.status()) { case idb::XctestRunResponse_Status_TERMINATED_NORMALLY: case idb::XctestRunResponse_Status_TERMINATED_ABNORMALLY: [self.logger logFormat:@"Test Reporting has finished with status %d", response.status()]; [self.reportingTerminatedMutable resolveWithResult:@(response.status())]; self.writer = nil; break; default: break; } } } - (FBFuture<NSData *> *)getResultsBundle { return [FBArchiveOperations createGzippedTarDataForPath:self.configuration.resultBundlePath queue:self.queue logger:self.logger]; } - (FBFuture<NSData *> *)getLogDirectoryData { return [FBArchiveOperations createGzippedTarDataForPath:self.configuration.logDirectoryPath queue:self.queue logger:self.logger]; } #pragma mark Code Coverage - (FBFuture<NSData *> *)getCoverageResponseData { return [self.processUnderTestExitedMutable onQueue:self.queue fmap:^FBFuture<NSData *> *(id _) { switch (self.configuration.coverageConfiguration.format) { case FBCodeCoverageExported: return [[self getCoverageDataExported] onQueue:self.queue fmap:^FBFuture<NSNull *> *(NSData *coverageData) { return [[FBArchiveOperations createGzipDataFromData:coverageData logger:self.logger] onQueue:self.queue map:^NSData *(FBProcess<NSData *,NSData *,id> *task) { return task.stdOut; }]; }]; case FBCodeCoverageRaw: return [self getCoverageDataDirectory]; default: return [[FBControlCoreError describeFormat:@"Unsupported code coverage format"] failFuture]; } }]; } - (FBFuture<NSData *> *)getCoverageDataDirectory { return [FBArchiveOperations createGzippedTarDataForPath:self.configuration.coverageConfiguration.coverageDirectory queue:self.queue logger:self.logger]; } - (FBFuture<NSData *> *)getCoverageDataExported { FBFuture<FBProcess<NSNull *, NSString *, NSString *> *> * (^checkXcrunError)(FBProcess<NSNull *, NSData *, NSString *> *) = ^FBFuture<FBProcess<NSNull *, NSString *, NSString *> *> * (FBProcess<NSNull *, NSData *, NSString *> *task) { NSNumber *exitCode = task.exitCode.result; if ([exitCode isEqual:@0]) { return [FBFuture futureWithResult:task]; } else { return [[FBControlCoreError describeFormat:@"xcrun failed to export code coverage data %@, %@", exitCode, task.stdErr] failFuture]; } }; NSString *coverageDirectoryPath = self.configuration.coverageConfiguration.coverageDirectory; NSString *profdataPath = [coverageDirectoryPath stringByAppendingPathComponent:@"coverage.profdata"]; NSError *error = nil; NSArray<NSString *> *profraws = [NSFileManager.defaultManager contentsOfDirectoryAtPath:coverageDirectoryPath error:&error]; if (profraws == nil) { return [[FBControlCoreError describeFormat:@"Couldn't find code coverage raw data: %@", error] failFuture]; } profraws = [profraws filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSString *evaluatedObject, NSDictionary<NSString *,id> *_) { return [[evaluatedObject pathExtension] isEqualToString:@"profraw"]; }]]; NSMutableArray<NSString *> *mergeArgs = @[@"llvm-profdata", @"merge", @"-o", profdataPath].mutableCopy; for (NSString *profraw in profraws) { [mergeArgs addObject:[coverageDirectoryPath stringByAppendingPathComponent:profraw]]; } FBFuture *mergeFuture = [[[[FBProcessBuilder withLaunchPath:@"/usr/bin/xcrun" arguments:mergeArgs.copy] withStdOutInMemoryAsData] withStdErrInMemoryAsString] runUntilCompletionWithAcceptableExitCodes:nil]; return [[[[mergeFuture onQueue:self.queue fmap:[checkXcrunError copy]] onQueue:self.queue fmap:^FBFuture<FBProcess<NSNull *, NSData *, NSString *> *> *(id _) { NSMutableArray<NSString *> *exportArgs = @[@"llvm-cov", @"export", @"-instr-profile", profdataPath].mutableCopy; for (NSString *binary in self.configuration.binariesPaths) { [exportArgs addObject:@"-object"]; [exportArgs addObject:binary]; } return [[[[FBProcessBuilder withLaunchPath:@"/usr/bin/xcrun" arguments:exportArgs.copy] withStdOutInMemoryAsData] withStdErrInMemoryAsString] runUntilCompletionWithAcceptableExitCodes:nil]; }] onQueue:self.queue fmap:[checkXcrunError copy]] onQueue:self.queue map:^NSData *(FBProcess<NSNull *,NSData *,NSString *> *task) { return task.stdOut; }]; } @end