Repository: wix/Detox
Pull request #2446 (2020-11-02 13:54:33)

Introduce the fundamentals of Genymotion-cloud support
- [ ] This is a small change 
- [ ] This change has been discussed in issue #2429 and the solution has been agreed upon with maintainers.

---

**Description:**
Introduce the fundamental support for Genymotion cloud emulators, enabling e2e execution over cloud device instances.

**There are still many loose ends to close here, and merging this does not in any way mean Genymotion-cloud is fully supported.**
To name a few:

- *Detox-based device instances teardown (a big todo).*
- An optimization of instance creation is required (i.e. usage of the `--no-wait` flag), with boot-up polling.
- Better tweaking of Detox' CI script, so as to support exec on both local and cloud emulators in each build.
- Fix broken `ci-unhappy` e2e flavor

**How to review these changes**
Start from the new, top-level `GenyCloudDriver` class, and drill down through its self-generated delegation -- mainly the 3 `service`-layer classes that allow for recipe querying, instance creation, deletion and lookup. In turn, they have delegations of their own (injected), such as a wrapper over the `gmsaas` executable, and an instance naming concern provider. Follow through to drill further down the chain to review these classes as well, last.

Be advised that per each such class, there's a unit test suite that's been written TDD with full coverage over the equivalent production code.

d4vidi: Guys sorry if this is overwhelming, I couldn't break it down any further than this. In fact, I want to continue with applying further optimizations over this, as explained. Please find the time to at least skim through.

Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
index 422bb2c3f..a39ee2f88 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java
@@ -8,7 +8,6 @@ import android.util.Log;
 
 import com.wix.detox.instruments.DetoxInstrumentsManager;
 import com.wix.detox.reactnative.ReactNativeExtension;
-import com.wix.detox.systeminfo.Environment;
 import com.wix.invoke.MethodInvocation;
 
 import java.util.HashMap;
@@ -56,9 +55,6 @@ class DetoxManager implements WebSocketClient.ActionHandler {
 
         Bundle arguments = InstrumentationRegistry.getArguments();
         detoxServerUrl = arguments.getString(DETOX_SERVER_ARG_KEY);
-        if (detoxServerUrl != null) {
-            detoxServerUrl = detoxServerUrl.replace(Environment.DEVICE_LOCALHOST, Environment.getServerHost());
-        }
         detoxSessionId = arguments.getString(DETOX_SESSION_ID_ARG_KEY);
 
         if (detoxServerUrl == null || detoxSessionId == null) {
diff --git a/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java b/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java
index a47705254..5034e9a3f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java
@@ -2,8 +2,6 @@ package com.wix.detox;
 
 import android.util.Log;
 
-import com.wix.detox.systeminfo.Environment;
-
 import org.json.JSONException;
 import org.json.JSONObject;
 
@@ -43,10 +41,6 @@ public class WebSocketClient {
         this.actionHandler = actionHandler;
     }
 
-    public void connectToServer(String sessionId) {
-        connectToServer(Environment.getServerHost(), sessionId);
-    }
-
     public void connectToServer(String url, String sessionId) {
         Log.i(LOG_TAG, "At connectToServer");
 
diff --git a/detox/android/detox/src/main/java/com/wix/detox/systeminfo/Environment.java b/detox/android/detox/src/main/java/com/wix/detox/systeminfo/Environment.java
deleted file mode 100644
index b7dc35a23..000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/systeminfo/Environment.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.wix.detox.systeminfo;
-
-import android.os.Build;
-
-public class Environment {
-    public static final String EMULATOR_LOCALHOST = "ws://10.0.2.2";
-    public static final String GENYMOTION_LOCALHOST = "ws://10.0.3.2";
-    public static final String DEVICE_LOCALHOST = "ws://localhost";
-
-    private static boolean isRunningOnGenymotion() {
-        return Build.FINGERPRINT.contains("vbox")
-                || Build.MANUFACTURER.contains("Genymotion");
-    }
-
-    private static boolean isRunningOnStockEmulator() {
-        return Build.FINGERPRINT.startsWith("generic")
-                || Build.FINGERPRINT.startsWith("unknown")
-                || Build.MODEL.contains("google_sdk")
-                || Build.MODEL.contains("Emulator")
-                || Build.MODEL.contains("Android SDK built for x86")
-                || (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
-                || "google_sdk".equals(Build.PRODUCT);
-    }
-
-    public static String getServerHost() {
-        // Since genymotion runs in vbox it use different hostname to refer to adb host.
-        // We detect whether app runs on genymotion and replace js bundle server hostname accordingly
-
-        if (isRunningOnGenymotion()) {
-            return GENYMOTION_LOCALHOST;
-        }
-
-        if (isRunningOnStockEmulator()) {
-            return EMULATOR_LOCALHOST;
-        }
-
-        return DEVICE_LOCALHOST;
-    }
-}
diff --git a/detox/ios/Detox/Actions/UIView+DetoxActions.m b/detox/ios/Detox/Actions/UIView+DetoxActions.m
index 58f339432..f88b389b5 100644
--- a/detox/ios/Detox/Actions/UIView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIView+DetoxActions.m
@@ -251,7 +251,6 @@ static UIView* _ensureFirstResponderIfNeeded(UIView* view)
 	}
 	
 	UIView* firstResponder = _isViewOrDescendantFirstResponder(view);
-	
 	if(firstResponder != nil)
 	{
 		return firstResponder;
@@ -400,26 +399,26 @@ static void _DTXTypeText(NSString* text)
 	UIView<UITextInput>* firstResponder = (id)_ensureFirstResponderIfNeeded(self);
 	_assertFirstResponderSupportsTextInput(firstResponder);
 	
-	BOOL isControl = [self isKindOfClass:UIControl.class];
-	BOOL isTextField = [self isKindOfClass:UITextField.class];
-	BOOL isTextView = [self isKindOfClass:UITextView.class];
-	UITextView* textView = (UITextView*)self;
+	BOOL isControl = [firstResponder isKindOfClass:UIControl.class];
+	BOOL isTextField = [firstResponder isKindOfClass:UITextField.class];
+	BOOL isTextView = [firstResponder isKindOfClass:UITextView.class];
+	UITextView* textView = (UITextView*)firstResponder;
 	
 	if(isControl == YES)
 	{
-		[(UIControl*)self sendActionsForControlEvents:UIControlEventEditingDidBegin];
+		[(UIControl*)firstResponder sendActionsForControlEvents:UIControlEventEditingDidBegin];
 	}
 	
 	if(isTextField == YES)
 	{
-		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidBeginEditingNotification object:self];
+		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidBeginEditingNotification object:firstResponder];
 	}
 	
 	if(isTextView == YES)
 	{
 		if([textView.delegate respondsToSelector:@selector(textViewDidBeginEditing:)])
 		{
-			[textView.delegate textViewDidBeginEditing:(id)self];
+			[textView.delegate textViewDidBeginEditing:textView];
 		}
 	}
 	
@@ -428,29 +427,29 @@ static void _DTXTypeText(NSString* text)
 		
 	UITextRange* range = [firstResponder textRangeFromPosition:beginningOfDocument toPosition:endOfDocument];
 	
-	[(id<UITextInput>)self replaceRange:range withText:text];
+	[firstResponder replaceRange:range withText:text];
 	
 	if(isControl == YES)
 	{
-		[(UIControl*)self sendActionsForControlEvents:UIControlEventEditingChanged];
-		[(UIControl*)self sendActionsForControlEvents:UIControlEventEditingDidEnd];
+		[(UIControl*)firstResponder sendActionsForControlEvents:UIControlEventEditingChanged];
+		[(UIControl*)firstResponder sendActionsForControlEvents:UIControlEventEditingDidEnd];
 	}
 	
 	if(isTextField == YES)
 	{
-		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidChangeNotification object:self];
-		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidEndEditingNotification object:self];
+		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidChangeNotification object:firstResponder];
+		[NSNotificationCenter.defaultCenter postNotificationName:UITextFieldTextDidEndEditingNotification object:firstResponder];
 	}
 	
 	if(isTextView == YES)
 	{
 		if([textView.delegate respondsToSelector:@selector(textViewDidChange:)])
 		{
-			[textView.delegate textViewDidChange:(id)self];
+			[textView.delegate textViewDidChange:textView];
 		}
 		if([textView.delegate respondsToSelector:@selector(textViewDidEndEditing:)])
 		{
-			[textView.delegate textViewDidEndEditing:(id)self];
+			[textView.delegate textViewDidEndEditing:textView];
 		}
 	}
 }
