Repository: wix/Detox (js)

Issue:
Introduce basic support for push notifications mocking on Android
### Description

Define and implement an API for mocking push notification invocation events on Android, so as to introduce parity with the equivalent API for iOS.

This has long been a missing functionality on Android compared to iOS, as - unlike on iOS, there's no skeletal definition of notification invocation events. Rather, on Android, notifications fire pending intents, originally produced upon notification arrival handling *by the app itself*; All the while there's also no restriction as to how and when notifications show, and what the nature of the registered pending intent is.

The general direction is towards formalizing a reduced intents API, to be used by the JS test code.

#### Phase 1

In this basic-support phase, we limit the functionality to a naive one where the same intent is always used*, and the user is only able to provide payload, using the API's already defined for iOS.

This boils down to 3 types of use cases:

```js
    const userNotification = {
      payload: {
        userData1: 'userDataValue1',
        userData2: 'userDataValue2',
      },
    };

    // #1 - most common
    await device.launchApp({ newInstance: true, userNotification });

    // #2 - `launchApp` used for a running app (a not very appealing API overall)
    await device.launchApp({ newInstance: false, userNotification });

    // #3 - The dedicated function
    await device.launchApp({newInstance: true});
    await device.sendUserNotification(userNotification);
```
> Note: while `sendUserNotification()` is an API that's not very idiomatic on Android, we use it here nonetheless, assuming we will abandon all of this in phase 2 (see below).

#### Phase 2

For the full vision and more versatile Android-idiomatic solution, see #2141.

---
<sup> * This refers to a simply, launcher-like intent, with user payload "flat"-set in its extras -- much like the way the default FCM impl. handles *message* notifications on the device.</sup>

Pull request description:
Update docs across the board with respect to notification support on Android
- [ ] This is a small change 
- [ ] This change has been discussed in issue #1192 and the solution has been agreed upon with maintainers.

---

**Description:**
Updates the missing documentation with respect to changes introduced in #2134 and #2138, both associated with #1192 (i.e. android notification mocking support). Resolves #1192.

Patch:
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index feb3a44ef..da2a3cb8e 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -11,7 +11,7 @@ const AAPT = require('./exec/AAPT');
 const APKPath = require('./tools/APKPath');
 const TempFileXfer = require('./tools/TempFileXfer');
 const AppUninstallHelper = require('./tools/AppUninstallHelper');
-const Instrumentation = require('./tools/Instrumentation');
+const MonitoredInstrumentation = require('./tools/MonitoredInstrumentation');
 const DetoxApi = require('../../../android/espressoapi/Detox');
 const EspressoDetoxApi = require('../../../android/espressoapi/EspressoDetox');
 const UiDeviceProxy = require('../../../android/espressoapi/UiDeviceProxy');
