Repository: wix/Detox
Pull request #1819 (2019-12-30 03:19:03)

Update docs
For MacOS Mojave 10.14.6 you need to install first

`xcode-select install` before Step number 1.3

```sh
brew tap wix/brew
brew install applesimutils
```

<!---
Thank you for contributing!
Before submitting a pull request that implements a new functionality or fixing a major bug,
please open an issue first and propose your solution. This way we can discuss before time is 
spent on a solution that may not work for us.

Please check the correct option in the below list and delete the irrelevant one.
For the second option, please fill the issue where the solution has been discussed.
-->

- [ ] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**


LeoNatan: I don’t think this is necessary if you have Xcode installed and have run it at least once before installing. 
pacozaa: I have been using Xcode for years and updated it to latest version and yet still got this problem. Yes it is necessary.

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report---android.md b/.github/ISSUE_TEMPLATE/bug-report---android.md
index a95a44480..f84c7bb26 100644
--- a/.github/ISSUE_TEMPLATE/bug-report---android.md
+++ b/.github/ISSUE_TEMPLATE/bug-report---android.md
@@ -8,26 +8,37 @@ assignees: ''
 ---
 
 **Describe the bug**
-A clear and concise description of what the bug is.
+<!-- A clear and concise description of what the bug is. -->
 
 **To Reproduce**
 
 - [ ] I have tested this issue on the latest Detox release and it still reproduces
 
+<!--
 Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.
+-->
 
+Steps to reproduce:
 1. 
 2. 
 3. 
-4. 
 
+<!--
 If possible, please provide a small demo project that reproduces the issue, or attach a video with the reproduction - this would be very appreciated.
+-->
 
 **Expected behavior**
-A clear and concise description of what you expected to happen.
+<!-- A clear and concise description of what you think should happen. -->
 
 **Screenshots**
-If applicable, add screenshots to help explain your problem.
+<!-- If applicable, add screenshots to help explain your problem. -->
+
+**Device and Verbose Detox Logs**
+<!--
+Provide the device and verbose Detox logs so we can understand what happened.
+Detox logs can be obtained by passing the loglevel param: `detox test --loglevel trace`.
+Device logs can be retrieved from the device using `adb logcat`, or if recorded, Detox' artifacts.
+-->
 
 **Environment (please complete the following information):**
  - Detox:
@@ -35,6 +46,6 @@ If applicable, add screenshots to help explain your problem.
  - Node:
  - Device:
  - OS: 
+ - Test-runner (select one): Mocha | Jest+jasmine | Jest+jest-circus
 
-**Device and Verbose Detox Logs**
-Provide the device and "trace" Detox logs so we can understand what happened. You can obtain them by passing the loglevel param: `detox test --loglevel trace`
+<!-- Note: Test-runner is set in Detox.test-runner in your package.json -->
diff --git a/.github/ISSUE_TEMPLATE/bug-report---ios.md b/.github/ISSUE_TEMPLATE/bug-report---ios.md
index 51481f9a0..17c2e022e 100644
--- a/.github/ISSUE_TEMPLATE/bug-report---ios.md
+++ b/.github/ISSUE_TEMPLATE/bug-report---ios.md
@@ -2,18 +2,18 @@
 name: Bug Report - iOS
 about: Create a report to help us improve Detox on iOS
 title: ''
-labels: 'z_platform: ios, type: triage/bug'
+labels: 'type: triage/bug, z_platform: ios'
 assignees: ''
 
 ---
 
-**Describe the bug**
+### Description
 A clear and concise description of what the bug is.
 
-**To Reproduce**
-
 - [ ] I have tested this issue on the latest Detox release and it still reproduces
 
+#### Reproduction
+
 Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.
 
 1. 
@@ -21,15 +21,13 @@ Provide the steps necessary to reproduce the issue. If you are seeing a regressi
 3. 
 4. 
 
-If possible, please provide a small demo project that reproduces the issue, or attach a video with the reproduction - this would be very appreciated.
-
-**Expected behavior**
+#### Expected behavior
 A clear and concise description of what you expected to happen.
 
-**Screenshots**
+#### Screenshots
 If applicable, add screenshots to help explain your problem.
 
-**Environment (please complete the following information):**
+#### Environment (please complete the following information):
  - Detox:
  - React Native:
  - Node:
@@ -38,8 +36,13 @@ If applicable, add screenshots to help explain your problem.
  - iOS:
  - macOS: 
 
-**Device and Verbose Detox Logs**
-Provide the device and "trace" Detox logs so we can understand what happened. You can obtain them by passing the loglevel param: `detox test --loglevel trace`
+### Logs
+
+#### If you are experiencing a timeout in your test
+- [ ] I have followed the instructions under [Lower-level Idling Resources Debug](https://github.com/wix/Detox/blob/master/docs/Troubleshooting.Synchronization.md#lower-level-idling-resources-debug-ios-only) and am providing the log below:
+
+#### If you are seeing a build problem (e.g. during `npm install`)
+- [ ] I am providing the `npm install` log below:
 
-**iOS Framework Build Logs**
-If you are seeing a build problem (e.g. during `npm install`), provide the log found here: `~/Library/Detox/ios/{...}/detox_ios.log`
+#### Device and verbose Detox logs
+- [ ] I have run my tests using the `--loglevel trace` argument and am providing the verbose log below:
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 035869b0e..6e95ff383 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -108,8 +108,8 @@ if (rootProject.hasProperty('isOfficialDetoxLib') ||
     }
 
     dependencies {
-        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.7'
-        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.7'
+        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.9'
+        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.9'
         testImplementation "org.jetbrains.kotlin:kotlin-reflect:$_kotlinVersion"
     }
 }
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 9690b75d3..f228868c3 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
@@ -3,6 +3,8 @@ package com.wix.detox
 import android.content.Context
 import android.util.Log
 import androidx.test.espresso.IdlingResource
+import com.wix.detox.instruments.DetoxInstrumentsException
+import com.wix.detox.instruments.DetoxInstrumentsManager
 import com.wix.invoke.MethodInvocation
 import org.json.JSONArray
 import org.json.JSONException
@@ -110,3 +112,61 @@ class QueryStatusActionHandler(
             })
         }
 }
+
+class InstrumentsRecordingStateActionHandler(
+        private val instrumentsManager: DetoxInstrumentsManager,
+        private val wsClient: WebSocketClient
+) : DetoxActionHandler {
+
+    override fun handle(params: String, messageId: Long) {
+        val recordingPath = JSONObject(params).optString("recordingPath", null)
+        if (recordingPath != null) {
+            instrumentsManager.startRecordingAtLocalPath(recordingPath)
+        } else {
+            instrumentsManager.stopRecording()
+        }
+
+        wsClient.sendAction("setRecordingStateDone", emptyMap<String, Any>(), messageId)
+    }
+}
+
+class InstrumentsEventsActionsHandler(
+        private val instrumentsManager: DetoxInstrumentsManager,
+        private val wsClient: WebSocketClient
+) : DetoxActionHandler {
+
+    override fun handle(params: String, messageId: Long) {
+        with (JSONObject(params))  {
+            when (getString("action")) {
+                "begin" -> {
+                    instrumentsManager.eventBeginInterval(
+                            getString("category"),
+                            getString("name"),
+                            getString("id"),
+                            getString("additionalInfo")
+                    )
+                }
+                "end" -> {
+                    instrumentsManager.eventEndInterval(
+                            getString("id"),
+                            getString("status"),
+                            getString("additionalInfo")
+                    )
+                }
+                "mark" -> {
+                    instrumentsManager.eventMark(
+                            getString("category"),
+                            getString("name"),
+                            getString("id"),
+                            getString("status"),
+                            getString("additionalInfo")
+                    )
+                }
+                else -> throw DetoxInstrumentsException("Invalid action")
+            }
+        }
+
+        wsClient.sendAction("eventDone", emptyMap<String, Any>(), messageId)
+    }
+
+}
\ No newline at end of file
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 2ff129231..6d23af10a 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
@@ -7,6 +7,7 @@ import android.os.Looper;
 import androidx.annotation.NonNull;
 import android.util.Log;
 
+import com.wix.detox.instruments.DetoxInstrumentsManager;
 import com.wix.detox.reactnative.ReactNativeExtension;
 import com.wix.detox.systeminfo.Environment;
 import com.wix.invoke.MethodInvocation;
@@ -27,6 +28,7 @@ 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 String detoxServerUrl;
     private String detoxSessionId;
@@ -39,6 +41,7 @@ class DetoxManager implements WebSocketClient.ActionHandler {
     private ReadyActionHandler readyActionHandler = null;
 
     private Context reactNativeHostHolder;
+    private DetoxInstrumentsManager instrumentsManager;
 
     DetoxManager(@NonNull Context context) {
         this.reactNativeHostHolder = context;
@@ -57,6 +60,13 @@ class DetoxManager implements WebSocketClient.ActionHandler {
             stop();
             return;
         }
+        if (DetoxInstrumentsManager.supports()) {
+            final String recordingPath = arguments.getString(DETOX_RECORDING_PATH_ARG_KEY);
+            if (recordingPath != null) {
+                instrumentsManager = new DetoxInstrumentsManager(reactNativeHostHolder);
+                instrumentsManager.startRecordingAtLocalPath(recordingPath);
+            }
+        }
 
         Log.i(LOG_TAG, "DetoxServerUrl: " + detoxServerUrl);
         Log.i(LOG_TAG, "DetoxSessionId: " + detoxSessionId);
@@ -145,5 +155,13 @@ class DetoxManager implements WebSocketClient.ActionHandler {
                 return null;
             }
         }));
+
+        if (DetoxInstrumentsManager.supports()) {
+            if (instrumentsManager == null) {
+                instrumentsManager = new DetoxInstrumentsManager(reactNativeHostHolder);
+            }
+            actionHandlers.put("setRecordingState", new InstrumentsRecordingStateActionHandler(instrumentsManager, wsClient));
+            actionHandlers.put("event", new InstrumentsEventsActionsHandler(instrumentsManager, wsClient));
+        }
     }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt b/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt
new file mode 100644
index 000000000..c4dfedea5
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt
@@ -0,0 +1,11 @@
+package com.wix.detox.common
+
+import kotlin.annotation.AnnotationTarget.*
+
+/**
+ * Source-annotation, indicating that some changes need to be made once the associated RN version
+ * (or higher) becomes the one minimally supported by Detox Android.
+ */
+@Target(FUNCTION, CLASS, CONSTRUCTOR, PROPERTY_GETTER, PROPERTY_SETTER, PROPERTY, FIELD, FILE)
+@Retention(AnnotationRetention.SOURCE)
+annotation class RNDropSupportTodo(val rnMajorVersion: Int, val message: String)
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
index cb5336e81..3bc135b0b 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
@@ -1,11 +1,10 @@
 package com.wix.detox.espresso;
 
-import android.view.InputDevice;
-import android.view.MotionEvent;
 import android.view.View;
 
 import com.wix.detox.espresso.DetoxErrors.DetoxRuntimeException;
 import com.wix.detox.espresso.DetoxErrors.StaleActionException;
+import com.wix.detox.espresso.action.RNClickAction;
 import com.wix.detox.espresso.common.annot.MotionDir;
 import com.wix.detox.espresso.scroll.ScrollEdgeException;
 import com.wix.detox.espresso.scroll.ScrollHelper;
@@ -20,7 +19,6 @@ import androidx.test.espresso.action.GeneralLocation;
 import androidx.test.espresso.action.GeneralSwipeAction;
 import androidx.test.espresso.action.Press;
 import androidx.test.espresso.action.Swipe;
-import androidx.test.espresso.action.Tap;
 
 import static androidx.test.espresso.action.ViewActions.actionWithAssertions;
 import static androidx.test.espresso.action.ViewActions.swipeDown;
@@ -61,12 +59,10 @@ public class DetoxAction {
                 view.getLocationOnScreen(xy);
                 final float fx = xy[0] + px;
                 final float fy = xy[1] + py;
-                float[] coordinates = {fx, fy};
-                return coordinates;
+                return new float[] {fx, fy};
             }
         };