diff --git a/detox/ios/EarlGrey b/detox/ios/EarlGrey
index d4a69a00a..410ac0645 160000
--- a/detox/ios/EarlGrey
+++ b/detox/ios/EarlGrey
@@ -1,1 +1,1 @@
-Subproject commit d4a69a00a7e0b8ba43bdc1e80e79ec709da4bd11
+Subproject commit 410ac0645cc87b6bc4b56281cb7721eaaec68dc8
diff --git a/detox/package.json b/detox/package.json
index 2fc51bd37..d6d36b7f5 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "bin": {
     "detox": "local-cli/cli.js"
   },
diff --git a/detox/src/devices/DriverRegistry.js b/detox/src/devices/DriverRegistry.js
index 0c7dcfc58..21e544a14 100644
--- a/detox/src/devices/DriverRegistry.js
+++ b/detox/src/devices/DriverRegistry.js
@@ -29,6 +29,7 @@ DriverRegistry.default = new DriverRegistry({
   'ios.simulator': require('./drivers/ios/SimulatorDriver'),
   'android.emulator': require('./drivers/android/emulator/EmulatorDriver'),
   'android.attached': require('./drivers/android/attached/AttachedAndroidDriver'),
+  'android.genycloud': require('./drivers/android/genycloud/GenyCloudDriver'),
 });
 
 module.exports = DriverRegistry;
