Repository: wix/Detox
Pull request #1165 (2019-02-21 16:53:03)

Detox Instruments Integration (Phase 1)
This PR aims to add initial integration between Detox and Detox Instruments.

Detox will load or use the already loaded Profiler framework and call its APIs to record performance metrics for each test. Each recording will be collected by the artifacts manager.

Discussion/Tasks

- [x] Determine optimal way of collecting performance metrics (one recording for entire suite vs one recording per describe vs one recording per test)
- [x] Artifacts manager should collect the recordings
- [x] Finalize the actual performance collection config
- [x] Prerequisite: complete and merge yargs PR #1109 
- [x] Add Detox argument to enable recording
- [x] Handle app termination from Detox API—need to stop recording before using `xcrun simctl terminate`.
- [x] Handle crashing or otherwise app exist—need to stop recording
- [x] JS coverage
- [x] Change Detox server to notify testee in case tester is disconnected (to support ⌃+C)
- [x] Handle cases when Detox Instruments is not installed on user's system

LeoNatan: @noomorph Could you please take the "Artifacts manager should collect the recordings" and "Change Detox server to notify testee in case tester is disconnected" tasks? Thanks
LeoNatan: For now, every test has its own recording file. I first sanitize the fullName string for file name: `:/\?%*|"<>` characters turn into `_`. To that the `.dtxrec` extension is added, and these files are saved under the app's Documents folder.

Patch:
diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj
index e49c3988d..fb6e847b4 100644
--- a/detox/ios/Detox.xcodeproj/project.pbxproj
+++ b/detox/ios/Detox.xcodeproj/project.pbxproj
@@ -65,6 +65,8 @@
 		39BCE599224BDBF000B0D357 /* ReactNativeSupportNoARC.m in Sources */ = {isa = PBXBuildFile; fileRef = 39BCE597224BDBF000B0D357 /* ReactNativeSupportNoARC.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; };
 		39C3C3511DBF9A13008177E1 /* EarlGrey.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 394767DC1DBF991E00D72256 /* EarlGrey.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		39C3C3531DBF9A19008177E1 /* SocketRocket.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 394767E91DBF992400D72256 /* SocketRocket.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+		39CE25AB22197F0900D78AA1 /* DetoxInstrumentsManager.h in Headers */ = {isa = PBXBuildFile; fileRef = 39CE25A922197F0900D78AA1 /* DetoxInstrumentsManager.h */; };
+		39CE25AC22197F0900D78AA1 /* DetoxInstrumentsManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 39CE25AA22197F0900D78AA1 /* DetoxInstrumentsManager.m */; };
 		39CEFCDB1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CEFCDA1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift */; };
 		39F642281FDD5EB100468FED /* DTXLogging.h in Headers */ = {isa = PBXBuildFile; fileRef = 39F642201FDD5EB000468FED /* DTXLogging.h */; };
 		39F642291FDD5EB100468FED /* DTXLogging.m in Sources */ = {isa = PBXBuildFile; fileRef = 39F642271FDD5EB000468FED /* DTXLogging.m */; };
@@ -240,6 +242,8 @@
 		39A34C6F1E30F10D00BEBB59 /* DetoxAppDelegateProxy.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DetoxAppDelegateProxy.h; sourceTree = "<group>"; };
 		39A34C701E30F10D00BEBB59 /* DetoxAppDelegateProxy.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DetoxAppDelegateProxy.m; sourceTree = "<group>"; };
 		39AB2D30205ABBD90029CD1F /* DetoxUserActivityDispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetoxUserActivityDispatcher.swift; sourceTree = "<group>"; };
+		39CE25A922197F0900D78AA1 /* DetoxInstrumentsManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DetoxInstrumentsManager.h; sourceTree = "<group>"; };
+		39CE25AA22197F0900D78AA1 /* DetoxInstrumentsManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = DetoxInstrumentsManager.m; sourceTree = "<group>"; };
 		39BCE596224BDBF000B0D357 /* ReactNativeSupportNoARC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ReactNativeSupportNoARC.h; sourceTree = "<group>"; };
 		39BCE597224BDBF000B0D357 /* ReactNativeSupportNoARC.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ReactNativeSupportNoARC.m; sourceTree = "<group>"; };
 		39CEFCDA1E34E91B00A09124 /* DetoxUserNotificationDispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetoxUserNotificationDispatcher.swift; sourceTree = "<group>"; };
@@ -382,6 +386,8 @@
 				39F6422A1FDD5EEC00468FED /* Detox.pch */,
 				399BF36621933F0C00F96D50 /* ExternalLogging.h */,
 				399BF36721933F0C00F96D50 /* ExternalLogging.m */,
+				39CE25A922197F0900D78AA1 /* DetoxInstrumentsManager.h */,
+				39CE25AA22197F0900D78AA1 /* DetoxInstrumentsManager.m */,
 			);
 			path = Detox;
 			sourceTree = "<group>";
@@ -487,6 +493,7 @@
 				394767B21DBF987E00D72256 /* TestFailureHandler.h in Headers */,
 				394767CE1DBF98D900D72256 /* ReactNativeSupport.h in Headers */,
 				394767B61DBF987E00D72256 /* WebSocket.h in Headers */,
+				39CE25AB22197F0900D78AA1 /* DetoxInstrumentsManager.h in Headers */,
 				394767BE1DBF98A700D72256 /* EarlGreyExtensions.h in Headers */,
 				394767B41DBF987E00D72256 /* TestRunner.h in Headers */,
 				390D1CA01E3A4E38007F5F46 /* UNPushNotificationTrigger+PrivateHeaders.h in Headers */,
@@ -717,6 +724,7 @@
 				398260E8220CC9530061E83E /* GREYActions+Detox.m in Sources */,
 				39FFD9471FD730A600C97030 /* DetoxCrashHandler.mm in Sources */,
 				46A6A63D1EF697BB00E3AA79 /* GREYConfiguration+Detox.m in Sources */,
