Repository: wix/Detox
Pull request #2085 (2020-05-26 09:43:02)

Upgrade EG to use modern way of network tracking
🤷‍♂️


Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
index f715e9813..e90858769 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxActionHandlers.kt
@@ -118,11 +118,16 @@ class InstrumentsRecordingStateActionHandler(
         private val instrumentsManager: DetoxInstrumentsManager,
         private val wsClient: WebSocketClient
 ) : DetoxActionHandler {
+    companion object {
+        const val DEFAULT_SAMPLING_INTERVAL = 250L
+    }
 
     override fun handle(params: String, messageId: Long) {
-        val recordingPath = JSONObject(params).opt("recordingPath")
+        val json = JSONObject(params)
+        val recordingPath = json.opt("recordingPath")
         if (recordingPath is String) {
-            instrumentsManager.startRecordingAtLocalPath(recordingPath)
+            val samplingInterval = json.optLong("samplingInterval", DEFAULT_SAMPLING_INTERVAL)
+            instrumentsManager.startRecordingAtLocalPath(recordingPath, samplingInterval)
         } else {
             instrumentsManager.stopRecording()
         }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxJUnitRunner.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxJUnitRunner.java
index b32804fab..d32efd307 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxJUnitRunner.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxJUnitRunner.java
@@ -34,12 +34,25 @@ public class DetoxJUnitRunner extends AndroidJUnitRunner {
         monitor.addLifecycleCallback(lifecycleCallback);
     }
 
+    @Override
+    public void onDestroy() {
+        instrumentsManager.stopRecording();
+        instrumentsManager = null;
+        lifecycleCallback = null;
+
+        super.onDestroy();
+    }
+
     private void onBeforeAppOnCreate(Application app, Bundle arguments) {
         if (DetoxInstrumentsManager.supports()) {
             final String recordingPath = arguments.getString("detoxInstrumRecPath");
             if (recordingPath != null) {
+                final long samplingInterval = Long.parseLong(
+                        arguments.getString("detoxInstrumSamplingInterval", "250")
+                );
+
                 instrumentsManager = new DetoxInstrumentsManager(app);
-                instrumentsManager.startRecordingAtLocalPath(recordingPath);
+                instrumentsManager.startRecordingAtLocalPath(recordingPath, samplingInterval);
             }
         }
     }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
index 5f53c601d..422bb2c3f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
@@ -29,7 +29,6 @@ class DetoxManager implements WebSocketClient.ActionHandler {
 
     private final static String DETOX_SERVER_ARG_KEY = "detoxServer";
     private final static String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId";
-    private final static String DETOX_RECORDING_PATH_ARG_KEY = "detoxInstrumRecPath";
 
     private final static Function1<Throwable, String> errorParseFn = new Function1<Throwable, String>() {
         @Override
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
index 206f829de..857a107d3 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
@@ -29,12 +29,12 @@ public class DetoxInstrumentsManager {
         instruments.tryInstallJsiHook(context);
     }
 
-    public void startRecordingAtLocalPath(String path) {
+    public void startRecordingAtLocalPath(String path, long samplingInterval) {
         if (!instruments.installed()) {
             return;
         }
 
-        recording = instruments.startRecording(context, true, 500L, new File(path), false);
+        recording = instruments.startRecording(context, true, samplingInterval, new File(path), false);
     }
 
     public void stopRecording() {
diff --git a/detox/ios/Detox/DetoxInstrumentsManager.h b/detox/ios/Detox/DetoxInstrumentsManager.h
index 0318c3b07..ca58f826a 100644
--- a/detox/ios/Detox/DetoxInstrumentsManager.h
+++ b/detox/ios/Detox/DetoxInstrumentsManager.h
@@ -8,12 +8,17 @@
 
 #import <Foundation/Foundation.h>
 
+@interface DetoxInstrumentsConfiguration : NSObject
+
+@end
+
 @interface DetoxInstrumentsManager : NSObject
 
 + (NSURL*)defaultURLForTestName:(NSString*)testName;
 
-- (void)startRecordingAtURL:(NSURL*)URL;
-- (void)continueRecordingAtURL:(NSURL*)URL;
+- (DetoxInstrumentsConfiguration *)configurationWithDictionary:(NSDictionary *)props;
+- (void)startRecordingWithConfiguration:(DetoxInstrumentsConfiguration *)configuration;
+- (void)continueRecordingWithConfiguration:(DetoxInstrumentsConfiguration *)configuration;
 - (void)stopRecordingWithCompletionHandler:(void(^)(NSError* error))completionHandler;
 
 @end
diff --git a/detox/ios/Detox/DetoxInstrumentsManager.m b/detox/ios/Detox/DetoxInstrumentsManager.m
index ed43fc0b2..f1dea097a 100644
--- a/detox/ios/Detox/DetoxInstrumentsManager.m
+++ b/detox/ios/Detox/DetoxInstrumentsManager.m
@@ -55,6 +55,22 @@ static void (*__DTXProfilerMarkEvent)(NSString* category, NSString* name, __DTXE
 WEAK_IMPORT_ATTRIBUTE
 @interface DTXProfiler : NSObject @end
 
+@interface DetoxInstrumentsConfiguration ()
+- (instancetype)initWithConfig:(id)config;
+@property (nonatomic, retain) id config;
+@end
+
+@implementation DetoxInstrumentsConfiguration
+
+- (instancetype)initWithConfig:(id)config
+{
+	self = [super init];
+	self.config = config;
+	return self;
+}
+
+@end
+
 @implementation DetoxInstrumentsManager
 {
 	id _recorderInstance;
@@ -234,10 +250,10 @@ static BOOL __DTXDecryptFramework(NSURL* encryptedBinaryURL, NSURL* targetBinary
 	return self;
 }
 
-- (id)_configForDetoxRecordingWithURL:(NSURL*)URL
+- (DetoxInstrumentsConfiguration *)configurationWithDictionary:(NSDictionary *)props
 {
 	id config = [__DTXMutableProfilingConfiguration defaultProfilingConfiguration];
-	[config setRecordingFileURL:URL];
+	[config setRecordingFileURL:[NSURL fileURLWithPath:props[@"recordingPath"]]];
 	
 	//TODO: Finalize the actual config for Detox perf recording.
 	[config setRecordEvents:YES];
@@ -252,21 +268,31 @@ static BOOL __DTXDecryptFramework(NSURL* encryptedBinaryURL, NSURL* targetBinary
 	[config setRecordThreadInformation:YES];
 	[config setCollectStackTraces:YES];
 	[config setSymbolicateStackTraces:YES];
-	[config setSamplingInterval:0.1];
+	NSTimeInterval samplingInterval = [props[@"samplingInterval"] doubleValue];
+	if (samplingInterval == 0) {
+		samplingInterval = 0.25;
+	}
+	[config setSamplingInterval:samplingInterval];
 	
-	return config;
+	return [[DetoxInstrumentsConfiguration alloc] initWithConfig:config];
 }
 
-- (void)startRecordingAtURL:(NSURL*)URL
+- (void)startRecordingWithConfiguration:(DetoxInstrumentsConfiguration *)configuration
 {
-	dtx_log_info(@"Starting recording at %@", URL);
-	[_recorderInstance startProfilingWithConfiguration:[self _configForDetoxRecordingWithURL:URL]];
+	id config = configuration.config;
+	dtx_log_info(@"Starting recording at %@ with samplingInterval %f",
+				 [config recordingFileURL],
+				 [config samplingInterval]);
+	[_recorderInstance startProfilingWithConfiguration:config];
 }
 
-- (void)continueRecordingAtURL:(NSURL*)URL
+- (void)continueRecordingWithConfiguration:(DetoxInstrumentsConfiguration *)configuration
 {
-	dtx_log_info(@"Continuing recording at %@", URL);
-	[_recorderInstance continueProfilingWithConfiguration:[self _configForDetoxRecordingWithURL:URL]];
+	id config = configuration.config;
+	dtx_log_info(@"Continuing recording at %@ with samplingInterval %f",
+				 [config recordingFileURL],
+				 [config samplingInterval]);
+	[_recorderInstance continueProfilingWithConfiguration:config];
 }
 
 - (void)stopRecordingWithCompletionHandler:(void(^)(NSError* error))completionHandler
diff --git a/detox/ios/Detox/DetoxManager.m b/detox/ios/Detox/DetoxManager.m
index 40eaef4ae..9f83ec9ea 100644
--- a/detox/ios/Detox/DetoxManager.m
+++ b/detox/ios/Detox/DetoxManager.m
@@ -90,11 +90,17 @@ 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];
-	
-	NSString* recordingPath = [NSUserDefaults.standardUserDefaults objectForKey:@"recordingPath"];
+
+	NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults;
+	NSString* recordingPath = [userDefaults objectForKey:@"recordingPath"];
 	if(recordingPath != nil)
 	{
-		[self _handlePerformanceRecording:NSDictionaryOfVariableBindings(recordingPath) isFromLaunch:YES completionHandler:nil];
+		NSString* samplingInterval = [userDefaults objectForKey:@"samplingInterval"];
+		NSMutableDictionary *props = [NSMutableDictionary new];
+		props[@"recordingPath"] = recordingPath;
+		props[@"samplingInterval"] = samplingInterval;
+
+		[self _handlePerformanceRecording:props isFromLaunch:YES completionHandler:nil];
 	}
 	
 	return self;
@@ -423,19 +429,18 @@ static void detoxConditionalInit()
 	
 	if(props[@"recordingPath"] != nil)
 	{
-		NSURL* absoluteURL = [NSURL fileURLWithPath:props[@"recordingPath"]];
-		
 		static dispatch_once_t onceToken;
 		dispatch_once(&onceToken, ^{
 			_recordingManager = [DetoxInstrumentsManager new];
 		});
+		DetoxInstrumentsConfiguration *configuration = [_recordingManager configurationWithDictionary:props];
 		if(launch)
 		{
-			[_recordingManager continueRecordingAtURL:absoluteURL];
+			[_recordingManager continueRecordingWithConfiguration:configuration];
 		}
 		else
 		{
-			[_recordingManager startRecordingAtURL:absoluteURL];
+			[_recordingManager startRecordingWithConfiguration:configuration];
 		}
 	}
 	else
diff --git a/detox/ios/EarlGrey b/detox/ios/EarlGrey
index 6ff611863..e7ba13864 160000
--- a/detox/ios/EarlGrey
+++ b/detox/ios/EarlGrey
@@ -1,1 +1,1 @@
-Subproject commit 6ff611863bec405c3812d5475d5aa4563a7074db
+Subproject commit e7ba138640ebc8ae11ce146aa9d95849d64c3327
diff --git a/detox/package.json b/detox/package.json
index dffc56107..346da25d9 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -83,7 +83,6 @@
       "__tests__",
       "local-cli/utils",
       "src/artifacts/log",
-      "src/artifacts/instruments",
       "src/artifacts/screenshot",
       "src/artifacts/video",
       "src/devices/drivers/ios/tools/AppleSimUtils.js",
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
index 07fc7de22..2c0725c80 100644
--- a/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
@@ -1,11 +1,12 @@
 const Artifact = require('../templates/artifact/Artifact');
 
 class InstrumentsArtifactRecording extends Artifact {
-  constructor({ pluginContext, client, temporaryRecordingPath }) {
+  constructor({ pluginContext, client, userConfig, temporaryRecordingPath }) {
     super();
 
     this._pluginContext = pluginContext;
     this._client = client;
+    this._userConfig = userConfig;
     this.temporaryRecordingPath = temporaryRecordingPath;
   }
 
@@ -20,9 +21,14 @@ class InstrumentsArtifactRecording extends Artifact {
 
     await this._client.startInstrumentsRecording({
       recordingPath: this.temporaryRecordingPath,
+      samplingInterval: this.prepareSamplingInterval(this._userConfig.samplingInterval)
     });
   }
 
+  prepareSamplingInterval(samplingInterval) {
+    return samplingInterval;
+  }
+
   async doStop() {
     if (this._isClientConnected()) {
       await this._client.stopInstrumentsRecording();
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
index a1849d173..c2fbbee98 100644
--- a/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
@@ -15,6 +15,7 @@ class AndroidInstrumentsPlugin extends InstrumentsArtifactPlugin {
 
     if (this.testRecording) {
       event.launchArgs['detoxInstrumRecPath'] = this.testRecording.temporaryRecordingPath;
+      event.launchArgs['detoxInstrumSamplingInterval'] = this.api.userConfig.samplingInterval;
     }
   }
 
@@ -28,6 +29,7 @@ class AndroidInstrumentsPlugin extends InstrumentsArtifactPlugin {
       pluginContext: this.context,
       client: this.client,
       deviceId: this.context.deviceId,
+      userConfig: this.api.userConfig,
       temporaryRecordingPath: this.devicePathBuilder.buildTemporaryArtifactPath('.dtxplain'),
     });
   }
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
index 35262fa8f..1d1946589 100644
--- a/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
@@ -2,8 +2,8 @@ const log = require('../../../utils/logger').child({ __filename });
 const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
 
 class AndroidInstrumentsRecording extends InstrumentsArtifactRecording {
-  constructor({ adb, pluginContext, client, deviceId, temporaryRecordingPath }) {
-    super({ pluginContext, client, temporaryRecordingPath });
+  constructor({ adb, pluginContext, client, deviceId, userConfig, temporaryRecordingPath }) {
+    super({ pluginContext, client, userConfig, temporaryRecordingPath });
     this.adb = adb;
     this.deviceId = deviceId;
   }
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
index 66e910853..64f0bd9d5 100644
--- a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
@@ -3,8 +3,8 @@ const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording')
 const temporaryPath = require('../../utils/temporaryPath');
 
 class SimulatorInstrumentsPlugin extends InstrumentsArtifactPlugin {
-  constructor({ api, client }) {
-    super({ api });
+  constructor({api, client}) {
+    super({api});
 
     this.client = client;
   }
@@ -14,6 +14,8 @@ class SimulatorInstrumentsPlugin extends InstrumentsArtifactPlugin {
 
     if (this.testRecording) {
       event.launchArgs['recordingPath'] = this.testRecording.temporaryRecordingPath;
+      event.launchArgs['samplingInterval'] =
+        SimulatorInstrumentsRecording.prepareSamplingInterval(this.api.userConfig.samplingInterval);
     }
 
     if (process.env.DETOX_INSTRUMENTS_PATH) {
@@ -29,6 +31,7 @@ class SimulatorInstrumentsPlugin extends InstrumentsArtifactPlugin {
     return new SimulatorInstrumentsRecording({
       pluginContext: this.context,
       client: this.client,
+      userConfig: this.api.userConfig,
       temporaryRecordingPath: temporaryPath.for.dtxrec(),
     });
   }
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
index ac7b5637f..68d1ec06b 100644
--- a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
@@ -1,11 +1,23 @@
 const _ = require('lodash');
+const fs = require('fs-extra');
 const log = require('../../../utils/logger').child({ __filename });
 const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
 const FileArtifact = require('../../templates/artifact/FileArtifact');
 
 class SimulatorInstrumentsRecording extends InstrumentsArtifactRecording {
-  constructor({ pluginContext, client, temporaryRecordingPath }) {
-    super({ pluginContext, client, temporaryRecordingPath });
+  constructor({ pluginContext, client, userConfig, temporaryRecordingPath }) {
+    super({ pluginContext, client, userConfig, temporaryRecordingPath });
+  }
+
+  static prepareSamplingInterval(samplingInterval) {
+    if (samplingInterval) {
+      return samplingInterval / 1000.0;
+    }
+    return 0.25;
+  }
+
+  prepareSamplingInterval(samplingInterval) {
+    return SimulatorInstrumentsRecording.prepareSamplingInterval(samplingInterval);
   }
 
   async doSave(artifactPath) {
diff --git a/detox/src/client/Client.js b/detox/src/client/Client.js
index 98cfb6561..80b22e50a 100644
--- a/detox/src/client/Client.js
+++ b/detox/src/client/Client.js
@@ -64,9 +64,9 @@ class Client {
     await this.sendAction(new actions.Shake());
   }
 
-  async startInstrumentsRecording({ recordingPath }) {
+  async startInstrumentsRecording({ recordingPath, samplingInterval }) {
     await this.sendAction(new actions.SetInstrumentsRecordingState({
-      recordingPath
+      recordingPath, samplingInterval
     }));
   }
 


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
index 68101e231..57fa46b99 100644
--- a/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
+++ b/detox/android/detox/src/test/java/com/wix/detox/DetoxActionHandlersSpec.kt
@@ -162,8 +162,17 @@ object DetoxActionHandlersSpec : Spek({
             }
 
             it("should start recording with path") {
+                uut().handle("{\"recordingPath\":\"/MockPath\", \"samplingInterval\":100500}", messageId)
+                verify(instrumentsManager).startRecordingAtLocalPath(eq("/MockPath"), eq(100500L))
+            }
+
+            it("should start recording with path and default samplingInterval") {
                 uut().handle("{\"recordingPath\":\"/MockPath\"}", messageId)
-                verify(instrumentsManager).startRecordingAtLocalPath(eq("/MockPath"))
+                verify(instrumentsManager)
+                        .startRecordingAtLocalPath(
+                                eq("/MockPath"),
+                                eq(InstrumentsRecordingStateActionHandler.DEFAULT_SAMPLING_INTERVAL)
+                        )
             }
 
             it("should stop recording without path") {
diff --git a/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
index ec5bfa55b..9257e0175 100644
--- a/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
+++ b/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
@@ -2,7 +2,6 @@ package com.wix.detox.instruments
 
 import android.content.Context
 import com.nhaarman.mockitokotlin2.*
-import org.assertj.core.api.Assertions
 import org.spekframework.spek2.Spek
 import org.spekframework.spek2.style.specification.describe
 import java.io.File
@@ -19,6 +18,7 @@ object DetoxInstrumentsManagerSpec : Spek({
         val mockAdditionalInfo = "MockAdditionalInfo"
         val mockStatus = "MockStatus"
         val mockPath = "/mock"
+        val mockSamplingInterval = 100500L
 
         beforeEachTest {
             appContext = mock()
@@ -28,10 +28,10 @@ object DetoxInstrumentsManagerSpec : Spek({
 
         it("should start recording when instruments installed with default params") {
             whenever(instruments.installed()).thenReturn(true)
-            instrumentsManager.startRecordingAtLocalPath(mockPath)
+            instrumentsManager.startRecordingAtLocalPath(mockPath, mockSamplingInterval)
 
             verify(instruments).installed()
-            verify(instruments).startRecording(appContext, true, 500L, File(mockPath), false)
+            verify(instruments).startRecording(appContext, true, mockSamplingInterval, File(mockPath), false)
         }
 
         describe("proxy events") {
@@ -54,7 +54,7 @@ object DetoxInstrumentsManagerSpec : Spek({
             }
 
             it("passing within started recording") {
-                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.startRecordingAtLocalPath(mockPath, mockSamplingInterval)
                 instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
                 instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
                 instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
@@ -65,7 +65,7 @@ object DetoxInstrumentsManagerSpec : Spek({
             }
 
             it("skipping with started recording which was stopped") {
-                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.startRecordingAtLocalPath(mockPath, mockSamplingInterval)
                 instrumentsManager.stopRecording()
                 instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
                 instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
@@ -79,7 +79,7 @@ object DetoxInstrumentsManagerSpec : Spek({
 
         it("should not start recording when instruments not installed") {
             whenever(instruments.installed()).thenReturn(false)
-            instrumentsManager.startRecordingAtLocalPath(mockPath)
+            instrumentsManager.startRecordingAtLocalPath(mockPath, mockSamplingInterval)
 
             verify(instruments).installed()
             verify(instruments, never()).startRecording(any(), any(), any(), any(), any())
@@ -109,7 +109,7 @@ object DetoxInstrumentsManagerSpec : Spek({
                         instruments.startRecording(any(), any(), any(), any(), any())
                 ).thenReturn(recording)
 
-                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.startRecordingAtLocalPath(mockPath, mockSamplingInterval)
             }
 
             it("should begin event interval") {
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
index 8533d4d47..92f98ca6f 100644
--- a/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
@@ -1,20 +1,87 @@
 const InstrumentsArtifactPlugin = require('./InstrumentsArtifactPlugin');
 
 describe('InstrumentsArtifactPlugin', () => {
-    describe('static parseConfig(config)', () => {
-        const parseConfig = InstrumentsArtifactPlugin.parseConfig;
+  describe('static parseConfig(config)', () => {
+    const parseConfig = InstrumentsArtifactPlugin.parseConfig;
 
-        const ENABLE_MODES = ['all'].map(x => [x]);
-        const DISABLE_MODES = ['none', 'manual', 'failing', 'blabla'].map(x => [x]);
-        const INCLUSIVE_MODES = ['all', 'manual', 'none', 'failing', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
+    const ENABLE_MODES = ['all'].map(x => [x]);
+    const DISABLE_MODES = ['none', 'manual', 'failing', 'blabla'].map(x => [x]);
+    const INCLUSIVE_MODES = ['all', 'manual', 'none', 'failing', { keepOnlyFailedTestsArtifacts: true }].map(x => [x]);
 
-        it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
-            expect(parseConfig(config).enabled).toBe(true));
+    it.each(ENABLE_MODES)('should enable plugin if config = %j', (config) =>
+      expect(parseConfig(config).enabled).toBe(true));
 
-        it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
-            expect(parseConfig(config).enabled).toBe(false));
+    it.each(DISABLE_MODES)('should disable plugin if config = %j', (config) =>
+      expect(parseConfig(config).enabled).toBe(false));
 
-        it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
-            expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+    it.each(INCLUSIVE_MODES)('should save all screenshots if config = %j', (config) =>
+      expect(parseConfig(config).keepOnlyFailedTestsArtifacts).toBe(false));
+  });
+
+  describe('recording', () => {
+    let plugin, testRecording;
+
+    beforeEach(() => {
+      plugin = new InstrumentsArtifactPlugin({
+        api: {
+          userConfig: {
+            enabled: true
+          }
+        }
+      });
+      testRecording = {
+        stop: jest.fn(),
+        start: jest.fn()
+      };
+    });
+
+    describe('not exists in plugin', () => {
+      it('should not be stopped', async () => {
+        await plugin._stopRecordingIfExists();
+        expect(testRecording.stop).not.toBeCalled();
+      });
+
+      it('should not be started', async () => {
+        const event = {};
+        await plugin.onLaunchApp(event);
+        expect(testRecording.start).not.toBeCalledWith({
+          dry: true
+        });
+      });
+    });
+
+    describe('exists in plugin', () => {
+      beforeEach(() => {
+        plugin.testRecording = testRecording;
+      });
+
+      it('should be stopped if exists', async () => {
+        await plugin._stopRecordingIfExists();
+        expect(testRecording.stop).toBeCalled();
+      });
+
+      it('should be stopped on onBeforeUninstallApp', async () => {
+        await plugin.onBeforeUninstallApp();
+        expect(testRecording.stop).toBeCalled();
+      });
+
+      it('should be stopped on onBeforeTerminateApp', async () => {
+        await plugin.onBeforeTerminateApp();
+        expect(testRecording.stop).toBeCalled();
+      });
+
+      it('should be stopped on onBeforeShutdownDevice', async () => {
+        await plugin.onBeforeShutdownDevice();
+        expect(testRecording.stop).toBeCalled();
+      });
+
+      it('should be started on onLaunchApp', async () => {
+        const event = {};
+        await plugin.onLaunchApp(event);
+        expect(testRecording.start).toBeCalledWith({
+          dry: true
+        });
+      });
     });
+  });
 });
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactRecording.test.js b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.test.js
new file mode 100644
index 000000000..1b056f76c
--- /dev/null
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.test.js
@@ -0,0 +1,142 @@
+const InstrumentsArtifactRecording = require('./InstrumentsArtifactRecording');
+
+describe('InstrumentsArtifactRecording', () => {
+  let mockedApi, mockedClient, mockedPluginContext;
+
+  beforeEach(() => {
+    mockedApi = {
+      trackArtifact: jest.fn(),
+      untrackArtifact: jest.fn()
+    };
+    mockedClient = {
+      pandingAppCrash: false,
+      startInstrumentsRecording: jest.fn(),
+      stopInstrumentsRecording: jest.fn()
+    };
+    mockedPluginContext = {
+      bundleId: 'some.bundle.id'
+    };
+  });
+
+  describe('isClientConnected', () => {
+    it('should be connected with real connection', () => {
+      const recording = new InstrumentsArtifactRecording({
+        pluginContext: mockedPluginContext,
+        client: mockedClient,
+      });
+      mockedClient.isConnected = true;
+      expect(recording._isClientConnected()).toBe(true);
+    });
+
+    it('should be disconnected without real connection', () => {
+      const recording = new InstrumentsArtifactRecording({
+        pluginContext: mockedPluginContext,
+        client: mockedClient,
+      });
+      mockedClient.isConnected = false;
+      expect(recording._isClientConnected()).toBe(false);
+    });
+
+    it('should be disconnected with real connection and pending app crash', () => {
+      const recording = new InstrumentsArtifactRecording({
+        pluginContext: mockedPluginContext,
+        client: mockedClient,
+      });
+      mockedClient.isConnected = true;
+      mockedClient.pandingAppCrash = true;
+      expect(recording._isClientConnected()).toBe(false);
+    });
+
+    it('should be disconnected with real connection and without app running', () => {
+      const recording = new InstrumentsArtifactRecording({
+        pluginContext: mockedPluginContext,
+        client: mockedClient,
+      });
+      mockedClient.isConnected = true;
+      mockedPluginContext.bundleId = undefined;
+      expect(recording._isClientConnected()).toBe(false);
+    });
+  });
+
+  it('should not start instruments recording as client not connected', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient,
+      userConfig: {
+        enabled: true
+      },
+      temporaryRecordingPath: 'SomeRecordingPath'
+    });
+    mockedClient.isConnected = false;
+    await recording.doStart();
+    expect(mockedClient.startInstrumentsRecording).not.toBeCalled();
+  });
+
+  it('should not start instruments recording with connected client and DRY', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient,
+      userConfig: {
+        enabled: true
+      },
+      temporaryRecordingPath: 'SomeRecordingPath'
+    });
+    mockedClient.isConnected = true;
+    await recording.doStart({ dry: true });
+    expect(mockedClient.startInstrumentsRecording).not.toBeCalled();
+  });
+
+  it('should start instruments recording with empty user config', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient,
+      userConfig: {
+        enabled: true
+      },
+      temporaryRecordingPath: 'SomeRecordingPath'
+    });
+    mockedClient.isConnected = true;
+    await recording.doStart();
+    expect(mockedClient.startInstrumentsRecording).toBeCalledWith({
+      recordingPath: 'SomeRecordingPath'
+    });
+  });
+
+  it('should start instruments recording with sampling interval', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient,
+      userConfig: {
+        enabled: true,
+        samplingInterval: 100500
+      },
+      temporaryRecordingPath: 'SomeRecordingPath'
+    });
+    mockedClient.isConnected = true;
+    await recording.doStart();
+    expect(mockedClient.startInstrumentsRecording).toBeCalledWith({
+      recordingPath: 'SomeRecordingPath',
+      samplingInterval: 100500
+    });
+  });
+
+  it('should not stop instruments recording with disconnected client', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient
+    });
+    mockedClient.isConnected = false;
+    await recording.doStop();
+    expect(mockedClient.stopInstrumentsRecording).not.toBeCalled();
+  });
+
+  it('should stop instruments recording with connected client', async () => {
+    const recording = new InstrumentsArtifactRecording({
+      pluginContext: mockedPluginContext,
+      client: mockedClient
+    });
+    mockedClient.isConnected = true;
+    await recording.doStop();
+    expect(mockedClient.stopInstrumentsRecording).toBeCalled();
+  });
+});
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.test.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.test.js
new file mode 100644
index 000000000..73cd1bf73
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.test.js
@@ -0,0 +1,76 @@
+const AndroidInstrumentsPlugin = require('./AndroidInstrumentsPlugin');
+
+describe('AndroidInstrumentsPlugin', () => {
+  const testSummary = 'SomeTestSummary';
+  let pluginConfig;
+
+  beforeEach(() => {
+    const mockedApi = {
+      trackArtifact: jest.fn(),
+      untrackArtifact: jest.fn()
+    };
+    const mockedClient = {
+      isConnected: true,
+      pandingAppCrash: false
+    };
+    const mockedDevicePathBuilder = {
+      buildTemporaryArtifactPath: () => {
+        return 'test123';
+      }
+    };
+    pluginConfig = {
+      api: {
+        ...mockedApi,
+        userConfig: {
+          enabled: true
+        }
+      },
+      client: mockedClient,
+      devicePathBuilder: mockedDevicePathBuilder
+    };
+  });
+
+  it('should not build launchArgs without started test', async () => {
+    const plugin = new AndroidInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs).toEqual({});
+  });
+
+  it('should build launchArgs with started test and empty config', async () => {
+    const plugin = new AndroidInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onTestStart(testSummary);
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs.detoxInstrumRecPath).toBe(pluginConfig.devicePathBuilder.buildTemporaryArtifactPath());
+    expect(event.launchArgs.detoxInstrumSamplingInterval).toBeUndefined();
+  });
+
+  it('should build launchArgs with started test and samplingInterval from config', async () => {
+    pluginConfig.api.userConfig.samplingInterval = 100500;
+
+    const plugin = new AndroidInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onTestStart(testSummary);
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs.detoxInstrumSamplingInterval).toBe(100500);
+  });
+
+  it('should prepare path for artifact ', async () => {
+    const preparedPath = 'SomePreparedPath';
+    const preparePathForArtifact = jest.fn();
+    pluginConfig.api.preparePathForArtifact = preparePathForArtifact;
+    preparePathForArtifact.mockReturnValueOnce(preparedPath);
+
+    const plugin = new AndroidInstrumentsPlugin(pluginConfig);
+    const path = await plugin.preparePathForTestArtifact(testSummary);
+    expect(preparePathForArtifact).toBeCalledWith('test.dtxplain', testSummary);
+    expect(path).toBe(preparedPath);
+  });
+});
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.test.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.test.js
new file mode 100644
index 000000000..ca453fea6
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.test.js
@@ -0,0 +1,32 @@
+const AndroidInstrumentsRecording = require('./AndroidInstrumentsRecording');
+
+describe('AndroidInstrumentsRecording', () => {
+  const deviceId = 'SomeDeviceId';
+  const temporaryRecordingPath = 'SomeTemporaryRecordingPath';
+  const artifactPath = 'SomeArtifactPath';
+
+  let mockedAdb, recording;
+
+  beforeEach(() => {
+    mockedAdb = {
+      pull: jest.fn(),
+      rm: jest.fn()
+    };
+    recording = new AndroidInstrumentsRecording({
+      adb: mockedAdb,
+      deviceId,
+      temporaryRecordingPath
+    });
+  });
+
+  it('should pull & remove artifact via adb on save', async () => {
+    await recording.doSave(artifactPath);
+    expect(mockedAdb.pull).toBeCalledWith(deviceId, temporaryRecordingPath, artifactPath);
+    expect(mockedAdb.rm).toBeCalledWith(deviceId, temporaryRecordingPath, true);
+  });
+
+  it('should remove artifact via adb on discard', async () => {
+    await recording.doDiscard(artifactPath);
+    expect(mockedAdb.rm).toBeCalledWith(deviceId, temporaryRecordingPath, true);
+  });
+});
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.test.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.test.js
new file mode 100644
index 000000000..3139f3d04
--- /dev/null
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.test.js
@@ -0,0 +1,89 @@
+const SimulatorInstrumentsPlugin = require('./SimulatorInstrumentsPlugin');
+const temporaryPath = require('../../utils/temporaryPath');
+
+jest.mock('../../utils/temporaryPath');
+
+describe('SimulatorInstrumentsPlugin', () => {
+  const testSummary = 'TestSummary';
+  const testPath = 'TestPath';
+
+  let pluginConfig;
+
+  beforeEach(() => {
+    const mockedApi = {
+      trackArtifact: jest.fn(),
+      untrackArtifact: jest.fn()
+    };
+    const mockedClient = {
+      isConnected: true,
+      pandingAppCrash: false
+    };
+    pluginConfig = {
+      api: {
+        ...mockedApi,
+        userConfig: {
+          enabled: true
+        }
+      },
+      client: mockedClient
+    };
+    temporaryPath.for.dtxrec.mockReturnValueOnce(testPath);
+  });
+
+  it('should not build launchArgs without started test', async () => {
+    const plugin = new SimulatorInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs.recordingPath).toBeUndefined()
+  });
+
+  it('should build launchArgs with empty config', async () => {
+    const plugin = new SimulatorInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onTestStart(testSummary);
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs.recordingPath).toBe(testPath);
+    expect(event.launchArgs.samplingInterval).toBe(0.25);
+    expect(event.launchArgs.instrumentsPath).toBeUndefined();
+  });
+
+  it('should build launchArgs with samplingInterval from config', async () => {
+    pluginConfig.api.userConfig.samplingInterval = 100500;
+
+    const plugin = new SimulatorInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onTestStart(testSummary);
+    await plugin.onBeforeLaunchApp(event);
+    expect(event.launchArgs.samplingInterval).toBe(100.500);
+  });
+
+  it('should build launchArgs with instrumentsPath from ENV', async () => {
+    const plugin = new SimulatorInstrumentsPlugin(pluginConfig);
+    const event = {
+      launchArgs: {}
+    };
+    await plugin.onTestStart(testSummary);
+    const detoxInstrumentsPath = '/DETOX/INSTRUMENTS/PATH';
+    process.env.DETOX_INSTRUMENTS_PATH = detoxInstrumentsPath;
+    await plugin.onBeforeLaunchApp(event);
+    delete process.env.DETOX_INSTRUMENTS_PATH;
+    expect(event.launchArgs.instrumentsPath).toBe(detoxInstrumentsPath);
+  });
+
+  it('should prepare path for artifact ', async () => {
+    const preparePathForArtifact = jest.fn();
+    pluginConfig.api.preparePathForArtifact = preparePathForArtifact;
+
+    const plugin = new SimulatorInstrumentsPlugin(pluginConfig);
+    preparePathForArtifact.mockReturnValueOnce(testPath);
+    const path = await plugin.preparePathForTestArtifact(testSummary);
+    expect(preparePathForArtifact).toBeCalledWith('test.dtxrec', testSummary);
+    expect(path).toBe(testPath);
+  });
+});
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.test.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.test.js
new file mode 100644
index 000000000..0013f6fe9
--- /dev/null
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.test.js
@@ -0,0 +1,49 @@
+const fs = require('fs-extra');
+
+const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+const FileArtifact = require('../../templates/artifact/FileArtifact');
+const log = require('../../../utils/logger');
+
+jest.mock('fs-extra');
+jest.mock('../../templates/artifact/FileArtifact');
+jest.mock('../../../utils/logger');
+
+describe('SimulatorInstrumentsRecording', () => {
+  const temporaryRecordingPath = 'SomeTemporaryRecordingPath';
+  const artifactPath = 'SomeArtifactPath';
+
+  let recording;
+
+  beforeEach(() => {
+    recording = new SimulatorInstrumentsRecording({
+      temporaryRecordingPath
+    });
+  });
+
+  it('should prepare sampling interval', () => {
+    expect(recording.prepareSamplingInterval(100500)).toBe(100.5);
+  });
+
+  it('should move artifact to temporary path', async () => {
+    FileArtifact.moveTemporaryFile.mockReturnValueOnce(true);
+    await recording.doSave(artifactPath);
+    expect(FileArtifact.moveTemporaryFile).toBeCalledWith(expect.anything(), temporaryRecordingPath, artifactPath);
+  });
+
+  it('should not warn console on moving artifact success', async () => {
+    FileArtifact.moveTemporaryFile.mockReturnValueOnce(true);
+    await recording.doSave(artifactPath);
+    expect(log.warn).not.toBeCalled();
+  });
+
+  it('should warn console only on moving artifact failure', async () => {
+    FileArtifact.moveTemporaryFile.mockReturnValueOnce(false);
+    await recording.doSave(artifactPath);
+    expect(log.warn).toBeCalled();
+  });
+
+  it('should remove artifact on discard', async () => {
+    await recording.doDiscard(artifactPath);
+    expect(fs.remove).toBeCalledWith(temporaryRecordingPath);
+  });
+});