diff --git a/detox/src/devices/drivers/android/exec/EmulatorExec.js b/detox/src/devices/drivers/android/exec/EmulatorExec.js
index 657f13eae..6db90f7cf 100644
--- a/detox/src/devices/drivers/android/exec/EmulatorExec.js
+++ b/detox/src/devices/drivers/android/exec/EmulatorExec.js
@@ -21,7 +21,7 @@ class ListAVDsCommand extends ExecCommand {
 
 class QueryVersionCommand extends ExecCommand {
   _getArgs() {
-    return ['-version'];
+    return ['-version', `${argparse.getArgValue('headless')}` === 'true' ? '-no-window' : ''];
   }
 }
 
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
new file mode 100644
index 000000000..93f3584bc
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
@@ -0,0 +1,66 @@
+const _ = require('lodash');
+const AndroidDriver = require('../AndroidDriver');
+const GenyCloudExec = require('./exec/GenyCloudExec');
+const RecipesService = require('./services/GenyRecipesService');
+const InstanceLookupService = require('./services/GenyInstanceLookupService');
+const InstanceLifecycleService = require('./services/GenyInstanceLifecycleService');
+const InstanceNaming = require('./services/GenyInstanceNaming');
+const AllocationHelper = require('./helpers/GenyAllocationHelper');
+const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
+const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
+const logger = require('../../../../utils/logger').child({ __filename });
+
+class GenyCloudDriver extends AndroidDriver {
+  constructor(config) {
+    super(config);
+    this._name = 'Unspecified Genymotion Cloud Emulator';
+
+    const exec = new GenyCloudExec();
+    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.recipeService = new RecipesService(exec, logger);
+    this.instanceLookupService = new InstanceLookupService(exec, instanceNaming, this.deviceRegistry);
+    this.instanceLifecycleService = new InstanceLifecycleService(exec, instanceNaming);
+    this.deviceQueryHelper = new DeviceQueryHelper(this.recipeService);
+    this.allocationHelper = new AllocationHelper(this.instanceLookupService, this.instanceLifecycleService);
+  }
+
+  get name() {
+    return this._name;
+  }
+
+  async acquireFreeDevice(deviceQuery) {
+    const recipe = await this.deviceQueryHelper.getRecipeFromQuery(deviceQuery);
+    this.assertRecipe(deviceQuery, recipe);
+
+    const cookie = { coldBoot: false };
+    const adbName = await this.allocateDevice({
+      recipe,
+      cookie,
+      toString: () => recipe.toString(),
+    });
+
+    await this.emitter.emit('bootDevice', { coldBoot: cookie.coldBoot, deviceId: adbName, type: recipe.name});
+    await this.adb.apiLevel(adbName);
+    await this.adb.disableAndroidAnimations(adbName);
+
+    this._name = `GenyCloud-${adbName}`;
+    return adbName;
+  }
+
+  async doAllocateDevice({ recipe, cookie }) {
+    const { instance, coldBooted } = await this.allocationHelper.allocateInstance(recipe.uuid);
+    cookie.coldBoot = coldBooted;
+    return instance.adbName;
+  }
+
+  assertRecipe(deviceQuery, recipe) {
+    if (!recipe) {
+      throw new DetoxRuntimeError({
+        message: 'No Genycloud devices found for recipe!',
+        hint: `Check that your Genycloud account has a template associated with your Detox device configuration: ${JSON.stringify(deviceQuery)}\n`,
+      });
+    }
+  }
+}
+
+module.exports = GenyCloudDriver;
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
new file mode 100644
index 000000000..0fbefbb5d
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
@@ -0,0 +1,45 @@
+const _ = require('lodash');
+const exec = require('../../../../../utils/exec').execWithRetriesAndLogs;
+
+class GenyCloudExec {
+  getRecipe(name) {
+    return this._exec(`recipes list --name "${name}"`);
+  }
+
+  getInstances() {
+    return this._exec(`instances list`);
+  }
+
+  startInstance(recipeUUID, instanceName) {
+    // TODO should we use --no-wait so as to release workers lock "mutex" asap and then wait-for-boot as we do with google emulators?
+    return this._exec(`instances start --stop-when-inactive ${recipeUUID} "${instanceName}"`);
+  }
+
+  adbConnect(instanceUUID) {
+    return this._exec(`instances adbconnect ${instanceUUID}`);
+  }
+
+  stopInstance(instanceUUID) {
+    return this._exec(`instances stop ${instanceUUID}`);
+  }
+
+  async _exec(args) {
+    try {
+      const rawResult = await this.__exec(args);
+      return JSON.parse(rawResult);
+    } catch (error) {
+      throw JSON.parse(error.stderr);
+    }
+  }
+
+  async __exec(args) {
+    const options = {
+      statusLogs: {
+        retrying: true,
+      },
+    };
+    return (await exec(`"gmsaas" --format compactjson ${args}`, options )).stdout;
+  }
+}
+
+module.exports = GenyCloudExec;
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js
new file mode 100644
index 000000000..89fa21370
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.js
@@ -0,0 +1,28 @@
+class GenyAllocationHelper {
+  constructor(instanceLookupService, instanceLifecycleService) {
+    this.instanceLookupService = instanceLookupService;
+    this.instanceLifecycleService = instanceLifecycleService;
+    this.coldBooted = undefined;
+  }
+
+  async allocateInstance(recipeUUID) {
+    let coldBooted = false;
+
+    let instance = await this.instanceLookupService.findFreeInstance(recipeUUID);
+    if (!instance) {
+      instance = await this.instanceLifecycleService.createInstance(recipeUUID);
+      coldBooted = true;
+    }
+
+    if (!instance.isAdbConnected()) {
+      instance = await this.instanceLifecycleService.adbConnectInstance(instance.uuid);
+    }
+
+    return {
+      instance,
+      coldBooted,
+    };
+  }
+}
+
+module.exports = GenyAllocationHelper;
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.js
new file mode 100644
index 000000000..9272ccf63
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.js
@@ -0,0 +1,17 @@
+const _ = require('lodash');
+
+class GenyDeviceQueryHelper {
+  constructor(recipesService) {
+    this.recipesService = recipesService;
+  }
+
+  async getRecipeFromQuery(deviceQuery) {
+    const deviceObj = _.isPlainObject(deviceQuery) ? deviceQuery : { recipeName: deviceQuery }
+    if (deviceObj.recipeUUID) {
+      return this.recipesService.getRecipeByUUID(deviceObj.recipeUUID);
+    }
+    return this.recipesService.getRecipeByName(deviceObj.recipeName);
+  }
+}
+
+module.exports = GenyDeviceQueryHelper;
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.js
new file mode 100644
index 000000000..875ffc3ae
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.js
@@ -0,0 +1,25 @@
+const Instance = require('./dto/GenyInstance');
+
+class GenyInstanceLifecycleService {
+  constructor(genyCloudExec, instanceNaming) {
+    this.genyCloudExec = genyCloudExec;
+    this.instanceNaming = instanceNaming;
+  }
+
+  async createInstance(recipeUUID) {
+    const result = await this.genyCloudExec.startInstance(recipeUUID, this.instanceNaming.generateName());
+    return new Instance(result.instance);
+  }
+
+  async adbConnectInstance(instanceUUID) {
+    const result = (await this.genyCloudExec.adbConnect(instanceUUID));
+    return new Instance(result.instance);
+  }
+
+  async deleteInstance(instanceUUID) {
+    const result = await this.genyCloudExec.stopInstance(instanceUUID);
+    return new Instance(result.instance);
+  }
+}
+
+module.exports = GenyInstanceLifecycleService;
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js
new file mode 100644
index 000000000..95c1960ee
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.js
@@ -0,0 +1,40 @@
+const Instance = require('./dto/GenyInstance');
+
+class GenyInstanceLookupService {
+  constructor(genyCloudExec, instanceNaming, deviceRegistry) {
+    this.genyCloudExec = genyCloudExec;
+    this.instanceNaming = instanceNaming;
+    this.deviceRegistry = deviceRegistry;
+  }
+
+  async findFreeInstance(recipeUUID) {
+    const freeInstances = await this._getRelevantInstances(recipeUUID);
+    return (freeInstances[0] || null);
+  }
+
+  async _getRelevantInstances(recipeUUID) {
+    const isRelevant = (instance) =>
+      instance.recipeUUID === recipeUUID &&
+      !instance.isTerminated() &&
+      this.instanceNaming.isFamilial(instance.name) &&
+      this._isInstanceFree(instance);
+
+    const result = await this._getAllInstances();
+    return result.filter(isRelevant);
+  }
+
+  async _getAllInstances() {
+    return (await this.genyCloudExec.getInstances())
+      .instances
+      .map((rawInstance) => new Instance(rawInstance));
+  }
+
+  _isInstanceFree(instance) {
+    if (!instance.isAdbConnected()) {
+      return instance;
+    }
+    return !this.deviceRegistry.isDeviceBusy(instance.adbName);
+  }
+}
+
+module.exports = GenyInstanceLookupService;
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.js
new file mode 100644
index 000000000..2d7c05b4d
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.js
@@ -0,0 +1,17 @@
+class GenyInstanceNaming {
+  constructor(nowProvider = () => new Date().getTime()) {
+    this.uniqueSessionId = Number(process.env.DETOX_START_TIMESTAMP);
+    this.nowProvider = nowProvider;
+  }
+
+  generateName() {
+    const uniqueDeviceId = this.nowProvider() - this.uniqueSessionId;
+    return `Detox-${this.uniqueSessionId}.${uniqueDeviceId}`;
+  }
+
+  isFamilial(name) {
+    return name.startsWith(`Detox-${this.uniqueSessionId}.`);
+  }
+}
+
+module.exports = GenyInstanceNaming;
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.js b/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.js
new file mode 100644
index 000000000..e71f4ddf5
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.js
@@ -0,0 +1,30 @@
+const Recipe = require('./dto/GenyRecipe');
+
+class GenyRecipesService {
+  constructor(genyCloudExec, logger) {
+    this.genyCloudExec = genyCloudExec;
+    this.logger = logger;
+  }
+
+  async getRecipeByName(recipeName) {
+    const result = await this.genyCloudExec.getRecipe(recipeName);
+    const { recipes } = result;
+    if (!recipes.length) {
+      return null;
+    }
+
+    if (recipes.length > 1) {
+      this.logger.warn({ event: 'GENYCLOUD_RECIPE_LOOKUP'}, `More than one Genymotion-Cloud recipe found for recipe name ${recipeName}`);
+    }
+    return new Recipe(recipes[0]);
+  }
+
+  async getRecipeByUUID(uuid) {
+    return new Recipe({
+      uuid,
+      name: `Recipe of ${uuid}`,
+    });
+  }
+}
+
+module.exports = GenyRecipesService;
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
new file mode 100644
index 000000000..f86a19c84
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.js
@@ -0,0 +1,35 @@
+const _ = require('lodash');
+const Recipe = require('./GenyRecipe');
+
+class GenyInstance {
+  constructor(rawInstance) {
+    Object.assign(this, _.pick(rawInstance, 'uuid', 'name', 'state'));
+    this.adb = {
+      name: rawInstance.adb_serial,
+      port: rawInstance.adb_serial_port,
+    }
+    this.recipe = new Recipe(rawInstance.recipe);
+  }
+
+  isAdbConnected() {
+    return this.adb.name !== '0.0.0.0';
+  }
+
+  isTerminated() {
+    return this.state === 'RECYCLED';
+  }
+
+  get recipeName() {
+    return this.recipe.name;
+  }
+
+  get recipeUUID() {
+    return this.recipe.uuid;
+  }
+
+  get adbName() {
+    return this.adb.name;
+  }
+}
+
+module.exports = GenyInstance;
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.js
new file mode 100644
index 000000000..19d196b75
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.js
@@ -0,0 +1,13 @@
+const _ = require('lodash');
+
+class Recipe {
+  constructor(rawRecipe) {
+    Object.assign(this, _.pick(rawRecipe, 'uuid', 'name'));
+  }
+
+  toString() {
+    return this.name;
+  }
+}
+
+module.exports = Recipe;
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index 4c3756d0a..b3a22ac54 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 22c05e745..b29995946 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json
index 79fcb0866..7d3d86be5 100644
--- a/examples/demo-plugin/package.json
+++ b/examples/demo-plugin/package.json
@@ -1,12 +1,12 @@
 {
   "name": "demo-plugin",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "scripts": {
     "test:plugin": "detox test --configuration plugin -l verbose"
   },
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "jest": "^26.5.0"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index fb83cf6e7..25b086933 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index a88401e44..ff1d17566 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,6 +1,6 @@
 {
   "name": "demo-react-native-jest",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -9,7 +9,7 @@
     "test:android-release-ci": "detox test --configuration android.emu.release -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index 9580476a7..a91a2567f 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -22,7 +22,7 @@
     "react-native": "0.62.x"
   },
   "devDependencies": {
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "mocha": "^6.1.3"
   }
 }
diff --git a/lerna.json b/lerna.json
index 406866ea1..8c186ea4d 100644
--- a/lerna.json
+++ b/lerna.json
@@ -13,7 +13,7 @@
     "generation",
     "."
   ],
