Repository: wix/Detox (js)

Issue:
Catch app-launch crashes
**Is your feature request related to a problem? Please describe.**
Since on Android, the Detox manager is only initialized _after_ the activity is successfully launched, exceptions associated with crashes in the activity launch itself do not get reported back to the tester, and thus leave the tester to run until timeout.
After that, the only reasonable way to figure out what happened is by digging the logs inside the artifacts -- and only if such have been recorded to begin with.
This is far from ideal.

**Describe the solution you'd like**
'Need to somewhat reorganize the init flow so that the web-socket and crash handler could be set up before everything else (in particular, activity launch).

**Describe alternatives you've considered**
N/A

**Additional context**
I've struggled with this in trying to decipher timeout taking place on the Wix internal CI.


Pull request description:
(Android) Friendly-fail test runner if app crashes on launch
- [ ] This is a small change 
- [x] This change has been discussed in issue #1880 and the solution has been agreed upon with maintainers.

---

**Description:**

Fixes #1880 by monitoring instrumentation status immediately after instrumentation launch (i.e. by running the `adb shell am instrument ...` command), alongside waiting for the traditional 'device ready' response from the device. Upon failure, also dumps stacktrace parsed from background-reported instrumentation logs.

Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/Detox.java b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
index a48ecedbb..a9ec0339f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/Detox.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
@@ -6,14 +6,14 @@ import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
 import android.os.Bundle;
-import android.os.Handler;
 import android.os.Looper;
-import androidx.annotation.NonNull;
 import android.util.Base64;
+import android.util.Log;
 
 import java.util.Arrays;
 import java.util.List;
 
+import androidx.annotation.NonNull;
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
@@ -118,7 +118,7 @@ public final class Detox {
         sActivityTestRule = activityTestRule;
 
         Intent intent = extractInitialIntent();
-        activityTestRule.launchActivity(intent);
+        sActivityTestRule.launchActivity(intent);
 
         // Kicks off another thread and attaches a Looper to that.
         // The goal is to keep the test thread intact,
@@ -127,14 +127,7 @@ public final class Detox {
             @Override
             public void run() {
                 Looper.prepare();
-                Handler handler = new Handler();
-                handler.post(new Runnable() {
-                    @Override
-                    public void run() {
-                        DetoxManager detoxManager = new DetoxManager(context);
-                        detoxManager.start();
-                    }
-                });
+                new DetoxManager(context).start();
                 Looper.loop();
             }
         }, "com.wix.detox.manager");
