Repository: wix/Detox
Pull request #1705 (2019-10-19 08:10:57)

More helpful error message if Detox.init() fails
I am very tired of user reports that tell `device (or element) is not defined` and so on.
Let's solve it.

The goal of this pull request to transform those errors:

![Old errors](https://user-images.githubusercontent.com/1962469/67159143-83beea00-f349-11e9-86c9-4dcde5d1fda4.png)

To these, telling that there was an error in detox.init():

![New errors](https://user-images.githubusercontent.com/1962469/67159141-7a358200-f349-11e9-81bc-495a4b258428.png)

stale[bot]: This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.
Thank you for your contributions.
For more information on bots in this reporsitory, read [this discussion](https://github.com/wix/Detox/issues/1305).


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report---android.md b/.github/ISSUE_TEMPLATE/bug-report---android.md
index a95a44480..f84c7bb26 100644
--- a/.github/ISSUE_TEMPLATE/bug-report---android.md
+++ b/.github/ISSUE_TEMPLATE/bug-report---android.md
@@ -8,26 +8,37 @@ assignees: ''
 ---
 
 **Describe the bug**
-A clear and concise description of what the bug is.
+<!-- A clear and concise description of what the bug is. -->
 
 **To Reproduce**
 
 - [ ] I have tested this issue on the latest Detox release and it still reproduces
 
+<!--
 Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.
+-->
 
+Steps to reproduce:
 1. 
 2. 
 3. 
-4. 
 
+<!--
 If possible, please provide a small demo project that reproduces the issue, or attach a video with the reproduction - this would be very appreciated.
+-->
 
 **Expected behavior**
-A clear and concise description of what you expected to happen.
+<!-- A clear and concise description of what you think should happen. -->
 
 **Screenshots**
-If applicable, add screenshots to help explain your problem.
+<!-- If applicable, add screenshots to help explain your problem. -->
+
+**Device and Verbose Detox Logs**
+<!--
+Provide the device and verbose Detox logs so we can understand what happened.
+Detox logs can be obtained by passing the loglevel param: `detox test --loglevel trace`.
+Device logs can be retrieved from the device using `adb logcat`, or if recorded, Detox' artifacts.
+-->
 
 **Environment (please complete the following information):**
  - Detox:
@@ -35,6 +46,6 @@ If applicable, add screenshots to help explain your problem.
  - Node:
  - Device:
  - OS: 
+ - Test-runner (select one): Mocha | Jest+jasmine | Jest+jest-circus
 
-**Device and Verbose Detox Logs**
-Provide the device and "trace" Detox logs so we can understand what happened. You can obtain them by passing the loglevel param: `detox test --loglevel trace`
+<!-- Note: Test-runner is set in Detox.test-runner in your package.json -->
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt b/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt
new file mode 100644
index 000000000..c4dfedea5
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/RNAnnotations.kt
@@ -0,0 +1,11 @@
+package com.wix.detox.common
+
+import kotlin.annotation.AnnotationTarget.*
+
+/**
+ * Source-annotation, indicating that some changes need to be made once the associated RN version
+ * (or higher) becomes the one minimally supported by Detox Android.
+ */
+@Target(FUNCTION, CLASS, CONSTRUCTOR, PROPERTY_GETTER, PROPERTY_SETTER, PROPERTY, FIELD, FILE)
+@Retention(AnnotationRetention.SOURCE)
+annotation class RNDropSupportTodo(val rnMajorVersion: Int, val message: String)
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
index ac2522809..d305622a1 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/ReactNativeIdlingResources.kt
@@ -6,6 +6,8 @@ import androidx.test.espresso.IdlingRegistry
 import androidx.test.espresso.base.IdlingResourceRegistry
 import com.facebook.react.bridge.ReactContext
 import com.wix.detox.reactnative.idlingresources.*
+import com.wix.detox.reactnative.idlingresources.timers.TimersIdlingResource
+import com.wix.detox.reactnative.idlingresources.timers.getInterrogationStrategy
 import org.joor.Reflect
 import org.joor.ReflectException
 import java.util.Set
@@ -108,7 +110,7 @@ class ReactNativeIdlingResources constructor(
 
     private fun setupCustomRNIdlingResources() {
         rnBridgeIdlingResource = BridgeIdlingResource(reactContext)
-        timersIdlingResource = TimersIdlingResource(reactContext)
+        timersIdlingResource = TimersIdlingResource(getInterrogationStrategy(reactContext)!!)
         uiModuleIdlingResource = UIModuleIdlingResource(reactContext)
         animIdlingResource = AnimatedModuleIdlingResource(reactContext)
 
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt
deleted file mode 100644
index 1dd1ea4ec..000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/TimersIdlingResource.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.wix.detox.reactnative.idlingresources
-
-import android.view.Choreographer
-import androidx.test.espresso.IdlingResource
-import com.facebook.react.bridge.ReactContext
-import com.facebook.react.modules.core.Timing
-import org.joor.Reflect
-import java.util.*
-import java.util.concurrent.atomic.AtomicBoolean
-
-const val BUSY_WINDOW_THRESHOLD = 1500
-
-class TimerReflected(timer: Any) {
-    private var reflected = Reflect.on(timer)
-
-    val isRepeating: Boolean
-        get() = reflected.field("mRepeat").get()
-
-    val interval: Int
-        get() = reflected.field("mInterval").get()
-
-    val targetTime: Long
-        get() = reflected.field("mTargetTime").get()
-}
-
-class TimingModuleReflected(reactContext: ReactContext) {
-    private var nativeModule = reactContext.getNativeModule(Timing::class.java)
-
-    val timersQueue: PriorityQueue<Any>
-        get() = Reflect.on(nativeModule).field("mTimers").get()
-
-    val timersLock: Any
-        get() = Reflect.on(nativeModule).field("mTimerGuard").get()
-
-    operator fun component1() = timersQueue
-    operator fun component2() = timersLock
-}
-
-class TimersIdlingResource @JvmOverloads constructor(
-        private val reactContext: ReactContext,
-        private val getChoreographer: () -> Choreographer = { Choreographer.getInstance() }
-    ) : IdlingResource, Choreographer.FrameCallback {
-
-    private var callback: IdlingResource.ResourceCallback? = null
-    private var paused = AtomicBoolean(false)
-
-    override fun getName(): String = this.javaClass.name
-
-    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
-        this.callback = callback
-        getChoreographer().postFrameCallback(this)
-    }
-
-    override fun isIdleNow(): Boolean {
-        if (paused.get()) {
-            return true
-        }
-
-        return checkIdle().apply {
-            val result = this
-            if (result) {
-                callback?.onTransitionToIdle()
-            } else {
-                getChoreographer().postFrameCallback(this@TimersIdlingResource)
-            }
-        }
-    }
-
-    override fun doFrame(frameTimeNanos: Long) {
-        callback?.let {
-            isIdleNow
-        }
-    }
-
-    fun pause() {
-        paused.set(true)
-        callback?.onTransitionToIdle()
-    }
-
-    fun resume() {
-        paused.set(false)
-    }
-
-    private fun checkIdle(): Boolean {
-        val (timersQueue, timersLock) = TimingModuleReflected(reactContext)
-        synchronized(timersLock) {
-            val nextTimer = timersQueue.peek()
-            nextTimer?.let {
-                return !isTimerInBusyWindow(it) && !hasBusyTimers(timersQueue)
-            }
-            return true
-        }
-    }
-
-    private fun isTimerInBusyWindow(timer: Any): Boolean {
-        val timerReflected = TimerReflected(timer)
-        return when {
-            timerReflected.isRepeating -> false
-            timerReflected.interval > BUSY_WINDOW_THRESHOLD -> false
-            else -> true
-        }
-    }
-
-    private fun hasBusyTimers(timersQueue: PriorityQueue<Any>): Boolean {
-        timersQueue.forEach {
-            if (isTimerInBusyWindow(it)) {
-                return true
-            }
-        }
-        return false
-    }
-}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt
new file mode 100644
index 000000000..51e8d2e01
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategy.kt
@@ -0,0 +1,76 @@
+@file:RNDropSupportTodo(62, "Remove all of this; Use DelegatedIdleInterrogationStrategy, instead.")
+
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactContext
+import com.wix.detox.common.RNDropSupportTodo
+import org.joor.Reflect
+import java.util.*
+
+private const val BUSY_WINDOW_THRESHOLD = 1500
+
+private class TimerReflected(timer: Any) {
+    private var reflected = Reflect.on(timer)
+
+    val isRepeating: Boolean
+        get() = reflected.field("mRepeat").get()
+    val interval: Int
+        get() = reflected.field("mInterval").get()
+    val targetTime: Long
+        get() = reflected.field("mTargetTime").get()
+}
+
+private class TimingModuleReflected(private val nativeModule: NativeModule) {
+    val timersQueue: PriorityQueue<Any>
+        get() = Reflect.on(nativeModule).field("mTimers").get()
+    val timersLock: Any
+        get() = Reflect.on(nativeModule).field("mTimerGuard").get()
+
+    operator fun component1() = timersQueue
+    operator fun component2() = timersLock
+}
+
+class DefaultIdleInterrogationStrategy
+    internal constructor(private val timersModule: NativeModule)
+    : IdleInterrogationStrategy {
+
+    override fun isIdleNow(): Boolean {
+        val (timersQueue, timersLock) = TimingModuleReflected(timersModule)
+        synchronized(timersLock) {
+            val nextTimer = timersQueue.peek()
+            nextTimer?.let {
+                return !isTimerInBusyWindow(it) && !hasBusyTimers(timersQueue)
+            }
+            return true
+        }
+    }
+
+    private fun isTimerInBusyWindow(timer: Any): Boolean {
+        val timerReflected = TimerReflected(timer)
+        return when {
+            timerReflected.isRepeating -> false
+            timerReflected.interval > BUSY_WINDOW_THRESHOLD -> false
+            else -> true
+        }
+    }
+
+    private fun hasBusyTimers(timersQueue: PriorityQueue<Any>): Boolean {
+        timersQueue.forEach {
+            if (isTimerInBusyWindow(it)) {
+                return true
+            }
+        }
+        return false
+    }
+
+    companion object {
+        fun createIfSupported(reactContext: ReactContext): DefaultIdleInterrogationStrategy? =
+            try {
+                val timingClass: Class<NativeModule> = Class.forName("com.facebook.react.modules.core.Timing") as Class<NativeModule>
+                DefaultIdleInterrogationStrategy(reactContext.getNativeModule(timingClass))
+            } catch (ex: Exception) {
+                null
+            }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt
new file mode 100644
index 000000000..6e5431b11
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategy.kt
@@ -0,0 +1,51 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactContext
+import com.wix.detox.common.RNDropSupportTodo
+import org.joor.Reflect
+
+private const val BUSY_WINDOW_THRESHOLD = 1500L
+
+private class RN62TimingModuleReflected(private val timingModule: NativeModule) {
+    fun hasActiveTimers(): Boolean = Reflect.on(timingModule).call("hasActiveTimersInRange", BUSY_WINDOW_THRESHOLD).get()
+}
+
+/**
+ * Delegates the interrogation to the native module itself, added
+ * [here](https://github.com/facebook/react-native/pull/27539) in the context
+ * of RN v0.62 (followed by a previous refactor and rename of the class).
+ */
+@RNDropSupportTodo(62, """
+    When min RN version supported by Detox is 0.62.x (or higher), 
+    | can (and should) remove any usage of reflection here. That 
+    | includes the unit test's stub being used for that reason in particular.
+    """)
+class DelegatedIdleInterrogationStrategy(timingModule: NativeModule): IdleInterrogationStrategy {
+    private val timingModuleReflected = RN62TimingModuleReflected(timingModule)
+
+    override fun isIdleNow(): Boolean = timingModuleReflected.hasActiveTimers()
+
+    companion object {
+        fun createIfSupported(reactContext: ReactContext): DelegatedIdleInterrogationStrategy? {
+            val moduleClass: Class<NativeModule>?
+            try {
+                moduleClass = Class.forName("com.facebook.react.modules.core.TimingModule") as Class<NativeModule>
+            } catch (ex: Exception) {
+                return null
+            }
+
+            if (!reactContext.hasNativeModule(moduleClass)) {
+                return null
+            }
+
+            try {
+                moduleClass.getDeclaredMethod("hasActiveTimersInRange", Long::class.java)
+            } catch (ex: Exception) {
+                return null
+            }
+
+            return DelegatedIdleInterrogationStrategy(reactContext.getNativeModule(moduleClass))
+        }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
new file mode 100644
index 000000000..9ae72d879
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/IdleInterrogationStrategy.kt
@@ -0,0 +1,21 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.util.Log
+import com.facebook.react.bridge.ReactContext
+
+interface IdleInterrogationStrategy {
+    fun isIdleNow(): Boolean
+}
+
+fun getInterrogationStrategy(reactContext: ReactContext): IdleInterrogationStrategy? {
+    DelegatedIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+        return it
+    }
+
+    DefaultIdleInterrogationStrategy.createIfSupported(reactContext)?.let {
+        return it
+    }
+
+    Log.e(LOG_TAG, "Failed to determine proper implementation-strategy for timers idling resource")
+    return null
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
new file mode 100644
index 000000000..cca694a08
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResource.kt
@@ -0,0 +1,54 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.view.Choreographer
+import androidx.test.espresso.IdlingResource
+import java.util.concurrent.atomic.AtomicBoolean
+
+const val LOG_TAG = "TimersIdlingResource"
+
+class TimersIdlingResource @JvmOverloads constructor(
+        private val interrogationStrategy: IdleInterrogationStrategy,
+        private val getChoreographer: () -> Choreographer = { Choreographer.getInstance() }
+    ) : IdlingResource, Choreographer.FrameCallback {
+
+    private var callback: IdlingResource.ResourceCallback? = null
+    private var paused = AtomicBoolean(false)
+
+    override fun getName(): String = this.javaClass.name
+
+    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
+        this.callback = callback
+        getChoreographer().postFrameCallback(this)
+    }
+
+    override fun isIdleNow(): Boolean {
+        if (paused.get()) {
+            return true
+        }
+
+        return checkIdle().also { result ->
+            if (result) {
+                callback?.onTransitionToIdle()
+            } else {
+                getChoreographer().postFrameCallback(this@TimersIdlingResource)
+            }
+        }
+    }
+
+    override fun doFrame(frameTimeNanos: Long) {
+        callback?.let {
+            isIdleNow
+        }
+    }
+
+    fun pause() {
+        paused.set(true)
+        callback?.onTransitionToIdle()
+    }
+
+    fun resume() {
+        paused.set(false)
+    }
+
+    private fun checkIdle() = interrogationStrategy.isIdleNow()
+}
diff --git a/detox/package.json b/detox/package.json
index 036ad6ff3..8a3b79d4c 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "15.1.2",
+  "version": "15.1.3",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -88,6 +88,7 @@
       "src/devices/ios/AppleSimUtils.js",
       "src/server/DetoxServer.js",
       ".*Driver.js",
+      "MissingDetox.js",
       "EmulatorTelnet.js",
       "Emulator.js",
       "DeviceDriverBase.js",
diff --git a/detox/runners/jest/WorkerAssignReporterImpl.js b/detox/runners/jest/WorkerAssignReporterImpl.js
index 3643f9b63..23a76bfdc 100644
--- a/detox/runners/jest/WorkerAssignReporterImpl.js
+++ b/detox/runners/jest/WorkerAssignReporterImpl.js
@@ -1,3 +1,4 @@
+const _ = require('lodash');
 const chalk = require('chalk').default;
 const ReporterBase = require('./ReporterBase');
 const log = require('../../src/utils/logger').child();
@@ -9,7 +10,12 @@ class WorkerAssignReporterImpl extends ReporterBase {
   }
 
   report(workerName) {
-    log.info({event: 'WORKER_ASSIGN'}, `${chalk.whiteBright(workerName)} assigned to ${chalk.blueBright(this.device.name)}`);
+    const deviceName = _.attempt(() => this.device.name);
+    const formattedDeviceName = _.isError(deviceName)
+      ? chalk.redBright('undefined')
+      : chalk.blueBright(deviceName);
+
+    log.info({event: 'WORKER_ASSIGN'}, `${chalk.whiteBright(workerName)} is assigned to ${formattedDeviceName}`);
   }
 }
 
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index 5b7f4eeef..f3181ce28 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -9,6 +9,7 @@ const EmulatorDriver = require('./devices/drivers/EmulatorDriver');
 const AttachedAndroidDriver = require('./devices/drivers/AttachedAndroidDriver');
 const DetoxRuntimeError = require('./errors/DetoxRuntimeError');
 const argparse = require('./utils/argparse');
+const MissingDetox = require('./utils/MissingDetox');
 const configuration = require('./configuration');
 const Client = require('./client/Client');
 const DetoxServer = require('./server/DetoxServer');
@@ -177,4 +178,6 @@ class Detox {
   }
 }
 
+Detox.none = new MissingDetox();
+
 module.exports = Detox;
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index a8c693997..58e598d72 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -10,7 +10,7 @@ const _detox = Symbol('detox');
 
 class DetoxExportWrapper {
   constructor() {
-    this[_detox] = null;
+    this[_detox] = Detox.none;
 
     this.init = this.init.bind(this);
     this.cleanup = this.cleanup.bind(this);
@@ -29,33 +29,39 @@ class DetoxExportWrapper {
   }
 
   async init(config, params) {
+    if (!params || params.initGlobals !== false) {
+      Detox.none.initContext(global);
+    }
+
     this[_detox] = await DetoxExportWrapper._initializeInstance(config, params);
     return this[_detox];
   }
 
   async cleanup() {
-    if (this[_detox]) {
+    Detox.none.cleanupContext(global);
+
+    if (this[_detox] !== Detox.none) {
       await this[_detox].cleanup();
-      this[_detox] = null;
+      this[_detox] = Detox.none;
     }
   }
 
   _definePassthroughMethod(name) {
     this[name] = (...args) => {
-      if (this[_detox]) {
-        return this[_detox][name](...args);
-      }
+      return this[_detox][name](...args);
     };
   }
 
   _defineProxy(name) {
-    this[name] = funpermaproxy(() => (this[_detox] && this[_detox][name]));
+    this[name] = funpermaproxy(() => this[_detox][name]);
   }
 
   static async _initializeInstance(detoxConfig, params) {
     let instance = null;
 
     try {
+      Detox.none.setError(null);
+
       if (!detoxConfig) {
         throw new Error(`No configuration was passed to detox, make sure you pass a detoxConfig when calling 'detox.init(detoxConfig)'`);
       }
@@ -81,6 +87,7 @@ class DetoxExportWrapper {
       await instance.init(params);
       return instance;
     } catch (err) {
+      Detox.none.setError(err);
       log.error({ event: 'DETOX_INIT_ERROR' }, '\n', err);
 
       if (instance) {
diff --git a/detox/src/utils/MissingDetox.js b/detox/src/utils/MissingDetox.js
new file mode 100644
index 000000000..23c097baa
--- /dev/null
+++ b/detox/src/utils/MissingDetox.js
@@ -0,0 +1,77 @@
+const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
+
+class MissingDetox {
+  constructor() {
+    this.throwError = this.throwError.bind(this);
+    this.initContext(this);
+
+    this._defineRequiredProperty(this, 'beforeEach', async () => this.throwError(), true);
+    this._defineRequiredProperty(this, 'afterEach', async () => this.throwError(), true);
+  }
+
+  initContext(context) {
+    const readonly = context === this;
+
+    this._defineRequiredProperty(context, 'by', undefined, readonly);
+    this._defineRequiredProperty(context, 'device', undefined, readonly);
+    this._defineRequiredProperty(context, 'element', this.throwError, readonly);
+    this._defineRequiredProperty(context, 'expect', this.throwError, readonly);
+    this._defineRequiredProperty(context, 'waitFor', this.throwError, readonly);
+  }
+
+  cleanupContext(context) {
+    this._cleanupProperty(context, 'by');
+    this._cleanupProperty(context, 'device');
+    this._cleanupProperty(context, 'element');
+    this._cleanupProperty(context, 'expect');
+    this._cleanupProperty(context, 'waitFor');
+  }
+
+  _cleanupProperty(context, name) {
+    if (context.hasOwnProperty(name)) {
+      context[name] = undefined;
+    }
+  }
+
+  _defineRequiredProperty(context, name, initialValue, readonly) {
+    if (context.hasOwnProperty(name)) {
+      return;
+    }
+
+    let _value = initialValue;
+
+    const descriptor = {
+      get: () => {
+        if (_value === undefined) {
+          this.throwError();
+        }
+
+        return _value;
+      },
+    };
+
+    if (!readonly) {
+      descriptor.set = (value) => {
+        _value = value;
+      };
+    }
+
+    Object.defineProperty(context, name, descriptor);
+  }
+
+  setError(err) {
+    this._lastError = err;
+  }
+
+  throwError() {
+    throw new DetoxRuntimeError({
+      message: 'Detox instance has not been initialized',
+      hint: this._lastError
+        ? 'There was an error on attempt to call detox.init()'
+        : 'Make sure to call detox.init() before your test begins',
+      debugInfo: this._lastError && this._lastError.stack || '',
+    });
+  }
+}
+
+module.exports = MissingDetox;
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index 4c68182dd..ef436dde1 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": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index aed939f34..f9cf9235e 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": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "devDependencies": {
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "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 ca4e9e379..09fd59900 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": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 5479149fa..d9251716e 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": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -13,7 +13,7 @@
     "test:jest-circus:android-release-ci": "detox test --configuration android.emu.release -o e2e/config-circus.json -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "jest": "24.8.x",
     "jest-circus": "24.8.x",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index d35cc973a..2430be1a9 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -23,7 +23,7 @@
     "react-native": "0.60.x"
   },
   "devDependencies": {
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/lerna.json b/lerna.json
index 4aa6865d9..29880039d 100644
--- a/lerna.json
+++ b/lerna.json
@@ -12,7 +12,7 @@
     "generation",
     "."
   ],
-  "version": "15.1.2",
+  "version": "15.1.3",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index a9a6e9521..ae9ffde28 100644
--- a/package.json
+++ b/package.json
@@ -17,5 +17,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "15.1.2"
+  "version": "15.1.3"
 }


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt
deleted file mode 100644
index 1e6749e0f..000000000
--- a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/ReactNativeTimersIdlingResourceSpec.kt
+++ /dev/null
@@ -1,262 +0,0 @@
-package com.wix.detox.reactnative.idlingresources
-
-import android.view.Choreographer
-import androidx.test.espresso.IdlingResource
-import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.modules.core.Timing
-import com.nhaarman.mockitokotlin2.*
-import com.wix.detox.UTHelpers.yieldToOtherThreads
-import org.assertj.core.api.Assertions
-import org.joor.Reflect
-import org.mockito.ArgumentMatchers
-import org.spekframework.spek2.Spek
-import org.spekframework.spek2.style.specification.describe
-import java.util.*
-import java.util.concurrent.Executors
-
-private const val BUSY_INTERVAL_MS = 1500
-private const val MEANINGFUL_TIMER_INTERVAL = BUSY_INTERVAL_MS
-
-private fun now() = System.nanoTime() / 1000000L
-
-private fun aTimer(interval: Int, isRepeating: Boolean) = aTimer(now() + interval + 10, interval, isRepeating)
-
-private fun aTimer(targetTime: Long, interval: Int, isRepeating: Boolean): Any {
-    val timerClass = Class.forName("com.facebook.react.modules.core.Timing\$Timer")
-    return Reflect.on(timerClass).create(-1, targetTime, interval, isRepeating).get()
-}
-
-private fun aOneShotTimer(interval: Int) = aTimer(interval, false)
-private fun aRepeatingTimer(interval: Int) = aTimer(interval, true)
-private fun anOverdueTimer() = aTimer(now() - 100, 123, false)
-
-private fun anIdlingResourceCallback() = mock<IdlingResource.ResourceCallback>()
-
-object ReactNativeTimersIdlingResourceSpec : Spek({
-    describe("React Native timers idling-resource") {
-        lateinit var reactAppContext: ReactApplicationContext
-        lateinit var timersLock: String
-        lateinit var timersNativeModule: Timing
-        lateinit var choreographer: Choreographer
-        lateinit var pendingTimers: PriorityQueue<Any>
-
-        fun uut() = TimersIdlingResource(reactAppContext) { choreographer }
-
-        fun givenTimer(timer: Any) {
-            pendingTimers.add(timer)
-        }
-
-        fun getChoreographerCallback(): Choreographer.FrameCallback {
-            argumentCaptor<Choreographer.FrameCallback>().apply {
-                verify(choreographer).postFrameCallback(capture())
-                return firstValue
-            }
-        }
-
-        fun invokeChoreographerCallback() {
-            getChoreographerCallback().doFrame(0L)
-        }
-
-        beforeEachTest {
-            pendingTimers = PriorityQueue(2) { _, _ -> 0}
-
-            timersNativeModule = mock()
-            timersLock = "Lock-Mock"
-            Reflect.on(timersNativeModule).set("mTimers", pendingTimers)
-            Reflect.on(timersNativeModule).set("mTimerGuard", timersLock)
-
-            choreographer = mock()
-
-            reactAppContext = mock {
-                on { hasNativeModule<Timing>(ArgumentMatchers.any()) }.doReturn(true)
-                on { getNativeModule<Timing>(ArgumentMatchers.any()) }.doReturn(timersNativeModule)
-            }
-        }
-
-        it("should be idle if there are no timers in queue") {
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should transition to idle if found idle in query") {
-            val callback = anIdlingResourceCallback()
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                isIdleNow
-            }
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should NOT transition to idle if found busy in query") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                isIdleNow
-            }
-
-            verify(callback, never()).onTransitionToIdle()
-        }
-
-        it("should be busy if there's a meaningful pending timer") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be idle if pending timer is too far away (ie not meaningful)") {
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should be idle if the only timer is a repeating one") {
-            givenTimer(aRepeatingTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isTrue()
-        }
-
-        it("should be busy if a meaningful pending timer lies beyond a repeating one") {
-            givenTimer(aRepeatingTimer(BUSY_INTERVAL_MS / 10))
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        /**
-         * Note: Reversed logic due to this issue: https://github.com/wix/Detox/issues/1171 !!!
-         *
-         * Apparently at times (rare) this caused Espresso to think we're idle too soon, rendering
-         * it never to query any idling resource again even after the timer effectively expires...
-         */
-        it("should be *busy* even if all timers are overdue") {
-            givenTimer(anOverdueTimer())
-            givenTimer(anOverdueTimer())
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be busy if has a meaningful pending timer set beyond an overdue timer") {
-            givenTimer(anOverdueTimer())
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            Assertions.assertThat(uut().isIdleNow).isFalse()
-        }
-
-        it("should be idle if paused") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut().apply {
-                pause()
-            }
-
-            Assertions.assertThat(uut.isIdleNow).isTrue()
-        }
-
-        it("should be busy if paused and resumed") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut().apply {
-                pause()
-                resume()
-            }
-
-            Assertions.assertThat(uut.isIdleNow).isFalse()
-        }
-
-        it("should notify of transition to idle upon pausing") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            with(uut()) {
-                registerIdleTransitionCallback(callback)
-                pause()
-            }
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should enqueue an is-idle check using choreographer when a callback gets registered") {
-            with(uut()) {
-                registerIdleTransitionCallback(mock())
-            }
-
-            verify(choreographer).postFrameCallback(any())
-        }
-
-        it("should transition to idle when preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            uut().registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(callback).onTransitionToIdle()
-        }
-
-        it("should NOT transition to idle if not idle when preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            uut().registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(callback, never()).onTransitionToIdle()
-        }
-
-        it("should re-register choreographer if found idle while preregistered choreographer is dispatched") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            val uut = uut()
-            uut.registerIdleTransitionCallback(callback)
-            invokeChoreographerCallback()
-
-            verify(choreographer, times(2)).postFrameCallback(any())
-        }
-
-        it("should adhere to pausing also when invoked via choreographer callback") {
-            val callback = anIdlingResourceCallback()
-
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-
-            uut().apply {
-                pause()
-                registerIdleTransitionCallback(callback)
-            }
-            val runtimeChoreographerCallback = getChoreographerCallback()
-
-            reset(callback, choreographer)
-            runtimeChoreographerCallback.doFrame(0L)
-
-            verify(callback, never()).onTransitionToIdle()
-            verify(choreographer, never()).postFrameCallback(any())
-        }
-
-        it("should enqueue an additional idle check (using choreographer) if found busy") {
-            givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
-            uut().isIdleNow
-            verify(choreographer).postFrameCallback(any())
-        }
-
-        it("should NOT enqueue an additional idle check (using choreographer) if found idle") {
-            givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
-            uut().isIdleNow
-            verify(choreographer, never()).postFrameCallback(any())
-        }
-
-        it("should yield to other threads using the timers module") {
-            val executor = Executors.newSingleThreadExecutor()
-            var isIdle: Boolean? = null
-
-            synchronized(timersLock) {
-                executor.submit {
-                    isIdle = uut().isIdleNow
-                }
-                yieldToOtherThreads(executor)
-                Assertions.assertThat(isIdle).isNull()
-            }
-            yieldToOtherThreads(executor)
-            Assertions.assertThat(isIdle).isNotNull()
-        }
-    }
-})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt
new file mode 100644
index 000000000..8ca0ff8b5
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DefaultIdleInterrogationStrategySpec.kt
@@ -0,0 +1,113 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.wix.detox.UTHelpers
+import org.assertj.core.api.Assertions
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+import java.util.*
+import java.util.concurrent.Executors
+
+private const val BUSY_INTERVAL_MS = 1500
+private const val MEANINGFUL_TIMER_INTERVAL = BUSY_INTERVAL_MS
+
+data class TimerStub(
+        private var mCallbackID: Int,
+        private var mTargetTime: Long,
+        private var mInterval: Int,
+        private var mRepeat: Boolean)
+
+class TimersNativeModuleStub : NativeModule {
+    val mTimers: PriorityQueue<Any> = PriorityQueue(2) { _, _ -> 0}
+    val mTimerGuard = "Lock-Mock"
+
+    override fun onCatalystInstanceDestroy() {}
+    override fun getName(): String = "TimersNativeModuleStub"
+    override fun canOverrideExistingModule() = false
+    override fun initialize() {}
+}
+
+private fun now() = System.nanoTime() / 1000000L
+private fun aTimer(targetTime: Long, interval: Int, isRepeating: Boolean): TimerStub {
+    return TimerStub(-1, targetTime, interval, isRepeating)
+}
+private fun aTimer(interval: Int, isRepeating: Boolean) = aTimer(now() + interval + 10, interval, isRepeating)
+private fun aOneShotTimer(interval: Int) = aTimer(interval, false)
+private fun aRepeatingTimer(interval: Int) = aTimer(interval, true)
+private fun anOverdueTimer() = aTimer(now() - 100, 123, false)
+
+object DefaultIdleInterrogationStrategySpec: Spek({
+  describe("Default timers idle-interrogation strategy") {
+
+      lateinit var timersNativeModule: TimersNativeModuleStub
+
+      beforeEachTest {
+          timersNativeModule = TimersNativeModuleStub()
+      }
+
+      fun givenTimer(timer: Any) {
+          timersNativeModule.mTimers.add(timer)
+      }
+
+      fun uut() = DefaultIdleInterrogationStrategy(timersNativeModule)
+
+      it("should be idle if there are no timers in queue") {
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be busy if there's a meaningful pending timer") {
+          givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should be idle if pending timer is too far away (ie not meaningful)") {
+          givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be idle if the only timer is a repeating one") {
+          givenTimer(aRepeatingTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isTrue()
+      }
+
+      it("should be busy if a meaningful pending timer lies beyond a repeating one") {
+          givenTimer(aRepeatingTimer(BUSY_INTERVAL_MS / 10))
+          givenTimer(aOneShotTimer(BUSY_INTERVAL_MS))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      /**
+       * Note: Reversed logic due to this issue: https://github.com/wix/Detox/issues/1171 !!!
+       *
+       * Apparently at times (rare) this caused Espresso to think we're idle too soon, rendering
+       * it never to query any idling resource again even after the timer effectively expires...
+       */
+      it("should be *busy* even if all timers are overdue") {
+          givenTimer(anOverdueTimer())
+          givenTimer(anOverdueTimer())
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should be busy if has a meaningful pending timer set beyond an overdue timer") {
+          givenTimer(anOverdueTimer())
+          givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+          Assertions.assertThat(uut().isIdleNow()).isFalse()
+      }
+
+      it("should yield to other threads using the timers module") {
+          val executor = Executors.newSingleThreadExecutor()
+          var isIdle: Boolean? = null
+
+          synchronized(timersNativeModule.mTimerGuard) {
+              executor.submit {
+                  isIdle = uut().isIdleNow()
+              }
+              UTHelpers.yieldToOtherThreads(executor)
+              Assertions.assertThat(isIdle).isNull()
+          }
+          UTHelpers.yieldToOtherThreads(executor)
+          Assertions.assertThat(isIdle).isNotNull()
+          executor.shutdownNow()
+      }
+  }
+})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt
new file mode 100644
index 000000000..ffdbebf5f
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/DelegatedIdleInterrogationStrategySpec.kt
@@ -0,0 +1,55 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import com.facebook.react.bridge.NativeModule
+import com.nhaarman.mockitokotlin2.*
+import org.assertj.core.api.Assertions.assertThat
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+
+private const val BUSY_INTERVAL_MS = 1500L
+
+abstract class TimingModuleStub: NativeModule {
+    abstract fun hasActiveTimersInRange(rangeMs: Long): Boolean
+
+    override fun onCatalystInstanceDestroy() {}
+    override fun getName(): String = "TimersNativeModuleStub"
+    override fun canOverrideExistingModule() = false
+    override fun initialize() {}
+}
+
+object DelegatedIdleInterrogationStrategySpec : Spek({
+    describe("RN62+ timers idle-interrogation strategy") {
+
+        lateinit var timingModule: TimingModuleStub
+
+        beforeEachTest {
+            timingModule = mock()
+        }
+
+        fun givenActiveTimersInQueue() {
+            whenever(timingModule.hasActiveTimersInRange(any())).thenReturn(true)
+        }
+
+        fun givenNoActiveTimersInQueue() {
+            whenever(timingModule.hasActiveTimersInRange(any())).thenReturn(false)
+        }
+
+        fun uut() = DelegatedIdleInterrogationStrategy(timingModule)
+
+        it("should be idle if timing module has no active timers") {
+            givenActiveTimersInQueue()
+            assertThat(uut().isIdleNow()).isTrue()
+        }
+
+        it("should be busy if timing module has active timers") {
+            givenNoActiveTimersInQueue()
+            assertThat(uut().isIdleNow()).isFalse()
+        }
+
+        it("should specify the busy-interval as the active-timers lookahead range") {
+            givenActiveTimersInQueue()
+            uut().isIdleNow()
+            verify(timingModule).hasActiveTimersInRange(eq(BUSY_INTERVAL_MS))
+        }
+    }
+})
diff --git a/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
new file mode 100644
index 000000000..11a825c09
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/reactnative/idlingresources/timers/TimersIdlingResourceSpec.kt
@@ -0,0 +1,185 @@
+package com.wix.detox.reactnative.idlingresources.timers
+
+import android.view.Choreographer
+import androidx.test.espresso.IdlingResource
+import com.nhaarman.mockitokotlin2.*
+import org.assertj.core.api.Assertions
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+
+private fun anIdlingResourceCallback() = mock<IdlingResource.ResourceCallback>()
+
+object TimersIdlingResourceSpec : Spek({
+    describe("React Native timers idling-resource") {
+        lateinit var choreographer: Choreographer
+        lateinit var idleInterrogationStrategy: IdleInterrogationStrategy
+
+        beforeEachTest {
+            idleInterrogationStrategy = mock()
+            choreographer = mock()
+        }
+
+        fun uut() = TimersIdlingResource(idleInterrogationStrategy) { choreographer }
+
+        fun givenIdleStrategy() {
+            whenever(idleInterrogationStrategy.isIdleNow()).thenReturn(true)
+        }
+
+        fun givenBusyStrategy() {
+            whenever(idleInterrogationStrategy.isIdleNow()).thenReturn(false)
+        }
+
+        fun getChoreographerCallback(): Choreographer.FrameCallback {
+            argumentCaptor<Choreographer.FrameCallback>().apply {
+                verify(choreographer).postFrameCallback(capture())
+                return firstValue
+            }
+        }
+
+        fun invokeChoreographerCallback() {
+            getChoreographerCallback().doFrame(0L)
+        }
+
+        it("should be idle if strategy says so") {
+            givenIdleStrategy()
+            Assertions.assertThat(uut().isIdleNow).isTrue()
+        }
+
+        it("should be busy if strategy says so") {
+            givenBusyStrategy()
+            Assertions.assertThat(uut().isIdleNow).isFalse()
+        }
+
+        it("should transition to idle if found idle by strategy") {
+            givenIdleStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                isIdleNow
+            }
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should NOT transition to idle if found busy by strategy") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                isIdleNow
+            }
+
+            verify(callback, never()).onTransitionToIdle()
+        }
+
+        it("should be idle if paused") {
+            givenBusyStrategy()
+
+            val uut = uut().apply {
+                pause()
+            }
+
+            Assertions.assertThat(uut.isIdleNow).isTrue()
+        }
+
+        it("should be busy if paused and resumed") {
+            givenBusyStrategy()
+
+            val uut = uut().apply {
+                pause()
+                resume()
+            }
+
+            Assertions.assertThat(uut.isIdleNow).isFalse()
+        }
+
+        it("should notify of transition to idle upon pausing") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            with(uut()) {
+                registerIdleTransitionCallback(callback)
+                pause()
+            }
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should enqueue an is-idle check using choreographer when a callback gets registered") {
+            with(uut()) {
+                registerIdleTransitionCallback(mock())
+            }
+
+            verify(choreographer).postFrameCallback(any())
+        }
+
+        it("should transition to idle when preregistered choreographer is dispatched") {
+            givenIdleStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(callback).onTransitionToIdle()
+        }
+
+        it("should NOT transition to idle if not idle when preregistered choreographer is dispatched") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(callback, never()).onTransitionToIdle()
+        }
+
+        it("should re-register choreographer if found idle while preregistered choreographer is dispatched") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            val uut = uut()
+            uut.registerIdleTransitionCallback(callback)
+            invokeChoreographerCallback()
+
+            verify(choreographer, times(2)).postFrameCallback(any())
+        }
+
+        it("should adhere to pausing also when invoked via choreographer callback") {
+            givenBusyStrategy()
+
+            val callback = anIdlingResourceCallback()
+
+            uut().apply {
+                pause()
+                registerIdleTransitionCallback(callback)
+            }
+            val runtimeChoreographerCallback = getChoreographerCallback()
+
+            reset(callback, choreographer)
+            runtimeChoreographerCallback.doFrame(0L)
+
+            verify(callback, never()).onTransitionToIdle()
+            verify(choreographer, never()).postFrameCallback(any())
+        }
+
+        it("should enqueue an additional idle check (using choreographer) if found busy") {
+            givenBusyStrategy()
+            uut().isIdleNow
+            verify(choreographer).postFrameCallback(any())
+        }
+
+        it("should NOT enqueue an additional idle check (using choreographer) if found idle") {
+            givenIdleStrategy()
+            uut().isIdleNow
+            verify(choreographer, never()).postFrameCallback(any())
+        }
+    }
+})
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index 1b1d3f2e8..e037b321c 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -1,4 +1,5 @@
 const schemes = require('./configurations.mock');
+
 describe('index', () => {
   let detox;
   let mockDevice;
@@ -24,7 +25,13 @@ describe('index', () => {
       .mock('./devices/Device')
       .mock('./utils/logger')
       .mock('./client/Client')
-      .mock('./Detox', () => jest.fn(() => mockDetox))
+      .mock('./Detox', () => {
+        const MissingDetox = require('./utils/MissingDetox');
+        const none = new MissingDetox();
+        none.cleanupContext = () => {}; // avoid ruining global state
+
+        return Object.assign(jest.fn(() => mockDetox), { none });
+      });
 
     process.env.DETOX_UNIT_TEST = true;
     detox = require('./index');
@@ -39,6 +46,14 @@ describe('index', () => {
       .unmock('./Detox')
   });
 
+  it(`constructs detox without globals if initGlobals = false`, async () => {
+    const Detox = require('./Detox');
+
+    await detox.init(schemes.validOneDeviceNoSession, { initGlobals: false });
+
+    expect('by' in global).toBe(false);
+  });
+
   it(`throws if there was no config passed`, async () => {
     const logger = require('./utils/logger');
     let exception = undefined;
@@ -185,8 +200,9 @@ describe('index', () => {
   it(`Basic usage with memorized exported objects`, async() => {
     const { device, by } = detox;
 
-    expect(device.launchApp).toBe(undefined);
-    expect(by.id).toBe(undefined);
+    expect(() => { device, by }).not.toThrowError();
+    expect(() => { device.launchApp }).toThrowError();
+    expect(() => { by.id }).toThrowError();
 
     await detox.init(schemes.validOneDeviceNoSession);
 
@@ -195,8 +211,9 @@ describe('index', () => {
 
     await detox.cleanup();
 
-    expect(device.launchApp).toBe(undefined);
-    expect(by.id).toBe(undefined);
+    expect(() => { device, by }).not.toThrowError();
+    expect(() => { device.launchApp }).toThrowError();
+    expect(() => { by.id }).toThrowError();
   });
 
   it(`Basic usage, do not throw an error if cleanup is done twice`, async() => {
@@ -221,7 +238,14 @@ describe('index', () => {
   });
 
   it(`beforeEach() should be covered - with detox not initialized`, async() => {
-    await detox.beforeEach();
+    await expect(detox.beforeEach()).rejects.toThrow(/Make sure to call/);
+  });
+
+  it(`error message should be covered - with detox failed to initialize`, async() => {
+    mockDetox.init.mockReturnValue(Promise.reject(new Error('Test error')));
+
+    await expect(detox.init(schemes.validOneDeviceNoSession)).rejects.toThrow(/Test error/);
+    await expect(detox.beforeEach()).rejects.toThrow(/There was an error/);
   });
 
   it(`afterEach() should be covered - with detox initialized`, async() => {
@@ -230,7 +254,6 @@ describe('index', () => {
   });
 
   it(`afterEach() should be covered - with detox not initialized`, async() => {
-    await detox.afterEach();
+    await expect(detox.afterEach()).rejects.toThrow(/Make sure to call/);
   });
-
 });
diff --git a/detox/test/package.json b/detox/test/package.json
index bf4b7b2fc..9306b655b 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "15.1.2",
+  "version": "15.1.3",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^15.1.2",
+    "detox": "^15.1.3",
     "express": "^4.15.3",
     "jest": "24.9.x",
     "jest-circus": "24.9.x",