+				39CE25AC22197F0900D78AA1 /* DetoxInstrumentsManager.m in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
diff --git a/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme b/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
index f70c7cf2b..a20cbd0a9 100644
--- a/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
+++ b/detox/ios/Detox.xcodeproj/xcshareddata/xcschemes/Detox.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "0900"
+   LastUpgradeVersion = "1020"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "YES"
diff --git a/detox/ios/Detox/DetoxInstrumentsManager.h b/detox/ios/Detox/DetoxInstrumentsManager.h
new file mode 100644
index 000000000..0318c3b07
--- /dev/null
+++ b/detox/ios/Detox/DetoxInstrumentsManager.h
@@ -0,0 +1,19 @@
+//
+//  DetoxInstrumentsManager.h
+//  Detox
+//
+//  Created by Leo Natan (Wix) on 2/17/19.
+//  Copyright © 2019 Wix. All rights reserved.
+//
+
+#import <Foundation/Foundation.h>
+
+@interface DetoxInstrumentsManager : NSObject
+
++ (NSURL*)defaultURLForTestName:(NSString*)testName;
+
+- (void)startRecordingAtURL:(NSURL*)URL;
+- (void)continueRecordingAtURL:(NSURL*)URL;
+- (void)stopRecordingWithCompletionHandler:(void(^)(NSError* error))completionHandler;
+
+@end
diff --git a/detox/ios/Detox/DetoxInstrumentsManager.m b/detox/ios/Detox/DetoxInstrumentsManager.m
new file mode 100644
index 000000000..491785ab2
--- /dev/null
+++ b/detox/ios/Detox/DetoxInstrumentsManager.m
@@ -0,0 +1,183 @@
+//
+//  DetoxInstrumentsManager.m
+//  Detox
+//
+//  Created by Leo Natan (Wix) on 2/17/19.
+//  Copyright © 2019 Wix. All rights reserved.
+//
+
+#import "DetoxInstrumentsManager.h"
+#import "DTXLogging.h"
+#include <dlfcn.h>
+
+DTX_CREATE_LOG_PREFIX(DetoxInstrumentsManager, @"🥶")
+
+@interface NSObject ()
+
+@property (class, nonatomic, strong, readonly) id defaultProfilingConfiguration;
+@property (nonatomic, readwrite) NSTimeInterval samplingInterval;
+@property (nonatomic, readwrite) BOOL recordNetwork;
+@property (nonatomic, readwrite) BOOL recordThreadInformation;
+@property (nonatomic, readwrite) BOOL collectStackTraces;
+@property (nonatomic, readwrite) BOOL symbolicateStackTraces;
+@property (atomic, assign, readonly, getter=isRecording) BOOL recording;
+@property (nonatomic, copy, null_resettable, readwrite) NSURL* recordingFileURL;
+
+- (void)startProfilingWithConfiguration:(id)configuration;
+- (void)continueProfilingWithConfiguration:(id)configuration;
+- (void)stopProfilingWithCompletionHandler:(void(^ __nullable)(NSError* __nullable error))completionHandler;
+
+@end
+
+typedef NS_ENUM(NSUInteger, __DTXEventStatus) {
+	__DTXEventStatusCompleted,
+	__DTXEventStatusError,
+	__DTXEventStatusCancelled
+};
+
+static Class __DTXProfiler;
+static Class __DTXMutableProfilingConfiguration;
+
+static void (*__DTXProfilerAddTag)(NSString* tag);
+static NSString* (*__DTXProfilerMarkEventIntervalBegin)(NSString* category, NSString* name, NSString* __nullable message);
+static void (*__DTXProfilerMarkEventIntervalEnd)(NSString* identifier, __DTXEventStatus eventStatus, NSString* __nullable endMessage);
+static void (*__DTXProfilerMarkEvent)(NSString* category, NSString* name, __DTXEventStatus eventStatus, NSString* __nullable startMessage);
+
+@implementation DetoxInstrumentsManager
+{
+	id _recorderInstance;
+}
+
++ (void)_loadProfiler
+{
+	static dispatch_once_t onceToken;
+	dispatch_once(&onceToken, ^{
+		__DTXProfiler = NSClassFromString(@"DTXProfiler");
+		
+		if(__DTXProfiler == NULL)
+		{
+			//The user has not linked the Profiler framework. Load it manually.
+			
+			NSString* instrumentsPath = [NSUserDefaults.standardUserDefaults stringForKey:@"instrumentsPath"];
+			if(instrumentsPath == nil)
+			{
+				instrumentsPath = @"/Applications/Detox Instruments.app";
+			}
+			
+			NSURL* bundleURL = [[NSURL fileURLWithPath:instrumentsPath] URLByAppendingPathComponent:@"/Contents/SharedSupport/ProfilerFramework/DTXProfiler.framework"];
+			NSBundle* profilerBundle = [NSBundle bundleWithURL:bundleURL];
+			
+			if(profilerBundle == nil)
+			{
+				dtx_log_error(@"Error loading Profiler framework bundle. Bundle not found at %@", bundleURL.path);
+				return;
+			}
+			
+			NSError* error = nil;
+			[profilerBundle loadAndReturnError:&error];
+			
+			if(error != nil)
+			{
+				dtx_log_error(@"Error loading Profiler framework bundle: %@", error);
+				return;
+			}
+		}
+		
+		__DTXProfiler = NSClassFromString(@"DTXProfiler");
+		if(__DTXProfiler == NULL)
+		{
+			dtx_log_error(@"DTXProfiler class not found—this should not have happened!");
+			return;
+		}
+		
+		__DTXMutableProfilingConfiguration = NSClassFromString(@"DTXMutableProfilingConfiguration");
+		if(__DTXMutableProfilingConfiguration == NULL)
+		{
+			dtx_log_error(@"DTXMutableProfilingConfiguration class not found—this should not have happened!");
+			return;
+		}
+		
+		__DTXProfilerAddTag = dlsym(RTLD_DEFAULT, "DTXProfilerAddTag");
+		__DTXProfilerMarkEventIntervalBegin = dlsym(RTLD_DEFAULT, "DTXProfilerMarkEventIntervalBegin");
+		__DTXProfilerMarkEventIntervalEnd = dlsym(RTLD_DEFAULT, "DTXProfilerMarkEventIntervalEnd");
+		__DTXProfilerMarkEvent = dlsym(RTLD_DEFAULT, "DTXProfilerMarkEvent");
+		
+		if(__DTXProfilerAddTag == NULL || __DTXProfilerMarkEventIntervalBegin == NULL || __DTXProfilerMarkEventIntervalEnd == NULL || __DTXProfilerMarkEvent == NULL)
+		{
+			dtx_log_error(@"One or more DTXProfilerAPI functions are NULL—this should not have happened!");
+			return;
+		}
+	});
+}
+
++ (NSString *)_sanitizeFileNameString:(NSString *)fileName
+{
+	NSCharacterSet* illegalFileNameCharacters = [NSCharacterSet characterSetWithCharactersInString:@":/\\?%*|\"<>"];
+	return [[fileName componentsSeparatedByCharactersInSet:illegalFileNameCharacters] componentsJoinedByString:@"_"];
+}
+
++ (NSURL*)defaultURLForTestName:(NSString*)testName
+{
+	NSURL* documents = [NSFileManager.defaultManager URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject;
+	NSURL* rv = [documents URLByAppendingPathComponent:[self _sanitizeFileNameString:testName]];
+	
+	dtx_log_debug(@"Returning %@ as URL", rv.path);
+	
+	return rv;
+}
+
+- (instancetype)init
+{
+	[DetoxInstrumentsManager _loadProfiler];
+	
+	self = [super init];
+	
+	if(self)
+	{
+		_recorderInstance = [__DTXProfiler new];
+	}
+	
+	return self;
+}
+
+- (id)_configForDetoxRecordingWithURL:(NSURL*)URL
+{
+	id config = [__DTXMutableProfilingConfiguration defaultProfilingConfiguration];
+	[config setRecordingFileURL:URL];
+	
+	//TODO: Finalize the actual config for Detox perf recording.
+	[config setRecordNetwork:NO];
+	[config setRecordThreadInformation:YES];
+	[config setCollectStackTraces:YES];
+	[config setSymbolicateStackTraces:YES];
+	[config setSamplingInterval:0.1];
+	
+	return config;
+}
+
+- (void)startRecordingAtURL:(NSURL*)URL
+{
+	[_recorderInstance startProfilingWithConfiguration:[self _configForDetoxRecordingWithURL:URL]];
+}
+
+- (void)continueRecordingAtURL:(NSURL*)URL
+{
+	[_recorderInstance continueProfilingWithConfiguration:[self _configForDetoxRecordingWithURL:URL]];
+}
+
+- (void)stopRecordingWithCompletionHandler:(void(^)(NSError* error))completionHandler
+{
+	if(_recorderInstance == nil || [_recorderInstance isRecording] == NO)
+	{
+		if(completionHandler != nil)
+		{
+			completionHandler(nil);
+		}
+		
+		return;
+	}
+	
+	[_recorderInstance stopProfilingWithCompletionHandler:completionHandler];
+}
+
+@end
diff --git a/detox/ios/Detox/DetoxManager.m b/detox/ios/Detox/DetoxManager.m
index 337c521f4..382e91b21 100644
--- a/detox/ios/Detox/DetoxManager.m
+++ b/detox/ios/Detox/DetoxManager.m
@@ -17,6 +17,8 @@
 #import "EarlGreyExtensions.h"
 #import "EarlGreyStatistics.h"
 
+#import "DetoxInstrumentsManager.h"
+
 @interface UIApplication ()
 
 - (void)_sendMotionBegan:(UIEventSubtype)arg;
@@ -27,6 +29,9 @@
 DTX_CREATE_LOG(DetoxManager)
 
 @interface DetoxManager() <WebSocketDelegate, TestRunnerDelegate>
+{
+	DetoxInstrumentsManager* _recordingManager;
+}
 
 @property (nonatomic) BOOL isReady;
 @property (nonatomic, strong) WebSocket *webSocket;
@@ -99,6 +104,19 @@ static void detoxConditionalInit()
 	[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidLaunch:) name:UIApplicationDidFinishLaunchingNotification object:nil];
 	[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_appDidEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
 	
+	dispatch_async(dispatch_get_main_queue(), ^{
+		_recordingManager = [DetoxInstrumentsManager new];
+	});
+	
+	if([NSUserDefaults.standardUserDefaults objectForKey:@"currentTestSummaryDataURL"])
+	{
+		NSString* recordingPath = [NSUserDefaults.standardUserDefaults objectForKey:@"recordingPath"];
+		if(recordingPath != nil)
+		{
+			[self _handlePerformanceRecording:@{@"recordingPath": recordingPath} isFromLaunch:YES completionHandler:nil];
+		}
+	}
+	
 	return self;
 }
 
@@ -171,7 +189,7 @@ static void detoxConditionalInit()
 	[self.webSocket connectToServer:url withSessionId:sessionId];
 }
 
-- (void)websocketDidConnect
+- (void)websocketDidConnect:(WebSocket*)websocket
 {
 	if (![ReactNativeSupport isReactNativeApp])
 	{
@@ -187,11 +205,21 @@ static void detoxConditionalInit()
 	}];
 }
 
-- (void)websocketDidReceiveAction:(NSString *)type withParams:(NSDictionary *)params withMessageId:(NSNumber *)messageId
+- (void)websocket:(WebSocket*)websocket didReceiveAction:(NSString *)type withParams:(NSDictionary *)params withMessageId:(NSNumber *)messageId
 {
 	NSAssert(messageId != nil, @"Got action with a null messageId");
 	
-	if([type isEqualToString:@"waitForActive"])
+	if([type isEqualToString:@"testerDisconnected"])
+	{
+		[self _stopAndCleanupRecording];
+	}
+	else if([type isEqualToString:@"setRecordingState"])
+	{
+		[self _handlePerformanceRecording:params isFromLaunch:NO completionHandler:^ {
+			[self _safeSendAction:@"setRecordingStateDone" params:@{} messageId:messageId];
+		}];
+	}
+	else if([type isEqualToString:@"waitForActive"])
 	{
 		[self _waitForApplicationState:UIApplicationStateActive action:type messageId:messageId];
 		return;
@@ -320,6 +348,16 @@ static void detoxConditionalInit()
 	}
 }
 
+- (void)websocketDidClose:(WebSocket *)websocket
+{
+	[self _stopAndCleanupRecording];
+}
+
+- (void)_stopAndCleanupRecording
+{
+	[self _handlePerformanceRecording:nil isFromLaunch:NO completionHandler:nil];
+}
+
 - (void)_waitForRNLoadWithId:(id)messageId
 {
 	__weak __typeof(self) weakSelf = self;
@@ -354,6 +392,14 @@ static void detoxConditionalInit()
 
 - (void)notifyOnCrashWithDetails:(NSDictionary*)details
 {
+	dispatch_semaphore_t semaphore = dispatch_semaphore_create(1);
+	
+	[_recordingManager stopRecordingWithCompletionHandler:^(NSError *error) {
+		dispatch_semaphore_signal(semaphore);
+	}];
+	
+	dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
+	
 	[self.webSocket sendAction:@"AppWillTerminateWithError" withParams:details withMessageId:@-10000];
 }
 
@@ -365,4 +411,43 @@ static void detoxConditionalInit()
 	[[UIApplication sharedApplication] _sendMotionEnded:UIEventSubtypeMotionShake];
 }
 
+- (void)_handlePerformanceRecording:(NSDictionary*)props isFromLaunch:(BOOL)launch completionHandler:(void(^)(void))completionHandler
+{
+	if(completionHandler == nil)
+	{
+		completionHandler = ^ {};
+	}
+	
+	BOOL completionBlocked = NO;
+	
+	if(props[@"recordingPath"] != nil)
+	{
+		NSURL* absoluteURL = [NSURL fileURLWithPath:props[@"recordingPath"]];
+		
+		if(launch)
+		{
+			[_recordingManager continueRecordingAtURL:absoluteURL];
+		}
+		else
+		{
+			[_recordingManager startRecordingAtURL:absoluteURL];
+		}
+	}
+	else
+	{
+		completionBlocked = YES;
+		[_recordingManager stopRecordingWithCompletionHandler:^(NSError *error) {
+			dispatch_async(dispatch_get_main_queue(), ^{
+				completionHandler();
+			});
+		}];
+	}
+	
+	
+	if(completionBlocked == NO)
+	{
+		completionHandler();
+	}
+}
+
 @end
diff --git a/detox/ios/Detox/WebSocket.h b/detox/ios/Detox/WebSocket.h
index a87c66412..62e4a30d9 100644
--- a/detox/ios/Detox/WebSocket.h
+++ b/detox/ios/Detox/WebSocket.h
@@ -8,10 +8,13 @@
 
 #import <Foundation/Foundation.h>
 
+@class WebSocket;
+
 @protocol WebSocketDelegate <NSObject>
 
-- (void)websocketDidConnect;
-- (void)websocketDidReceiveAction:(NSString*)type withParams:(NSDictionary*)params withMessageId:(NSNumber*) messageId;
+- (void)websocketDidConnect:(WebSocket*)websocket;
+- (void)websocket:(WebSocket*)websocket didReceiveAction:(NSString*)type withParams:(NSDictionary*)params withMessageId:(NSNumber*)messageId;
+- (void)websocketDidClose:(WebSocket*)websocket;
 
 @end
 
@@ -21,5 +24,6 @@
 @property (nonatomic, assign) id<WebSocketDelegate> delegate;
 - (void)connectToServer:(NSString*)url withSessionId:(NSString*)sessionId;
 - (void)sendAction:(NSString*)type withParams:(NSDictionary*)params withMessageId:(NSNumber*)messageId;
+- (void)close;
 
 @end
diff --git a/detox/ios/Detox/WebSocket.m b/detox/ios/Detox/WebSocket.m
index e70ca6d15..13c869308 100644
--- a/detox/ios/Detox/WebSocket.m
+++ b/detox/ios/Detox/WebSocket.m
@@ -48,6 +48,15 @@ DTX_CREATE_LOG(WebSocket);
 	_isModern = [self.websocket respondsToSelector:@selector(sendString:error:)];
 }
 
+- (void)close
+{
+	if (self.websocket)
+	{
+		[self.websocket close];
+		self.websocket = nil;
+	}
+}
+
 - (void)sendAction:(NSString*)type withParams:(NSDictionary*)params withMessageId:(NSNumber*)messageId
 {
 	NSDictionary *data = @{@"type": type, @"params": params, @"messageId": messageId};
@@ -102,14 +111,15 @@ DTX_CREATE_LOG(WebSocket);
 		dtx_log_fault(@"receiveAction invalid messageId");
 		return;
 	}
-	dtx_log_info(@"Action Received: %@ params: %@", type, params);
-	if (self.delegate) [self.delegate websocketDidReceiveAction:type withParams:params withMessageId:messageId];
+	dtx_log_info(@"Action Received: %@", type);
+//	dtx_log_debug(@"params: %@", params);
+	[self.delegate websocket:self didReceiveAction:type withParams:params withMessageId:messageId];
 }
 
 - (void)webSocketDidOpen:(SRWebSocket *)webSocket
 {
 	[self sendAction:@"login" withParams:@{@"sessionId": self.sessionId, @"role": @"testee"} withMessageId:@0];
-	if (self.delegate) [self.delegate websocketDidConnect];
+	[self.delegate websocketDidConnect:self];
 }
 
 - (void)webSocket:(SRWebSocket *)webSocket didReceiveMessageWithString:(NSString *)string
@@ -125,6 +135,7 @@ DTX_CREATE_LOG(WebSocket);
 - (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean
 {
 	dtx_log_info(@"Socket closed: %@", reason);
+	[self.delegate websocketDidClose:self];
 }
 
 - (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message {
diff --git a/detox/local-cli/run-server.js b/detox/local-cli/run-server.js
index 58c0aa67e..67a36ebcd 100644
--- a/detox/local-cli/run-server.js
+++ b/detox/local-cli/run-server.js
@@ -31,5 +31,6 @@ module.exports.handler = async function runServer(argv) {
   new DetoxServer({
     port: +argv.port,
     log,
+    standalone: true,
   });
 };
diff --git a/detox/package.json b/detox/package.json
index 961ef4add..4cc9e5811 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -78,6 +78,7 @@
       "__tests__",
       "local-cli/utils",
       "src/artifacts/log",
+      "src/artifacts/instruments",
       "src/artifacts/screenshot",
       "src/artifacts/video",
       "src/server/DetoxServer.js",
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
new file mode 100644
index 000000000..bdbdb41b0
--- /dev/null
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
@@ -0,0 +1,94 @@
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+const argparse = require('../../utils/argparse');
+const log = require('../../utils/logger').child({ __filename });
+const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
+const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+
+class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
+  constructor(config) {
+    super(config);
+
+    this.client = config.client;
+    this.enabled = false;
+    this.shouldRecord = argparse.getArgValue('record-performance') === 'all';
+  }
+
+  async onBeforeUninstallApp(event) {
+    await super.onBeforeUninstallApp(event);
+    await this._stopRecordingIfExists();
+  }
+
+  async onBeforeTerminateApp(event) {
+    await super.onBeforeTerminateApp(event);
+    await this._stopRecordingIfExists();
+  }
+
+  async onBeforeShutdownDevice(event) {
+    await super.onBeforeShutdownDevice(event);
+    await this._stopRecordingIfExists();
+  }
+
+  async _stopRecordingIfExists() {
+    if (this.testRecording) {
+      await this.testRecording.stop();
+    }
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+
+    if (!this.enabled && this.shouldRecord) {
+      const isInstalled = await this._assertDetoxInstrumentsInstalled(event.launchArgs['-instrumentsPath']);
+      this.enabled = this.shouldRecord = isInstalled;
+    }
+
+    const isInsideRunningTest = !!this.context.testSummary;
+    if (this.enabled && isInsideRunningTest && !this.testRecording) {
+      this.testRecording = this.createTrackedTestRecording();
+    }
+
+    if (this.testRecording) {
+      event.launchArgs['-recordingPath'] = this.testRecording.temporaryRecordingPath;
+    }
+  }
+
+  async onLaunchApp(event) {
+    await super.onLaunchApp(event);
+
+    if (this.testRecording) {
+      await this.testRecording.start({ dry: true }); // start nominally, to set a correct recording state
+    }
+  }
+
+  async _assertDetoxInstrumentsInstalled(customInstrumentsPath) {
+    const instrumentsPath = customInstrumentsPath || SimulatorInstrumentsPlugin.DEFAULT_INSTRUMENTS_PATH;
+    if (await fs.exists(instrumentsPath)) {
+      return true;
+    }
+
+    const hint = customInstrumentsPath
+      ? `Please verify that -instrumentsPath argument points to the existing Detox Instruments installation.`
+      : `To enable recording performance profiles, please follow: https://github.com/wix/DetoxInstruments#installation`;
+
+    log.warn({ event: 'INSTRUMENTS_NOT_FOUND' },
+      `Failed to find Detox Instruments app at path: ${instrumentsPath}\n${hint}`);
+
+    return false;
+  }
+
+  createTestRecording() {
+    return new SimulatorInstrumentsRecording({
+      client: this.client,
+      temporaryRecordingPath: tempfile('.dtxrec'),
+    });
+  }
+
+  async preparePathForTestArtifact(testSummary) {
+    return this.api.preparePathForArtifact('test.dtxrec', testSummary);
+  }
+}
+
+SimulatorInstrumentsPlugin.DEFAULT_INSTRUMENTS_PATH = '/Applications/Detox Instruments.app';
+
+module.exports = SimulatorInstrumentsPlugin;
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js b/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
new file mode 100644
index 000000000..9cf45cd49
--- /dev/null
+++ b/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
@@ -0,0 +1,48 @@
+const log = require('../../utils/logger').child({ __filename });
+const Artifact = require('../templates/artifact/Artifact');
+
+class SimulatorInstrumentsRecording extends Artifact {
+  constructor({
+    client,
+    temporaryRecordingPath,
+  }) {
+    super();
+
+    this._client = client;
+    this.temporaryRecordingPath = temporaryRecordingPath;
+  }
+
+  async doStart({ dry = false } = {}) {
+    if (dry) {
+      return; // nominal start, to preserve state change
+    }
+
+    if (!this._isClientConnected()) {
+      return;
+    }
+
+    await this._client.startInstrumentsRecording({
+      recordingPath: this.temporaryRecordingPath,
+    });
+  }
+
+  async doStop() {
+    if (this._isClientConnected()) {
+      await this._client.stopInstrumentsRecording();
+    }
+  }
+
+  async doSave(artifactPath) {
+    await Artifact.moveTemporaryFile(log, this.temporaryRecordingPath, artifactPath);
+  }
+
+  async doDiscard() {
+    await fs.remove(this.temporaryRecordingPath);
+  }
+
+  _isClientConnected() {
+    return this._client.isConnected && !this._client.pandingAppCrash;
+  }
+}
+
+module.exports = SimulatorInstrumentsRecording;
diff --git a/detox/src/client/Client.js b/detox/src/client/Client.js
index c758ce1af..e00bad085 100644
--- a/detox/src/client/Client.js
+++ b/detox/src/client/Client.js
@@ -62,6 +62,16 @@ class Client {
     await this.sendAction(new actions.Shake());
   }
 
+  async startInstrumentsRecording({ recordingPath }) {
+    await this.sendAction(new actions.SetInstrumentsRecordingState({
+      recordingPath
+    }));
+  }
+
+  async stopInstrumentsRecording() {
+    await this.sendAction(new actions.SetInstrumentsRecordingState());
+  }
+
   async deliverPayload(params) {
     await this.sendAction(new actions.DeliverPayload(params));
   }
diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js
index d63605657..c3f761f0c 100644
--- a/detox/src/client/actions/actions.js
+++ b/detox/src/client/actions/actions.js
@@ -139,6 +139,16 @@ class CurrentStatus extends Action {
   }
 }
 
+class SetInstrumentsRecordingState extends Action {
+  constructor(params) {
+    super('setRecordingState', params);
+  }
+
+  async handle(response) {
+    this.expectResponseOfType(response, 'setRecordingStateDone');
+  }
+}
+
 class AppWillTerminateWithError extends Action {
   constructor(params) {
     super(params);
@@ -162,5 +172,6 @@ module.exports = {
   DeliverPayload,
   CurrentStatus,
   Shake,
+  SetInstrumentsRecordingState,
   AppWillTerminateWithError
 };
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/IosDriver.js
index 14178e3f7..b08c1a796 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/IosDriver.js
@@ -7,15 +7,35 @@ const invoke = require('../../invoke');
 const GREYConfigurationApi = require('../../ios/earlgreyapi/GREYConfiguration');
 const GREYConfigurationDetox = require('../../ios/earlgreyapi/GREYConfigurationDetox');
 const EarlyGreyImpl = require('../../ios/earlgreyapi/EarlGreyImpl');
+const AppleSimUtils = require('../ios/AppleSimUtils');
+
+const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin');
+const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
+const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
+const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/SimulatorInstrumentsPlugin');
 
 class IosDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
 
+    this.applesimutils = new AppleSimUtils();
+
     this.expect = require('../../ios/expect');
     this.expect.setInvocationManager(new InvocationManager(this.client));
   }
 
+  declareArtifactPlugins() {
+    const appleSimUtils = this.applesimutils;
+    const client = this.client;
+
+    return {
+      instruments: (api) => new SimulatorInstrumentsPlugin({ api, client }),
+      log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }),
+      screenshot: (api) => new SimulatorScreenshotPlugin({ api, appleSimUtils }),
+      video: (api) => new SimulatorRecordVideoPlugin({ api, appleSimUtils }),
+    };
+  }
+
   exportGlobals() {
     this.expect.exportGlobals();
   }
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index 701ef1db8..6e34f6847 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -3,37 +3,21 @@ const path = require('path');
 const fs = require('fs');
 const _ = require('lodash');
 const IosDriver = require('./IosDriver');
-const AppleSimUtils = require('../ios/AppleSimUtils');
 const configuration = require('../../configuration');
 const environment = require('../../utils/environment');
 const DeviceRegistry = require('../DeviceRegistry');
 
-const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin');
-const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
-const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
-
 class SimulatorDriver extends IosDriver {
 
   constructor(config) {
     super(config);
 
-    this._applesimutils = new AppleSimUtils();
     this.deviceRegistry = new DeviceRegistry({
-      getDeviceIdsByType: async type => await this._applesimutils.findDevicesUDID(type),
-      createDevice: type => this._applesimutils.create(type),
+      getDeviceIdsByType: async type => await this.applesimutils.findDevicesUDID(type),
+      createDevice: type => this.applesimutils.create(type),
     });
   }
 
-  declareArtifactPlugins() {
-    const appleSimUtils = this._applesimutils;
-
-    return {
-      log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }),
-      screenshot: (api) => new SimulatorScreenshotPlugin({ api, appleSimUtils }),
-      video: (api) => new SimulatorRecordVideoPlugin({ api, appleSimUtils }),
-    };
-  }
-
   async prepare() {
     const detoxFrameworkPath = await environment.getFrameworkPath();
 
@@ -72,22 +56,22 @@ class SimulatorDriver extends IosDriver {
   }
 
   async boot(deviceId) {
-    const coldBoot = await this._applesimutils.boot(deviceId);
+    const coldBoot = await this.applesimutils.boot(deviceId);
     await this.emitter.emit('bootDevice', { coldBoot, deviceId });
   }
 
   async installApp(deviceId, binaryPath) {
-    await this._applesimutils.install(deviceId, binaryPath);
+    await this.applesimutils.install(deviceId, binaryPath);
   }
 
   async uninstallApp(deviceId, bundleId) {
     await this.emitter.emit('beforeUninstallApp', { deviceId, bundleId });
-    await this._applesimutils.uninstall(deviceId, bundleId);
+    await this.applesimutils.uninstall(deviceId, bundleId);
   }
 
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
     await this.emitter.emit('beforeLaunchApp', {bundleId, deviceId, launchArgs});
-    const pid = await this._applesimutils.launch(deviceId, bundleId, launchArgs, languageAndLocale);
+    const pid = await this.applesimutils.launch(deviceId, bundleId, launchArgs, languageAndLocale);
     await this.emitter.emit('launchApp', {bundleId, deviceId, launchArgs, pid});
 
     return pid;
@@ -95,30 +79,30 @@ class SimulatorDriver extends IosDriver {
 
   async terminate(deviceId, bundleId) {
     await this.emitter.emit('beforeTerminateApp', { deviceId, bundleId });
-    await this._applesimutils.terminate(deviceId, bundleId);
+    await this.applesimutils.terminate(deviceId, bundleId);
   }
 
   async sendToHome(deviceId) {
-    await this._applesimutils.sendToHome(deviceId);
+    await this.applesimutils.sendToHome(deviceId);
   }
 
   async shutdown(deviceId) {
     await this.emitter.emit('beforeShutdownDevice', { deviceId });
-    await this._applesimutils.shutdown(deviceId);
+    await this.applesimutils.shutdown(deviceId);
     await this.emitter.emit('shutdownDevice', { deviceId });
   }
 
   async setLocation(deviceId, lat, lon) {
-    await this._applesimutils.setLocation(deviceId, lat, lon);
+    await this.applesimutils.setLocation(deviceId, lat, lon);
   }
 
   async setPermissions(deviceId, bundleId, permissions) {
-    await this._applesimutils.setPermissions(deviceId, bundleId, permissions);
+    await this.applesimutils.setPermissions(deviceId, bundleId, permissions);
   }
 
   async resetContentAndSettings(deviceId) {
     await this.shutdown(deviceId);
-    await this._applesimutils.resetContentAndSettings(deviceId);
+    await this.applesimutils.resetContentAndSettings(deviceId);
     await this.boot(deviceId);
   }
 
@@ -133,7 +117,7 @@ class SimulatorDriver extends IosDriver {
   }
 
   getLogsPaths(deviceId) {
-    return this._applesimutils.getLogsPaths(deviceId);
+    return this.applesimutils.getLogsPaths(deviceId);
   }
 
   async waitForActive() {
diff --git a/detox/src/server/DetoxServer.js b/detox/src/server/DetoxServer.js
index 2acefa54e..b865413d5 100644
--- a/detox/src/server/DetoxServer.js
+++ b/detox/src/server/DetoxServer.js
@@ -1,10 +1,12 @@
 const _ = require('lodash');
-const WebSocketServer = require('ws').Server;
+const WebSocket = require('ws');
+const WebSocketServer = WebSocket.Server;
 
 class DetoxServer {
-  constructor({ port, log }) {
+  constructor({ port, log, standalone = false }) {
     this.wss = new WebSocketServer({ port });
     this.sessions = {};
+    this.standalone = standalone;
     this.log = log.child({ __filename });
     this.log.info(`server listening on localhost:${this.wss.options.port}...`);
     this._setup();
@@ -42,6 +44,11 @@ class DetoxServer {
       ws.on('close', () => {
         if (sessionId && role) {
           this.log.debug({ event: 'DISCONNECT' }, `role=${role}, sessionId=${sessionId}`);
+
+          if (this.standalone && role === 'tester') {
+            this.sendToOtherRole(sessionId, role, { type: 'testerDisconnected' });
+          }
+
           _.set(this.sessions, [sessionId, role], undefined);
         }
       });
@@ -57,7 +64,7 @@ class DetoxServer {
   sendToOtherRole(sessionId, role, action) {
     const otherRole = role === 'testee' ? 'tester' : 'testee';
     const ws = _.get(this.sessions, [sessionId, otherRole]);
-    if (ws) {
+    if (ws && ws.readyState === WebSocket.OPEN) {
       this.sendAction(ws, action);
     } else {
       this.log.debug({ event: 'CANNOT_FORWARD' }, `role=${otherRole} not connected, cannot fw action (sessionId=${sessionId})`);
diff --git a/docs/APIRef.Artifacts.md b/docs/APIRef.Artifacts.md
index 491788d87..831f55b8f 100644
--- a/docs/APIRef.Artifacts.md
+++ b/docs/APIRef.Artifacts.md
@@ -16,6 +16,7 @@ Artifacts are disabled by default. Two things are required to enable them:
 
 * To record `.log` files, add `--record-logs all` (or `--record-logs failing`, if you want to keep logs only for failing tests).
 * To record `.mp4` test run videos, add `--record-videos all` (or `--record-videos failing`, if you want to keep video recordings only for failing tests).
+* To record `.dtxrec` (Detox Instruments recordings) for each test, add `--record-performance all`. To open those recordings, you'll need [Detox Instruments](https://github.com/wix/DetoxInstruments). **NOTE:** only iOS is supported.
 * To take `.png` screenshots before and after each test, add `--take-screenshots all` (or `--take-screenshots failing`, if you want to keep only screenshots of failing tests).  
 Alternatively, you might leverage `await device.takeScreenshot('your screenshot name')` API to have manual control over taking the screenshots.  
 To disable screenshots subsystem forcibly, use `--take-screenshots none`.
@@ -43,6 +44,7 @@ detox test --artifacts-location /tmp/detox_artifacts/ # won't append anything, h
 ```
 test.log
 test.mp4
+test.dtxrec/
 beforeEach.png
 afterEach.png
 ```
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index 6f7757315..2dad2b1b3 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -76,6 +76,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | --record-logs [failing/all/none]              | Save logs during each test to artifacts directory. Pass "failing" to save logs of failing tests only. The default value is **none**. |
 | --take-screenshots [manual/failing/all/none]  | Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only. The default value is **manual**. |
 | --record-videos [failing/all/none]            | Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only. The default value is **none**. |
+| --record-performance [all/none]               | [iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory. The default value is **none**. |
 | -r, --reuse                                   | Reuse existing installed app (do not delete + reinstall) for a faster run. |
 | -u, --cleanup                                 | Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue |
 | -w, --workers                                 | [iOS Only] Specifies number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest) |
diff --git a/scripts/unit.ios.sh b/scripts/unit.ios.sh
index 1ef129f28..a15bcb582 100755
--- a/scripts/unit.ios.sh
+++ b/scripts/unit.ios.sh
@@ -1,4 +1,4 @@
 #!/bin/bash -e
 
 set -o pipefail && xcodebuild -project detox/ios/Detox.xcodeproj -scheme Detox -configuration Debug -sdk iphonesimulator build-for-testing | xcpretty
-set -o pipefail && xcodebuild -project detox/ios/Detox.xcodeproj -scheme Detox -configuration Debug -sdk iphonesimulator test-without-building -destination 'platform=iOS Simulator,name=iPhone 7 Plus' | xcpretty
+set -o pipefail && xcodebuild -project detox/ios/Detox.xcodeproj -scheme Detox -configuration Debug -sdk iphonesimulator test-without-building -destination 'platform=iOS Simulator,name=iPhone 7 Plus'


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index 524808c81..89acdac0d 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -104,6 +104,13 @@ module.exports.builder = {
     describe:
       'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.'
   },
+  'record-performance': {
+    group: 'Debugging:',
+    choices: ['all', 'none'],
+    default: 'none',
+    describe:
+      '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.'
+  },
   w: {
     alias: 'workers',
     group: 'Execution:',
@@ -213,6 +220,7 @@ module.exports.handler = async function test(program) {
       (hasCustomValue('record-logs') ? `--record-logs ${program.recordLogs}` : ''),
       (hasCustomValue('take-screenshots') ? `--take-screenshots ${program.takeScreenshots}` : ''),
       (hasCustomValue('record-videos') ? `--record-videos ${program.recordVideos}` : ''),
+      (hasCustomValue('record-performance') ? `--record-performance ${program.recordPerformance}` : ''),
       (program.artifactsLocation ? `--artifacts-location "${program.artifactsLocation}"` : ''),
       (program.deviceName ? `--device-name "${program.deviceName}"` : ''),
       ...getPassthroughArguments(),
@@ -249,6 +257,7 @@ module.exports.handler = async function test(program) {
       'recordLogs',
       'takeScreenshots',
       'recordVideos',
+      'recordPerformance',
       'deviceName',
     ]);
 
diff --git a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
index f42e297c2..91e52998d 100644
--- a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
+++ b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
@@ -14,11 +14,8 @@ class WholeTestRecorderPlugin extends ArtifactPlugin {
     await super.onBeforeEach(testSummary);
 
     if (this.enabled) {
-      const recording = this.createTestRecording();
-      await recording.start();
-
-      this.api.trackArtifact(recording);
-      this.testRecording = recording;
+      this.testRecording = this.createTrackedTestRecording();
+      await this.testRecording.start();
     }
   }
 
@@ -38,6 +35,16 @@ class WholeTestRecorderPlugin extends ArtifactPlugin {
     }
   }
 
+  /***
+   * @protected
+   */
+  createTrackedTestRecording(config) {
+    const recording = this.createTestRecording(config);
+    this.api.trackArtifact(recording);
+
+    return recording;
+  }
+
   /***
    * @abstract
    * @protected
@@ -65,4 +72,4 @@ class WholeTestRecorderPlugin extends ArtifactPlugin {
   }
 }
 
-module.exports = WholeTestRecorderPlugin;
\ No newline at end of file
+module.exports = WholeTestRecorderPlugin;
diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js
index b9294adba..b18a3da61 100644
--- a/detox/src/client/Client.test.js
+++ b/detox/src/client/Client.test.js
@@ -1,3 +1,4 @@
+const tempfile = require('tempfile');
 const config = require('../configurations.mock').validOneDeviceAndSession.session;
 const invoke = require('../invoke');
 
@@ -50,6 +51,31 @@ describe('Client', () => {
     expect(client.ws.send).toHaveBeenCalledTimes(2);
   });
 
+  it(`startInstrumentsRecording() - should send a 'setRecordingState' action and resolve when 'setRecordingStateDone' returns`, async () => {
+    await connect();
+    client.ws.send.mockReturnValueOnce(response("setRecordingStateDone", {}, 1));
+
+    const recordingPath = tempfile('.dtxrec');
+    await client.startInstrumentsRecording({ recordingPath });
+
+    expect(client.ws.send).toHaveBeenCalledWith(expect.objectContaining({
+      type: 'setRecordingState',
+      params: expect.objectContaining({ recordingPath }),
+    }), undefined);
+  });
+
+  it(`stopInstrumentsRecording() - should send an empty 'setRecordingState' action and resolve when 'setRecordingStateDone' returns`, async () => {
+    await connect();
+    client.ws.send.mockReturnValueOnce(response("setRecordingStateDone", {}, 1));
+
+    await client.stopInstrumentsRecording();
+
+    expect(client.ws.send).toHaveBeenCalledWith(expect.objectContaining({
+      type: 'setRecordingState',
+      params: {}
+    }), undefined);
+  });
+
   it(`waitUntilReady() - should receive ready from device and resolve`, async () => {
     await connect();
     client.ws.send.mockReturnValueOnce(response("ready", {}, 1));
diff --git a/detox/test/ios/example.xcodeproj/project.pbxproj b/detox/test/ios/example.xcodeproj/project.pbxproj
index cf4f99f50..81c35676f 100644
--- a/detox/test/ios/example.xcodeproj/project.pbxproj
+++ b/detox/test/ios/example.xcodeproj/project.pbxproj
@@ -682,7 +682,7 @@
 		83CBB9F71A601CBA00E9B192 /* Project object */ = {
 			isa = PBXProject;
 			attributes = {
-				LastUpgradeCheck = 0900;
+				LastUpgradeCheck = 1020;
 				ORGANIZATIONNAME = Facebook;
 			};
 			buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "example" */;
@@ -690,6 +690,7 @@
 			developmentRegion = English;
 			hasScannedForEncodings = 0;
 			knownRegions = (
+				English,
 				en,
 				Base,
 			);
@@ -1186,7 +1187,7 @@
 					"-lc++",
 					"-Wl,-U,___dtx_send_external_log",
 				);
