Repository: wix/Detox
Pull request #2724 (2021-04-13 11:32:23)

Improve mocking guide instructions
## Description

<!--
Step 2: Provide an overview of how your fix / enhancement works.
If possible, provide screenshots of the before and after states (even for simple command line options - show the terminal).
-->

In this pull request, I have improved the mocking guide slightly. Due to the way it was previously worded, I completely skipped step 1 as I assumed it was not relevant for me as I am using a modern version of RN. Had I read step 1 in more detail, I would have seen that I needed to follow this step. This change makes the instructions easier to consume to prevent confusion over the RN versions.

<!--
Step 3: Please review the checklist below.
-->

---


> _For features/enhancements:_
 - [ ] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

> _For API changes:_
 - [ ] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.



Patch:
diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md
index 8feb71638..476c0561c 100644
--- a/.github/ISSUE_TEMPLATE/enhancement.md
+++ b/.github/ISSUE_TEMPLATE/enhancement.md
@@ -4,6 +4,7 @@ about: Suggest an idea for this project
 title: ''
 labels: 'type: triage/enhancement'
 assignees: ''
+projects: wix/5
 
 ---
 
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index c590d068c..16d237fb8 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -113,7 +113,11 @@ dependencies {
     // noinspection GradleDynamicVersion
     testImplementation 'com.facebook.react:react-native:+'
     testImplementation 'org.json:json:20140107'
-    testImplementation 'junit:junit:4.13'
+
+// https://github.com/spekframework/spek/issues/232#issuecomment-610732158
+//    testImplementation 'junit:junit:4.13.2'
+    testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.6.0'
+
     testImplementation 'org.assertj:assertj-core:3.16.1'
     testImplementation "org.jetbrains.kotlin:kotlin-test:$_kotlinVersion"
     testImplementation 'org.apache.commons:commons-io:1.3.2'
@@ -131,7 +135,7 @@ if (rootProject.hasProperty('isOfficialDetoxLib') ||
             junitPlatform {
                 filters {
                     engines {
-                        include 'spek2'
+                        include 'spek2', 'junit-vintage'
                     }
                 }
             }
@@ -139,8 +143,8 @@ if (rootProject.hasProperty('isOfficialDetoxLib') ||
     }
 
     dependencies {
-        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.12'
-        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.12'
+        testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.15'
+        testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.15'
         testImplementation "org.jetbrains.kotlin:kotlin-reflect:$_kotlinVersion"
     }
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/Detox.java b/detox/android/detox/src/full/java/com/wix/detox/Detox.java
index ef18428ea..8d6c1627e 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/Detox.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/Detox.java
@@ -5,8 +5,6 @@ import android.app.Instrumentation;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
-import android.os.Looper;
-import android.util.Log;
 
 import com.wix.detox.config.DetoxConfig;
 import com.wix.detox.espresso.UiControllerSpy;
@@ -18,8 +16,6 @@ import androidx.test.espresso.IdlingPolicies;
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
-import static com.wix.detox.common.DetoxLog.LOG_TAG;
-
 /**
  * <p>Static class.</p>
  *
@@ -200,25 +196,9 @@ public final class Detox {
         Intent intent = extractInitialIntent();
         sActivityTestRule.launchActivity(intent);
 
-        // Kicks off another thread and attaches a Looper to that.
-        // The goal is to keep the test thread intact,
-        // as Loopers can't run on a thread twice.
-        final Thread t = new Thread(new Runnable() {
-            @Override
-            public void run() {
-                Thread thread = Thread.currentThread();
-                Log.i(LOG_TAG, "Detox thread starting (" + thread.getName() + ")");
-
-                Looper.prepare();
-                new DetoxManager(context).start();
-                Looper.loop();
-            }
-        }, "com.wix.detox.manager");
-        t.start();
-
         try {
-            t.join();
-        } catch (InterruptedException e) {
+            DetoxMain.run(context);
+        } catch (Exception e) {
             Thread.currentThread().interrupt();
             throw new RuntimeException("Detox got interrupted prematurely", e);
         }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt
index 7c4d805e0..6108e8bce 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt
@@ -2,12 +2,13 @@ package com.wix.detox
 
 import android.util.Log
 import com.github.anrwatchdog.ANRWatchDog
+import com.wix.detox.adapters.server.OutboundServerAdapter
 
-class DetoxANRHandler(private val wsClient: WebSocketClient) {
+class DetoxANRHandler(private val outboundServerAdapter: OutboundServerAdapter) {
     fun attach() {
         ANRWatchDog().setReportMainThreadOnly().setANRListener {
             val info = mapOf("threadDump" to Log.getStackTraceString(it))
-            wsClient.sendAction(ACTION_NAME, info, MESSAGE_ID)
+            outboundServerAdapter.sendMessage(ACTION_NAME, info, MESSAGE_ID)
         }.start()
 
         ANRWatchDog().setANRListener {
diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt
index 105369baf..2c2c45a58 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt
@@ -1,14 +1,15 @@
 package com.wix.detox
 
 import android.util.Log
+import com.wix.detox.adapters.server.OutboundServerAdapter
 
-class DetoxCrashHandler(private val wsClient: WebSocketClient) {
+class DetoxCrashHandler(private val outboundServerAdapter: OutboundServerAdapter) {
     fun attach() {
         Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
             Log.e(LOG_TAG, "Crash detected!!! thread=${thread.name} (${thread.id})")
 
             val crashInfo = mapOf("errorDetails" to "@Thread ${thread.name}(${thread.id}):\n${Log.getStackTraceString(exception)}\nCheck device logs for full details!")
-            wsClient.sendAction(ACTION_NAME, crashInfo, MESSAGE_ID)
+            outboundServerAdapter.sendMessage(ACTION_NAME, crashInfo, MESSAGE_ID)
         }
     }
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt
new file mode 100644
index 000000000..5e4f0bd88
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt
@@ -0,0 +1,104 @@
+package com.wix.detox
+
+import android.content.Context
+import android.util.Log
+import com.wix.detox.adapters.server.*
+import com.wix.detox.common.DetoxLog.Companion.LOG_TAG
+import com.wix.detox.instruments.DetoxInstrumentsManager
+import com.wix.detox.reactnative.ReactNativeExtension
+import com.wix.invoke.MethodInvocation
+
+private const val INIT_ACTION = "_init"
+private const val IS_READY_ACTION = "isReady"
+private const val TERMINATION_ACTION = "_terminate"
+
+object DetoxMain {
+    @JvmStatic
+    fun run(rnHostHolder: Context) {
+        val detoxServerInfo = DetoxServerInfo()
+        Log.i(LOG_TAG, "Detox server connection details: $detoxServerInfo")
+
+        val testEngineFacade = TestEngineFacade()
+        val actionsDispatcher = DetoxActionsDispatcher()
+        val externalAdapter = DetoxServerAdapter(actionsDispatcher, detoxServerInfo, IS_READY_ACTION, TERMINATION_ACTION)
+        initActionHandlers(actionsDispatcher, externalAdapter, testEngineFacade, rnHostHolder)
+        actionsDispatcher.dispatchAction(INIT_ACTION, "", 0)
+        actionsDispatcher.join()
+    }
+
+    private fun doInit(externalAdapter: DetoxServerAdapter, rnHostHolder: Context) {
+        externalAdapter.connect()
+
+        initCrashHandler(externalAdapter)
+        initANRListener(externalAdapter)
+        initReactNativeIfNeeded(rnHostHolder)
+    }
+
+    private fun doTeardown(serverAdapter: DetoxServerAdapter, actionsDispatcher: DetoxActionsDispatcher, testEngineFacade: TestEngineFacade) {
+        testEngineFacade.resetReactNative()
+
+        serverAdapter.teardown()
+        actionsDispatcher.teardown()
+    }
+
+    private fun initActionHandlers(actionsDispatcher: DetoxActionsDispatcher, serverAdapter: DetoxServerAdapter, testEngineFacade: TestEngineFacade, rnHostHolder: Context) {
+        // Primary actions
+        with(actionsDispatcher) {
+            val rnReloadHandler = ReactNativeReloadActionHandler(rnHostHolder, serverAdapter, testEngineFacade)
+
+            associateActionHandler(INIT_ACTION, object : DetoxActionHandler {
+                override fun handle(params: String, messageId: Long) =
+                    synchronized(this@DetoxMain) {
+                        this@DetoxMain.doInit(serverAdapter, rnHostHolder)
+                    }
+            })
+            associateActionHandler(IS_READY_ACTION, ReadyActionHandler(serverAdapter, testEngineFacade))
+
+            associateActionHandler("loginSuccess", ScarceActionHandler())
+            associateActionHandler("reactNativeReload", object: DetoxActionHandler {
+                override fun handle(params: String, messageId: Long) =
+                    synchronized(this@DetoxMain) {
+                        rnReloadHandler.handle(params, messageId)
+                    }
+            })
+            associateActionHandler("invoke", InvokeActionHandler(MethodInvocation(), serverAdapter))
+            associateActionHandler("cleanup", CleanupActionHandler(serverAdapter, testEngineFacade) {
+                dispatchAction(TERMINATION_ACTION, "", 0)
+            })
+            associateActionHandler(TERMINATION_ACTION, object: DetoxActionHandler {
+                override fun handle(params: String, messageId: Long) {
+                    this@DetoxMain.doTeardown(serverAdapter, actionsDispatcher, testEngineFacade)
+                }
+            })
+
+            if (DetoxInstrumentsManager.supports()) {
+                val instrumentsManager = DetoxInstrumentsManager(rnHostHolder)
+                associateActionHandler("setRecordingState", InstrumentsRecordingStateActionHandler(instrumentsManager, serverAdapter))
+                associateActionHandler("event", InstrumentsEventsActionsHandler(instrumentsManager, serverAdapter))
+            }
+        }
+
+        // Secondary actions
+        with(actionsDispatcher) {
+            val queryStatusHandler = QueryStatusActionHandler(serverAdapter, testEngineFacade)
+            associateActionHandler("currentStatus", object: DetoxActionHandler {
+                override fun handle(params: String, messageId: Long) =
+                    synchronized(this@DetoxMain) {
+                        queryStatusHandler.handle(params, messageId)
+                    }
+            }, false)
+        }
+    }
+
+    private fun initCrashHandler(outboundServerAdapter: OutboundServerAdapter) {
+        DetoxCrashHandler(outboundServerAdapter).attach()
+    }
+
+    private fun initANRListener(outboundServerAdapter: OutboundServerAdapter) {
+        DetoxANRHandler(outboundServerAdapter).attach()
+    }
+
+    private fun initReactNativeIfNeeded(rnHostHolder: Context) {
+        ReactNativeExtension.waitForRNBootstrap(rnHostHolder)
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/full/java/com/wix/detox/DetoxManager.java
deleted file mode 100644
index a39ee2f88..000000000
--- a/detox/android/detox/src/full/java/com/wix/detox/DetoxManager.java
+++ /dev/null
@@ -1,170 +0,0 @@
-package com.wix.detox;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.util.Log;
-
-import com.wix.detox.instruments.DetoxInstrumentsManager;
-import com.wix.detox.reactnative.ReactNativeExtension;
-import com.wix.invoke.MethodInvocation;
-
-import java.util.HashMap;
-import java.util.Map;
-
-import androidx.annotation.NonNull;
-import androidx.test.platform.app.InstrumentationRegistry;
-import kotlin.Unit;
-import kotlin.jvm.functions.Function0;
-import kotlin.jvm.functions.Function1;
-
-/**
- * Created by rotemm on 04/01/2017.
- */
-class DetoxManager implements WebSocketClient.ActionHandler {
-
-    private final static String LOG_TAG =  "DetoxManager";
-
-    private final static String DETOX_SERVER_ARG_KEY = "detoxServer";
-    private final static String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId";
-
-    private final static Function1<Throwable, String> errorParseFn = new Function1<Throwable, String>() {
-        @Override
-        public String invoke(Throwable t) {
-            return Log.getStackTraceString(t);
-        }
-    };
-
-    private String detoxServerUrl;
-    private String detoxSessionId;
-
-    private WebSocketClient wsClient;
-    private Handler handler;
-
-    private TestEngineFacade testEngineFacade = new TestEngineFacade();
-    private Map<String, DetoxActionHandler> actionHandlers = new HashMap<>();
-    private ReadyActionHandler readyActionHandler = null;
-
-    private Context reactNativeHostHolder;
-
-    DetoxManager(@NonNull Context context) {
-        this.reactNativeHostHolder = context;
-
-        handler = new Handler();
-
-        Bundle arguments = InstrumentationRegistry.getArguments();
-        detoxServerUrl = arguments.getString(DETOX_SERVER_ARG_KEY);
-        detoxSessionId = arguments.getString(DETOX_SESSION_ID_ARG_KEY);
-
-        if (detoxServerUrl == null || detoxSessionId == null) {
-            Log.i(LOG_TAG, "Missing arguments : detoxServer and/or detoxSession. Detox quits.");
-            stop();
-            return;
-        }
-
-        Log.i(LOG_TAG, "DetoxServerUrl: " + detoxServerUrl);
-        Log.i(LOG_TAG, "DetoxSessionId: " + detoxSessionId);
-    }
-
-    void start() {
-        if (detoxServerUrl != null && detoxSessionId != null) {
-            handler.post(new Runnable() {
-                @Override
-                public void run() {
-                    initReactNativeIfNeeded();
-                    initWSClient();
-                    initCrashHandler();
-                    initANRListener();
-                    initActionHandlers();
-                }
-            });
-        }
-    }
-
-    boolean stopping = false;
-
-    void stop() {
-        Log.i(LOG_TAG, "Stopping Detox.");
-        handler.postAtFrontOfQueue(new Runnable() {
-            @Override
-            public void run() {
-                if (stopping) return;
-                stopping = true;
-
-                testEngineFacade.resetReactNative();
-
-                actionHandlers.clear();
-                readyActionHandler = null;
-
-                if (wsClient != null) {
-                    wsClient.close();
-                }
-                Looper.myLooper().quit();
-            }
-        });
-    }
-
-    @Override
-    public void onAction(final String type, final String params, final long messageId) {
-        Log.i(LOG_TAG, "onAction: type: " + type + " params: " + params);
-        handler.post(new Runnable() {
-            @Override
-            public void run() {
-                final DetoxActionHandler handler = actionHandlers.get(type);
-                if (handler != null) {
-                    handler.handle(params, messageId);
-                }
-            }
-        });
-    }
-
-    @Override
-    public void onConnect() {
-        readyActionHandler.handle("", -1000L);
-    }
-
-    @Override
-    public void onClosed() {
-        stop();
-    }
-
-    private void initReactNativeIfNeeded() {
-        ReactNativeExtension.waitForRNBootstrap(reactNativeHostHolder);
-    }
-
-    private void initWSClient() {
-        wsClient = new WebSocketClient(this);
-        wsClient.connectToServer(detoxServerUrl, detoxSessionId);
-    }
-
-    private void initCrashHandler() {
-        new DetoxCrashHandler(wsClient).attach();
-    }
-
-    private void initANRListener() {
-        new DetoxANRHandler(wsClient).attach();
-    }
-
-    private void initActionHandlers() {
-        readyActionHandler = new ReadyActionHandler(wsClient, testEngineFacade);
-        actionHandlers.clear();
-        actionHandlers.put("isReady", readyActionHandler);
-        actionHandlers.put("reactNativeReload", new ReactNativeReloadActionHandler(reactNativeHostHolder, wsClient, testEngineFacade));
-        actionHandlers.put("currentStatus", new QueryStatusActionHandler(wsClient, testEngineFacade));
-        actionHandlers.put("invoke", new InvokeActionHandler(new MethodInvocation(), wsClient, errorParseFn));
-        actionHandlers.put("cleanup", new CleanupActionHandler(wsClient, testEngineFacade, new Function0<Unit>() {
-            @Override
-            public Unit invoke() {
-                stop();
-                return null;
-            }
-        }));
-
-        if (DetoxInstrumentsManager.supports()) {
-            final DetoxInstrumentsManager 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/full/java/com/wix/detox/LaunchArgs.java b/detox/android/detox/src/full/java/com/wix/detox/LaunchArgs.java
index d2c9328f0..78737cfdd 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/LaunchArgs.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/LaunchArgs.java
@@ -8,28 +8,38 @@ import java.util.List;
 
 import androidx.test.platform.app.InstrumentationRegistry;
 
-class LaunchArgs {
+public class LaunchArgs {
+    private static final String DETOX_SERVER_URL_ARG = "detoxServer";
+    private static final String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId";
     private static final String DETOX_NOTIFICATION_PATH_ARG = "detoxUserNotificationDataURL";
     private static final String DETOX_URL_OVERRIDE_ARG = "detoxURLOverride";
     private static final List<String> RESERVED_INSTRUMENTATION_ARGS = Arrays.asList("class", "package", "func", "unit", "size", "perf", "debug", "log", "emma", "coverageFile");
 
-    boolean hasNotificationPath() {
+    public boolean hasNotificationPath() {
         return InstrumentationRegistry.getArguments().containsKey(DETOX_NOTIFICATION_PATH_ARG);
     }
 
-    String getNotificationPath() {
+    public String getNotificationPath() {
         return InstrumentationRegistry.getArguments().getString(DETOX_NOTIFICATION_PATH_ARG);
     }
 
-    boolean hasUrlOverride() {
+    public boolean hasUrlOverride() {
         return InstrumentationRegistry.getArguments().containsKey(DETOX_URL_OVERRIDE_ARG);
     }
 
-    String getUrlOverride() {
+    public String getUrlOverride() {
         return InstrumentationRegistry.getArguments().getString(DETOX_URL_OVERRIDE_ARG);
     }
 
-    Bundle asIntentBundle() {
+    public String getDetoxServerUrl() {
+        return InstrumentationRegistry.getArguments().getString(DETOX_SERVER_URL_ARG);
+    }
+
+    public String getDetoxSessionId() {
+        return InstrumentationRegistry.getArguments().getString(DETOX_SESSION_ID_ARG_KEY);
+    }
+
+    public Bundle asIntentBundle() {
         final Bundle instrumentationArgs = InstrumentationRegistry.getArguments();
         final Bundle launchArgs = new Bundle();
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxActionHandlers.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt
similarity index 64%
rename from detox/android/detox/src/full/java/com/wix/detox/DetoxActionHandlers.kt
rename to detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt
index 588020545..414c0c837 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/DetoxActionHandlers.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt
@@ -1,14 +1,14 @@
-package com.wix.detox
+package com.wix.detox.adapters.server
 
 import android.content.Context
 import android.util.Log
 import androidx.test.espresso.IdlingResource
+import com.wix.detox.TestEngineFacade
 import com.wix.detox.common.extractRootCause
 import com.wix.detox.instruments.DetoxInstrumentsException
 import com.wix.detox.instruments.DetoxInstrumentsManager
+import com.wix.detox.reactnative.idlingresources.DescriptiveIdlingResource
 import com.wix.invoke.MethodInvocation
-import org.json.JSONArray
-import org.json.JSONException
 import org.json.JSONObject
 import java.lang.reflect.InvocationTargetException
 
@@ -19,33 +19,34 @@ interface DetoxActionHandler {
 }
 
 class ReadyActionHandler(
-        private val wsClient: WebSocketClient,
+        private val outboundServerAdapter: OutboundServerAdapter,
         private val testEngineFacade: TestEngineFacade)
     : DetoxActionHandler {
 
     override fun handle(params: String, messageId: Long) {
         testEngineFacade.awaitIdle()
-        wsClient.sendAction("ready", emptyMap<Any, Any>(), messageId)
+        outboundServerAdapter.sendMessage("ready", emptyMap(), messageId)
     }
 }
 
-class ReactNativeReloadActionHandler(
+open class ReactNativeReloadActionHandler(
         private val appContext: Context,
-        private val wsClient: WebSocketClient,
+        private val outboundServerAdapter: OutboundServerAdapter,
         private val testEngineFacade: TestEngineFacade)
     : DetoxActionHandler {
 
     override fun handle(params: String, messageId: Long) {
         testEngineFacade.syncIdle()
         testEngineFacade.reloadReactNative(appContext)
-        wsClient.sendAction("ready", emptyMap<Any, Any>(), messageId)
+        outboundServerAdapter.sendMessage("ready", emptyMap(), messageId)
     }
 }
 
-class InvokeActionHandler(
+
+class InvokeActionHandler @JvmOverloads constructor(
         private val methodInvocation: MethodInvocation,
-        private val wsClient: WebSocketClient,
-        private val errorParse: (e: Throwable?) -> String)
+        private val outboundServerAdapter: OutboundServerAdapter,
+        private val errorParse: (e: Throwable?) -> String = Log::getStackTraceString)
     : DetoxActionHandler {
 
     private val VIEW_HIERARCHY_TEXT = "View Hierarchy:"
@@ -53,14 +54,14 @@ class InvokeActionHandler(
     override fun handle(params: String, messageId: Long) {
         try {
             val invocationResult = methodInvocation.invoke(params)
-            wsClient.sendAction("invokeResult", mapOf<String, Any?>("result" to invocationResult), messageId)
+            outboundServerAdapter.sendMessage("invokeResult", mapOf<String, Any?>("result" to invocationResult), messageId)
         } catch (e: InvocationTargetException) {
             Log.i(LOG_TAG, "Test exception", e)
             val payload = extractFailurePayload(e)
-            wsClient.sendAction("testFailed", payload, messageId)
+            outboundServerAdapter.sendMessage("testFailed", payload, messageId)
         }  catch (e: Exception) {
             Log.e(LOG_TAG, "Exception", e)
-            wsClient.sendAction("error", mapOf<String, Any?>("error" to "${errorParse(e)}\nCheck device logs for full details!\n"), messageId)
+            outboundServerAdapter.sendMessage("error", mapOf<String, Any?>("error" to "${errorParse(e)}\nCheck device logs for full details!\n"), messageId)
         }
     }
 
@@ -78,7 +79,7 @@ class InvokeActionHandler(
 }
 
 class CleanupActionHandler(
-        private val wsClient: WebSocketClient,
+        private val outboundServerAdapter: OutboundServerAdapter,
         private val testEngineFacade: TestEngineFacade,
         private val doStopDetox: () -> Unit)
     : DetoxActionHandler {
@@ -89,37 +90,44 @@ class CleanupActionHandler(
         } else {
             testEngineFacade.resetReactNative()
         }
-        wsClient.sendAction("cleanupDone", emptyMap<Any, Any>(), messageId)
+        outboundServerAdapter.sendMessage("cleanupDone", emptyMap(), messageId)
     }
 }
 
 class QueryStatusActionHandler(
-        private val wsClient: WebSocketClient,
+        private val outboundServerAdapter: OutboundServerAdapter,
         private val testEngineFacade: TestEngineFacade)
     : DetoxActionHandler {
 
     override fun handle(params: String, messageId: Long) {
         val data = mutableMapOf<String, Any>()
         val busyResources = testEngineFacade.getBusyIdlingResources()
-        var status = ""
 
-        if (busyResources.isEmpty()) {
-            status = "The app is idle."
-        } else {
-            status = "Busy idling resources:\n"
-            for (res in busyResources) {
-                status += "\t- ${res.name}\n"
+        data["status"] = "App synchronization debug: " +
+            if (busyResources.isEmpty()) {
+                "The app appears to be idle!"
+            } else {
+                val summary = busyResources.joinToString("\n") { "\t - ${formatResource(it)}" }
+                "\nThe app is busy, due to: \n$summary"
             }
-        }
-
-        data["status"] = status
-        wsClient.sendAction("currentStatusResult", data, messageId)
+        outboundServerAdapter.sendMessage("currentStatusResult", data, messageId)
     }
+
+    private fun formatResource(resource: IdlingResource): String =
+            if (resource is DescriptiveIdlingResource) {
+                resource.getDescription()
+            } else if (resource.javaClass.name.contains("LooperIdlingResource") && resource.name.contains("mqt_js")) {
+                "Javascript code execution"
+            } else if (resource.javaClass.name.contains("LooperIdlingResource") && resource.name.contains("mqt_native")) {
+                "Javascript code execution (native)"
+            } else {
+                "Resource ${resource.name} being busy"
+            }
 }
 
 class InstrumentsRecordingStateActionHandler(
         private val instrumentsManager: DetoxInstrumentsManager,
-        private val wsClient: WebSocketClient
+        private val outboundServerAdapter: OutboundServerAdapter
 ) : DetoxActionHandler {
     companion object {
         const val DEFAULT_SAMPLING_INTERVAL = 250L
@@ -135,13 +143,13 @@ class InstrumentsRecordingStateActionHandler(
             instrumentsManager.stopRecording()
         }
 
-        wsClient.sendAction("setRecordingStateDone", emptyMap<String, Any>(), messageId)
+        outboundServerAdapter.sendMessage("setRecordingStateDone", emptyMap<String, Any>(), messageId)
     }
 }
 
 class InstrumentsEventsActionsHandler(
         private val instrumentsManager: DetoxInstrumentsManager,
-        private val wsClient: WebSocketClient
+        private val outboundServerAdapter: OutboundServerAdapter
 ) : DetoxActionHandler {
 
     override fun handle(params: String, messageId: Long) {
@@ -174,8 +182,10 @@ class InstrumentsEventsActionsHandler(
                 else -> throw DetoxInstrumentsException("Invalid action")
             }
         }
-
-        wsClient.sendAction("eventDone", emptyMap<String, Any>(), messageId)
+        outboundServerAdapter.sendMessage("eventDone", emptyMap<String, Any>(), messageId)
     }
+}
 
-}
\ No newline at end of file
+class ScarceActionHandler: DetoxActionHandler {
+    override fun handle(params: String, messageId: Long) {}
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt
new file mode 100644
index 000000000..ac82d2c3e
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt
@@ -0,0 +1,80 @@
+package com.wix.detox.adapters.server
+
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import java.util.concurrent.CountDownLatch
+
+private const val LOG_TAG = "DetoxDispatcher"
+
+class DetoxActionsDispatcher {
+    private val primaryExec = ActionsExecutor("detox.primary")
+    private val secondaryExec = ActionsExecutor("detox.secondary")
+
+    fun associateActionHandler(type: String, actionHandler: DetoxActionHandler, isPrimary: Boolean = true) {
+        val actionsExecutor = (if (isPrimary) primaryExec else secondaryExec)
+        actionsExecutor.associateHandler(type, actionHandler)
+    }
+
+    fun dispatchAction(type: String, params: String, messageId: Long) {
+        (primaryExec.executeAction(type, params, messageId) ||
+            secondaryExec.executeAction(type, params, messageId))
+                .let { handled ->
+                    if (!handled) Log.w(LOG_TAG, "No handler found for action '$type'")
+                }
+    }
+
+    fun teardown() {
+        primaryExec.teardown()
+        secondaryExec.teardown()
+    }
+
+    fun join() {
+        primaryExec.join()
+        secondaryExec.join()
+    }
+}
+
+private class ActionsExecutor(name: String) {
+    private val actionHandlers = mutableMapOf<String, DetoxActionHandler>()
+    private val thread: Thread
+    private lateinit var handler: Handler
+
+    init {
+        val latch = CountDownLatch(1)
+
+        thread = Thread(Runnable {
+            Looper.prepare()
+            handler = Handler(Looper.myLooper()!!)
+            latch.countDown()
+            Looper.loop()
+        }, name)
+        thread.start()
+        latch.await()
+    }
+
+    fun associateHandler(type: String, actionHandler: DetoxActionHandler) {
+        actionHandlers[type] = actionHandler
+    }
+
+    fun executeAction(type: String, params: String, messageId: Long): Boolean {
+        actionHandlers[type]?.let {
+            handler.post {
+                Log.i(LOG_TAG, "Handling action '$type' (ID #$messageId)...")
+                it.handle(params, messageId)
+                Log.i(LOG_TAG, "Done with action '$type'")
+            }
+            return true
+        }
+        return false
+    }
+
+    fun teardown() {
+        actionHandlers.clear()
+        handler.looper.quit()
+    }
+
+    fun join() {
+        thread.join()
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt
new file mode 100644
index 000000000..2d8b11fdc
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt
@@ -0,0 +1,44 @@
+package com.wix.detox.adapters.server
+
+import android.util.Log
+import com.wix.detox.common.DetoxLog
+
+interface OutboundServerAdapter {
+    fun sendMessage(type: String, payload: Map<String, Any?>, id: Long)
+}
+
+class DetoxServerAdapter(
+        private val actionsDispatcher: DetoxActionsDispatcher,
+        private val detoxServerInfo: DetoxServerInfo,
+        private val readyActionType: String,
+        private val terminationActionType: String)
+    : WebSocketClient.WSEventsHandler, OutboundServerAdapter {
+
+    private val wsClient = WebSocketClient(this)
+
+    fun connect() {
+        Log.i(DetoxLog.LOG_TAG, "Connecting to server...")
+        wsClient.connectToServer(detoxServerInfo.serverUrl, detoxServerInfo.sessionId)
+    }
+
+    fun teardown() {
+        wsClient.close()
+    }
+
+    override fun onConnect() {
+        Log.i(DetoxLog.LOG_TAG, "Connected to server!")
+        actionsDispatcher.dispatchAction(readyActionType, "", -1000L)
+    }
+
+    override fun onClosed() {
+        Log.i(DetoxLog.LOG_TAG, "Disconnected from server")
+        actionsDispatcher.dispatchAction(terminationActionType, "", 0)
+    }
+
+    override fun onAction(type: String, params: String, messageId: Long) {
+        actionsDispatcher.dispatchAction(type, params, messageId)
+    }
+
+    override fun sendMessage(type: String, payload: Map<String, Any?>, id: Long)
+            = wsClient.sendAction(type, payload, id)
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt
new file mode 100644
index 000000000..f2828de4e
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt
@@ -0,0 +1,15 @@
+package com.wix.detox.adapters.server
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.wix.detox.LaunchArgs
+
+private const val DEFAULT_URL = "ws://localhost:8099"
+
+class DetoxServerInfo internal constructor(launchArgs: LaunchArgs = LaunchArgs()) {
+    val serverUrl: String = launchArgs.detoxServerUrl ?: DEFAULT_URL
+    val sessionId: String = launchArgs.detoxSessionId ?: InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo.packageName
+
+    override fun toString(): String {
+        return "url=$serverUrl, sessionId=$sessionId"
+    }
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/WebSocketClient.java b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java
similarity index 78%
rename from detox/android/detox/src/full/java/com/wix/detox/WebSocketClient.java
rename to detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java
index 6e2794ce6..e58dbdf78 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/WebSocketClient.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java
@@ -1,4 +1,4 @@
-package com.wix.detox;
+package com.wix.detox.adapters.server;
 
 import android.util.Log;
 
@@ -18,6 +18,8 @@ import okio.ByteString;
 
 public class WebSocketClient {
 
+    private static final String LOG_TAG = "DetoxWSClient";
+
     private volatile boolean closing = false;
 
     public void close() {
@@ -26,19 +28,17 @@ public class WebSocketClient {
         websocket.close(NORMAL_CLOSURE_STATUS, null);
     }
 
-    private static final String LOG_TAG = "WebSocketClient";
-
     private String url;
     private String sessionId;
     private WebSocket websocket = null;
 
-    private final ActionHandler actionHandler;
-    private final WebSocketEventsHandler wsEventsHandler = new WebSocketEventsHandler();
+    private final WSEventsHandler wsEventsHandler;
+    private final WebSocketEventsListener wsEventListener = new WebSocketEventsListener();
 
     private static final int NORMAL_CLOSURE_STATUS = 1000;
 
-    public WebSocketClient(ActionHandler actionHandler) {
-        this.actionHandler = actionHandler;
+    public WebSocketClient(WSEventsHandler wsEventsHandler) {
+        this.wsEventsHandler = wsEventsHandler;
     }
 
     public void connectToServer(String url, String sessionId) {
@@ -54,13 +54,13 @@ public class WebSocketClient {
                 .build();
 
         final Request request = new Request.Builder().url(url).build();
-        this.websocket = client.newWebSocket(request, wsEventsHandler);
+        this.websocket = client.newWebSocket(request, wsEventListener);
 
         client.dispatcher().executorService().shutdown();
     }
 
     public void sendAction(String type, Map params, Long messageId) {
-        Log.i(LOG_TAG, "At sendAction");
+        Log.i(LOG_TAG, "Sending out action '" + type + "' (ID #" + messageId + ")");
 
         final Map<String, Object> data = new HashMap<>();
         data.put("type", type);
@@ -69,21 +69,19 @@ public class WebSocketClient {
 
         final JSONObject json = new JSONObject(data);
         websocket.send(json.toString());
-        Log.d(LOG_TAG, "Detox Action Sent: " + type);
     }
 
     private void receiveAction(String json) {
-        Log.i(LOG_TAG, "At receiveAction");
         try {
             final JSONObject object = new JSONObject(json);
             final String type = (String) object.get("type");
             final Object params = object.get("params");
             final long messageId = object.getLong("messageId");
 
-            Log.d(LOG_TAG, "Detox Action Received: " + type);
+            Log.d(LOG_TAG, "Received action '" + type + "' (ID #" + messageId + ", params=" + params + ")");
 
-            if (actionHandler != null) {
-                actionHandler.onAction(type, params.toString(), messageId);
+            if (wsEventsHandler != null) {
+                wsEventsHandler.onAction(type, params.toString(), messageId);
             }
         } catch (JSONException e) {
             Log.e(LOG_TAG, "Detox Error: receiveAction decode - " + e.toString());
@@ -94,22 +92,22 @@ public class WebSocketClient {
      * These methods are called on an inner worker thread.
      * @see <a href="https://medium.com/@jakewharton/listener-messages-are-called-on-a-background-thread-since-okhttp-is-agnostic-with-respect-to-5fdc5182e240">OkHTTP</a>
      */
-    public interface ActionHandler {
+    public interface WSEventsHandler {
         void onAction(String type, String params, long messageId);
         void onConnect();
         void onClosed();
     }
 
-    private class WebSocketEventsHandler extends WebSocketListener {
+    private class WebSocketEventsListener extends WebSocketListener {
         @Override
         public void onOpen(WebSocket webSocket, Response response) {
-            Log.i(LOG_TAG, "At onOpen");
+            Log.d(LOG_TAG, "At onOpen");
 
             Map<String, Object> params = new HashMap<>();
             params.put("sessionId", sessionId);
             params.put("role", "app");
             sendAction("login", params, 0L);
-            actionHandler.onConnect();
+            wsEventsHandler.onConnect();
         }
 
         @Override
@@ -123,13 +121,13 @@ public class WebSocketClient {
             } catch (InterruptedException e2) {
                 Log.d(LOG_TAG, "interrupted", e2);
             }
+
             Log.d(LOG_TAG, "Retrying...");
             connectToServer(url, sessionId);
         }
 
         @Override
         public void onMessage(WebSocket webSocket, String text) {
-            Log.i(LOG_TAG, "At onMessage");
             receiveAction(text);
         }
 
@@ -140,14 +138,12 @@ public class WebSocketClient {
 
         @Override
         public void onClosed(WebSocket webSocket, int code, String reason) {
-            Log.d(LOG_TAG, "Detox WS Closed: " + code + " " + reason);
             closing = true;
-            actionHandler.onClosed();
+            wsEventsHandler.onClosed();
         }
 
         @Override
         public void onClosing(WebSocket webSocket, int code, String reason) {
-            Log.i(LOG_TAG, "At onClosing");
             closing = true;
             websocket.close(NORMAL_CLOSURE_STATUS, null);
         }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java
index 4526967e3..aa65c6a2f 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java
@@ -4,26 +4,21 @@ import android.app.Activity;
 import android.content.Context;
 import android.content.ContextWrapper;
 import android.content.pm.ActivityInfo;
-import android.os.Handler;
 import android.util.Log;
 import android.view.View;
 import android.view.ViewGroup;
 
+import com.wix.detox.common.UIThread;
 import com.wix.detox.reactnative.ReactNativeExtension;
 import com.wix.detox.reactnative.idlingresources.NetworkIdlingResource;
 
 import org.hamcrest.Matcher;
-import org.joor.Reflect;
-import org.joor.ReflectException;
 
 import java.util.ArrayList;
 
-import androidx.test.espresso.Espresso;
-import androidx.test.espresso.IdlingResource;
 import androidx.test.espresso.UiController;
 import androidx.test.espresso.ViewAction;
 import androidx.test.espresso.ViewInteraction;
-import androidx.test.platform.app.InstrumentationRegistry;
 
 import static androidx.test.espresso.Espresso.onView;
 import static androidx.test.espresso.matcher.ViewMatchers.isRoot;
@@ -113,71 +108,12 @@ public class EspressoDetox {
     }
 
     public static void setURLBlacklist(final ArrayList<String> urls) {
-        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
+        UIThread.postSync(new Runnable() {
             @Override
             public void run() {
                 NetworkIdlingResource.setURLBlacklist(urls);
             }
         });
     }
-
-    public static ArrayList<IdlingResource> getBusyEspressoResources() {
-        // We do this in this complicated way for two reasons
-        // 1. we want to use postAtFrontOfQueue()
-        // 2. we want it to be synchronous
-        final ArrayList<IdlingResource> busyResources = new ArrayList<>();
-        final Handler handler = new Handler(InstrumentationRegistry.getInstrumentation().getTargetContext().getMainLooper());
-        final SyncRunnable sr = new SyncRunnable(new Runnable() {
-            @Override
-            public void run() {
-                // The following snippet works only in Espresso 3.0
-                try {
-                    ArrayList<Object> idlingStates = Reflect.on(Espresso.class)
-                            .field("baseRegistry")
-                            .field("idlingStates")
-                            .get();
-                    for (int i = 0; i < idlingStates.size(); ++i) {
-                        if (!(boolean)Reflect.on(idlingStates.get(i)).field("idle").get()) {
-                            busyResources.add((IdlingResource)Reflect.on(idlingStates.get(i)).field("resource").get());
-                        }
-                    }
-                } catch (ReflectException e) {
-                    Log.d(LOG_TAG, "Couldn't get busy resources", e);
-                }
-            }
-        });
-        handler.postAtFrontOfQueue(sr);
-        sr.waitForComplete();
-        return busyResources;
-    }
-
-
-    private static final class SyncRunnable implements Runnable {
-        private final Runnable mTarget;
-        private boolean mComplete;
-
-        public SyncRunnable(Runnable target) {
-            mTarget = target;
-        }
-
-        public void run() {
-            mTarget.run();
-            synchronized (this) {
-                mComplete = true;
-                notifyAll();
-            }
-        }
-
-        public void waitForComplete() {
-            synchronized (this) {
-                while (!mComplete) {
-                    try {
-                        wait();
-                    } catch (InterruptedException e) {
-                    }
-                }
-            }
-        }
-    }
 }
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java
index 06eb3efb4..775a6b6f8 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java
@@ -6,6 +6,7 @@ import android.util.DisplayMetrics;
 import android.util.Log;
 import android.view.Choreographer;
 
+import com.wix.detox.common.UIThread;
 import com.wix.detox.espresso.common.utils.UiControllerUtils;
 
 import org.joor.Reflect;
@@ -33,7 +34,7 @@ public class UiAutomatorHelper {
         // I want to invoke Espresso's sync mechanism manually.
         // This turned out to be amazingly difficult. This below is the
         // nicest solution I could come up with.
-        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
+        UIThread.runSync(new Runnable() {
             @Override
             public void run() {
                 try {
@@ -54,7 +55,7 @@ public class UiAutomatorHelper {
         // I want to invoke Espresso's sync mechanism manually.
         // This turned out to be amazingly difficult. This below is the
         // nicest solution I could come up with.
-        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
+        UIThread.runSync(new Runnable() {
             @Override
             public void run() {
                 try {
diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt
new file mode 100644
index 000000000..ccefdca9e
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/registry/IRStatusInquirer.kt
@@ -0,0 +1,24 @@
+package com.wix.detox.espresso.registry
+
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.IdlingResource
+import androidx.test.espresso.base.IdlingResourceRegistry
+import com.wix.detox.common.UIThread
+import org.joor.Reflect
+import java.util.concurrent.Callable
+
+class IRStatusInquirer(private val registry: IdlingResourceRegistry) {
+    fun getAllBusyResources(): List<IdlingResource> {
+        return UIThread.postFirstSync(Callable<List<IdlingResource>> {
+            registry.resources.filter { resource ->
+                !resource.isIdleNow
+            }
+        })
+    }
+
+    companion object {
+        val INSTANCE = IRStatusInquirer(getRegistryDefault())
+    }
+}
+
+private fun getRegistryDefault() = Reflect.on(Espresso::class.java).get<IdlingResourceRegistry>("baseRegistry")
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
index 2c9eae9a0..6cc8b3bfe 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
@@ -46,6 +46,7 @@ private class MQThreadReflected(private val queue: Any?, private val queueName:
 class ReactNativeIdlingResources constructor(
         private val reactContext: ReactContext,
         internal var networkSyncEnabled: Boolean = true)
+
     {
 
     companion object {
@@ -63,7 +64,7 @@ class ReactNativeIdlingResources constructor(
     private var networkIdlingResource: NetworkIdlingResource? = null
 
     fun registerAll() {
-        Log.i(LOG_TAG, "Setting up Espresso Idling Resources for React Native.")
+        Log.i(LOG_TAG, "Setting up Espresso Idling Resources for React Native")
 
         unregisterAll()
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/helpers/RNHelpers.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/helpers/RNHelpers.kt
index 636fe35ac..f3874c0ca 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/helpers/RNHelpers.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/helpers/RNHelpers.kt
@@ -14,11 +14,11 @@ object RNHelpers {
             if (reactContext.hasNativeModule(moduleClass)) {
                 reactContext.getNativeModule(moduleClass)
             } else {
-                Log.d(LOG_TAG, "Native module not resolved: no registered module")
+                Log.d(LOG_TAG, "Native RN module resolution (class $className): no registered module found")
                 null
             }
-        } catch (ex: Exception) {
-            Log.d(LOG_TAG, "Native module not resolved: no such class")
+        } catch (ex: ClassNotFoundException) {
+            Log.d(LOG_TAG, "Native RN module resolution (class $className): no such class")
             null
         }
 }
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
index bceb31fe5..d683a8703 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java
@@ -5,11 +5,11 @@ import android.view.Choreographer;
 
 import com.wix.detox.reactnative.ReactNativeInfo;
 
+import org.jetbrains.annotations.NotNull;
 import org.joor.Reflect;
 import org.joor.ReflectException;
 
 import androidx.annotation.NonNull;
-import androidx.test.espresso.IdlingResource;
 
 /**
  * Created by simonracz on 25/08/2017.
@@ -29,7 +29,7 @@ import androidx.test.espresso.IdlingResource;
  *
  * @see <a href="https://github.com/facebook/react-native/blob/259eac8c30b536abddab7925f4c51f0bf7ced58d/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java#L143">AnimatedModule</a>
  */
-public class AnimatedModuleIdlingResource implements IdlingResource, Choreographer.FrameCallback {
+public class AnimatedModuleIdlingResource implements DescriptiveIdlingResource, Choreographer.FrameCallback {
     private static final String LOG_TAG = "Detox";
 
     private final static String CLASS_ANIMATED_MODULE = "com.facebook.react.animated.NativeAnimatedModule";
@@ -63,6 +63,12 @@ public class AnimatedModuleIdlingResource implements IdlingResource, Choreograph
         return AnimatedModuleIdlingResource.class.getName();
     }
 
+    @NotNull
+    @Override
+    public String getDescription() {
+        return "Animations running on screen";
+    }
+
     @Override
     public boolean isIdleNow() {
         Class<?> animModuleClass = null;
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
index eb6fc350f..a3c9750ae 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AsyncStorageIdlingResource.kt
@@ -28,7 +28,7 @@ open class AsyncStorageIdlingResource
     @JvmOverloads constructor(
         module: NativeModule,
         sexecutorReflectedGenFn: SExecutorReflectedGenFnType = defaultSExecutorReflectedGenFn)
-    : IdlingResource {
+    : DescriptiveIdlingResource {
 
     open val logTag: String
         get() = LOG_TAG
@@ -50,6 +50,7 @@ open class AsyncStorageIdlingResource
     }
 
     override fun getName(): String = javaClass.name
+    override fun getDescription() = "Disk I/O activity"
     override fun isIdleNow(): Boolean =
         checkIdle().also { idle ->
             if (!idle) {
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
index a68fe834a..b3d90a279 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/BridgeIdlingResource.java
@@ -5,6 +5,8 @@ import android.util.Log;
 import com.facebook.react.bridge.NotThreadSafeBridgeIdleDebugListener;
 import com.facebook.react.bridge.ReactContext;
 
+import org.jetbrains.annotations.NotNull;
+
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -38,6 +40,12 @@ public class BridgeIdlingResource extends DetoxBaseIdlingResource implements Not
         return BridgeIdlingResource.class.getName();
     }
 
+    @NotNull
+    @Override
+    public String getDescription() {
+        return "Activity on the React-Native bridge";
+    }
+
     @Override
     protected boolean checkIdle() {
         boolean ret = idleNow.get();
@@ -64,6 +72,7 @@ public class BridgeIdlingResource extends DetoxBaseIdlingResource implements Not
         // Log.i(LOG_TAG, "JS Bridge transitions to busy.");
     }
 
+    @Override
     public void onBridgeDestroyed() {
     }
 
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt
new file mode 100644
index 000000000..562f85fd6
--- /dev/null
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DescriptiveIdlingResource.kt
@@ -0,0 +1,7 @@
+package com.wix.detox.reactnative.idlingresources
+
+import androidx.test.espresso.IdlingResource
+
+interface DescriptiveIdlingResource: IdlingResource {
+    fun getDescription(): String
+}
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
index 92a2ad6fb..168f7c5ff 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/DetoxBaseIdlingResource.java
@@ -2,9 +2,7 @@ package com.wix.detox.reactnative.idlingresources;
 
 import java.util.concurrent.atomic.AtomicBoolean;
 
-import androidx.test.espresso.IdlingResource;
-
-public abstract class DetoxBaseIdlingResource implements IdlingResource {
+public abstract class DetoxBaseIdlingResource implements DescriptiveIdlingResource {
     AtomicBoolean paused = new AtomicBoolean(false);
 
     public void pause() {
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
index 1655c6eba..856a2d871 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/NetworkIdlingResource.java
@@ -5,6 +5,8 @@ import android.view.Choreographer;
 
 import com.facebook.react.bridge.ReactContext;
 
+import org.jetbrains.annotations.NotNull;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Pattern;
@@ -64,6 +66,12 @@ public class NetworkIdlingResource extends DetoxBaseIdlingResource implements Ch
         return NetworkIdlingResource.class.getName();
     }
 
+    @NotNull
+    @Override
+    public String getDescription() {
+        return "In-flight network activity";
+    }
+
     @Override
     protected boolean checkIdle() {
         boolean idle = true;
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/UIModuleIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/UIModuleIdlingResource.java
index 90bebf4d2..240792657 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/UIModuleIdlingResource.java
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/UIModuleIdlingResource.java
@@ -3,6 +3,7 @@ package com.wix.detox.reactnative.idlingresources;
 import android.util.Log;
 import android.view.Choreographer;
 
+import org.jetbrains.annotations.NotNull;
 import org.joor.Reflect;
 import org.joor.ReflectException;
 
@@ -50,6 +51,12 @@ public class UIModuleIdlingResource extends DetoxBaseIdlingResource implements C
         return UIModuleIdlingResource.class.getName();
     }
 
+    @NotNull
+    @Override
+    public String getDescription() {
+        return "UI rendering activity";
+    }
+
     @Override
     protected boolean checkIdle() {
         Class<?> uiModuleClass;
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
index 475540494..8997caca3 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
@@ -2,20 +2,25 @@ package com.wix.detox.reactnative.idlingresources.timers
 
 import android.util.Log
 import com.facebook.react.bridge.ReactContext
+import com.wix.detox.common.UIThread
+import java.util.concurrent.Callable
 
 interface IdleInterrogationStrategy {
     fun isIdleNow(): Boolean
 }
 
-fun getInterrogationStrategy(reactContext: ReactContext): IdleInterrogationStrategy? {
-    DelegatedIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
-        return it
-    }
+fun getInterrogationStrategy(reactContext: ReactContext): IdleInterrogationStrategy? =
+    // Getting a native-module (inside) also initializes it if needed. That has to run on a
+    // looper thread, and the easiest to make sure that happens is to use the main thread.
+    UIThread.runSync(Callable {
+        DelegatedIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+            return@Callable it
+        }
 
-    DefaultIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
-        return it
-    }
+        DefaultIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+            return@Callable it
+        }
 
-    Log.e(TimersIdlingResource.LOG_TAG, "Failed to determine proper implementation-strategy for timers idling resource")
-    return null
-}
+        Log.e(TimersIdlingResource.LOG_TAG, "Failed to determine proper implementation-strategy for timers idling resource")
+        null
+    })
diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
index be1fdeb6c..70e751085 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
@@ -12,6 +12,7 @@ class TimersIdlingResource @JvmOverloads constructor(
     private var callback: IdlingResource.ResourceCallback? = null
 
     override fun getName(): String = this.javaClass.name
+    override fun getDescription(): String = "Enqueued timers"
 
     override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
         this.callback = callback
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/UIThread.kt b/detox/android/detox/src/main/java/com/wix/detox/common/UIThread.kt
new file mode 100644
index 000000000..f92c70496
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/UIThread.kt
@@ -0,0 +1,69 @@
+package com.wix.detox.common
+
+import android.os.Handler
+import android.os.Looper
+import java.util.concurrent.Callable
+import java.util.concurrent.FutureTask
+
+object UIThread {
+    private val handler = Handler(Looper.getMainLooper())
+
+    @JvmStatic
+    fun post(runnable: Runnable) {
+        handler.post(runnable)
+    }
+
+    @JvmStatic
+    fun postSync(runnable: Runnable) =
+        postSync(Callable<Any?> {
+            runnable.run()
+            null
+        })
+
+    @JvmStatic
+    fun <T> postSync(callable: Callable<T>): T =
+        FutureTask<T>(callable)
+            .also {
+                handler.post(it)
+            }.get()
+
+    @JvmStatic
+    fun runSync(runnable: Runnable) {
+        if (isOnUIThread()) {
+            runnable.run()
+        } else {
+            postSync(runnable)
+        }
+    }
+
+    @JvmStatic
+    fun <T> runSync(callable: Callable<T>): T {
+        if (isOnUIThread()) {
+            return callable.call()
+        }
+        return postSync(callable)
+    }
+
+    @JvmStatic
+    fun postFirst(runnable: Runnable) {
+        handler.postAtFrontOfQueue(runnable)
+    }
+
+    @JvmStatic
+    fun postFirstSync(runnable: Runnable) {
+        postFirstSync(Callable<Any?> {
+            runnable.run()
+            null
+        })
+    }
+
+    @JvmStatic
+    fun <T> postFirstSync(callable: Callable<T>): T =
+        FutureTask<T>(callable)
+            .also {
+                handler.postAtFrontOfQueue(it)
+            }.get()
+
+    private fun isOnUIThread() =
+        (Looper.myLooper() == Looper.getMainLooper())
+}
diff --git a/detox/package.json b/detox/package.json
index d002be130..f73dfa109 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "18.11.1",
+  "version": "18.12.0",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -65,7 +65,7 @@
     "telnet-client": "1.2.8",
     "tempfile": "^2.0.0",
     "which": "^1.3.1",
-    "ws": "^7.4.3",
+    "ws": "^7.0.0",
     "yargs": "^16.0.3",
     "yargs-unparser": "^2.0.0"
   },
diff --git a/detox/src/android/espressoapi/EspressoDetox.js b/detox/src/android/espressoapi/EspressoDetox.js
index 256ef4fd5..05ffa4791 100644
--- a/detox/src/android/espressoapi/EspressoDetox.js
+++ b/detox/src/android/espressoapi/EspressoDetox.js
@@ -66,33 +66,6 @@ class EspressoDetox {
     };
   }
 
-  static getBusyEspressoResources() {
-    return {
-      target: {
-        type: "Class",
-        value: "com.wix.detox.espresso.EspressoDetox"
-      },
-      method: "getBusyEspressoResources",
-      args: []
-    };
-  }
-
-  static run(element) {
-    return {
-      target: element,
-      method: "run",
-      args: []
-    };
-  }
-
-  static waitForComplete(element) {
-    return {
-      target: element,
-      method: "waitForComplete",
-      args: []
-    };
-  }
-
 }
 
 module.exports = EspressoDetox;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/UiDeviceProxy.js b/detox/src/android/espressoapi/UiDeviceProxy.js
index 139adf9f9..ec336935a 100644
--- a/detox/src/android/espressoapi/UiDeviceProxy.js
+++ b/detox/src/android/espressoapi/UiDeviceProxy.js
@@ -3,7 +3,6 @@ const uiAutomator = require('./UIAutomator');
 const invoke = require('../../invoke');
 
 class UiDeviceProxy {
-
   constructor(invocationManager) {
     this.invocationManager = invocationManager;
     this.getUIDevice = this.getUIDevice.bind(this);
@@ -16,7 +15,7 @@ class UiDeviceProxy {
           return async (...params) => {
             const call = target[prop](invoke.callDirectly(uiAutomator.uiDevice()), ...params);
             const invokeResult = await this.invocationManager.execute(call);
-            if (invokeResult && invokeResult && invokeResult.result) {
+            if (invokeResult && invokeResult.result) {
                 return invokeResult.result;
             }
           }
diff --git a/detox/src/server/DetoxConnection.js b/detox/src/server/DetoxConnection.js
index e503171af..9323e01e5 100644
--- a/detox/src/server/DetoxConnection.js
+++ b/detox/src/server/DetoxConnection.js
@@ -5,10 +5,11 @@ const AnonymousConnectionHandler = require('./handlers/AnonymousConnectionHandle
 const logger = require('../utils/logger').child({ __filename });
 
 const EVENTS = {
-  GET: { event: 'GET_FROM' },
-  SEND: { event: 'SEND_TO' },
+  NEW: { event: 'WSS_CONNECTION' },
+  GET: { event: 'WSS_GET_FROM' },
+  SEND: { event: 'WSS_SEND_TO' },
   ERROR: { event: 'ERROR' },
-  SOCKET_ERROR: { event: 'SOCKET_ERROR' },
+  SOCKET_ERROR: { event: 'WSS_SOCKET_ERROR' },
 };
 
 class DetoxConnection {
@@ -28,6 +29,7 @@ class DetoxConnection {
     this._webSocket.on('message', this._onMessage);
     this._webSocket.on('error', this._onError);
     this._webSocket.on('close', this._onClose);
+    this._log.debug(EVENTS.NEW, 'registered a new connection.');
 
     const log = this._log;
     this._handler = new AnonymousConnectionHandler({
diff --git a/detox/src/server/DetoxServer.js b/detox/src/server/DetoxServer.js
index f138ae0b1..0f7d3da96 100644
--- a/detox/src/server/DetoxServer.js
+++ b/detox/src/server/DetoxServer.js
@@ -29,13 +29,13 @@ class DetoxServer {
     await this._startListening();
 
     const level = this._options.standalone ? 'info' : 'debug';
-    log[level](`Detox server listening on localhost:${this._options.port}...`);
+    log[level]({ event: 'WSS_CREATE' }, `Detox server listening on localhost:${this._options.port}...`);
   }
 
   async close() {
     try {
       await this._closeWithTimeout(10000);
-      log.debug({ event: 'SERVER_CLOSE' }, 'Detox server has been closed gracefully');
+      log.debug({ event: 'WSS_CLOSE' }, 'Detox server has been closed gracefully');
     } catch (e) {
       log.warn({ event: 'ERROR' },
         `Detox server has been closed abruptly! See the error details below:\n`
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index d5433c793..c6803d108 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -72,7 +72,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | -o, --runner-config \<config\>                | Test runner config file, defaults to 'e2e/mocha.opts' for mocha and 'e2e/config.json' for jest. |
 | -n, --device-name [name]                      | Override the device name specified in a configuration. Useful for running a single build configuration on multiple devices. |
 | -l, --loglevel [value]                        | Log level: fatal, error, warn, info, verbose, trace |
-| -d, --debug-synchronization \<value\>         | [iOS Only] Customize how long an action/expectation can take to complete before Detox starts querying the app why it is busy. By default, the app status will be printed if the action takes more than 10s to complete. |
+| -d, --debug-synchronization \<value\>         | Customize how long an action/expectation can take to complete before Detox starts querying the app why it is busy. By default, the app status will be printed if the action takes more than 10s to complete. |
 | -a, --artifacts-location \<path\>             | Artifacts (logs, screenshots, etc) root directory.<sup>[[2]](#notice-artifacts)</sup> |
 | --record-logs [failing/all/none]              | Save logs during each test to artifacts directory. Pass "failing" to save logs of failing tests only. The default value is **none**. |
 | --take-screenshots [manual/failing/all/none]  | Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only. The default value is **manual**. |
diff --git a/docs/Guide.Mocking.md b/docs/Guide.Mocking.md
index 86fae396d..8da533502 100644
--- a/docs/Guide.Mocking.md
+++ b/docs/Guide.Mocking.md
@@ -23,7 +23,11 @@ This replacement mechanism provides a lot of flexibility to change implementatio
 
 #### Configuration
 0. For RN < 0.55, setup `react-native-repackager` in your library.
-1. For case 0.55 <= RN < 0.59 create a file called `rn-cli.config.js` in the root folder. If you use RN >= 0.59 (which in turn uses Metro with breaking changes introduced in 0.43 - https://github.com/facebook/metro/releases/tag/v0.43.0) file should have name `metro.config.js` or `metro.config.json` (or define metro field in `package.json`) to root dir. Then set up `resolver.sourceExts` to prioritize any given source extension over the default one:
+1. Configure the metro bundler to use the extensions defined by `RN_SRC_EXT`:
+   - If you use 0.55 <= RN < 0.59, create a file called `rn-cli.config.js` in the root folder. 
+   - If you use RN >= 0.59 (which in turn uses Metro with breaking changes introduced in [0.43](https://github.com/facebook/metro/releases/tag/v0.43.0)) the file should be named `metro.config.js` or `metro.config.json` (or define metro field in `package.json`) to root dir. 
+   
+   Then set up `resolver.sourceExts` to prioritize any given source extension over the default one:
 
     ```js
     const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index 796bfe966..e684a9d65 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": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index eaff588c2..96ae888ae 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": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "devDependencies": {
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "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 d56bc3e22..810d4802f 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": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 65643d44a..a6cfc11b2 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": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -10,7 +10,7 @@
   },
   "devDependencies": {
     "@types/jest": "^26.0.19",
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
     "sanitize-filename": "^1.6.1",
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index e2ebe7d20..8eeb981a6 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -25,7 +25,7 @@
   },
   "devDependencies": {
     "@types/mocha": "^8.2.0",
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "mocha": "^6.1.3",
     "ts-node": "^9.1.1",
     "typescript": "^4.1.3"
diff --git a/lerna.json b/lerna.json
index c41bf3435..58130995c 100644
--- a/lerna.json
+++ b/lerna.json
@@ -13,7 +13,7 @@
     "generation",
     "."
   ],
-  "version": "18.11.1",
+  "version": "18.12.0",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index 885f8425d..7f919a5ef 100644
--- a/package.json
+++ b/package.json
@@ -19,5 +19,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "18.11.1"
+  "version": "18.12.0"
 }


Test patch:
diff --git a/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
index 16a40833f..ff648046b 100644
--- a/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
+++ b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt
@@ -3,10 +3,9 @@ package com.wix.detox
 import android.content.Context
 import android.util.Log
 import androidx.test.espresso.Espresso
-import androidx.test.espresso.IdlingResource
 import com.wix.detox.common.DetoxLog.Companion.LOG_TAG
-import com.wix.detox.espresso.EspressoDetox
 import com.wix.detox.espresso.UiAutomatorHelper
+import com.wix.detox.espresso.registry.IRStatusInquirer
 import com.wix.detox.reactnative.ReactNativeExtension
 
 class TestEngineFacade {
@@ -15,7 +14,9 @@ class TestEngineFacade {
         null
     }
     fun syncIdle() = UiAutomatorHelper.espressoSync() // TODO Check whether this can be replaced with #awaitIdle()
-    fun getBusyIdlingResources() = EspressoDetox.getBusyEspressoResources() as List<IdlingResource>
+    fun getBusyIdlingResources() = IRStatusInquirer.INSTANCE.getAllBusyResources()
+
+    // TODO Refactor RN related stuff away
     fun reloadReactNative(appContext: Context) = ReactNativeExtension.reloadReactNative(appContext)
     fun resetReactNative() = ReactNativeExtension.clearAllSynchronization()
 }
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/DetoxActionHandlersSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/DetoxActionHandlersSpec.kt
similarity index 84%
rename from detox/android/detox/src/testFull/java/com/wix/detox/DetoxActionHandlersSpec.kt
rename to detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/DetoxActionHandlersSpec.kt
index 3b2635f77..7dcb893d3 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/DetoxActionHandlersSpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/DetoxActionHandlersSpec.kt
@@ -1,7 +1,8 @@
-package com.wix.detox
+package com.wix.detox.adapters.server
 
 import android.content.Context
 import com.nhaarman.mockitokotlin2.*
+import com.wix.detox.TestEngineFacade
 import com.wix.detox.UTHelpers.yieldToOtherThreads
 import com.wix.detox.instruments.DetoxInstrumentsException
 import com.wix.detox.instruments.DetoxInstrumentsManager
@@ -22,12 +23,12 @@ object DetoxActionHandlersSpec : Spek({
         val messageId = 666L
 
         lateinit var appContext: Context
-        lateinit var wsClient: WebSocketClient
+        lateinit var outboundServerAdapter: OutboundServerAdapter
         lateinit var testEngineFacade: TestEngineFacade
 
         beforeEachTest {
             appContext = mock()
-            wsClient = mock()
+            outboundServerAdapter = mock()
 
             testEngineFacade = mock()
             whenever(testEngineFacade.awaitIdle()).then {
@@ -39,11 +40,11 @@ object DetoxActionHandlersSpec : Spek({
         }
 
         describe("Ready action") {
-            fun uut() = ReadyActionHandler(wsClient, testEngineFacade)
+            fun uut() = ReadyActionHandler(outboundServerAdapter, testEngineFacade)
 
             it("should reply with a 'ready' ACK if ready") {
                 uut().handle(params, messageId)
-                verify(wsClient).sendAction(eq("ready"), eq(Collections.emptyMap<Any, Any>()), eq(messageId))
+                verify(outboundServerAdapter).sendMessage(eq("ready"), eq(Collections.emptyMap<String, Any>()), eq(messageId))
             }
 
             it("should block waiting for idle before ACK-ing") {
@@ -55,15 +56,15 @@ object DetoxActionHandlersSpec : Spek({
                     }
                     yieldToOtherThreads(executor)
                     verify(testEngineFacade).awaitIdle()
-                    verify(wsClient, never()).sendAction(any(), any(), any())
+                    verify(outboundServerAdapter, never()).sendMessage(any(), any(), any())
                 }
                 yieldToOtherThreads(executor)
-                verify(wsClient, times(1)).sendAction(any(), any(), any())
+                verify(outboundServerAdapter, times(1)).sendMessage(any(), any(), any())
             }
         }
 
         describe("React-native reload action") {
-            fun uut() = ReactNativeReloadActionHandler(appContext, wsClient, testEngineFacade)
+            fun uut() = ReactNativeReloadActionHandler(appContext, outboundServerAdapter, testEngineFacade)
 
             it("should reload the app") {
                 uut().handle(params, messageId)
@@ -72,7 +73,7 @@ object DetoxActionHandlersSpec : Spek({
 
             it("should reply with a 'ready' ACK when ready") {
                 uut().handle(params, messageId)
-                verify(wsClient).sendAction(eq("ready"), eq(emptyMap<Any, Any>()), eq(messageId))
+                verify(outboundServerAdapter).sendMessage(eq("ready"), eq(emptyMap<String, Any>()), eq(messageId))
             }
 
             it("should sync before ACK-ing") {
@@ -85,18 +86,18 @@ object DetoxActionHandlersSpec : Spek({
                     yieldToOtherThreads(executor)
                     verify(testEngineFacade).syncIdle()
                     verify(testEngineFacade, never()).reloadReactNative(any())
-                    verify(wsClient, never()).sendAction(any(), any(), any())
+                    verify(outboundServerAdapter, never()).sendMessage(any(), any(), any())
                 }
                 yieldToOtherThreads(executor)
                 verify(testEngineFacade, times(1)).reloadReactNative(eq(appContext))
-                verify(wsClient, times(1)).sendAction(any(), any(), any())
+                verify(outboundServerAdapter, times(1)).sendMessage(any(), any(), any())
             }
         }
 
         describe("Invoke actions") {
             lateinit var methodInvocationMock: MethodInvocation
 
-            fun uut() = InvokeActionHandler(methodInvocationMock, wsClient, mockErrorParser)
+            fun uut() = InvokeActionHandler(methodInvocationMock, outboundServerAdapter, mockErrorParser)
 
             beforeEachTest {
                 methodInvocationMock = mock()
@@ -109,13 +110,13 @@ object DetoxActionHandlersSpec : Spek({
 
             it("should reply with an 'invokeResult' ACK") {
                 uut().handle(params, messageId)
-                verify(wsClient).sendAction(eq("invokeResult"), any(), eq(messageId))
+                verify(outboundServerAdapter).sendMessage(eq("invokeResult"), any(), eq(messageId))
             }
 
             it("should reply with empty result data") {
                 uut().handle(params, messageId)
 
-                verify(wsClient).sendAction(any(), argThat { size == 1 && this["result"] == null }, any())
+                verify(outboundServerAdapter).sendMessage(any(), argThat { size == 1 && this["result"] == null }, any())
             }
 
             it("should reply with actual result data") {
@@ -123,7 +124,7 @@ object DetoxActionHandlersSpec : Spek({
                 whenever(methodInvocationMock.invoke(isA<String>())).thenReturn(someResult)
                 uut().handle(params, messageId)
 
-                verify(wsClient).sendAction(any(), argThat { size == 1 && this["result"] == someResult }, any())
+                verify(outboundServerAdapter).sendMessage(any(), argThat { size == 1 && this["result"] == someResult }, any())
             }
 
             it("should handle runtime errors") {
@@ -132,7 +133,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 uut().handle(params, messageId)
 
-                verify(wsClient).sendAction(
+                verify(outboundServerAdapter).sendMessage(
                         eq("error"),
                         argThat { size == 1 && this["error"] == "mockErrorParser(${exception})\nCheck device logs for full details!\n" },
                         eq(messageId))
@@ -145,7 +146,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 uut().handle(params, messageId)
 
-                verify(wsClient).sendAction(
+                verify(outboundServerAdapter).sendMessage(
                         eq("testFailed"),
                         argThat {
                             this["details"] == "before\n" &&
@@ -163,7 +164,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 uut().handle(params, messageId)
 
-                verify(wsClient).sendAction(
+                verify(outboundServerAdapter).sendMessage(
                         eq("testFailed"),
                         argThat {
                             this["details"] == "root-exception-mock" &&
@@ -177,7 +178,7 @@ object DetoxActionHandlersSpec : Spek({
         describe("InstrumentsRecording recording state actions") {
             lateinit var instrumentsManager: DetoxInstrumentsManager
 
-            fun uut() = InstrumentsRecordingStateActionHandler(instrumentsManager, wsClient)
+            fun uut() = InstrumentsRecordingStateActionHandler(instrumentsManager, outboundServerAdapter)
 
             beforeEachTest {
                 instrumentsManager = mock()
@@ -204,7 +205,7 @@ object DetoxActionHandlersSpec : Spek({
 
             it("should reply with a 'done' ACK on set state finish") {
                 uut().handle(params, messageId)
-                verify(wsClient).sendAction(eq("setRecordingStateDone"), any(), eq(messageId))
+                verify(outboundServerAdapter).sendMessage(eq("setRecordingStateDone"), any(), eq(messageId))
             }
         }
 
@@ -216,7 +217,7 @@ object DetoxActionHandlersSpec : Spek({
             val mockAdditionalInfo = "MockAdditionalInfo"
             val mockStatus = "MockStatus"
 
-            fun uut() = InstrumentsEventsActionsHandler(instrumentsManager, wsClient)
+            fun uut() = InstrumentsEventsActionsHandler(instrumentsManager, outboundServerAdapter)
 
             beforeEachTest {
                 instrumentsManager = mock()
@@ -243,7 +244,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 it("should reply with a 'done' ACK") {
                     uut().handle(json, messageId)
-                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                    verify(outboundServerAdapter).sendMessage(eq("eventDone"), any(), eq(messageId))
                 }
             }
 
@@ -266,7 +267,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 it("should reply with a 'done' ACK") {
                     uut().handle(json, messageId)
-                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                    verify(outboundServerAdapter).sendMessage(eq("eventDone"), any(), eq(messageId))
                 }
             }
 
@@ -293,7 +294,7 @@ object DetoxActionHandlersSpec : Spek({
 
                 it("should reply with a 'done' ACK") {
                     uut().handle(json, messageId)
-                    verify(wsClient).sendAction(eq("eventDone"), any(), eq(messageId))
+                    verify(outboundServerAdapter).sendMessage(eq("eventDone"), any(), eq(messageId))
                 }
             }
 
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/common/proxy/MethodsSpySpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/common/proxy/MethodsSpySpec.kt
index b9a82e2af..38df3e503 100644
--- a/detox/android/detox/src/testFull/java/com/wix/detox/common/proxy/MethodsSpySpec.kt
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/common/proxy/MethodsSpySpec.kt
@@ -5,7 +5,7 @@ import org.spekframework.spek2.Spek
 import org.spekframework.spek2.style.specification.describe
 import java.util.*
 
-class MethodsSpySpec: Spek({
+object MethodsSpySpec: Spek({
     describe("Method-calls spy") {
 
         val defaultMethodName = "mockMethod"
diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt
new file mode 100644
index 000000000..49ec542f0
--- /dev/null
+++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/registry/IRStatusInquirerTest.kt
@@ -0,0 +1,87 @@
+package com.wix.detox.espresso.registry
+
+import android.os.Looper
+import androidx.test.espresso.IdlingResource
+import androidx.test.espresso.base.IdlingResourceRegistry
+import com.nhaarman.mockitokotlin2.doAnswer
+import com.nhaarman.mockitokotlin2.doReturn
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
+import com.wix.detox.UTHelpers
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import java.util.concurrent.*
+
+@RunWith(RobolectricTestRunner::class)
+class IRStatusInquirerTest {
+
+    lateinit var registry: IdlingResourceRegistry
+    lateinit var uut: IRStatusInquirer
+
+    private fun anIdleResource() = mock<IdlingResource> {
+        on { isIdleNow } doReturn true
+    }
+
+    private fun aBusyResource() = mock<IdlingResource> {
+        on { isIdleNow } doReturn false
+    }
+
+    private fun givenIdlingResources(vararg resources: IdlingResource) {
+        whenever(registry.resources).doReturn(resources.asList())
+    }
+
+    @Before
+    fun setup() {
+        registry = mock()
+        uut = IRStatusInquirer(registry)
+    }
+
+    @Test
+    fun `should return no busy resources if there are no registered resources`() {
+        val result = uut.getAllBusyResources()
+        assertThat(result).isEmpty()
+    }
+
+    @Test
+    fun `should return busy resources if there are some`() {
+        val resourceIdle = anIdleResource()
+        val resourceBusy = aBusyResource()
+        givenIdlingResources(resourceIdle, resourceBusy)
+
+        val result = uut.getAllBusyResources()
+        assertThat(result).isEqualTo(arrayListOf(resourceBusy))
+    }
+
+    @Test
+    fun `should execute busy-resources inquiry on main thread`() {
+        // This is tricky because we actually need to assert two things:
+        // 1. Inquiry takes place on the main thread (i.e. the test-exec thread in robolectric UT's)
+        // 2. getAllBusyResources() actually awaits for the execution to finish.
+        // The trick, then, is to execute uut.getAllBusyResources() on a separate thread, and assert
+        // the resources are queried on the main thread nonetheless + that the *final* result is a real
+        // one (i.e. a non-empty list of IR's).
+
+        val executor = Executors.newSingleThreadExecutor()
+        val resource = mock<IdlingResource> {
+            on { isIdleNow } doAnswer {
+                assertThat(Looper.myLooper()).isEqualTo(Looper.getMainLooper()) // Assert (1)
+                Thread.sleep(10) // Make more certain to fail if inquirer doesn't *wait* for the main thread exec to complete
+                false
+            }
+        }
+        givenIdlingResources(resource)
+
+        var result: List<IdlingResource>? = null
+        executor.execute {
+            result = uut.getAllBusyResources()
+        }
+        Thread.sleep(100L) // Give time for the unit to actually post on the main thread (as it's suppose to...)
+        Robolectric.flushForegroundThreadScheduler()
+        UTHelpers.yieldToOtherThreads(executor)
+        assertThat(result).isEqualTo(arrayListOf(resource)) // Assert (2)
+    }
+}
diff --git a/detox/local-cli/utils/testCommandArgs.js b/detox/local-cli/utils/testCommandArgs.js
index cb8dca8c9..800be297e 100644
--- a/detox/local-cli/utils/testCommandArgs.js
+++ b/detox/local-cli/utils/testCommandArgs.js
@@ -63,7 +63,7 @@ module.exports = {
       return Number.isNaN(+value) ? value : +value;
     },
     describe:
-      '[iOS Only] Customize how long an action/expectation can take to complete before Detox starts querying the app why it is busy. ' +
+      'Customize how long an action/expectation can take to complete before Detox starts querying the app why it is busy. ' +
       'By default, the app status will be printed if the action takes more than 10s to complete.'
   },
   a: {
diff --git a/detox/src/server/DetoxServer.test.js b/detox/src/server/DetoxServer.test.js
index ecf7b2b3f..4bfa76ad2 100644
--- a/detox/src/server/DetoxServer.test.js
+++ b/detox/src/server/DetoxServer.test.js
@@ -47,8 +47,8 @@ describe('DetoxServer', () => {
         await server.open();
 
         const expectedString = expect.stringContaining(`localhost:${options.port}`);
-        expect(log.info).toHaveBeenCalledWith(expectedString);
-        expect(log.debug).not.toHaveBeenCalled();
+        expect(log.info).toHaveBeenCalledWith({ event: 'WSS_CREATE' }, expectedString);
+        expect(log.debug).not.toHaveBeenCalledWith({ event: 'WSS_CREATE' }, expectedString);
       } finally {
         await server.close();
       }
@@ -61,8 +61,8 @@ describe('DetoxServer', () => {
         await server.open();
 
         const expectedString = expect.stringContaining(`localhost:${options.port}`);
-        expect(log.debug).toHaveBeenCalledWith(expectedString);
-        expect(log.info).not.toHaveBeenCalled();
+        expect(log.debug).toHaveBeenCalledWith({ event: 'WSS_CREATE' }, expectedString);
+        expect(log.info).not.toHaveBeenCalledWith({ event: 'WSS_CREATE' }, expectedString);
       } finally {
         await server.close();
       }
@@ -74,7 +74,7 @@ describe('DetoxServer', () => {
       await server.close();
 
       const expectedString = expect.stringContaining(`has been closed gracefully`);
-      expect(log.debug).toHaveBeenCalledWith({ event: 'SERVER_CLOSE' }, expectedString);
+      expect(log.debug).toHaveBeenCalledWith({ event: 'WSS_CLOSE' }, expectedString);
     });
 
     it('should WARN log a message upon unsuccessful server closing (timeout case)', async () => {
diff --git a/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap b/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap
index 7d85423d6..5515a9921 100644
--- a/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap
+++ b/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap
@@ -1,5 +1,9 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`Detox server integration "app" connects first, and then disconnects 1`] = `"registered a new connection."`;
+
+exports[`Detox server integration "tester" connects first, and then disconnects 1`] = `"registered a new connection."`;
+
 exports[`Detox server integration edge cases .registerSession - calling twice 1`] = `
 "DetoxInternalError: Cannot login the same WebSocket instance twice into the same session.
 Please report this issue on our GitHub tracker:
diff --git a/detox/src/server/__tests__/server-integration.test.js b/detox/src/server/__tests__/server-integration.test.js
index 0d5b81ff4..d491f9933 100644
--- a/detox/src/server/__tests__/server-integration.test.js
+++ b/detox/src/server/__tests__/server-integration.test.js
@@ -37,6 +37,7 @@ describe('Detox server integration', () => {
     const webSocket = new FakeWebSocket({ remotePort: 8081 });
     sessionManager.registerConnection(webSocket, webSocket.socket);
 
+    expect(getLoggerMsg('debug', 0, 1)).toMatchSnapshot();
     expect(sessionManager.getSession(webSocket)).toBe(null);
 
     webSocket.mockLogin({ messageId: 100500, role });
@@ -283,7 +284,7 @@ describe('Detox server integration', () => {
     test('app dispatches "ready" action before login', async () => {
       sessionManager.registerConnection(webSocket, webSocket.socket);
       webSocket.mockMessage({ type: 'ready', messageId: -1000 });
-      expect(getLoggerMsg('debug')).toMatchSnapshot();
+      expect(getLoggerMsg('debug', 1)).toMatchSnapshot();
     });
 
     test('socket error', () => {
@@ -293,7 +294,7 @@ describe('Detox server integration', () => {
       delete err.stack;
       webSocket.mockError(err);
 
-      expect(getLoggerMsg('warn', 0, 0)).toEqual({ event: 'SOCKET_ERROR' });
+      expect(getLoggerMsg('warn', 0, 0)).toEqual({ event: 'WSS_SOCKET_ERROR' });
       expect(getLoggerMsg('warn', 0, 1)).toMatchSnapshot();
     });
   });
diff --git a/detox/test/e2e/24.uidevice.test.js b/detox/test/e2e/24.uidevice.test.js
index ba306072a..e098f3fb9 100644
--- a/detox/test/e2e/24.uidevice.test.js
+++ b/detox/test/e2e/24.uidevice.test.js
@@ -1,25 +1,27 @@
-  describe(':android: UIDevice', () => {
-    beforeEach(async () => {
-      await device.reloadReactNative();
-    });
+const { forEachSeries } = require('p-iteration');
 
-    it(`device.getUiDevice() + getDisplayHeight() + getDisplayWidth() + click()`, async () => {
-      await element(by.text('Device')).tap();
-      const uiDevice = device.getUiDevice();
-      const height = await uiDevice.getDisplayHeight();
-      const width = await uiDevice.getDisplayWidth();
-      await uiDevice.click(width / 2, height / 2);
-      await expect(element(by.text('Tap works'))).toBeVisible();
-    });
+describe(':android: UIDevice', () => {
+  beforeEach(async () => {
+    await device.reloadReactNative();
+  });
 
-    it(`should type in an element using pressKeyCode()`, async () => {
-      const text = "a1b2c3";
-      const textAsCodes = [29, 8, 30, 9, 31, 10];
-      const uiDevice = device.getUiDevice();
+  it(`device.getUiDevice() + getDisplayHeight() + getDisplayWidth() + click()`, async () => {
+    await element(by.text('Device')).tap();
+    const uiDevice = device.getUiDevice();
+    const height = await uiDevice.getDisplayHeight();
+    const width = await uiDevice.getDisplayWidth();
+    await uiDevice.click(width / 2, height / 2);
+    await expect(element(by.text('Tap works'))).toBeVisible();
+  });
 
-      await element(by.text('Actions')).tap();
-      await element(by.id('UniqueId937')).tap();
-      await textAsCodes.forEach((keyCode) => uiDevice.pressKeyCode(keyCode));
-      await expect(element(by.text(text))).toBeVisible();
-    });
+  it(`should type in an element using pressKeyCode()`, async () => {
+    const text = "a1b2c3";
+    const textAsCodes = [29, 8, 30, 9, 31, 10];
+    const uiDevice = device.getUiDevice();
+
+    await element(by.text('Actions')).tap();
+    await element(by.id('UniqueId937')).tap();
+    await forEachSeries(textAsCodes, (keyCode) => uiDevice.pressKeyCode(keyCode));
+    await expect(element(by.text(text))).toBeVisible();
+  });
 });
diff --git a/detox/test/package.json b/detox/test/package.json
index 4782f41b9..a84dc7de9 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "18.11.1",
+  "version": "18.12.0",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -36,7 +36,7 @@
   "devDependencies": {
     "@babel/core": "^7.8.7",
     "@types/node": "^14.14.20",
-    "detox": "^18.11.1",
+    "detox": "^18.12.0",
     "dtslint": "^4.0.6",
     "express": "^4.15.3",
     "jest": "^26.5.0",
@@ -44,6 +44,7 @@
     "jest-junit": "^10.0.0",
     "lodash": "^4.14.1",
     "nyc": "^15.1.0",
+    "p-iteration": "^1.1.8",
     "pngjs": "^3.4.0",
     "typescript": "^4.1.3"
   },