XCTestBootstrap/TestManager/FBTestBundleConnection.m (340 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 "FBTestBundleConnection.h"
#import <XCTestPrivate/XCTestDriverInterface-Protocol.h>
#import <XCTestPrivate/XCTestManager_DaemonConnectionInterface-Protocol.h>
#import <XCTestPrivate/XCTestManager_IDEInterface-Protocol.h>
#import <DTXConnectionServices/DTXConnection.h>
#import <DTXConnectionServices/DTXProxyChannel.h>
#import <DTXConnectionServices/DTXRemoteInvocationReceipt.h>
#import <DTXConnectionServices/DTXTransport.h>
#import <DTXConnectionServices/DTXSocketTransport.h>
#import <objc/runtime.h>
#import <FBControlCore/FBCrashLogCommands.h>
#import "XCTestBootstrapError.h"
#import "FBTestManagerContext.h"
#import "FBTestManagerAPIMediator.h"
static NSTimeInterval BundleReadyTimeout = 20; // Time for `_XCT_testBundleReadyWithProtocolVersion` to be called after the 'connect'.
static NSTimeInterval IDEInterfaceReadyTimeout = 10; // Time for `XCTestManager_IDEInterface` to be returned.
static NSTimeInterval DaemonSessionReadyTimeout = 10; // Time for `_IDE_initiateSessionWithIdentifier` to be returned.
static NSTimeInterval CrashCheckWaitLimit = 30; // Time to wait for crash report to be generated.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wprotocol"
#pragma clang diagnostic ignored "-Wincomplete-implementation"
@interface FBTestBundleConnection () <XCTestManager_IDEInterface>
@property (nonatomic, strong, readonly) FBTestManagerContext *context;
@property (nonatomic, strong, readonly) id<FBiOSTarget, FBXCTestExtendedCommands> target;
@property (nonatomic, strong, readonly) id<XCTestManager_IDEInterface, NSObject> interface;
@property (nonatomic, strong, readonly) id<FBLaunchedApplication> testHostApplication;
@property (nonatomic, strong, readonly) dispatch_queue_t requestQueue;
@property (nonatomic, strong, nullable, readonly) id<FBControlCoreLogger> logger;
@property (nonatomic, strong, readonly) FBMutableFuture<NSNull *> *bundleDisconnected;
@property (nonatomic, strong, readonly) FBMutableFuture<NSNull *> *bundleReadyFuture;
@property (nonatomic, strong, readonly) FBMutableFuture<NSNull *> *testPlanFuture;
@end
@implementation FBTestBundleConnection
+ (NSString *)clientProcessUniqueIdentifier
{
static dispatch_once_t onceToken;
static NSString *_clientProcessUniqueIdentifier;
dispatch_once(&onceToken, ^{
_clientProcessUniqueIdentifier = NSProcessInfo.processInfo.globallyUniqueString;
});
return _clientProcessUniqueIdentifier;
}
+ (NSString *)clientProcessDisplayPath
{
static dispatch_once_t onceToken;
static NSString *_clientProcessDisplayPath;
dispatch_once(&onceToken, ^{
NSString *path = NSBundle.mainBundle.bundlePath;
if (![path.pathExtension isEqualToString:@"app"]) {
path = NSBundle.mainBundle.executablePath;
}
_clientProcessDisplayPath = path;
});
return _clientProcessDisplayPath;
}
- (instancetype)initWithWithContext:(FBTestManagerContext *)context target:(id<FBiOSTarget, FBXCTestExtendedCommands>)target interface:(id<XCTestManager_IDEInterface, NSObject>)interface testHostApplication:(id<FBLaunchedApplication>)testHostApplication requestQueue:(dispatch_queue_t)requestQueue logger:(nullable id<FBControlCoreLogger>)logger
{
self = [super init];
if (!self) {
return nil;
}
_context = context;
_target = target;
_interface = interface;
_testHostApplication = testHostApplication;
_requestQueue = requestQueue;
_logger = logger;
_bundleDisconnected = FBMutableFuture.future;
_bundleReadyFuture = FBMutableFuture.future;
_testPlanFuture = FBMutableFuture.future;
return self;
}
#pragma mark Message Forwarding
- (BOOL)respondsToSelector:(SEL)selector
{
return [super respondsToSelector:selector] || [self.interface respondsToSelector:selector];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector
{
return [super methodSignatureForSelector:selector] ?: [(id)self.interface methodSignatureForSelector:selector];
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
if ([self.interface respondsToSelector:invocation.selector]) {
[invocation invokeWithTarget:self.interface];
} else {
[super forwardInvocation:invocation];
}
}
#pragma mark Public
+ (FBFuture<NSNull *> *)connectAndRunBundleToCompletionWithContext:(FBTestManagerContext *)context target:(id<FBiOSTarget, FBXCTestExtendedCommands>)target interface:(id<XCTestManager_IDEInterface, NSObject>)interface testHostApplication:(id<FBLaunchedApplication>)testHostApplication requestQueue:(dispatch_queue_t)requestQueue logger:(nullable id<FBControlCoreLogger>)logger
{
FBTestBundleConnection *connection = [[self alloc] initWithWithContext:context target:target interface:interface testHostApplication:testHostApplication requestQueue:requestQueue logger:logger];
return [connection connectAndRunToCompletion];
}
#pragma mark Private
/*
* Checks if:
* - there is a process for the test host application
* - the pid of existing process is the same pid we prepared to execute the tests
* The returned FBFuture will always fail, either with the original error or an error
* indicating which of the checks above failed.
*/
- (FBFuture<NSNull *> *)performDiagnosisOnBundleConnectionError:(NSError *)error {
return [[[self.target
processIDWithBundleID:self.context.testHostLaunchConfiguration.bundleID]
onQueue:self.requestQueue handleError:^FBFuture *(NSError *pidLookupError) {
NSString *msg = @"Error while establishing connection to test bundle: "
@"Could not find process for test host application. "
@"The host application is likely to have crashed during startup.";
// In this case the application lived long enough to avoid a relaunch (see bellow), but crashed before idb could connect to it.
return [[[FBXCTestError describe:msg] causedBy:pidLookupError] failFuture];
}]
onQueue:self.requestQueue fmap:^FBFuture *(NSNumber *runningPid) {
if (self.testHostApplication.processIdentifier != runningPid.intValue) {
NSString *msg = @"Error while establishing connection to test bundle: "
@"Running test host application pid is different from the pid launched and set up to execute the tests. "
@"The host application is likely to have crashed during startup and been relaunched by iOS.";
// Sometimes when an application crashes very early (e.g. during dylib loading) iOS retries launching the
// app with none of the settings idb added in the original launch configuration.
// idb can't work with this 'vanilla' app process, resulting in errors connecting to the bundle (there won't be a bundle to connect to).
return [[[FBXCTestError describe:msg] causedBy:error] failFuture];
}
// No obvious issue with the process, returning the original error
return [FBFuture futureWithError:error];
}];
}
- (FBFuture<NSNull *> *)connectAndRunToCompletion
{
[self.logger log:@"Connecting Test Bundle"];
__block id<XCTestDriverInterface> testBundleProxy;
__block DTXConnection *testBundleConnection;
return [[[[[self
startTestmanagerdConnection]
onQueue:self.requestQueue pend:^(DTXConnection *connection) {
[connection registerDisconnectHandler:^{
[self.bundleDisconnected resolveWithResult:NSNull.null];
}];
testBundleConnection = connection;
return [FBFuture
futureWithFutures:@[
[self setupTestBundleConnectionWithConnection:connection],
[self sendStartSessionRequestWithConnection:connection],
]];
}]
onQueue:self.requestQueue pend:^(NSArray<id> *results) {
[self.logger logFormat:@"Waiting for test bundle to be ready.."];
testBundleProxy = results[0];
return [self.bundleReadyFuture timeout:BundleReadyTimeout waitingFor:@"Bundle Ready to be called"];
}]
onQueue:self.requestQueue pop:^(id result) {
[self.logger logFormat:@"Starting Execution of the test plan w/ version %ld", FBProtocolVersion];
[testBundleProxy _IDE_startExecutingTestPlanWithProtocolVersion:@(FBProtocolVersion)];
return self.bundleDisconnectedSuccessfully;
}]
onQueue:self.requestQueue handleError:^(NSError *error) {
return [self performDiagnosisOnBundleConnectionError:error];
}];
}
- (FBFutureContext<DTXConnection *> *)startTestmanagerdConnection
{
id<FBControlCoreLogger> logger = self.logger;
dispatch_queue_t queue = self.requestQueue;
[logger log:@"Starting a fresh testmanagerd connection"];
return [[self.target
transportForTestManagerService]
onQueue:queue push:^(NSNumber *socket) {
return [FBTestBundleConnection connectionWithSocket:socket.intValue queue:queue logger:logger];
}];
}
+ (FBFutureContext<DTXConnection *> *)connectionWithSocket:(int)socket queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger
{
[logger logFormat:@"Wrapping testmanagerd socket (%d) in DTXTransport and DTXConnection", socket];
DTXTransport *transport = [[objc_lookUpClass("DTXSocketTransport") alloc] initWithConnectedSocket:socket disconnectAction:^{
[logger logFormat:@"Notified that daemon socket disconnected"];
}];
DTXConnection *connection = [[objc_lookUpClass("DTXConnection") alloc] initWithTransport:transport];
[connection registerDisconnectHandler:^{
[logger logFormat:@"Notified that testmanagerd connection disconnected"];
}];
[logger logFormat:@"testmanagerd socket %d wrapped in %@", socket, connection];
return [[FBFuture
futureWithResult:connection]
onQueue:queue contextualTeardown:^(id _, FBFutureState __) {
[logger logFormat:@"Ending the testmanagerd connection. %@", connection];
[connection suspend];
[connection cancel];
return FBFuture.empty;
}];
}
- (FBFuture<id<XCTestDriverInterface>> *)setupTestBundleConnectionWithConnection:(DTXConnection *)connection
{
FBMutableFuture<id<XCTestDriverInterface>> *future = FBMutableFuture.future;
[self.logger logFormat:@"Listening for proxy connection request from the test bundle (all platforms)"];
[connection
handleProxyRequestForInterface:@protocol(XCTestManager_IDEInterface)
peerInterface:@protocol(XCTestDriverInterface)
handler:^(DTXProxyChannel *channel){
[self.logger logFormat:@"Got proxy channel request from test bundle"];
[channel setExportedObject:self queue:self.target.workQueue];
id<XCTestDriverInterface> interface = channel.remoteObjectProxy;
[future resolveWithResult:interface];
}];
[self.logger logFormat:@"Resuming the test bundle connection."];
[connection resume];
return [future timeout:IDEInterfaceReadyTimeout waitingFor:@"XCTestManager_IDEInterface to be ready"];
}
- (FBFuture<NSNumber *> *)sendStartSessionRequestWithConnection:(DTXConnection *)connection
{
[self.logger log:@"Checking test manager availability..."];
DTXProxyChannel *proxyChannel = [connection
makeProxyChannelWithRemoteInterface:@protocol(XCTestManager_DaemonConnectionInterface)
exportedInterface:@protocol(XCTestManager_IDEInterface)];
[proxyChannel setExportedObject:self queue:self.target.workQueue];
id<XCTestManager_DaemonConnectionInterface> remoteProxy = (id<XCTestManager_DaemonConnectionInterface>) proxyChannel.remoteObjectProxy;
[self.logger logFormat:@"Starting test session with ID %@", self.context.sessionIdentifier.UUIDString];
DTXRemoteInvocationReceipt *receipt = [remoteProxy
_IDE_initiateSessionWithIdentifier:self.context.sessionIdentifier
forClient:self.class.clientProcessUniqueIdentifier
atPath:self.class.clientProcessDisplayPath
protocolVersion:@(FBProtocolVersion)];
NSString *sessionStartMethod = NSStringFromSelector(@selector(_IDE_initiateSessionWithIdentifier:forClient:atPath:protocolVersion:));
FBMutableFuture<NSNumber *> *future = FBMutableFuture.future;
[receipt handleCompletion:^(NSNumber *version, NSError *error){
[proxyChannel cancel];
if (error) {
[self.logger logFormat:@"testmanagerd did %@ failed: %@", sessionStartMethod, error];
[future resolveWithError:error];
}
[future resolveWithResult:version];
[self.logger logFormat:@"testmanagerd handled session request using protcol version %ld.", (long)FBProtocolVersion];
}];
return [future timeout:DaemonSessionReadyTimeout waitingFor:@"%@ to be resolved", sessionStartMethod];
}
- (FBFuture<NSNull *> *)bundleDisconnectedSuccessfully
{
return [[self
bundleDisconnected]
onQueue:self.requestQueue fmap:^ FBFuture<NSNull *> * (id _) {
if (self.testPlanFuture.hasCompleted) {
[self.logger logFormat:@"Bundle disconnected, with the test plan completed. Bundle exited successfully."];
return FBFuture.empty;
}
[self.logger logFormat:@"Bundle disconnected, but test plan has not completed. This could mean a crash has occured"];
return [[self
findCrashedProcessLog]
onQueue:self.target.workQueue chain:^ FBFuture<NSNull *> * (FBFuture<FBCrashLog *> *future) {
FBCrashLog *crashLog = future.result;
if (!crashLog) {
return [[[XCTestBootstrapError
describeFormat:@"Lost connection to test process, but could not find a crash log"]
code:XCTestBootstrapErrorCodeLostConnection]
failFuture];
}
return [[XCTestBootstrapError
describeFormat:@"Test Bundle Crashed: %@", crashLog]
failFuture];
}];
}];
}
- (FBFuture<FBCrashLog *> *)findCrashedProcessLog
{
id<FBLaunchedApplication> testHostApplication = self.testHostApplication;
NSString *testHostBundleID = self.context.testHostLaunchConfiguration.bundleID;
return [[[self.target
processIDWithBundleID:self.context.testHostLaunchConfiguration.bundleID]
onQueue:self.target.workQueue chain:^ FBFuture<FBCrashLogInfo *> * (FBFuture<NSNumber *> *processIdentifierFuture) {
if (processIdentifierFuture.result) {
return [[FBControlCoreError
describeFormat:@"The Process for %@ is not crashed as it is running", processIdentifierFuture.result]
failFuture];
}
id<FBCrashLogCommands> crashLog = (id<FBCrashLogCommands>) self.target;
if (![crashLog conformsToProtocol:@protocol(FBCrashLogCommands)]) {
return [[FBControlCoreError
describeFormat:@"%@ does not conform to %@", self.target, NSStringFromProtocol(@protocol(FBCrashLogCommands))]
failFuture];
}
NSTimeInterval crashWaitTimeout = CrashCheckWaitLimit;
NSString *crashWaitTimeoutFromEnv = NSProcessInfo.processInfo.environment[@"FBXCTEST_CRASH_WAIT_TIMEOUT"];
if (crashWaitTimeoutFromEnv) {
crashWaitTimeout = crashWaitTimeoutFromEnv.floatValue;
}
return [[crashLog
notifyOfCrash:[FBCrashLogInfo predicateForCrashLogsWithProcessID:testHostApplication.processIdentifier]]
timeout:crashWaitTimeout
waitingFor:@"Getting crash log for process with pid %d, bunndle ID: %@", testHostApplication.processIdentifier, testHostBundleID];
}]
onQueue:self.target.workQueue fmap:^(FBCrashLogInfo *info) {
NSError *error = nil;
FBCrashLog *log = [info obtainCrashLogWithError:&error];
if (!log) {
return [FBFuture futureWithError:error];
}
return [FBFuture futureWithResult:log];
}];
}
- (void)concludeWithError:(NSError *)error
{
[self.logger logFormat:@"Test Completed with error: %@", error];
[self.bundleReadyFuture resolveWithError:error];
[self.testPlanFuture resolveWithError:error];
}
#pragma mark XCTestDriverInterface
- (id)_XCT_didFinishExecutingTestPlan
{
[self.testPlanFuture resolveWithResult:NSNull.null];
return [self.interface _XCT_didFinishExecutingTestPlan];
}
- (id)_XCT_testBundleReadyWithProtocolVersion:(NSNumber *)protocolVersion minimumVersion:(NSNumber *)minimumVersion
{
NSInteger protocolVersionInt = protocolVersion.integerValue;
NSInteger minimumVersionInt = minimumVersion.integerValue;
[self.logger logFormat:@"Test bundle is ready, running protocol %ld, requires at least version %ld. IDE is running %ld and requires at least %ld", protocolVersionInt, minimumVersionInt, FBProtocolVersion, FBProtocolMinimumVersion];
if (minimumVersionInt > FBProtocolVersion) {
NSError *error = [[[XCTestBootstrapError
describeFormat:@"Protocol mismatch: test process requires at least version %ld, IDE is running version %ld", minimumVersionInt, FBProtocolVersion]
code:XCTestBootstrapErrorCodeStartupFailure]
build];
[self concludeWithError:error];
return nil;
}
if (protocolVersionInt < FBProtocolMinimumVersion) {
NSError *error = [[[XCTestBootstrapError
describeFormat:@"Protocol mismatch: IDE requires at least version %ld, test process is running version %ld", FBProtocolMinimumVersion,protocolVersionInt]
code:XCTestBootstrapErrorCodeStartupFailure]
build];
[self concludeWithError:error];
return nil;
}
[self.logger logFormat:@"Test Bundle is Ready"];
[self.bundleReadyFuture resolveWithResult:NSNull.null];
return [self.interface _XCT_testBundleReadyWithProtocolVersion:protocolVersion minimumVersion:minimumVersion];
}
- (id)_XCT_initializationForUITestingDidFailWithError:(NSError *)error
{
NSError *innerError = [[[[XCTestBootstrapError
describe:@"Failed to initilize for UI testing"]
causedBy:error]
code:XCTestBootstrapErrorCodeStartupFailure]
build];
[self concludeWithError:innerError];
return nil;
}
- (id)_XCT_testRunnerReadyWithCapabilities:(XCTCapabilities *)arg1
{
[self.logger logFormat:@"Test Bundle is Ready"];
[self.bundleReadyFuture resolveWithResult:NSNull.null];
return nil;
}
@end
#pragma clang diagnostic pop