-        return actionWithAssertions(new GeneralClickAction(
-                Tap.SINGLE, c, Press.FINGER, InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY));
+        return actionWithAssertions(new RNClickAction(c));
     }
 
     /**
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/RNClickAction.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/RNClickAction.java
index f8e1daacb..68bf03906 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/RNClickAction.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/RNClickAction.java
@@ -10,6 +10,7 @@ import org.hamcrest.Matcher;
 
 import androidx.test.espresso.UiController;
 import androidx.test.espresso.ViewAction;
+import androidx.test.espresso.action.CoordinatesProvider;
 import androidx.test.espresso.action.GeneralClickAction;
 import androidx.test.espresso.action.GeneralLocation;
 import androidx.test.espresso.action.Press;
@@ -17,13 +18,25 @@ import androidx.test.espresso.action.Press;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast;
 
 public class RNClickAction implements ViewAction {
-    private final GeneralClickAction clickAction =
-            new GeneralClickAction(
-                new DetoxSingleTap(),
-                GeneralLocation.VISIBLE_CENTER,
-                Press.FINGER,
-                InputDevice.SOURCE_UNKNOWN,
-                MotionEvent.BUTTON_PRIMARY);
+    private final GeneralClickAction clickAction;
+
+    public RNClickAction() {
+        clickAction = new GeneralClickAction(
+                        new DetoxSingleTap(),
+                        GeneralLocation.VISIBLE_CENTER,
+                        Press.FINGER,
+                        InputDevice.SOURCE_UNKNOWN,
+                        MotionEvent.BUTTON_PRIMARY);
+    }
+
+    public RNClickAction(CoordinatesProvider coordinatesProvider) {
+        clickAction = new GeneralClickAction(
+                        new DetoxSingleTap(),
+                        coordinatesProvider,
+                        Press.FINGER,
+                        InputDevice.SOURCE_UNKNOWN,
+                        MotionEvent.BUTTON_PRIMARY);
+    }
 
     @Override
     public Matcher<View> getConstraints() {
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java
new file mode 100644
index 000000000..9b39f0f39
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsException.java
@@ -0,0 +1,11 @@
+package com.wix.detox.instruments;
+
+public class DetoxInstrumentsException extends RuntimeException {
+    public DetoxInstrumentsException(String message) {
+        super(message);
+    }
+
+    public DetoxInstrumentsException(Throwable cause) {
+        super(cause);
+    }
+}
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
new file mode 100644
index 000000000..22500ee65
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/DetoxInstrumentsManager.java
@@ -0,0 +1,79 @@
+package com.wix.detox.instruments;
+
+import android.content.Context;
+
+import com.wix.detox.instruments.reflected.InstrumentsReflected;
+
+import java.io.File;
+
+public class DetoxInstrumentsManager {
+
+    private final Context context;
+    private final Instruments instruments;
+    private InstrumentsRecording recording;
+
+    public DetoxInstrumentsManager(Context context) {
+        this(context, getDefaultInstruments());
+    }
+
+    public DetoxInstrumentsManager(Context context, Instruments instruments) {
+        this.context = context;
+        this.instruments = instruments;
+    }
+
+    public void startRecordingAtLocalPath(String path) {
+        if (!instruments.installed()) {
+            return;
+        }
+
+        recording = instruments.startRecording(context, true, 500L, new File(path), false);
+    }
+
+    public void stopRecording() {
+        if (recording != null) {
+            recording.stop();
+            recording = null;
+        }
+    }
+
+    public void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventBeginInterval(category, name, id, additionalInfo);
+        }
+    }
+
+    public void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventEndInterval(id, eventStatus, additionalInfo);
+        }
+    }
+
+    public void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        if (recording != null) {
+            recording.eventMark(category, name, id, eventStatus, additionalInfo);
+        }
+    }
+
+    public static boolean supports() {
+        return getDefaultInstruments().installed();
+    }
+
+    private static Instruments getDefaultInstruments() {
+        return InstrumentsReflected.getInstance();
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java
new file mode 100644
index 000000000..3c6190548
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/Instruments.java
@@ -0,0 +1,17 @@
+package com.wix.detox.instruments;
+
+import android.content.Context;
+
+import java.io.File;
+
+public interface Instruments {
+    boolean installed();
+
+    InstrumentsRecording startRecording(
+            Context context,
+            boolean recordPerformance,
+            long samplingInterval,
+            File recordingFile,
+            boolean recordReactNativeTimersAsEvents
+    );
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java
new file mode 100644
index 000000000..4a59402af
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/InstrumentsRecording.java
@@ -0,0 +1,27 @@
+package com.wix.detox.instruments;
+
+
+public interface InstrumentsRecording {
+    void stop();
+
+    void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    );
+
+    void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    );
+
+    void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    );
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java
new file mode 100644
index 000000000..f3f3c8cab
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsRecordingReflected.java
@@ -0,0 +1,109 @@
+package com.wix.detox.instruments.reflected;
+
+import com.wix.detox.instruments.DetoxInstrumentsException;
+import com.wix.detox.instruments.InstrumentsRecording;
+
+import java.lang.reflect.Method;
+
+public class InstrumentsRecordingReflected implements InstrumentsRecording {
+    private static Method methodStopRecording;
+    private static Method methodEventBeginInterval;
+    private static Method methodEventEndInterval;
+    private static Method methodEventMark;
+
+    static {
+        try {
+            final String basePackageName = "com.wix.detoxprofiler";
+            final Class<?> profilerClass = Class.forName(basePackageName + ".DTXProfiler");
+
+            methodStopRecording = profilerClass.getDeclaredMethod("stopProfiling");
+            methodEventBeginInterval = profilerClass.getDeclaredMethod("eventBeginInterval",
+                    String.class,//category
+                    String.class,//name
+                    String.class,//id
+                    String.class,//additionalInfo
+                    String.class//stackTrace
+            );
+            methodEventEndInterval = profilerClass.getDeclaredMethod("eventEndInterval",
+                    String.class,//id
+                    String.class,//eventStatus
+                    String.class//additionalInfo
+            );
+            methodEventMark = profilerClass.getDeclaredMethod("eventMark",
+                    String.class,//category
+                    String.class,//name
+                    String.class,//id
+                    String.class,//eventStatus
+                    String.class//additionalInfo
+            );
+        } catch (Exception e) {
+            methodStopRecording = null;
+            methodEventBeginInterval = null;
+            methodEventEndInterval = null;
+            methodEventMark = null;
+        }
+    }
+
+    private final Object profilerInstance;
+
+    public InstrumentsRecordingReflected(Object profilerInstance) {
+        this.profilerInstance = profilerInstance;
+    }
+
+    @Override
+    public void stop() {
+        try {
+            methodStopRecording.invoke(profilerInstance);
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventBeginInterval(
+            String category,
+            String name,
+            String id,
+            String additionalInfo
+    ) {
+        try {
+            methodEventBeginInterval.invoke(profilerInstance,
+                    category, name, id, additionalInfo, null
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventEndInterval(
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        try {
+            methodEventEndInterval.invoke(profilerInstance,
+                    id, eventStatus, additionalInfo
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    @Override
+    public void eventMark(
+            String category,
+            String name,
+            String id,
+            String eventStatus,
+            String additionalInfo
+    ) {
+        try {
+            methodEventMark.invoke(profilerInstance,
+                    category, name, id, eventStatus, additionalInfo
+            );
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java
new file mode 100644
index 000000000..74b5a94a2
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/instruments/reflected/InstrumentsReflected.java
@@ -0,0 +1,80 @@
+package com.wix.detox.instruments.reflected;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.wix.detox.instruments.DetoxInstrumentsException;
+import com.wix.detox.instruments.Instruments;
+import com.wix.detox.instruments.InstrumentsRecording;
+
+import java.io.File;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+
+public class InstrumentsReflected implements Instruments {
+    private static final InstrumentsReflected instance = new InstrumentsReflected();
+
+    private static Constructor constructorDtxProfilingConfiguration;
+    private static Method methodGetInstanceOfProfiler;
+    private static Method methodStartRecording;
+    private static final boolean hasProfiler;
+
+    static {
+        try {
+            final String basePackageName = "com.wix.detoxprofiler";
+            final Class<?> profilerClass = Class.forName(basePackageName + ".DTXProfiler");
+            final Class<?> configurationClass = Class.forName(basePackageName + ".DTXProfilingConfiguration");
+
+            constructorDtxProfilingConfiguration = configurationClass.getConstructor(
+                    boolean.class,//recordPerformance
+                    long.class,//samplingIntervalMillis
+                    File.class,//recordingFile
+                    boolean.class//recordReactNativeTimersAsEvents
+            );
+            methodGetInstanceOfProfiler = profilerClass.getDeclaredMethod("getInstance", Context.class);
+            methodStartRecording = profilerClass.getDeclaredMethod("startProfiling", Context.class, configurationClass);
+        } catch (Exception e) {
+            Log.i("DetoxInstrumentsManager", "InstrumentsRecording not found", e);
+
+            constructorDtxProfilingConfiguration = null;
+            methodGetInstanceOfProfiler = null;
+            methodStartRecording = null;
+        }
+        hasProfiler = methodGetInstanceOfProfiler != null;
+    }
+
+    private InstrumentsReflected() {
+    }
+
+    @Override
+    public boolean installed() {
+        return hasProfiler;
+    }
+
+    @Override
+    public InstrumentsRecording startRecording(
+            Context context,
+            boolean recordPerformance,
+            long samplingInterval,
+            File recordingFile,
+            boolean recordReactNativeTimersAsEvents
+    ) {
+        try {
+            final Object configurationInstance = constructorDtxProfilingConfiguration.newInstance(
+                    recordPerformance,
+                    samplingInterval,
+                    recordingFile,
+                    recordReactNativeTimersAsEvents
+            );
+            final Object profilerInstance = methodGetInstanceOfProfiler.invoke(null, context);
+            methodStartRecording.invoke(profilerInstance, context, configurationInstance);
+            return new InstrumentsRecordingReflected(profilerInstance);
+        } catch (Exception e) {
+            throw new DetoxInstrumentsException(e);
+        }
+    }
+
+    public static Instruments getInstance() {
+        return instance;
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
index ac2522809..d305622a1 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
@@ -6,6 +6,8 @@ import androidx.test.espresso.IdlingRegistry
 import androidx.test.espresso.base.IdlingResourceRegistry
 import com.facebook.react.bridge.ReactContext
 import com.wix.detox.reactnative.idlingresources.*
+import com.wix.detox.reactnative.idlingresources.timers.TimersIdlingResource
+import com.wix.detox.reactnative.idlingresources.timers.getInterrogationStrategy
 import org.joor.Reflect
 import org.joor.ReflectException
 import java.util.Set
@@ -108,7 +110,7 @@ class ReactNativeIdlingResources constructor(
 
     private fun setupCustomRNIdlingResources() {
         rnBridgeIdlingResource = BridgeIdlingResource(reactContext)
-        timersIdlingResource = TimersIdlingResource(reactContext)
+        timersIdlingResource = TimersIdlingResource(getInterrogationStrategy(reactContext)!!)
         uiModuleIdlingResource = UIModuleIdlingResource(reactContext)
         animIdlingResource = AnimatedModuleIdlingResource(reactContext)
 
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt
deleted file mode 100644
index 1dd1ea4ec..000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.wix.detox.reactnative.idlingresources
-
-import android.view.Choreographer
-import androidx.test.espresso.IdlingResource
-import com.facebook.react.bridge.ReactContext
-import com.facebook.react.modules.core.Timing
-import org.joor.Reflect
-import java.util.*
-import java.util.concurrent.atomic.AtomicBoolean
-
-const val BUSY_WINDOW_THRESHOLD = 1500
-
-class TimerReflected(timer: Any) {
-    private var reflected = Reflect.on(timer)
-
-    val isRepeating: Boolean
-        get() = reflected.field("mRepeat").get()
-
-    val interval: Int
-        get() = reflected.field("mInterval").get()
-
-    val targetTime: Long
-        get() = reflected.field("mTargetTime").get()
-}
-
-class TimingModuleReflected(reactContext: ReactContext) {
-    private var nativeModule = reactContext.getNativeModule(Timing::class.java)
-
-    val timersQueue: PriorityQueue<Any>
-        get() = Reflect.on(nativeModule).field("mTimers").get()
-
-    val timersLock: Any
-        get() = Reflect.on(nativeModule).field("mTimerGuard").get()
-
-    operator fun component1() = timersQueue
-    operator fun component2() = timersLock
-}
-
-class TimersIdlingResource @JvmOverloads constructor(
-        private val reactContext: ReactContext,
-        private val getChoreographer: () -> Choreographer = { Choreographer.getInstance() }
-    ) : IdlingResource, Choreographer.FrameCallback {
-
-    private var callback: IdlingResource.ResourceCallback? = null
-    private var paused = AtomicBoolean(false)
-
-    override fun getName(): String = this.javaClass.name
-
-    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
-        this.callback = callback
-        getChoreographer().postFrameCallback(this)
-    }
-
-    override fun isIdleNow(): Boolean {
-        if (paused.get()) {
-            return true
-        }
-
-        return checkIdle().apply {
-            val result = this
-            if (result) {
-                callback?.onTransitionToIdle()
-            } else {
-                getChoreographer().postFrameCallback(this@TimersIdlingResource)
-            }
-        }
-    }
-
-    override fun doFrame(frameTimeNanos: Long) {
-        callback?.let {
-            isIdleNow
-        }
-    }
-
-    fun pause() {
-        paused.set(true)
-        callback?.onTransitionToIdle()
-    }
-
-    fun resume() {
-        paused.set(false)
-    }
-
-    private fun checkIdle(): Boolean {
-        val (timersQueue, timersLock) = TimingModuleReflected(reactContext)
-        synchronized(timersLock) {
-            val nextTimer = timersQueue.peek()
-            nextTimer?.let {
-                return !isTimerInBusyWindow(it) && !hasBusyTimers(timersQueue)
-            }
-            return true
-        }
-    }
-
-    private fun isTimerInBusyWindow(timer: Any): Boolean {
-        val timerReflected = TimerReflected(timer)
-        return when {
-            timerReflected.isRepeating -> false
-            timerReflected.interval > BUSY_WINDOW_THRESHOLD -> false
-            else -> true
-        }
-    }
-
-    private fun hasBusyTimers(timersQueue: PriorityQueue<Any>): Boolean {
-        timersQueue.forEach {
-            if (isTimerInBusyWindow(it)) {
-                return true
-            }
-        }
-        return false
-    }
-}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt
new file mode 100644
index 000000000..51e8d2e01
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt
@@ -0,0 +1,76 @@
+@file:RNDropSupportTodo(62, "Remove all of this; Use DelegatedIdleInterrogationStrategy, instead.")
+
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactContext
+import com.wix.detox.common.RNDropSupportTodo
+import org.joor.Reflect
+import java.util.*
+
+private const val BUSY_WINDOW_THRESHOLD = 1500
+
+private class TimerReflected(timer: Any) {
+    private var reflected = Reflect.on(timer)
+
+    val isRepeating: Boolean
+        get() = reflected.field("mRepeat").get()
+    val interval: Int
+        get() = reflected.field("mInterval").get()
+    val targetTime: Long
+        get() = reflected.field("mTargetTime").get()
+}
+
+private class TimingModuleReflected(private val nativeModule: NativeModule) {
+    val timersQueue: PriorityQueue<Any>
+        get() = Reflect.on(nativeModule).field("mTimers").get()
+    val timersLock: Any
+        get() = Reflect.on(nativeModule).field("mTimerGuard").get()
+
+    operator fun component1() = timersQueue
+    operator fun component2() = timersLock
+}
+
+class DefaultIdleInterrogationStrategy
+    internal constructor(private val timersModule: NativeModule)
+    : IdleInterrogationStrategy {
+
+    override fun isIdleNow(): Boolean {
+        val (timersQueue, timersLock) = TimingModuleReflected(timersModule)
+        synchronized(timersLock) {
+            val nextTimer = timersQueue.peek()
+            nextTimer?.let {
+                return !isTimerInBusyWindow(it) && !hasBusyTimers(timersQueue)
+            }
+            return true
+        }
+    }
+
+    private fun isTimerInBusyWindow(timer: Any): Boolean {
+        val timerReflected = TimerReflected(timer)
+        return when {
+            timerReflected.isRepeating -> false
+            timerReflected.interval > BUSY_WINDOW_THRESHOLD -> false
+            else -> true
+        }
+    }
+
+    private fun hasBusyTimers(timersQueue: PriorityQueue<Any>): Boolean {
+        timersQueue.forEach {
+            if (isTimerInBusyWindow(it)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    companion object {
+        fun createIfSupported(reactContext: ReactContext): DefaultIdleInterrogationStrategy? =
+            try {
+                val timingClass: Class<NativeModule> = Class.forName("com.facebook.react.modules.core.Timing") as Class<NativeModule>
+                DefaultIdleInterrogationStrategy(reactContext.getNativeModule(timingClass))
+            } catch (ex: Exception) {
+                null
+            }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt
new file mode 100644
index 000000000..6e5431b11
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt
@@ -0,0 +1,51 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactContext
+import com.wix.detox.common.RNDropSupportTodo
+import org.joor.Reflect
+
+private const val BUSY_WINDOW_THRESHOLD = 1500L
+
+private class RN62TimingModuleReflected(private val timingModule: NativeModule) {
+    fun hasActiveTimers(): Boolean = Reflect.on(timingModule).call("hasActiveTimersInRange", BUSY_WINDOW_THRESHOLD).get()
+}
+
+/**
+ * Delegates the interrogation to the native module itself, added
+ * [here](https://github.com/facebook/react-native/pull/27539) in the context
+ * of RN v0.62 (followed by a previous refactor and rename of the class).
+ */
+@RNDropSupportTodo(62, """
+    When min RN version supported by Detox is 0.62.x (or higher), 
+    | can (and should) remove any usage of reflection here. That 
+    | includes the unit test's stub being used for that reason in particular.
+    """)
+class DelegatedIdleInterrogationStrategy(timingModule: NativeModule): IdleInterrogationStrategy {
+    private val timingModuleReflected = RN62TimingModuleReflected(timingModule)
+
+    override fun isIdleNow(): Boolean = timingModuleReflected.hasActiveTimers()
+
+    companion object {
+        fun createIfSupported(reactContext: ReactContext): DelegatedIdleInterrogationStrategy? {
+            val moduleClass: Class<NativeModule>?
+            try {
+                moduleClass = Class.forName("com.facebook.react.modules.core.TimingModule") as Class<NativeModule>
+            } catch (ex: Exception) {
+                return null
+            }
+
+            if (!reactContext.hasNativeModule(moduleClass)) {
+                return null
+            }
+
+            try {
+                moduleClass.getDeclaredMethod("hasActiveTimersInRange", Long::class.java)
+            } catch (ex: Exception) {
+                return null
+            }
+
+            return DelegatedIdleInterrogationStrategy(reactContext.getNativeModule(moduleClass))
+        }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
new file mode 100644
index 000000000..9ae72d879
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
@@ -0,0 +1,21 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.util.Log
+import com.facebook.react.bridge.ReactContext
+
+interface IdleInterrogationStrategy {
+    fun isIdleNow(): Boolean
+}
+
+fun getInterrogationStrategy(reactContext: ReactContext): IdleInterrogationStrategy? {
+    DelegatedIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+        return it
+    }
+
+    DefaultIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+        return it
+    }
+
+    Log.e(LOG_TAG, "Failed to determine proper implementation-strategy for timers idling resource")
+    return null
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
new file mode 100644
index 000000000..cca694a08
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
@@ -0,0 +1,54 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.view.Choreographer
+import androidx.test.espresso.IdlingResource
+import java.util.concurrent.atomic.AtomicBoolean
+
+const val LOG_TAG = "TimersIdlingResource"
+
+class TimersIdlingResource @JvmOverloads constructor(
+        private val interrogationStrategy: IdleInterrogationStrategy,
+        private val getChoreographer: () -> Choreographer = { Choreographer.getInstance() }
+    ) : IdlingResource, Choreographer.FrameCallback {
+
+    private var callback: IdlingResource.ResourceCallback? = null
+    private var paused = AtomicBoolean(false)
+
+    override fun getName(): String = this.javaClass.name
+
+    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
+        this.callback = callback
+        getChoreographer().postFrameCallback(this)
+    }
+
+    override fun isIdleNow(): Boolean {
+        if (paused.get()) {
+            return true
+        }
+
+        return checkIdle().also { result ->
+            if (result) {
+                callback?.onTransitionToIdle()
+            } else {
+                getChoreographer().postFrameCallback(this@TimersIdlingResource)
+            }
+        }
+    }
+
+    override fun doFrame(frameTimeNanos: Long) {
+        callback?.let {
+            isIdleNow
+        }
+    }
+
+    fun pause() {
+        paused.set(true)
+        callback?.onTransitionToIdle()
+    }
+
+    fun resume() {
+        paused.set(false)
+    }
+
+    private fun checkIdle() = interrogationStrategy.isIdleNow()
+}
diff --git a/detox/local-cli/templates/jest.js b/detox/local-cli/templates/jest.js
index 40fb3f7c2..feb3e4d86 100644
--- a/detox/local-cli/templates/jest.js
+++ b/detox/local-cli/templates/jest.js
@@ -14,6 +14,7 @@ const specReporter = require('detox/runners/jest/specReporter');
 
 // Set the default timeout
 jest.setTimeout(120000);
+
 jasmine.getEnv().addReporter(adapter);
 
 // This takes care of generating status logs on a per-spec basis. By default, jest only reports at file-level.
