Repository: wix/Detox
Pull request #2768 (2021-05-04 08:35:38)

Reorganize roles in Emu delegates to match cleanup vs. shutdown
## Description

<!--
Thank you for contributing!

Step 1: Before submitting a pull request that introduces a new functionality or fixes a bug,
please open an issue where we could discuss the suggestion, problem - and potential ways to fix.
-->

I've found this while working on redefining the concept of a `DeviceId` throughout our JS framework, as a tech debt left from the work on Genymotion (see #2655):

In the context of Android drivers, the terms 'cleanup' and 'shutdown' were (semantically) wrongly interpreted inside the related delegate (i.e. `EmulatorDeviceAllocation` class). While the implementation was correct, the associated code was simply misplaced inside it.

<!--
Step 2: Provide an overview of how your fix / enhancement works.
If possible, provide screenshots of the before and after states (even for simple command line options - show the terminal).
-->

In this pull request, I have reorganized `EmulatorDeviceAllocation` such that the code inside `deallocateDevice()` would in fact simply have it removed from the device registry, instead of shutting it down altogether. The latter was relocated onto a new method in an existing class: `EmulatorLauncher.shutdown()`.

UPDATE: I've done the same for Genymotion.

<!--
Step 3: Please review the checklist below.
-->

---


> _For features/enhancements:_
 - [ ] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

> _For API changes:_
 - [ ] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.


d4vidi: Merging even though build is red because there was an ANR on the local emulators in 1 of the 2 main Android builds, which isn't related to cleanup etc.

Patch:
diff --git a/detox/package.json b/detox/package.json
index 471c7be3d..4a3dcf04c 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -105,7 +105,7 @@
       "src/devices/drivers/DeviceDriverBase.js",
       "src/devices/drivers/android/AndroidDriver.js",
       "src/devices/drivers/android/emulator/EmulatorDriver.js",
-      "src/devices/drivers/android/emulator/EmulatorLauncher.js",
+      "src/devices/drivers/android/emulator/helpers/EmulatorLauncher.js",
       "src/devices/drivers/android/exec/AAPT.js",
       "src/devices/drivers/android/exec/ADB.js",
       "src/devices/drivers/android/exec/EmulatorExec.js",
diff --git a/detox/src/devices/drivers/android/AndroidDeviceAllocation.js b/detox/src/devices/drivers/android/AndroidDeviceAllocation.js
index 6c62d836e..2da3205e6 100644
--- a/detox/src/devices/drivers/android/AndroidDeviceAllocation.js
+++ b/detox/src/devices/drivers/android/AndroidDeviceAllocation.js
@@ -18,14 +18,6 @@ class AndroidDeviceAllocation {
   async _notifyAllocation(deviceId, type, isNew) {
     return this._eventEmitter.emit('bootDevice', { coldBoot: isNew, deviceId, type, });
   }
-
-  async _notifyPreDeallocation(deviceId) {
-    return this._eventEmitter.emit('beforeShutdownDevice', { deviceId });
-  }
-
-  async _notifyDeallocationCompleted(deviceId) {
-    return this._eventEmitter.emit('shutdownDevice', { deviceId });
-  }
 }
 
 AndroidDeviceAllocation.ALLOCATE_DEVICE_LOG_EVT = ALLOCATE_DEVICE_LOG_EVT;
diff --git a/detox/src/devices/drivers/android/AndroidDeviceLauncher.js b/detox/src/devices/drivers/android/AndroidDeviceLauncher.js
new file mode 100644
index 000000000..ee01f5fce
--- /dev/null
+++ b/detox/src/devices/drivers/android/AndroidDeviceLauncher.js
@@ -0,0 +1,15 @@
+class AndroidDeviceLauncher {
+  constructor(eventEmitter) {
+    this._eventEmitter = eventEmitter;
+  }
+
+  async _notifyPreShutdown(deviceId) {
+    return this._eventEmitter.emit('beforeShutdownDevice', { deviceId });
+  }
+
+  async _notifyShutdownCompleted(deviceId) {
+    return this._eventEmitter.emit('shutdownDevice', { deviceId });
+  }
+}
+
+module.exports = AndroidDeviceLauncher;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
index f85ed19b3..cf3c56031 100644
--- a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
@@ -6,7 +6,7 @@ const AndroidDriver = require('../AndroidDriver');
 const EmulatorDeviceAllocation = require('./helpers/EmulatorDeviceAllocation');
 const AVDValidator = require('./AVDValidator');
 const AVDsResolver = require('./AVDsResolver');