@@ -144,7 +137,7 @@ public final class Detox {
             t.join();
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
-            throw new RuntimeException("Got interrupted", e);
+            throw new RuntimeException("Detox got interrupted prematurely", e);
         }
     }
 
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
index 6d23af10a..120eb86a4 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
@@ -74,10 +74,15 @@ class DetoxManager implements WebSocketClient.ActionHandler {
 
     void start() {
         if (detoxServerUrl != null && detoxSessionId != null) {
-            initReactNativeIfNeeded();
-            initWSClient();
-            initCrashHandler();
-            initActionHandlers();
+            handler.post(new Runnable() {
+                @Override
+                public void run() {
+                    initReactNativeIfNeeded();
+                    initWSClient();
+                    initCrashHandler();
+                    initActionHandlers();
+                }
+            });
         }
     }
 
diff --git a/detox/ios/EarlGrey b/detox/ios/EarlGrey
index 6ff611863..2c2c9b35b 160000
--- a/detox/ios/EarlGrey
+++ b/detox/ios/EarlGrey
@@ -1,1 +1,1 @@
-Subproject commit 6ff611863bec405c3812d5475d5aa4563a7074db
+Subproject commit 2c2c9b35b92e5899a8510b5f1275ebd8a58a1f8b
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index 9b36c6e69..eb2756fef 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -19,10 +19,12 @@ const ADBScreencapPlugin = require('../../artifacts/screenshot/ADBScreencapPlugi
 const ADBScreenrecorderPlugin = require('../../artifacts/video/ADBScreenrecorderPlugin');
 const AndroidDevicePathBuilder = require('../../artifacts/utils/AndroidDevicePathBuilder');
 const temporaryPath = require('../../artifacts/utils/temporaryPath');
+const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const sleep = require('../../utils/sleep');
 const retry = require('../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../utils/exec');
 const AndroidExpect = require('../../android/expect');
+const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
 
 const reservedInstrumentationArgs = ['class', 'package', 'func', 'unit', 'size', 'perf', 'debug', 'log', 'emma', 'coverageFile'];
 const isReservedInstrumentationArg = (arg) => reservedInstrumentationArgs.includes(arg);
@@ -31,6 +33,11 @@ class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
 
+    this.instrumentationLogsParser = null;
+    this.instrumentationProcess = null;
+    this.instrumentationStackTrace = '';
+    this.instrumentationCloseListener = _.noop;
+
     this.invocationManager = new InvocationManager(this.client);
     this.matchers = new AndroidExpect(this.invocationManager);
     this.uiDevice = new UiDeviceProxy(this.invocationManager).getUIDevice();
@@ -92,7 +99,7 @@ class AndroidDriver extends DeviceDriverBase {
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
     await this.emitter.emit('beforeLaunchApp', { deviceId, bundleId, launchArgs });
 
-    if (!this.instrumentationProcess) {
+    if (!this._isInstrumentationRunning()) {
       await this._launchInstrumentationProcess(deviceId, bundleId, launchArgs);
       await sleep(500);
     } else {
@@ -125,6 +132,20 @@ class AndroidDriver extends DeviceDriverBase {
     // Other payload content types are not yet supported.
   }
 
+  async waitUntilReady() {
+      try {
+        await Promise.race([
+          super.waitUntilReady(),
+          new Promise((resolve, reject) => {
+            this.instrumentationCloseListener = () => reject(this._getInstrumentationCrashError());
+            !this._isInstrumentationRunning() && this.instrumentationCloseListener();
+          }),
+        ]);
+      } finally {
+        this.instrumentationCloseListener = _.noop;
+      }
+  }
+
   async sendToHome(deviceId, params) {
     await this.uiDevice.pressHome();
   }
@@ -137,12 +158,25 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async _terminateInstrumentation() {
-    if (this.instrumentationProcess) {
+    if (this._isInstrumentationRunning()) {
       await interruptProcess(this.instrumentationProcess);
       this.instrumentationProcess = null;
+      this.instrumentationCloseListener();
     }
   }
 
+  _isInstrumentationRunning() {
+    return !!this.instrumentationProcess;
+  }
+
+  _getInstrumentationCrashError() {
+    return new DetoxRuntimeError({
+      message: 'Failed to run application on the device',
+      hint: 'Most likely, your main activity has crashed prematurely',
+      debugInfo: 'Native stacktrace dump: ' + this.instrumentationStackTrace,
+    });
+  }
+
   async cleanup(deviceId, bundleId) {
     await this._terminateInstrumentation();
     await super.cleanup(deviceId, bundleId);
@@ -216,13 +250,24 @@ class AndroidDriver extends DeviceDriverBase {
     const testRunner = await this.adb.getInstrumentationRunner(deviceId, bundleId);
     const spawnFlags = [`-s`, `${deviceId}`, `shell`, `am`, `instrument`, `-w`, `-r`, ...launchArgs, ...additionalLaunchArgs, testRunner];
 
+    this.instrumentationLogsParser = new InstrumentationLogsParser();
     this.instrumentationProcess = spawnAndLog(this.adb.adbBin, spawnFlags, { detached: false });
+    this.instrumentationProcess.childProcess.stdout.setEncoding('utf8');
+    this.instrumentationProcess.childProcess.stdout.on('data', this._extractStackTraceFromInstrumLogs.bind(this));
     this.instrumentationProcess.childProcess.on('close', async () => {
       await this._terminateInstrumentation();
       await this.adb.reverseRemove(deviceId, serverPort);
     });
   }
 
+  _extractStackTraceFromInstrumLogs(logsDump) {
+    this.instrumentationLogsParser.parse(logsDump);
+
+    if (this.instrumentationLogsParser.containsStackTraceLog(logsDump)) {
+      this.instrumentationStackTrace = this.instrumentationLogsParser.getStackTrace(logsDump);
+    }
+  }
+
   async _queryPID(deviceId, bundleId, waitAtStart = true) {
     if (waitAtStart) {
       await sleep(500);
diff --git a/detox/src/devices/drivers/InstrumentationLogsParser.js b/detox/src/devices/drivers/InstrumentationLogsParser.js
new file mode 100644
index 000000000..1db453979
--- /dev/null
+++ b/detox/src/devices/drivers/InstrumentationLogsParser.js
@@ -0,0 +1,68 @@
+const _ = require('lodash');
+
+const LOG_PREFIX = 'INSTRUMENTATION_STATUS';
+const START_WITH_PREFIX = new RegExp(`^${LOG_PREFIX}(?:_[A-Z]+)*:[\\s]+`, 'm');
+const STACKTRACE_PREFIX = 'stack=';
+
+class InstrumentationLogsParser {
+  constructor() {
+    this._partialLog = undefined;
+    this._stackTrace = undefined;
+  }
+
+  parse(logsDump) {
+    this._analyzeLogs(logsDump);
+  }
+
+  containsStackTraceLog() {
+    return !_.isUndefined(this._stackTrace);
+  }
+
+  getStackTrace() {
+    return this._stackTrace || '';
+  }
+
+  _analyzeLogs(_logsDump) {
+    const logsDump = this._partialLog ? this._partialLog.concat(_logsDump) : _logsDump;
+    const logs = logsDump.split(START_WITH_PREFIX);
+    this._extractStackTraceLogIfExists(logs);
+    this._keepPartialLogIfNeeded(logs);
+  }
+
+  _extractStackTraceLogIfExists(logs) {
+    const stackTraceLogs = logs.filter(this._verifyStackTraceLog);
+    if (!_.isEmpty(stackTraceLogs)) {
+      this._stackTrace = _.last(stackTraceLogs).replace(STACKTRACE_PREFIX, '').trim().concat('\n');
+    }
+  }
+
+  _keepPartialLogIfNeeded(logs) {
+    const lastLog = _.last(logs);
+    if (this._isPartialLog(lastLog)) {
+      this._partialLog = lastLog;
+    }
+  }
+
+  _verifyStackTraceLog(log) {
+    return log.startsWith(STACKTRACE_PREFIX) && log.endsWith('\n\n');
+  }
+
+  _isPartialLog(log) {
+    return (
+      this._isPartialStackTraceLog(log) ||
+      this._isPartialAnyLog(log)
+    );
+  }
+
+  _isPartialStackTraceLog(log) {
+    return log.startsWith(STACKTRACE_PREFIX) && !log.endsWith('\n\n');
+  }
+
+  _isPartialAnyLog(log) {
+    return !log.endsWith('\n');
+  }
+}
+
+module.exports = {
+  InstrumentationLogsParser
+};


Test patch:
diff --git a/detox/src/devices/drivers/AndroidDriver.test.js b/detox/src/devices/drivers/AndroidDriver.test.js
index 9891de3ad..554b05ac9 100644
--- a/detox/src/devices/drivers/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/AndroidDriver.test.js
@@ -4,6 +4,7 @@ describe('Android driver', () => {
   const bundleId = 'bundle-id-mock';
 
   let logger;
+  let client;
   let exec;
   beforeEach(() => {
     jest.mock('../../utils/encoding', () => ({
@@ -15,6 +16,10 @@ describe('Android driver', () => {
     jest.mock('../../utils/sleep', () => jest.fn().mockResolvedValue(''));
     jest.mock('../../utils/retry', () => jest.fn().mockResolvedValue(''));
 
+    jest.mock('./InstrumentationLogsParser', () => ({
+      InstrumentationLogsParser: mockInstrumentationLogsParserClass,
+    }));
+
     const mockLogger = {
       warn: jest.fn(),
     };
@@ -28,25 +33,91 @@ describe('Android driver', () => {
       spawnAndLog: jest.fn().mockReturnValue({
         childProcess: {
           on: jest.fn(),
+          stdout: {
+            setEncoding: jest.fn(),
+            on: jest.fn(),
+          }
         }
       }),
+      interruptProcess: jest.fn(),
     }));
     exec = require('../../utils/exec');
+
+    client = {
+      configuration: {
+        server: 'ws://localhost:1234'
+      },
+      waitUntilReady: jest.fn(),
+    };
   });
 
   let uut;
   beforeEach(() => {
     const AndroidDriver = require('./AndroidDriver');
     uut = new AndroidDriver({
-      client: {
-        configuration: {
-          server: 'ws://localhost:1234'
-        }
-      }
+      client,
+    });
+  });
+
+  describe('Instrumentation bootstrap', () => {
+    it('should launch instrumentation upon app launch', async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
+
+      expect(exec.spawnAndLog).toHaveBeenCalledWith(
+        expect.anything(),
+        expect.arrayContaining(['shell', 'am', 'instrument']),
+        expect.anything(),
+      );
+    });
+  });
+
+  describe('Device ready-wait', () => {
+    beforeEach(async () => {
+      await uut.launchApp(deviceId, bundleId, {}, '');
     });
+
+    it('should delegate wait to device being ready via client api', async () => {
+      await uut.waitUntilReady();
+      expect(client.waitUntilReady).toHaveBeenCalled();
+    }, 2000);
+
+    it('should fail if instrumentation dies prematurely while waiting for device-ready resolution', async () => {
+      const clientWaitResolve = mockDeviceReadyPromise();
+
+      const promise = uut.waitUntilReady();
+      setTimeout(async () => await killInstrumentation(exec.spawnAndLog()), 1);
+
+      try {
+        await promise;
+        fail('Expected an error and none was thrown');
+      } catch (e) {
+        expect(e.message).toContain('DetoxRuntimeError: Failed to run application on the device');
+        expect(e.message).toContain(`Native stacktrace dump: ${mockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK}`);
+      } finally {
+        clientWaitResolve();
+      }
+    }, 2000);
+
+    const mockDeviceReadyPromise = () => {
+      let clientResolve;
+      client.waitUntilReady.mockReturnValue(new Promise((resolve) => clientResolve = resolve));
+      return clientResolve;
+    };
+
+    const killInstrumentation = async (instrumProcess) => {
+      const { childProcess } = instrumProcess;
+
+      const stdoutSubscribeCallArgs = childProcess.stdout.on.mock.calls[0];
+      const stdoutListenerFn = stdoutSubscribeCallArgs[1];
+      stdoutListenerFn('Doesnt matter what we put here');
+
+      const closeEvCallArgs = childProcess.on.mock.calls[0];
+      const closeEvListenerFn = closeEvCallArgs[1];
+      await closeEvListenerFn();
+    };
   });
 
-  describe('launch args', () => {
+  describe('Launch args', () => {
     const expectSpawnedFlag = (spawnedFlags) => ({
       startingIndex: (index) => ({
         toBe: ({key, value}) => {
@@ -163,6 +234,8 @@ class mockADBClass {
     this.getInstrumentationRunner = jest.fn();
     this.reverse = jest.fn();
     this.reverseRemove = jest.fn();
+
+    this.adbBin = 'ADB binary mock';
   }
 }
 
@@ -171,3 +244,12 @@ class mockAsyncEmitter {
     this.emit = jest.fn();
   }
 }
+
+class mockInstrumentationLogsParserClass {
+  constructor() {
+    this.parse = () => {};
+    this.containsStackTraceLog = () => true;
+    this.getStackTrace = () => mockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK;
+  }
+}
+mockInstrumentationLogsParserClass.INSTRUMENTATION_STACKTRACE_MOCK = 'Stacktrace mock';
diff --git a/detox/src/devices/drivers/InstrumentationLogsParser.test.js b/detox/src/devices/drivers/InstrumentationLogsParser.test.js
new file mode 100644
index 000000000..f3f8f2d6a
--- /dev/null
+++ b/detox/src/devices/drivers/InstrumentationLogsParser.test.js
@@ -0,0 +1,85 @@
+describe('Instrumentation logs parser', () => {
+  describe('stack trace parser', () => {
+    let uut;
+    beforeEach(() => {
+      const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
+      uut = new InstrumentationLogsParser();
+    });
+
+    it('should query stacktrace for false for a no-string', () => {
+      const logsDump = '';
+      uut.parse(logsDump);
+      expect(uut.containsStackTraceLog()).toEqual(false);
+    });
+
+    it('should query stacktrace for true for a log matching the stacktrace prefix', () => {
+      const logsDump = 'INSTRUMENTATION_STATUS: stack=\n\n';
+      uut.parse(logsDump);
+      expect(uut.containsStackTraceLog()).toEqual(true);
+    });
+
+    it('should query stacktrace for true for a log that holds the stacktrace prefix alongside other stuff', () => {
+      const logsDump = [
+        'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest\n',
+        'INSTRUMENTATION_STATUS: stack=stackFrame1\n\tstackFrame2\n\n',
+        'INSTRUMENTATION_STATUS: id=AndroidJUnitRunner\n',
+      ].join('');
+      uut.parse(logsDump);
+      expect(uut.containsStackTraceLog()).toEqual(true);
+    });
+
+    it('should return empty stacktrace for a no-string', () => {
+      const logsDump = '';
+      uut.parse(logsDump);
+      expect(uut.getStackTrace(logsDump)).toEqual('');
+    });
+
+    it('should return stacktrace for a stack-trace logs dump', () => {
+      const logsDump = 'INSTRUMENTATION_STATUS: stack=stackFrame1\n\tstackFrame2\n\n';
+      uut.parse(logsDump);
+      expect(uut.getStackTrace(logsDump)).toEqual('stackFrame1\n\tstackFrame2\n');
+    });
+
+    it('should return stacktrace for a multi-content logs dump', () => {
+      const logsDump = [
+        'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest\n',
+        'INSTRUMENTATION_STATUS: stack=stackFrame1\n\tstackFrame2\n\n',
+        'INSTRUMENTATION_STATUS_CODE: 1\n',
+        'INSTRUMENTATION_STATUS: id=AndroidJUnitRunner\n',
+      ].join('');
+
+      uut.parse(logsDump);
+      expect(uut.getStackTrace()).toEqual('stackFrame1\n\tstackFrame2\n');
+    });
+
+    it('should return stacktrace for a stack split across 2 log dumps', () => {
+      const logsDump1 = [
+        'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest\n',
+        'INSTRUMENTATION_STATUS: stack=stackFrame1\n',
+      ].join('');
+      const logsDump2 = [
+        '\tstackFrame2\n\n',
+        'INSTRUMENTATION_STATUS_CODE: 1\n',
+      ].join('');
+
+      uut.parse(logsDump1);
+      uut.parse(logsDump2);
+      expect(uut.getStackTrace()).toEqual('stackFrame1\n\tstackFrame2\n');
+    });
+
+    it('should return stacktrace for a split-stack cut in prefix', () => {
+      const logsDump1 = [
+        'INSTRUMENTATION_STATUS: stream=\ncom.example.DetoxTest\n',
+        'INSTRUM',
+      ].join('');
+      const logsDump2 = [
+        'ENTATION_STATUS: stack=stackFrame1\n\tstackFrame2\n\n',
+        'INSTRUMENTATION_STATUS_CODE: 1\n',
+      ].join('');
+
+      uut.parse(logsDump1);
+      uut.parse(logsDump2);
+      expect(uut.getStackTrace()).toEqual('stackFrame1\n\tstackFrame2\n');
+    });
+  });
+});
diff --git a/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
index 5a0c347ee..fbb5a1d17 100644
--- a/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
+++ b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
@@ -2,20 +2,16 @@ package com.example;
 
 import android.os.Bundle;
 
-import androidx.test.filters.LargeTest;
-import androidx.test.platform.app.InstrumentationRegistry;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.rule.ActivityTestRule;
-
 import com.wix.detox.Detox;
 
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-/**
- * Created by simonracz on 28/05/2017.
- */
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
 
 @RunWith(AndroidJUnit4.class)
 @LargeTest
@@ -26,7 +22,10 @@ public class DetoxTest {
      * Important so as to allow for some testing of Detox in this particular mode, which has been proven to introduce caveats.
      * </br>Here for internal usage; Not external-API related.
      */
-    private static final String SINGLE_INSTANCE_ACTIVITY_ARG = "androidSingleInstanceActivity";
+    private static final String USE_SINGLE_INSTANCE_ACTIVITY_ARG = "detoxAndroidSingleInstanceActivity";
+
+    /** Similar concept to that of {@link #USE_SINGLE_INSTANCE_ACTIVITY_ARG}. */
+    private static final String USE_CRASHING_ACTIVITY_ARG = "detoxAndroidCrashingActivity";
 
     @Rule
     public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
@@ -34,11 +33,20 @@ public class DetoxTest {
     @Rule
     public ActivityTestRule<SingleInstanceActivity> mSingleInstanceActivityRule = new ActivityTestRule<>(SingleInstanceActivity.class, false, false);
 
+    @Rule
+    public ActivityTestRule<CrashingActivity> mCrashingActivityTestRule = new ActivityTestRule<>(CrashingActivity.class, false, false);
+
     @Test
     public void runDetoxTests() {
         final Bundle arguments = InstrumentationRegistry.getArguments();
-        final boolean forceSingleTaskActivity = Boolean.parseBoolean(arguments.getString(SINGLE_INSTANCE_ACTIVITY_ARG, "false"));
-        final ActivityTestRule<?> rule = forceSingleTaskActivity ? mSingleInstanceActivityRule : mActivityRule;
+        final boolean useSingleTaskActivity = Boolean.parseBoolean(arguments.getString(USE_SINGLE_INSTANCE_ACTIVITY_ARG, "false"));
+        final boolean useCrashingActivity = Boolean.parseBoolean(arguments.getString(USE_CRASHING_ACTIVITY_ARG, "false"));
+        final ActivityTestRule<?> rule =
+                useSingleTaskActivity
+                        ? mSingleInstanceActivityRule
+                        : useCrashingActivity
+                            ? mCrashingActivityTestRule
+                            : mActivityRule;
         Detox.runTests(rule);
     }
 }
diff --git a/detox/test/android/app/src/main/AndroidManifest.xml b/detox/test/android/app/src/main/AndroidManifest.xml
index 37bb75ddc..24cb9e6bf 100644
--- a/detox/test/android/app/src/main/AndroidManifest.xml
+++ b/detox/test/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,5 @@
 <manifest
     xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
     package="com.example"
     android:versionCode="1"
     android:versionName="1.0"
@@ -56,10 +55,17 @@
             <category android:name="android.intent.category.DEFAULT" />
             <data android:scheme="detoxtesturlscheme.singleinstance"/>
         </intent-filter>
-       </activity>
+      </activity>
 
+      <!--
+       MainActivity flavor that crashes on onResume(), thus keeping Detox from starting.
+       Used by specific Detox E2E tests, inspecting this behavior.
+       -->
+      <activity
+        android:name=".CrashingActivity"
+        android:label="@string/app_name_single_instance"
+        />
       <activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
 
     </application>
-
 </manifest>
diff --git a/detox/test/android/app/src/main/java/com/example/CrashingActivity.java b/detox/test/android/app/src/main/java/com/example/CrashingActivity.java
new file mode 100644
index 000000000..eb2235b72
--- /dev/null
+++ b/detox/test/android/app/src/main/java/com/example/CrashingActivity.java
@@ -0,0 +1,9 @@
+package com.example;
+
+public class CrashingActivity extends MainActivity {
+    @Override
+    protected void onResume() {
+        super.onResume();
+        throw new IllegalStateException("This is an intentional crash!");
+    }
+}
diff --git a/detox/test/android/app/src/main/java/com/example/MainActivity.java b/detox/test/android/app/src/main/java/com/example/MainActivity.java
index 05e90ece4..e84b72551 100644
--- a/detox/test/android/app/src/main/java/com/example/MainActivity.java
+++ b/detox/test/android/app/src/main/java/com/example/MainActivity.java
@@ -12,5 +12,4 @@ public class MainActivity extends ReactActivity {
     protected String getMainComponentName() {
         return "example";
     }
-
 }
diff --git a/detox/test/android/app/src/main/res/values/strings.xml b/detox/test/android/app/src/main/res/values/strings.xml
index 4a9b28c69..78aece69c 100644
--- a/detox/test/android/app/src/main/res/values/strings.xml
+++ b/detox/test/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,5 @@
 <resources>
     <string name="app_name">Detox Test</string>
     <string name="app_name_single_instance">Detox Test (SingleInstance)</string>
+    <string name="app_name_crashing">Detox Test (Crashing)</string>
 </resources>
diff --git a/detox/test/e2e/15.urls.test.js b/detox/test/e2e/15.urls.test.js
index bcb85ccba..d1b94e778 100644
--- a/detox/test/e2e/15.urls.test.js
+++ b/detox/test/e2e/15.urls.test.js
@@ -14,7 +14,7 @@ describe('Open URLs', () => {
 
   const withSingleInstanceActivityArgs = () => ({
     url: 'detoxtesturlscheme.singleinstance://such-string',
-    launchArgs: { androidSingleInstanceActivity: true },
+    launchArgs: { detoxAndroidSingleInstanceActivity: true },
   });
 
   [
diff --git a/detox/test/e2e/19.crash-handling.test.js b/detox/test/e2e/19.crash-handling.test.js
index eaac728ce..927da6be2 100644
--- a/detox/test/e2e/19.crash-handling.test.js
+++ b/detox/test/e2e/19.crash-handling.test.js
@@ -1,5 +1,12 @@
 describe('Crash Handling', () => {
-  it('Should throw error upon app crash', async () => {
+  afterAll(async () => {
+    await device.launchApp({
+      newInstance: true,
+      launchArgs: undefined,
+    });
+  });
+
+  it('Should throw error upon internal app crash', async () => {
     await device.reloadReactNative();
     let failed = false;
 
@@ -17,4 +24,18 @@ describe('Crash Handling', () => {
     await device.launchApp({newInstance: false});
     await expect(element(by.text('Sanity'))).toBeVisible();
   });
+
+  it(':android: Should throw error upon app bootstrap crash', async () => {
+    let failed = false;
+    try {
+      await device.launchApp({ newInstance: true, launchArgs: { detoxAndroidCrashingActivity: true }});
+    } catch (e) {
+      console.error(e);
+      failed = true;
+    }
+
+    // This is not effectively needed, as if crash handling doesn't go right launchApp would typically
+    // just hang forever (and thus a timeout will fail the test - not this assertion).
+    if (!failed) throw new Error('Test should have thrown an error, but did not');
+  }, 60000);
 });