@@ -22,12 +22,10 @@ const ADBScreenrecorderPlugin = require('../../../artifacts/video/ADBScreenrecor
 const AndroidDevicePathBuilder = require('../../../artifacts/utils/AndroidDevicePathBuilder');
 const TimelineArtifactPlugin = require('../../../artifacts/timeline/TimelineArtifactPlugin');
 const temporaryPath = require('../../../artifacts/utils/temporaryPath');
-const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
 const AndroidExpect = require('../../../android/expect');
-const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
 
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
@@ -42,10 +40,7 @@ class AndroidDriver extends DeviceDriverBase {
     this.fileXfer = new TempFileXfer(this.adb);
     this.devicePathBuilder = new AndroidDevicePathBuilder();
 
-    this.instrumentationLogsParser = null;
-    this.instrumentationStackTrace = '';
-    this.instrumentationCloseListener = _.noop;
-    this.instrumentation = new Instrumentation(this.adb, logger);
+    this.instrumentation = new MonitoredInstrumentation(this.adb, logger);
   }
 
   declareArtifactPlugins() {
@@ -107,15 +102,9 @@ class AndroidDriver extends DeviceDriverBase {
 
   async waitUntilReady() {
       try {
-        await Promise.race([
-          super.waitUntilReady(),
-          new Promise((resolve, reject) => {
-            this.instrumentationCloseListener = () => reject(this._getInstrumentationCrashError());
-            !this.instrumentation.isRunning() && this.instrumentationCloseListener();
-          }),
-        ]);
+        await Promise.race([super.waitUntilReady(), this.instrumentation.waitForCrash()]);
       } finally {
-        this.instrumentationCloseListener = _.noop;
+        this.instrumentation.abortWaitForCrash();
       }
   }
 
@@ -243,30 +232,16 @@ class AndroidDriver extends DeviceDriverBase {
     const serverPort = new URL(this.client.configuration.server).port;
     await this.adb.reverse(deviceId, serverPort);
 
-    this.instrumentationLogsParser = new InstrumentationLogsParser();
-    this.instrumentation.setTerminationFn(() => this._onInstrumentationTerminated(deviceId, serverPort));
-    this.instrumentation.setLogListenFn(this._extractStackTraceFromInstrumLogs.bind(this));
+    this.instrumentation.setTerminationFn(async () => {
+      await this._terminateInstrumentation();
+      await this.adb.reverseRemove(deviceId, serverPort);
+    });
     await this.instrumentation.launch(deviceId, bundleId, userLaunchArgs);
   }
 
-  async _onInstrumentationTerminated(deviceId, serverPort) {
-    await this.adb.reverseRemove(deviceId, serverPort);
-    this.instrumentationCloseListener();
-    this.instrumentation.clearAllCallbackFn();
-  }
-
   async _terminateInstrumentation() {
     await this.instrumentation.terminate();
-    this.instrumentationCloseListener();
-    this.instrumentation.clearAllCallbackFn();
-  }
-
-  _extractStackTraceFromInstrumLogs(logsDump) {
-    this.instrumentationLogsParser.parse(logsDump);
-
-    if (this.instrumentationLogsParser.containsStackTraceLog(logsDump)) {
-      this.instrumentationStackTrace = this.instrumentationLogsParser.getStackTrace(logsDump);
-    }
+    await this.instrumentation.setTerminationFn(null);
   }
 
   async _sendNotificationDataToDevice(dataFileLocalPath, deviceId) {
@@ -307,20 +282,6 @@ class AndroidDriver extends DeviceDriverBase {
     }
     return pid;
   }
-
-  _getInstrumentationCrashError() {
-    return new DetoxRuntimeError({
-      message: 'Failed to run application on the device',
-      hint: this.instrumentationStackTrace
-        ? 'Most likely, your main activity has crashed prematurely.'
-        : 'Most likely, your tests have timed out and called detox.cleanup() ' +
-        'while it was waiting for "ready" message (over WebSocket) ' +
-        'from the instrumentation process.',
-      debugInfo: this.instrumentationStackTrace
-        ? `Native stacktrace dump: ${this.instrumentationStackTrace}`
-        : '',
-    });
-  }
 }
 
 module.exports = AndroidDriver;
diff --git a/detox/src/devices/drivers/android/tools/Instrumentation.js b/detox/src/devices/drivers/android/tools/Instrumentation.js
index 77714e707..b7f9f36aa 100644
--- a/detox/src/devices/drivers/android/tools/Instrumentation.js
+++ b/detox/src/devices/drivers/android/tools/Instrumentation.js
@@ -34,19 +34,6 @@ class Instrumentation {
     return !!this.instrumentationProcess;
   }
 
-  setTerminationFn(userTerminationFn) {
-    this.userTerminationFn = userTerminationFn || _.noop;
-  }
-
-  setLogListenFn(userLogListenFn) {
-    this.userLogListenFn = userLogListenFn || _.noop;
-  }
-
-  clearAllCallbackFn() {
-    this.setTerminationFn(null);
-    this.setLogListenFn(null);
-  }
-
   _getSpawnArgs(userLaunchArgs) {
     const launchArgs = prepareInstrumentationArgs(userLaunchArgs);
     const additionalLaunchArgs = prepareInstrumentationArgs({ debug: false });
diff --git a/detox/src/devices/drivers/android/InstrumentationLogsParser.js b/detox/src/devices/drivers/android/tools/InstrumentationLogsParser.js
similarity index 100%
rename from detox/src/devices/drivers/android/InstrumentationLogsParser.js
rename to detox/src/devices/drivers/android/tools/InstrumentationLogsParser.js
diff --git a/detox/src/devices/drivers/android/InstrumentationLogsParser.test.js b/detox/src/devices/drivers/android/tools/InstrumentationLogsParser.test.js
similarity index 100%
rename from detox/src/devices/drivers/android/InstrumentationLogsParser.test.js
rename to detox/src/devices/drivers/android/tools/InstrumentationLogsParser.test.js
diff --git a/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.js b/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.js
new file mode 100644
index 000000000..c88d4b1fe
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.js
@@ -0,0 +1,82 @@
+const _ = require('lodash');
+const Instrumentation = require('./Instrumentation');
+const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
+const Deferred = require('../../../../utils/Deferred');
+const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
+
+class MonitoredInstrumentation {
+  constructor(adb, logger) {
+    this.instrumentationLogsParser = null;
+    this.instrumentationStackTrace = '';
+    this.instrumentation = new Instrumentation(adb, logger, this._onInstrumentationTerminated.bind(this), this._onInstrumentationLogData.bind(this));
+    this.userTerminationFn = _.noop;
+    this.pendingPromise = Deferred.resolved();
+  }
+
+  async launch(deviceId, bundleId, userLaunchArgs) {
+    this.instrumentationLogsParser = new InstrumentationLogsParser();
+    await this.instrumentation.launch(deviceId, bundleId, userLaunchArgs);
+  }
+
+  setTerminationFn(userTerminationFn) {
+    this.userTerminationFn = userTerminationFn || _.noop;
+  }
+
+  waitForCrash() {
+    if (this.pendingPromise.isPending()) {
+      return this.pendingPromise.promise;
+    }
+
+    this.pendingPromise = new Deferred();
+    if (!this.instrumentation.isRunning()) {
+      this._rejectPendingCrashPromise();
+    }
+    return this.pendingPromise.promise;
+  }
+
+  abortWaitForCrash() {
+    this.pendingPromise.resolve();
+  }
+
+  isRunning() {
+    return this.instrumentation.isRunning();
+  }
+
+  async terminate() {
+    await this.instrumentation.terminate();
+    this._rejectPendingCrashPromise();
+  }
+
+  async _onInstrumentationTerminated() {
+    this._rejectPendingCrashPromise();
+    await this.userTerminationFn();
+  }
+
+  _rejectPendingCrashPromise() {
+    this.pendingPromise.reject(this._getInstrumentationCrashError());
+  }
+
+  _onInstrumentationLogData(logsDump) {
+    this.instrumentationLogsParser.parse(logsDump);
+
+    if (this.instrumentationLogsParser.containsStackTraceLog(logsDump)) {
+      this.instrumentationStackTrace = this.instrumentationLogsParser.getStackTrace(logsDump);
+    }
+  }
+
+  _getInstrumentationCrashError() {
+    return new DetoxRuntimeError({
+      message: 'Failed to run application on the device',
+      hint: this.instrumentationStackTrace
+        ? 'Most likely, your main activity has crashed prematurely.'
+        : 'Most likely, your tests have timed out and called detox.cleanup() ' +
+        'while it was waiting for "ready" message (over WebSocket) ' +
+        'from the instrumentation process.',
+      debugInfo: this.instrumentationStackTrace
+        ? `Native stacktrace dump: ${this.instrumentationStackTrace}`
+        : '',
+    });
+  }
+}
+
+module.exports = MonitoredInstrumentation;
diff --git a/detox/src/utils/Deferred.js b/detox/src/utils/Deferred.js
index c9918d3a9..2a92c7a82 100644
--- a/detox/src/utils/Deferred.js
+++ b/detox/src/utils/Deferred.js
@@ -18,6 +18,18 @@ class Deferred {
     this.reject = this.reject.bind(this);
   }
 
+  static resolved(value) {
+    const deferred = new Deferred();
+    deferred.resolve(value);
+    return deferred;
+  }
+
+  static rejected(error) {
+    const deferred = new Deferred();
+    deferred.reject(error);
+    return deferred;
+  }
+
   get status() {
     return this[_status];
   }
@@ -26,6 +38,18 @@ class Deferred {
     return this[_promise];
   }
 
+  isPending() {
+    return this.status === Deferred.PENDING;
+  }
+
+  isResolved() {
+    return this.status === Deferred.RESOLVED;
+  }
+
+  isRejected() {
+    return this.status === Deferred.REJECTED;
+  }
+
   resolve(value) {
     if (this[_status] === Deferred.PENDING) {
       this[_resolve](value);
@@ -45,4 +69,4 @@ Deferred.PENDING = 'pending';
 Deferred.RESOLVED = 'resolved';
 Deferred.REJECTED = 'rejected';
 
-module.exports = Deferred;
\ No newline at end of file
+module.exports = Deferred;
diff --git a/docs/APIRef.DeviceObjectAPI.md b/docs/APIRef.DeviceObjectAPI.md
index 8a5d46018..d046def40 100644
--- a/docs/APIRef.DeviceObjectAPI.md
+++ b/docs/APIRef.DeviceObjectAPI.md
@@ -29,7 +29,7 @@ The value will be `undefined` until the device is properly _prepared_ (i.e. in `
 - [`device.uninstallApp()`](#deviceuninstallapp)
 - [`device.openURL(url)`](#deviceopenurlurl-sourceappoptional)
 - [`device.sendUserNotification(params)`](#devicesendusernotificationparams)
-- [`device.sendUserActivity(params)`](#devicesenduseractivityparams)
+- [`device.sendUserActivity(params)` **iOS Only**](#devicesenduseractivityparams)
 - [`device.setOrientation(orientation)`](#devicesetorientationorientation)
 - [`device.setLocation(lat, lon)`](#devicesetlocationlat-lon)
 - [`device.setURLBlacklist([urls])`](#deviceseturlblacklisturls)
@@ -60,7 +60,9 @@ Launch the app defined in the current [`configuration`](APIRef.Configuration.md)
 
 ##### 1. `newInstance`—Launching a New Instance of the App
 
-Terminates the app and launch it again. If set to `false`, the simulator will try to bring app from background, if the app isn't running, it will launch a new instance. Default is `false`.
+Terminate the app and launch it again. 
+
+If set to `false`, the device will try to resume the app (e.g. bring from foreground to background). If the app isn't running, **it will launch a new instance** nonetheless. **Default is `false`.**
 
 ```js
 await device.launchApp({newInstance: true});
@@ -80,29 +82,28 @@ Detox uses [AppleSimUtils](https://github.com/wix/AppleSimulatorUtils) to implem
 Launches the app with the specified URL to test your app's deep link handling mechanism.
 
 ```js
-await device.launchApp({url: url});
-await device.launchApp({url: url, newInstance: true}); //Launch a new instance of the app
-await device.launchApp({url: url, newInstance: false}); //Reuse existing instance
+await device.launchApp({url});
+await device.launchApp({url, newInstance: true}); // Launch a new instance of the app
+await device.launchApp({url, newInstance: false}); // Reuse existing instance
 ```
-This will launch the app from background and handle the deep link.
 
-Read more in [here](APIRef.MockingOpenWithURL.md).
+Read more [here](APIRef.MockingOpenWithURL.md). Go back to subsection 1 to read about the full effect of the `newInstance` argument.
 
-##### 4. `userNotification`—Launching with User Notifications (iOS Only)
+##### 4. `userNotification`—Launching with User Notifications
 
-Launches with the specified user notification
+Launches with the specified user notification.
 
 ```js
-await device.launchApp({userNotification: notification});
-await device.launchApp({userNotification: notification, newInstance: true}); //Launch a new instance of the app
-await device.launchApp({userNotification: notification, newInstance: false}); //Reuse existing instance
+await device.launchApp({userNotification});
+await device.launchApp({userNotification, newInstance: true}); // Launch a new instance of the app
+await device.launchApp({userNotification, newInstance: false}); // Reuse existing instance
 ```
 
-Read more in [here](APIRef.MockingUserNotifications.md).
+Read more [here](APIRef.MockingUserNotifications.md). Go back to subsection 1 to read about the full effect of the `newInstance` argument.
 
 ##### 5. `userActivity`—Launch with User Activity (iOS Only)
 
-Launches the app with the specified user activity
+Launches the app with the specified user activity.
 
 ```js
 await device.launchApp({userActivity: activity});
@@ -110,7 +111,7 @@ await device.launchApp({userActivity: activity, newInstance: true}); //Launch a
 await device.launchApp({userActivity: activity, newInstance: false}); //Reuse existing instance
 ```
 
-Read more in [here](APIRef.MockingUserActivity.md).
+Read more in [here](APIRef.MockingUserActivity.md). Go back to subsection 1 to read about the full effect of the `newInstance` argument.
 
 ##### 6. `delete`—Delete and Reinstall Application Before Launching
 
@@ -211,7 +212,7 @@ await device.launchApp({
 
 ### `device.relaunchApp(params)`
 
-**Deprecated:** Use `device.launchApp(params)` instead. This method is now calling `launchApp({newInstance: true})` for backwards compatibility.
+**Deprecated:** Use `device.launchApp(params)` instead. The method calls `launchApp({newInstance: true})` for backwards compatibility.
 
 ### `device.terminateApp()`
 By default, `terminateApp()` with no params will terminate the app file defined in the current [`configuration`](APIRef.Configuration.md).
@@ -258,16 +259,21 @@ await device.uninstallApp('other.bundle.id');
 ```
 
 ### `device.openURL({url, sourceApp[optional]})`
-Mock opening the app from URL. `sourceApp` is an optional parameter to specify source application bundle id.
+Mock opening the app from URL. `sourceApp` is an optional **iOS-only** parameter to specify source application bundle id.
+
 Read more in [Mocking Open From URL](APIRef.MockingOpenFromURL.md) section.
 Check out Detox's [own test suite](../detox/test/e2e/15.urls.test.js)
 
 ### `device.sendUserNotification(params)`
-Mock handling of received user notification when app is in foreground.
+Mock handling of a user notification previously received in the system, while the app is already running.
+
 Read more in [Mocking User Notifications](APIRef.MockingUserNotifications.md) section.
 Check out Detox's [own test suite](../detox/test/e2e/11.user-notifications.test.js)
 
 ### `device.sendUserActivity(params)`
+
+> iOS-only API
+
 Mock handling of received user activity when app is in foreground.
 Read more in [Mocking User Activity](APIRef.MockingUserActivity.md) section.
 Check out Detox's [own test suite](../detox/test/e2e/18.user-activities.test.js)
diff --git a/docs/APIRef.MockingOpenWithURL.md b/docs/APIRef.MockingOpenWithURL.md
index 7a603f97f..5e81cf71a 100644
--- a/docs/APIRef.MockingOpenWithURL.md
+++ b/docs/APIRef.MockingOpenWithURL.md
@@ -5,22 +5,19 @@ You can mock opening the app from URL to test your app's deep link handling mech
 ## Mocking App Launch with a URL
 
 ```js
-await device.launchApp({newInstance: true, url: url, sourceApp: bundleId}); //sourceApp is optional
+await device.launchApp({newInstance: true, url, sourceApp: bundleId}); // sourceApp is an optional iOS-only argument
 ```
 
 #### Example
 
 ```js
 describe('launch app from URL', () => {
-    before(async () => {
+    it('should handle URL successfully', async () => {
       await device.launchApp({
         newInstance: true,
         url: 'scheme://some.url',
         sourceApp: 'com.apple.mobilesafari'
       });
-    });
-
-    it('should tap successfully', async () => {
       await expect(element(by.text('a label'))).toBeVisible();
     });
   });
@@ -28,6 +25,8 @@ describe('launch app from URL', () => {
 
 ## Mocking Opening with a URL on a Launched App
 
+> iOS-only
+
 ```js
 await device.openURL({url: 'scheme://some.url', sourceApp: 'com.apple.mobilesafari'});
 ```
diff --git a/docs/APIRef.MockingUserNotifications.md b/docs/APIRef.MockingUserNotifications.md
index acff7e86a..2f564c204 100644
--- a/docs/APIRef.MockingUserNotifications.md
+++ b/docs/APIRef.MockingUserNotifications.md
@@ -1,12 +1,12 @@
 # Mocking User Notifications
 
-Detox supports mocking user notifications for iOS apps.
+Detox supports mocking user notifications.
 
->**Note:** The mocking mechanism will not mimic the UI of a user notification. Instead, it will only simulate a user interaction with the notification.
+>**Note:** The mocking mechanism will not mimic the UI of a user notification. Instead, it will only simulate a user interaction with the notification - namely, the "opening" of it (equivalent to a user's tap/swipe on it in the notification center).
 
 ## Mocking App Launch with a Notification
 
-Using `launchApp()` with custom params (e.g., `userNotification`) will trigger the mocking mechanism.
+`launchApp()` with custom parameters (i.e. `userNotification`) will trigger the mocking mechanism.
 
 ```js
 await device.launchApp({newInstance: true, userNotification: notification});
@@ -15,15 +15,12 @@ await device.launchApp({newInstance: true, userNotification: notification});
 #### Example
 
 ```js
-describe('Background push notification', () => {
-  beforeEach(async () => {
+describe('Launch with push notification', () => {
+  it('should handle the notification', async () => {
     await device.launchApp({
       newInstance: true,
       userNotification: userNotificationPushTrigger,
     });
-  });
-
-  it('push notification from background', async () => {
     await expect(element(by.text('From push'))).toBeVisible();
   });
 });
@@ -31,49 +28,46 @@ describe('Background push notification', () => {
 
 ## Mocking Notification Reception on a Running App
 
-Use the `sendUserNotification()` method to send notification to running app. Notifications can be sent to an active or a background app.
+Use the `sendUserNotification()` method to send notification to **running** app. Notifications can be sent to an active or a background app.
+
+> Note: while the name `sendUserNotification()` is not very idiodmatic on Android, as notification data is not "sent" to apps (rather, it is bundled into an Activity/Service launch Intent as Intent-extras), this unified API is used, for the time being, for both platforms equivalently. With [plans of a more extensive support](https://github.com/wix/Detox/issues/2141) for Android, we estimate it would be deprecated when the time comes.
 
 ```js
-await device.sendUserNotification(notification)
+await device.sendUserNotification(notification);
 ```
 
 **Example:**
 
 ```js
 describe('Foreground user notifications', () => {
-
-beforeEach(async () => {
-  await device.launchApp();
-});
-
-it('Local notification from inside the app', async () => {
-  await device.sendUserNotification(localNotification);
-  await expect(element(by.text('from local notificaton'))).toBeVisible();
- });
+  it('should handle the local notification from inside the app', async () => {
+    await device.launchApp();
+    await device.sendUserNotification(localNotification);
+    await expect(element(by.text('from local notificaton'))).toBeVisible();
+   });
 });
 ```
 
 ## Notification JSON Format
 
+User notifications are passed as JSON objects to Detox. The JSON object needs to provide some required data, but can also provide an additional, optional payload.
 
-User notifications are passed as JSON objects to Detox. The JSON object needs to provide some required data, but can also provide additional, optional data.
+**Mind the major difference here between the two platforms.** While on iOS many types of data fields are applicable, Android is very loosely defined - with support for just free-form user data in the `payload` field.
 
-<!--- Use http://www.tablesgenerator.com/markdown_tables to edit these tables. --->
+| Key | Required | Value Type | Platform | Description |
+|---------------------|----------|------------|---------------------------------------------------------------------------------------------------|---------------------|
+| `trigger` | Yes | Object | iOS | The conditions that trigger the delivery of the notification. See the [Triggers section](https://github.com/wix/detox/wiki/User-Notifications-JSON-Format-Documentation#triggers) below. |
+| `title` | No | String | iOS | A short description of the reason for the alert. |
+| `subtitle` | No | String | iOS | A secondary description of the reason for the alert. |
+| `body` | No | String | iOS | The body of the notification. |
+| `badge` | No | Integer | iOS | The number to display as the app’s icon badge. |
+| `payload` | iOS: No<br />Android: Yes | Object | iOS & Android | An object of custom information associated with the notification.<br />Android: see [full description below](#Payload) |
+| `category` | No | String | iOS | The identifier of the app-defined category object. |
+| `content-available` | No | Integer | iOS | Include this key with a value of 1 to configure a silent notification. |
+| `user-text` | No | String | iOS | The text response provided by the user. |
+| `action-identifier` | No | String | iOS | The identifier for the action that the user selected. |
 
-| Key | Required | Value Type | Description |
-|---------------------|----------|------------|---------------------------------------------------------------------------------------------------|
-| `trigger` | Yes | Object | The conditions that trigger the delivery of the notification. See the [Triggers section](https://github.com/wix/detox/wiki/User-Notifications-JSON-Format-Documentation#triggers) below. |
-| `title` | No | String | A short description of the reason for the alert. |
-| `subtitle` | No | String | A secondary description of the reason for the alert. |
-| `body` | No | String | The body of the notification. |
-| `badge` | No | Integer | The number to display as the app’s icon badge. |
-| `payload` | No | Object | An object of custom information associated with the notification. |
-| `category` | No | String | The identifier of the app-defined category object. |
-| `content-available` | No | Integer | Include this key with a value of 1 to configure a silent notification. |
-| `user-text` | No | String | The text response provided by the user. |
-| `action-identifier` | No | String | The identifier for the action that the user selected. |
-
-### Triggers
+### Triggers (iOS-only)
 
 Triggers are objects representing the trigger.
 
@@ -106,7 +100,7 @@ const userNotification = {
 }
 ```
 
-### Date Components
+### Date Components (iOS-only)
 
 | Key | Required | Value Type | Description |
 |------------------|----------|------------|-------------------------------------------------------|
@@ -123,7 +117,7 @@ const userNotification = {
 | `weekOfMonth` | No | Integer | The week number of the month for the receiver. |
 | `leapMonth` | No | Boolean | Indicates whether the month is a leap month. |
 
-### Region
+### Region (iOS-only)
 
 | Key | Required | Value Type | Description |
 |-----------------|----------|------------|------------------------------------------------------------------------------------|
@@ -132,13 +126,48 @@ const userNotification = {
 | `notifyOnEntry` | No | Boolean | Indicates that notifications are generated upon entry into the region. |
 | `notifyOnExit` | No | Boolean | Indicates that notifications are generated upon exit from the region. |
 
-### Coordinate
+### Coordinate (iOS-only)
 
 | Key | Required | Value Type | Description |
 |-------------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `latitude` | Yes | Number | The latitude in degrees. Positive values indicate latitudes north of the equator. Negative values indicate latitudes south of the equator. |
 | `longitude` | Yes | Number | The longitude in degrees. Measurements are relative to the zero meridian, with positive values extending east of the meridian and negative values extending west of the meridian. |
 
+### Payload
+
+On Android, the content will be available via the activity's [`getIntent()`](https://developer.android.com/reference/android/app/Activity#getIntent()) API, inside the intent's _extra_ bundle. Under some limitations, that includes data-cascading so as to provide comprehensive support for Javascript's advanced object-hierarchy capabilities as much as possible. As an example, consider this payload:
+
+```javascript
+  const userNotification = {
+    payload: {
+      userData: 'userDataValue',
+      userDataNum: 111.2,
+      userDataFlag: true,
+      userDataArray: ['rock', 'paper', 'scissors'],
+      userDataObj: {
+        cascadedKey: 'cascadedValue'
+      },
+    },
+  };
+
+```
+
+The outcome on the native side will be such that all of these conditions evaluate to _true_:
+
+```java
+activity.getIntent().getStringExtra("userData") == "userDataValue";
+activity.getIntent().getDoubleExtra("userDataNum") == 111.2;
+activity.getIntent().getBooleanExtra("userDataFlag") == true;
+activity.getIntent().getStringArrayExtra("userDataArray")[0] == "rock";
+activity.getIntent().getBundleExtra("userDataObj").getString("cascadedKey") == "cascadedValue";
+```
+
+#### Handling at Runtime
+
+Note that on Android, data delivered through an intent at runtime, is typically received in your activity's [`onNewIntent`](https://developer.android.com/reference/android/app/Activity#onNewIntent(android.content.Intent)) callback. Be sure to consider what should be done in order to handle this type of a use case in your app: Namely, that `setIntent()` should be called in order for the data to be later available in your app through `getIntent()`, as explained earlier.
+
+>  **This isn't related to Detox in particular**, and is set here simply to help you consider all the use cases in your app so that tests coverage would be optimal.
+
 ### Examples
 
 1. [Calendar Trigger](https://github.com/wix/detox/blob/master/detox/ios/DetoxUserNotificationTests/user_notification_calendar_trigger.json)


Test patch:
diff --git a/detox/src/devices/drivers/android/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
index 67466d6b1..6017fc15d 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -2,6 +2,7 @@ describe('Android driver', () => {
 
   const deviceId = 'device-id-mock';
   const bundleId = 'bundle-id-mock';
+  const detoxServerPort = 1234;
   const mockNotificationDataTargetPath = '/ondevice/path/to/notification.json';
 
   const mockGetAbsoluteBinaryPathImpl = (x) => `absolutePathOf(${x})`;
@@ -22,28 +23,13 @@ describe('Android driver', () => {
     }
   }
 
-  class MockAsyncEmitterClass {
-    constructor() {
-      this.emit = jest.fn();
-    }
-  }
-
-  class MockInstrumentationLogsParserClass {
-    constructor() {
-      this.parse = () => {};
-      this.containsStackTraceLog = () => true;
-      this.getStackTrace = () => MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK;
-    }
-  }
-  MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK = 'Stacktrace mock';
-
   class MockInvocationManagerClass {
     constructor() {
       Object.assign(this, invocationManager);
     }
   }
 
-  class MockInstrumentationClass {
+  class MockMonitoredInstrumentationClass {
     constructor(...args) {
       instrumentation.mockCtor(...args);
       Object.assign(this, instrumentation);
@@ -55,8 +41,8 @@ describe('Android driver', () => {
   let getAbsoluteBinaryPath;
   let fs;
   let exec;
+  let emitter;
   let detoxApi;
-  let instrumentationArgs;
   let instrumentation;
   beforeEach(() => {
     jest.mock('fs', () => ({
@@ -72,10 +58,6 @@ describe('Android driver', () => {
     jest.mock('../../../utils/sleep', () => jest.fn().mockResolvedValue(''));
     jest.mock('../../../utils/retry', () => jest.fn().mockResolvedValue(''));
 
-    jest.mock('./InstrumentationLogsParser', () => ({
-      InstrumentationLogsParser: MockInstrumentationLogsParserClass,
-    }));
-
     jest.mock('../../../utils/getAbsoluteBinaryPath', () =>
       jest.fn().mockImplementation((x) => `absolutePathOf(${x})`),
     );
@@ -101,23 +83,24 @@ describe('Android driver', () => {
 
     client = {
       configuration: {
-        server: 'ws://localhost:1234'
+        server: `ws://localhost:${detoxServerPort}`
       },
       waitUntilReady: jest.fn(),
     };
 
+    emitter = {
+      emit: jest.fn(),
+      off: jest.fn(),
+    };
+
     jest.mock('../../../android/espressoapi/Detox');
     detoxApi = require('../../../android/espressoapi/Detox');
 
-    jest.mock('./tools/instrumentationArgs');
-    instrumentationArgs = require('./tools/instrumentationArgs');
-    instrumentationArgs.prepareInstrumentationArgs.mockReturnValue({args: [], usedReservedArgs: []});
-
-    const Instrumentation = jest.genMockFromModule('./tools/Instrumentation');
-    instrumentation = new Instrumentation();
+    const MonitoredInstrumentation = jest.genMockFromModule('./tools/MonitoredInstrumentation');
+    instrumentation = new MonitoredInstrumentation();
     instrumentation.mockCtor = jest.fn();
     mockInstrumentationDead();
-    jest.mock('./tools/Instrumentation', () => MockInstrumentationClass);
+    jest.mock('./tools/MonitoredInstrumentation', () => MockMonitoredInstrumentationClass);
   });
 
   let adb;
@@ -159,7 +142,7 @@ describe('Android driver', () => {
     const AndroidDriver = require('./AndroidDriver');
     uut = new AndroidDriver({
       client,
-      emitter: new MockAsyncEmitterClass(),
+      emitter,
     });
   });
 
@@ -184,6 +167,64 @@ describe('Android driver', () => {
         fail();
       } catch (e) {}
     });
+
+    it('should set a termination callback function', async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+      expect(instrumentation.setTerminationFn).toHaveBeenCalledWith(expect.any(Function));
+    });
+
+    it('should adb-reverse the detox server port', async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+      await expect(adb.reverse).toHaveBeenCalledWith(deviceId, detoxServerPort.toString());
+    });
+  });
+
+  describe('Instrumentation unexpected termination', () => {
+    beforeEach(async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+      await invokeTerminationCallbackFn();
+    });
+
+    it('should clear out the termination callback function', () =>
+      expect(instrumentation.setTerminationFn).toHaveBeenCalledWith(null));
+
+    it('should adb-unreverse the detox server port', () =>
+      expect(adb.reverseRemove).toHaveBeenCalledWith(deviceId, detoxServerPort.toString()));
+
+    const extractTerminationCallbackFn = () => instrumentation.setTerminationFn.mock.calls[0][0];
+    const invokeTerminationCallbackFn = async () => {
+      const fn = extractTerminationCallbackFn();
+      await fn();
+    }
+  });
+
+  describe('App termination', () => {
+    beforeEach(async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+      await uut.terminate();
+    });
+
+    it('should terminate instrumentation', () =>
+      expect(instrumentation.terminate).toHaveBeenCalled());
+
+    it('should clear out the termination callback function', () =>
+      expect(instrumentation.setTerminationFn).toHaveBeenCalledWith(null));
+
+    it('should terminate ADB altogether', () =>
+      expect(adb.terminate).toHaveBeenCalled());
+  });
+
+  describe('Cleanup', () => {
+    beforeEach(async () => await uut.cleanup());
+
+    it('should terminate instrumentation', () =>
+      expect(instrumentation.terminate).toHaveBeenCalled());
+
+    it('should clear out the termination callback function', () =>
+      expect(instrumentation.setTerminationFn).toHaveBeenCalledWith(null));
+
+    it('should turn off the events emitter', () =>
+      expect(emitter.off).toHaveBeenCalled());
   });
 
   describe('URL runtime delivery handling', () => {
@@ -362,45 +403,59 @@ describe('Android driver', () => {
 
   describe('Device ready-wait', () => {
     it('should delegate wait to device being ready via client api', async () => {
-      mockInstrumentationRunning();
       await uut.waitUntilReady();
       expect(client.waitUntilReady).toHaveBeenCalled();
     }, 2000);
 
     it('should fail if instrumentation async\'ly-dies prematurely while waiting for device-ready resolution', async () => {
+      const crashError = new Error('mock instrumentation crash error');
+      let waitForCrashReject = () => {};
+      instrumentation.waitForCrash.mockImplementation(() => {
+        return new Promise((__, reject) => {
+          waitForCrashReject = reject;
+        });
+      });
+
       await uut.launchApp(deviceId, bundleId, {}, '');
 
-      mockInstrumentationRunning();
       const clientWaitResolve = mockDeviceReadyPromise();
-
       const promise = uut.waitUntilReady();
-      setTimeout(fakeInstrumentationTermination, 1);
+      setTimeout(() => waitForCrashReject(crashError), 1);
 
       try {
         await promise;
         fail('Expected an error and none was thrown');
       } catch (e) {
-        expect(e.toString()).toContain('DetoxRuntimeError: Failed to run application on the device');
-        expect(e.toString()).toContain(`Native stacktrace dump: ${MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK}`);
+        expect(e).toEqual(crashError);
       } finally {
         clientWaitResolve();
       }
     }, 2000);
 
+    it('should abort crash-wait if instrumentation doesnt crash', async () => {
+      client.waitUntilReady.mockResolvedValue('mocked');
+      await uut.waitUntilReady();
+      expect(instrumentation.abortWaitForCrash).toHaveBeenCalled();
+    });
+
+    it('should abort crash-wait if instrumentation crashes', async () => {
+      client.waitUntilReady.mockResolvedValue('mocked');
+      instrumentation.waitForCrash.mockRejectedValue(new Error());
+
+      await uut.launchApp(deviceId, bundleId, {}, '');
+      try {
+        await uut.waitUntilReady();
+        fail();
+      } catch (e) {
+        expect(instrumentation.abortWaitForCrash).toHaveBeenCalled();
+      }
+    });
+
     const mockDeviceReadyPromise = () => {
       let clientResolve;
       client.waitUntilReady.mockReturnValue(new Promise((resolve) => clientResolve = resolve));
       return clientResolve;
     };
-
-    const fakeInstrumentationTermination = async () => {
-      const instrumentationLoggingCallback = instrumentation.setLogListenFn.mock.calls[0][0];
-      await instrumentationLoggingCallback('Doesnt matter what we put here because actual stacktrace should be read from logs-parser');
-
-      const instrumentationTerminationCallback = instrumentation.setTerminationFn.mock.calls[0][0];
-      await instrumentationTerminationCallback();
-      mockInstrumentationDead();
-    };
   });
 
   describe('App installation', () => {
diff --git a/detox/src/devices/drivers/android/tools/Instrumentation.test.js b/detox/src/devices/drivers/android/tools/Instrumentation.test.js
index c6b4a1233..31089ed68 100644
--- a/detox/src/devices/drivers/android/tools/Instrumentation.test.js
+++ b/detox/src/devices/drivers/android/tools/Instrumentation.test.js
@@ -211,53 +211,11 @@ describe('Instrumentation', () => {
     });
   });
 
-  it('should allow for runtime setup of termination callback', async () => {
-    const runtimeCallback = jest.fn();
-
+  it('should work with no user callbacks registration', async () => {
     const Instrumentation = require('./Instrumentation');
     uut = new Instrumentation(adb, logger, undefined, undefined);
-    uut.setTerminationFn(runtimeCallback);
-    await uut.launch(deviceId, bundleId, []);
-    await invokeTerminationCallback();
-
-    expect(runtimeCallback).toHaveBeenCalled();
-  });
-
-  it('should allow for clearing of termination callback', async () => {
-    uut.setTerminationFn(null);
     await uut.launch(deviceId, bundleId, []);
     await uut.terminate();
-    expect(userTerminationCallback).not.toHaveBeenCalled();
-  });
-
-  it('should allow for runtime setup of log-tapping callback', async () => {
-    const runtimeCallback = jest.fn();
-
-    const Instrumentation = require('./Instrumentation');
-    uut = new Instrumentation(adb, logger, undefined, undefined);
-    await uut.launch(deviceId, bundleId, []);
-
-    uut.setLogListenFn(runtimeCallback);
-    await invokeDataCallbackWith('mock data');
-
-    expect(runtimeCallback).toHaveBeenCalledWith('mock data');
-  });
-
-  it('should allow for clearing of log-tapping callback', async () => {
-    uut.setLogListenFn(null);
-    await uut.launch(deviceId, bundleId, []);
-    await invokeDataCallbackWith('data');
-    expect(logListenCallback).not.toHaveBeenCalled();
-  });
-
-  it('should allow for clearing of all user-callbacks', async () => {
-    uut.clearAllCallbackFn();
-
-    await uut.launch(deviceId, bundleId, []);
-    await invokeDataCallbackWith('data');
-    await invokeTerminationCallback();
-    expect(userTerminationCallback).not.toHaveBeenCalled();
-    expect(logListenCallback).not.toHaveBeenCalled();
   });
 
   const extractDataCallback = () => childProcess.stdout.on.mock.calls[0][1];
diff --git a/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.test.js b/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.test.js
new file mode 100644
index 000000000..d0393ce0c
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/MonitoredInstrumentation.test.js
@@ -0,0 +1,231 @@
+describe('Monitored instrumentation', () => {
+  const deviceId = 'mock-device-id';
+  const bundleId = 'mock-bundle-id';
+
+  class MockInstrumentationClass {
+    constructor(...args) {
+      instrumentation.mockCtor(...args);
+      Object.assign(this, instrumentation);
+    }
+  }
+
+  class MockInstrumentationLogsParserClass {
+    constructor() {
+      Object.assign(this, instrumentationLogsParser);
+    }
+  }
+  MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK = 'Stacktrace mock';
+
+  let adb;
+  let logger;
+  beforeEach(() => {
+    const ADB = jest.genMockFromModule('../exec/ADB');
+    adb = new ADB();
+
+    logger = {};
+  });
+
+  let instrumentation;
+  let instrumentationLogsParser;
+  beforeEach(() => {
+
+    const Instrumentation = jest.genMockFromModule('./Instrumentation');
+    instrumentation = new Instrumentation();
+    instrumentation.mockCtor = jest.fn();
+    jest.mock('./Instrumentation', () => MockInstrumentationClass);
+
+    const { InstrumentationLogsParser } = jest.genMockFromModule('./InstrumentationLogsParser');
+    instrumentationLogsParser = new InstrumentationLogsParser();
+    instrumentationLogsParser.getStackTrace.mockReturnValue(MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK);
+    jest.mock('./InstrumentationLogsParser', () => ({
+      InstrumentationLogsParser: MockInstrumentationLogsParserClass,
+    }));
+  });
+
+  let uut;
+  beforeEach(() => {
+    const MonitoredInstrumentation = require('./MonitoredInstrumentation');
+    uut = new MonitoredInstrumentation(adb, logger);
+  });
+
+  it('should properly init the underlying instrumentation', () => {
+    expect(instrumentation.mockCtor).toHaveBeenCalledWith(adb, logger, expect.any(Function), expect.any(Function));
+  });
+
+  describe('launch', () => {
+    it('should launch the underlying instrumentation', async () => {
+      const launchArgs = {};
+      await uut.launch(deviceId, bundleId, launchArgs);
+      expect(instrumentation.launch).toHaveBeenCalledWith(deviceId, bundleId, launchArgs);
+    });
+
+    it('should break if underlying launch fails', async () => {
+      instrumentation.launch.mockRejectedValue(new Error());
+
+      try {
+        await uut.launch(deviceId, bundleId, {});
+        fail();
+      } catch (e) {}
+    });
+  });
+
+  describe('Unexpected termination', () => {
+    it('should invoke user termination callback', async () => {
+      const terminationFn = jest.fn();
+      uut.setTerminationFn(terminationFn);
+      await uut.launch(deviceId, bundleId, {});
+      await invokeUnderlyingTerminationCallback();
+      expect(terminationFn).toHaveBeenCalled();
+    });
+  });
+
+  describe('Initiation termination', () => {
+    it('should terminate the underlying instrumentation', async () => {
+      await uut.launch(deviceId, bundleId, {});
+      await uut.terminate();
+      expect(instrumentation.terminate).toHaveBeenCalled();
+    });
+
+    it('should break if underlying termination fails', async () => {
+      instrumentation.terminate.mockRejectedValue(new Error());
+
+      await uut.launch(deviceId, bundleId, {});
+
+      try {
+        await uut.terminate();
+        fail();
+      } catch (e) {}
+    });
+
+    it('should allow for termination without launch', async () => {
+      await uut.terminate();
+      expect(instrumentation.terminate).toHaveBeenCalled();
+    });
+  });
+
+  it('should allow for user-initiated clearing of termination callback function', async () => {
+    const terminationFn = jest.fn();
+    uut.setTerminationFn(terminationFn);
+    await uut.launch(deviceId, bundleId, {});
+
+    uut.setTerminationFn(null);
+    await invokeUnderlyingTerminationCallback();
+    expect(terminationFn).not.toHaveBeenCalled();
+  });
+
+  it('should query underlying instrumentation for status', async () => {
+    mockUnderlyingInstrumentationRunning();
+    expect(uut.isRunning()).toEqual(true);
+
+    mockUnderlyingInstrumentationDead();
+    expect(uut.isRunning()).toEqual(false);
+  });
+
+  describe('Crash monitoring', () => {
+    let onReject;
+    beforeEach(() => {
+      onReject = jest.fn();
+
+      mockUnderlyingInstrumentationRunning();
+    });
+
+    it('should signal termination due to unexpected underlying termination, if waited-for', async () => {
+      await uut.launch(deviceId, bundleId, {});
+      uut.waitForCrash().catch(onReject);
+
+      await invokeUnderlyingTerminationCallback();
+      expect(onReject).toHaveBeenCalled();
+    });
+
+    it('should signal termination due to initiated termination, if waited-for', async () => {
+      await uut.launch(deviceId, bundleId, {});
+      uut.waitForCrash().catch(onReject);
+
+      await uut.terminate();
+      expect(onReject).toHaveBeenCalled();
+    });
+
+    it('should signal termination with a parsed stack-trace', async () => {
+      instrumentationLogsParser.containsStackTraceLog.mockReturnValue(true);
+
+      await uut.launch(deviceId, bundleId, {});
+      uut.waitForCrash().catch(onReject);
+
+      await invokeUnderlyingLogListenerCallbackWith('mock data');
+      await invokeUnderlyingTerminationCallback();
+
+      assertRejectedWithNativeStacktrace();
+    });
+
+    it('should signal termination with no stack-trace, if none available', async () => {
+      instrumentationLogsParser.containsStackTraceLog.mockReturnValue(false);
+
+      await uut.launch(deviceId, bundleId, {});
+      uut.waitForCrash().catch(onReject);
+
+      await invokeUnderlyingLogListenerCallbackWith('mock data');
+      await invokeUnderlyingTerminationCallback();
+
+      expect(instrumentationLogsParser.parse).toHaveBeenCalledWith('mock data');
+
+      assertRejectedWithoutStacktrace();
+    });
+
+    it('should allow for user-initiated clearing of app-wait', async () => {
+      const onResolve = jest.fn();
+
+      await uut.launch(deviceId, bundleId, {});
+      const promise = uut.waitForCrash().then(onResolve);
+      uut.abortWaitForCrash();
+
+      await promise;
+      expect(onResolve).toHaveBeenCalled();
+    });
+
+    it('should immediately signal termination if already terminated', async () => {
+      await uut.launch(deviceId, bundleId, {});
+
+      mockUnderlyingInstrumentationDead();
+      await uut.waitForCrash().catch(onReject);
+
+      expect(onReject).toHaveBeenCalled();
+    });
+
+    it('should account for all waiting crash-wait clients', async () => {
+      await uut.launch(deviceId, bundleId, {});
+
+      uut.waitForCrash().catch(onReject);
+      uut.waitForCrash().catch(onReject);
+
+      await uut.terminate();
+
+      expect(onReject).toHaveBeenCalledTimes(2);
+    });
+
+    const assertRejectedWithNativeStacktrace = () => {
+      const e = onReject.mock.calls[0][0];
+      expect(e.toString()).toContain('DetoxRuntimeError: Failed to run application on the device');
+      expect(e.toString()).toContain(`Native stacktrace dump: ${MockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK}`);
+    };
+
+    const assertRejectedWithoutStacktrace = () => {
+      const e = onReject.mock.calls[0][0];
+      expect(e.toString()).toContain('DetoxRuntimeError: Failed to run application on the device');
+      expect(e.toString()).not.toContain('Native stacktrace dump');
+    };
+  });
+
+  const extractUnderlyingTerminationCallback = () => instrumentation.mockCtor.mock.calls[0][2];
+  const invokeUnderlyingTerminationCallback = async () => {
+    const fn = extractUnderlyingTerminationCallback();
+    await fn();
+  }
+  const extractUnderlyingLogListenerCallback = () => instrumentation.mockCtor.mock.calls[0][3];
+  const invokeUnderlyingLogListenerCallbackWith = async (data) => {
+    const fn = extractUnderlyingLogListenerCallback();
+    await fn(data);
+  }
+
+  const mockUnderlyingInstrumentationRunning = () => instrumentation.isRunning.mockReturnValue(true);
+  const mockUnderlyingInstrumentationDead = () => instrumentation.isRunning.mockReturnValue(false);
+});
diff --git a/detox/src/utils/Deferred.test.js b/detox/src/utils/Deferred.test.js
index 52240cdfd..e6bf3ac1a 100644
--- a/detox/src/utils/Deferred.test.js
+++ b/detox/src/utils/Deferred.test.js
@@ -23,6 +23,12 @@ describe('Deferred', () => {
     it('should have pending status', () =>
       expect(deferred.status).toBe(Deferred.PENDING));
 
+    it('should indicate an isPending=true, other statuses as false', () => {
+      expect(deferred.isPending()).toEqual(true);
+      expect(deferred.isResolved()).toEqual(false);
+      expect(deferred.isRejected()).toEqual(false);
+    });
+
     it('should have promise', () =>
       expect(deferred.promise).toBeInstanceOf(Promise));
 
@@ -38,6 +44,12 @@ describe('Deferred', () => {
       it('should have a resolved status', () =>
         expect(deferred.status).toBe(Deferred.RESOLVED));
 
+      it('should indicate an isResolved=true, other statuses as false', () => {
+        expect(deferred.isResolved()).toEqual(true);
+        expect(deferred.isPending()).toEqual(false);
+        expect(deferred.isRejected()).toEqual(false);
+      });
+
       it('should have a resolved promise', () =>
         expect(deferred.promise).resolves.toBe(42));
 
@@ -61,6 +73,12 @@ describe('Deferred', () => {
       it('should have a rejected promise', () =>
         expect(deferred.promise).rejects.toThrowError('RejectionTest'));
 
+      it('should indicate an isRejected=true, other statuses as false', () => {
+        expect(deferred.isRejected()).toEqual(true);
+        expect(deferred.isResolved()).toEqual(false);
+        expect(deferred.isPending()).toEqual(false);
+      });
+
       describe('and then resolved', () => {
         beforeEach(() => deferred.resolve(42));
 
@@ -72,4 +90,27 @@ describe('Deferred', () => {
       });
     });
   });
+
+  describe('when precreated as resolved', () => {
+    it('should be resolved', async () => {
+      deferred = Deferred.resolved('mock resolution');
+
+      expect(deferred.status).toBe(Deferred.RESOLVED);
+      expect(await deferred.promise).toEqual('mock resolution');
+    });
+  });
+
+  describe('when precreated as rejected', () => {
+    it('should be resolved', async () => {
+      deferred = Deferred.rejected(new Error('error mock'));
+
+      expect(deferred.status).toBe(Deferred.REJECTED);
+      try {
+        await deferred.promise;
+        fail();
+      } catch (e) {
+        expect(e.message).toEqual('error mock');
+      }
+    });
+  });
 });