XCTestBootstrap/Reporters/FBJSONTestReporter.m (276 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 "FBJSONTestReporter.h"
#import "XCTestBootstrapError.h"
static inline NSString *FBFullyFormattedXCTestName(NSString *className, NSString *methodName);
@interface FBJSONTestReporter ()
@property (nonatomic, strong, readonly) id<FBDataConsumer> dataConsumer;
@property (nonatomic, strong, readonly) id<FBControlCoreLogger> logger;
@property (nonatomic, copy, readonly) NSString *testBundlePath;
@property (nonatomic, copy, readonly) NSString *testType;
@property (nonatomic, copy, readonly) NSMutableArray<NSDictionary<NSString *, id> *> *events;
@property (nonatomic, copy, readonly) NSMutableDictionary<NSString *, NSMutableArray<NSDictionary<NSString *, id> *> *> *xctestNameExceptionsMapping;
@property (nonatomic, copy, readonly) NSMutableArray<NSString *> *pendingTestOutput;
@property (nonatomic, copy, readwrite) NSString *currentTestName;
@property (nonatomic, copy, readwrite) NSError *crashError;
@property (nonatomic, assign, readwrite) BOOL started;
@property (nonatomic, assign, readwrite) BOOL finished;
@end
@implementation FBJSONTestReporter
- (instancetype)initWithTestBundlePath:(NSString *)testBundlePath testType:(NSString *)testType logger:(id<FBControlCoreLogger>)logger dataConsumer:(id<FBDataConsumer>)dataConsumer
{
self = [super init];
if (!self) {
return nil;
}
_dataConsumer = dataConsumer;
_logger = logger;
_testBundlePath = testBundlePath;
_testType = testType;
_xctestNameExceptionsMapping = [NSMutableDictionary dictionary];
_pendingTestOutput = [NSMutableArray array];
_currentTestName = nil;
_started = NO;
_finished = NO;
return self;
}
#pragma mark FBXCTestReporter
- (BOOL)printReportWithError:(NSError **)error
{
if (!_started) {
return [[FBXCTestError describe:[self noStartOfTestPlanErrorMessage]] failBool:error];
}
if (!_finished) {
NSError *crashError = nil;
NSString *errorMessage = @"No didFinishExecutingTestPlan event was received, the test bundle has likely crashed.";
if (crashError) {
errorMessage = crashError.localizedDescription;
}
if (_currentTestName) {
errorMessage = [errorMessage stringByAppendingString:@". Crash occurred while this test was running: "];
errorMessage = [errorMessage stringByAppendingString:_currentTestName];
}
[self printEvent:[FBJSONTestReporter createOCUnitEndEvent:self.testType testBundlePath:self.testBundlePath message:errorMessage success:NO]];
return [[FBXCTestError describe:errorMessage] failBool:error];
}
[self.dataConsumer consumeEndOfFile];
return YES;
}
- (void)processWaitingForDebuggerWithProcessIdentifier:(pid_t)pid
{
[self printEvent:[FBJSONTestReporter waitingForDebuggerEvent:pid]];
}
- (void)didBeginExecutingTestPlan
{
_started = YES;
[self printEvent:[FBJSONTestReporter createOCUnitBeginEvent:self.testType testBundlePath:self.testBundlePath]];
}
- (void)didFinishExecutingTestPlan
{
if (_started) {
[self printEvent:[FBJSONTestReporter createOCUnitEndEvent:self.testType testBundlePath:self.testBundlePath message:nil success:YES]];
} else {
[self printEvent:[FBJSONTestReporter createOCUnitBeginEvent:self.testType testBundlePath:self.testBundlePath]];
NSString *errorMessage = [self noStartOfTestPlanErrorMessage];
[self printEvent:[FBJSONTestReporter createOCUnitEndEvent:self.testType testBundlePath:self.testBundlePath message:errorMessage success:NO]];
}
_finished = YES;
}
- (void)testSuite:(NSString *)testSuite didStartAt:(NSString *)startTime
{
[self printEvent:[FBJSONTestReporter beginTestSuiteEvent:testSuite]];
}
- (void)testCaseDidStartForTestClass:(NSString *)testClass method:(NSString *)method
{
NSString *xctestName = FBFullyFormattedXCTestName(testClass, method);
_currentTestName = xctestName;
self.xctestNameExceptionsMapping[xctestName] = [NSMutableArray array];
[self printEvent:[FBJSONTestReporter beginTestCaseEvent:testClass testMethod:method]];
}
- (void)testCaseDidFailForTestClass:(NSString *)testClass method:(NSString *)method withMessage:(NSString *)message file:(NSString *)file line:(NSUInteger)line
{
NSString *xctestName = FBFullyFormattedXCTestName(testClass, method);
[self.xctestNameExceptionsMapping[xctestName] addObject:[FBJSONTestReporter exceptionEvent:message file:file line:line]];
}
- (void)testCaseDidFinishForTestClass:(NSString *)testClass method:(NSString *)method withStatus:(FBTestReportStatus)status duration:(NSTimeInterval)duration logs:(NSArray<NSString *> *)logs
{
_currentTestName = nil;
NSDictionary<NSString *, id> *event = [FBJSONTestReporter
testCaseDidFinishForTestClass:testClass
method:method
status:status
duration:duration
pendingTestOutput:self.pendingTestOutput
xctestNameExceptionsMapping:self.xctestNameExceptionsMapping];
[self printEvent:event];
[self.pendingTestOutput removeAllObjects];
}
- (void)finishedWithSummary:(FBTestManagerResultSummary *)summary
{
[self printEvent:[FBJSONTestReporter finishedEventFromSummary:summary]];
}
- (void)didRecordVideoAtPath:(nonnull NSString *)videoRecordingPath
{
[self printEvent:@{
@"event" : @"video-recording-finished",
@"videoRecordingPath" : videoRecordingPath,
}];
}
- (void)didSaveOSLogAtPath:(nonnull NSString *)osLogPath
{
[self printEvent:@{
@"event" : @"os-log-saved",
@"osLogPath" : osLogPath,
}];
}
- (void)didCopiedTestArtifact:(nonnull NSString *)testArtifactFilename toPath:(nonnull NSString *)path
{
[self printEvent:@{
@"event" : @"copy-test-artifact",
@"test_artifact_file_name" : testArtifactFilename,
@"path" : path,
}];
}
- (void)testHadOutput:(NSString *)output
{
[self.pendingTestOutput addObject:output];
[self printEvent:[FBJSONTestReporter testOutputEvent:output]];
}
- (void)handleExternalEvent:(NSString *)line
{
if (line.length == 0) {
return;
}
NSError *error = nil;
NSDictionary *event = [NSJSONSerialization JSONObjectWithData:[line dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
if (event == nil) {
[self.logger logFormat:@"Received unexpected output from otest-shim:\n%@", line];
}
if ([event[@"event"] isEqualToString:@"end-test"]) {
NSMutableDictionary *mutableEvent = event.mutableCopy;
mutableEvent[@"output"] = [self.pendingTestOutput componentsJoinedByString:@""];
event = mutableEvent.copy;
[self.pendingTestOutput removeAllObjects];
}
[self.events addObject:event];
}
- (void)processUnderTestDidExit
{
}
- (void)didCrashDuringTest:(NSError *)error
{
self.crashError = error;
}
#pragma mark Private
- (void)printEvent:(NSDictionary *)event
{
NSMutableDictionary *timestamped = event.mutableCopy;
if (!timestamped[@"timestamp"]) {
timestamped[@"timestamp"] = @(NSDate.date.timeIntervalSince1970);
}
NSData *data = [NSJSONSerialization dataWithJSONObject:timestamped options:0 error:nil];
[self.dataConsumer consumeData:data];
[self.dataConsumer consumeData:[NSData dataWithBytes:"\n" length:1]];
}
- (NSString *)noStartOfTestPlanErrorMessage
{
NSString *errorMessage = @"No didBeginExecutingTestPlan event was received.";
if (_currentTestName) {
errorMessage = [errorMessage stringByAppendingString:@". However a test was running: "];
errorMessage = [errorMessage stringByAppendingString:_currentTestName];
}
return errorMessage;
}
+ (NSDictionary<NSString *, id> *)exceptionEvent:(NSString *)reason file:(NSString *)file line:(NSUInteger)line
{
return @{
@"lineNumber" : @(line),
@"filePathInProject" : file,
@"reason" : reason,
};
}
+ (NSDictionary<NSString *, id> *)beginTestCaseEvent:(NSString *)testClass testMethod:(NSString *)method
{
return @{
@"event" : @"begin-test",
@"className" : testClass,
@"methodName" : method,
@"test" : FBFullyFormattedXCTestName(testClass, method),
};
}
+ (NSDictionary<NSString *, id> *)beginTestSuiteEvent:(NSString *)testSuite
{
return @{
@"event" : @"begin-test-suite",
@"suite" : testSuite,
};
}
+ (NSDictionary<NSString *, id> *)testOutputEvent:(NSString *)output
{
return @{
@"event": @"test-output",
@"output": output,
};
}
+ (NSDictionary<NSString *, id> *)waitingForDebuggerEvent:(pid_t)pid
{
return @{
@"event": @"begin-status",
@"pid": @(pid),
@"level": @"Info",
@"message": [NSString stringWithFormat:@"Tests waiting for debugger. To debug run: lldb -p %@", @(pid)],
};
}
+ (NSDictionary<NSString *, id> *)createOCUnitBeginEvent:(NSString *)testType testBundlePath:(NSString *)testBundlePath
{
return @{
@"event" : @"begin-ocunit",
@"testType" : testType,
@"bundleName" : [testBundlePath lastPathComponent],
@"targetName" : testBundlePath,
};
}
+ (NSDictionary<NSString *, id> *)createOCUnitEndEvent:(NSString *)testType testBundlePath:(NSString *)testBundlePath message:(NSString *)message success:(BOOL)success
{
NSMutableDictionary<NSString *, id> *event = [NSMutableDictionary dictionaryWithDictionary:@{
@"event" : @"end-ocunit",
@"testType" : testType,
@"bundleName" : [testBundlePath lastPathComponent],
@"targetName" : testBundlePath,
@"succeeded" : success ? @YES : @NO,
}];
if (message) {
event[@"message"] = message;
}
return [event copy];
}
+ (NSDictionary<NSString *, id> *)finishedEventFromSummary:(FBTestManagerResultSummary *)summary
{
return @{
@"event" : @"end-test-suite",
@"suite" : summary.testSuite,
@"testCaseCount" : @(summary.runCount),
@"totalFailureCount" : @(summary.failureCount),
@"totalDuration" : @(summary.totalDuration),
@"unexpectedExceptionCount" : @(summary.unexpected),
@"testDuration" : @(summary.testDuration)
};
}
+ (NSDictionary<NSString *, id> *)testCaseDidFinishForTestClass:(NSString *)testClass method:(NSString *)method status:(FBTestReportStatus)status duration:(NSTimeInterval)duration pendingTestOutput:(NSArray<NSString *> *)pendingTestOutput xctestNameExceptionsMapping:(NSDictionary<NSString *, NSArray<NSDictionary *> *> *)xctestNameExceptionsMapping
{
NSString *xctestName = FBFullyFormattedXCTestName(testClass, method);
return @{
@"event" : @"end-test",
@"result" : (status == FBTestReportStatusPassed ? @"success" : @"failure"),
@"output" : [pendingTestOutput componentsJoinedByString:@""],
@"test" : xctestName,
@"className" : testClass,
@"methodName" : method,
@"succeeded" : (status == FBTestReportStatusPassed ? @YES : @NO),
@"exceptions" : xctestNameExceptionsMapping[xctestName] ?: @[],
@"totalDuration" : @(duration),
};
}
@end
static inline NSString *FBFullyFormattedXCTestName(NSString *className, NSString *methodName)
{
return [NSString stringWithFormat:@"-[%@ %@]", className, methodName];
}