@@ -22,7 +23,7 @@ jasmine.getEnv().addReporter(specReporter);
 
 beforeAll(async () => {
   await detox.init(config);
-});
+}, 300000);
 
 beforeEach(async () => {
   await adapter.beforeEach();
diff --git a/detox/local-cli/utils/deprecation.js b/detox/local-cli/utils/deprecation.js
index 4afa7928d..348dcf181 100644
--- a/detox/local-cli/utils/deprecation.js
+++ b/detox/local-cli/utils/deprecation.js
@@ -1,26 +1,14 @@
 const chalk = require('chalk');
 const log = require('../../src/utils/logger').child({ __filename });
-const migrationGuideUrl = 'https://github.com/wix/Detox/blob/master/docs/Guide.Migration.md#migrating-from-detox-120x-to-121x';
 
 function coerceDeprecation(option) {
   return function coerceDeprecationFn(value) {
     log.warn(`Beware: ${option} will be removed in the next version of Detox.`);
-    log.warn(`See the migration guide:\n${migrationGuideUrl} `);
 
     return value;
   };
 }
 
-function printFileDeprecationWarning(file) {
-  log.warn('Deprecated: "file" option in "detox" section of package.json won\'t be supported in the next Detox version.\n');
-  console.log(`   "detox": {`);
-  console.log(chalk.red(`-    "file": ${JSON.stringify(file)},`));
-  console.log(chalk.green(`+    "specs": ${JSON.stringify(file)},\n`));
-  log.warn(`Please rename it to "specs", as demonstrated above.`);
-}
-
 module.exports = {
   coerceDeprecation,
-  migrationGuideUrl,
-  printFileDeprecationWarning,
 };
diff --git a/detox/package.json b/detox/package.json
index 3f5d52d01..b41da4a66 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -88,6 +88,7 @@
       "src/devices/ios/AppleSimUtils.js",
       "src/server/DetoxServer.js",
       ".*Driver.js",
+      "MissingDetox.js",
       "EmulatorTelnet.js",
       "Emulator.js",
       "DeviceDriverBase.js",
diff --git a/detox/runners/jest/WorkerAssignReporterImpl.js b/detox/runners/jest/WorkerAssignReporterImpl.js
index 3643f9b63..23a76bfdc 100644
--- a/detox/runners/jest/WorkerAssignReporterImpl.js
+++ b/detox/runners/jest/WorkerAssignReporterImpl.js
@@ -1,3 +1,4 @@
+const _ = require('lodash');
 const chalk = require('chalk').default;
 const ReporterBase = require('./ReporterBase');
 const log = require('../../src/utils/logger').child();
@@ -9,7 +10,12 @@ class WorkerAssignReporterImpl extends ReporterBase {
   }
 
   report(workerName) {
-    log.info({event: 'WORKER_ASSIGN'}, `${chalk.whiteBright(workerName)} assigned to ${chalk.blueBright(this.device.name)}`);
+    const deviceName = _.attempt(() => this.device.name);
+    const formattedDeviceName = _.isError(deviceName)
+      ? chalk.redBright('undefined')
+      : chalk.blueBright(deviceName);
+
+    log.info({event: 'WORKER_ASSIGN'}, `${chalk.whiteBright(workerName)} is assigned to ${formattedDeviceName}`);
   }
 }
 
diff --git a/detox/scripts/build_framework.ios.sh b/detox/scripts/build_framework.ios.sh
index 315016109..d518a2980 100755
--- a/detox/scripts/build_framework.ios.sh
+++ b/detox/scripts/build_framework.ios.sh
@@ -35,7 +35,16 @@ function buildFramework () {
   detoxSourcePath="${1}"
   echo "Building Detox.framework from ${detoxSourcePath}..."
   mkdir -p "${detoxFrameworkDirPath}"
-  "${detoxRootPath}"/scripts/build_universal_framework.sh "${detoxSourcePath}"/Detox.xcodeproj "${detoxFrameworkDirPath}" &> "${detoxFrameworkDirPath}"/detox_ios.log
+	logPath="${detoxFrameworkDirPath}"/detox_ios.log
+	echo -n "" > "${logPath}"
+  "${detoxRootPath}"/scripts/build_universal_framework.sh "${detoxSourcePath}"/Detox.xcodeproj "${detoxFrameworkDirPath}" &> "${logPath}"
+	errno=$?
+	if [ $errno -ne 0 ]; then
+		echo -e "#################################\nError building Detox.framework:\n----------------------------------\n"
+		cat "${logPath}"
+		echo "#################################"
+	fi
+	exit $errno
 }
 
 function main () {
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index a4bb63f77..f3181ce28 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -9,6 +9,7 @@ const EmulatorDriver = require('./devices/drivers/EmulatorDriver');
 const AttachedAndroidDriver = require('./devices/drivers/AttachedAndroidDriver');
 const DetoxRuntimeError = require('./errors/DetoxRuntimeError');
 const argparse = require('./utils/argparse');
+const MissingDetox = require('./utils/MissingDetox');
 const configuration = require('./configuration');
 const Client = require('./client/Client');
 const DetoxServer = require('./server/DetoxServer');
@@ -81,13 +82,11 @@ class Detox {
       Object.assign(global, globalsToExport);
     }
 
-    await this._artifactsManager.onBeforeAll();
-
     return this;
   }
 
   async cleanup() {
-    await this._artifactsManager.onAfterAll();
+    await this._artifactsManager.onBeforeCleanup();
 
     if (this._client) {
       this._client.dumpPendingRequests();
@@ -114,13 +113,13 @@ class Detox {
       pendingRequests: false,
       testName: testSummary.fullName,
     });
-    await this._artifactsManager.onBeforeEach(testSummary);
+    await this._artifactsManager.onTestStart(testSummary);
   }
 
   async afterEach(testSummary) {
     this._validateTestSummary(testSummary);
     this._logTestRunCheckpoint('DETOX_AFTER_EACH', testSummary);
-    await this._artifactsManager.onAfterEach(testSummary);
+    await this._artifactsManager.onTestDone(testSummary);
     await this._dumpUnhandledErrorsIfAny({
       pendingRequests: testSummary.timedOut,
       testName: testSummary.fullName,
@@ -179,4 +178,6 @@ class Detox {
   }
 }
 
+Detox.none = new MissingDetox();
+
 module.exports = Detox;
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index a8c693997..58e598d72 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -10,7 +10,7 @@ const _detox = Symbol('detox');
 
 class DetoxExportWrapper {
   constructor() {
-    this[_detox] = null;
+    this[_detox] = Detox.none;
 
     this.init = this.init.bind(this);
     this.cleanup = this.cleanup.bind(this);
@@ -29,33 +29,39 @@ class DetoxExportWrapper {
   }
 
   async init(config, params) {
+    if (!params || params.initGlobals !== false) {
+      Detox.none.initContext(global);
+    }
+
     this[_detox] = await DetoxExportWrapper._initializeInstance(config, params);
     return this[_detox];
   }
 
   async cleanup() {
-    if (this[_detox]) {
+    Detox.none.cleanupContext(global);
+
+    if (this[_detox] !== Detox.none) {
       await this[_detox].cleanup();
-      this[_detox] = null;
+      this[_detox] = Detox.none;
     }
   }
 
   _definePassthroughMethod(name) {
     this[name] = (...args) => {
-      if (this[_detox]) {
-        return this[_detox][name](...args);
-      }
+      return this[_detox][name](...args);
     };
   }
 
   _defineProxy(name) {
-    this[name] = funpermaproxy(() => (this[_detox] && this[_detox][name]));
+    this[name] = funpermaproxy(() => this[_detox][name]);
   }
 
   static async _initializeInstance(detoxConfig, params) {
     let instance = null;
 
     try {
+      Detox.none.setError(null);
+
       if (!detoxConfig) {
         throw new Error(`No configuration was passed to detox, make sure you pass a detoxConfig when calling 'detox.init(detoxConfig)'`);
       }
@@ -81,6 +87,7 @@ class DetoxExportWrapper {
       await instance.init(params);
       return instance;
     } catch (err) {
+      Detox.none.setError(err);
       log.error({ event: 'DETOX_INIT_ERROR' }, '\n', err);
 
       if (instance) {
diff --git a/detox/src/artifacts/ArtifactsManager.js b/detox/src/artifacts/ArtifactsManager.js
index d9b249ba5..999cb8448 100644
--- a/detox/src/artifacts/ArtifactsManager.js
+++ b/detox/src/artifacts/ArtifactsManager.js
@@ -96,6 +96,7 @@ class ArtifactsManager {
     deviceEmitter.on('launchApp', this.onLaunchApp.bind(this));
     deviceEmitter.on('beforeUninstallApp', this.onBeforeUninstallApp.bind(this));
     deviceEmitter.on('beforeTerminateApp', this.onBeforeTerminateApp.bind(this));
+    deviceEmitter.on('terminateApp', this.onTerminateApp.bind(this));
     deviceEmitter.on('createExternalArtifact', this.onCreateExternalArtifact.bind(this));
   }
 
@@ -107,6 +108,10 @@ class ArtifactsManager {
     await this._callPlugins('plain', 'onBeforeTerminateApp', appInfo);
   }
 
+  async onTerminateApp(appInfo) {
+    await this._callPlugins('plain', 'onTerminateApp', appInfo);
+  }
+
   async onBeforeUninstallApp(appInfo) {
     await this._callPlugins('plain', 'onBeforeUninstallApp', appInfo);
   }
@@ -134,20 +139,16 @@ class ArtifactsManager {
     });
   }
 
-  async onBeforeAll() {
-    await this._callPlugins('ascending', 'onBeforeAll');
-  }
-
-  async onBeforeEach(testSummary) {
-    await this._callPlugins('ascending', 'onBeforeEach', testSummary);
+  async onTestStart(testSummary) {
+    await this._callPlugins('ascending', 'onTestStart', testSummary);
   }
 
-  async onAfterEach(testSummary) {
-    await this._callPlugins('descending', 'onAfterEach', testSummary);
+  async onTestDone(testSummary) {
+    await this._callPlugins('descending', 'onTestDone', testSummary);
   }
 
-  async onAfterAll() {
-    await this._callPlugins('descending', 'onAfterAll');
+  async onBeforeCleanup() {
+    await this._callPlugins('descending', 'onBeforeCleanup');
     await this._idlePromise;
   }
 
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
similarity index 54%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
rename to detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
index 94bcaa245..669b3ab56 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.js
@@ -1,11 +1,9 @@
-const temporaryPath = require('../utils/temporaryPath');
 const WholeTestRecorderPlugin = require('../templates/plugin/WholeTestRecorderPlugin');
-const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+const log = require('../../utils/logger').child({ __filename });
 
-class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
-  constructor({ api, client }) {
+class InstrumentsArtifactPlugin extends WholeTestRecorderPlugin {
+  constructor({ api }) {
     super({ api });
-    this.client = client;
   }
 
   async onBeforeUninstallApp(event) {
@@ -29,18 +27,6 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
     }
   }
 
-  async onBeforeLaunchApp(event) {
-    await super.onBeforeLaunchApp(event);
-
-    if (process.env.DETOX_INSTRUMENTS_PATH) {
-      event.launchArgs['instrumentsPath'] = process.env.DETOX_INSTRUMENTS_PATH;
-    }
-
-    if (this.testRecording) {
-      event.launchArgs['recordingPath'] = this.testRecording.temporaryRecordingPath;
-    }
-  }
-
   async onLaunchApp(event) {
     await super.onLaunchApp(event);
 
@@ -49,17 +35,6 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
     }
   }
 
-  createTestRecording() {
-    return new SimulatorInstrumentsRecording({
-      client: this.client,
-      temporaryRecordingPath: temporaryPath.for.dtxrec(),
-    });
-  }
-
-  async preparePathForTestArtifact(testSummary) {
-    return this.api.preparePathForArtifact('test.dtxrec', testSummary);
-  }
-
   static parseConfig(config) {
     switch (config) {
       case 'all':
@@ -77,4 +52,4 @@ class SimulatorInstrumentsPlugin extends WholeTestRecorderPlugin {
   }
 }
 
-module.exports = SimulatorInstrumentsPlugin;
+module.exports = InstrumentsArtifactPlugin;
diff --git a/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
new file mode 100644
index 000000000..ef851f07f
--- /dev/null
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactRecording.js
@@ -0,0 +1,36 @@
+const Artifact = require('../templates/artifact/Artifact');
+
+class InstrumentsArtifactRecording 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();
+    }
+  }
+
+  _isClientConnected() {
+    return this._client.isConnected && !this._client.pandingAppCrash;
+  }
+}
+
+module.exports = InstrumentsArtifactRecording;
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
new file mode 100644
index 000000000..9ea82070f
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsPlugin.js
@@ -0,0 +1,35 @@
+const InstrumentsArtifactPlugin = require('../InstrumentsArtifactPlugin');
+const AndroidInstrumentsRecording = require('./AndroidInstrumentsRecording');
+
+class AndroidInstrumentsPlugin extends InstrumentsArtifactPlugin {
+  constructor({ api, adb, client, devicePathBuilder }) {
+    super({ api });
+
+    this.adb = adb;
+    this.client = client;
+    this.devicePathBuilder = devicePathBuilder;
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+
+    if (this.testRecording) {
+      event.launchArgs['detoxInstrumRecPath'] = this.testRecording.temporaryRecordingPath;
+    }
+  }
+
+  async preparePathForTestArtifact(testSummary) {
+    return this.api.preparePathForArtifact('test.dtxplain', testSummary);
+  }
+
+  createTestRecording() {
+    return new AndroidInstrumentsRecording({
+      adb: this.adb,
+      client: this.client,
+      deviceId: this.context.deviceId,
+      temporaryRecordingPath: this.devicePathBuilder.buildTemporaryArtifactPath('.dtxplain'),
+    });
+  }
+}
+
+module.exports = AndroidInstrumentsPlugin;
diff --git a/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
new file mode 100644
index 000000000..b163e52a7
--- /dev/null
+++ b/detox/src/artifacts/instruments/android/AndroidInstrumentsRecording.js
@@ -0,0 +1,22 @@
+const log = require('../../../utils/logger').child({ __filename });
+const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
+
+class AndroidInstrumentsRecording extends InstrumentsArtifactRecording {
+  constructor({ adb, client, deviceId, temporaryRecordingPath }) {
+    super({ client, temporaryRecordingPath });
+    this.adb = adb;
+    this.deviceId = deviceId;
+  }
+
+  async doSave(artifactPath) {
+    await super.doSave(artifactPath);
+    await this.adb.pull(this.deviceId, this.temporaryRecordingPath, artifactPath);
+    await this.adb.rm(this.deviceId, this.temporaryRecordingPath, true);
+  }
+
+  async doDiscard() {
+    await this.adb.rm(this.deviceId, this.temporaryRecordingPath, true);
+  }
+}
+
+module.exports = AndroidInstrumentsRecording;
diff --git a/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
new file mode 100644
index 000000000..66e910853
--- /dev/null
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsPlugin.js
@@ -0,0 +1,37 @@
+const InstrumentsArtifactPlugin = require('../InstrumentsArtifactPlugin');
+const SimulatorInstrumentsRecording = require('./SimulatorInstrumentsRecording');
+const temporaryPath = require('../../utils/temporaryPath');
+
+class SimulatorInstrumentsPlugin extends InstrumentsArtifactPlugin {
+  constructor({ api, client }) {
+    super({ api });
+
+    this.client = client;
+  }
+
+  async onBeforeLaunchApp(event) {
+    await super.onBeforeLaunchApp(event);
+
+    if (this.testRecording) {
+      event.launchArgs['recordingPath'] = this.testRecording.temporaryRecordingPath;
+    }
+
+    if (process.env.DETOX_INSTRUMENTS_PATH) {
+      event.launchArgs['instrumentsPath'] = process.env.DETOX_INSTRUMENTS_PATH;
+    }
+  }
+
+  async preparePathForTestArtifact(testSummary) {
+    return this.api.preparePathForArtifact('test.dtxrec', testSummary);
+  }
+
+  createTestRecording() {
+    return new SimulatorInstrumentsRecording({
+      pluginContext: this.context,
+      client: this.client,
+      temporaryRecordingPath: temporaryPath.for.dtxrec(),
+    });
+  }
+}
+
+module.exports = SimulatorInstrumentsPlugin;
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
similarity index 51%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
rename to detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
index ec1e2bf9d..b72d27685 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsRecording.js
+++ b/detox/src/artifacts/instruments/ios/SimulatorInstrumentsRecording.js
@@ -1,37 +1,13 @@
 const _ = require('lodash');
-const log = require('../../utils/logger').child({ __filename });
-const Artifact = require('../templates/artifact/Artifact');
-const FileArtifact = require('../templates/artifact/FileArtifact');
-
-class SimulatorInstrumentsRecording extends Artifact {
-  constructor({
-    client,
-    temporaryRecordingPath,
-  }) {
-    super();
-
-    this._client = client;
-    this.temporaryRecordingPath = temporaryRecordingPath;
-  }
+const log = require('../../../utils/logger').child({ __filename });
+const InstrumentsArtifactRecording = require('../InstrumentsArtifactRecording');
+const FileArtifact = require('../../templates/artifact/FileArtifact');
 
-  async doStart({ dry = false } = {}) {
-    if (dry) {
-      return; // nominal start, to preserve state change
-    }
+class SimulatorInstrumentsRecording extends InstrumentsArtifactRecording {
+  constructor({ pluginContext, client, temporaryRecordingPath }) {
+    super({ client, temporaryRecordingPath });
 
-    if (!this._isClientConnected()) {
-      return;
-    }
-
-    await this._client.startInstrumentsRecording({
-      recordingPath: this.temporaryRecordingPath,
-    });
-  }
-
-  async doStop() {
-    if (this._isClientConnected()) {
-      await this._client.stopInstrumentsRecording();
-    }
+    this._pluginContext = pluginContext;
   }
 
   async doSave(artifactPath) {
@@ -45,8 +21,12 @@ class SimulatorInstrumentsRecording extends Artifact {
     await fs.remove(this.temporaryRecordingPath);
   }
 
+
   _isClientConnected() {
-    return this._client.isConnected && !this._client.pandingAppCrash;
+    const isConnectedToDetoxServer = super._isClientConnected();
+    const isAppRunning = this._pluginContext.bundleId;
+
+    return Boolean(isConnectedToDetoxServer && isAppRunning);
   }
 }
 
diff --git a/detox/src/artifacts/log/LogArtifactPlugin.js b/detox/src/artifacts/log/LogArtifactPlugin.js
index c36c3f627..dc7bd46a9 100644
--- a/detox/src/artifacts/log/LogArtifactPlugin.js
+++ b/detox/src/artifacts/log/LogArtifactPlugin.js
@@ -11,8 +11,8 @@ class LogArtifactPlugin extends StartupAndTestRecorderPlugin {
     super({ api });
   }
 
-  async onAfterAll() {
-    await super.onAfterAll();
+  async onBeforeCleanup() {
+    await super.onBeforeCleanup();
 
     if (this.shouldKeepArtifactOfSession()) {
       this.api.requestIdleCallback(async () => {
diff --git a/detox/src/artifacts/log/ios/SimulatorLogPlugin.js b/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
index 25f1a438a..eb48170f9 100644
--- a/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
+++ b/detox/src/artifacts/log/ios/SimulatorLogPlugin.js
@@ -17,8 +17,8 @@ class SimulatorLogPlugin extends LogArtifactPlugin {
 
     if (this.currentRecording) {
       await this.currentRecording.start({
-        udid: this.context.deviceId,
-        bundleId: this.context.bundleId,
+        udid: event.deviceId,
+        bundleId: event.bundleId,
       });
     }
   }
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
index 0914ecbf2..ea80013d0 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.js
@@ -11,7 +11,7 @@ const log = require('../../../utils/logger').child({ __filename });
 class ArtifactPlugin {
   constructor({ api }) {
     this.api = api;
-    this.context = {};
+    this.context = { testSummary: null };
     this.enabled = api.userConfig.enabled;
     this.keepOnlyFailedTestsArtifacts = api.userConfig.keepOnlyFailedTestsArtifacts;
     this.priority = 16;
@@ -46,14 +46,7 @@ class ArtifactPlugin {
    * @param {Object} event.launchArgs - Mutable key-value pairs of args before the launch
    * @return {Promise<void>} - when done
    */
-  async onBeforeLaunchApp(event) {
-    Object.assign(this.context, {
-      bundleId: event.bundleId,
-      deviceId: event.deviceId,
-      launchArgs: event.launchArgs,
-      pid: NaN,
-    });
-  }
+  async onBeforeLaunchApp(event) {}
 
   /**
    * Hook that is called inside device.launchApp() and
@@ -72,7 +65,6 @@ class ArtifactPlugin {
   async onLaunchApp(event) {
     Object.assign(this.context, {
       bundleId: event.bundleId,
-      deviceId: event.deviceId,
       launchArgs: event.launchArgs,
       pid: event.pid,
    });
@@ -91,8 +83,6 @@ class ArtifactPlugin {
   async onBootDevice(event) {
     Object.assign(this.context, {
       deviceId: event.deviceId,
-      bundleId: '',
-      pid: NaN,
     });
   }
 
@@ -106,10 +96,23 @@ class ArtifactPlugin {
    * @param {string} event.bundleId - Current bundleId
    * @return {Promise<void>} - when done
    */
-  async onBeforeTerminateApp(event) {
+  async onBeforeTerminateApp(event) {}
+
+  /**
+   * Hook that is supposed to be called after app has been terminated
+   *
+   * @protected
+   * @async
+   * @param {Object} event - App termination event object
+   * @param {string} event.deviceId - Current deviceId
+   * @param {string} event.bundleId - Terminated bundleId
+   * @return {Promise<void>} - when done
+   */
+  async onTerminateApp(event) {
     Object.assign(this.context, {
-      deviceId: event.deviceId,
-      bundleId: event.bundleId,
+      bundleId: '',
+      launchArgs: null,
+      pid: NaN,
     });
   }
 
@@ -123,12 +126,7 @@ class ArtifactPlugin {
    * @param {string} event.bundleId - Current bundleId
    * @return {Promise<void>} - when done
    */
-  async onBeforeUninstallApp(event) {
-    Object.assign(this.context, {
-      deviceId: event.deviceId,
-      bundleId: event.bundleId,
-    });
-  }
+  async onBeforeUninstallApp(event) {}
 
   /**
    * Hook that is supposed to be called before device.shutdown() happens
@@ -139,11 +137,7 @@ class ArtifactPlugin {
    * @param {string} event.deviceId - Current deviceId
    * @return {Promise<void>} - when done
    */
-  async onBeforeShutdownDevice(event) {
-    Object.assign(this.context, {
-      deviceId: event.deviceId,
-    });
-  }
+  async onBeforeShutdownDevice(event) {}
 
   /**
    * Hook that is supposed to be called from device.shutdown()
@@ -156,8 +150,9 @@ class ArtifactPlugin {
    */
   async onShutdownDevice(event) {
     Object.assign(this.context, {
-      deviceId: event.deviceId,
+      deviceId: '',
       bundleId: '',
+      launchArgs: null,
       pid: NaN,
     });
   }
@@ -175,19 +170,6 @@ class ArtifactPlugin {
    */
   async onCreateExternalArtifact(event) {}
 
-  /**
-   * Hook that is called before any test begins
-   *
-   * @protected
-   * @async
-   * @return {Promise<void>} - when done
-   */
-  async onBeforeAll() {
-    this.context.testSummary = null;
-    this._hasFailingTests = false;
-    this._finishedTests = false;
-  }
-
   /**
    * Hook that is called before a test begins
    *
@@ -196,7 +178,7 @@ class ArtifactPlugin {
    * @param {TestSummary} testSummary - has name of currently running test
    * @return {Promise<void>} - when done
    */
-  async onBeforeEach(testSummary) {
+  async onTestStart(testSummary) {
     this.context.testSummary = testSummary;
   }
 
@@ -206,7 +188,7 @@ class ArtifactPlugin {
    * @param {TestSummary} testSummary - has name and status of test that ran
    * @return {Promise<void>} - when done
    */
-  async onAfterEach(testSummary) {
+  async onTestDone(testSummary) {
     this.context.testSummary = testSummary;
 
     if (testSummary.status === 'failed') {
@@ -215,13 +197,13 @@ class ArtifactPlugin {
   }
 
   /**
-   * Hook that is called after all tests run
+   * Hook that is called when detox.cleanup() just begins
    *
    * @protected
    * @async
    * @return {Promise<void>} - when done
    */
-  async onAfterAll() {
+  async onBeforeCleanup() {
     this.context.testSummary = null;
     this._finishedTests = true;
     this._logDisableWarning();
@@ -242,13 +224,13 @@ class ArtifactPlugin {
     this.onBeforeShutdownDevice = _.noop;
     this.onShutdownDevice = _.noop;
     this.onBeforeTerminateApp = _.noop;
+    this.onTerminateApp = _.noop;
     this.onBeforeLaunchApp = _.noop;
     this.onLaunchApp = _.noop;
     this.onUserAction = _.noop;
-    this.onBeforeAll = _.noop;
-    this.onBeforeEach = _.noop;
-    this.onAfterEach = _.noop;
-    this.onAfterAll = _.noop;
+    this.onTestStart = _.noop;
+    this.onTestDone = _.noop;
+    this.onBeforeCleanup = _.noop;
   }
 
   _logDisableWarning() {
diff --git a/detox/src/configuration.js b/detox/src/configuration.js
index f657cf3ca..8254f8772 100644
--- a/detox/src/configuration.js
+++ b/detox/src/configuration.js
@@ -6,7 +6,7 @@ const argparse = require('./utils/argparse');
 const getPort = require('get-port');
 const buildDefaultArtifactsRootDirpath = require('./artifacts/utils/buildDefaultArtifactsRootDirpath');
 
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsArtifactPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
@@ -82,9 +82,8 @@ function getArtifactsCliConfig() {
 }
 
 function resolveModuleFromPath(modulePath) {
-  return path.isAbsolute(modulePath)
-    ? require(modulePath)
-    : require(path.join(process.cwd(), modulePath));
+  const resolvedModulePath = require.resolve(modulePath, { paths: [process.cwd()]});
+  return require(resolvedModulePath);
 }
 
 function composeArtifactsConfig({
@@ -128,7 +127,7 @@ function composeArtifactsConfig({
 
   artifactsConfig.plugins = _.mapValues(artifactsConfig.plugins, (value, key) => {
     switch (key) {
-      case 'instruments': return SimulatorInstrumentsPlugin.parseConfig(value);
+      case 'instruments': return InstrumentsArtifactPlugin.parseConfig(value);
       case 'log': return LogArtifactPlugin.parseConfig(value);
       case 'screenshot': return ScreenshotArtifactPlugin.parseConfig(value);
       case 'video': return VideoArtifactPlugin.parseConfig(value);
diff --git a/detox/src/configurations.mock.js b/detox/src/configurations.mock.js
index e3e624924..7353976b9 100644
--- a/detox/src/configurations.mock.js
+++ b/detox/src/configurations.mock.js
@@ -1,4 +1,4 @@
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./artifacts/instruments/InstrumentsArtifactPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
@@ -29,7 +29,7 @@ const pluginsDefaultsResolved = {
   log: LogArtifactPlugin.parseConfig('none'),
   screenshot: ScreenshotArtifactPlugin.parseConfig('manual'),
   video: VideoArtifactPlugin.parseConfig('none'),
-  instruments: SimulatorInstrumentsPlugin.parseConfig('none'),
+  instruments: InstrumentsArtifactPlugin.parseConfig('none'),
 };
 
 const pluginsFailingResolved = {
@@ -42,7 +42,7 @@ const pluginsAllResolved = {
   log: LogArtifactPlugin.parseConfig('all'),
   screenshot: ScreenshotArtifactPlugin.parseConfig('all'),
   video: VideoArtifactPlugin.parseConfig('all'),
-  instruments: SimulatorInstrumentsPlugin.parseConfig('all'),
+  instruments: InstrumentsArtifactPlugin.parseConfig('all'),
 };
 
 const validOneDeviceNoSession = {
diff --git a/detox/src/devices/android/Emulator.js b/detox/src/devices/android/Emulator.js
index b95c81081..6696af089 100644
--- a/detox/src/devices/android/Emulator.js
+++ b/detox/src/devices/android/Emulator.js
@@ -42,8 +42,8 @@ class Emulator {
       '-verbose',
       '-no-audio',
       '-no-boot-anim',
-      argparse.getArgValue('headless') ? '-no-window' : '',
-      argparse.getArgValue('readOnlyEmu') ? '-read-only' : '',
+      argparse.getArgValue('headless') === 'true' ? '-no-window' : '',
+      argparse.getArgValue('readOnlyEmu') === 'true' ? '-read-only' : '',
       options.port ? `-port` : '',
       options.port ? `${options.port}` : '',
       ...deviceLaunchArgs,
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index 36042aa1e..9b36c6e69 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -13,6 +13,7 @@ const DeviceDriverBase = require('./DeviceDriverBase');
 const DetoxApi = require('../../android/espressoapi/Detox');
 const EspressoDetoxApi = require('../../android/espressoapi/EspressoDetox');
 const UiDeviceProxy = require('../../android/espressoapi/UiDeviceProxy');
+const AndroidInstrumentsPlugin = require('../../artifacts/instruments/android/AndroidInstrumentsPlugin');
 const ADBLogcatPlugin = require('../../artifacts/log/android/ADBLogcatPlugin');
 const ADBScreencapPlugin = require('../../artifacts/screenshot/ADBScreencapPlugin');
 const ADBScreenrecorderPlugin = require('../../artifacts/video/ADBScreenrecorderPlugin');
@@ -42,9 +43,10 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   declareArtifactPlugins() {
-    const { adb, devicePathBuilder } = this;
+    const { adb, client, devicePathBuilder } = this;
 
     return {
+      instruments: (api) => new AndroidInstrumentsPlugin({ api, adb, client, devicePathBuilder }),
       log: (api) => new ADBLogcatPlugin({ api, adb, devicePathBuilder }),
       screenshot: (api) => new ADBScreencapPlugin({ api, adb, devicePathBuilder }),
       video: (api) => new ADBScreenrecorderPlugin({ api, adb, devicePathBuilder }),
@@ -131,6 +133,7 @@ class AndroidDriver extends DeviceDriverBase {
     await this.emitter.emit('beforeTerminateApp', { deviceId, bundleId });
     await this._terminateInstrumentation();
     await this.adb.terminate(deviceId, bundleId);
+    await this.emitter.emit('terminateApp', { deviceId, bundleId });
   }
 
   async _terminateInstrumentation() {
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index 7e5886354..db7951e4c 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -15,6 +15,7 @@ class DeviceDriverBase {
         'beforeShutdownDevice',
         'shutdownDevice',
         'beforeTerminateApp',
+        'terminateApp',
         'beforeUninstallApp',
         'beforeLaunchApp',
         'launchApp',
diff --git a/detox/src/devices/drivers/EmulatorDriver.js b/detox/src/devices/drivers/EmulatorDriver.js
index 3cecb1984..5ec1acd25 100644
--- a/detox/src/devices/drivers/EmulatorDriver.js
+++ b/detox/src/devices/drivers/EmulatorDriver.js
@@ -133,7 +133,8 @@ class EmulatorDriver extends AndroidDriver {
   }
 
   async _fixEmulatorConfigIniSkinNameIfNeeded(avdName) {
-    const configFile = `${os.homedir()}/.android/avd/${avdName}.avd/config.ini`;
+    const avdPath = environment.getAvdDir(avdName);
+    const configFile = path.join(avdPath, 'config.ini');
     const config = ini.parse(fs.readFileSync(configFile, 'utf-8'));
 
     if (!config['skin.name']) {
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/IosDriver.js
index cc9144354..fb064f4bf 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/IosDriver.js
@@ -12,7 +12,7 @@ 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');
+const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
 const IosExpect = require('../../ios/expect');
 
 class IosDriver extends DeviceDriverBase {
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index 0c705e6f2..9022c907e 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -94,6 +94,7 @@ class SimulatorDriver extends IosDriver {
   async terminate(deviceId, bundleId) {
     await this.emitter.emit('beforeTerminateApp', { deviceId, bundleId });
     await this.applesimutils.terminate(deviceId, bundleId);
+    await this.emitter.emit('terminateApp', { deviceId, bundleId });
   }
 
   async setBiometricEnrollment(deviceId, yesOrNo) {
diff --git a/detox/src/utils/MissingDetox.js b/detox/src/utils/MissingDetox.js
new file mode 100644
index 000000000..23c097baa
--- /dev/null
+++ b/detox/src/utils/MissingDetox.js
@@ -0,0 +1,77 @@
+const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
+
+class MissingDetox {
+  constructor() {
+    this.throwError = this.throwError.bind(this);
+    this.initContext(this);
+
+    this._defineRequiredProperty(this, 'beforeEach', async () => this.throwError(), true);
+    this._defineRequiredProperty(this, 'afterEach', async () => this.throwError(), true);
+  }
+
+  initContext(context) {
+    const readonly = context === this;
+
+    this._defineRequiredProperty(context, 'by', undefined, readonly);
+    this._defineRequiredProperty(context, 'device', undefined, readonly);
+    this._defineRequiredProperty(context, 'element', this.throwError, readonly);
+    this._defineRequiredProperty(context, 'expect', this.throwError, readonly);
+    this._defineRequiredProperty(context, 'waitFor', this.throwError, readonly);
+  }
+
+  cleanupContext(context) {
+    this._cleanupProperty(context, 'by');
+    this._cleanupProperty(context, 'device');
+    this._cleanupProperty(context, 'element');
+    this._cleanupProperty(context, 'expect');
+    this._cleanupProperty(context, 'waitFor');
+  }
+
+  _cleanupProperty(context, name) {
+    if (context.hasOwnProperty(name)) {
+      context[name] = undefined;
+    }
+  }
+
+  _defineRequiredProperty(context, name, initialValue, readonly) {
+    if (context.hasOwnProperty(name)) {
+      return;
+    }
+
+    let _value = initialValue;
+
+    const descriptor = {
+      get: () => {
+        if (_value === undefined) {
+          this.throwError();
+        }
+
+        return _value;
+      },
+    };
+
+    if (!readonly) {
+      descriptor.set = (value) => {
+        _value = value;
+      };
+    }
+
+    Object.defineProperty(context, name, descriptor);
+  }
+
+  setError(err) {
+    this._lastError = err;
+  }
+
+  throwError() {
+    throw new DetoxRuntimeError({
+      message: 'Detox instance has not been initialized',
+      hint: this._lastError
+        ? 'There was an error on attempt to call detox.init()'
+        : 'Make sure to call detox.init() before your test begins',
+      debugInfo: this._lastError && this._lastError.stack || '',
+    });
+  }
+}
+
+module.exports = MissingDetox;
diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js
index 2db839c36..f0ed09ebc 100644
--- a/detox/src/utils/environment.js
+++ b/detox/src/utils/environment.js
@@ -2,6 +2,7 @@ const _ = require('lodash');
 const fs = require('fs');
 const os = require('os');
 const path = require('path');
+const ini = require('ini');
 const _which = require('which');
 const exec = require('child-process-promise').exec;
 const appdatapath = require('./appdatapath');
@@ -21,6 +22,32 @@ function getAndroidSDKPath() {
   return process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || '';
 }
 
+function getAndroidSDKHome() {
+  return process.env['ANDROID_SDK_HOME'] || os.homedir();
+}
+
+function getEmulatorHome() {
+  return process.env['ANDROID_EMULATOR_HOME'] || path.join(getAndroidSDKHome(), '.android');
+}
+
+function getAvdHome() {
+  return process.env['ANDROID_AVD_HOME'] || path.join(getEmulatorHome(), 'avd');
+}
+
+function getAvdDir(avdName) {
+  const avdIniPath = path.join(getAvdHome(), `${avdName}.ini`);
+  if (!fs.existsSync(avdIniPath)) {
+    throwMissingAvdINIError(avdName, avdIniPath)
+  }
+
+  const avdIni = ini.parse(fs.readFileSync(avdIniPath, 'utf-8'));
+  if (!fs.existsSync(avdIni.path)) {
+    throwMissingAvdError(avdName, avdIni.path, avdIniPath)
+  }
+
+  return avdIni.path;
+}
+
 function getAndroidEmulatorPath() {
   const sdkRoot = getAndroidSDKPath();
   if (!sdkRoot) {
@@ -83,7 +110,18 @@ function getAdbPath() {
 }
 
 function throwMissingSdkError() {
-    throw new Error(MISSING_SDK_ERROR);
+  throw new Error(MISSING_SDK_ERROR);
+}
+
+function throwMissingAvdINIError(avdName, avdIniPath) {
+  throw new Error(`Failed to find INI file for ${avdName} at path: ${avdIniPath}`);
+}
+
+function throwMissingAvdError(avdName, avdPath, avdIniPath) {
+  throw new Error(
+    `Failed to find AVD ${avdName} directory at path: ${avdPath}\n` +
+    `Please verify "path" property in the INI file: ${avdIniPath}`
+  );
 }
 
 function throwSdkIntegrityError(sdkRoot, relativeExecutablePath) {
@@ -126,6 +164,8 @@ function getHomeDir() {
 module.exports = {
   getAaptPath,
   getAdbPath,
+  getAvdHome,
+  getAvdDir,
   getDetoxVersion,
   getFrameworkPath,
   getAndroidSDKPath,
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index 83b1634a2..7527d47f7 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -63,7 +63,7 @@ Detox can control artifacts collection via settings from `package.json`:
   "detox": {
     "artifacts": {
       "rootDir": ".artifacts",
-      "pathBuilder": "config/pathbuilder.js",
+      "pathBuilder": "./config/pathbuilder.js",
       "plugins": {
         "instruments": "none",
         "log": "all",
diff --git a/docs/Guide.DebuggingInXcode.md b/docs/Guide.DebuggingInXcode.md
index 3a7670f93..2b2ff671d 100644
--- a/docs/Guide.DebuggingInXcode.md
+++ b/docs/Guide.DebuggingInXcode.md
@@ -6,7 +6,7 @@
 
 * Go to `node_modules/detox` and extract `Detox-ios-src.tbz`
 * Drag `Detox-ios-src/Detox.xcodeproj` into your Xcode project
-* Go to your project settings -> **General** and add **Detox.framework** to **Embedded Binaries**
+* Go to your project settings -> **General** and add **Detox.framework** to **Frameworks, Libraries, and Embedded Content** (make sure **Embed & Sign** is selected under **Embed**)
 
 > NOTE: Apps should not be submitted to the App Store with the Detox framework linked. Follow this guide only to debug Detox issues in your project. Once finished, make sure to remove **Detox.framework** from your project.
 
diff --git a/docs/Introduction.Android.md b/docs/Introduction.Android.md
index c6f354026..cfdaaea45 100644
--- a/docs/Introduction.Android.md
+++ b/docs/Introduction.Android.md
@@ -280,7 +280,7 @@ dependencies {
 
 Detox should work with `kotlin-stdlib-jdk7`, as well.
 
-A typical error output formed by `Gradle` in this case is as provided, for example, in [1380](https://github.com/wix/Detox/issues/1380):
+A typical error output formed by `Gradle` in this case is as provided, for example, in [#1380](https://github.com/wix/Detox/issues/1380):
 
 ```
 Could not determine the dependencies of task ':detox:compileDebugAidl'.
@@ -321,3 +321,37 @@ buildscript {
 }
 ```
 
+
+
+### Problem: The app loads but tests fail to start in SDK >= 28
+
+As reported in issue [#1450](https://github.com/wix/Detox/issues/1450), sometimes the application under test would properly launch on an emulator/device when running Detox, but the test runner will hang and will not start running the actual tests.
+
+More specifically, when this happens:
+
+1. Detox and the tests runner launch successfully, alongside the app being run (unless `launchApp: false` has been passed to `detox.init()`), but the first test simply hangs forever (as explained).
+2. Eventually, the test runner would time-out.
+3. The last reported Detox-logs before time-out would indicate the device failing to connect to the Detox tester on the host. For example:
+
+```
+detox[12345] DEBUG: [DetoxServer.js/CANNOT_FORWARD] role=testee not connected, cannot fw action (sessionId=11111111-2222-3333-4444-555555555555)
+```
+
+A common reason for this set of symptoms to take place is the lack of a clear-text network traffic policy configuration in app - the kind that would allow both the app _and Detox_ to work properly. These Android developers blog posts ([1](https://android-developers.googleblog.com/2016/04/protecting-against-unintentional.html), [2](https://developer.android.com/training/articles/security-config)) elaborate on what should be configured for apps and how, in great length. The pain-point in this context is that in SDK 28, clear-text traffic was set to be denied by Android, by default. In that mode, Detox, which requires such traffic while testing, is blocked.
+
+The solution here is therefore to first properly configure clear-text traffic and for all app flavors (e.g. release / debug) -- if you haven't already. Second, the app needs to be configured to allow for clear-text traffic between the emulator and the running host. Examples:
+
+1. Applying `android:cleartextTrafficPermitted="true"` in the application tag of the main `AndroidManifest.xml` **(not recommended)**.
+2. **(Untested)** Applying a base config denying clear-text (i.e. using `cleartextTrafficPermitted=false`, or just by omitting it altogether), and enabling it for the special-localhost domain:
+
+```xml
+<?xml version="1.0" encoding="utf-8"?>
+<network-security-config>
+    <domain-config cleartextTrafficPermitted="false">
+        <domain includeSubdomains="true">10.0.2.2</domain>
+    </domain-config>
+</network-security-config>
+```
+
+> `10.0.2.2` is the IP equivalent to the localhost on the computer hosting the Android emulator, on Google emulators. On Genymotion ones, it is `10.0.3.2`.
+
diff --git a/docs/Introduction.GettingStarted.md b/docs/Introduction.GettingStarted.md
index e82b22545..57ffd49bb 100644
--- a/docs/Introduction.GettingStarted.md
+++ b/docs/Introduction.GettingStarted.md
@@ -23,6 +23,12 @@ Running Detox (on iOS) requires the following:
 
 Homebrew is a package manager for macOS, we'll need it to install other command line tools.
 
+To ensure everything needed for Homebrew tool installation is installed, run
+
+```sh
+xcode-select install
+```
+
 > TIP: Verify it works by typing in terminal `brew -h` to output list of available commands
 
 #### 2. Install [Node.js](https://nodejs.org/en/)
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index c0c96775d..c1c8c14e6 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index ade39b9c5..cf6231977 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "devDependencies": {
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index 05a2cfaee..b19be212f 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index cb7e70c39..3af6ad2c8 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,6 +1,6 @@
 {
   "name": "demo-react-native-jest",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -13,7 +13,7 @@
     "test:jest-circus:android-release-ci": "detox test --configuration android.emu.release -o e2e/config-circus.json -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "jest": "24.8.x",
     "jest-circus": "24.8.x",
     "sanitize-filename": "^1.6.1"
@@ -23,7 +23,7 @@
     "configurations": {
       "ios.sim.release": {
         "artifacts": {
-          "pathBuilder": "e2e/detox.pathbuilder.ios.js"
+          "pathBuilder": "./e2e/detox.pathbuilder.ios.js"
         },
         "binaryPath": "../demo-react-native/ios/build/Build/Products/Release-iphonesimulator/example.app",
         "type": "ios.simulator",
@@ -33,7 +33,7 @@
       },
       "android.emu.release": {
         "artifacts": {
-          "pathBuilder": "e2e/detox.pathbuilder.android.js"
+          "pathBuilder": "./e2e/detox.pathbuilder.android.js"
         },
         "binaryPath": "../demo-react-native/android/app/build/outputs/apk/release/app-release.apk",
         "type": "android.emulator",
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index 03b8c80b8..f0f2f24f5 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -23,7 +23,7 @@
     "react-native": "0.60.x"
   },
   "devDependencies": {
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/lerna.json b/lerna.json
index c7260377c..61b113cdf 100644
--- a/lerna.json
+++ b/lerna.json
@@ -12,7 +12,7 @@
     "generation",
     "."
   ],
-  "version": "14.9.2",
+  "version": "15.1.4",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index ae0b093f7..4ef914a35 100644
--- a/package.json
+++ b/package.json
@@ -1,4 +1,6 @@
 {
+  "name": "",
+  "private": true,
   "scripts": {
     "test:e2e:ios": "pushd detox/test && npm run e2e:ios && popd",
     "test:e2e:android": "pushd detox/test && npm run e2e:android && popd",
@@ -15,5 +17,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "14.9.2"
+  "version": "15.1.4"
 }


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 a96d190ec..e8291b3d5 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
@@ -3,14 +3,18 @@ package com.wix.detox
 import android.content.Context
 import com.nhaarman.mockitokotlin2.*
 import com.wix.detox.UTHelpers.yieldToOtherThreads
+import com.wix.detox.instruments.DetoxInstrumentsException
+import com.wix.detox.instruments.DetoxInstrumentsManager
 import com.wix.invoke.MethodInvocation
+import org.assertj.core.api.Assertions
+import org.json.JSONObject
 import org.spekframework.spek2.Spek
 import org.spekframework.spek2.style.specification.describe
 import java.lang.reflect.InvocationTargetException
 import java.util.*
 import java.util.concurrent.Executors
 
-object DetoxActionHandlersSpec: Spek({
+object DetoxActionHandlersSpec : Spek({
     describe("Action handlers") {
         val params = "{\"mock\": \"params\"}"
         val messageId = 666L
@@ -142,5 +146,135 @@ object DetoxActionHandlersSpec: Spek({
                         eq(messageId))
             }
         }
+
+        describe("InstrumentsRecording recording state actions") {
+            lateinit var instrumentsManager: DetoxInstrumentsManager
+
+            fun uut() = InstrumentsRecordingStateActionHandler(instrumentsManager, wsClient)
+
+            beforeEachTest {
+                instrumentsManager = mock()
+            }
+
+            it("should start recording with path") {
+                uut().handle("{\"recordingPath\":\"/MockPath\"}", messageId)
+                verify(instrumentsManager).startRecordingAtLocalPath(eq("/MockPath"))
+            }
+
+            it("should stop recording without path") {
+                uut().handle("{\"recordingPath\":null}", messageId)
+                verify(instrumentsManager).stopRecording()
+            }
+
+            it("should reply with a 'done' ACK on set state finish") {
+                uut().handle(params, messageId)
+                verify(wsClient).sendAction(eq("setRecordingStateDone"), any(), eq(messageId))
+            }
+        }
+
+        describe("InstrumentsRecording events actions") {
+            lateinit var instrumentsManager: DetoxInstrumentsManager
+            val mockCategory = "MockCategory"
+            val mockName = "MockName"
+            val mockId = "MockId"
+            val mockAdditionalInfo = "MockAdditionalInfo"
+            val mockStatus = "MockStatus"
+
+            fun uut() = InstrumentsEventsActionsHandler(instrumentsManager, wsClient)
+
+            beforeEachTest {
+                instrumentsManager = mock()
+            }
+
+            describe("begin interval") {
+                val json = with(JSONObject()) {
+                    put("category", mockCategory)
+                    put("name", mockName)
+                    put("id", mockId)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "begin")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventBeginInterval(
+                            eq(mockCategory),
+                            eq(mockName),
+                            eq(mockId),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            describe("end interval") {
+                val json = with(JSONObject()) {
+                    put("id", mockId)
+                    put("status", mockStatus)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "end")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventEndInterval(
+                            eq(mockId),
+                            eq(mockStatus),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            describe("mark") {
+                val json = with(JSONObject()) {
+                    put("category", mockCategory)
+                    put("name", mockName)
+                    put("id", mockId)
+                    put("status", mockStatus)
+                    put("additionalInfo", mockAdditionalInfo)
+                    put("action", "mark")
+                }.toString()
+
+                it("should invoke instrumentation") {
+                    uut().handle(json, messageId)
+                    verify(instrumentsManager).eventMark(
+                            eq(mockCategory),
+                            eq(mockName),
+                            eq(mockId),
+                            eq(mockStatus),
+                            eq(mockAdditionalInfo)
+                    )
+                }
+
+                it("should reply with a 'done' ACK") {
+                    uut().handle(json, messageId)
+                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                }
+            }
+
+            it("wrong event action") {
+                val json = with(JSONObject()) {
+                    put("action", "wrong")
+                }.toString()
+
+                var err: Exception? = null
+                try {
+                    uut().handle(json, messageId)
+                } catch (e: DetoxInstrumentsException) {
+                    err = e
+                }
+                Assertions.assertThat(err).isNotNull()
+                Assertions.assertThat(err).hasMessage("Invalid action")
+            }
+        }
     }
 })
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
new file mode 100644
index 000000000..31f5316f2
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/instruments/DetoxInstrumentsManagerSpec.kt
@@ -0,0 +1,130 @@
+package com.wix.detox.instruments
+
+import android.content.Context
+import com.nhaarman.mockitokotlin2.*
+import com.wix.detox.TestEngineFacade
+import com.wix.detox.UTHelpers.yieldToOtherThreads
+import com.wix.detox.instruments.DetoxInstrumentsManager
+import com.wix.invoke.MethodInvocation
+import org.json.JSONObject
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+import java.io.File
+import java.lang.reflect.InvocationTargetException
+import java.util.*
+import java.util.concurrent.Executors
+
+object DetoxInstrumentsManagerSpec : Spek({
+    describe("Instruments manager") {
+        lateinit var appContext: Context
+        lateinit var instruments: Instruments
+        lateinit var instrumentsManager: DetoxInstrumentsManager
+
+        val mockCategory = "MockCategory"
+        val mockName = "MockName"
+        val mockId = "MockId"
+        val mockAdditionalInfo = "MockAdditionalInfo"
+        val mockStatus = "MockStatus"
+        val mockPath = "/mock"
+
+        beforeEachTest {
+            appContext = mock()
+            instruments = mock()
+            instrumentsManager = DetoxInstrumentsManager(appContext, instruments)
+        }
+
+        it("should start recording when instruments installed with default params") {
+            whenever(instruments.installed()).thenReturn(true)
+            instrumentsManager.startRecordingAtLocalPath(mockPath)
+
+            verify(instruments).installed()
+            verify(instruments).startRecording(appContext, true, 500L, File(mockPath), false)
+        }
+
+        describe("proxy events") {
+            lateinit var recording: InstrumentsRecording
+
+            beforeEachTest {
+                recording = mock()
+                whenever(instruments.installed()).thenReturn(true)
+                whenever(instruments.startRecording(any(), any(), any(), any(), any())).thenReturn(recording)
+            }
+
+            it("skipping without started recording") {
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording, never()).eventBeginInterval(any(), any(), any(), any())
+                verify(recording, never()).eventEndInterval(any(), any(), any())
+                verify(recording, never()).eventMark(any(), any(), any(), any(), any())
+            }
+
+            it("passing within started recording") {
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording).eventBeginInterval(any(), any(), any(), any())
+                verify(recording).eventEndInterval(any(), any(), any())
+                verify(recording).eventMark(any(), any(), any(), any(), any())
+            }
+
+            it("skipping with started recording which was stopped") {
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+                instrumentsManager.stopRecording()
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+
+                verify(recording, never()).eventBeginInterval(any(), any(), any(), any())
+                verify(recording, never()).eventEndInterval(any(), any(), any())
+                verify(recording, never()).eventMark(any(), any(), any(), any(), any())
+            }
+        }
+
+        it("should not start recording when instruments not installed") {
+            whenever(instruments.installed()).thenReturn(false)
+            instrumentsManager.startRecordingAtLocalPath(mockPath)
+
+            verify(instruments).installed()
+            verify(instruments, never()).startRecording(any(), any(), any(), any(), any())
+        }
+
+        describe("recording") {
+            lateinit var recording: InstrumentsRecording
+
+            beforeEachTest {
+                recording = mock()
+
+                whenever(instruments.installed()).thenReturn(true)
+                whenever(
+                        instruments.startRecording(any(), any(), any(), any(), any())
+                ).thenReturn(recording)
+
+                instrumentsManager.startRecordingAtLocalPath(mockPath)
+            }
+
+            it("should begin event interval") {
+                instrumentsManager.eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+                verify(recording).eventBeginInterval(mockCategory, mockName, mockId, mockAdditionalInfo)
+            }
+
+            it("should end event interval") {
+                instrumentsManager.eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+                verify(recording).eventEndInterval(mockId, mockStatus, mockAdditionalInfo)
+            }
+
+            it("should mark event") {
+                instrumentsManager.eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+                verify(recording).eventMark(mockCategory, mockName, mockId, mockStatus, mockAdditionalInfo)
+            }
+
+            it("should stop") {
+                instrumentsManager.stopRecording()
+                verify(recording).stop()
+            }
+        }
+    }
+})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt
deleted file mode 100644
index 1e6749e0f..000000000
--- a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt
+++ /dev/null
@@ -1,262 +0,0 @@
-package com.wix.detox.reactnative.idlingresources
-
-import android.view.Choreographer
-import androidx.test.espresso.IdlingResource
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.modules.core.Timing
-import com.nhaarman.mockitokotlin2.*
-import com.wix.detox.UTHelpers.yieldToOtherThreads
-import org.assertj.core.api.Assertions
-import org.joor.Reflect
-import org.mockito.ArgumentMatchers
-import org.spekframework.spek2.Spek
-import org.spekframework.spek2.style.specification.describe
-import java.util.*
-import java.util.concurrent.Executors
-
-private const val BUSY_INTERVAL_MS = 1500
-private const val MEANINGFUL_TIMER_INTERVAL = BUSY_INTERVAL_MS
-
-private fun now() = System.nanoTime() / 1000000L
-
-private fun aTimer(interval: Int, isRepeating: Boolean) = aTimer(now() + interval + 10, interval, isRepeating)
-
-private fun aTimer(targetTime: Long, interval: Int, isRepeating: Boolean): Any {
-    val timerClass = Class.forName("com.facebook.react.modules.core.Timing\$Timer")
-    return Reflect.on(timerClass).create(-1, targetTime, interval, isRepeating).get()
-}
-
-private fun aOneShotTimer(interval: Int) = aTimer(interval, false)
-private fun aRepeatingTimer(interval: Int) = aTimer(interval, true)
-private fun anOverdueTimer() = aTimer(now() - 100, 123, false)
-
-private fun anIdlingResourceCallback() = mock<IdlingResource.ResourceCallback>()
-
-object ReactNativeTimersIdlingResourceSpec : Spek({
-    describe("React Native timers idling-resource") {
-        lateinit var reactAppContext: ReactApplicationContext
-        lateinit var timersLock: String
-        lateinit var timersNativeModule: Timing
-        lateinit var choreographer: Choreographer
-        lateinit var pendingTimers: PriorityQueue<Any>
-
-        fun uut() = TimersIdlingResource(reactAppContext) { choreographer }
-
-        fun givenTimer(timer: Any) {
-            pendingTimers.add(timer)
-        }
-
-        fun getChoreographerCallback(): Choreographer.FrameCallback {
-            argumentCaptor<Choreographer.FrameCallback>().apply {
-                verify(choreographer).postFrameCallback(capture())
-                return firstValue
-            }
-        }
-
-        fun invokeChoreographerCallback() {
-            getChoreographerCallback().doFrame(0L)
-        }
-
-        beforeEachTest {
-            pendingTimers = PriorityQueue(2) { _, _ -> 0}
-
-            timersNativeModule = mock()
-            timersLock = "Lock-Mock"
-            Reflect.on(timersNativeModule).set("mTimers", pendingTimers)
-            Reflect.on(timersNativeModule).set("mTimerGuard", timersLock)
-
-            choreographer = mock()
-
-            reactAppContext = mock {
-                on { hasNativeModule<Timing>(ArgumentMatchers.any()) }.doReturn(true)
-                on { getNativeModule<Timing>(ArgumentMatchers.any()) }.doReturn(timersNativeModule)
-            }
-        }
-
-        it("should be idle if there are no timers in queue") {
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should transition to idle if found idle in query") {
-            val callback = anIdlingResourceCallback()
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                isIdleNow
-            }
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should NOT transition to idle if found busy in query") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                isIdleNow
-            }
-
-            verify(callback, never()).onTransitionToIdle()
-        }
-
-        it("should be busy if there's a meaningful pending timer") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be idle if pending timer is too far away (ie not meaningful)") {
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should be idle if the only timer is a repeating one") {
-            givenTimer(aRepeatingTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should be busy if a meaningful pending timer lies beyond a repeating one") {
-            givenTimer(aRepeatingTimer(BUSY_INTERVAL_MS / 10))
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        /**
-         * Note: Reversed logic due to this issue: https://github.com/wix/Detox/issues/1171 !!!
-         *
-         * Apparently at times (rare) this caused Espresso to think we're idle too soon, rendering
-         * it never to query any idling resource again even after the timer effectively expires...
-         */
-        it("should be *busy* even if all timers are overdue") {
-            givenTimer(anOverdueTimer())
-            givenTimer(anOverdueTimer())
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be busy if has a meaningful pending timer set beyond an overdue timer") {
-            givenTimer(anOverdueTimer())
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be idle if paused") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut().apply {
-                pause()
-            }
-
-            Assertions.assertThat(uut.isIdleNow).isTrue()
-        }
-
-        it("should be busy if paused and resumed") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut().apply {
-                pause()
-                resume()
-            }
-
-            Assertions.assertThat(uut.isIdleNow).isFalse()
-        }
-
-        it("should notify of transition to idle upon pausing") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                pause()
-            }
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should enqueue an is-idle check using choreographer when a callback gets registered") {
-            with(uut()) {
-                registerIdleTransitionCallback(mock())
-            }
-
-            verify(choreographer).postFrameCallback(any())
-        }
-
-        it("should transition to idle when preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            uut().registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should NOT transition to idle if not idle when preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            uut().registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(callback, never()).onTransitionToIdle()
-        }
-
-        it("should re-register choreographer if found idle while preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut()
-            uut.registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(choreographer, times(2)).postFrameCallback(any())
-        }
-
-        it("should adhere to pausing also when invoked via choreographer callback") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            uut().apply {
-                pause()
-                registerIdleTransitionCallback(callback)
-            }
-            val runtimeChoreographerCallback = getChoreographerCallback()
-
-            reset(callback, choreographer)
-            runtimeChoreographerCallback.doFrame(0L)
-
-            verify(callback, never()).onTransitionToIdle()
-            verify(choreographer, never()).postFrameCallback(any())
-        }
-
-        it("should enqueue an additional idle check (using choreographer) if found busy") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            uut().isIdleNow
-            verify(choreographer).postFrameCallback(any())
-        }
-
-        it("should NOT enqueue an additional idle check (using choreographer) if found idle") {
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
-            uut().isIdleNow
-            verify(choreographer, never()).postFrameCallback(any())
-        }
-
-        it("should yield to other threads using the timers module") {
-            val executor = Executors.newSingleThreadExecutor()
-            var isIdle: Boolean? = null
-
-            synchronized(timersLock) {
-                executor.submit {
-                    isIdle = uut().isIdleNow
-                }
-                yieldToOtherThreads(executor)
-                Assertions.assertThat(isIdle).isNull()
-            }
-            yieldToOtherThreads(executor)
-            Assertions.assertThat(isIdle).isNotNull()
-        }
-    }
-})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt
new file mode 100644
index 000000000..8ca0ff8b5
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt
@@ -0,0 +1,113 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.wix.detox.UTHelpers
+import org.assertj.core.api.Assertions
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+import java.util.*
+import java.util.concurrent.Executors
+
+private const val BUSY_INTERVAL_MS = 1500
+private const val MEANINGFUL_TIMER_INTERVAL = BUSY_INTERVAL_MS
+
+data class TimerStub(
+        private var mCallbackID: Int,
+        private var mTargetTime: Long,
+        private var mInterval: Int,
+        private var mRepeat: Boolean)
+
+class TimersNativeModuleStub : NativeModule {
+    val mTimers: PriorityQueue<Any> = PriorityQueue(2) { _, _ -> 0}
+    val mTimerGuard = "Lock-Mock"
+
+    override fun onCatalystInstanceDestroy() {}
+    override fun getName(): String = "TimersNativeModuleStub"
+    override fun canOverrideExistingModule() = false
+    override fun initialize() {}
+}
+
+private fun now() = System.nanoTime() / 1000000L
+private fun aTimer(targetTime: Long, interval: Int, isRepeating: Boolean): TimerStub {
+    return TimerStub(-1, targetTime, interval, isRepeating)
+}
+private fun aTimer(interval: Int, isRepeating: Boolean) = aTimer(now() + interval + 10, interval, isRepeating)
+private fun aOneShotTimer(interval: Int) = aTimer(interval, false)
+private fun aRepeatingTimer(interval: Int) = aTimer(interval, true)
+private fun anOverdueTimer() = aTimer(now() - 100, 123, false)
+
+object DefaultIdleInterrogationStrategySpec: Spek({
+  describe("Default timers idle-interrogation strategy") {
+
+      lateinit var timersNativeModule: TimersNativeModuleStub
+
+      beforeEachTest {
+          timersNativeModule = TimersNativeModuleStub()
+      }
+
+      fun givenTimer(timer: Any) {
+          timersNativeModule.mTimers.add(timer)
+      }
+
+      fun uut() = DefaultIdleInterrogationStrategy(timersNativeModule)
+
+      it("should be idle if there are no timers in queue") {
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be busy if there's a meaningful pending timer") {
+          givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should be idle if pending timer is too far away (ie not meaningful)") {
+          givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be idle if the only timer is a repeating one") {
+          givenTimer(aRepeatingTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be busy if a meaningful pending timer lies beyond a repeating one") {
+          givenTimer(aRepeatingTimer(BUSY_INTERVAL_MS / 10))
+          givenTimer(aOneShotTimer(BUSY_INTERVAL_MS))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      /**
+       * Note: Reversed logic due to this issue: https://github.com/wix/Detox/issues/1171 !!!
+       *
+       * Apparently at times (rare) this caused Espresso to think we're idle too soon, rendering
+       * it never to query any idling resource again even after the timer effectively expires...
+       */
+      it("should be *busy* even if all timers are overdue") {
+          givenTimer(anOverdueTimer())
+          givenTimer(anOverdueTimer())
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should be busy if has a meaningful pending timer set beyond an overdue timer") {
+          givenTimer(anOverdueTimer())
+          givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should yield to other threads using the timers module") {
+          val executor = Executors.newSingleThreadExecutor()
+          var isIdle: Boolean? = null
+
+          synchronized(timersNativeModule.mTimerGuard) {
+              executor.submit {
+                  isIdle = uut().isIdleNow()
+              }
+              UTHelpers.yieldToOtherThreads(executor)
+              Assertions.assertThat(isIdle).isNull()
+          }
+          UTHelpers.yieldToOtherThreads(executor)
+          Assertions.assertThat(isIdle).isNotNull()
+          executor.shutdownNow()
+      }
+  }
+})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt
new file mode 100644
index 000000000..ffdbebf5f
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt
@@ -0,0 +1,55 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.nhaarman.mockitokotlin2.*
+import org.assertj.core.api.Assertions.assertThat
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+
+private const val BUSY_INTERVAL_MS = 1500L
+
+abstract class TimingModuleStub: NativeModule {
+    abstract fun hasActiveTimersInRange(rangeMs: Long): Boolean
+
+    override fun onCatalystInstanceDestroy() {}
+    override fun getName(): String = "TimersNativeModuleStub"
+    override fun canOverrideExistingModule() = false
+    override fun initialize() {}
+}
+
+object DelegatedIdleInterrogationStrategySpec : Spek({
+    describe("RN62+ timers idle-interrogation strategy") {
+
+        lateinit var timingModule: TimingModuleStub
+
+        beforeEachTest {
+            timingModule = mock()
+        }
+
+        fun givenActiveTimersInQueue() {
+            whenever(timingModule.hasActiveTimersInRange(any())).thenReturn(true)
+        }
+
+        fun givenNoActiveTimersInQueue() {
+            whenever(timingModule.hasActiveTimersInRange(any())).thenReturn(false)
+        }
+
+        fun uut() = DelegatedIdleInterrogationStrategy(timingModule)
+
+        it("should be idle if timing module has no active timers") {
+            givenActiveTimersInQueue()
+            assertThat(uut().isIdleNow()).isTrue()
+        }
+
+        it("should be busy if timing module has active timers") {
+            givenNoActiveTimersInQueue()
+            assertThat(uut().isIdleNow()).isFalse()
+        }
+
+        it("should specify the busy-interval as the active-timers lookahead range") {
+            givenActiveTimersInQueue()
+            uut().isIdleNow()
+            verify(timingModule).hasActiveTimersInRange(eq(BUSY_INTERVAL_MS))
+        }
+    }
+})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
new file mode 100644
index 000000000..11a825c09
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
@@ -0,0 +1,185 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.view.Choreographer
+import androidx.test.espresso.IdlingResource
+import com.nhaarman.mockitokotlin2.*
+import org.assertj.core.api.Assertions
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+
+private fun anIdlingResourceCallback() = mock<IdlingResource.ResourceCallback>()
+
+object TimersIdlingResourceSpec : Spek({
+    describe("React Native timers idling-resource") {
+        lateinit var choreographer: Choreographer
+        lateinit var idleInterrogationStrategy: IdleInterrogationStrategy
+
+        beforeEachTest {
+            idleInterrogationStrategy = mock()
+            choreographer = mock()
+        }
+
+        fun uut() = TimersIdlingResource(idleInterrogationStrategy) { choreographer }
+
+        fun givenIdleStrategy() {
+            whenever(idleInterrogationStrategy.isIdleNow()).thenReturn(true)
+        }
+
+        fun givenBusyStrategy() {
+            whenever(idleInterrogationStrategy.isIdleNow()).thenReturn(false)
+        }
+
+        fun getChoreographerCallback(): Choreographer.FrameCallback {
+            argumentCaptor<Choreographer.FrameCallback>().apply {
+                verify(choreographer).postFrameCallback(capture())
+                return firstValue
+            }
+        }
+
+        fun invokeChoreographerCallback() {
+            getChoreographerCallback().doFrame(0L)
+        }
+
+        it("should be idle if strategy says so") {
+            givenIdleStrategy()
+            Assertions.assertThat(uut().isIdleNow).isTrue()
+        }
+
+        it("should be busy if strategy says so") {
+            givenBusyStrategy()
+            Assertions.assertThat(uut().isIdleNow).isFalse()
+        }
+
+        it("should transition to idle if found idle by strategy") {
+            givenIdleStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                isIdleNow
+            }
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should NOT transition to idle if found busy by strategy") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                isIdleNow
+            }
+
+            verify(callback, never()).onTransitionToIdle()
+        }
+
+        it("should be idle if paused") {
+            givenBusyStrategy()
+
+            val uut = uut().apply {
+                pause()
+            }
+
+            Assertions.assertThat(uut.isIdleNow).isTrue()
+        }
+
+        it("should be busy if paused and resumed") {
+            givenBusyStrategy()
+
+            val uut = uut().apply {
+                pause()
+                resume()
+            }
+
+            Assertions.assertThat(uut.isIdleNow).isFalse()
+        }
+
+        it("should notify of transition to idle upon pausing") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                pause()
+            }
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should enqueue an is-idle check using choreographer when a callback gets registered") {
+            with(uut()) {
+                registerIdleTransitionCallback(mock())
+            }
+
+            verify(choreographer).postFrameCallback(any())
+        }
+
+        it("should transition to idle when preregistered choreographer is dispatched") {
+            givenIdleStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should NOT transition to idle if not idle when preregistered choreographer is dispatched") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(callback, never()).onTransitionToIdle()
+        }
+
+        it("should re-register choreographer if found idle while preregistered choreographer is dispatched") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            val uut = uut()
+            uut.registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(choreographer, times(2)).postFrameCallback(any())
+        }
+
+        it("should adhere to pausing also when invoked via choreographer callback") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().apply {
+                pause()
+                registerIdleTransitionCallback(callback)
+            }
+            val runtimeChoreographerCallback = getChoreographerCallback()
+
+            reset(callback, choreographer)
+            runtimeChoreographerCallback.doFrame(0L)
+
+            verify(callback, never()).onTransitionToIdle()
+            verify(choreographer, never()).postFrameCallback(any())
+        }
+
+        it("should enqueue an additional idle check (using choreographer) if found busy") {
+            givenBusyStrategy()
+            uut().isIdleNow
+            verify(choreographer).postFrameCallback(any())
+        }
+
+        it("should NOT enqueue an additional idle check (using choreographer) if found idle") {
+            givenIdleStrategy()
+            uut().isIdleNow
+            verify(choreographer, never()).postFrameCallback(any())
+        }
+    }
+})
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index ed4057b8e..e244500e0 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -7,7 +7,6 @@ const DetoxConfigError = require('../src/errors/DetoxConfigError');
 
 const log = require('../src/utils/logger').child({ __filename });
 const {getDetoxSection, getDefaultConfiguration, getConfigurationByKey} = require('./utils/configurationUtils');
-const {coerceDeprecation, printFileDeprecationWarning} = require('./utils/deprecation');
 const shellQuote = require('./utils/shellQuote');
 
 module.exports.command = 'test';
@@ -25,20 +24,6 @@ module.exports.builder = {
     group: 'Configuration:',
     describe: 'Test runner config file, defaults to e2e/mocha.opts for mocha and e2e/config.json for jest',
   },
-  f: {
-    alias: 'file',
-    group: 'Configuration:',
-    describe: 'Specify test file to run',
-    coerce: coerceDeprecation('-f, --file'),
-    hidden: true,
-  },
-  s: {
-    alias: 'specs',
-    group: 'Configuration:',
-    describe: 'Root of tests look-up folder. Overrides the equivalent configuration in `package.json`, if set.',
-    coerce: coerceDeprecation('-s, --specs'),
-    hidden: true,
-  },
   l: {
     alias: 'loglevel',
     group: 'Debugging:',
@@ -145,11 +130,6 @@ const collectExtraArgs = require('./utils/collectExtraArgs')(module.exports.buil
 
 module.exports.handler = async function test(program) {
   const config = getDetoxSection();
-
-  if (!program.file && config.file) {
-    printFileDeprecationWarning(config.file);
-  }
-
   const runner = getConfigFor('test-runner') || 'mocha';
   const runnerConfig = getConfigFor('runner-config') || getDefaultRunnerConfig();
 
@@ -201,7 +181,7 @@ module.exports.handler = async function test(program) {
       return args;
     }
 
-    const fallbackTestFolder = `"${getConfigFor('file', 'specs') || 'e2e'}"`;
+    const fallbackTestFolder = `"${config.specs || 'e2e'}"`;
     return args.concat(fallbackTestFolder);
   }
 
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 187dbfe1e..c1870fe5f 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -55,12 +55,6 @@ describe('test', () => {
       );
     });
 
-    it('should warn about deprecated options', async () => {
-      mockAndroidMochaConfiguration();
-      await callCli('./test', 'test --specs e2e');
-      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('migration guide'));
-    });
-
     it('should pass in device-launch-args as an environment variable', async () => {
       mockAndroidMochaConfiguration();
 
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index 9d48e7a23..556b231d1 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -198,47 +198,43 @@ describe('Detox', () => {
       artifactsManager = detox._artifactsManager; // TODO: rewrite to avoid accessing private fields
     });
 
-    it(`Calling detox.init() should trigger artifactsManager.beforeAll()`, async () => {
-      expect(artifactsManager.onBeforeAll).toHaveBeenCalledTimes(1);
-    });
-
     it(`Calling detox.beforeEach() will trigger artifacts manager .onBeforeEach`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'running' };
       await detox.beforeEach(testSummary);
 
-      expect(artifactsManager.onBeforeEach).toHaveBeenCalledWith(testSummary);
+      expect(artifactsManager.onTestStart).toHaveBeenCalledWith(testSummary);
     });
 
     it(`Calling detox.beforeEach() and detox.afterEach() with a deprecated signature will throw an exception`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'running' };
 
       await expect(detox.beforeEach(testSummary.title, testSummary.fullName, testSummary.status)).rejects.toThrowError();
-      expect(artifactsManager.onBeforeEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onTestStart).not.toHaveBeenCalled();
 
       await expect(detox.afterEach(testSummary.title, testSummary.fullName, testSummary.status)).rejects.toThrowError();
-      expect(artifactsManager.onAfterEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onTestDone).not.toHaveBeenCalled();
     });
 
     it(`Calling detox.beforeEach() and detox.afterEach() with incorrect test status will throw an exception`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'incorrect status' };
 
       await expect(detox.beforeEach(testSummary)).rejects.toThrowError();
-      expect(artifactsManager.onBeforeEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onTestStart).not.toHaveBeenCalled();
 
       await expect(detox.afterEach(testSummary)).rejects.toThrowError();
-      expect(artifactsManager.onAfterEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onTestDone).not.toHaveBeenCalled();
     });
 
     it(`Calling detox.afterEach() should trigger artifactsManager.onAfterEach`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'passed' };
       await detox.afterEach(testSummary);
 
-      expect(artifactsManager.onAfterEach).toHaveBeenCalledWith(testSummary);
+      expect(artifactsManager.onTestDone).toHaveBeenCalledWith(testSummary);
     });
 
-    it(`Calling detox.cleanup() should trigger artifactsManager.afterAll()`, async () => {
+    it(`Calling detox.cleanup() should trigger artifactsManager.onBeforeCleanup()`, async () => {
       await detox.cleanup();
-      expect(artifactsManager.onAfterAll).toHaveBeenCalledTimes(1);
+      expect(artifactsManager.onBeforeCleanup).toHaveBeenCalledTimes(1);
     });
   });
 });
diff --git a/detox/src/artifacts/ArtifactsManager.test.js b/detox/src/artifacts/ArtifactsManager.test.js
index 996bf9d8f..d37cf5588 100644
--- a/detox/src/artifacts/ArtifactsManager.test.js
+++ b/detox/src/artifacts/ArtifactsManager.test.js
@@ -117,13 +117,13 @@ describe('ArtifactsManager', () => {
           onShutdownDevice: jest.fn(),
           onBeforeUninstallApp: jest.fn(),
           onBeforeTerminateApp: jest.fn(),
+          onTerminateApp: jest.fn(),
           onBeforeLaunchApp: jest.fn(),
           onLaunchApp: jest.fn(),
           onCreateExternalArtifact: jest.fn(),
-          onBeforeAll: jest.fn(),
-          onBeforeEach: jest.fn(),
-          onAfterEach: jest.fn(),
-          onAfterAll: jest.fn(),
+          onTestStart: jest.fn(),
+          onTestDone: jest.fn(),
+          onBeforeCleanup: jest.fn(),
           onTerminate: jest.fn(),
         });
       };
@@ -281,13 +281,11 @@ describe('ArtifactsManager', () => {
           });
         }
 
-        itShouldCatchErrorsOnPhase('onBeforeAll', () => undefined);
+        itShouldCatchErrorsOnPhase('onTestStart', () => testSummaries.running());
 
-        itShouldCatchErrorsOnPhase('onBeforeEach', () => testSummaries.running());
+        itShouldCatchErrorsOnPhase('onTestDone', () => testSummaries.passed());
 
-        itShouldCatchErrorsOnPhase('onAfterEach', () => testSummaries.passed());
-
-        itShouldCatchErrorsOnPhase('onAfterAll', () => undefined);
+        itShouldCatchErrorsOnPhase('onBeforeCleanup', () => undefined);
 
         itShouldCatchErrorsOnPhase('onTerminate', () => undefined);
 
@@ -326,45 +324,42 @@ describe('ArtifactsManager', () => {
           deviceId: 'testDeviceId',
         }));
 
-        itShouldCatchErrorsOnPhase('onBeforeUninstallApp', () => ({
+        itShouldCatchErrorsOnPhase('onTerminateApp', () => ({
           bundleId: 'testBundleId',
           deviceId: 'testDeviceId',
         }));
-      });
 
-      describe('onBeforeAll', () => {
-        it('should call onBeforeAll in plugins', async () => {
-          expect(testPlugin.onBeforeAll).not.toHaveBeenCalled();
-          await artifactsManager.onBeforeAll();
-          expect(testPlugin.onBeforeAll).toHaveBeenCalled();
-        });
+        itShouldCatchErrorsOnPhase('onBeforeUninstallApp', () => ({
+          bundleId: 'testBundleId',
+          deviceId: 'testDeviceId',
+        }));
       });
 
-      describe('onBeforeEach', () => {
-        it('should call onBeforeEach in plugins with the passed argument', async () => {
+      describe('onTestStart', () => {
+        it('should call onTestStart in plugins with the passed argument', async () => {
           const testSummary = testSummaries.running();
 
-          expect(testPlugin.onBeforeEach).not.toHaveBeenCalled();
-          await artifactsManager.onBeforeEach(testSummary);
-          expect(testPlugin.onBeforeEach).toHaveBeenCalledWith(testSummary);
+          expect(testPlugin.onTestStart).not.toHaveBeenCalled();
+          await artifactsManager.onTestStart(testSummary);
+          expect(testPlugin.onTestStart).toHaveBeenCalledWith(testSummary);
         });
       });
 
-      describe('onAfterEach', () => {
-        it('should call onAfterEach in plugins with the passed argument', async () => {
+      describe('onTestDone', () => {
+        it('should call onTestDone in plugins with the passed argument', async () => {
           const testSummary = testSummaries.passed();
 
-          expect(testPlugin.onAfterEach).not.toHaveBeenCalled();
-          await artifactsManager.onAfterEach(testSummary);
-          expect(testPlugin.onAfterEach).toHaveBeenCalledWith(testSummary);
+          expect(testPlugin.onTestDone).not.toHaveBeenCalled();
+          await artifactsManager.onTestDone(testSummary);
+          expect(testPlugin.onTestDone).toHaveBeenCalledWith(testSummary);
         });
       });
 
-      describe('onAfterAll', () => {
-        it('should call onAfterAll in plugins', async () => {
-          expect(testPlugin.onAfterAll).not.toHaveBeenCalled();
-          await artifactsManager.onAfterAll();
-          expect(testPlugin.onAfterAll).toHaveBeenCalled();
+      describe('onBeforeCleanup', () => {
+        it('should call onBeforeCleanup in plugins', async () => {
+          expect(testPlugin.onBeforeCleanup).not.toHaveBeenCalled();
+          await artifactsManager.onBeforeCleanup();
+          expect(testPlugin.onBeforeCleanup).toHaveBeenCalled();
         });
       });
 
@@ -402,6 +397,19 @@ describe('ArtifactsManager', () => {
         });
       });
 
+      describe('onTerminateApp', () => {
+        it('should call onTerminateApp in plugins', async () => {
+          const terminateInfo = {
+            deviceId: 'testDeviceId',
+            bundleId: 'testBundleId',
+          };
+
+          expect(testPlugin.onBeforeTerminateApp).not.toHaveBeenCalled();
+          await artifactsManager.onBeforeTerminateApp(terminateInfo);
+          expect(testPlugin.onBeforeTerminateApp).toHaveBeenCalledWith(terminateInfo);
+        });
+      });
+
       describe('onBeforeUninstallApp', () => {
         it('should call onBeforeUninstallApp in plugins', async () => {
           const uninstallInfo = {
diff --git a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
similarity index 80%
rename from detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
rename to detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
index b42164285..a73061406 100644
--- a/detox/src/artifacts/instruments/SimulatorInstrumentsPlugin.test.js
+++ b/detox/src/artifacts/instruments/InstrumentsArtifactPlugin.test.js
@@ -1,8 +1,8 @@
-const SimulatorInstrumentsPlugin = require('./SimulatorInstrumentsPlugin');
+const InstrumentsArtifactPlugin = require('./InstrumentsArtifactPlugin');
 
-describe('SimulatorInstrumentsPlugin', () => {
+describe('InstrumentsArtifactPlugin', () => {
     describe('static parseConfig(config)', () => {
-        const parseConfig = SimulatorInstrumentsPlugin.parseConfig;
+        const parseConfig = InstrumentsArtifactPlugin.parseConfig;
 
         const ENABLE_MODES = ['all'].map(x => [x]);
         const DISABLE_MODES = ['none', 'manual', 'failing', { enabled: true }].map(x => [x]);
diff --git a/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js b/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
index f2221864e..58d11021b 100644
--- a/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
+++ b/detox/src/artifacts/log/ios/SimulatorLogPlugin.test.js
@@ -1,4 +1,3 @@
-jest.mock('../../../utils/argparse');
 jest.mock('../../../utils/logger');
 
 const _ = require('lodash');
@@ -9,7 +8,6 @@ const path = require('path');
 
 describe('SimulatorLogPlugin', () => {
   async function majorWorkflow() {
-    let argparse = null;
     let fakePathBuilder = null;
     let fakeSources = null;
     let fakeAppleSimUtils = null;
@@ -19,24 +17,16 @@ describe('SimulatorLogPlugin', () => {
     let createdArtifacts = null;
 
     function init() {
-      argparse = require('../../../utils/argparse');
-      argparse.getArgValue.mockImplementation((key) => {
-        switch (key) {
-          case 'record-logs': return 'all';
-          case 'loglevel': return 'trace';
-          case 'artifacts-location': return path.dirname(tempfile(''));
-          default: throw new Error(`unexpected argparse.getArgValue mock call: ${key}`);
-        }
-      });
-
       SimulatorLogPlugin = require('./SimulatorLogPlugin');
       ArtifactsManager = require('../../ArtifactsManager');
 
       createdArtifacts = [];
       fakePathBuilder = {
-        buildPathForTestArtifact: jest.fn((_, summary) => {
+        buildPathForTestArtifact: jest.fn((artifactName, summary) => {
           const artifactPath = tempfile(summary ? '.log' : '.startup.log');
-          createdArtifacts.push(artifactPath);
+          if (!artifactName.includes('detox_')) {
+            createdArtifacts.push(artifactPath);
+          }
 
           return artifactPath;
         }),
@@ -48,7 +38,7 @@ describe('SimulatorLogPlugin', () => {
 
       fakeAppleSimUtils = {
         logStream({ udid, processImagePath, level, stdout, stderr }) {
-          fs.writeFileSync(fakeSources.stdin, '');
+          // fs.writeFileSync(fakeSources.stdin, '');
 
           const handle = fs.openSync(fakeSources.stdin, 'r');
           const process = exec.spawnAndLog('cat', [], {
@@ -65,7 +55,15 @@ describe('SimulatorLogPlugin', () => {
         },
       };
 
-      artifactsManager = new ArtifactsManager({ pathBuilder: fakePathBuilder });
+      artifactsManager = new ArtifactsManager({
+        pathBuilder: fakePathBuilder,
+        plugins: {
+          log: {
+            enabled: true,
+            keepOnlyFailedTestsArtifacts: false,
+          },
+        },
+      });
       artifactsManager.registerArtifactPlugins({
         log: (api) => new SimulatorLogPlugin({
           api,
@@ -88,26 +86,23 @@ describe('SimulatorLogPlugin', () => {
     await artifactsManager.onLaunchApp({ device: 'booted', bundleId: 'com.test', pid: 8000 });
     await logToDeviceLogs('omit - after launch inside detox.init()');
 
-    await artifactsManager.onBeforeAll();
-    await logToDeviceLogs('take - inside before all');
-
-    await artifactsManager.onBeforeEach({ title: 'test', fullName: 'some test', status: 'running'});
-    await logToDeviceLogs('take - inside before each');
-
+    await artifactsManager.onTestStart({ title: 'test', fullName: 'some test', status: 'running'});
     await logToDeviceLogs('take - before relaunch inside test');
+
     await artifactsManager.onBeforeLaunchApp({ device: 'booted', bundleId: 'com.test' });
     await logToDeviceLogs('take - during relaunch inside test');
     await artifactsManager.onLaunchApp({ device: 'booted', bundleId: 'com.test', pid: 8001 });
+
     await logToDeviceLogs('take - after relaunch inside test');
+    await artifactsManager.onTestDone({ title: 'test', fullName: 'some test', status: 'passed'});
 
-    await artifactsManager.onAfterEach({ title: 'test', fullName: 'some test', status: 'passed'});
-    await logToDeviceLogs('omit - after afterEach');
-    await artifactsManager.onAfterAll();
-    await logToDeviceLogs('omit - after afterAll');
+    await logToDeviceLogs('omit - before cleanup');
+    await artifactsManager.onBeforeCleanup();
+    await logToDeviceLogs('omit - after cleanup');
 
     const result = {};
 
-    expect(fakePathBuilder.buildPathForTestArtifact).toHaveBeenCalledTimes(2);
+    expect(fakePathBuilder.buildPathForTestArtifact).toHaveBeenCalledTimes(4);
     for (const artifact of createdArtifacts) {
       const contents = (await fs.readFile(artifact, 'utf8')).trim().split('\n');
       const extension = path.basename(artifact).split('.').slice(1).join('.');
diff --git a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
index c78678492..c77c0b7e4 100644
--- a/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/ArtifactPlugin.test.js
@@ -73,12 +73,12 @@ describe('ArtifactPlugin', () => {
   describe('lifecycle hooks', () => {
     beforeEach(() => {
       plugin.context = {
-        deviceId: 'shouldNotBeInExpectSnapshots',
+        deviceId: 'someOriginalDeviceId',
         shouldNotBeDeletedFromContext: 'extraProperty'
       };
     });
 
-    it('should update context on .onBeforeLaunchApp', async () => {
+    it('should not update context on .onBeforeLaunchApp', async () => {
       await expect(plugin.onBeforeLaunchApp({
         deviceId: 'testDeviceId',
         bundleId: 'testBundleId',
@@ -88,17 +88,12 @@ describe('ArtifactPlugin', () => {
       }));
 
       expect(plugin.context).toEqual({
-        bundleId: 'testBundleId',
-        deviceId: 'testDeviceId',
-        launchArgs: {
-          detoxSessionId: 'test',
-        },
-        pid: NaN,
-        shouldNotBeDeletedFromContext: 'extraProperty',
+        deviceId: 'someOriginalDeviceId',
+        shouldNotBeDeletedFromContext: 'extraProperty'
       });
     });
 
-    it('should have .onLaunchApp', async () => {
+    it('should update context .onLaunchApp', async () => {
       await expect(plugin.onLaunchApp({
         deviceId: 'testDeviceId',
         bundleId: 'testBundleId',
@@ -110,7 +105,7 @@ describe('ArtifactPlugin', () => {
 
       expect(plugin.context).toEqual({
         bundleId: 'testBundleId',
-        deviceId: 'testDeviceId',
+        deviceId: 'someOriginalDeviceId',
         launchArgs: {
           detoxSessionId: 'test',
         },
@@ -119,53 +114,64 @@ describe('ArtifactPlugin', () => {
       });
     });
 
-    it('should update context on .onBeforeUninstallApp', async () => {
+    it('should not update context on .onBeforeUninstallApp', async () => {
       await expect(plugin.onBeforeUninstallApp({
         deviceId: 'testDeviceId',
         bundleId: 'testBundleId',
       }));
 
       expect(plugin.context).toEqual({
-        bundleId: 'testBundleId',
-        deviceId: 'testDeviceId',
-        shouldNotBeDeletedFromContext: 'extraProperty',
+        deviceId: 'someOriginalDeviceId',
+        shouldNotBeDeletedFromContext: 'extraProperty'
       });
     });
 
-    it('should update context on .onBeforeTerminateApp', async () => {
+    it('should not update context on .onBeforeTerminateApp', async () => {
       await expect(plugin.onBeforeTerminateApp({
         deviceId: 'testDeviceId',
         bundleId: 'testBundleId',
       }));
 
       expect(plugin.context).toEqual({
-        bundleId: 'testBundleId',
+        deviceId: 'someOriginalDeviceId',
+        shouldNotBeDeletedFromContext: 'extraProperty'
+      });
+    });
+
+    it('should update context on .onTerminateApp', async () => {
+      await expect(plugin.onTerminateApp({
         deviceId: 'testDeviceId',
+        bundleId: 'testBundleId',
+      }));
+
+      expect(plugin.context).toEqual({
+        bundleId: '',
+        launchArgs: null,
+        pid: NaN,
+        deviceId: 'someOriginalDeviceId',
         shouldNotBeDeletedFromContext: 'extraProperty',
       });
     });
 
-    it('should have .onBootDevice', async () => {
+    it('should update context on .onBootDevice', async () => {
       await expect(plugin.onBootDevice({
         deviceId: 'testDeviceId',
         coldBoot: true
       }));
 
       expect(plugin.context).toEqual({
-        bundleId: '',
         deviceId: 'testDeviceId',
-        pid: NaN,
         shouldNotBeDeletedFromContext: 'extraProperty',
       });
     });
 
-    it('should have .onBeforeShutdownDevice', async () => {
+    it('should not update context on .onBeforeShutdownDevice', async () => {
       await expect(plugin.onBeforeShutdownDevice({
         deviceId: 'testDeviceId'
       }));
 
       expect(plugin.context).toEqual({
-        deviceId: 'testDeviceId',
+        deviceId: 'someOriginalDeviceId',
         shouldNotBeDeletedFromContext: 'extraProperty',
       });
     });
@@ -176,8 +182,9 @@ describe('ArtifactPlugin', () => {
       }));
 
       expect(plugin.context).toEqual({
+        deviceId: '',
         bundleId: '',
-        deviceId: 'testDeviceId',
+        launchArgs: null,
         pid: NaN,
         shouldNotBeDeletedFromContext: 'extraProperty',
       });
@@ -192,27 +199,21 @@ describe('ArtifactPlugin', () => {
       })).resolves.toBe(void 0);
     });
 
-    it('should have .onBeforeAll, which resets context.testSummary if called', async () => {
-      plugin.context.testSummary = {};
-      await plugin.onBeforeAll();
-      expect(plugin.context.testSummary).toBe(null);
-    });
-
-    it('should have .onBeforeEach, which updates context.testSummary if called', async () => {
+    it('should have .onTestStart, which updates context.testSummary if called', async () => {
       const testSummary = testSummaries.running();
-      await plugin.onBeforeEach(testSummary);
+      await plugin.onTestStart(testSummary);
       expect(plugin.context.testSummary).toBe(testSummary);
     });
 
-    it('should have .onAfterEach, which updates context.testSummary if called', async () => {
+    it('should have .onTestDone, which updates context.testSummary if called', async () => {
       const testSummary = testSummaries.failed();
-      await plugin.onAfterEach(testSummary);
+      await plugin.onTestDone(testSummary);
       expect(plugin.context.testSummary).toBe(testSummary);
     });
 
-    it('should have .onAfterAll, which resets context.testSummary if called', async () => {
+    it('should have .onBeforeCleanup, which resets context.testSummary if called', async () => {
       plugin.context.testSummary = {};
-      await plugin.onAfterAll();
+      await plugin.onBeforeCleanup();
       expect(plugin.context.testSummary).toBe(null);
     });
 
@@ -232,10 +233,10 @@ describe('ArtifactPlugin', () => {
         expect(plugin.onBeforeLaunchApp).toBe(plugin.onTerminate);
         expect(plugin.onLaunchApp).toBe(plugin.onTerminate);
         expect(plugin.onBeforeTerminateApp).toBe(plugin.onTerminate);
-        expect(plugin.onBeforeAll).toBe(plugin.onTerminate);
-        expect(plugin.onBeforeEach).toBe(plugin.onTerminate);
-        expect(plugin.onAfterEach).toBe(plugin.onTerminate);
-        expect(plugin.onAfterAll).toBe(plugin.onTerminate);
+        expect(plugin.onTerminateApp).toBe(plugin.onTerminate);
+        expect(plugin.onTestStart).toBe(plugin.onTerminate);
+        expect(plugin.onTestDone).toBe(plugin.onTerminate);
+        expect(plugin.onBeforeCleanup).toBe(plugin.onTerminate);
         expect(plugin.onUserAction).toBe(plugin.onTerminate);
       });
 
diff --git a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
index 88a918e3a..cca9ddbca 100644
--- a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
+++ b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.js
@@ -34,7 +34,7 @@ class StartupAndTestRecorderPlugin extends WholeTestRecorderPlugin {
     }
   }
 
-  async onBeforeEach(testSummary) {
+  async onTestStart(testSummary) {
     this._isInStartupPhase = false;
 
     if (this._isRecordingStartup) {
@@ -42,25 +42,25 @@ class StartupAndTestRecorderPlugin extends WholeTestRecorderPlugin {
       this._isRecordingStartup = false;
     }
 
-    await super.onBeforeEach(testSummary);
+    await super.onTestStart(testSummary);
   }
 
-  async onAfterEach(testSummary) {
-    await super.onAfterEach(testSummary);
+  async onTestDone(testSummary) {
+    await super.onTestDone(testSummary);
 
     if (this.startupRecording) {
       this._tryToFinalizeStartupRecording();
     }
   }
 
-  async onAfterAll() {
-    await super.onAfterAll();
+  async onBeforeCleanup() {
+    await super.onBeforeCleanup();
 
     if (this.startupRecording) {
       this._tryToFinalizeStartupRecording();
     }
 
-    await super.onAfterAll();
+    await super.onBeforeCleanup();
   }
 
   /***
diff --git a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
index 078c06589..41e428dfe 100644
--- a/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/StartupAndTestRecorderPlugin.test.js
@@ -31,28 +31,28 @@ describe('StartupAndTestRecorderPlugin', () => {
       it('should end correctly, but do nothing', expectThatNothingActuallyHappens);
     });
 
-    describe('onBeforeEach', () => {
+    describe('onTestStart', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
       });
 
       it('should end correctly, but do nothing', expectThatNothingActuallyHappens);
     });
 
-    describe('onAfterEach', () => {
+    describe('onTestDone', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.failed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.failed());
       });
 
       it('should end correctly, but do nothing', expectThatNothingActuallyHappens);
     });
 
-    describe('onAfterAll', () => {
+    describe('onBeforeCleanup', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.failed());
-        await plugin.onAfterAll();
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.failed());
+        await plugin.onBeforeCleanup();
       });
 
       it('should end correctly, but do nothing', expectThatNothingActuallyHappens);
@@ -89,11 +89,11 @@ describe('StartupAndTestRecorderPlugin', () => {
     });
   });
 
-  describe('onBeforeEach', () => {
+  describe('onTestStart', () => {
     describe('if app was launched before', () => {
       beforeEach(async () => {
         await plugin.onReadyToRecord();
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
       });
 
       it('should stop start-up recording', () => {
@@ -107,7 +107,7 @@ describe('StartupAndTestRecorderPlugin', () => {
 
     describe('', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
       });
 
       it('should create test recording', () => {
@@ -132,7 +132,7 @@ describe('StartupAndTestRecorderPlugin', () => {
     });
   });
 
-  describe('onAfterEach', () => {
+  describe('onTestDone', () => {
     describe('when plugin is keeping only artifacts from failed tests', () => {
       beforeEach(() => {
         plugin.keepOnlyFailedTestsArtifacts = true;
@@ -141,8 +141,8 @@ describe('StartupAndTestRecorderPlugin', () => {
       describe('and current test passed well', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onBeforeEach(testSummaries.running());
-          await plugin.onAfterEach(testSummaries.passed());
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
           await api.emulateRunningAllIdleCallbacks();
         });
 
@@ -159,8 +159,8 @@ describe('StartupAndTestRecorderPlugin', () => {
       describe('and current test failed', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onBeforeEach(testSummaries.running());
-          await plugin.onAfterEach(testSummaries.failed());
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.failed());
         });
 
         itShouldScheduleSavingAndUntrackingOfBothArtifacts();
@@ -175,8 +175,8 @@ describe('StartupAndTestRecorderPlugin', () => {
       describe('and test finished anyhow', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onBeforeEach(testSummaries.running());
-          await plugin.onAfterEach(testSummaries.passed());
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
         });
 
         itShouldScheduleSavingAndUntrackingOfBothArtifacts();
@@ -184,16 +184,16 @@ describe('StartupAndTestRecorderPlugin', () => {
     });
   });
 
-  describe('onAfterAll', () => {
+  describe('onBeforeCleanup', () => {
     describe('when the plugin is configured to keep all artifacts', () => {
       beforeEach(() => {
         plugin.keepOnlyFailedTestsArtifacts = false;
       });
 
-      describe('when there were no calls to .onBeforeEach and .onAfterEach', () => {
+      describe('when there were no calls to .onTestStart and .onTestDone', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onAfterAll();
+          await plugin.onBeforeCleanup();
         });
 
         it('should schedule saving of the start-up recording', () => {
@@ -224,11 +224,11 @@ describe('StartupAndTestRecorderPlugin', () => {
         });
       });
 
-      describe('when there were already calls to .onAfterEach', () => {
+      describe('when there were already calls to .onTestDone', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onBeforeEach(testSummaries.running());
-          await plugin.onAfterEach(testSummaries.passed());
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
           api.requestIdleCallback.mockClear();
         });
 
@@ -247,12 +247,12 @@ describe('StartupAndTestRecorderPlugin', () => {
         plugin.keepOnlyFailedTestsArtifacts = true;
       });
 
-      describe('when there were no calls to .onBeforeEach and .onAfterEach', () => {
+      describe('when there were no calls to .onTestStart and .onTestDone', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
 
           api.requestIdleCallback.mockClear();
-          await plugin.onAfterAll();
+          await plugin.onBeforeCleanup();
         });
 
         itShouldScheduleDiscardingAndUntrackingOfStartupArtifact();
@@ -261,11 +261,11 @@ describe('StartupAndTestRecorderPlugin', () => {
       describe('when all tests were successful', () => {
         beforeEach(async () => {
           await plugin.onReadyToRecord();
-          await plugin.onBeforeEach(testSummaries.running());
-          await plugin.onAfterEach(testSummaries.passed());
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
 
           api.requestIdleCallback.mockClear();
-          await plugin.onAfterAll();
+          await plugin.onBeforeCleanup();
         });
 
         itShouldScheduleDiscardingAndUntrackingOfStartupArtifact();
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
index 90b2b2506..c72754c10 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
@@ -15,19 +15,19 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
     };
   }
 
-  async onBeforeEach(testSummary) {
+  async onTestStart(testSummary) {
     this.context.testSummary = null;
     this._flushSessionSnapshots();
 
-    await super.onBeforeEach(testSummary);
-    await this._takeAutomaticSnapshot('beforeEach');
+    await super.onTestStart(testSummary);
+    await this._takeAutomaticSnapshot('testStart');
   }
 
-  async onAfterEach(testSummary) {
-    await super.onAfterEach(testSummary);
+  async onTestDone(testSummary) {
+    await super.onTestDone(testSummary);
 
     if (this.shouldKeepArtifactOfTest(testSummary)) {
-      await this._takeAutomaticSnapshot('afterEach');
+      await this._takeAutomaticSnapshot('testDone');
       this._startSavingSnapshots('fromTest');
     } else {
       this._startDiscardingSnapshots('fromTest');
@@ -37,8 +37,8 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
     this._flushSessionSnapshots();
   }
 
-  async onAfterAll() {
-    await super.onAfterAll();
+  async onBeforeCleanup() {
+    await super.onBeforeCleanup();
     this._flushSessionSnapshots();
   }
 
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
index 1d722f736..866b93a2f 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
@@ -22,18 +22,18 @@ describe('TwoSnapshotsPerTestPlugin', () => {
   describe('when disabled', () => {
     beforeEach(() => plugin.disable());
 
-    describe('onBeforeEach', () => {
-      beforeEach(async () => plugin.onBeforeEach(testSummaries.running()));
+    describe('onTestStart', () => {
+      beforeEach(async () => plugin.onTestStart(testSummaries.running()));
 
-      it('should not create artifact onBeforeEach', async () =>
+      it('should not create artifact onTestStart', async () =>
         expect(plugin.createTestArtifact).not.toHaveBeenCalled());
     });
 
     describe('when configured to keep artifacts', function() {
       beforeEach(() => plugin.configureToKeepArtifacts(true));
 
-      describe('onAfterEach', () => {
-        beforeEach(async () => plugin.onAfterEach(testSummaries.passed()));
+      describe('onTestDone', () => {
+        beforeEach(async () => plugin.onTestDone(testSummaries.passed()));
 
         it('should not do create artifacts', async () =>
           expect(plugin.createTestArtifact).not.toHaveBeenCalled());
@@ -46,8 +46,8 @@ describe('TwoSnapshotsPerTestPlugin', () => {
     describe('when configured to keep artifacts', function() {
       beforeEach(() => plugin.configureToKeepArtifacts(false));
 
-      describe('onAfterEach', () => {
-        beforeEach(async () => plugin.onAfterEach(testSummaries.passed()));
+      describe('onTestDone', () => {
+        beforeEach(async () => plugin.onTestDone(testSummaries.passed()));
 
         it('should not do create artifacts', async () =>
           expect(plugin.createTestArtifact).not.toHaveBeenCalled());
@@ -58,9 +58,9 @@ describe('TwoSnapshotsPerTestPlugin', () => {
     });
   });
 
-  describe('when onBeforeEach called', function() {
+  describe('when onTestStart called', function() {
     beforeEach(async () => {
-      await plugin.onBeforeEach(testSummaries.running());
+      await plugin.onTestStart(testSummaries.running());
     });
 
     it('should create test artifact', () => {
@@ -68,12 +68,12 @@ describe('TwoSnapshotsPerTestPlugin', () => {
     });
 
     it('should start and stop recording in the artifact', () => {
-      expect(plugin.snapshots.fromTest['beforeEach'].start).toHaveBeenCalledTimes(1);
-      expect(plugin.snapshots.fromTest['beforeEach'].stop).toHaveBeenCalledTimes(1);
+      expect(plugin.snapshots.fromTest['testStart'].start).toHaveBeenCalledTimes(1);
+      expect(plugin.snapshots.fromTest['testStart'].stop).toHaveBeenCalledTimes(1);
     });
 
     it('should put the artifact under tracking', () => {
-      expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['beforeEach']);
+      expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testStart']);
     });
   });
 
@@ -94,10 +94,10 @@ describe('TwoSnapshotsPerTestPlugin', () => {
   describe('when the plugin should keep a test artifact', () => {
     beforeEach(() => plugin.configureToKeepArtifacts(true));
 
-    describe('when onBeforeEach and onAfterEach are called', () => {
+    describe('when onTestStart and onTestDone are called', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.passed());
       });
 
       it('should create the second test artifact', () => {
@@ -105,12 +105,12 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       });
 
       it('should start and stop the second test artifact', () => {
-        expect(plugin.snapshots.fromTest['afterEach'].start).toHaveBeenCalledTimes(1);
-        expect(plugin.snapshots.fromTest['afterEach'].stop).toHaveBeenCalledTimes(1);
+        expect(plugin.snapshots.fromTest['testDone'].start).toHaveBeenCalledTimes(1);
+        expect(plugin.snapshots.fromTest['testDone'].stop).toHaveBeenCalledTimes(1);
       });
 
       it('should put the second test artifact under tracking', () => {
-        expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['afterEach']);
+        expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testDone']);
       });
 
       it('should schedule two saving operations and specify itself as an initiator', () => {
@@ -122,25 +122,25 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       it('should schedule to save and untrack the first artifact', async () => {
         const [saveRequest] = api.requestIdleCallback.mock.calls[0];
 
-        expect(plugin.snapshots.fromTest['beforeEach'].save).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['testStart'].save).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await saveRequest();
 
-        expect(plugin.snapshots.fromTest['beforeEach'].save).toBeCalledWith('test/beforeEach.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['beforeEach']);
+        expect(plugin.snapshots.fromTest['testStart'].save).toBeCalledWith('test/testStart.png');
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
       });
 
       it('should ultimately save and untrack the second artifact', async () => {
         const [saveRequest] = api.requestIdleCallback.mock.calls[1];
 
-        expect(plugin.snapshots.fromTest['afterEach'].save).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['testDone'].save).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await saveRequest();
 
-        expect(plugin.snapshots.fromTest['afterEach'].save).toBeCalledWith('test/afterEach.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['afterEach']);
+        expect(plugin.snapshots.fromTest['testDone'].save).toBeCalledWith('test/testDone.png');
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testDone']);
       });
     });
 
@@ -150,12 +150,12 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       beforeEach(async () => {
         artifact = new ArtifactMock('screenshot');
 
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
         await plugin.onCreateExternalArtifact({
           artifact,
           name: 'final_name',
         });
-        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onTestDone(testSummaries.passed());
       });
 
       it('should be saved using the suggested name and untracked', async () => {
@@ -179,7 +179,7 @@ describe('TwoSnapshotsPerTestPlugin', () => {
           artifact,
           name: 'final_name',
         });
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
       });
 
       it('should be saved using the suggested name and untracked', async () => {
@@ -193,19 +193,19 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       });
     });
 
-    describe('when an external snapshot is created before afterAll', function() {
+    describe('when an external snapshot is created before Detox cleanup', function() {
       let artifact;
 
       beforeEach(async () => {
         artifact = new ArtifactMock('screenshot');
 
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.passed());
         await plugin.onCreateExternalArtifact({
           artifact,
           name: 'final_name',
         });
-        await plugin.onAfterAll();
+        await plugin.onBeforeCleanup();
       });
 
       it('should be saved using the suggested name and untracked', async () => {
@@ -223,10 +223,10 @@ describe('TwoSnapshotsPerTestPlugin', () => {
   describe('when the plugin should not keep a test artifact', () => {
     beforeEach(() => plugin.configureToKeepArtifacts(false));
 
-    describe('when onBeforeEach and onAfterEach are called', () => {
+    describe('when onTestStart and onTestDone are called', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.passed());
       });
 
       it('should not create the second test artifact', () => {
@@ -241,13 +241,13 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       it('should ultimately discard and untrack the first artifact', async () => {
         const [discardRequest] = api.requestIdleCallback.mock.calls[0];
 
-        expect(plugin.snapshots.fromTest['beforeEach'].discard).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['testStart'].discard).not.toHaveBeenCalled();
         expect(api.untrackArtifact).not.toHaveBeenCalled();
 
         await discardRequest();
 
-        expect(plugin.snapshots.fromTest['beforeEach'].discard).toHaveBeenCalledTimes(1);
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['beforeEach']);
+        expect(plugin.snapshots.fromTest['testStart'].discard).toHaveBeenCalledTimes(1);
+        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
       });
     });
 
@@ -257,12 +257,12 @@ describe('TwoSnapshotsPerTestPlugin', () => {
       beforeEach(async () => {
         artifact = new ArtifactMock('screenshot');
 
-        await plugin.onBeforeEach(testSummaries.running());
+        await plugin.onTestStart(testSummaries.running());
         await plugin.onCreateExternalArtifact({
           artifact,
           name: 'final_name',
         });
-        await plugin.onAfterEach(testSummaries.passed());
+        await plugin.onTestDone(testSummaries.passed());
       });
 
       it('should be discarded and untracked', async () => {
diff --git a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
index 91e52998d..4541ec1c9 100644
--- a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
+++ b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.js
@@ -10,8 +10,8 @@ class WholeTestRecorderPlugin extends ArtifactPlugin {
     this.testRecording = null;
   }
 
-  async onBeforeEach(testSummary) {
-    await super.onBeforeEach(testSummary);
+  async onTestStart(testSummary) {
+    await super.onTestStart(testSummary);
 
     if (this.enabled) {
       this.testRecording = this.createTrackedTestRecording();
@@ -19,8 +19,8 @@ class WholeTestRecorderPlugin extends ArtifactPlugin {
     }
   }
 
-  async onAfterEach(testSummary) {
-    await super.onAfterEach(testSummary);
+  async onTestDone(testSummary) {
+    await super.onTestDone(testSummary);
 
     if (this.testRecording) {
       await this.testRecording.stop();
diff --git a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
index fe7f513e0..da98da8f8 100644
--- a/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/WholeTestRecorderPlugin.test.js
@@ -20,15 +20,15 @@ describe('WholeTestRecorderPlugin', () => {
   describe('when disabled', () => {
     beforeEach(() => plugin.disable());
 
-    describe('onBeforeEach', () => {
-      beforeEach(async () => plugin.onBeforeEach(testSummaries.running()));
+    describe('onTestStart', () => {
+      beforeEach(async () => plugin.onTestStart(testSummaries.running()));
 
-      it('should not create recording onBeforeEach', async () =>
+      it('should not create recording onTestStart', async () =>
         expect(plugin.createTestRecording).not.toHaveBeenCalled());
     });
 
-    describe('onAfterEach', () => {
-      beforeEach(async () => plugin.onAfterEach(testSummaries.passed()));
+    describe('onTestDone', () => {
+      beforeEach(async () => plugin.onTestDone(testSummaries.passed()));
 
       it('should not create recording', async () =>
         expect(plugin.createTestRecording).not.toHaveBeenCalled());
@@ -38,8 +38,8 @@ describe('WholeTestRecorderPlugin', () => {
     });
   });
 
-  describe('onBeforeEach', () => {
-    beforeEach(async () => plugin.onBeforeEach(testSummaries.running()));
+  describe('onTestStart', () => {
+    beforeEach(async () => plugin.onTestStart(testSummaries.running()));
 
     it('should create artifact', async () => {
       expect(plugin.createTestRecording).toHaveBeenCalled();
@@ -61,10 +61,10 @@ describe('WholeTestRecorderPlugin', () => {
   describe('when the plugin should keep a test artifact', () => {
     beforeEach(() => plugin.configureToKeepArtifacts(true));
 
-    describe('onAfterEach', () => {
+    describe('onTestDone', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.failed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.failed());
       });
 
       it('should stop artifact recording', async () => {
@@ -93,10 +93,10 @@ describe('WholeTestRecorderPlugin', () => {
   describe('when the plugin should discard a test artifact', () => {
     beforeEach(() => plugin.configureToKeepArtifacts(false));
 
-    describe('onAfterEach', () => {
+    describe('onTestDone', () => {
       beforeEach(async () => {
-        await plugin.onBeforeEach(testSummaries.running());
-        await plugin.onAfterEach(testSummaries.failed());
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.failed());
       });
 
       it('should stop artifact recording', async () => {
diff --git a/detox/src/configuration.test.js b/detox/src/configuration.test.js
index 867279901..39ea59cbd 100644
--- a/detox/src/configuration.test.js
+++ b/detox/src/configuration.test.js
@@ -1,7 +1,6 @@
 const _ = require('lodash');
 const path = require('path');
 const schemes = require('./configurations.mock');
-const SimulatorInstrumentsPlugin = require('./artifacts/instruments/SimulatorInstrumentsPlugin');
 const LogArtifactPlugin = require('./artifacts/log/LogArtifactPlugin');
 const ScreenshotArtifactPlugin = require('./artifacts/screenshot/ScreenshotArtifactPlugin');
 const VideoArtifactPlugin = require('./artifacts/video/VideoArtifactPlugin');
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index 1b1d3f2e8..e037b321c 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -1,4 +1,5 @@
 const schemes = require('./configurations.mock');
+
 describe('index', () => {
   let detox;
   let mockDevice;
@@ -24,7 +25,13 @@ describe('index', () => {
       .mock('./devices/Device')
       .mock('./utils/logger')
       .mock('./client/Client')
-      .mock('./Detox', () => jest.fn(() => mockDetox))
+      .mock('./Detox', () => {
+        const MissingDetox = require('./utils/MissingDetox');
+        const none = new MissingDetox();
+        none.cleanupContext = () => {}; // avoid ruining global state
+
+        return Object.assign(jest.fn(() => mockDetox), { none });
+      });
 
     process.env.DETOX_UNIT_TEST = true;
     detox = require('./index');
@@ -39,6 +46,14 @@ describe('index', () => {
       .unmock('./Detox')
   });
 
+  it(`constructs detox without globals if initGlobals = false`, async () => {
+    const Detox = require('./Detox');
+
+    await detox.init(schemes.validOneDeviceNoSession, { initGlobals: false });
+
+    expect('by' in global).toBe(false);
+  });
+
   it(`throws if there was no config passed`, async () => {
     const logger = require('./utils/logger');
     let exception = undefined;
@@ -185,8 +200,9 @@ describe('index', () => {
   it(`Basic usage with memorized exported objects`, async() => {
     const { device, by } = detox;
 
-    expect(device.launchApp).toBe(undefined);
-    expect(by.id).toBe(undefined);
+    expect(() => { device, by }).not.toThrowError();
+    expect(() => { device.launchApp }).toThrowError();
+    expect(() => { by.id }).toThrowError();
 
     await detox.init(schemes.validOneDeviceNoSession);
 
@@ -195,8 +211,9 @@ describe('index', () => {
 
     await detox.cleanup();
 
-    expect(device.launchApp).toBe(undefined);
-    expect(by.id).toBe(undefined);
+    expect(() => { device, by }).not.toThrowError();
+    expect(() => { device.launchApp }).toThrowError();
+    expect(() => { by.id }).toThrowError();
   });
 
   it(`Basic usage, do not throw an error if cleanup is done twice`, async() => {
@@ -221,7 +238,14 @@ describe('index', () => {
   });
 
   it(`beforeEach() should be covered - with detox not initialized`, async() => {
-    await detox.beforeEach();
+    await expect(detox.beforeEach()).rejects.toThrow(/Make sure to call/);
+  });
+
+  it(`error message should be covered - with detox failed to initialize`, async() => {
+    mockDetox.init.mockReturnValue(Promise.reject(new Error('Test error')));
+
+    await expect(detox.init(schemes.validOneDeviceNoSession)).rejects.toThrow(/Test error/);
+    await expect(detox.beforeEach()).rejects.toThrow(/There was an error/);
   });
 
   it(`afterEach() should be covered - with detox initialized`, async() => {
@@ -230,7 +254,6 @@ describe('index', () => {
   });
 
   it(`afterEach() should be covered - with detox not initialized`, async() => {
-    await detox.afterEach();
+    await expect(detox.afterEach()).rejects.toThrow(/Make sure to call/);
   });
-
 });
diff --git a/detox/src/utils/environment.test.js b/detox/src/utils/environment.test.js
index 0b26b9201..36d88f836 100644
--- a/detox/src/utils/environment.test.js
+++ b/detox/src/utils/environment.test.js
@@ -37,6 +37,55 @@ describe('Environment', () => {
       await fs.remove(tempSdkPath);
     });
 
+    describe('getAvdHome', () => {
+      const testCases = [
+        [path.join(os.homedir(), '.android', 'avd'), null, null, null],
+        [path.join('homedir', '.android', 'avd'), null, null, 'homedir'],
+        [path.join('emu', 'avd'), null, 'emu', 'homedir'],
+        ['AVD', 'AVD', 'emu', 'homedir'],
+      ];
+
+      it.each(testCases)('should return %j ' + [
+        'if $ANDROID_AVD_HOME = %j',
+        'if $ANDROID_EMULATOR_HOME = %j',
+        'if $ANDROID_SDK_HOME = %j',
+      ].join(' and '), (...args) => {
+
+        process.env['ANDROID_AVD_HOME'] = args[1];
+        process.env['ANDROID_EMULATOR_HOME'] = args[2];
+        process.env['ANDROID_SDK_HOME'] = args[3];
+
+        expect(Environment.getAvdHome()).toBe(args[0]);
+      });
+    });
+
+    describe('getAvdDir', () => {
+      let avdHome;
+
+      beforeEach(async () => {
+        avdHome = process.env['ANDROID_AVD_HOME'] = tempfile();
+        await fs.mkdir(avdHome);
+      });
+
+      afterEach(async () => {
+        await fs.remove(avdHome);
+      });
+
+      it('should throw error if ${avdHome}/${avdName}.ini does not exist', () => {
+        expect(() => Environment.getAvdDir('nonExistent')).toThrow(/Failed.*INI.*at path:/);
+      });
+
+      it('should throw error if path specified in INI file does not exist', () => {
+        fs.writeFileSync(path.join(avdHome, 'MyAVD.ini'), `path=randomPath${Math.random()}`)
+        expect(() => Environment.getAvdDir('MyAVD')).toThrow(/Failed to find.*randomPath0\./);
+      });
+
+      it('should return path specified in INI file if it exists', () => {
+        fs.writeFileSync(path.join(avdHome, 'MyAVD.ini'), `path=${avdHome}`)
+        expect(Environment.getAvdDir('MyAVD')).toBe(avdHome);
+      });
+    });
+
     describe('getAndroidSDKPath', () => {
       it(`should return empty string if $ANDROID_SDK_ROOT and $ANDROID_HOME both are not set`, () => {
         delete process.env.ANDROID_SDK_ROOT;
diff --git a/detox/test/e2e/23.flows.test.js b/detox/test/e2e/23.flows.test.js
index 48e22f929..42f5986f7 100644
--- a/detox/test/e2e/23.flows.test.js
+++ b/detox/test/e2e/23.flows.test.js
@@ -4,4 +4,8 @@ describe('Flows', () => {
     await device.launchApp({newInstance: true});
     await device.terminateApp();
   });
+
+  it('should be able to start the next test with the terminated app', async () => {
+    await device.launchApp({newInstance: true});
+  });
 });
diff --git a/detox/test/e2e/init-circus.js b/detox/test/e2e/init-circus.js
index 5e2d4ca58..c40b46a75 100644
--- a/detox/test/e2e/init-circus.js
+++ b/detox/test/e2e/init-circus.js
@@ -9,11 +9,11 @@ detoxCircus.getEnv().addEventsListener(assignReporter);
 detoxCircus.getEnv().addEventsListener(specReporter);
 
 // Set the default timeout
-jest.setTimeout(300000);
+jest.setTimeout(120000);
 
 beforeAll(async () => {
   await detox.init(config);
-});
+}, 300000);
 
 beforeEach(async () => {
   await adapter.beforeEach();
diff --git a/detox/test/e2e/init.js b/detox/test/e2e/init.js
index 31e4760a4..8fc85d41e 100644
--- a/detox/test/e2e/init.js
+++ b/detox/test/e2e/init.js
@@ -15,11 +15,11 @@ jasmine.getEnv().addReporter(specReporter);
 jasmine.getEnv().addReporter(assignReporter);
 
 // Set the default timeout
-jest.setTimeout(360000);
+jest.setTimeout(120000);
 
 beforeAll(async () => {
   await detox.init(config);
-});
+}, 300000);
 
 beforeEach(async () => {
   await adapter.beforeEach();
diff --git a/detox/test/package.json b/detox/test/package.json
index 751418129..12003ca53 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "14.9.2",
+  "version": "15.1.4",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^14.9.2",
+    "detox": "^15.1.4",
     "express": "^4.15.3",
     "jest": "24.9.x",
     "jest-circus": "24.9.x",
diff --git a/examples/demo-react-native-jest/e2e/init-circus.js b/examples/demo-react-native-jest/e2e/init-circus.js
index 05bc48df3..b22a8a858 100644
--- a/examples/demo-react-native-jest/e2e/init-circus.js
+++ b/examples/demo-react-native-jest/e2e/init-circus.js
@@ -9,11 +9,11 @@ detoxCircus.getEnv().addEventsListener(assignReporter);
 detoxCircus.getEnv().addEventsListener(specReporter);
 
 // Set the default timeout
-jest.setTimeout(300000);
+jest.setTimeout(90000);
 
 beforeAll(async () => {
   await detox.init(config);
-});
+}, 300000);
 
 beforeEach(async () => {
   await adapter.beforeEach();
diff --git a/examples/demo-react-native-jest/e2e/init.js b/examples/demo-react-native-jest/e2e/init.js
index 31fad763c..1d63d35bb 100644
--- a/examples/demo-react-native-jest/e2e/init.js
+++ b/examples/demo-react-native-jest/e2e/init.js
@@ -4,9 +4,6 @@ const adapter = require('detox/runners/jest/adapter');
 const specReporter = require('detox/runners/jest/specReporter');
 const assignReporter = require('detox/runners/jest/assignReporter');
 
-// Set the default timeout
-jest.setTimeout(300000);
-
 jasmine.getEnv().addReporter(adapter);
 
 // This takes care of generating status logs on a per-spec basis. By default, jest only reports at file-level.
@@ -17,9 +14,12 @@ jasmine.getEnv().addReporter(specReporter);
 // This is strictly optional.
 jasmine.getEnv().addReporter(assignReporter);
 
+// Set the default timeout
+jest.setTimeout(90000);
+
 beforeAll(async () => {
   await detox.init(config);
-});
+}, 300000);
 
 beforeEach(async () => {
   await adapter.beforeEach();