Repository: wix/Detox (js)

Issue:
Parallel testing on Android using Jest, phase #1
Much like on iOS, we need support for running multiple test workers simultaneously on Android using Jest, each worker running on a preassigned, unique emulator instance.

Current challenge is in allowing for multiple emulator instances - all of the same type (e.g. `Pixel_2_API_28`). In essence, the SDK tools allow for a single emulator (instance) per AVD.

Pull request:
Android parallelism - phase 1
- [ ] This is a small change 
- [ ] This change has been discussed in issue #1572 and the solution has been agreed upon with maintainers.

---

**Description:**

Fixes #1572. This is the reopening of PR #1595.

Patch:
diff --git a/detox/runners/jest/JasmineWorkerAssignReporter.js b/detox/runners/jest/JasmineWorkerAssignReporter.js
new file mode 100644
index 000000000..69c66ce66
--- /dev/null
+++ b/detox/runners/jest/JasmineWorkerAssignReporter.js
@@ -0,0 +1,21 @@
+const { device } = require('detox'); // eslint-disable-line node/no-missing-require
+const chalk = require('chalk').default;
+const path = require('path');
+
+class JasmineWorkerAssignReporter {
+  suiteStarted(suiteInfo) {
+    const fileName = path.basename(suiteInfo.testPath);
+    this._traceln(`${chalk.whiteBright(fileName)} assigned to ${chalk.blueBright(device.name)}`);
+    this._traceln('');
+  }
+
+  _trace(message) {
+    process.stdout.write(message);
+  }
+
+  _traceln(message) {
+    this._trace(message);
+    process.stdout.write('\n');
+  }
+}
+module.exports = JasmineWorkerAssignReporter;
diff --git a/detox/runners/jest/assignReporter.js b/detox/runners/jest/assignReporter.js
new file mode 100644
index 000000000..17e997076
--- /dev/null
+++ b/detox/runners/jest/assignReporter.js
@@ -0,0 +1,2 @@
+const Reporter = require('./JasmineWorkerAssignReporter');
+module.exports = new Reporter();
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index dd5686c96..6668216d5 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -96,6 +96,10 @@ class Device {
     }
   }
 
