Repository: wix/Detox
Pull request #2573 (2021-01-06 08:55:24)

Rework android device allocation framework
## Description

Genymotion-cloud work tech debt (#2429): Avoid returning complex values from Android device allocation helpers onto the drivers, by pushing *all* responsibility onto the helper classes (rather than have it split between the two).

This cleans up driver code, simplified contracts and increases unit test coverage.

d4vidi: @noomorph, 'not going to wait for a review with this one - it's all based on your feedback, basically. Obviously if you're into it as a sport, I would appreciate it.

Patch:
diff --git a/detox/package.json b/detox/package.json
index 5febf5669..4d93605f2 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -103,8 +103,7 @@
       "EmulatorDriver.js",
       "IosDriver.js",
       "SimulatorDriver.js",
-      "AndroidDeviceAllocator.js",
-      "AttachedDeviceAllocator.js",
+      "AndroidDeviceAllocation.js",
       "MissingDetox.js",
       "EmulatorTelnet.js",
       "EmulatorExec.js",
diff --git a/detox/src/devices/drivers/android/AndroidDeviceAllocation.js b/detox/src/devices/drivers/android/AndroidDeviceAllocation.js
new file mode 100644
index 000000000..73de51dc0
--- /dev/null
+++ b/detox/src/devices/drivers/android/AndroidDeviceAllocation.js
@@ -0,0 +1,31 @@
+const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
+
+class AndroidDeviceAllocation {
+  constructor(deviceRegistry, eventEmitter, logger) {
+    this._deviceRegistry = deviceRegistry;
+    this._eventEmitter = eventEmitter;
+    this._logger = logger;
+  }
+
+  _logAllocationAttempt(deviceQuery) {
+    this._logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on "${deviceQuery}"`);
+  }
+
+  _logAllocationResult(deviceQuery, deviceHandle) {
+    this._logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${deviceHandle}`);
+  }
+
+  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 });
+  }
+}
+
+module.exports = AndroidDeviceAllocation;
diff --git a/detox/src/devices/drivers/android/AndroidDeviceAllocator.js b/detox/src/devices/drivers/android/AndroidDeviceAllocator.js
deleted file mode 100644
index 915d29a4a..000000000
--- a/detox/src/devices/drivers/android/AndroidDeviceAllocator.js
+++ /dev/null
@@ -1,21 +0,0 @@
-const ALLOCATE_DEVICE_LOG_EVT = 'ALLOCATE_DEVICE';
-
-class AndroidDeviceAllocator {
-  constructor(deviceRegistry, logger) {
-    this.deviceRegistry = deviceRegistry;
-    this.logger = logger;
-  }
-
-  async allocateDevice(deviceQuery) {
-    this.logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Trying to allocate a device based on "${deviceQuery}"`);
-    const deviceId = await this._doAllocateDevice(deviceQuery);
-    this.logger.debug({ event: ALLOCATE_DEVICE_LOG_EVT }, `Settled on ${deviceId}`);
-    return deviceId;
-  }
-
-  async _doAllocateDevice(deviceQuery) {
-    throw new Error('Not implemented!');
-  }
-}
-
-module.exports = AndroidDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.js b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.js
new file mode 100644
index 000000000..04402acfd
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.js
@@ -0,0 +1,99 @@
+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');
+
+const DetoxEmulatorsPortRange = {
+  min: 10000,
+  max: 20000
+};
+
+class EmulatorDeviceAllocation extends AndroidDeviceAllocation {
+  constructor(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, telnetGeneratorFn = () => new EmulatorTelnet(), 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) {
+    this._logAllocationAttempt(avdName);
+    const {
+      adbName,
+      placeholderPort,
+    } = await this._doSynchronizedAllocation(avdName);
+    this._logAllocationResult(avdName, adbName);
+
+    const coldBoot = !!placeholderPort;
+    if (coldBoot) {
+      await this._launchEmulator(avdName, placeholderPort);
+    }
+    await this._awaitEmulatorBoot(adbName);
+    await this._notifyAllocation(adbName, avdName, coldBoot);
+    return adbName;
+  }
+
+  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);
+  }
+
+  async _doSynchronizedAllocation(avdName) {
+    let placeholderPort = null;
+    let adbName = null;
+
+    await this._deviceRegistry.allocateDevice(async () => {
+      adbName = await this._freeDeviceFinder.findFreeDevice(avdName);
+      if (!adbName) {
+        placeholderPort = this._allocateEmulatorPlaceholderPort();
+        adbName = `emulator-${placeholderPort}`;
+      }
+      return adbName;
+    });
+
+    return {
+      adbName,
+      placeholderPort,
+    };
+  }
+
+  async _launchEmulator(avdName, bootPort) {
+    await traceCall('emulatorLaunch', () =>
+      this._emulatorLauncher.launch(avdName, { port: bootPort }));
+  }
+
+  async _awaitEmulatorBoot(adbName) {
+    await traceCall('awaitBoot', () =>
+      this._waitForBootToComplete(adbName));
+  }
+
+  async _waitForBootToComplete(adbName) {
+    await retry({ retries: 240, interval: 2500 }, async () => {
+      const isBootComplete = await this._adb.isBootComplete(adbName);
+
+      if (!isBootComplete) {
+        throw new DetoxRuntimeError({
+          message: `Waited for ${adbName} to complete booting for too long!`,
+        });
+      }
+    });
+  }
+
+  _allocateEmulatorPlaceholderPort() {
+    const { min, max } = DetoxEmulatorsPortRange;
+    let port = this._rand() * (max - min) + min;
+    port = port & 0xFFFFFFFE; // Should always be even
+    return port;
+  }
+}
+
+module.exports = EmulatorDeviceAllocation;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js
deleted file mode 100644
index 30ec5e185..000000000
--- a/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocator.js
+++ /dev/null
@@ -1,44 +0,0 @@
-const AndroidDeviceAllocator = require('../AndroidDeviceAllocator');
-const logger = require('../../../../utils/logger').child({ __filename });
-
-const DetoxEmulatorsPortRange = {
-  min: 10000,
-  max: 20000
-};
-
-// TODO unit test
-class EmulatorDeviceAllocator extends AndroidDeviceAllocator {
-  constructor(deviceRegistry, freeDeviceFinder) {
-    super(deviceRegistry, logger);
-    this._freeDeviceFinder = freeDeviceFinder;
-  }
-
-  async _doAllocateDevice(deviceQuery) {
-    let placeholderPort = null;
-    let adbName = null;
-
-    await this.deviceRegistry.allocateDevice(async () => {
-      adbName = await this._freeDeviceFinder.findFreeDevice(deviceQuery);
-      if (!adbName) {
-        placeholderPort = this._allocateEmulatorPlaceholderPort();
-        adbName = `emulator-${placeholderPort}`;
-      }
-      return adbName;
-    });
-
-    return {
-      adbName,
-      placeholderPort,
-      toString: () => adbName,
-    }
-  }
-
-  _allocateEmulatorPlaceholderPort() {
-    const { min, max } = DetoxEmulatorsPortRange;
-    let port = Math.random() * (max - min) + min;
-    port = port & 0xFFFFFFFE; // Should always be even
-    return port;
-  }
-}
-
-module.exports = EmulatorDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
index bac4ef0bf..c9f76c298 100644
--- a/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDriver.js
@@ -3,41 +3,35 @@ const fs = require('fs');
 const path = require('path');
 const ini = require('ini');
 const AndroidDriver = require('../AndroidDriver');
-const EmulatorDeviceAllocator = require('./EmulatorDeviceAllocator');
+const EmulatorDeviceAllocation = require('./EmulatorDeviceAllocation');
 const AVDValidator = require('./AVDValidator');
 const AVDsResolver = require('./AVDsResolver');
 const EmulatorLauncher = require('./EmulatorLauncher');
 const EmulatorVersionResolver = require('./EmulatorVersionResolver');
 const FreeEmulatorFinder = require('./FreeEmulatorFinder');
 const { EmulatorExec } = require('../exec/EmulatorExec');
-const EmulatorTelnet = require('../tools/EmulatorTelnet');
-const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
 const DeviceRegistry = require('../../../DeviceRegistry');
 const environment = require('../../../../utils/environment');
-const retry = require('../../../../utils/retry');
 const log = require('../../../../utils/logger').child({ __filename });
 const argparse = require('../../../../utils/argparse');
-const { traceCall } = require('../../../../utils/trace');
 
 const EMU_BIN_STABLE_SKIN_VER = 28;
 
 class EmulatorDriver extends AndroidDriver {
   constructor(config) {
     super(config);
-
+    this._name = 'Unspecified Emulator';
     this._deviceRegistry = DeviceRegistry.forAndroid();
 
     const emulatorExec = new EmulatorExec();
+    const emulatorLauncher = new EmulatorLauncher(emulatorExec);
     this._emuVersionResolver = new EmulatorVersionResolver(emulatorExec);
-    this._emuLauncher = new EmulatorLauncher(emulatorExec);
 
     const avdsResolver = new AVDsResolver(emulatorExec);
     this._avdValidator = new AVDValidator(avdsResolver, this._emuVersionResolver);
 
     const freeEmulatorFinder = new FreeEmulatorFinder(this.adb, this._deviceRegistry)
-    this._deviceAllocator = new EmulatorDeviceAllocator(this._deviceRegistry, freeEmulatorFinder);
-
-    this._name = 'Unspecified Emulator';
+    this._deviceAllocation = new EmulatorDeviceAllocation(this._deviceRegistry, freeEmulatorFinder, emulatorLauncher, this.adb, this.emitter);
   }
 
   get name() {
@@ -48,15 +42,9 @@ class EmulatorDriver extends AndroidDriver {
     const avdName = _.isPlainObject(deviceQuery) ? deviceQuery.avdName : deviceQuery;
 
     await this._avdValidator.validate(avdName);
-    await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
-
-    const {
-      adbName,
-      placeholderPort,
-    } = await this._deviceAllocator.allocateDevice(avdName);
-
-    await this._boot(avdName, adbName, placeholderPort);
+    await this._fixAvdConfigIniSkinNameIfNeeded(avdName);
 
+    const adbName = await this._deviceAllocation.allocateDevice(avdName);
     await this.adb.apiLevel(adbName);
     await this.adb.disableAndroidAnimations(adbName);
     await this.adb.unlockScreen(adbName);
@@ -81,45 +69,16 @@ class EmulatorDriver extends AndroidDriver {
     return this._emuVersionResolver.resolve();
   }
 
-  async _boot(avdName, adbName, bootPort) {
-    const coldBoot = !!bootPort;
-    if (coldBoot) {
-      await traceCall('emulatorLaunch', () =>
-        this._emuLauncher.launch(avdName, { port: bootPort }));
-    }
-
-    await traceCall('awaitBoot', () =>
-      this._waitForBootToComplete(adbName));
-    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName, type: avdName });
-  }
-
-  async _waitForBootToComplete(deviceId) {
-    await retry({ retries: 240, interval: 2500 }, async () => {
-      const isBootComplete = await this.adb.isBootComplete(deviceId);
-
-      if (!isBootComplete) {
-        throw new DetoxRuntimeError({
-          message: `Android device ${deviceId} has not completed its boot yet.`,
-        });
-      }
-    });
-  }
-
   async cleanup(deviceId, bundleId) {
     await this._deviceRegistry.disposeDevice(deviceId);
     await super.cleanup(deviceId, bundleId);
   }
 
   async shutdown(deviceId) {
-    await this.emitter.emit('beforeShutdownDevice', { deviceId });
-    const port = _.split(deviceId, '-')[1];
-    const telnet = new EmulatorTelnet();
-    await telnet.connect(port);
-    await telnet.kill();
-    await this.emitter.emit('shutdownDevice', { deviceId });
+    await this._deviceAllocation.deallocateDevice(deviceId)
   }
 
-  async _fixEmulatorConfigIniSkinNameIfNeeded(avdName) {
+  async _fixAvdConfigIniSkinNameIfNeeded(avdName) {
     const binaryVersion = _.get(await this.binaryVersion(), 'major');
     if (!binaryVersion) {
       log.warn({ event: 'EMU_SKIN_CFG_PATCH' }, [
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js
deleted file mode 100644
index 84713181a..000000000
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.js
+++ /dev/null
@@ -1,79 +0,0 @@
-const AndroidDeviceAllocator = require('../AndroidDeviceAllocator');
-const GenyCloudInstanceHandle = require('./GenyCloudInstanceHandle');
-const retry = require('../../../../utils/retry');
-const logger = require('../../../../utils/logger').child({ __filename });
-
-class GenyCloudDeviceAllocator extends AndroidDeviceAllocator {
-  constructor(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService) {
-    super(deviceRegistry, logger);
-
-    this.deviceCleanupRegistry = deviceCleanupRegistry;
-    this.instanceLookupService = instanceLookupService;
-    this.instanceLifecycleService = instanceLifecycleService;
-  }
-
-  async _doAllocateDevice(recipe) {
-    let { instance, isNew } = await this._doSynchronizedAllocation(recipe);
-    const instanceHandle = new GenyCloudInstanceHandle(instance);
-
-    if (isNew) {
-      await this.deviceCleanupRegistry.allocateDevice(instanceHandle);
-    }
-
-    instance = await this._waitForInstanceBoot(instance);
-    instance = await this._adbConnectIfNeeded(instance);
-    return {
-      instance,
-      isNew,
-      toString: () => instanceHandle.toString(),
-    }
-  }
-
-  async _doSynchronizedAllocation(recipe) {
-    let instance = null;
-    let isNew = false;
-
-    await this.deviceRegistry.allocateDevice(async () => {
-      instance = await this.instanceLookupService.findFreeInstance();
-      if (!instance) {
-        instance = await this.instanceLifecycleService.createInstance(recipe.uuid);
-        isNew = true;
-      }
-      return instance.uuid;
-    });
-
-    return {
-      instance,
-      isNew,
-    }
-  }
-
-  async _waitForInstanceBoot(instance) {
-    if (instance.isOnline()) {
-      return instance;
-    }
-
-    const options = {
-      backoff: 'none', // TODO apply reverse-linear polling
-      retries: 18,
-      interval: 10000,
-    };
-
-    return await retry(options, async () => {
-      const _instance = await this.instanceLookupService.getInstance(instance.uuid);
-      if (!_instance.isOnline()) {
-        throw new Error(`Timeout waiting for instance ${instance.uuid} to be ready`);
-      }
-      return _instance;
-    });
-  }
-
-  async _adbConnectIfNeeded(instance) {
-    if (!instance.isAdbConnected()) {
-      instance = await this.instanceLifecycleService.adbConnectInstance(instance.uuid);
-    }
-    return instance;
-  }
-}
-
-module.exports = GenyCloudDeviceAllocator;
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
index 53a4983f7..6abc28b39 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
@@ -1,9 +1,8 @@
 const semver = require('semver'); // eslint-disable-line node/no-extraneous-require
 const onSignalExit = require('signal-exit');
 const AndroidDriver = require('../AndroidDriver');
-const GenyCloudDeviceAllocator = require('./GenyCloudDeviceAllocator');
+const GenyCloudInstanceAllocation = require('./GenyCloudInstanceAllocation');
 const GenyDeviceRegistryFactory = require('./GenyDeviceRegistryFactory');
-const GenyCloudInstanceHandle = require('./GenyCloudInstanceHandle');
 const GenyCloudExec = require('./exec/GenyCloudExec');
 const RecipesService = require('./services/GenyRecipesService');
 const InstanceLookupService = require('./services/GenyInstanceLookupService');
@@ -23,7 +22,7 @@ const cleanupLogData = {
 class GenyCloudDriver extends AndroidDriver {
   constructor(config) {
     super(config);
-    this._name = 'Unspecified Genymotion Cloud Emulator';
+    this._name = 'Unspecified GMSaaS Emulator';
 
     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)
@@ -34,7 +33,7 @@ class GenyCloudDriver extends AndroidDriver {
     const instanceLookupService = new InstanceLookupService(this._exec, instanceNaming, this._deviceRegistry);
     this._instanceLifecycleService = new InstanceLifecycleService(this._exec, instanceNaming);
     this._deviceQueryHelper = new DeviceQueryHelper(recipeService);
-    this._deviceAllocator = new GenyCloudDeviceAllocator(this._deviceRegistry, this._deviceCleanupRegistry, instanceLookupService, this._instanceLifecycleService);
+    this._instanceAllocation = new GenyCloudInstanceAllocation(this._deviceRegistry, this._deviceCleanupRegistry, instanceLookupService, this._instanceLifecycleService, this.emitter);
 
     this._authService = new AuthService(this._exec);
   }
@@ -52,14 +51,12 @@ class GenyCloudDriver extends AndroidDriver {
     const recipe = await this._deviceQueryHelper.getRecipeFromQuery(deviceQuery);
     this._assertRecipe(deviceQuery, recipe);
 
-    const { instance, isNew } = await this._deviceAllocator.allocateDevice(recipe);
-    const { adbName, uuid } = instance;
+    const instance = await this._instanceAllocation.allocateDevice(recipe);
 
-    await this.emitter.emit('bootDevice', { coldBoot: isNew, deviceId: adbName, type: recipe.name});
-    await this.adb.apiLevel(adbName);
-    await this.adb.disableAndroidAnimations(adbName);
+    await this.adb.apiLevel(instance.adbName);
+    await this.adb.disableAndroidAnimations(instance.adbName);
 
-    this._name = `GenyCloud:${instance.name} (${uuid} ${adbName})`;
+    this._name = instance.toString();
     return instance;
   }
 
@@ -77,12 +74,7 @@ class GenyCloudDriver extends AndroidDriver {
   }
 
   async shutdown(instance) {
-    const instanceHandle = new GenyCloudInstanceHandle(instance);
-
-    await this.emitter.emit('beforeShutdownDevice', { deviceId: instance.adbName });
-    await this._instanceLifecycleService.deleteInstance(instance.uuid);
-    await this._deviceCleanupRegistry.disposeDevice(instanceHandle);
-    await this.emitter.emit('shutdownDevice', { deviceId: instance.adbName });
+    await this._instanceAllocation.deallocateDevice(instance);
   }
 
   _assertRecipe(deviceQuery, recipe) {
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.js b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.js
new file mode 100644
index 000000000..c9836ea67
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.js
@@ -0,0 +1,88 @@
+const AndroidDeviceAllocation = require('../AndroidDeviceAllocation');
+const GenyCloudInstanceHandle = require('./GenyCloudInstanceHandle');
+const retry = require('../../../../utils/retry');
+const logger = require('../../../../utils/logger').child({ __filename });
+
+class GenyCloudInstanceAllocation extends AndroidDeviceAllocation {
+  constructor(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService, eventEmitter) {
+    super(deviceRegistry, eventEmitter, logger);
+
+    this._deviceCleanupRegistry = deviceCleanupRegistry;
+    this._instanceLookupService = instanceLookupService;
+    this._instanceLifecycleService = instanceLifecycleService;
+  }
+
+  async allocateDevice(recipe) {
+    this._logAllocationAttempt(recipe);
+    let { instance, isNew } = await this._doSynchronizedAllocation(recipe);
+    this._logAllocationResult(recipe, instance);
+
+    if (isNew) {
+      const instanceHandle = new GenyCloudInstanceHandle(instance);
+      await this._deviceCleanupRegistry.allocateDevice(instanceHandle);
+    }
+
+    instance = await this._waitForInstanceBoot(instance);
+    instance = await this._adbConnectIfNeeded(instance);
+
+    await this._notifyAllocation(instance.adbName, recipe.name, isNew);
+    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 _doSynchronizedAllocation(recipe) {
+    let instance = null;
+    let isNew = false;
+
+    await this._deviceRegistry.allocateDevice(async () => {
+      instance = await this._instanceLookupService.findFreeInstance();
+      if (!instance) {
+        instance = await this._instanceLifecycleService.createInstance(recipe.uuid);
+        isNew = true;
+      }
+      return instance.uuid;
+    });
+
+    return {
+      instance,
+      isNew,
+    }
+  }
+
+  async _waitForInstanceBoot(instance) {
+    if (instance.isOnline()) {
+      return instance;
+    }
+
+    const options = {
+      backoff: 'none', // TODO apply reverse-linear polling
+      retries: 18,
+      interval: 10000,
+    };
+
+    return await retry(options, async () => {
+      const _instance = await this._instanceLookupService.getInstance(instance.uuid);
+      if (!_instance.isOnline()) {
+        throw new Error(`Timeout waiting for instance ${instance.uuid} to be ready`);
+      }
+      return _instance;
+    });
+  }
+
+  async _adbConnectIfNeeded(instance) {
+    if (!instance.isAdbConnected()) {
+      instance = await this._instanceLifecycleService.adbConnectInstance(instance.uuid);
+    }
+    return instance;
+  }
+}
+
+module.exports = GenyCloudInstanceAllocation;
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceHandle.js b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceHandle.js
index 2e58c6278..3045ef023 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceHandle.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceHandle.js
@@ -3,10 +3,6 @@ class GenyCloudInstanceHandle {
     this.uuid = instance.uuid;
     this.name = instance.name;
   }
-
-  toString() {
-    return `GenyCloud:${this.name} (${this.uuid})`;
-  }
 }
 
 module.exports = GenyCloudInstanceHandle;
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
index a84e741b9..406870fff 100644
--- a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
@@ -45,6 +45,10 @@ class GenyInstance {
   get adbName() {
     return this.adb.name;
   }
+
+  toString() {
+    return `GenyCloud:${this.name} (${this.uuid} ${this.adbName})`;
+  }
 }
 
 module.exports = GenyInstance;


Test patch:
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.test.js b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.test.js
new file mode 100644
index 000000000..9d7b592a7
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorDeviceAllocation.test.js
@@ -0,0 +1,284 @@
+describe('Android emulator device allocation', () => {
+  const adbName = 'mock_adb_name-1117';
+  const avdName = 'mock-AVD-name';
+
+  let logger;
+  let retry;
+  let eventEmitter;
+  let deviceRegistry;
+  let freeDeviceFinder;
+  let emulatorLauncher;
+  let adb;
+  let randomFunc;
+  let emulatorTelnet;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../utils/logger');
+    logger = require('../../../../utils/logger');
+
+    jest.mock('../../../../utils/retry');
+    retry = require('../../../../utils/retry');
+    retry.mockImplementation((options, func) => func());
+
+    const EmulatorLauncher = jest.genMockFromModule('./EmulatorLauncher');
+    emulatorLauncher = new EmulatorLauncher();
+
+    const AsyncEmitter = jest.genMockFromModule('../../../../utils/AsyncEmitter');
+    eventEmitter = new AsyncEmitter();
+
+    const DeviceRegistry = jest.genMockFromModule('../../../../devices/DeviceRegistry');
+    deviceRegistry = new DeviceRegistry();
+    deviceRegistry.allocateDevice.mockImplementation((func) => func());
+
+    const FreeDeviceFinder = jest.genMockFromModule('./FreeEmulatorFinder');
+    freeDeviceFinder = new FreeDeviceFinder();
+
+    const ADB = jest.genMockFromModule('../exec/ADB');
+    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);
+  });
+
+  const givenFreeDevice = (adbName) => freeDeviceFinder.findFreeDevice.mockResolvedValue(adbName);
+  const givenNoFreeDevices = () => freeDeviceFinder.findFreeDevice.mockResolvedValue(null);
+  const givenRandomFuncResult = (result) => randomFunc.mockReturnValue(result);
+  const givenDeviceBootCompleted = () => adb.isBootComplete.mockResolvedValue(true);
+  const givenDeviceBootIncomplete = () => adb.isBootComplete.mockResolvedValue(false);
+  const expectDeviceBootEvent = (adbName, avdName, coldBoot) =>
+    expect(eventEmitter.emit).toHaveBeenCalledWith('bootDevice', {
+      coldBoot,
+      deviceId: adbName,
+      type: avdName,
+    });
+  const expectDeviceBootEventAnonymous = (avdName, coldBoot) =>
+    expect(eventEmitter.emit).toHaveBeenCalledWith('bootDevice', expect.objectContaining({
+      coldBoot,
+      type: avdName,
+    }));
+
+  describe('allocation', () => {
+    it('should return a free device', async () => {
+      givenFreeDevice(adbName);
+
+      const result = await uut.allocateDevice(avdName);
+      expect(result).toEqual(adbName);
+    });
+
+    it('should register an allocated device', async () => {
+      givenFreeDevice(adbName);
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        const result = await func();
+        expect(result).toEqual(adbName);
+        return result;
+      });
+
+      await uut.allocateDevice(avdName);
+      expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
+    });
+
+    it('should look-up a free device *inside* inter-locked registry callback', async () => {
+      givenFreeDevice(adbName);
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        expect(freeDeviceFinder.findFreeDevice).not.toHaveBeenCalled();
+        return await func();
+      });
+
+      await uut.allocateDevice(avdName);
+      expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
+    });
+
+    it('should launch an emulator if no free devices are available', async () => {
+      givenNoFreeDevices();
+
+      await uut.allocateDevice(avdName);
+      expect(emulatorLauncher.launch).toHaveBeenCalledWith(avdName, expect.any(Object));
+    });
+
+    it('should not launch an emulator if a free device is available', async () => {
+      givenFreeDevice(adbName);
+
+      await uut.allocateDevice(avdName);
+      expect(emulatorLauncher.launch).not.toHaveBeenCalled();
+    });
+
+    it('should fail if emulator launch fails', async () => {
+      givenNoFreeDevices();
+      emulatorLauncher.launch.mockRejectedValue(new Error());
+
+      try {
+        await uut.allocateDevice(avdName);
+        fail('Expected an error');
+      } catch (e) {}
+    });
+
+    it('should randomize a custom port for a newly launched emulator, in the 10000-20000 range', async () => {
+      givenNoFreeDevices();
+      givenRandomFuncResult(0.5);
+      const expectedPort = 15000;
+
+      await uut.allocateDevice(avdName);
+
+      expect(emulatorLauncher.launch).toHaveBeenCalledWith(avdName, { port: expectedPort });
+    });
+
+    it('should use adjacent even port for a randomized odd port (because this is what Android emulators expect)', async () => {
+      givenNoFreeDevices();
+      givenRandomFuncResult(0.0001);
+      const expectedPort = 10000; // i.e. and not 10001
+
+      await uut.allocateDevice(avdName);
+
+      expect(emulatorLauncher.launch).toHaveBeenCalledWith(avdName, { port: expectedPort });
+    });
+
+    it('should register device according to randomly allocated port in registry', async () => {
+      givenNoFreeDevices();
+      givenRandomFuncResult(0.5);
+
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        const result = await func();
+        expect(result).toEqual('emulator-15000');
+        return result;
+      });
+
+      await uut.allocateDevice(avdName);
+    });
+
+    it('should poll for boot completion', async () => {
+      givenFreeDevice(adbName);
+      givenDeviceBootCompleted();
+
+      retry.mockImplementation(async (options, func) => {
+        expect(adb.isBootComplete).not.toHaveBeenCalled();
+        await func();
+        expect(adb.isBootComplete).toHaveBeenCalledWith(adbName);
+      });
+
+      await uut.allocateDevice(avdName);
+      expect(retry).toHaveBeenCalled();
+    });
+
+    it('should throw if boot completion check returns negative', async () => {
+      givenFreeDevice(adbName);
+      givenDeviceBootIncomplete();
+
+      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!`);
+      }
+    });
+
+    it('should call retry with decent options', async () => {
+      const expectedRetryOptions = {
+        retries: 240,
+        interval: 2500,
+      };
+
+      givenNoFreeDevices();
+      givenDeviceBootCompleted();
+
+      await uut.allocateDevice(avdName);
+
+      expect(retry).toHaveBeenCalledWith(
+        expect.objectContaining(expectedRetryOptions),
+        expect.any(Function)
+      );
+    });
+
+    it('should emit a device boot event', async () => {
+      givenFreeDevice(adbName);
+      givenDeviceBootCompleted();
+
+      await uut.allocateDevice(avdName);
+
+      expectDeviceBootEvent(adbName, avdName, false);
+    });
+
+    it('should emit a device boot event for allocated emulators', async () => {
+      givenNoFreeDevices();
+      givenDeviceBootCompleted();
+
+      await uut.allocateDevice(avdName);
+
+      expectDeviceBootEventAnonymous(avdName, true);
+    });
+
+    it('should log pre-allocate message', async () => {
+      givenFreeDevice(adbName);
+
+      await uut.allocateDevice(avdName);
+
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('Trying to allocate'));
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining(avdName));
+    });
+
+    it('should log post-allocate message', async () => {
+      givenFreeDevice(adbName);
+
+      await uut.allocateDevice(avdName);
+
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining(`Settled on ${adbName}`));
+      expect(logger.debug).toHaveBeenCalledTimes(2);
+    });
+
+    it('should resort to a default random func', async () => {
+      givenNoFreeDevices();
+      givenDeviceBootCompleted();
+
+      const EmulatorDeviceAllocation = require('./EmulatorDeviceAllocation');
+      uut = new EmulatorDeviceAllocation(deviceRegistry, freeDeviceFinder, emulatorLauncher, adb, eventEmitter, () => emulatorTelnet, 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 () => {
+      await uut.deallocateDevice(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.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);
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js
deleted file mode 100644
index 61c386760..000000000
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDeviceAllocator.test.js
+++ /dev/null
@@ -1,256 +0,0 @@
-describe('Genymotion-Cloud device allocator', () => {
-  const recipeUUID = 'mock-recipe-uuid';
-
-  let logger;
-  let retry;
-  let deviceRegistry;
-  let deviceCleanupRegistry;
-  let instanceLookupService;
-  let instanceLifecycleService;
-  let GenyInstance;
-  let uut;
-  beforeEach(() => {
-    jest.mock('../../../../utils/logger');
-    logger = require('../../../../utils/logger');
-
-    jest.mock('../../../../utils/retry');
-    retry = require('../../../../utils/retry');
-    retry.mockImplementation((options, func) => func());
-
-    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();
-
-    const InstanceLifecycleService = jest.genMockFromModule('./services/GenyInstanceLifecycleService');
-    instanceLifecycleService = new InstanceLifecycleService();
-
-    GenyInstance = jest.genMockFromModule('./services/dto/GenyInstance');
-
-    const DeviceAllocator = require('./GenyCloudDeviceAllocator');
-    uut = new DeviceAllocator(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService);
-  });
-
-  const aRecipe = () => ({
-    uuid: recipeUUID,
-    toString: () => 'mock-recipe-toString()',
-  });
-
-  const anOfflineInstance = () => {
-    const instance = new GenyInstance();
-    instance.uuid = 'mock-instance-uuid';
-    instance.isAdbConnected.mockReturnValue(false);
-    instance.isOnline.mockReturnValue(false);
-    instance.adbName = '0.0.0.0';
-    return instance;
-  };
-
-  const anOnlineInstance = () => {
-    const instance = anOfflineInstance();
-    instance.isOnline.mockReturnValue(true);
-    return instance;
-  };
-
-  const aFullyConnectedInstance = () => {
-    const instance = anOnlineInstance();
-    instance.isAdbConnected.mockReturnValue(true);
-    instance.adbName = 'localhost:1234';
-    return instance;
-  };
-
-  const aDisconnectedInstance = () => {
-    const instance = anOnlineInstance();
-    instance.isAdbConnected.mockReturnValue(false);
-    return instance;
-  };
-
-  const givenFreeInstance = (instance) => instanceLookupService.findFreeInstance.mockResolvedValueOnce(instance)
-  const givenNoFreeInstances = () => instanceLookupService.findFreeInstance.mockResolvedValue(undefined);
-  const givenCreatedInstance = (instance) => instanceLifecycleService.createInstance.mockResolvedValueOnce(instance);
-  const givenConnectionInstance = (instance) => instanceLifecycleService.adbConnectInstance.mockResolvedValue(instance);
-  const givenRefreshedInstance = (instance) => instanceLookupService.getInstance.mockReturnValueOnce(instance);
-
-  it('should return a free device', async () => {
-    const connectedInstance = aFullyConnectedInstance();
-    givenFreeInstance(connectedInstance);
-    givenConnectionInstance(connectedInstance);
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.instance).toEqual(connectedInstance);
-    expect(instanceLookupService.findFreeInstance).toHaveBeenCalledWith();
-  });
-
-  it('should adb-connect device if disconnected', async () => {
-    const connectedInstance = aFullyConnectedInstance();
-    const disconnectedInstance = aDisconnectedInstance();
-    givenFreeInstance(disconnectedInstance);
-    givenConnectionInstance(connectedInstance);
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.instance).toEqual(connectedInstance);
-
-    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
-  });
-
-  it('should not connect a connected device', async () => {
-    const connectedInstance = aFullyConnectedInstance();
-    givenFreeInstance(connectedInstance);
-
-    await uut.allocateDevice(aRecipe());
-
-    expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
-  });
-
-  it('should not connect inside locking callback (!!!)', async () => {
-    const connectedInstance = aFullyConnectedInstance();
-    const disconnectedInstance = aDisconnectedInstance();
-    givenFreeInstance(disconnectedInstance);
-    givenConnectionInstance(connectedInstance);
-
-    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
-      const result = await func();
-      expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
-      return result;
-    });
-
-    await uut.allocateDevice(aRecipe());
-  });
-
-  it('should create a device if no free one is available', async () => {
-    const instance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.instance).toEqual(instance);
-    expect(instanceLifecycleService.createInstance).toHaveBeenCalledWith(recipeUUID);
-  });
-
-  it('should wait for a created device to become online', async () => {
-    const instance = anOfflineInstance();
-    const onlineInstance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-    givenRefreshedInstance(onlineInstance);
-    givenConnectionInstance(onlineInstance);
-
-    retry.mockImplementationOnce(async (options, func) => {
-      const instance = await func();
-      expect(instanceLookupService.getInstance).toHaveBeenCalledWith(instance.uuid);
-      return instance;
-    });
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.instance).toEqual(onlineInstance);
-    expect(retry).toHaveBeenCalled();
-  });
-
-  it('should fail if device never becomes online', async () => {
-    const instance = anOfflineInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-    givenRefreshedInstance(instance);
-
-    try {
-      await uut.allocateDevice(aRecipe());
-      fail('Expected an error');
-    } catch (e) {
-      expect(e.toString()).toContain(`Timeout waiting for instance ${instance.uuid} to be ready`);
-    }
-  });
-
-  it('should not wait for created device to become online if already online', async () => {
-    const instance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-    givenRefreshedInstance(instance);
-
-    await uut.allocateDevice(aRecipe());
-    expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
-    expect(retry).not.toHaveBeenCalled();
-  });
-
-  it('should not wait for device to become online, inside locking callback (!!!)', async () => {
-    const instance = anOfflineInstance();
-    const onlineInstance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-    givenRefreshedInstance(onlineInstance);
-
-    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
-      const result = await func();
-      expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
-      return result;
-    });
-
-    await uut.allocateDevice(aRecipe());
-  });
-
-  it('should return isNew=true if new device was allocated', async () => {
-    const instance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.isNew).toEqual(true);
-  });
-
-  it('should return isNew=false if a device was reused', async () => {
-    const instance = aFullyConnectedInstance();
-    givenFreeInstance(instance);
-
-    const result = await uut.allocateDevice(aRecipe());
-    expect(result.isNew).toEqual(false);
-  });
-
-  it('should allocate device based on the instance\'s UUID', async () => {
-    const instance = aFullyConnectedInstance();
-    givenFreeInstance(instance);
-
-    deviceRegistry.allocateDevice.mockImplementation(async (func) => {
-      const result = await func();
-      expect(result).toEqual(instance.uuid);
-      return result;
-    });
-
-    await uut.allocateDevice(aRecipe());
-    expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
-  });
-
-  it('should register a new device for cleanup', async () => {
-    const instance = aFullyConnectedInstance();
-    givenNoFreeInstances();
-    givenCreatedInstance(instance);
-
-    await uut.allocateDevice(aRecipe());
-    expect(deviceCleanupRegistry.allocateDevice).toHaveBeenCalledWith({
-      uuid: instance.uuid,
-      name: instance.name,
-    });
-  });
-
-  it('should not register an existing device for cleanup', async () => {
-    const instance = aFullyConnectedInstance();
-    givenFreeInstance(instance);
-
-    await uut.allocateDevice(aRecipe());
-    expect(deviceCleanupRegistry.allocateDevice).not.toHaveBeenCalled();
-  });
-
-  it('should log pre-allocate event', async () => {
-    givenFreeInstance(aFullyConnectedInstance());
-    await uut.allocateDevice(aRecipe());
-    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('Trying to allocate'));
-    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('mock-recipe-toString()'));
-  });
-
-  it('should log post-allocate event', async () => {
-    const instance = aFullyConnectedInstance();
-    givenFreeInstance(instance);
-    await uut.allocateDevice(aRecipe());
-    expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining(`Settled on GenyCloud:${instance.name} (${instance.uuid})`));
-  });
-});
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
index bde74f777..ef289b8f2 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
@@ -14,6 +14,7 @@ const anInstance = () => ({
   uuid: 'mock-instance-uuid',
   name: 'mock-instance-name',
   adbName: 'mock-instance-adb-name',
+  toString: () => 'mock-instance-toString()',
 });
 
 const aDeviceQuery = () => ({
@@ -42,7 +43,7 @@ describe('Genymotion-cloud driver', () => {
   let deviceQueryHelper;
   let deviceRegistry;
   let deviceCleanupRegistry;
-  let deviceAllocator;
+  let instanceAllocation;
   let authServiceObj;
   beforeEach(() => {
     signalExit = require('signal-exit');
@@ -77,9 +78,9 @@ describe('Genymotion-cloud driver', () => {
     const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
     deviceQueryHelper = () => latestInstanceOf(DeviceQueryHelper);
 
-    jest.mock('./GenyCloudDeviceAllocator');
-    const DeviceAllocator = require('./GenyCloudDeviceAllocator');
-    deviceAllocator = () => latestInstanceOf(DeviceAllocator);
+    jest.mock('./GenyCloudInstanceAllocation');
+    const InstanceAllocation = require('./GenyCloudInstanceAllocation');
+    instanceAllocation = () => latestInstanceOf(InstanceAllocation);
 
     const AuthService = require('./services/GenyAuthService');
     authServiceObj = () => latestInstanceOf(AuthService);
@@ -106,7 +107,7 @@ describe('Genymotion-cloud driver', () => {
     });
 
     it('should return a generic name at pre-init', () => {
-      expect(uut.name).toEqual('Unspecified Genymotion Cloud Emulator');
+      expect(uut.name).toEqual('Unspecified GMSaaS Emulator');
     });
 
     it('should initialize the common executable using the path set by the environment', async () => {
@@ -182,13 +183,13 @@ describe('Genymotion-cloud driver', () => {
     describe('device (instance) allocation', () => {
       const givenNoRecipes = () => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(null);
       const givenResolvedRecipeForQuery = (recipe) => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(recipe);
-      const givenDeviceAllocationResult = ({ instance, isNew = false }) => deviceAllocator().allocateDevice.mockResolvedValue({ instance, isNew });
+      const givenDeviceAllocationResult = (instance) => instanceAllocation().allocateDevice.mockResolvedValue(instance);
 
       it('should get a recipe and allocate a device', async () => {
         const recipe = aRecipe();
         const instance = anInstance();
         givenResolvedRecipeForQuery(recipe);
-        givenDeviceAllocationResult({ instance });
+        givenDeviceAllocationResult(instance);
 
         const deviceQuery = aDeviceQuery();
         const result = await uut.acquireFreeDevice(deviceQuery);
@@ -198,13 +199,13 @@ describe('Genymotion-cloud driver', () => {
           uuid: instance.uuid,
         }));
         expect(deviceQueryHelper().getRecipeFromQuery).toHaveBeenCalledWith(deviceQuery);
-        expect(deviceAllocator().allocateDevice).toHaveBeenCalledWith(recipe);
+        expect(instanceAllocation().allocateDevice).toHaveBeenCalledWith(recipe);
       });
 
       it('should throw a descriptive error recipe not found', async () => {
         const deviceQuery = aDeviceQuery();
         givenNoRecipes();
-        givenDeviceAllocationResult({ instance: anInstance() });
+        givenDeviceAllocationResult(anInstance());
 
         try {
           await uut.acquireFreeDevice(deviceQuery);
@@ -215,58 +216,20 @@ describe('Genymotion-cloud driver', () => {
         }
       });
 
-      it('should emit a bootDevice event', async () => {
-        const recipe = aRecipe();
-        const instance = anInstance();
-        givenResolvedRecipeForQuery(recipe);
-        givenDeviceAllocationResult({ instance });
-
-        await uut.acquireFreeDevice(aDeviceQuery());
-
-        expect(emitter.emit).toHaveBeenCalledWith('bootDevice', { coldBoot: false, deviceId: instance.adbName, type: recipe.name });
-      });
-
-      it('should fail if even emission fails', async () => {
-        const recipe = aRecipe();
-        const instance = anInstance();
-        givenResolvedRecipeForQuery(recipe);
-        givenDeviceAllocationResult({ instance });
-
-        const error = new Error('mocked error');
-        try {
-          emitter.emit.mockRejectedValue(error);
-          await uut.acquireFreeDevice(aDeviceQuery());
-          fail('Expected an error');
-        } catch (e) {
-          expect(e).toEqual(error);
-        }
-      });
-
-      it('should emit coldBoot=true in bootDevice event', async () => {
-        const recipe = aRecipe();
-        const instance = anInstance();
-        givenResolvedRecipeForQuery(recipe);
-        givenDeviceAllocationResult({ instance, isNew: true });
-
-        await uut.acquireFreeDevice(aDeviceQuery());
-
-        expect(emitter.emit).toHaveBeenCalledWith('bootDevice', { coldBoot: true, deviceId: instance.adbName, type: recipe.name });
-      });
-
       it('should return a descriptive device name in post-allocation state', async () => {
         const instance = anInstance();
         givenResolvedRecipeForQuery(aRecipe());
-        givenDeviceAllocationResult({ instance });
+        givenDeviceAllocationResult(instance);
 
         await uut.acquireFreeDevice(aDeviceQuery());
 
-        expect(uut.name).toEqual(`GenyCloud:${instance.name} (${instance.uuid} ${instance.adbName})`);
+        expect(uut.name).toEqual('mock-instance-toString()');
       });
 
       it('should init ADB\'s API-level', async () => {
         const instance = anInstance();
         givenResolvedRecipeForQuery(aRecipe());
-        givenDeviceAllocationResult({ instance });
+        givenDeviceAllocationResult(instance);
 
         await uut.acquireFreeDevice(aDeviceQuery());
 
@@ -276,7 +239,7 @@ describe('Genymotion-cloud driver', () => {
       it('should disable native animations', async () => {
         const instance = anInstance();
         givenResolvedRecipeForQuery(aRecipe());
-        givenDeviceAllocationResult({ instance });
+        givenDeviceAllocationResult(instance);
 
         await uut.acquireFreeDevice(aDeviceQuery());
 
@@ -326,52 +289,12 @@ describe('Genymotion-cloud driver', () => {
     });
 
     describe('shutdown', () => {
-      const givenAnInstanceDeletionError = () => instanceLifecycleService.deleteInstance.mockRejectedValue(new Error());
-
-      it('should delete the instance', async () => {
-        const instance = anInstance();
-        await uut.shutdown(instance);
-        expect(instanceLifecycleService.deleteInstance).toHaveBeenCalledWith(instance.uuid);
-      });
-
-      it('should throw if deletion fails', async () => {
-        givenAnInstanceDeletionError();
-
-        try {
-          await uut.shutdown(anInstance());
-          fail('Expected an error');
-        } catch (e) {}
-      });
-
-      it('should emit associated events', async () => {
-        const instance = anInstance();
-        await uut.shutdown(instance);
-        expect(emitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', { deviceId: instance.adbName });
-        expect(emitter.emit).toHaveBeenCalledWith('shutdownDevice', { deviceId: instance.adbName });
-      });
-
-      it('should not emit shutdown even in case of failure', async () => {
-        givenAnInstanceDeletionError();
-
-        try {
-          await uut.shutdown(anInstance());
-          fail('Expected an error');
-        } catch (e) {
-          expect(emitter.emit).toHaveBeenCalledWith('beforeShutdownDevice', expect.anything());
-          expect(emitter.emit).not.toHaveBeenCalledWith('shutdownDevice', expect.anything());
-        }
-      });
-
-      it('should remove instance from device-registry', async () => {
+      it('should deallocate the instance', async () => {
         const instance = anInstance();
         await uut.shutdown(instance);
-        expect(deviceCleanupRegistry.disposeDevice).toHaveBeenCalledWith({
-          name: instance.name,
-          uuid: instance.uuid,
-        });
+        expect(instanceAllocation().deallocateDevice).toHaveBeenCalledWith(instance);
       });
     });
-
   });
 
   describe('class scope', () => {
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.test.js
new file mode 100644
index 000000000..cd3693a1e
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudInstanceAllocation.test.js
@@ -0,0 +1,351 @@
+describe('Genymotion-Cloud instance allocation', () => {
+  const recipeUUID = 'mock-recipe-uuid';
+  const recipeName = 'mock-recipe-name';
+
+  let logger;
+  let retry;
+  let eventEmitter;
+  let deviceRegistry;
+  let deviceCleanupRegistry;
+  let instanceLookupService;
+  let instanceLifecycleService;
+  let GenyInstance;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../utils/logger');
+    logger = require('../../../../utils/logger');
+
+    jest.mock('../../../../utils/retry');
+    retry = require('../../../../utils/retry');
+    retry.mockImplementation((options, func) => func());
+
+    const AsyncEmitter = jest.genMockFromModule('../../../../utils/AsyncEmitter');
+    eventEmitter = new AsyncEmitter();
+
+    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();
+
+    const InstanceLifecycleService = jest.genMockFromModule('./services/GenyInstanceLifecycleService');
+    instanceLifecycleService = new InstanceLifecycleService();
+
+    GenyInstance = jest.genMockFromModule('./services/dto/GenyInstance');
+
+    const InstanceAllocation = require('./GenyCloudInstanceAllocation');
+    uut = new InstanceAllocation(deviceRegistry, deviceCleanupRegistry, instanceLookupService, instanceLifecycleService, eventEmitter);
+  });
+
+  const aRecipe = () => ({
+    uuid: recipeUUID,
+    name: recipeName,
+    toString: () => 'mock-recipe-toString()',
+  });
+
+  const anOfflineInstance = () => {
+    const instance = new GenyInstance();
+    instance.uuid = 'mock-instance-uuid';
+    instance.name = 'mock-instance-name';
+    instance.isAdbConnected.mockReturnValue(false);
+    instance.isOnline.mockReturnValue(false);
+    instance.adbName = '0.0.0.0';
+    instance.toString = () => 'mock-instance-toString()';
+    return instance;
+  };
+
+  const anOnlineInstance = () => {
+    const instance = anOfflineInstance();
+    instance.isOnline.mockReturnValue(true);
+    return instance;
+  };
+
+  const aFullyConnectedInstance = () => {
+    const instance = anOnlineInstance();
+    instance.isAdbConnected.mockReturnValue(true);
+    instance.adbName = 'localhost:1234';
+    return instance;
+  };
+
+  const aDisconnectedInstance = () => {
+    const instance = anOnlineInstance();
+    instance.isAdbConnected.mockReturnValue(false);
+    return instance;
+  };
+
+  const givenFreeInstance = (instance) => instanceLookupService.findFreeInstance.mockResolvedValueOnce(instance)
+  const givenNoFreeInstances = () => instanceLookupService.findFreeInstance.mockResolvedValue(undefined);
+  const givenCreatedInstance = (instance) => instanceLifecycleService.createInstance.mockResolvedValueOnce(instance);
+  const givenConnectionInstance = (instance) => instanceLifecycleService.adbConnectInstance.mockResolvedValue(instance);
+  const givenRefreshedInstance = (instance) => instanceLookupService.getInstance.mockReturnValueOnce(instance);
+
+  const expectDeviceBootEvent = (instance, recipe, coldBoot) =>
+    expect(eventEmitter.emit).toHaveBeenCalledWith('bootDevice', {
+      coldBoot,
+      deviceId: instance.adbName,
+      type: recipe.name,
+    });
+
+  describe('allocation', () => {
+    it('should return a free instance', async () => {
+      const connectedInstance = aFullyConnectedInstance();
+      givenFreeInstance(connectedInstance);
+      givenConnectionInstance(connectedInstance);
+
+      const result = await uut.allocateDevice(aRecipe());
+      expect(result).toEqual(connectedInstance);
+      expect(instanceLookupService.findFreeInstance).toHaveBeenCalled();
+    });
+
+    it('should adb-connect to instance if disconnected', async () => {
+      const connectedInstance = aFullyConnectedInstance();
+      const disconnectedInstance = aDisconnectedInstance();
+      givenFreeInstance(disconnectedInstance);
+      givenConnectionInstance(connectedInstance);
+
+      const result = await uut.allocateDevice(aRecipe());
+      expect(result).toEqual(connectedInstance);
+
+      expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
+    });
+
+    it('should not connect a connected instance', async () => {
+      const connectedInstance = aFullyConnectedInstance();
+      givenFreeInstance(connectedInstance);
+
+      await uut.allocateDevice(aRecipe());
+
+      expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
+    });
+
+    it('should not connect inside locking callback (!!!)', async () => {
+      const connectedInstance = aFullyConnectedInstance();
+      const disconnectedInstance = aDisconnectedInstance();
+      givenFreeInstance(disconnectedInstance);
+      givenConnectionInstance(connectedInstance);
+
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        const result = await func();
+        expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
+        return result;
+      });
+
+      await uut.allocateDevice(aRecipe());
+    });
+
+    it('should create an instance if no free one is available', async () => {
+      const instance = aFullyConnectedInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+
+      const result = await uut.allocateDevice(aRecipe());
+      expect(result).toEqual(instance);
+      expect(instanceLifecycleService.createInstance).toHaveBeenCalledWith(recipeUUID);
+    });
+
+    it('should wait for a created instance to become online', async () => {
+      const instance = anOfflineInstance();
+      const onlineInstance = aFullyConnectedInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+      givenRefreshedInstance(onlineInstance);
+      givenConnectionInstance(onlineInstance);
+
+      retry.mockImplementationOnce(async (options, func) => {
+        const instance = await func();
+        expect(instanceLookupService.getInstance).toHaveBeenCalledWith(instance.uuid);
+        return instance;
+      });
+
+      const result = await uut.allocateDevice(aRecipe());
+      expect(result).toEqual(onlineInstance);
+      expect(retry).toHaveBeenCalled();
+    });
+
+    it('should fail if instance never becomes online', async () => {
+      const instance = anOfflineInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+      givenRefreshedInstance(instance);
+
+      try {
+        await uut.allocateDevice(aRecipe());
+        fail('Expected an error');
+      } catch (e) {
+        expect(e.toString()).toContain(`Timeout waiting for instance ${instance.uuid} to be ready`);
+      }
+    });
+
+    it('should not wait for created instance to become online if already online', async () => {
+      const instance = aFullyConnectedInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+      givenRefreshedInstance(instance);
+
+      await uut.allocateDevice(aRecipe());
+      expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
+      expect(retry).not.toHaveBeenCalled();
+    });
+
+    it('should not wait for instance to become online, inside locking callback (!!!)', async () => {
+      const instance = anOfflineInstance();
+      const onlineInstance = aFullyConnectedInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+      givenRefreshedInstance(onlineInstance);
+
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        const result = await func();
+        expect(instanceLookupService.getInstance).not.toHaveBeenCalled();
+        return result;
+      });
+
+      await uut.allocateDevice(aRecipe());
+    });
+
+    it('should allocate instance based on the instance\'s UUID', async () => {
+      const instance = aFullyConnectedInstance();
+      givenFreeInstance(instance);
+
+      deviceRegistry.allocateDevice.mockImplementation(async (func) => {
+        const result = await func();
+        expect(result).toEqual(instance.uuid);
+        return result;
+      });
+
+      await uut.allocateDevice(aRecipe());
+      expect(deviceRegistry.allocateDevice).toHaveBeenCalled();
+    });
+
+    it('should register a new instance for cleanup', async () => {
+      const instance = aFullyConnectedInstance();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+
+      await uut.allocateDevice(aRecipe());
+      expect(deviceCleanupRegistry.allocateDevice).toHaveBeenCalledWith({
+        uuid: instance.uuid,
+        name: instance.name,
+      });
+    });
+
+    it('should not register an existing instance for cleanup', async () => {
+      const instance = aFullyConnectedInstance();
+      givenFreeInstance(instance);
+
+      await uut.allocateDevice(aRecipe());
+      expect(deviceCleanupRegistry.allocateDevice).not.toHaveBeenCalled();
+    });
+
+    it('should log pre-allocate message', async () => {
+      givenFreeInstance(aFullyConnectedInstance());
+      await uut.allocateDevice(aRecipe());
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('Trying to allocate'));
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining('mock-recipe-toString()'));
+    });
+
+    it('should log post-allocate message', async () => {
+      givenFreeInstance(aFullyConnectedInstance());
+      await uut.allocateDevice(aRecipe());
+      expect(logger.debug).toHaveBeenCalledWith({ event: 'ALLOCATE_DEVICE' }, expect.stringContaining(`Settled on mock-instance-toString()`));
+      expect(logger.debug).toHaveBeenCalledTimes(2);
+    });
+
+    it('should emit a boot-device event for an existing instance', async () => {
+      const instance = aFullyConnectedInstance();
+      const recipe = aRecipe();
+      givenFreeInstance(instance);
+
+      await uut.allocateDevice(recipe);
+
+      expectDeviceBootEvent(instance, recipe, false);
+    });
+
+    it('should emit a boot-device event for a new instance', async () => {
+      const instance = aFullyConnectedInstance();
+      const recipe = aRecipe();
+      givenNoFreeInstances();
+      givenCreatedInstance(instance);
+
+      await uut.allocateDevice(recipe);
+
+      expectDeviceBootEvent(instance, recipe, true);
+    });
+
+    it('should fail if event emission fails', async () => {
+      const instance = aFullyConnectedInstance();
+      givenFreeInstance(instance);
+
+      eventEmitter.emit.mockRejectedValue(new Error());
+
+      try {
+        await uut.allocateDevice(aRecipe);
+        fail('Expected an error');
+      } catch(e) {}
+    });
+
+    it('should emit boot-device event *after* post-allocate log', async () => {
+      const instance = aFullyConnectedInstance();
+      givenFreeInstance(instance);
+
+      eventEmitter.emit.mockRejectedValue(new Error());
+
+      try {
+        await uut.allocateDevice(aRecipe);
+      } catch(e) {
+        expect(logger.debug).toHaveBeenCalledTimes(2);
+      }
+    });
+  });
+
+  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 not emit shutdownDevice prematurely', async () => {
+      givenAnInstanceDeletionError();
+
+      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));
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
index 137ed42c4..b05fb138d 100644
--- a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
@@ -1,35 +1,41 @@
 describe('Genymotion-Cloud Instance DTO', () => {
-  const connectedRawInstance = {
+  const rawInstance = {
     uuid: 'mock-uuid',
     name: 'mock-name',
-    state: 'ONLINE',
-    adb_serial: 'localhost:7777',
-    adb_serial_port: 7777,
+    state: undefined,
     recipe: {
       uuid: 'mock-recipe-uuid',
       name: 'mock-recipe-name',
     },
-  };
+  }
 
   const disconnectedRawInstance = {
-    ...connectedRawInstance,
+    ...rawInstance,
+    state: 'ONLINE',
     adb_serial: '0.0.0.0',
     adb_serial_port: 0,
   };
 
-  const onlineInstance = disconnectedRawInstance;
+  const connectedRawInstance = {
+    ...rawInstance,
+    state: 'ONLINE',
+    adb_serial: 'localhost:7777',
+    adb_serial_port: 7777,
+  };
+
   const bootingInstance = {
-    ...onlineInstance,
+    ...disconnectedRawInstance,
     state: 'BOOTING',
   };
   const startingInstance = {
-    ...onlineInstance,
+    ...disconnectedRawInstance,
     state: 'STARTING',
   };
   const creatingInstance = {
-    ...onlineInstance,
+    ...disconnectedRawInstance,
     state: 'CREATING',
   };
+  const onlineInstance = disconnectedRawInstance;
 
   let Instance;
   beforeEach(() => {
@@ -88,4 +94,9 @@ describe('Genymotion-Cloud Instance DTO', () => {
     const instance = new Instance(startingInstance);
     expect(instance.isInitializing()).toEqual(true);
   });
+
+  it('should override toString()', () => {
+    const instance = new Instance(connectedRawInstance);
+    expect(`${instance}`).toEqual(`GenyCloud:${instance.name} (${instance.uuid} ${instance.adbName})`);
+  });
 });