-  "version": "17.11.0",
+  "version": "17.11.2",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index 7b1247754..ca25b6141 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,5 @@
 {
-  "name": "",
+  "name": "root",
   "private": true,
   "scripts": {
     "test:e2e:ios": "pushd detox/test && npm run e2e:ios && popd",
@@ -19,5 +19,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "17.11.0"
+  "version": "17.11.2"
 }
diff --git a/scripts/ci.publish.js b/scripts/ci.publish.js
index f0e652445..0d979c129 100644
--- a/scripts/ci.publish.js
+++ b/scripts/ci.publish.js
@@ -19,7 +19,7 @@ function validatePrerequisites() {
 function projectSetup() {
   logSection('Project setup');
   exec.execSync(`git checkout ${process.env.BRANCH}`);
-  exec.execSync(`lerna bootstrap --no-ci`);
+  exec.execSync(`lerna bootstrap --no-ci --loglevel verebose`);
 }
 
 function publishToNpm(npmTag) {


Test patch:
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
new file mode 100644
index 000000000..c7d094896
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
@@ -0,0 +1,186 @@
+const _ = require('lodash');
+const latestInstanceOf = (clazz) => _.last(clazz.mock.instances);
+
+const mockBaseClassesDependencies = () => {
+  jest.mock('../exec/ADB');
+  jest.mock('../exec/AAPT');
+  jest.mock('../tools/APKPath');
+  jest.mock('../tools/TempFileXfer');
+  jest.mock('../tools/AppInstallHelper');
+  jest.mock('../tools/AppUninstallHelper');
+  jest.mock('../tools/MonitoredInstrumentation');
+  jest.mock('../../../../artifacts/utils/AndroidDevicePathBuilder');
+  jest.mock('../../../../android/espressoapi/UiDeviceProxy');
+  jest.mock('../../../DeviceRegistry');
+  jest.mock('../../../../utils/logger');
+};
+
+const mockDirectDependencies = () => {
+  jest.mock('./exec/GenyCloudExec');
+  jest.mock('./services/GenyRecipesService');
+  jest.mock('./services/GenyInstanceLookupService');
+  jest.mock('./services/GenyInstanceLifecycleService');
+  jest.mock('./services/GenyInstanceNaming');
+};
+
+const aRecipe = () => ({
+  uuid: 'mock-recipe-uuid',
+  name: 'mock-recipe-name',
+  toString: () => 'mock-recipe-toString()-result',
+});
+
+const anInstance = () => ({
+  uuid: 'mock-instance-uuid',
+  adbName: 'mock-instance-adb-name',
+});
+
+const aDeviceQuery = () => ({
+  query: 'mock',
+});
+
+describe('Genymotion-cloud driver', () => {
+  beforeEach(mockBaseClassesDependencies);
+  beforeEach(mockDirectDependencies);
+
+  let emitter;
+  let adb;
+  let deviceQueryHelper;
+  let allocationHelper;
+  let uut;
+  beforeEach(() => {
+    const Emitter = jest.genMockFromModule('../../../../utils/AsyncEmitter');
+    emitter = new Emitter();
+
+    const { InvocationManager } = jest.genMockFromModule('../../../../invoke');
+    const invocationManager = new InvocationManager();
+
+    const ADB = require('../exec/ADB');
+    adb = () => latestInstanceOf(ADB);
+
+    const DeviceRegistry = require('../../../DeviceRegistry');
+    const deviceRegistry = new DeviceRegistry();
+    deviceRegistry.allocateDevice.mockImplementation((doAllocateFn) => doAllocateFn());
+    DeviceRegistry.forAndroid.mockReturnValue(deviceRegistry);
+
+    jest.mock('./helpers/GenyDeviceQueryHelper');
+    const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
+    deviceQueryHelper = () => latestInstanceOf(DeviceQueryHelper);
+
+    jest.mock('./helpers/GenyAllocationHelper');
+    const AllocationHelper = require('./helpers/GenyAllocationHelper');
+    allocationHelper = () => latestInstanceOf(AllocationHelper);
+
+    const GenyCloudDriver = require('./GenyCloudDriver');
+    uut = new GenyCloudDriver({
+      invocationManager,
+      emitter,
+      client: {},
+    });
+  });
+
+  it('should return a generic name at pre-init', () => {
+    expect(uut.name).toEqual('Unspecified Genymotion Cloud Emulator');
+  });
+
+  describe('device (instance) allocation', () => {
+    const givenNoReceipes = () => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(null);
+    const givenResolvedRecipeForQuery = (recipe) => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(recipe);
+    const givenInstanceAllocationResult = ({ instance, coldBooted = false }) => allocationHelper().allocateInstance.mockResolvedValue({ instance, coldBooted });
+
+    it('should get a recipe and allocate a device', async () => {
+      const recipe = aRecipe();
+      const instance = anInstance();
+      givenResolvedRecipeForQuery(recipe);
+      givenInstanceAllocationResult({ instance });
+
+      const deviceQuery = aDeviceQuery();
+      const result = await uut.acquireFreeDevice(deviceQuery);
+
+      expect(result).toEqual(instance.adbName);
+      expect(deviceQueryHelper().getRecipeFromQuery).toHaveBeenCalledWith(deviceQuery);
+      expect(allocationHelper().allocateInstance).toHaveBeenCalledWith(recipe.uuid);
+    });
+
+    it('should throw a descriptive error recipe not found', async () => {
+      const deviceQuery = aDeviceQuery();
+      givenNoReceipes();
+      givenInstanceAllocationResult({ instance: anInstance() });
+
+      try {
+        await uut.acquireFreeDevice(deviceQuery);
+        fail('Excpected an error');
+      } catch (e) {
+        expect(e.toString()).toContain('No Genycloud devices found for recipe!');
+        expect(e.toString()).toContain('HINT: Check that your Genycloud account has a template associated with your Detox device configuration: ' + JSON.stringify(deviceQuery));
+      }
+    });
+
+    it('should emit a bootDevice event', async () => {
+      const recipe = aRecipe();
+      const instance = anInstance();
+      givenResolvedRecipeForQuery(recipe);
+      givenInstanceAllocationResult({ 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);
+      givenInstanceAllocationResult({ 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);
+      givenInstanceAllocationResult({ instance, coldBooted: 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());
+      givenInstanceAllocationResult({ instance });
+
+      await uut.acquireFreeDevice(aDeviceQuery());
+
+      expect(uut.name).toEqual(`GenyCloud-${instance.adbName}`);
+    });
+
+    it('should init ADb\'s API-level', async () => {
+      const instance = anInstance();
+      givenResolvedRecipeForQuery(aRecipe());
+      givenInstanceAllocationResult({ instance });
+
+      await uut.acquireFreeDevice(aDeviceQuery());
+
+      expect(adb().apiLevel).toHaveBeenCalledWith(instance.adbName);
+    });
+
+    it('should disable native animations', async () => {
+      const instance = anInstance();
+      givenResolvedRecipeForQuery(aRecipe());
+      givenInstanceAllocationResult({ instance });
+
+      await uut.acquireFreeDevice(aDeviceQuery());
+
+      expect(adb().disableAndroidAnimations).toHaveBeenCalledWith(instance.adbName);
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
new file mode 100644
index 000000000..4504ce6c9
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
@@ -0,0 +1,103 @@
+describe('Genymotion-cloud executable', () => {
+  const aResponse = (exit_code = 0, exit_code_desc = 'NO_ERROR') => ({
+    exit_code,
+    exit_code_desc,
+  });
+  const anErrorResponse = (exit_code, exit_code_desc, error_desc) => ({
+    ...aResponse(exit_code, exit_code_desc),
+    error: {
+      message: `API return unexpected code: ${exit_code}. Error: {"code":"${error_desc}","message":"Oh no, mocked error has occurred!"}`,
+      details: '',
+    }
+  });
+
+  const successResponse = aResponse();
+  const failResponse = anErrorResponse(4, 'API_ERROR', 'TOO_MANY_RUNNING_VDS');
+
+  const givenSuccessResult = () => exec.mockResolvedValue({
+    stdout: JSON.stringify(successResponse),
+  });
+  const givenErrorResult = () => exec.mockRejectedValue({
+    stderr: JSON.stringify(failResponse),
+  });
+
+  let exec;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../../utils/exec', () => ({
+      execWithRetriesAndLogs: jest.fn(),
+    }));
+    exec = require('../../../../../utils/exec').execWithRetriesAndLogs;
+
+    const GenyCloudExec = require('./GenyCloudExec');
+    uut = new GenyCloudExec;
+  });
+
+  const recipeName = 'mock-recipe-name';
+  const recipeUUID = 'mock-recipe-uuid';
+  const instanceUUID = 'mock-uuid';
+  const instanceName = 'detox-instance1';
+  [
+    {
+      commandName: 'Get Recipe',
+      commandExecFn: () => uut.getRecipe(recipeName),
+      expectedExec: `"gmsaas" --format compactjson recipes list --name "${recipeName}"`,
+    },
+    {
+      commandName: 'Get Instances',
+      commandExecFn: () => uut.getInstances(),
+      expectedExec: `"gmsaas" --format compactjson instances list`,
+    },
+    {
+      commandName: 'Start Instance',
+      commandExecFn: () => uut.startInstance(recipeUUID, instanceName),
+      expectedExec: `"gmsaas" --format compactjson instances start --stop-when-inactive ${recipeUUID} "${instanceName}"`,
+    },
+    {
+      commandName: 'ADB Connect',
+      commandExecFn: () => uut.adbConnect(instanceUUID),
+      expectedExec: `"gmsaas" --format compactjson instances adbconnect ${instanceUUID}`,
+    },
+    {
+      commandName: 'Stop Instance',
+      commandExecFn: () => uut.stopInstance(instanceUUID),
+      expectedExec: `"gmsaas" --format compactjson instances stop ${instanceUUID}`,
+    },
+  ].forEach((testCase) => {
+    describe(`${testCase.commandName} command`, () => {
+      it('should execute command by name', async () => {
+        givenSuccessResult();
+
+        const expectedOptions = {
+          statusLogs: {
+            retrying: true,
+          }
+        }
+
+        await testCase.commandExecFn();
+        expect(exec).toHaveBeenCalledWith(
+          testCase.expectedExec,
+          expectedOptions,
+        );
+      });
+
+      it('should return the result', async () => {
+        givenSuccessResult();
+
+        const result = await testCase.commandExecFn();
+        expect(result).toEqual(successResponse);
+      });
+
+      it('should fail upon an error result', async () => {
+        givenErrorResult();
+
+        try {
+          await testCase.commandExecFn();
+          fail('Expected an error');
+        } catch (e) {
+          expect(e).toEqual(failResponse);
+        }
+      });
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js
new file mode 100644
index 000000000..7b6677b8e
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyAllocationHelper.test.js
@@ -0,0 +1,121 @@
+describe('Genymotion-cloud instance allocation helper', () => {
+  const recipeUUID = 'mock-recipe-uuid';
+
+  let instanceLookupService;
+  let instanceLifecycleService;
+  let GenyInstance;
+  let uut;
+  beforeEach(() => {
+    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 GenyAllocationHelper = require('./GenyAllocationHelper');
+    uut = new GenyAllocationHelper(instanceLookupService, instanceLifecycleService);
+  });
+
+  const aConnectedInstance = () => {
+    const instance = new GenyInstance();
+    instance.uuid = 'mock-instance-uuid';
+    instance.isAdbConnected.mockReturnValue(true);
+    instance.adbName = 'mock-adb-name';
+    return instance;
+  };
+
+  const aDisconnectedInstance = () => {
+    const instance = new GenyInstance();
+    instance.uuid = 'mock-instance-uuid';
+    instance.isAdbConnected.mockReturnValue(false);
+    instance.adbName = '0.0.0.0';
+    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 givenConnectError = (error) => instanceLifecycleService.adbConnectInstance.mockRejectedValue(error);
+
+  it('should find and return a ready-to-use instance', async () => {
+    const connectedInstance = aConnectedInstance();
+    givenFreeInstance(connectedInstance);
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.instance).toEqual(connectedInstance);
+    expect(instanceLookupService.findFreeInstance).toHaveBeenCalledWith(recipeUUID);
+  });
+
+  it('should create an instance if no such is available', async () => {
+    const connectedInstance = aConnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(connectedInstance);
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.instance).toEqual(connectedInstance);
+    expect(instanceLifecycleService.createInstance).toHaveBeenCalledWith(recipeUUID);
+  });
+
+  it('should adb-connect a disconnected, newly-created instance', async () => {
+    const connectedInstance = aConnectedInstance();
+    const disconnectedInstance = aDisconnectedInstance();
+    givenNoFreeInstances();
+    givenCreatedInstance(disconnectedInstance);
+    givenConnectionInstance(connectedInstance);
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.instance).toEqual(connectedInstance);
+    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
+  });
+
+  it('should adb-connect a disconnected, free (preexisting) instance', async () => {
+    const connectedInstance = aConnectedInstance();
+    const disconnectedInstance = aDisconnectedInstance();
+    givenFreeInstance(disconnectedInstance);
+    givenConnectionInstance(connectedInstance);
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.instance).toEqual(connectedInstance);
+    expect(instanceLifecycleService.adbConnectInstance).toHaveBeenCalledWith(disconnectedInstance.uuid);
+  });
+
+  it('should throw if adb-connect fails', async () => {
+    const error = new Error('mocked adb-connect failure');
+    const disconnectedInstance = aDisconnectedInstance();
+    givenFreeInstance(disconnectedInstance);
+    givenConnectError(error);
+
+    try {
+      await uut.allocateInstance(recipeUUID);
+      fail('Expected an error');
+    } catch(e) {
+      expect(e).toEqual(error);
+    }
+  });
+
+  it('should not adb-connect an already-connected instance', async () => {
+    const connectedInstance = aConnectedInstance('mock-adb-name');
+    givenFreeInstance(connectedInstance);
+
+    await uut.allocateInstance(recipeUUID);
+    expect(instanceLifecycleService.adbConnectInstance).not.toHaveBeenCalled();
+  });
+
+  it('should indicate no cold-booting took place', async () => {
+    givenFreeInstance(aConnectedInstance());
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.coldBooted).toEqual(false);
+  });
+
+  it('should indicate cold-booting took place if new instance created', async () => {
+    givenNoFreeInstances();
+    givenCreatedInstance(aConnectedInstance());
+
+    const result = await uut.allocateInstance(recipeUUID);
+    expect(result.coldBooted).toEqual(true);
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.test.js b/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.test.js
new file mode 100644
index 000000000..64480d9af
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/helpers/GenyDeviceQueryHelper.test.js
@@ -0,0 +1,61 @@
+describe('Genymotion-cloud device-query helper', () => {
+
+  let recipesService;
+  let uut;
+  beforeEach(() => {
+    const RecipesService = jest.genMockFromModule('../services/GenyRecipesService');
+    recipesService = new RecipesService();
+
+    const DeviceQueryHelper = require('./GenyDeviceQueryHelper');
+    uut = new DeviceQueryHelper(recipesService);
+  });
+
+  const aRecipe = () => ({
+    uuid: 'mock-recipe-uuid',
+    name: 'mock-recipe-name',
+  });
+  const anotherRecipe = () => ({
+    uuid: 'mock-recipe-uuid2',
+    name: 'mock-recipe-name2',
+  });
+
+  const givenRecipeByNameResult = (recipe) => recipesService.getRecipeByName.mockResolvedValue(recipe);
+  const givenRecipeByUUIDResult = (recipe) => recipesService.getRecipeByUUID.mockResolvedValue(recipe);
+
+  it('should query using an explicit recipe name', async () => {
+    const deviceQuery = 'recipe-mock-name';
+    const recipe = aRecipe();
+    givenRecipeByNameResult(recipe);
+
+    const result = await uut.getRecipeFromQuery(deviceQuery);
+    expect(result).toEqual(recipe);
+    expect(recipesService.getRecipeByName).toHaveBeenCalledWith(deviceQuery);
+  });
+
+  it('should query based on an object containing recipe name', async () => {
+    const deviceQuery = {
+      recipeName: 'recipe-mock-name',
+    };
+    const recipe = aRecipe();
+    givenRecipeByNameResult(recipe);
+
+    const result = await uut.getRecipeFromQuery(deviceQuery);
+    expect(result).toEqual(recipe);
+    expect(recipesService.getRecipeByName).toHaveBeenCalledWith(deviceQuery.recipeName);
+  });
+
+  it('should query based on an object containing recipe UUID', async () => {
+    const deviceQuery = {
+      recipeUUID: 'recipe-mock-name',
+    };
+    const recipe1 = aRecipe();
+    const recipe2 = anotherRecipe();
+    givenRecipeByNameResult(recipe1);
+    givenRecipeByUUIDResult(recipe2);
+
+    const result = await uut.getRecipeFromQuery(deviceQuery);
+    expect(result).toEqual(recipe2);
+    expect(recipesService.getRecipeByUUID).toHaveBeenCalledWith(deviceQuery.recipeUUID);
+    expect(recipesService.getRecipeByName).not.toHaveBeenCalledWith();
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.test.js
new file mode 100644
index 000000000..820350cc6
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLifecycleService.test.js
@@ -0,0 +1,94 @@
+describe('Genymotion-Cloud instance-lifecycle service', () => {
+  const anInstance = () => ({
+    uuid: 'mock-instance-uuid',
+    name: 'mock-instance-name',
+    adb_serial: 'mock-serial:1111',
+    recipe: {
+      name: 'mock-recipe-name',
+    }
+  });
+
+  let exec;
+  let instanceNaming;
+  let uut;
+  beforeEach(() => {
+    const GenyCloudExec = jest.genMockFromModule('../exec/GenyCloudExec');
+    exec = new GenyCloudExec();
+
+    const GenyInstanceNaming = jest.genMockFromModule('./GenyInstanceNaming');
+    instanceNaming = new GenyInstanceNaming();
+
+    const GenyInstanceLifecycleService = require('./GenyInstanceLifecycleService');
+    uut = new GenyInstanceLifecycleService(exec, instanceNaming);
+  });
+
+  describe('device instance creation', () => {
+    const givenInstanceBirthName = (name) => instanceNaming.generateName.mockReturnValue(name);
+    const givenResultedInstance = (instance) => exec.startInstance.mockResolvedValue({ instance });
+
+    it('should exec instance creation according to recipe', async () => {
+      const instance = anInstance();
+      givenInstanceBirthName(instance.name);
+      givenResultedInstance(instance);
+
+      await uut.createInstance(instance.recipe.uuid);
+      expect(exec.startInstance).toHaveBeenCalledWith(instance.recipe.uuid, instance.name);
+    });
+
+    it('should return the newly created instance', async () => {
+      const instance = anInstance();
+      givenInstanceBirthName(instance.name);
+      givenResultedInstance(instance);
+
+      const result = await uut.createInstance(instance.recipe.name);
+      expect(result).toBeDefined();
+      expect(result.uuid).toEqual(instance.uuid);
+      expect(result.name).toEqual(instance.name);
+      expect(result.recipeUUID).toEqual(instance.recipe.uuid);
+      expect(result.constructor.name).toContain('Instance');
+    });
+  });
+
+  describe('device adb-connect setup', () => {
+    const givenAdbConnectResult = (instance) => exec.adbConnect.mockResolvedValue({ instance });
+
+    it('should exec adb-connect', async () => {
+      const instance = anInstance();
+      givenAdbConnectResult(instance);
+
+      await uut.adbConnectInstance(instance.uuid);
+      expect(exec.adbConnect).toHaveBeenCalledWith(instance.uuid);
+    });
+
+    it('should return the updated instance', async () => {
+      const instance = anInstance();
+      givenAdbConnectResult(instance);
+
+      const result = await uut.adbConnectInstance(instance.uuid);
+      expect(result.uuid).toEqual(instance.uuid);
+      expect(result.constructor.name).toContain('Instance');
+    });
+  });
+
+  describe('device instance deletion', () => {
+    const givenResult = (instance) => exec.stopInstance.mockResolvedValue({ instance });
+
+    it('should exec instance deletion', async () => {
+      const instance = anInstance();
+      givenResult(instance);
+
+      await uut.deleteInstance(instance.uuid);
+
+      expect(exec.stopInstance).toHaveBeenCalledWith(instance.uuid);
+    });
+
+    it('should return result', async () => {
+      const instance = anInstance();
+      givenResult(instance);
+
+      const result = await uut.deleteInstance(instance.uuid);
+      expect(result.uuid).toEqual(instance.uuid);
+      expect(result.constructor.name).toContain('Instance');
+    });
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js
new file mode 100644
index 000000000..bb581bb75
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceLookupService.test.js
@@ -0,0 +1,136 @@
+describe('Genymotion-Cloud instances lookup service', () => {
+  const INSTANCE_STATE_ONLINE = 'ONLINE';
+  const INSTANCE_STATE_TERMINATED = 'RECYCLED';
+
+  const anInstance = () => ({
+    uuid: 'mock-instance-uuid',
+    name: 'mock-instance-name',
+    adb_serial: 'mock-serial:1111',
+    state: INSTANCE_STATE_ONLINE,
+    recipe: {
+      name: 'mock-recipe-name',
+      uuid: 'mock-recipe-uuid',
+    }
+  });
+
+  let exec;
+  let deviceRegistry;
+  let instanceNaming;
+  let uut;
+  beforeEach(() => {
+    const GenyCloudExec = jest.genMockFromModule('../exec/GenyCloudExec');
+    exec = new GenyCloudExec();
+
+    const GenyInstanceNaming = jest.genMockFromModule('./GenyInstanceNaming');
+    instanceNaming = new GenyInstanceNaming();
+
+    const DeviceRegistry = jest.genMockFromModule('../../../../DeviceRegistry');
+    deviceRegistry = new DeviceRegistry();
+
+    const GenyInstancesLookupService = require('./GenyInstanceLookupService');
+    uut = new GenyInstancesLookupService(exec, instanceNaming, deviceRegistry);
+  });
+
+  const aDisconnectedInstance = () => ({
+    ...anInstance(),
+    adb_serial: '0.0.0.0',
+  });
+  const anotherInstance = () => ({
+    ...anInstance(),
+    uuid: 'mock-instance-uuid2',
+    name: 'mock-instance-name2',
+  });
+  const anInstanceOfOtherRecipe = () => ({
+    ...anInstance(),
+    recipe: {
+      name: 'other-mock-recipe-name',
+    },
+  });
+
+  const givenAllDevicesBusy = () => deviceRegistry.isDeviceBusy.mockReturnValue(true);
+  const givenAllDevicesFree = () => deviceRegistry.isDeviceBusy.mockReturnValue(false);
+  const givenInstances = (...instances) => exec.getInstances.mockResolvedValue({ instances });
+  const givenNoInstances = () => exec.getInstances.mockResolvedValue({ instances: [] });
+  const givenAllDevicesFamilial = () => instanceNaming.isFamilial.mockReturnValue(true);
+  const givenNoDevicesFamilial = () => instanceNaming.isFamilial.mockReturnValue(false);
+
+  it('should return null if there are no cloud-instances available', async () => {
+    givenNoInstances();
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+    expect(await uut.findFreeInstance('mock-recipe-uuid')).toEqual(null);
+  });
+
+  it('should return a free instance', async () => {
+    const instance = anInstance();
+    givenInstances(instance);
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+
+    const result = await uut.findFreeInstance(instance.recipe.uuid);
+    expect(result.uuid).toEqual(instance.uuid);
+    expect(result.constructor.name).toContain('Instance');
+  });
+
+  it('should not return an instance of a different recipe', async () => {
+    const instance = anInstance();
+    givenInstances(instance);
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+
+    expect(await uut.findFreeInstance('different-recipe-uuid')).toEqual(null);
+  });
+
+  it('should not return an instance whose name isn\'t in the family', async () => {
+    const instance = anInstance();
+    givenInstances(instance);
+    givenAllDevicesFree();
+    givenNoDevicesFamilial();
+
+    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
+    expect(instanceNaming.isFamilial).toHaveBeenCalledWith(instance.name);
+  });
+
+  it('should not return an instance marked "busy"', async () => {
+    const instance = anInstance();
+    givenInstances(instance);
+    givenAllDevicesBusy();
+    givenAllDevicesFamilial();
+
+    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
+    expect(deviceRegistry.isDeviceBusy).toHaveBeenCalledWith(instance.adb_serial);
+  });
+
+  it('should not return a recycled (auto-shutdown) instance', async () => {
+    const instance = {
+      ...anInstance(),
+      state: INSTANCE_STATE_TERMINATED,
+    };
+    givenInstances(instance);
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+
+    expect(await uut.findFreeInstance(instance.recipe.uuid)).toEqual(null);
+  });
+
+  it('should return a disconnected instance', async () => {
+    const instance = aDisconnectedInstance();
+    givenInstances(instance);
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+
+    const result = await uut.findFreeInstance(instance.recipe.uuid);
+    expect(result.uuid).toEqual(instance.uuid);
+    expect(deviceRegistry.isDeviceBusy).not.toHaveBeenCalled();
+  });
+
+  it('should filter multiple matches of multiple instances', async () => {
+    const instance = anInstance();
+    givenInstances(anInstanceOfOtherRecipe(), instance, anotherInstance());
+    givenAllDevicesFree();
+    givenAllDevicesFamilial();
+
+    const result = await uut.findFreeInstance(instance.recipe.uuid);
+    expect(result.uuid).toEqual(instance.uuid);
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.test.js
new file mode 100644
index 000000000..dc8442e9e
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyInstanceNaming.test.js
@@ -0,0 +1,52 @@
+describe('Genymotion-Cloud instance unique-name strategy', () => {
+  let now;
+  let uut;
+  beforeEach(() => {
+    process.env.DETOX_START_TIMESTAMP = '123456';
+
+    now = 123534;
+    const nowProvider = () => now;
+
+    const GenyInstanceNaming = require('./GenyInstanceNaming');
+    uut = new GenyInstanceNaming(nowProvider);
+  });
+
+  afterAll(() => {
+    delete process.env.DETOX_START_TIMESTAMP;
+  })
+
+  it('should generate a session-scope unique name', () =>
+    expect(uut.generateName()).toEqual('Detox-123456.78'));
+
+  it('should generate an instance-scope unique name', () => {
+    const name1 = uut.generateName();
+    now = now + 1;
+    const name2 = uut.generateName();
+
+    expect(name1).not.toEqual(name2);
+  });
+
+  it('should accept names with the correct timestamp as valid', () =>
+    expect(uut.isFamilial('Detox-123456.10')).toEqual(true));
+
+  it('should deny names with the incorrect timestamp', () =>
+    expect(uut.isFamilial('Detox-123457.10')).toEqual(false));
+
+  it('should deny names not starting with "Detox-"', () =>
+    expect(uut.isFamilial('Dtx-123456.10')).toEqual(false));
+
+  it('should deny names in wrong sections orders', () =>
+    expect(uut.isFamilial('123456-Detox-.10')).toEqual(false));
+
+  it('should deny names with the wrong prefix', () =>
+    expect(uut.isFamilial('_Detox-123456.10')).toEqual(false));
+
+  it('should deny names not containing a dot separator', () =>
+    expect(uut.isFamilial('Detox-123456-10')).toEqual(false));
+
+  it('should have a default now-provider', () => {
+    const GenyInstanceNaming = require('./GenyInstanceNaming');
+    uut = new GenyInstanceNaming(undefined);
+    expect(uut.generateName().startsWith('Detox-')).toEqual(true);
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.test.js
new file mode 100644
index 000000000..d768827fb
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyRecipesService.test.js
@@ -0,0 +1,94 @@
+describe('Genymotion-Cloud recipes service', () => {
+  const givenNoRecipes = () => {
+    exec.getRecipe.mockResolvedValue({
+      recipes: [],
+    });
+  };
+
+  const givenRecipes = (...recipes) => {
+    exec.getRecipe.mockResolvedValue({
+      recipes: [ ...recipes ],
+    });
+  }
+
+  const aRecipe = () => ({
+    uuid: 'mock-recipe-uuid',
+    name: 'mock-recipe-name',
+  });
+
+  const anotherRecipe = () => ({
+    uuid: 'another-mock-recipe-uuid',
+    name: 'mock-recipe-name',
+  });
+
+  let logger;
+  let exec;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../../utils/logger');
+    logger = require('../../../../../utils/logger');
+
+    const GenyCloudExec = jest.genMockFromModule('../exec/GenyCloudExec');
+    exec = new GenyCloudExec();
+
+    const GenyRecipesService = require('./GenyRecipesService');
+    uut = new GenyRecipesService(exec, logger);
+  });
+
+  describe('getting a recipe by name', () => {
+    it('should return null if no recipes found', async () => {
+      givenNoRecipes();
+      expect(await uut.getRecipeByName('mock-name')).toEqual(null);
+    });
+
+    it('should return 1st match if there are some recipes', async () => {
+      const recipe = aRecipe();
+      const recipe2 = anotherRecipe();
+      givenRecipes(recipe, recipe2);
+
+      expect(await uut.getRecipeByName(recipe.name)).toEqual(recipe);
+    });
+
+    it('should return a recipe DTO', async () => {
+      const recipe = aRecipe();
+      givenRecipes(recipe);
+
+      const result = await uut.getRecipeByName(recipe.name);
+      expect(result.constructor.name).toContain('Recipe');
+    });
+
+    it('should warn if there are multiple matches', async () => {
+      const recipe = aRecipe();
+      const recipe2 = anotherRecipe();
+      givenRecipes(recipe, recipe2);
+
+      await uut.getRecipeByName(recipe.name);
+
+      expect(logger.warn).toHaveBeenCalledWith(
+        { event: 'GENYCLOUD_RECIPE_LOOKUP' },
+        `More than one Genymotion-Cloud recipe found for recipe name ${recipe.name}`,
+      );
+    });
+
+    it('should not warn if there is only 1 match', async () => {
+      const recipe = aRecipe();
+      givenRecipes(recipe);
+
+      await uut.getRecipeByName(recipe.name);
+
+      expect(logger.warn).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('Getting a recipe by UUID', () => {
+    it('should immediately return a recipe', async () => {
+      const recipe = aRecipe();
+
+      const result = await uut.getRecipeByUUID(recipe.uuid);
+
+      expect(result.uuid).toEqual(recipe.uuid);
+      expect(result.name).toEqual(`Recipe of ${recipe.uuid}`);
+      expect(result.constructor.name).toContain('Recipe');
+    });
+  });
+});
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
new file mode 100644
index 000000000..eea6f6353
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyInstance.test.js
@@ -0,0 +1,62 @@
+describe('Genymotion-Cloud Instance DTO', () => {
+  const connectedRawInstance = {
+    uuid: 'mock-uuid',
+    name: 'mock-name',
+    state: 'ONLINE',
+    adb_serial: 'localhost:7777',
+    adb_serial_port: 7777,
+    recipe: {
+      uuid: 'mock-recipe-uuid',
+      name: 'mock-recipe-name',
+    },
+  };
+
+  const disconnectedRawInstance = {
+    ...connectedRawInstance,
+    adb_serial: '0.0.0.0',
+    adb_serial_port: 0,
+  };
+
+  const recycledRawInstance = {
+    ...connectedRawInstance,
+    state: 'RECYCLED',
+  }
+
+  let Instance;
+  beforeEach(() => {
+    Instance = require('./GenyInstance');
+  });
+
+  it('should have proper fields', () => {
+    const rawInstance = connectedRawInstance;
+
+    const instance = new Instance(rawInstance);
+
+    expect(instance.uuid).toEqual('mock-uuid');
+    expect(instance.name).toEqual('mock-name');
+    expect(instance.adbName).toEqual(rawInstance.adb_serial);
+    expect(instance.recipeName).toEqual('mock-recipe-name');
+    expect(instance.recipeUUID).toEqual('mock-recipe-uuid');
+    expect(instance.state).toEqual('ONLINE');
+  });
+
+  it('should indicate an ADB-connection', () => {
+    const instance = new Instance(connectedRawInstance);
+    expect(instance.isAdbConnected()).toEqual(true);
+  });
+
+  it('should indicate an ADB-disconnection', () => {
+    const instance = new Instance(disconnectedRawInstance);
+    expect(instance.isAdbConnected()).toEqual(false);
+  });
+
+  it('should indicate termination', () => {
+    const instance = new Instance(recycledRawInstance);
+    expect(instance.isTerminated()).toEqual(true);
+  });
+
+  it('should indicate termination', () => {
+    const instance = new Instance(disconnectedRawInstance);
+    expect(instance.isTerminated()).toEqual(false);
+  });
+});
diff --git a/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.test.js b/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.test.js
new file mode 100644
index 000000000..b2279f0f8
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/dto/GenyRecipe.test.js
@@ -0,0 +1,26 @@
+describe('Genymotion-Cloud Recipe DTO', () => {
+  it('should have a uuid', () => {
+    const rawRecipe = {
+      uuid: 'mock-uuid',
+      name: 'mock-name',
+    };
+
+    const Recipe = require('./GenyRecipe');
+    const recipe = new Recipe(rawRecipe);
+
+    expect(recipe.uuid).toEqual('mock-uuid');
+    expect(recipe.name).toEqual('mock-name');
+  });
+
+  it('should override toString()', () => {
+    const rawRecipe = {
+      uuid: 'mock-uuid',
+      name: 'mock-name',
+    };
+
+    const Recipe = require('./GenyRecipe');
+    const recipe = new Recipe(rawRecipe);
+
+    expect(recipe.toString()).toEqual('mock-name');
+  });
+});
diff --git a/detox/test/e2e/14.network.test.js b/detox/test/e2e/14.network.test.js
index 69c0f8077..25e2f3043 100644
--- a/detox/test/e2e/14.network.test.js
+++ b/detox/test/e2e/14.network.test.js
@@ -1,10 +1,11 @@
 const MockServer = require('../mock-server/mock-server');
 
 describe('Network Synchronization', () => {
-  let mockServer = new MockServer();
+  const mockServer = new MockServer();
 
-  beforeAll(() => {
+  beforeAll(async () => {
     mockServer.init();
+    await device.reverseTcpPort(mockServer.port);
   });
 
   afterAll(async () => {
@@ -33,14 +34,12 @@ describe('Network Synchronization', () => {
     await expect(element(by.text('Long Network Request Working!!!'))).not.toBeVisible();
     await waitFor(element(by.text('Long Network Request Working!!!'))).toBeVisible().withTimeout(4000);
     await expect(element(by.text('Long Network Request Working!!!'))).toBeVisible();
-
     await device.enableSynchronization();
   });
 
 
   it('setURLBlacklist() should disable synchronization for given endpoint', async () => {
-    const url = device.getPlatform() === 'ios' ? '.*localhost.*' : '.*10.0.2.2.*';
-    await device.setURLBlacklist([url]);
+    await device.setURLBlacklist(['.*localhost.*']);
 
     await element(by.id('LongNetworkRequest')).tap();
     await expect(element(by.text('Long Network Request Working!!!'))).not.toBeVisible();
diff --git a/detox/test/mock-server/mock-server.js b/detox/test/mock-server/mock-server.js
index 08d03c90c..7e414de1b 100644
--- a/detox/test/mock-server/mock-server.js
+++ b/detox/test/mock-server/mock-server.js
@@ -1,20 +1,17 @@
 const express = require('express');
 const bodyParser = require('body-parser');
 
-
 class Mockserver {
-
   constructor() {
     this.app = express();
     this.server = null;
+    this.port = process.env.PORT || 9001;
   }
 
   init() {
     this.app.use(bodyParser.urlencoded({extended: true}));
     this.app.use(bodyParser.json());
 
-    const port = process.env.PORT || 9001;
-
     const router = express.Router();
 
     router.get('/', (req, res) => {
@@ -32,8 +29,8 @@ class Mockserver {
 
     this.app.use('/', router);
 
-    this.server = this.app.listen(port);
-    console.log('Mock server', `Listening on port ${port}`);
+    this.server = this.app.listen(this.port);
+    console.log('Mock server', `Listening on port ${this.port}`);
   }
 
   close() {
diff --git a/detox/test/package.json b/detox/test/package.json
index 00aed1fdf..b4472ee46 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "17.11.0",
+  "version": "17.11.2",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.8.7",
-    "detox": "^17.11.0",
+    "detox": "^17.11.2",
     "express": "^4.15.3",
     "jest": "^26.5.0",
     "jest-circus": "^26.5.2",
@@ -131,6 +131,22 @@
           "avdName": "Pixel_API_28"
         }
       },
+      "android.genycloud.release": {
+        "binaryPath": "android/app/build/outputs/apk/fromBin/release/app-fromBin-release.apk",
+        "build": "cd android && ./gradlew assembleFromBinRelease assembleFromBinReleaseAndroidTest -DtestBuildType=release && cd ..",
+        "type": "android.genycloud",
+        "device": {
+          "recipeName": "Detox_Pixel_API_29"
+        }
+      },
+      "android.genycloud.release2": {
+        "binaryPath": "android/app/build/outputs/apk/fromBin/release/app-fromBin-release.apk",
+        "build": "cd android && ./gradlew assembleFromBinRelease assembleFromBinReleaseAndroidTest -DtestBuildType=release && cd ..",
+        "type": "android.genycloud",
+        "device": {
+          "recipeUUID": "a9da8503-c1d0-4aa0-9d8d-8b551da877d3"
+        }
+      },
       "android.emu.debug.fromSource": {
         "binaryPath": "android/app/build/outputs/apk/fromSource/debug/app-fromSource-debug.apk",
         "utilBinaryPaths": [
diff --git a/detox/test/src/Screens/NetworkScreen.js b/detox/test/src/Screens/NetworkScreen.js
index 994ccab13..089228aed 100644
--- a/detox/test/src/Screens/NetworkScreen.js
+++ b/detox/test/src/Screens/NetworkScreen.js
@@ -3,12 +3,8 @@ import {
   Text,
   View,
   TouchableOpacity,
-  Platform
 } from 'react-native';
 
-// TODO Use 10.0.3.2 for Genymotion
-const HOST = Platform.OS === 'ios' ? 'localhost': '10.0.2.2';
-
 export default class NetworkScreen extends Component {
 
   constructor(props) {
@@ -46,8 +42,8 @@ export default class NetworkScreen extends Component {
 
   async onNetworkButtonPress(greeting, delayMs) {
     try {
-      let response = await fetch(`http://${HOST}:9001/delay/${delayMs}`);
-      let responseJson = await response.json();
+      const response = await fetch(`http://localhost:9001/delay/${delayMs}`);
+      const responseJson = await response.json();
       console.log(responseJson.message);
 
       this.setState({