-const EmulatorLauncher = require('./EmulatorLauncher');
+const EmulatorLauncher = require('./helpers/EmulatorLauncher');
 const EmulatorVersionResolver = require('./EmulatorVersionResolver');
 const FreeEmulatorFinder = require('./FreeEmulatorFinder');
 const { EmulatorExec } = require('../exec/EmulatorExec');
@@ -25,14 +25,14 @@ class EmulatorDriver extends AndroidDriver {
     this._deviceRegistry = DeviceRegistry.forAndroid();
 
     const emulatorExec = new EmulatorExec();
-    const emulatorLauncher = new EmulatorLauncher(emulatorExec);
+    this._emulatorLauncher = new EmulatorLauncher(emulatorExec, this.emitter);
     this._emuVersionResolver = new EmulatorVersionResolver(emulatorExec);
 
     const avdsResolver = new AVDsResolver(emulatorExec);
     this._avdValidator = new AVDValidator(avdsResolver, this._emuVersionResolver);
 
     const freeEmulatorFinder = new FreeEmulatorFinder(this.adb, this._deviceRegistry)
-    this._deviceAllocation = new EmulatorDeviceAllocation(this._deviceRegistry, freeEmulatorFinder, emulatorLauncher, this.adb, this.emitter);
+    this._deviceAllocation = new EmulatorDeviceAllocation(this._deviceRegistry, freeEmulatorFinder, this._emulatorLauncher, this.adb, this.emitter);
   }
 
   get name() {
@@ -71,12 +71,15 @@ class EmulatorDriver extends AndroidDriver {
   }
 
   async cleanup(deviceId, bundleId) {
-    await this._deviceRegistry.disposeDevice(deviceId);
-    await super.cleanup(deviceId, bundleId);
+    try {
+      await super.cleanup(deviceId, bundleId);
+    } finally {
+      await this._deviceAllocation.deallocateDevice(deviceId)
+    }
   }
 
   async shutdown(deviceId) {
-    await this._deviceAllocation.deallocateDevice(deviceId)
+    await this._emulatorLauncher.shutdown(deviceId);
   }
 
   async _fixAvdConfigIniSkinNameIfNeeded(avdName) {
diff --git a/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.js b/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.js
index 6d7333f3e..bc8375e58 100644
--- a/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.js
+++ b/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.js
@@ -1,7 +1,5 @@
-const _ = require('lodash');
 const AndroidDeviceAllocation  = require('../../AndroidDeviceAllocation');
 const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError');
-const EmulatorTelnet = require('../../tools/EmulatorTelnet');
 const logger = require('../../../../../utils/logger').child({ __filename });
 const retry = require('../../../../../utils/retry');
 const { traceCall } = require('../../../../../utils/trace');
@@ -12,13 +10,13 @@ const DetoxEmulatorsPortRange = {
 };
 
 class EmulatorDeviceAllocation extends AndroidDeviceAllocation {
-  constructor(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, telnetGeneratorFn = () => new EmulatorTelnet(), rand = Math.random) {
+
+  constructor(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, rand = Math.random) {
     super(deviceRegistry, eventEmitter, logger);
     this._freeDeviceFinder = freeDeviceFinder;
     this._emulatorLauncher = emulatorLauncher;
     this._adb = adb;
     this._rand = rand;
-    this._telnetGeneratorFn = telnetGeneratorFn;
   }
 
   async allocateDevice(avdName) {
@@ -39,12 +37,7 @@ class EmulatorDeviceAllocation extends AndroidDeviceAllocation {
   }
 
   async deallocateDevice(adbName) {
-    await this._notifyPreDeallocation(adbName);
-    const port = _.split(adbName, '-')[1];
-    const telnet = this._telnetGeneratorFn();
-    await telnet.connect(port);
-    await telnet.kill();
-    await this._notifyDeallocationCompleted(adbName);
+    await this._deviceRegistry.disposeDevice(adbName);
   }
 
   async _doSynchronizedAllocation(avdName) {
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorLauncher.js b/detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.js
similarity index 74%
rename from detox/src/devices/drivers/android/emulator/EmulatorLauncher.js
rename to detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.js
index 8147cd4c9..51cd7476f 100644
--- a/detox/src/devices/drivers/android/emulator/EmulatorLauncher.js
+++ b/detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.js
@@ -1,15 +1,19 @@
 const _ = require('lodash');
 const fs = require('fs');
 const Tail = require('tail').Tail;
-const { LaunchCommand } = require('../exec/EmulatorExec');
-const unitLogger = require('../../../../utils/logger').child({ __filename });
-const retry = require('../../../../utils/retry');
+const AndroidDeviceLauncher = require('../../AndroidDeviceLauncher');
+const EmulatorTelnet = require('../../tools/EmulatorTelnet');
+const { LaunchCommand } = require('../../exec/EmulatorExec');
+const unitLogger = require('../../../../../utils/logger').child({ __filename });
+const retry = require('../../../../../utils/retry');
 
 const isUnknownEmulatorError = (err) => (err.message || '').includes('failed with code null');
 
-class EmulatorLauncher {
-  constructor(emulatorExec) {
+class EmulatorLauncher extends AndroidDeviceLauncher {
+  constructor(emulatorExec, eventEmitter, telnetGeneratorFn = () => new EmulatorTelnet()) {
+    super(eventEmitter);
     this._emulatorExec = emulatorExec;
+    this._telnetGeneratorFn = telnetGeneratorFn;
   }
 
   async launch(emulatorName, options = {port: undefined}) {
@@ -22,6 +26,15 @@ class EmulatorLauncher {
     }, () => this._launchEmulator(emulatorName, launchCommand));
   }
 
+  async shutdown(adbName) {
+    await this._notifyPreShutdown(adbName);
+    const port = _.split(adbName, '-')[1];
+    const telnet = this._telnetGeneratorFn();
+    await telnet.connect(port);
+    await telnet.kill();
+    await this._notifyShutdownCompleted(adbName);
+  }
+
   _launchEmulator(emulatorName, launchCommand) {
     let childProcessOutput;
     const portName = launchCommand.port ? `-${launchCommand.port}` : '';
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
index 7f1ce25d4..ffda664a7 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
@@ -1,6 +1,7 @@
 const semver = require('semver'); // eslint-disable-line node/no-extraneous-require
 const onSignalExit = require('signal-exit');
 const AndroidDriver = require('../AndroidDriver');
+const InstanceLauncher = require('./helpers/GenyCloudInstanceLauncher');
 const GenyCloudInstanceAllocation = require('./helpers/GenyCloudInstanceAllocation');
 const GenyDeviceRegistryFactory = require('./GenyDeviceRegistryFactory');
 const GenyCloudExec = require('./exec/GenyCloudExec');
@@ -26,14 +27,15 @@ class GenyCloudDriver extends AndroidDriver {
 
     this._exec = new GenyCloudExec(environment.getGmsaasPath());
     const instanceNaming = new InstanceNaming(); // TODO should consider a permissive impl for debug/dev mode. Maybe even a custom arg in package.json (Detox > ... > genycloud > sharedAccount: false)
-    this._deviceRegistry = GenyDeviceRegistryFactory.forRuntime();
-    this._deviceCleanupRegistry = GenyDeviceRegistryFactory.forGlobalShutdown();
+    const deviceRegistry = GenyDeviceRegistryFactory.forRuntime();
+    const deviceCleanupRegistry = GenyDeviceRegistryFactory.forGlobalShutdown();
 
     const recipeService = new RecipesService(this._exec, logger);
-    const instanceLookupService = new InstanceLookupService(this._exec, instanceNaming, this._deviceRegistry);
+    const instanceLookupService = new InstanceLookupService(this._exec, instanceNaming, deviceRegistry);
     this._instanceLifecycleService = new InstanceLifecycleService(this._exec, instanceNaming);
+    this._instanceLauncher = new InstanceLauncher(this._instanceLifecycleService, deviceCleanupRegistry, this.emitter);
     this._recipeQuerying = new RecipeQuerying(recipeService);
-    this._instanceAllocation = new GenyCloudInstanceAllocation(this._deviceRegistry, this._deviceCleanupRegistry, instanceLookupService, this._instanceLifecycleService, this.emitter);
+    this._instanceAllocation = new GenyCloudInstanceAllocation(deviceRegistry, instanceLookupService, this._instanceLifecycleService, this._instanceLauncher, this.emitter);
 
     this._authService = new AuthService(this._exec);
   }
@@ -69,12 +71,15 @@ class GenyCloudDriver extends AndroidDriver {
   }
 
   async cleanup(instance, bundleId) {
-    await this._deviceRegistry.disposeDevice(instance.uuid);
-    await super.cleanup(instance, bundleId);
+      try {
+        await super.cleanup(instance, bundleId);
+      } finally {
+        await this._instanceAllocation.deallocateDevice(instance.uuid);
+      }
   }
 
   async shutdown(instance) {
-    await this._instanceAllocation.deallocateDevice(instance);
+    await this._instanceLauncher.shutdown(instance);
   }
 
   _assertRecipe(deviceQuery, recipe) {
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.js
index 75024562b..077e66da8 100644
--- a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.js
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.js
@@ -1,5 +1,4 @@
 const AndroidDeviceAllocation = require('../../AndroidDeviceAllocation');
-const GenyCloudInstanceHandle = require('../GenyCloudInstanceHandle');
 const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError');
 const retry = require('../../../../../utils/retry');
 const logger = require('../../../../../utils/logger').child({ __filename });
@@ -7,12 +6,12 @@ const logger = require('../../../../../utils/logger').child({ __filename });
 const { ALLOCATE_DEVICE_LOG_EVT } = AndroidDeviceAllocation;
 
 class GenyCloudInstanceAllocation extends AndroidDeviceAllocation {
-  constructor(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService, eventEmitter) {
+  constructor(deviceRegistry, instanceLookupService, instanceLifecycleService, instanceLauncher, eventEmitter) {
     super(deviceRegistry, eventEmitter, logger);
 
-    this._deviceCleanupRegistry = deviceCleanupRegistry;
     this._instanceLookupService = instanceLookupService;
     this._instanceLifecycleService = instanceLifecycleService;
+    this._instanceLauncher = instanceLauncher;
   }
 
   async allocateDevice(recipe) {
@@ -21,8 +20,7 @@ class GenyCloudInstanceAllocation extends AndroidDeviceAllocation {
     this._logAllocationResult(recipe, instance);
 
     if (isNew) {
-      const instanceHandle = new GenyCloudInstanceHandle(instance);
-      await this._deviceCleanupRegistry.allocateDevice(instanceHandle);
+      await this._instanceLauncher.launch(instance);
     }
 
     instance = await this._waitForInstanceBoot(instance);
@@ -32,13 +30,8 @@ class GenyCloudInstanceAllocation extends AndroidDeviceAllocation {
     return instance;
   }
 
-  async deallocateDevice(instance) {
-    const instanceHandle = new GenyCloudInstanceHandle(instance);
-
-    await this._notifyPreDeallocation(instance.adbName);
-    await this._instanceLifecycleService.deleteInstance(instance.uuid);
-    await this._deviceCleanupRegistry.disposeDevice(instanceHandle);
-    await this._notifyDeallocationCompleted(instance.adbName);
+  async deallocateDevice(instanceUUID) {
+    await this._deviceRegistry.disposeDevice(instanceUUID);
   }
 
   async _doSynchronizedAllocation(recipe) {
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.js
new file mode 100644
index 000000000..1557bd854
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.js
@@ -0,0 +1,36 @@
+const AndroidDeviceLauncher = require('../../AndroidDeviceLauncher');
+const GenyCloudInstanceHandle = require('../GenyCloudInstanceHandle');
+
+class GenyCloudInstanceLauncher extends AndroidDeviceLauncher {
+  constructor(instanceLifecycleService, deviceCleanupRegistry, eventEmitter) {
+    super(eventEmitter);
+    this._instanceLifecycleService = instanceLifecycleService;
+    this._deviceCleanupRegistry = deviceCleanupRegistry;
+  }
+
+  /**
+   * Note:
+   * In the context of Genymotion-cloud (as opposed to local emulators), the "launching"
+   * effectively only boils down to book-keeping associated with it, rather than the
+   * launch itself. That it because the actual launching (creation) of it is done in a synchronized,
+   * isolated means beforehand.
+   *
+   * @param instance {GenyInstance} The freshly allocated cloud-instance.
+   * @returns {Promise<void>}
+   */
+  async launch(instance) {
+    const instanceHandle = new GenyCloudInstanceHandle(instance);
+    await this._deviceCleanupRegistry.allocateDevice(instanceHandle);
+  }
+
+  async shutdown(instance) {
+    const instanceHandle = new GenyCloudInstanceHandle(instance);
+
+    await this._notifyPreShutdown(instance.adbName);
+    await this._instanceLifecycleService.deleteInstance(instance.uuid);
+    await this._deviceCleanupRegistry.disposeDevice(instanceHandle);
+    await this._notifyShutdownCompleted(instance.adbName);
+  }
+}
+
+module.exports = GenyCloudInstanceLauncher;


Test patch:
diff --git a/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.test.js b/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.test.js
index d0b5f4888..58bb1d815 100644
--- a/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.test.js
+++ b/detox/src/devices/drivers/android/emulator/helpers/EmulatorDeviceAllocation.test.js
@@ -10,7 +10,6 @@ describe('Android emulator device allocation', () => {
   let emulatorLauncher;
   let adb;
   let randomFunc;
-  let emulatorTelnet;
   let uut;
   beforeEach(() => {
     jest.mock('../../../../../utils/logger');
@@ -20,7 +19,11 @@ describe('Android emulator device allocation', () => {
     retry = require('../../../../../utils/retry');
     retry.mockImplementation((options, func) => func());
 
-    const EmulatorLauncher = jest.genMockFromModule('../EmulatorLauncher');
+    jest.mock('../../../../../utils/trace', () => ({
+      traceCall: jest.fn().mockImplementation((__, func) => func()),
+    }));
+
+    const EmulatorLauncher = jest.genMockFromModule('./EmulatorLauncher');
     emulatorLauncher = new EmulatorLauncher();
 
     const AsyncEmitter = jest.genMockFromModule('../../../../../utils/AsyncEmitter');
@@ -37,18 +40,10 @@ describe('Android emulator device allocation', () => {
     adb = new ADB();
     adb.isBootComplete.mockResolvedValue(true);
 
-    const EmulatorTelnet = jest.genMockFromModule('../../tools/EmulatorTelnet');
-    emulatorTelnet = new EmulatorTelnet();
-    jest.mock('../../tools/EmulatorTelnet');
-
     randomFunc = jest.fn().mockReturnValue(1);
 
-    jest.mock('../../../../../utils/trace', () => ({
-      traceCall: jest.fn().mockImplementation((__, func) => func()),
-    }));
-
     const EmulatorDeviceAllocation = require('./EmulatorDeviceAllocation');
-    uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, () => emulatorTelnet, randomFunc);
+    uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, randomFunc);
   });
 
   const givenFreeDevice = (adbName) => freeDeviceFinder.findFreeDevice.mockResolvedValue(adbName);
@@ -119,8 +114,10 @@ describe('Android emulator device allocation', () => {
 
       try {
         await uut.allocateDevice(avdName);
-        fail('Expected an error');
-      } catch (e) {}
+      } catch (e) {
+        return;
+      }
+      throw new Error('Expected an error');
     });
 
     it('should randomize a custom port for a newly launched emulator, in the 10000-20000 range', async () => {
@@ -176,11 +173,12 @@ describe('Android emulator device allocation', () => {
 
       try {
         await uut.allocateDevice(avdName);
-        fail('Expected an error');
       } catch (e) {
         expect(e.constructor.name).toEqual('DetoxRuntimeError');
         expect(e.toString()).toContain(`Waited for ${adbName} to complete booting for too long!`);
+        return;
       }
+      throw new Error('Expected an error');
     });
 
     it('should call retry with decent options', async () => {
@@ -241,44 +239,27 @@ describe('Android emulator device allocation', () => {
       givenDeviceBootCompleted();
 
       const EmulatorDeviceAllocation = require('./EmulatorDeviceAllocation');
-      uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, () => emulatorTelnet, undefined);
+      uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, undefined);
 
       await uut.allocateDevice(avdName);
     });
   });
 
   describe('deallocation', () => {
-    it('should kill device via telnet', async () => {
-      await uut.deallocateDevice(adbName);
-
-      expect(emulatorTelnet.connect).toHaveBeenCalledWith('1117');
-      expect(emulatorTelnet.kill).toHaveBeenCalled();
-    });
-
-    it('should emit associated events', async () => {
+    it('should dispose the device from the registry', async () => {
       await uut.deallocateDevice(adbName);
-
-      expect(eventEmitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', { deviceId: adbName });
-      expect(eventEmitter.emit).toHaveBeenCalledWith('shutdownDevice', { deviceId: adbName });
+      expect(deviceRegistry.disposeDevice).toHaveBeenCalledWith(adbName);
     });
 
-    it('should not emit shutdownDevice prematurely', async () => {
-      emulatorTelnet.kill.mockRejectedValue(new Error());
+    it('should fail if registry fails', async () => {
+      deviceRegistry.disposeDevice.mockRejectedValue(new Error());
 
       try {
         await uut.deallocateDevice(adbName);
-        fail('Expected an error');
-      } catch (e) {}
-
-      expect(eventEmitter.emit).toHaveBeenCalledTimes(1);
-      expect(eventEmitter.emit).not.toHaveBeenCalledWith('shutdownDevice', expect.any(Object));
-    });
-
-    it('should resort to a default telnet generator func', async () => {
-      const EmulatorDeviceAllocation = require('./EmulatorDeviceAllocation');
-      uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, undefined, randomFunc);
-
-      await uut.deallocateDevice(adbName);
+      } catch (e) {
+        return;
+      }
+      throw new Error('Expected an error');
     });
   });
 });
diff --git a/detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.test.js b/detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.test.js
new file mode 100644
index 000000000..f186bc954
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/helpers/EmulatorLauncher.test.js
@@ -0,0 +1,77 @@
+const adbName = 'mock_adb_name-1117';
+const avdName = 'mock-AVD-name';
+
+describe('Emulator launcher', () => {
+
+  let logger;
+  let retry;
+  let eventEmitter;
+  let emulatorTelnet;
+  let emulatorExec;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../../utils/logger');
+    logger = require('../../../../../utils/logger');
+
+    jest.mock('../../../../../utils/retry');
+    retry = require('../../../../../utils/retry');
+    retry.mockImplementation((options, func) => func());
+
+    jest.mock('../../../../../utils/trace', () => ({
+      traceCall: jest.fn().mockImplementation((__, func) => func()),
+    }));
+
+    const AsyncEmitter = jest.genMockFromModule('../../../../..//utils/AsyncEmitter');
+    eventEmitter = new AsyncEmitter();
+
+    const EmulatorTelnet = jest.genMockFromModule('../../tools/EmulatorTelnet');
+    emulatorTelnet = new EmulatorTelnet();
+    jest.mock('../../tools/EmulatorTelnet');
+
+    const { EmulatorExec } = jest.genMockFromModule('../../exec/EmulatorExec');
+    emulatorExec = new EmulatorExec();
+
+    const EmulatorLauncher = require('./EmulatorLauncher');
+    uut = new EmulatorLauncher(emulatorExec, eventEmitter, () => emulatorTelnet);
+  });
+
+  describe('launch', () => {
+    // TODO
+  });
+
+  describe('shutdown', () => {
+    it('should kill device via telnet', async () => {
+      await uut.shutdown(adbName);
+
+      expect(emulatorTelnet.connect).toHaveBeenCalledWith('1117');
+      expect(emulatorTelnet.kill).toHaveBeenCalled();
+    });
+
+    it('should emit associated events', async () => {
+      await uut.shutdown(adbName);
+
+      expect(eventEmitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', { deviceId: adbName });
+      expect(eventEmitter.emit).toHaveBeenCalledWith('shutdownDevice', { deviceId: adbName });
+    });
+
+    it('should not emit shutdownDevice prematurely', async () => {
+      emulatorTelnet.kill.mockRejectedValue(new Error());
+
+      try {
+        await uut.shutdown(adbName);
+      } catch (e) {
+        expect(eventEmitter.emit).toHaveBeenCalledTimes(1);
+        expect(eventEmitter.emit).not.toHaveBeenCalledWith('shutdownDevice', expect.any(Object));
+        return;
+      }
+      throw new Error('Expected an error');
+    });
+
+    it('should resort to a default telnet generator func', async () => {
+      const EmulatorLauncher = require('./EmulatorLauncher');
+      uut = new EmulatorLauncher(emulatorExec, eventEmitter);
+
+      await uut.shutdown(adbName);
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
index 1101b19bd..5dbc51d38 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
@@ -43,6 +43,7 @@ describe('Genymotion-cloud driver', () => {
   let recipeQuerying;
   let deviceRegistry;
   let deviceCleanupRegistry;
+  let instanceLauncher;
   let instanceAllocation;
   let authServiceObj;
   beforeEach(() => {
@@ -78,6 +79,10 @@ describe('Genymotion-cloud driver', () => {
     const GenyRecipeQuerying = require('./helpers/GenyRecipeQuerying');
     recipeQuerying = () => latestInstanceOf(GenyRecipeQuerying);
 
+    jest.mock('./helpers/GenyCloudInstanceLauncher');
+    const InstanceLauncher = require('./helpers/GenyCloudInstanceLauncher');
+    instanceLauncher = () => latestInstanceOf(InstanceLauncher);
+
     jest.mock('./helpers/GenyCloudInstanceAllocation');
     const InstanceAllocation = require('./helpers/GenyCloudInstanceAllocation');
     instanceAllocation = () => latestInstanceOf(InstanceAllocation);
@@ -278,23 +283,34 @@ describe('Genymotion-cloud driver', () => {
         Instrumentation = require('../tools/MonitoredInstrumentation');
       });
 
-      it('should dispose an instance based on its UUID', async () => {
+      it('should deallocate an instance based on its UUID', async () => {
         const instance = anInstance();
         await uut.cleanup(instance, 'bundle-id');
-        expect(deviceRegistry.disposeDevice).toHaveBeenCalledWith(instance.uuid);
+        expect(instanceAllocation().deallocateDevice).toHaveBeenCalledWith(instance.uuid);
       });
 
       it('should kill instrumentation', async () => {
         await uut.cleanup(anInstance(), 'bundle-id');
         expect(instrumentationObj().terminate).toHaveBeenCalled();
       });
+
+      it('should deallocate the instance even if instrumentation termination fails', async () => {
+        const instance = anInstance();
+
+        instrumentationObj().terminate.mockRejectedValue(new Error());
+
+        try {
+          await uut.cleanup(instance, 'bundle-id');
+        } catch (e) {}
+        expect(instanceAllocation().deallocateDevice).toHaveBeenCalledWith(instance.uuid);
+      });
     });
 
     describe('shutdown', () => {
       it('should deallocate the instance', async () => {
         const instance = anInstance();
         await uut.shutdown(instance);
-        expect(instanceAllocation().deallocateDevice).toHaveBeenCalledWith(instance);
+        expect(instanceLauncher().shutdown).toHaveBeenCalledWith(instance);
       });
     });
   });
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.test.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.test.js
index c4321f7c0..89c2f3021 100644
--- a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.test.js
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceAllocation.test.js
@@ -6,9 +6,9 @@ describe('Genymotion-Cloud instance allocation', () => {
   let retry;
   let eventEmitter;
   let deviceRegistry;
-  let deviceCleanupRegistry;
   let instanceLookupService;
   let instanceLifecycleService;
+  let instanceLauncher;
   let GenyInstance;
   let uut;
   beforeEach(() => {
@@ -25,7 +25,6 @@ describe('Genymotion-Cloud instance allocation', () => {
     const DeviceRegistry = jest.genMockFromModule('../../../../../devices/DeviceRegistry');
     deviceRegistry = new DeviceRegistry();
     deviceRegistry.allocateDevice.mockImplementation((func) => func());
-    deviceCleanupRegistry = new DeviceRegistry();
 
     const InstanceLookupService = jest.genMockFromModule('../services/GenyInstanceLookupService');
     instanceLookupService = new InstanceLookupService();
@@ -33,10 +32,13 @@ describe('Genymotion-Cloud instance allocation', () => {
     const InstanceLifecycleService = jest.genMockFromModule('../services/GenyInstanceLifecycleService');
     instanceLifecycleService = new InstanceLifecycleService();
 
+    const InstanceLauncher = jest.genMockFromModule('./GenyCloudInstanceLauncher');
+    instanceLauncher = new InstanceLauncher();
+
     GenyInstance = jest.genMockFromModule('../services/dto/GenyInstance');
 
     const InstanceAllocation = require('./GenyCloudInstanceAllocation');
-    uut = new InstanceAllocation(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService, eventEmitter);
+    uut = new InstanceAllocation(deviceRegistry, instanceLookupService, instanceLifecycleService, instanceLauncher, eventEmitter);
   });
 
   const aRecipe = () => ({
@@ -219,16 +221,13 @@ describe('Genymotion-Cloud instance allocation', () => {
       expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
     });
 
-    it('should register a new instance for cleanup', async () => {
+    it('should "launch" a new instance', async () => {
       const instance = aFullyConnectedInstance();
       givenNoFreeInstances();
       givenCreatedInstance(instance);
 
       await uut.allocateDevice(aRecipe());
-      expect(deviceCleanupRegistry.allocateDevice).toHaveBeenCalledWith({
-        uuid: instance.uuid,
-        name: instance.name,
-      });
+      expect(instanceLauncher.launch).toHaveBeenCalledWith(instance);
     });
 
     it('should not register an existing instance for cleanup', async () => {
@@ -236,7 +235,7 @@ describe('Genymotion-Cloud instance allocation', () => {
       givenFreeInstance(instance);
 
       await uut.allocateDevice(aRecipe());
-      expect(deviceCleanupRegistry.allocateDevice).not.toHaveBeenCalled();
+      expect(instanceLauncher.launch).not.toHaveBeenCalled();
     });
 
     it('should log pre-allocate message', async () => {
@@ -304,51 +303,22 @@ describe('Genymotion-Cloud instance allocation', () => {
   });
 
   describe('Deallocation', () => {
-    const givenAnInstanceDeletionError = () => instanceLifecycleService.deleteInstance.mockRejectedValue(new Error());
-
-    it('should delete the associated instance', async () => {
-      const instance = aFullyConnectedInstance();
-      await uut.deallocateDevice(instance);
-      expect(instanceLifecycleService.deleteInstance).toHaveBeenCalledWith(instance.uuid);
-    });
-
-    it('should fail if deletion fails', async () => {
-      givenAnInstanceDeletionError();
-
-      try {
-        const instance = aFullyConnectedInstance();
-        await uut.deallocateDevice(instance);
-        fail('Expected an error');
-      } catch (e) {}
-    });
-
-    it('should remove the instance from the cleanup registry', async () => {
-      const instance = aFullyConnectedInstance();
-      await uut.deallocateDevice(instance);
-      expect(deviceCleanupRegistry.disposeDevice).toHaveBeenCalledWith(expect.objectContaining({
-        uuid: instance.uuid,
-      }));
-    });
-
-    it('should emit associated events', async () => {
-      const instance = aFullyConnectedInstance();
-      await uut.deallocateDevice(instance);
-
-      expect(eventEmitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', { deviceId: instance.adbName });
-      expect(eventEmitter.emit).toHaveBeenCalledWith('shutdownDevice', { deviceId: instance.adbName });
+    it('should dispose the instance from the standard device registry', async () => {
+      const instance = anOnlineInstance();
+      await uut.deallocateDevice(instance.uuid)
+      expect(deviceRegistry.disposeDevice).toHaveBeenCalledWith(instance.uuid);
     });
 
-    it('should not emit shutdownDevice prematurely', async () => {
-      givenAnInstanceDeletionError();
+    it('should fail if registry fails', async () => {
+      const instance = anOnlineInstance();
+      deviceRegistry.disposeDevice.mockRejectedValue(new Error());
 
       try {
-        const instance = aFullyConnectedInstance();
-        await uut.deallocateDevice(instance);
-        fail('Expected an error');
-      } catch (e) {}
-
-      expect(eventEmitter.emit).toHaveBeenCalledTimes(1);
-      expect(eventEmitter.emit).not.toHaveBeenCalledWith('shutdownDevice', expect.any(Object));
+        await uut.deallocateDevice(instance.uuid);
+      } catch (e) {
+        return;
+      }
+      throw new Error('Expected an error');
     });
   });
 });
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.test.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.test.js
new file mode 100644
index 000000000..d5b4a8ff4
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyCloudInstanceLauncher.test.js
@@ -0,0 +1,90 @@
+describe('Genymotion-Cloud instance launcher', () => {
+  const anInstance = () => {
+    const instance = new GenyInstance();
+    instance.uuid = 'mock-instance-uuid';
+    instance.name = 'mock-instance-name';
+    return instance;
+  }
+
+  const givenAnInstanceDeletionError = () => instanceLifecycleService.deleteInstance.mockRejectedValue(new Error());
+
+  let eventEmitter;
+  let deviceCleanupRegistry;
+  let instanceLifecycleService;
+  let GenyInstance;
+  let uut;
+  beforeEach(() => {
+    const AsyncEmitter = jest.genMockFromModule('../../../../../utils/AsyncEmitter');
+    eventEmitter = new AsyncEmitter();
+
+    const InstanceLifecycleService = jest.genMockFromModule('../services/GenyInstanceLifecycleService');
+    instanceLifecycleService = new InstanceLifecycleService();
+
+    const DeviceRegistry = jest.genMockFromModule('../../../../../devices/DeviceRegistry');
+    deviceCleanupRegistry = new DeviceRegistry();
+
+    GenyInstance = jest.genMockFromModule('../services/dto/GenyInstance');
+
+    const GenyCloudInstanceLauncher = require('./GenyCloudInstanceLauncher');
+    uut = new GenyCloudInstanceLauncher(instanceLifecycleService, deviceCleanupRegistry, eventEmitter);
+  });
+
+  describe('Launch', () => {
+    it('should register device in cleanup registry', async () => {
+      const instance = anInstance();
+
+      await uut.launch(instance);
+      expect(deviceCleanupRegistry.allocateDevice).toHaveBeenCalledWith({
+        uuid: instance.uuid,
+        name: instance.name,
+      })
+    });
+  });
+
+  describe('Shutdown', () => {
+    it('should delete the associated instance', async () => {
+      const instance = anInstance();
+      await uut.shutdown(instance);
+      expect(instanceLifecycleService.deleteInstance).toHaveBeenCalledWith(instance.uuid);
+    });
+
+    it('should fail if deletion fails', async () => {
+      givenAnInstanceDeletionError();
+
+      try {
+        const instance = anInstance();
+        await uut.shutdown(instance);
+        fail('Expected an error');
+      } catch (e) {}
+    });
+
+    it('should remove the instance from the cleanup registry', async () => {
+      const instance = anInstance();
+      await uut.shutdown(instance);
+      expect(deviceCleanupRegistry.disposeDevice).toHaveBeenCalledWith(expect.objectContaining({
+        uuid: instance.uuid,
+      }));
+    });
+
+    it('should emit associated events', async () => {
+      const instance = anInstance();
+      await uut.shutdown(instance);
+
+      expect(eventEmitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', { deviceId: instance.adbName });
+      expect(eventEmitter.emit).toHaveBeenCalledWith('shutdownDevice', { deviceId: instance.adbName });
+    });
+
+    it('should not emit shutdownDevice prematurely', async () => {
+      givenAnInstanceDeletionError();
+
+      try {
+        const instance = anInstance();
+        await uut.shutdown(instance);
+        fail('Expected an error');
+      } catch (e) {}
+
+      expect(eventEmitter.emit).toHaveBeenCalledTimes(1);
+      expect(eventEmitter.emit).not.toHaveBeenCalledWith('shutdownDevice', expect.any(Object));
+    });
+  });
+});