-				PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.wix.detox-example";
 				PRODUCT_NAME = example;
 			};
 			name = Debug;
@@ -1204,7 +1205,7 @@
 					"-lc++",
 					"-Wl,-U,___dtx_send_external_log",
 				);
-				PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
+				PRODUCT_BUNDLE_IDENTIFIER = "com.wix.detox-example";
 				PRODUCT_NAME = example;
 			};
 			name = Release;
diff --git a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
index 469e1617a..acc41d76e 100644
--- a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
+++ b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "0900"
+   LastUpgradeVersion = "1020"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "NO"
diff --git a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
index 6f9f5c486..ca9cdf7e6 100644
--- a/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
+++ b/detox/test/ios/example.xcodeproj/xcshareddata/xcschemes/example_ci.xcscheme
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
-   LastUpgradeVersion = "0900"
+   LastUpgradeVersion = "1020"
    version = "1.3">
    <BuildAction
       parallelizeBuildables = "NO"
diff --git a/detox/test/package.json b/detox/test/package.json
index a38b00ae7..f9baeaa37 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -6,7 +6,7 @@
     "test": ":",
     "packager": "react-native start",
     "detox-server": "detox run-server",
-    "e2e:ios": "detox test --configuration ios.sim.release --debug-synchronization --take-screenshots all --record-logs all",
+    "e2e:ios": "detox test --configuration ios.sim.release --debug-synchronization --take-screenshots all --record-logs all --record-performance all",
     "e2e:android": "detox test --configuration android.emu.release --take-screenshots all --record-logs all",
     "build:ios": "detox build --configuration ios.sim.release",
     "build:android": "detox build --configuration android.emu.release",