+  get name() {
+    return this.deviceDriver.name;
+  }
+
   async takeScreenshot(name) {
     if (!name) {
       throw new Error('Cannot take a screenshot with an empty name.');
diff --git a/detox/src/devices/DeviceRegistry.js b/detox/src/devices/DeviceRegistry.js
index 9ff9c0261..b13adb759 100644
--- a/detox/src/devices/DeviceRegistry.js
+++ b/detox/src/devices/DeviceRegistry.js
@@ -2,16 +2,15 @@ const _ = require('lodash');
 const fs = require('fs-extra');
 const plockfile = require('proper-lockfile');
 const retry = require('../utils/retry');
-const environment = require('../utils/environment');
-const DEVICE_LOCK_FILE_PATH = environment.getDeviceLockFilePath();
 
 const LOCK_RETRY_OPTIONS = {retries: 10000, interval: 5};
 
 class DeviceRegistry {
 
-  constructor({getDeviceIdsByType, createDevice}) {
+  constructor({getDeviceIdsByType, createDevice, lockfile}) {
     this.getDeviceIdsByType = getDeviceIdsByType;
     this.createDevice = createDevice;
+    this.lockfile = lockfile;
     this._createEmptyLockFileIfNeeded();
   }
 
@@ -39,11 +38,11 @@ class DeviceRegistry {
   }
 
   async _lock() {
-    await retry(LOCK_RETRY_OPTIONS, () => plockfile.lockSync(DEVICE_LOCK_FILE_PATH));
+    await retry(LOCK_RETRY_OPTIONS, () => plockfile.lockSync(this.lockfile));
   }
 
   async _unlock() {
-    await plockfile.unlockSync(DEVICE_LOCK_FILE_PATH);
+    await plockfile.unlockSync(this.lockfile);
   }
 
   clear() {
@@ -59,24 +58,25 @@ class DeviceRegistry {
   }
 
   _createEmptyLockFileIfNeeded() {
-    if (!fs.existsSync(DEVICE_LOCK_FILE_PATH)) {
-      fs.ensureFileSync(DEVICE_LOCK_FILE_PATH);
+    if (!fs.existsSync(this.lockfile)) {
+      fs.ensureFileSync(this.lockfile);
       this._writeBusyDevicesToLockFile([]);
     }
   }
 
   _writeBusyDevicesToLockFile(lockedDevices) {
-    fs.writeFileSync(DEVICE_LOCK_FILE_PATH, JSON.stringify(lockedDevices));
+    fs.writeFileSync(this.lockfile, JSON.stringify(lockedDevices));
   }
 
   _getBusyDevices() {
     this._createEmptyLockFileIfNeeded();
-    const lockFileContent = fs.readFileSync(DEVICE_LOCK_FILE_PATH, 'utf-8');
+    const lockFileContent = fs.readFileSync(this.lockfile, 'utf-8');
     return JSON.parse(lockFileContent);
   }
 
   async _getFreeDevice(deviceType) {
-    const deviceIds = await this.getDeviceIdsByType(deviceType);
+    const busyDevices = await this._getBusyDevices();
+    const deviceIds = await this.getDeviceIdsByType(deviceType, busyDevices);
 
     for (let i = 0; i < deviceIds.length; i++) {
       let deviceId = deviceIds[i];
diff --git a/detox/src/devices/android/ADB.js b/detox/src/devices/android/ADB.js
index 12ced9355..fa65d4b8e 100644
--- a/detox/src/devices/android/ADB.js
+++ b/detox/src/devices/android/ADB.js
@@ -13,8 +13,22 @@ class ADB {
   }
 
   async devices() {
-    const output = (await this.adbCmd('', 'devices', { verbosity: 'high' })).stdout;
-    return this.parseAdbDevicesConsoleOutput(output);
+    const parser = await this._adbDevices();
+    const devices = [];
+    for (let device = await parser.parseNext(); device; device = await parser.parseNext()) {
+      devices.push(device);
+    }
+    return devices;
+  }
+
+  async findDevice(matcher) {
+    const parser = await this._adbDevices();
+    for (let device = await parser.parseNext(); device; device = await parser.parseNext()) {
+      if (await matcher(device)) {
+        return device;
+      }
+    }
+    return undefined;
   }
 
   async unlockScreen(deviceId) {
@@ -33,8 +47,7 @@ class ADB {
   }
 
   async _getPowerStatus(deviceId) {
-    const stdout = await this.shell(deviceId, `dumpsys power | grep "^[ ]*m[UW].*="`);
-
+    const stdout = await this.shell(deviceId, `dumpsys power | grep "^[ ]*m[UW].*="`, { retries: 5 });
     return stdout
       .split('\n')
       .map(s => s.trim().split('='))
@@ -56,43 +69,6 @@ class ADB {
     await this.shell(deviceId, `input keyevent ${keyevent}`);
   }
 
-  async parseAdbDevicesConsoleOutput(input) {
-    const outputToList = input.trim().split('\n');
-    const devicesList = _.takeRight(outputToList, outputToList.length - 1);
-    const devices = [];
-    for (const deviceString of devicesList) {
-      const deviceParams = deviceString.split('\t');
-      const deviceAdbName = deviceParams[0];
-      let device;
-      if (this.isEmulator(deviceAdbName)) {
-        const port = _.split(deviceAdbName, '-')[1];
-        if (!port) {
-          _reportTelnetPortResolutionError(input, devicesList, deviceAdbName, port);
-        }
-
-        const telnet = new EmulatorTelnet();
-        await telnet.connect(port);
-        const name = await telnet.avdName();
-        device = {type: 'emulator', name: name, adbName: deviceAdbName, port: port};
-        await telnet.quit();
-      } else if (this.isGenymotion(deviceAdbName)) {
-        device = {type: 'genymotion', name: deviceAdbName, adbName: deviceAdbName};
-      } else {
-        device = {type: 'device', name: deviceAdbName, adbName: deviceAdbName};
-      }
-      devices.push(device);
-    }
-    return devices;
-  }
-
-  isEmulator(deviceAdbName) {
-    return _.includes(deviceAdbName, 'emulator-');
-  }
-
-  isGenymotion(string) {
-    return (/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(string));
-  }
-
   async now(deviceId) {
     return this.shell(deviceId, `date +"%m-%d %T.000"`);
   }
@@ -169,7 +145,7 @@ class ADB {
       return this._cachedApiLevels.get(deviceId);
     }
 
-    const lvl = Number(await this.shell(deviceId, `getprop ro.build.version.sdk`));
+    const lvl = Number(await this.shell(deviceId, `getprop ro.build.version.sdk`, { retries: 5 }));
     this._cachedApiLevels.set(deviceId, lvl);
 
     return lvl;
@@ -277,6 +253,11 @@ class ADB {
     return this.adbCmd(deviceId, `reverse --remove tcp:${port}`);
   }
 
+  async _adbDevices() {
+    const output = (await this.adbCmd('', 'devices', { verbosity: 'high' })).stdout;
+    return new ADBDevicesParser(output);
+  }
+
   async adbCmd(deviceId, params, options) {
     const serial = `${deviceId ? `-s ${deviceId}` : ''}`;
     const cmd = `${this.adbBin} ${serial} ${params}`;
@@ -295,6 +276,59 @@ class ADB {
   }
 }
 
+class ADBDevicesParser {
+  constructor(adbDevicesOutput) {
+    this.rawInput = adbDevicesOutput;
+    this.devicesList = this._extractDeviceNamesFromAdbDevicesOutput(adbDevicesOutput);
+    this.index = 0;
+  }
+
+  async parseNext() {
+    if (this.index < this.devicesList.length) {
+      return await this._parseAdbDevice(this.devicesList[this.index++]);
+    }
+    return undefined;
+  }
+
+  _extractDeviceNamesFromAdbDevicesOutput(input) {
+    const outputToList = input.trim().split('\n');
+    const devicesList = _.takeRight(outputToList, outputToList.length - 1);
+    return devicesList;
+  }
+
+  async _parseAdbDevice(deviceString) {
+    const deviceParams = deviceString.split('\t');
+    const adbName = deviceParams[0];
+
+    let device;
+    if (isEmulator(adbName)) {
+      const port = _.split(adbName, '-')[1];
+      if (!port) {
+        _reportTelnetPortResolutionError(this.rawInput, this.devicesList, adbName, port);
+      }
+
+      const telnet = new EmulatorTelnet();
+      await telnet.connect(port);
+      const name = await telnet.avdName();
+      device = {type: 'emulator', name: name, adbName, port};
+      await telnet.quit();
+    } else if (isGenymotion(adbName)) {
+      device = {type: 'genymotion', name: adbName, adbName};
+    } else {
+      device = {type: 'device', name: adbName, adbName};
+    }
+    return device;
+  }
+}
+
+function isEmulator(deviceAdbName) {
+  return _.includes(deviceAdbName, 'emulator-');
+}
+
+function isGenymotion(adbName) {
+  return (/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(adbName));
+}
+
 function _reportTelnetPortResolutionError(input, devicesList, deviceAdbName, port) {
   const {encodeBase64} = require('../../utils/encoding');
   const errorMessage = [
diff --git a/detox/src/devices/android/Emulator.js b/detox/src/devices/android/Emulator.js
index f595e8a9d..00b0d493b 100644
--- a/detox/src/devices/android/Emulator.js
+++ b/detox/src/devices/android/Emulator.js
@@ -1,12 +1,11 @@
-const path = require('path');
-const exec = require('../../utils/exec').execWithRetriesAndLogs;
-const spawn = require('child-process-promise').spawn;
 const _ = require('lodash');
-const unitLogger = require('../../utils/logger').child({ __filename });
 const fs = require('fs');
 const os = require('os');
-const Environment = require('../../utils/environment');
+const spawn = require('child-process-promise').spawn;
 const Tail = require('tail').Tail;
+const exec = require('../../utils/exec').execWithRetriesAndLogs;
+const unitLogger = require('../../utils/logger').child({ __filename });
+const Environment = require('../../utils/environment');
 const argparse = require('../../utils/argparse');
 
 class Emulator {
@@ -24,11 +23,15 @@ class Emulator {
     return (await exec(`${this.emulatorBin} ${cmd}`)).stdout;
   }
 
-  async boot(emulatorName) {
+  async boot(emulatorName, options = {port: undefined}) {
     const emulatorArgs = _.compact([
       '-verbose',
       '-no-audio',
+      '-no-boot-anim',
       argparse.getArgValue('headless') ? '-no-window' : '',
+      argparse.getArgValue('readOnlyEmu') ? '-read-only' : '',
+      options.port ? `-port` : '',
+      options.port ? `${options.port}` : '',
       `@${emulatorName}`
     ]);
 
@@ -38,7 +41,8 @@ class Emulator {
     }
 
     let childProcessOutput;
-    const tempLog = `./${emulatorName}.log`;
+    const portName = options.port ? `-${options.port}` : '';
+    const tempLog = `./${emulatorName}${portName}.log`;
     const stdout = fs.openSync(tempLog, 'a');
     const stderr = fs.openSync(tempLog, 'a');
     const tailOptions = {
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index c7465d698..f36094805 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -16,14 +16,11 @@ const ADBLogcatPlugin = require('../../artifacts/log/android/ADBLogcatPlugin');
 const ADBScreencapPlugin = require('../../artifacts/screenshot/ADBScreencapPlugin');
 const ADBScreenrecorderPlugin = require('../../artifacts/video/ADBScreenrecorderPlugin');
 const AndroidDevicePathBuilder = require('../../artifacts/utils/AndroidDevicePathBuilder');
-const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const sleep = require('../../utils/sleep');
 const retry = require('../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../utils/exec');
 const AndroidExpect = require('../../android/expect');
 
-const EspressoDetox = 'com.wix.detox.espresso.EspressoDetox';
-
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
diff --git a/detox/src/devices/drivers/AttachedAndroidDriver.js b/detox/src/devices/drivers/AttachedAndroidDriver.js
index e9bbff128..107d9c24c 100644
--- a/detox/src/devices/drivers/AttachedAndroidDriver.js
+++ b/detox/src/devices/drivers/AttachedAndroidDriver.js
@@ -7,12 +7,18 @@ class AttachedAndroidDriver extends AndroidDriver {
     super(config);
 
     this.emulator = new Emulator();
+    this._name = 'Unnamed Android Device';
+  }
+
+  get name() {
+    return this._name;
   }
 
   async acquireFreeDevice(name) {
     const deviceId = await this.findDeviceId({adbName: name});
     await this.adb.apiLevel(name);
     await this.adb.unlockScreen(deviceId);
+    this._name = `${deviceId} (${name})`;
     return deviceId;
   }
 }
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index b8a9ae55b..9c79b7bb0 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -24,6 +24,10 @@ class DeviceDriverBase {
     });
   }
 
+  get name() {
+    return 'UNSPECIFIED_DEVICE';
+  }
+
   off(event, listener) {
     this.emitter.off(event, listener);
   }
@@ -44,10 +48,6 @@ class DeviceDriverBase {
     return await Promise.resolve('');
   }
 
-  async boot() {
-    return await Promise.resolve('');
-  }
-
   async launchApp() {
     return await Promise.resolve('');
   }
@@ -78,7 +78,7 @@ class DeviceDriverBase {
   async matchFinger() {
     return await Promise.resolve('');
   }
-  
+
   async unmatchFinger() {
     return await Promise.resolve('');
   }
diff --git a/detox/src/devices/drivers/EmulatorDriver.js b/detox/src/devices/drivers/EmulatorDriver.js
index 039e8abd0..3d926f593 100644
--- a/detox/src/devices/drivers/EmulatorDriver.js
+++ b/detox/src/devices/drivers/EmulatorDriver.js
@@ -1,85 +1,78 @@
 const _ = require('lodash');
 const path = require('path');
+const ini = require('ini');
+const fs = require('fs');
+const os = require('os');
 const Emulator = require('../android/Emulator');
 const EmulatorTelnet = require('../android/EmulatorTelnet');
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
-const Environment = require('../../utils/environment');
+const environment = require('../../utils/environment');
 const retry = require('../../utils/retry');
-const sleep = require('../../utils/sleep');
 const AndroidDriver = require('./AndroidDriver');
-const ini = require('ini');
-const fs = require('fs');
-const os = require('os');
+const DeviceRegistry = require('../DeviceRegistry');
+
+const DetoxEmulatorsPortRange = {
+  min: 10000,
+  max: 20000
+};
 
 class EmulatorDriver extends AndroidDriver {
   constructor(config) {
     super(config);
 
     this.emulator = new Emulator();
+    this.deviceRegistry = new DeviceRegistry({
+      getDeviceIdsByType: this._getDeviceIdsByType.bind(this),
+      createDevice: this._createDevice.bind(this),
+      lockfile: environment.getDeviceLockFilePathAndroid(),
+    });
+    this.pendingBoots = {};
+    this._name = 'Unspecified Emulator';
   }
 
-  async _fixEmulatorConfigIniSkinName(name) {
-    const configFile = `${os.homedir()}/.android/avd/${name}.avd/config.ini`;
-    const config = ini.parse(fs.readFileSync(configFile, 'utf-8'));
-
-    if (!config['skin.name']) {
-      const width = config['hw.lcd.width'];
-      const height = config['hw.lcd.height'];
-
-      if (width === undefined || height === undefined) {
-        throw new Error(`Emulator with name ${name} has a corrupt config.ini file (${configFile}), try fixing it by recreating an emulator.`);
-      }
-
-      config['skin.name'] = `${width}x${height}`;
-      fs.writeFileSync(configFile, ini.stringify(config));
-    }
-    return config;
+  get name() {
+    return this._name
   }
 
-  async boot(avdName) {
-    let adbName = await this._findADBNameByAVDName(avdName, { strict: false });
-    const coldBoot = adbName == null;
+  async acquireFreeDevice(avdName) {
+    await this._validateAvd(avdName);
+    await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
 
-    // If it's not already running, start it now.
-    if (coldBoot) {
-      await this.emulator.boot(avdName);
-      adbName = await this._findADBNameByAVDName(avdName, { strict: true });
-    }
+    const adbName = await this.deviceRegistry.getDevice(avdName);
+    await this._boot(avdName, adbName);
 
-    await this._waitForBootToComplete(adbName);
-    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName });
+    await this.adb.apiLevel(adbName);
+    await this.adb.unlockScreen(adbName);
 
+    this._name = `${adbName} (${avdName})`;
     return adbName;
   }
 
-  async _findADBNameByAVDName(avdName, { strict }) {
-    const adbDevices = await this.adb.devices();
-    const filteredDevices = _.filter(adbDevices, {type: 'emulator', name: avdName});
-
-    if (filteredDevices.length === 1) {
-      return filteredDevices[0].adbName;
-    }
+  async cleanup(adbName, bundleId) {
+    await this.deviceRegistry.freeDevice(adbName);
+    await super.cleanup(adbName, bundleId);
+  }
 
-    if (filteredDevices.length > 1) {
-      throw new Error(`Got more than one device corresponding to the name: ${avdName}`);
-    }
+  async _boot(avdName, adbName) {
+    const coldBoot = !!this.pendingBoots[adbName];
 
-    if (strict) {
-      throw new Error(`Could not find '${avdName}' on the currently ADB attached devices, 
-      try restarting adb 'adb kill-server && adb start-server'`);
+    if (coldBoot) {
+      const port = this.pendingBoots[adbName];
+      await this.emulator.boot(avdName, {port});
+      delete this.pendingBoots[adbName];
     }
 
-    return null;
+    await this._waitForBootToComplete(adbName);
+    await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName });
   }
 
-  async acquireFreeDevice(avdName) {
+  async _validateAvd(avdName) {
     const avds = await this.emulator.listAvds();
-
     if (!avds) {
-      const avdmanagerPath = path.join(Environment.getAndroidSDKPath(), 'tools', 'bin', 'avdmanager');
+      const avdmanagerPath = path.join(environment.getAndroidSDKPath(), 'tools', 'bin', 'avdmanager');
 
       throw new Error(`Could not find any configured Android Emulator. 
-      Try creating a device first, example: ${avdmanagerPath} create avd --force --name Nexus_5X_API_24 --abi x86 --package 'system-images;android-24;google_apis_playstore;x86' --device "Nexus 5X"
+      Try creating a device first, example: ${avdmanagerPath} create avd --force --name Pixel_2_API_26 --abi x86 --package 'system-images;android-26;google_apis_playstore;x86' --device "pixel"
       or go to https://developer.android.com/studio/run/managing-avds.html for details on how to create an Emulator.`);
     }
 
@@ -87,18 +80,10 @@ class EmulatorDriver extends AndroidDriver {
       throw new Error(`Can not boot Android Emulator with the name: '${avdName}',
       make sure you choose one of the available emulators: ${avds.toString()}`);
     }
-
-    await this._fixEmulatorConfigIniSkinName(avdName);
-
-    const adbName = await this.boot(avdName);
-    await this.adb.apiLevel(adbName);
-    await this.adb.unlockScreen(adbName);
-
-    return adbName;
   }
 
   async _waitForBootToComplete(deviceId) {
-    await retry({ retries: 120, interval: 5000 }, async () => {
+    await retry({ retries: 240, interval: 2500 }, async () => {
       const isBootComplete = await this.adb.isBootComplete(deviceId);
 
       if (!isBootComplete) {
@@ -117,6 +102,55 @@ class EmulatorDriver extends AndroidDriver {
     await telnet.kill();
     await this.emitter.emit('shutdownDevice', { deviceId });
   }
+
+  async _fixEmulatorConfigIniSkinNameIfNeeded(avdName) {
+    const configFile = `${os.homedir()}/.android/avd/${avdName}.avd/config.ini`;
+    const config = ini.parse(fs.readFileSync(configFile, 'utf-8'));
+
+    if (!config['skin.name']) {
+      const width = config['hw.lcd.width'];
+      const height = config['hw.lcd.height'];
+
+      if (width === undefined || height === undefined) {
+        throw new Error(`Emulator with name ${avdName} has a corrupt config.ini file (${configFile}), try fixing it by recreating an emulator.`);
+      }
+
+      config['skin.name'] = `${width}x${height}`;
+      fs.writeFileSync(configFile, ini.stringify(config));
+    }
+    return config;
+  }
+
+  async _getDeviceIdsByType(name, currentBusyDevices) {
+    const device = await this.adb.findDevice(async (candidate) => {
+      const isEmulator = candidate.type === 'emulator';
+      const isMatchingName = candidate.name === name;
+      if (!(isEmulator && isMatchingName)) {
+        return false;
+      }
+
+      // Note: though not entirely pure, this is an important optimization so as to avoid the preparsing of all
+      // potentially fit emulators, which can be time consuming - mostly because of the telnet.
+      const isBusy = currentBusyDevices.includes(candidate.adbName);
+      return !isBusy;
+    });
+
+    const devices = [];
+    if (device) {
+      devices.push(device.adbName);
+    }
+    return devices;
+  }
+
+  async _createDevice() {
+    const {min, max} = DetoxEmulatorsPortRange;
+    let port = Math.random() * (max - min) + min;
+    port = port & 0xFFFFFFFE; // Should always be even
+
+    const adbName = `emulator-${port}`;
+    this.pendingBoots[adbName] = port;
+    return adbName;
+  }
 }
 
 module.exports = EmulatorDriver;
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index af254d6f8..1e8e592a1 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -15,7 +15,14 @@ class SimulatorDriver extends IosDriver {
     this.deviceRegistry = new DeviceRegistry({
       getDeviceIdsByType: async type => await this.applesimutils.findDevicesUDID(type),
       createDevice: type => this.applesimutils.create(type),
+      lockfile: environment.getDeviceLockFilePathIOS(),
     });
+
+    this._name = 'Unspecified Simulator';
+  }
+
+  get name() {
+    return this._name;
   }
 
   async prepare() {
@@ -35,10 +42,11 @@ class SimulatorDriver extends IosDriver {
   async acquireFreeDevice(name) {
     const deviceId = await this.deviceRegistry.getDevice(name);
     if (deviceId) {
-      await this.boot(deviceId);
+      await this._boot(deviceId);
     } else {
       console.error('Unable to acquire free device ', name);
     }
+    this._name = `${deviceId || 'UNKNOWN_DEVICE_ID'} (${name})`;
     return deviceId;
   }
 
@@ -55,7 +63,7 @@ class SimulatorDriver extends IosDriver {
     }
   }
 
-  async boot(deviceId) {
+  async _boot(deviceId) {
     const coldBoot = await this.applesimutils.boot(deviceId);
     await this.emitter.emit('bootDevice', { coldBoot, deviceId });
   }
@@ -123,7 +131,7 @@ class SimulatorDriver extends IosDriver {
   async resetContentAndSettings(deviceId) {
     await this.shutdown(deviceId);
     await this.applesimutils.resetContentAndSettings(deviceId);
-    await this.boot(deviceId);
+    await this._boot(deviceId);
   }
 
   validateDeviceConfig(deviceConfig) {
diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js
index 7aae535b1..dcc9759e2 100644
--- a/detox/src/utils/environment.js
+++ b/detox/src/utils/environment.js
@@ -5,7 +5,8 @@ const exec = require('child-process-promise').exec;
 const appdatapath = require('./appdatapath');
 
 const DETOX_LIBRARY_ROOT_PATH = path.join(appdatapath.appDataPath(), 'Detox');
-const DEVICE_LOCK_FILE_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'device.registry.state.lock');
+const DEVICE_LOCK_FILE_PATH_IOS = path.join(DETOX_LIBRARY_ROOT_PATH, 'device.registry.state.lock');
+const DEVICE_LOCK_FILE_PATH_ANDROID = path.join(DETOX_LIBRARY_ROOT_PATH, 'android-device.registry.state.lock');
 
 function getAndroidSDKPath() {
   let sdkPath = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME;
@@ -39,8 +40,12 @@ function getDetoxLibraryRootPath() {
   return DETOX_LIBRARY_ROOT_PATH;
 }
 
-function getDeviceLockFilePath() {
-  return DEVICE_LOCK_FILE_PATH;
+function getDeviceLockFilePathIOS() {
+  return DEVICE_LOCK_FILE_PATH_IOS;
+}
+
+function getDeviceLockFilePathAndroid() {
+  return DEVICE_LOCK_FILE_PATH_ANDROID;
 }
 
 function getHomeDir() {
@@ -53,6 +58,7 @@ module.exports = {
   getAndroidSDKPath,
   getAndroidEmulatorPath,
   getDetoxLibraryRootPath,
-  getDeviceLockFilePath,
+  getDeviceLockFilePathIOS,
+  getDeviceLockFilePathAndroid,
   getHomeDir,
 };


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index d05910121..02daeb377 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -147,10 +147,6 @@ const collectExtraArgs = require('./utils/collectExtraArgs')(module.exports.buil
 
 module.exports.handler = async function test(program) {
   program.artifactsLocation = buildDefaultArtifactsRootDirpath(program.configuration, program.artifactsLocation);
-  
-  if(!program.keepLockFile){
-    clearDeviceRegistryLockFile();
-  }
 
   const config = getDetoxSection();
 
@@ -168,6 +164,10 @@ module.exports.handler = async function test(program) {
 
   const platform = currentConfiguration.type.split('.')[0];
 
+  if(!program.keepLockFile){
+    clearDeviceRegistryLockFile();
+  }
+
   function run() {
     if (runner.includes('jest')) {
       return runJest();
@@ -240,16 +240,21 @@ module.exports.handler = async function test(program) {
   }
 
   function runJest() {
-    if (platform === 'android' && program.workers !== 1) {
-      log.warn('Can not use -w, --workers. Parallel test execution is only supported on iOS currently');
-      program.w = program.workers = 1;
+    const hasMultipleWorkers = (program.workers > 1);
+    if (platform === 'android') {
+      program.readOnlyEmu = false;
+      if (hasMultipleWorkers) {
+        program.readOnlyEmu = true;
+        log.warn('Multiple workers is an experimental feature on Android and requires an emulator binary of version 28.0.16 or higher. ' +
+          'Check your version by running: $ANDROID_HOME/tools/bin/sdkmanager --list');
+      }
     }
 
     const jestReportSpecsArg = program['jest-report-specs'];
     if (!_.isUndefined(jestReportSpecsArg)) {
       program.reportSpecs = (jestReportSpecsArg.toString() === 'true');
     } else {
-      program.reportSpecs = (program.workers === 1);
+      program.reportSpecs = !hasMultipleWorkers;
     }
 
     const command = _.compact([
@@ -276,6 +281,7 @@ module.exports.handler = async function test(program) {
       'recordPerformance',
       'deviceName',
       'reportSpecs',
+      'readOnlyEmu',
     ]);
 
     log.info(printEnvironmentVariables(detoxEnvironmentVariables) + command);
@@ -320,7 +326,7 @@ module.exports.handler = async function test(program) {
   }
 
   function clearDeviceRegistryLockFile() {
-    const lockFilePath = environment.getDeviceLockFilePath();
+    const lockFilePath = platform === 'ios' ? environment.getDeviceLockFilePathIOS() : environment.getDeviceLockFilePathAndroid();
     fs.ensureFileSync(lockFilePath);
     fs.writeFileSync(lockFilePath, '[]');
   }
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 8751e1266..84ab3a3b8 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -25,15 +25,22 @@ describe('test', () => {
     process.argv = argv;
   });
 
+  const mockAndroidJestConfiguration = () => mockConfiguration('android.emulator', 'jest');
+  const mockIOSJestConfiguration = () => mockConfiguration('ios.sim', 'jest');
+  const mockAndroidMochaConfiguration = () => mockConfiguration('android.emulator');
+  const mockIOSMochaConfiguration = () => mockConfiguration('ios.sim');
+  const mockConfiguration = (deviceType, runner) => mockPackageJson({
+    'test-runner': runner,
+    configurations: {
+      only: {
+        type: deviceType,
+      }
+    }
+  });
+
   describe('mocha', () => {
     it('runs successfully', async () => {
-      mockPackageJson({
-        configurations: {
-          only: {
-            type: 'android.emulator'
-          }
-        }
-      });
+      mockAndroidMochaConfiguration();
 
       await callCli('./test', 'test');
 
@@ -49,29 +56,16 @@ describe('test', () => {
     });
 
     it('should warn about deprecated options', async () => {
-      mockPackageJson({
-        configurations: {
-          only: {
-            type: 'android.emulator'
-          }
-        }
-      });
-
+      mockAndroidMochaConfiguration();
       await callCli('./test', 'test --specs e2e');
       expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('migration guide'));
     });
   });
 
+
   describe('jest', () => {
     it('runs successfully', async () => {
-      mockPackageJson({
-        testRunner: 'jest',
-        configurations: {
-          only: {
-            type: 'android.emulator'
-          }
-        }
-      });
+      mockAndroidJestConfiguration();
 
       const mockExec = jest.fn();
       jest.mock('child_process', () => ({
@@ -98,37 +92,12 @@ describe('test', () => {
   });
 
   it('fails with a different runner', async () => {
-    mockPackageJson({
-      testRunner: 'ava',
-      configurations: {
-        only: {
-          type: 'android.emulator'
-        }
-      }
-    });
-
+    mockConfiguration('android.emulator', 'ava');
     await expect(callCli('./test', 'test')).rejects.toThrowErrorMatchingSnapshot();
     expect(mockExec).not.toHaveBeenCalled();
   });
 
-  it('overrides workers count to 1 if running Android tests on Jest', async () => {
-    mockPackageJson({
-      'test-runner': 'jest',
-      configurations: {
-        only: {
-          type: 'android.emulator'
-        }
-      }
-    });
-
-    await callCli('./test', 'test --workers 2');
-    expect(mockExec).toHaveBeenCalledWith(
-      expect.stringContaining(` --maxWorkers=1 `),
-      expect.anything()
-    );
-  });
-
-  describe('specs reporting (propagated) switch', () => {
+  describe('Jest specs reporting (propagated) switch', () => {
     const expectReportSpecsArg = ({value}) => expect(mockExec).toHaveBeenCalledWith(
       expect.anything(),
       expect.objectContaining({
@@ -139,14 +108,7 @@ describe('test', () => {
     );
 
     describe('ios', () => {
-      beforeEach(() => mockPackageJson({
-        'test-runner': 'jest',
-        configurations: {
-          only: {
-            type: 'ios.sim'
-          }
-        }
-      }));
+      beforeEach(mockIOSJestConfiguration);
 
       it('should be enabled for a single worker', async () => {
         await callCli('./test', 'test --workers 1');
@@ -180,36 +142,55 @@ describe('test', () => {
     });
 
     describe('android', () => {
-      beforeEach(() => mockPackageJson({
-        'test-runner': 'jest',
-        configurations: {
-          only: {
-            type: 'android.emulator'
-          }
-        }
-      }));
-
-      it('should align with fallback to single-worker', async () => {
-        await callCli('./test', 'test --workers 2');
-        expectReportSpecsArg({value: true});
-      });
-
       it('should adhere to custom --jest-report-specs switch, as with ios', async () => {
+        mockAndroidJestConfiguration();
         await callCli('./test', 'test --workers 2 --jest-report-specs false');
         expectReportSpecsArg({value: false});
       });
     });
   });
 
-  it('sets default value for debugSynchronization', async () => {
-    mockPackageJson({
-      configurations: {
-        only: {
-          type: 'android.emulator'
-        }
-      }
+  describe('Jest read-only mode for emulators (propagated) switch', () => {
+    const expectReadOnlyEmulatorsArg = ({value}) => expect(mockExec).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        env: expect.objectContaining({
+          readOnlyEmu: value,
+        }),
+      })
+    );
+
+    const expectNoReadOnlyEmulatorsArg = () => expect(mockExec).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        env: expect.not.objectContaining({
+          readOnlyEmu: expect.anything(),
+        }),
+      })
+    );
+
+    it('should be enabled if more than 1 worker is set', async () => {
+      mockAndroidJestConfiguration();
+      await callCli('./test', 'test --workers 2');
+      expectReadOnlyEmulatorsArg({value: true});
     });
 
+    it('should be disabled by default', async () => {
+      mockAndroidJestConfiguration();
+      await callCli('./test', 'test');
+      expectReadOnlyEmulatorsArg({value: false});
+    });
+
+    it('should be disabled on iOS', async () => {
+      mockIOSJestConfiguration();
+      await callCli('./test', 'test --workers 2');
+      expectNoReadOnlyEmulatorsArg();
+    });
+  });
+
+  it('sets default value for debugSynchronization', async () => {
+    mockAndroidMochaConfiguration();
+
     try {
       await callCli('./test', 'test --debug-synchronization');
     } catch (e) {
@@ -224,13 +205,7 @@ describe('test', () => {
   });
 
   it('passes extra args to the test runner', async () => {
-    mockPackageJson({
-      configurations: {
-        only: {
-          type: 'android.emulator'
-        }
-      }
-    });
+    mockAndroidMochaConfiguration();
 
     try {
       process.argv = [...process.argv, '--unknown-property', '42', '--flag'];
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index 08204e001..a758f7a50 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -90,6 +90,13 @@ describe('Device', () => {
     return schemeDevice(validScheme, 'ios.sim.release');
   }
 
+  it('should return the name from the driver', async () => {
+    driverMock.driver.name = 'mock-device-name-from-driver';
+
+    const device = validDevice();
+    expect(device.name).toEqual('mock-device-name-from-driver');
+  });
+
   describe('prepare()', () => {
     it(`valid scheme, no binary, should throw`, async () => {
       const device = validDevice();
diff --git a/detox/src/devices/DeviceRegistry.test.js b/detox/src/devices/DeviceRegistry.test.js
index 517d68b11..d865f0ef0 100644
--- a/detox/src/devices/DeviceRegistry.test.js
+++ b/detox/src/devices/DeviceRegistry.test.js
@@ -16,10 +16,6 @@ describe('DeviceRegistry', () => {
 
     jest.mock('proper-lockfile');
 
-    jest.mock('../utils/environment', () => ({
-      getDeviceLockFilePath: jest.fn().mockReturnValue('lockfile-path/mock'),
-    }));
-
     createDevice = jest.fn();
     getDeviceIdsByType = jest.fn();
   });
@@ -31,7 +27,11 @@ describe('DeviceRegistry', () => {
 
   function deviceRegistry() {
     const DeviceRegistry = require('./DeviceRegistry');
-    const registry = new DeviceRegistry({getDeviceIdsByType, createDevice});
+    const registry = new DeviceRegistry({
+      getDeviceIdsByType,
+      createDevice,
+      lockfile: 'lockfile-path/mock'
+    });
     registry.clear();
     return registry;
   }
@@ -48,6 +48,18 @@ describe('DeviceRegistry', () => {
       expect(createDevice).toHaveBeenCalledWith('iPhone X');
     });
 
+    it(`should query for available devices by type`, async () => {
+      const devices = mockDeviceList('iPhone X', 2);
+      const busyDevices = mockDeviceList('iPhone X', 1);
+      mockDevices(devices);
+      mockBusyDevices(busyDevices);
+
+      const registry = deviceRegistry();
+      await registry.getDevice('iPhone X');
+
+      expect(getDeviceIdsByType).toHaveBeenCalledWith('iPhone X', busyDevices);
+    });
+
     it(`should not create device if there's no device available`, async () => {
       const devices = mockDeviceList('iPhone X', 1);
       mockDevices(devices);
@@ -73,7 +85,7 @@ describe('DeviceRegistry', () => {
     it(`should create a lockfile if none exists`, async () => {
       fs.existsSync.mockReturnValue(false);
 
-      const registry = deviceRegistry();
+      deviceRegistry();
 
       expect(fs.ensureFileSync).toHaveBeenCalledWith('lockfile-path/mock');
       expect(fs.writeFileSync).toHaveBeenCalledWith('lockfile-path/mock', "[]");
diff --git a/detox/src/devices/android/ADB.test.js b/detox/src/devices/android/ADB.test.js
index 42e3846c3..2dd071d70 100644
--- a/detox/src/devices/android/ADB.test.js
+++ b/detox/src/devices/android/ADB.test.js
@@ -50,13 +50,47 @@ describe('ADB', () => {
       expect(exec).toHaveBeenCalledTimes(1);
     });
 
-    it(`should parse emulator devices`, async () => {
+    it(`should query emulator devices' name via telnet`, async () => {
       exec.mockReturnValue({
-        stdout: 'List of devices attached\nemulator-5554\tdevice\nemulator-5555\tdevice\n'
+        stdout: 'List of devices attached\n'
+          + 'emulator-5554\tdevice\n'
+          + 'emulator-5555\tdevice\n'
+          + '\n',
       });
 
       await adb.devices();
       expect(mockEmulatorTelnet.connect).toHaveBeenCalledWith("5554");
+      expect(mockEmulatorTelnet.connect).toHaveBeenCalledWith("5555");
+    });
+
+    it(`Parse 'adb device' output with devices of all kinds`, async () => {
+      const adbDevicesConsoleOutput = "List of devices attached\n"
+        + "192.168.60.101:5555\tdevice\n"
+        + "emulator-5556\tdevice\n"
+        + "emulator-5554\tdevice\n"
+        + "sx432wsds\tdevice\n"
+        + "\n";
+      exec.mockReturnValue({
+        stdout: adbDevicesConsoleOutput
+      });
+
+      const parsedDevices = [
+        { "adbName": "192.168.60.101:5555", "name": "192.168.60.101:5555", "type": "genymotion" },
+        { "adbName": "emulator-5556", "name": undefined, "port": "5556", "type": "emulator" },
+        { "adbName": "emulator-5554", "name": undefined, "port": "5554", "type": "emulator" },
+        { "adbName": "sx432wsds", "name": "sx432wsds", "type": "device" }];
+
+      const actual = await adb.devices();
+      expect(actual).toEqual(parsedDevices);
+    });
+
+    it(`should return an empty list if no devices are available`, async () => {
+      exec.mockReturnValue({
+        stdout: 'List of devices attached\n'
+      });
+
+      const devices = await adb.devices();
+      expect(devices.length).toEqual(0);
     });
 
     it(`should abort if port can't be parsed`, async () => {
@@ -75,7 +109,7 @@ describe('ADB', () => {
       }
     });
 
-    it(`should skip if no devices are available`, async () => {
+    it(`should skip telnet if no devices are available`, async () => {
       exec.mockReturnValue({
         stdout: 'List of devices attached\n'
       });
@@ -85,13 +119,36 @@ describe('ADB', () => {
     });
   });
 
+  describe('lookup device', () => {
+    it('should look up a device by matcher', async () => {
+      const adbDevices = 'List of devices attached\n'
+        + 'MOCK_SERIAL\tdevice\n'
+        + '192.168.60.101:6666\tdevice\n'
+        + 'emulator-5554\tdevice\n'
+        + 'emulator-5556\tdevice\n'
+        + '\n';
+      const matcher = jest.fn()
+        .mockReturnValueOnce(false)
+        .mockReturnValueOnce(false)
+        .mockReturnValueOnce(true);
+
+      exec.mockReturnValue({
+        stdout: adbDevices,
+      });
+
+      const result = await adb.findDevice(matcher);
+
+      expect(result.adbName).toEqual('emulator-5554');
+      expect(matcher).toHaveBeenCalledTimes(3);
+    });
+  });
+
   it(`install`, async () => {
     await adb.install('emulator-5556', 'path inside "quotes" to/app');
 
     expect(exec).toHaveBeenCalledWith(
       expect.stringContaining('adb -s emulator-5556 shell "getprop ro.build.version.sdk"'),
-      undefined, undefined, 1
-    );
+      {}, undefined, 5);
 
     expect(exec).toHaveBeenCalledWith(
       expect.stringContaining('adb -s emulator-5556 install -rg "path inside \\"quotes\\" to/app"'),
@@ -188,27 +245,6 @@ describe('ADB', () => {
     expect(adb.shell).toBeCalledWith(deviceId, 'pm list instrumentation');
   });
 
-  it(`Parse 'adb device' output`, async () => {
-    const adbDevicesConsoleOutput = "List of devices attached\n"
-      + "192.168.60.101:5555\tdevice\n"
-      + "emulator-5556\tdevice\n"
-      + "emulator-5554\tdevice\n"
-      + "sx432wsds\tdevice\n"
-      + "\n";
-
-    const spyDevices = jest.spyOn(adb, 'devices');
-    spyDevices.mockReturnValue(Promise.resolve(adbDevicesConsoleOutput));
-
-    const parsedDevices = [
-      { "adbName": "192.168.60.101:5555", "name": "192.168.60.101:5555", "type": "genymotion" },
-      { "adbName": "emulator-5556", "name": undefined, "port": "5556", "type": "emulator" },
-      { "adbName": "emulator-5554", "name": undefined, "port": "5554", "type": "emulator" },
-      { "adbName": "sx432wsds", "name": "sx432wsds", "type": "device" }];
-
-    const actual = await adb.parseAdbDevicesConsoleOutput(adbDevicesConsoleOutput);
-    expect(actual).toEqual(parsedDevices);
-  });
-
   it(`getInstrumentationRunner parses the correct runner for the package`, async () => {
     const expectedRunner = "com.example.android.apis/.app.LocalSampleInstrumentation";
     const expectedPackage = "com.example.android.apis";
diff --git a/detox/src/devices/drivers/SimulatorDriver.test.js b/detox/src/devices/drivers/SimulatorDriver.test.js
index ccd83ccd9..14b7f3952 100644
--- a/detox/src/devices/drivers/SimulatorDriver.test.js
+++ b/detox/src/devices/drivers/SimulatorDriver.test.js
@@ -52,27 +52,27 @@ describe('IOS simulator driver', () => {
     it('enrolls in biometrics by passing to AppleSimUtils', async () => {
       await sim.setBiometricEnrollment(deviceId, 'YES');
       expect(sim.applesimutils.setBiometricEnrollment).toHaveBeenCalledWith(deviceId, 'YES');
-    })
+    });
 
     it('disenrolls in biometrics by passing to AppleSimUtils', async () => {
       await sim.setBiometricEnrollment(deviceId, 'NO');
       expect(sim.applesimutils.setBiometricEnrollment).toHaveBeenCalledWith(deviceId, 'NO');
-    })
+    });
 
     it('matches a face by passing to AppleSimUtils', async () => {
       await sim.matchFace(deviceId);
       expect(sim.applesimutils.matchBiometric).toHaveBeenCalledWith(deviceId, 'Face');
-    })
+    });
 
     it('fails to match a face by passing to AppleSimUtils', async () => {
       await sim.unmatchFace(deviceId);
       expect(sim.applesimutils.unmatchBiometric).toHaveBeenCalledWith(deviceId, 'Face');
-    })
+    });
 
     it('matches a face by passing to AppleSimUtils', async () => {
       await sim.matchFinger(deviceId);
       expect(sim.applesimutils.matchBiometric).toHaveBeenCalledWith(deviceId, 'Finger');
-    })
+    });
 
     it('fails to match a face by passing to AppleSimUtils', async () => {
       await sim.unmatchFinger(deviceId);
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index b421008bf..b2a93b0c8 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -13,6 +13,7 @@ describe('index', () => {
           id: jest.fn(),
         };
       }),
+      deviceName: jest.fn(),
       cleanup: jest.fn(),
       beforeEach: jest.fn(),
       afterEach: jest.fn(),
@@ -198,7 +199,7 @@ describe('index', () => {
     expect(detox.init(schemes.validOneDeviceNoSession)).rejects.toThrow();
     expect(mockDetox.cleanup).toHaveBeenCalled();
   });
-
+  
   it(`beforeEach() should be covered - with detox initialized`, async() => {
     await detox.init(schemes.validOneDeviceNoSession);
     await detox.beforeEach();
@@ -216,4 +217,5 @@ describe('index', () => {
   it(`afterEach() should be covered - with detox not initialized`, async() => {
     await detox.afterEach();
   });
+
 });
diff --git a/detox/test/e2e/23.flows.test.js b/detox/test/e2e/23.flows.test.js
index 2b6351e3d..48e22f929 100644
--- a/detox/test/e2e/23.flows.test.js
+++ b/detox/test/e2e/23.flows.test.js
@@ -1,6 +1,7 @@
 describe('Flows', () => {
+
   it('should exit without timeouts if app was terminated inside test', async () => {
-    await device.launchApp();
+    await device.launchApp({newInstance: true});
     await device.terminateApp();
   });
 });
diff --git a/detox/test/e2e/init.js b/detox/test/e2e/init.js
index ffed168cc..31e4760a4 100644
--- a/detox/test/e2e/init.js
+++ b/detox/test/e2e/init.js
@@ -2,6 +2,7 @@ const detox = require('detox');
 const config = require('../package.json').detox;
 const adapter = require('detox/runners/jest/adapter');
 const specReporter = require('detox/runners/jest/specReporter');
+const assignReporter = require('detox/runners/jest/assignReporter');
 
 jasmine.getEnv().addReporter(adapter);
 
@@ -9,8 +10,12 @@ jasmine.getEnv().addReporter(adapter);
 // This is strictly optional.
 jasmine.getEnv().addReporter(specReporter);
 
+// This will post which device has assigned to run a suite, which can be useful in a multiple-worker tests run.
+// This is strictly optional.
+jasmine.getEnv().addReporter(assignReporter);
+
 // Set the default timeout
-jest.setTimeout(300000);
+jest.setTimeout(360000);
 
 beforeAll(async () => {
   await detox.init(config);
diff --git a/detox/test/package.json b/detox/test/package.json
index 9abc50cc8..0d13bd9b4 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -13,7 +13,7 @@
     "e2e:ios-ci": "detox test --configuration ios.sim.release --workers 3 --debug-synchronization --take-screenshots all --record-logs all -- --coverage",
     "e2e:android": "detox test --configuration android.emu.release",
     "e2e:android-debug": "detox test --configuration android.emu.debug",
-    "e2e:android-ci": "detox test e2e --configuration android.emu.release --take-screenshots all --record-logs all --headless --loglevel verbose -- --coverage",
+    "e2e:android-ci": "detox test e2e --configuration android.emu.release --workers 3 --take-screenshots all --record-logs all --headless --loglevel verbose -- --coverage",
     "build:ios": "detox build --configuration ios.sim.release",
     "build:android": "detox build --configuration android.emu.release",
     "build:android-debug": "detox build --configuration android.emu.debug",