Repository: wix/Detox
Pull request #2153 (2020-06-25 13:49:21)

Android driver refactor 1.2
- [ ] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**
Following work on #1192 -- need to break some of the AndroidDriver monolith into sub-units. This is an official refactor finalization step - 1.2, following #2150, #2146, #2148 and #2149: Reduce instrumentation management in driver itself to bare minimum (logic extracted to new class `MonitoredInstrumentation`, which wraps around the pure `Instrumentation` class introduced at an earlier refactor point).

noomorph: Okay, I'll review tomorrow, I suppose.
d4vidi: @noomorph ❤️  forgot to mention - this is because I somewhat upgraded the `Deferred` class. Need your approval
noomorph: @d4vidi, the changes to Deferred look fine to me. 
d4vidi: @noomorph any idea what to make of coverall's report? It marks new lines as uncovered - which is fine in the sense that they are new, but nevertheless many of the reported lines _are_ covered.

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;


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');
+      }
+    });
+  });
 });