Repository: wix/Detox (js)

Issue:
TypeError: Cannot read property 'identifier' of undefined
**Description**
I have a problem from this lib during running detox tests, simulators for react native works okay. 


**Screenshots**
detox[17229] ERROR: [DetoxExportWrapper.js/DETOX_INIT_ERROR] 
 TypeError: Cannot read property 'identifier' of undefined
    at AppleSimUtils.create (/.../node_modules/detox/src/devices/ios/AppleSimUtils.js: 100:100)

**Environment**
 - macOS version: 10.14.5
 - Xcode version: 10.3

Before Xcode update I didn't have this issue 

Pull request:
refactor: device selection revisited
- [x] This change has been discussed in issues #853, #1103,  #1565 and pull requests #905, #1404 .

This pull request prepares the ground to finally deal with #853 and also should potentially resolve #1565. 

The consequent PR will add `device` property to a configuration, which can be configured in a more powerful way.

**Changes:**

1. **AppleSimUtils**: make it thinner and more explicit, therefore the search method is called now `list` (similar to the CLI). The query handler logic (`iPhone X, iOS 12.0`) is moved 1 layer upwards, to SimulatorDriver.

2. **SimulatorDriver**: logic of search remains intact - device type (+ device os, if specified via comma).

3. **AppleSimUtils**: remove the dependency on a heavy and non-maintainable direct call `xcrun simctl list -j`, by allowing `new AppleSimUtils().create(device)` to accept a "prototype" for a device you want to create. I pick it from the result the `list()` method returns as-is, and pass it to `create()`.

4. **SimulatorDriver**: remove redundant calls to applesimutils, querying the same things twice when creating a device. **Important:** *now, when creating a device, it takes an OS version of the first matching but a busy device.*

5. **AndroidDriver**: remove redundant Telnet queries to busy devices. That can happen, because getting a name now done explicitly, via `queryName()` method, so we can postpone it till the very last moment.

6. **ADB**: recompose an async-iterator-like ADB output parser to lazy DeviceHandle classes, to allow bullet (5) to happen.

7. **DeviceRegistry**: refactor to accommodate changes to `SimulatorDriver` and `AndroidDriver`. API was simplified to just `allocateDevice` and `disposeDevice`. This way, we remove 2-way connecting between *Driver -> DeviceRegistry -> injected methods of *Driver, therefore we have better maintainability.

TODO(@noomorph): conduct some more manual QA.

Patch:
diff --git a/detox/src/devices/DeviceRegistry.js b/detox/src/devices/DeviceRegistry.js
index b13adb759..7546a9776 100644
--- a/detox/src/devices/DeviceRegistry.js
+++ b/detox/src/devices/DeviceRegistry.js
@@ -1,89 +1,63 @@
 const _ = require('lodash');
-const fs = require('fs-extra');
-const plockfile = require('proper-lockfile');
-const retry = require('../utils/retry');
-
-const LOCK_RETRY_OPTIONS = {retries: 10000, interval: 5};
+const ExclusiveLockfile = require('../utils/ExclusiveLockfile');
+const safeAsync = require('../utils/safeAsync');
 
 class DeviceRegistry {
-
-  constructor({getDeviceIdsByType, createDevice, lockfile}) {
-    this.getDeviceIdsByType = getDeviceIdsByType;
-    this.createDevice = createDevice;
-    this.lockfile = lockfile;
-    this._createEmptyLockFileIfNeeded();
-  }
-
-  async freeDevice(deviceId) {
-    await this._lock();
-    const lockedDevices = this._getBusyDevices();
-    _.remove(lockedDevices, lockedDeviceId => lockedDeviceId === deviceId);
-    this._writeBusyDevicesToLockFile(lockedDevices);
-    await this._unlock();
-  }
-
-  async getDevice(deviceType) {
-    await this._lock();
-
-    let deviceId = await this._getFreeDevice(deviceType);
-    if (!deviceId) {
-      deviceId = await this.createDevice(deviceType);
-    }
-
-    const busyDevices = this._getBusyDevices();
-    busyDevices.push(deviceId);
-    this._writeBusyDevicesToLockFile(busyDevices);
-    await this._unlock();
-    return deviceId;
-  }
-
-  async _lock() {
-    await retry(LOCK_RETRY_OPTIONS, () => plockfile.lockSync(this.lockfile));
+  constructor({ lockfilePath }) {
+    /***
+     * @protected
+     * @type {ExclusiveLockfile}
+     */
+    this._lockfile = new ExclusiveLockfile(lockfilePath, {
+      getInitialState: this._getInitialLockFileState.bind(this),
+    });
   }
 
-  async _unlock() {
-    await plockfile.unlockSync(this.lockfile);
+  /***
+   * @param {string|Function} getDeviceId
+   * @returns {Promise<string>}
+   */
+  async allocateDevice(getDeviceId) {
+    return this._lockfile.exclusively(async () => {
+      const deviceId = await safeAsync(getDeviceId);
+      this._toggleDeviceStatus(deviceId, true);
+      return deviceId;
+    });
   }
 
-  clear() {
-    this._writeBusyDevicesToLockFile([]);
+  /***
+   * @param {string|Function} getDeviceId
+   * @returns {void}
+   */
+  async disposeDevice(getDeviceId) {
+    await this._lockfile.exclusively(async () => {
+      const deviceId = await safeAsync(getDeviceId);
+      this._toggleDeviceStatus(deviceId, false);
+    });
   }
 
-  async isBusy(deviceId) {
-    await this._lock();
-    const isBusy = this._getBusyDevices().includes(deviceId);
-
-    await this._unlock();
-    return isBusy;
+  isDeviceBusy(deviceId) {
+    return this._lockfile.read().includes(deviceId);
   }
 
-  _createEmptyLockFileIfNeeded() {
-    if (!fs.existsSync(this.lockfile)) {
-      fs.ensureFileSync(this.lockfile);
-      this._writeBusyDevicesToLockFile([]);
-    }
+  /***
+   * @private
+   */
+  _getInitialLockFileState() {
+    return [];
   }
 
-  _writeBusyDevicesToLockFile(lockedDevices) {
-    fs.writeFileSync(this.lockfile, JSON.stringify(lockedDevices));
-  }
-
-  _getBusyDevices() {
-    this._createEmptyLockFileIfNeeded();
-    const lockFileContent = fs.readFileSync(this.lockfile, 'utf-8');
-    return JSON.parse(lockFileContent);
-  }
+  /***
+   * @private
+   */
+  _toggleDeviceStatus(deviceId, busy) {
+    const state = this._lockfile.read();
 
-  async _getFreeDevice(deviceType) {
-    const busyDevices = await this._getBusyDevices();
-    const deviceIds = await this.getDeviceIdsByType(deviceType, busyDevices);
+    const newState = busy
+      ? _.concat(state, deviceId)
+      : _.without(state, deviceId);
 
-    for (let i = 0; i < deviceIds.length; i++) {
-      let deviceId = deviceIds[i];
-      if (!this._getBusyDevices().includes(deviceId)) {
-        return deviceId;
-      }
-    }
+    this._lockfile.write(newState);
   }
 }
 
diff --git a/detox/src/devices/android/ADB.js b/detox/src/devices/android/ADB.js
index 4fdf40247..be9be26ff 100644
--- a/detox/src/devices/android/ADB.js
+++ b/detox/src/devices/android/ADB.js
@@ -5,6 +5,7 @@ const {escape} = require('../../utils/pipeCommands');
 const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const EmulatorTelnet = require('./EmulatorTelnet');
 const {getAdbPath} = require('../../utils/environment');
+const {encodeBase64} = require('../../utils/encoding');
 
 class ADB {
   constructor() {
@@ -13,22 +14,24 @@ class ADB {
   }
 
   async devices() {
-    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;
+    const {stdout} = await this.adbCmd('', 'devices', { verbosity: 'high' });
+    const devices = _.chain(stdout)
+      .trim()
+      .split('\n')
+      .slice(1)
+      .map(s => _.trim(s))
+      .map(s => s.startsWith('emulator-')
+        ? new EmulatorHandle(s)
+        : new DeviceHandle(s))
+      .value();
+
+    for (const device of devices) {
+      if (device.type === 'emulator') {
+        assertEmulatorHasPort(device, stdout);
       }
     }
-    return undefined;
+
+    return { devices, stdout };
   }
 
   async unlockScreen(deviceId) {
@@ -253,11 +256,6 @@ 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}`;
@@ -274,75 +272,72 @@ class ADB {
     const serial = deviceId ? ['-s', deviceId] : [];
     return spawnAndLog(this.adbBin, [...serial, ...params]);
   }
-}
-
-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++]);
+  static inferDeviceType(adbName) {
+    if (adbName.startsWith('emulator-')) {
+      return 'emulator';
     }
-    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};
+    if ((/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(adbName))) {
+      return 'genymotion';
     }
-    return device;
-  }
-}
 
-function isEmulator(deviceAdbName) {
-  return _.includes(deviceAdbName, 'emulator-');
+    return 'device';
+  }
 }
 
-function isGenymotion(adbName) {
-  return (/^((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:)){4}[0-9]{4}/.test(adbName));
-}
+function assertEmulatorHasPort(device, stdout) {
+  if (device.port) {
+    return;
+  }
 
-function _reportTelnetPortResolutionError(input, devicesList, deviceAdbName, port) {
-  const {encodeBase64} = require('../../utils/encoding');
   const errorMessage = [
-    `Failed to determine telnet port for emulator device '${deviceAdbName}'!`,
+    `Failed to determine telnet port for emulator device '${device.adbName}'!`,
     `Please help us out by reporting dump below in: https://github.com/wix/Detox/issues/1427`,
     `------ BEGIN DUMP ------`,
-    `adb devices base64: ${encodeBase64(input)}`,
-    `adb devices: ${input}`,
-    `devicesList: ${devicesList}`,
-    `port: ${port}`,
+    `adb devices base64: ${encodeBase64(stdout)}`,
+    `adb devices: ${stdout}`,
+    `port: ${device.port}`,
     `------  END DUMP  ------`,
   ].join('\n');
 
   throw new Error(errorMessage);
 }
 
+class DeviceHandle {
+  constructor(deviceString) {
+    const [adbName, status] = deviceString.split('\t');
+    this.type = ADB.inferDeviceType(adbName);
+    this.adbName = adbName;
+    this.status = status;
+  }
+}
+
+class EmulatorHandle extends DeviceHandle {
+  constructor(deviceString) {
+    super(deviceString);
+
+    this.port = this.adbName.split('-')[1];
+  }
+
+  queryName() {
+    if (!this._name) {
+      this._name = this._queryNameViaTelnet();
+    }
+
+    return this._name;
+  }
+
+  async _queryNameViaTelnet() {
+    const telnet = new EmulatorTelnet();
+
+    await telnet.connect(this.port);
+    try {
+      return await telnet.avdName();
+    } finally {
+      await telnet.quit();
+    }
+  }
+}
+
 module.exports = ADB;
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index f36094805..e0a921905 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -148,27 +148,6 @@ class AndroidDriver extends DeviceDriverBase {
     return this.uiDevice;
   }
 
-  async findDeviceId(filter) {
-    const adbDevices = await this.adb.devices();
-    const filteredDevices = _.filter(adbDevices, filter);
-
-    let adbName;
-    switch (filteredDevices.length) {
-      case 1:
-        const adbDevice = filteredDevices[0];
-        adbName = adbDevice.adbName;
-        break;
-      case 0:
-        throw new Error(`Could not find '${filter.name}' on the currently ADB attached devices: '${JSON.stringify(adbDevices)}', 
-      try restarting adb 'adb kill-server && adb start-server'`);
-        break;
-      default:
-        throw new Error(`Got more than one device corresponding to the name: ${filter.name}. Current ADB attached devices: ${JSON.stringify(adbDevices)}`);
-    }
-
-    return adbName;
-  }
-
   async setURLBlacklist(urlList) {
     const call = EspressoDetoxApi.setURLBlacklist(urlList);
     await this.invocationManager.execute(call);
diff --git a/detox/src/devices/drivers/AttachedAndroidDriver.js b/detox/src/devices/drivers/AttachedAndroidDriver.js
index c429359ad..dfbeb6604 100644
--- a/detox/src/devices/drivers/AttachedAndroidDriver.js
+++ b/detox/src/devices/drivers/AttachedAndroidDriver.js
@@ -1,4 +1,5 @@
 const AndroidDriver = require('./AndroidDriver');
+const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 
 class AttachedAndroidDriver extends AndroidDriver {
 
@@ -11,12 +12,23 @@ class AttachedAndroidDriver extends AndroidDriver {
     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;
+  async acquireFreeDevice(adbName) {
+    const { devices, stdout } = await this.adb.devices();
+
+    if (!devices.some(d => d.adbName === adbName)) {
+      throw new DetoxRuntimeError({
+        message: `Could not find '${adbName}' on the currently ADB attached devices:`,
+        debugInfo: stdout,
+        hint: `Make sure your device is connected.\n` +
+              `You can also try restarting adb with 'adb kill-server && adb start-server'.`
+      });
+    }
+
+    await this.adb.apiLevel(adbName);
+    await this.adb.unlockScreen(adbName);
+    this._name = adbName;
+
+    return adbName;
   }
 }
 
diff --git a/detox/src/devices/drivers/EmulatorDriver.js b/detox/src/devices/drivers/EmulatorDriver.js
index 3d926f593..ab1046c9d 100644
--- a/detox/src/devices/drivers/EmulatorDriver.js
+++ b/detox/src/devices/drivers/EmulatorDriver.js
@@ -1,15 +1,15 @@
 const _ = require('lodash');
-const path = require('path');
-const ini = require('ini');
 const fs = require('fs');
+const path = require('path');
 const os = require('os');
+const ini = require('ini');
+const AndroidDriver = require('./AndroidDriver');
+const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
+const DeviceRegistry = require('../DeviceRegistry');
 const Emulator = require('../android/Emulator');
 const EmulatorTelnet = require('../android/EmulatorTelnet');
-const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const environment = require('../../utils/environment');
 const retry = require('../../utils/retry');
-const AndroidDriver = require('./AndroidDriver');
-const DeviceRegistry = require('../DeviceRegistry');
 
 const DetoxEmulatorsPortRange = {
   min: 10000,
@@ -22,9 +22,7 @@ class EmulatorDriver extends AndroidDriver {
 
     this.emulator = new Emulator();
     this.deviceRegistry = new DeviceRegistry({
-      getDeviceIdsByType: this._getDeviceIdsByType.bind(this),
-      createDevice: this._createDevice.bind(this),
-      lockfile: environment.getDeviceLockFilePathAndroid(),
+      lockfilePath: environment.getDeviceLockFilePathAndroid(),
     });
     this.pendingBoots = {};
     this._name = 'Unspecified Emulator';
@@ -38,7 +36,25 @@ class EmulatorDriver extends AndroidDriver {
     await this._validateAvd(avdName);
     await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
 
-    const adbName = await this.deviceRegistry.getDevice(avdName);
+    const adbName = await this.deviceRegistry.allocateDevice(async () => {
+      let freeEmulatorAdbName;
+
+      const { devices } = await this.adb.devices();
+      for (const candidate of devices) {
+        const isEmulator = candidate.type === 'emulator';
+        const isBusy = this.deviceRegistry.isDeviceBusy(candidate.adbName);
+
+        if (isEmulator && !isBusy) {
+          if (await candidate.queryName() === avdName) {
+            freeEmulatorAdbName = candidate.adbName;
+            break;
+          }
+        }
+      }
+
+      return freeEmulatorAdbName || this._createDevice();
+    });
+
     await this._boot(avdName, adbName);
 
     await this.adb.apiLevel(adbName);
@@ -49,7 +65,7 @@ class EmulatorDriver extends AndroidDriver {
   }
 
   async cleanup(adbName, bundleId) {
-    await this.deviceRegistry.freeDevice(adbName);
+    await this.deviceRegistry.disposeDevice(adbName);
     await super.cleanup(adbName, bundleId);
   }
 
@@ -121,27 +137,6 @@ class EmulatorDriver extends AndroidDriver {
     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;
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/SimulatorDriver.js
index 040f39398..49e04cb55 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/SimulatorDriver.js
@@ -1,11 +1,12 @@
-const exec = require('child-process-promise').exec;
-const path = require('path');
-const fs = require('fs');
 const _ = require('lodash');
+const fs = require('fs');
+const path = require('path');
+const exec = require('child-process-promise').exec;
+const DeviceRegistry = require('../DeviceRegistry');
 const IosDriver = require('./IosDriver');
 const configuration = require('../../configuration');
+const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const environment = require('../../utils/environment');
-const DeviceRegistry = require('../DeviceRegistry');
 const argparse = require('../../utils/argparse');
 
 class SimulatorDriver extends IosDriver {
@@ -14,9 +15,7 @@ class SimulatorDriver extends IosDriver {
     super(config);
 
     this.deviceRegistry = new DeviceRegistry({
-      getDeviceIdsByType: async type => await this.applesimutils.findDevicesUDID(type),
-      createDevice: type => this.applesimutils.create(type),
-      lockfile: environment.getDeviceLockFilePathIOS(),
+      lockfilePath: environment.getDeviceLockFilePathIOS(),
     });
 
     this._name = 'Unspecified Simulator';
@@ -36,19 +35,18 @@ class SimulatorDriver extends IosDriver {
   }
 
   async cleanup(deviceId, bundleId) {
-    await this.deviceRegistry.freeDevice(deviceId);
+    await this.deviceRegistry.disposeDevice(deviceId);
     await super.cleanup(deviceId, bundleId);
   }
 
-  async acquireFreeDevice(name) {
-    const deviceId = await this.deviceRegistry.getDevice(name);
-    if (deviceId) {
+  async acquireFreeDevice(query) {
+    return this.deviceRegistry.allocateDevice(async () => {
+      const deviceId = await this._findOrCreateDevice(query);
       await this._boot(deviceId);
-    } else {
-      console.error('Unable to acquire free device ', name);
-    }
-    this._name = `${deviceId || 'UNKNOWN_DEVICE_ID'} (${name})`;
-    return deviceId;
+
+      this._name = `${deviceId} (${query})`;
+      return deviceId;
+    });
   }
 
   async getBundleIdFromBinary(appPath) {
@@ -157,6 +155,75 @@ class SimulatorDriver extends IosDriver {
   async waitForBackground() {
     return await this.client.waitForBackground();
   }
+
+  /***
+   * @private
+   * @param deviceQuery
+   * @returns {Promise<String>}
+   */
+  async _findOrCreateDevice(deviceQuery) {
+    let udid;
+
+    const { free, busy } = await this._groupDevicesByStatus(deviceQuery);
+
+    if (_.isEmpty(free)) {
+      const prototypeDevice = busy[0];
+      udid = this.applesimutils.create(prototypeDevice);
+    } else {
+      udid = free[0].udid;
+    }
+
+    if (!udid) {
+      throw new Error(`Failed to find device matching "${deviceQuery}"`);
+    }
+
+    return udid;
+  }
+
+  async _groupDevicesByStatus(query) {
+    const searchResults = await this._queryDevices(query);
+
+    const { busy, free}  = _.groupBy(searchResults, device => {
+      return this.deviceRegistry.isDeviceBusy(device.udid)
+        ? 'busy'
+        : 'free';
+    });
+
+    const targetOS = _.get(busy, '0.os.identifier');
+    const isMatching = targetOS && { os: { identifier: targetOS } };
+
+    return {
+      busy: _.filter(busy, isMatching),
+      free: _.filter(free, isMatching),
+    }
+  }
+
+  async _queryDevices(query) {
+    let byType, byOS, searchCriteria;
+
+    if (_.includes(query, ',')) {
+      [byType, byOS] = _.split(query, /\s*,\s*/);
+      searchCriteria = `type "${byType}" with "${byOS}"`;
+    } else {
+      byType = query;
+      searchCriteria = `type "${byType}"`;
+    }
+
+    const result = await this.applesimutils.list(
+      { byType, byOS },
+      `Searching for device of ${searchCriteria}...`
+    );
+
+    if (_.isEmpty(result)) {
+      throw new DetoxRuntimeError({
+        message: `Failed to find a device of ${searchCriteria}`,
+        hint: `Run 'applesimutils --list' to list your supported devices. ` +
+              `It is advised only to specify a device type, e.g., "iPhone Xʀ" and avoid explicit search by OS version.`
+      });
+    }
+
+    return result;
+  }
 }
 
 module.exports = SimulatorDriver;
diff --git a/detox/src/devices/ios/AppleSimUtils.js b/detox/src/devices/ios/AppleSimUtils.js
index 84797e904..8b34e54be 100644
--- a/detox/src/devices/ios/AppleSimUtils.js
+++ b/detox/src/devices/ios/AppleSimUtils.js
@@ -1,4 +1,5 @@
 const _ = require('lodash');
+const {joinArgs} = require('../../utils/argparse');
 const exec = require('../../utils/exec');
 const log = require('../../utils/logger').child({ __filename });
 const environment = require('../../utils/environment');
@@ -18,46 +19,13 @@ class AppleSimUtils {
     }, statusLogs, 1);
   }
 
-  async findDeviceUDID(query) {
-    const udids = await this.findDevicesUDID(query);
-    return udids[0];
-  }
-
-  async findDevicesUDID(query) {
-    const statusLogs = {
-      trying: `Searching for device matching ${query}...`
-    };
-
-    let type;
-    let os;
-    if (_.includes(query, ',')) {
-      const parts = _.split(query, ',');
-      type = parts[0].trim();
-      os = parts[1].trim();
-    } else {
-      type = query;
-      const deviceInfo = await this.deviceTypeAndNewestRuntimeFor(query);
-      os = deviceInfo.newestRuntime.version;
-    }
-
-    const response = await this._execAppleSimUtils({ args: `--list --byType "${type}" --byOS "${os}"`}, statusLogs, 1);
+  async list(query, options = {}) {
+    const args = `--list ${joinArgs(query)}`;
+    const statusLogs = options.trying ? { trying: options.trying } : undefined;
+    const response = await this._execAppleSimUtils({ args }, statusLogs,  1);
     const parsed = this._parseResponseFromAppleSimUtils(response);
-    const udids = _.map(parsed, 'udid');
-    if (!udids || !udids.length || !udids[0]) {
-      throw new Error(`Can't find a simulator to match with "${query}", run 'xcrun simctl list' to list your supported devices.
-      It is advised to only state a device type, and not to state iOS version, e.g. "iPhone 7"`);
-    }
-    return udids;
-  }
 
-  async findDeviceByUDID(udid) {
-    const response = await this._execAppleSimUtils({args: `--list --byId "${udid}"`}, undefined, 1);
-    const parsed = this._parseResponseFromAppleSimUtils(response);
-    const device = _.find(parsed, (device) => _.isEqual(device.udid, udid));
-    if (!device) {
-      throw new Error(`Can't find device ${udid}`);
-    }
-    return device;
+    return parsed;
   }
 
   /***
@@ -80,29 +48,38 @@ class AppleSimUtils {
   }
 
   async isBooted(udid) {
-    const device = await this.findDeviceByUDID(udid);
+    const device = await this._findDeviceByUDID(udid);
     return (_.isEqual(device.state, 'Booted') || _.isEqual(device.state, 'Booting'));
   }
 
-  async deviceTypeAndNewestRuntimeFor(name) {
-    const result = await this._execSimctl({ cmd: `list -j` });
-    const stdout = _.get(result, 'stdout');
-    const output = JSON.parse(stdout);
-    const deviceType = _.filter(output.devicetypes, { 'name': name})[0];
-    const newestRuntime = _.maxBy(output.runtimes, r => Number(r.version));
-    return { deviceType, newestRuntime };
-  }
+  async _findDeviceByUDID(udid) {
+    const [device] = await this.list({ byId: udid, maxResults: 1 });
+    if (!device) {
+      throw new Error(`Can't find device with UDID = "${udid}"`);
+    }
 
-  async create(name) {
-    const deviceInfo = await this.deviceTypeAndNewestRuntimeFor(name);
+    return device;
+  }
 
-    if (deviceInfo.newestRuntime) {
-      const result = await this._execSimctl({cmd: `create "${name}-Detox" "${deviceInfo.deviceType.identifier}" "${deviceInfo.newestRuntime.identifier}"`});
-      const udid = _.get(result, 'stdout').trim();
-      return udid;
-    } else {
-      throw new Error(`Unable to create device. No runtime found for ${name}`);
+  /***
+   * @param deviceInfo - an item in output of `applesimutils --list`
+   * @returns {Promise<string>} UDID of a new device
+   */
+  async create(deviceInfo) {
+    const deviceName = _.get(deviceInfo, 'name');
+    const deviceTypeIdentifier = _.get(deviceInfo, 'deviceType.identifier');
+    const deviceRuntimeIdentifier = _.get(deviceInfo, 'os.identifier');
+
+    if (!deviceTypeIdentifier || !deviceRuntimeIdentifier) {
+      const deviceInfoStr = JSON.stringify(deviceInfo, null, 4);
+      throw new Error(`Unable to create device from: ${deviceInfoStr}`);
     }
+
+    const { stdout: udid } = await this._execSimctl({
+      cmd: `create "${deviceName}-Detox" "${deviceTypeIdentifier}" "${deviceRuntimeIdentifier}"`
+    });
+
+    return (udid || '').trim();
   }
 
   async install(udid, absPath) {
diff --git a/detox/src/devices/ios/applesimutils.mock.json b/detox/src/devices/ios/applesimutils.mock.json
new file mode 100644
index 000000000..af259c9ab
--- /dev/null
+++ b/detox/src/devices/ios/applesimutils.mock.json
@@ -0,0 +1,24 @@
+{
+  "--list": [
+    {
+      "deviceType" : {
+        "name" : "iPhone X",
+        "bundlePath" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Developer\/Library\/CoreSimulator\/Profiles\/DeviceTypes\/iPhone X.simdevicetype",
+        "identifier" : "com.apple.CoreSimulator.SimDeviceType.iPhone-X"
+      },
+      "state" : "Shutdown",
+      "isAvailable" : true,
+      "name" : "iPhone X",
+      "udid" : "FBA6D413-0A3D-466F-BF40-ECC019133E43",
+      "os" : {
+        "buildversion" : "16B91",
+        "bundlePath" : "\/Applications\/Xcode.app\/Contents\/Developer\/Platforms\/iPhoneOS.platform\/Developer\/Library\/CoreSimulator\/Profiles\/Runtimes\/iOS.simruntime",
+        "isAvailable" : true,
+        "name" : "iOS 12.1",
+        "identifier" : "com.apple.CoreSimulator.SimRuntime.iOS-12-1",
+        "version" : "12.1"
+      }
+    }
+  ]
+}
+
diff --git a/detox/src/utils/ExclusiveLockfile.js b/detox/src/utils/ExclusiveLockfile.js
new file mode 100644
index 000000000..5e66404d8
--- /dev/null
+++ b/detox/src/utils/ExclusiveLockfile.js
@@ -0,0 +1,140 @@
+const _ = require('lodash');
+const fs = require('fs');
+const plockfile = require('proper-lockfile');
+const retry = require('./retry');
+
+const DEFAULT_OPTIONS = {
+  retry: {retries: 10000, interval: 5},
+  read: { encoding: 'utf8' },
+  getInitialState: _.constant(null),
+};
+
+class ExclusiveLockfile {
+  constructor(lockfile, options) {
+    if (!lockfile) {
+      throw new Error('Path to the lockfile should be a non-empty string');
+    }
+
+    this._lockFilePath = lockfile;
+    this._options = _.defaultsDeep(options, DEFAULT_OPTIONS);
+
+    this._isLocked = false;
+    this._invalidate();
+  }
+
+  get options() {
+    return this._options;
+  }
+
+  /***
+   * @async
+   * @param {Function} fn
+   * @returns {Promise<any>}
+   */
+  async exclusively(fn) {
+    await this._lock();
+
+    try {
+      return (await fn());
+    } finally {
+      this._unlock();
+    }
+  }
+
+  /***
+   * @returns {any}
+   */
+  read() {
+    if (!this._isLocked) {
+      throw new Error('Forbidden to read in unlocked mode');
+    }
+
+    if (!this._hasValue) {
+      this._value = this._doRead();
+      this._hasValue = true;
+    }
+
+    return this._value;
+  }
+
+  /***
+   * @param {any} value
+   */
+  write(value) {
+    if (!this._isLocked) {
+      throw new Error('Forbidden to write in unlocked mode');
+    }
+
+    this._value = value;
+    this._hasValue = true;
+  }
+
+  /***
+   * @private
+   */
+  _invalidate() {
+    this._hasValue = false;
+    this._value = null;
+  }
+
+  /***
+   * @returns {any}
+   * @private
+   */
+  _doRead() {
+    const contents = fs.readFileSync(this._lockFilePath, this._options.read);
+    return JSON.parse(contents);
+  }
+
+  /***
+   * @param value
+   * @private
+   */
+  _doWrite(value) {
+    const contents = JSON.stringify(value);
+    fs.writeFileSync(this._lockFilePath, contents);
+  }
+
+  /***
+   * @returns {Promise<void>}
+   * @private
+   */
+  async _lock() {
+    this._ensureFileExists();
+
+    await retry(this._options.retry, () => {
+      const operationResult = plockfile.lockSync(this._lockFilePath);
+
+      this._isLocked = true;
+      this._invalidate();
+
+      return operationResult;
+    });
+  }
+
+  /***
+   * @private
+   */
+  _ensureFileExists() {
+    if (!fs.existsSync(this._lockFilePath)) {
+      const initialState = this._options.getInitialState();
+      this._doWrite(initialState);
+    }
+  }
+
+  /***
+   * @returns {Promise<void>}
+   * @private
+   */
+  async _unlock() {
+    if (this._hasValue) {
+      this._doWrite(this._value);
+    }
+
+    plockfile.unlockSync(this._lockFilePath);
+    this._isLocked = false;
+    this._invalidate();
+  }
+}
+
+module.exports = ExclusiveLockfile;
\ No newline at end of file
diff --git a/detox/src/utils/argparse.js b/detox/src/utils/argparse.js
index 59ac70f89..2f1a5939b 100644
--- a/detox/src/utils/argparse.js
+++ b/detox/src/utils/argparse.js
@@ -1,4 +1,6 @@
+const _ = require('lodash');
 const argv = require('minimist')(process.argv.slice(2));
+const {escape} = require('./pipeCommands');
 
 function getArgValue(key) {
   let value;
@@ -23,7 +25,43 @@ function getFlag(key) {
   return false;
 }
 
+const DEFAULT_JOIN_ARGUMENTS_OPTIONS = {
+  prefix: '--',
+  joiner: ' ',
+};
+
+function joinArgs(keyValues, options = DEFAULT_JOIN_ARGUMENTS_OPTIONS) {
+  const {prefix, joiner} = options === DEFAULT_JOIN_ARGUMENTS_OPTIONS
+    ? options
+    : { ...DEFAULT_JOIN_ARGUMENTS_OPTIONS, ...options };
+
+  const argArray = [];
+
+  for (const [key, value] of Object.entries(keyValues)) {
+    if (value == null) {
+      continue;
+    }
+
+    let arg = (!key.startsWith('-') ? prefix : '') + key;
+
+    if (value !== true) {
+      arg += joiner;
+
+      if (_.isString(value) && value.includes(' ')) {
+        arg += `"${escape.inQuotedString(value)}"`;
+      } else {
+        arg += value;
+      }
+    }
+
+    argArray.push(arg)
+  }
+
+  return argArray.join(' ');
+}
+
 module.exports = {
   getArgValue,
-  getFlag
+  getFlag,
+  joinArgs,
 };
diff --git a/detox/src/utils/safeAsync.js b/detox/src/utils/safeAsync.js
new file mode 100644
index 000000000..1360ea296
--- /dev/null
+++ b/detox/src/utils/safeAsync.js
@@ -0,0 +1,11 @@
+async function safeAsync(fnOrValue) {
+  if (typeof fnOrValue === 'function') {
+    const fn = fnOrValue;
+    return (await fn());
+  } else {
+    return fnOrValue;
+  }
+}
+
+module.exports = safeAsync;
+


Test patch:
diff --git a/detox/src/devices/DeviceRegistry.test.js b/detox/src/devices/DeviceRegistry.test.js
index b223da33f..402540465 100644
--- a/detox/src/devices/DeviceRegistry.test.js
+++ b/detox/src/devices/DeviceRegistry.test.js
@@ -1,128 +1,46 @@
-describe('DeviceRegistry', () => {
-  let fs;
-  let createDevice = jest.fn();
-  let getDeviceIdsByType = jest.fn();
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+const DeviceRegistry = require('./DeviceRegistry');
 
-  function mockDeviceList(type, length) {
-    const devicesIds = Array.from(Array(length).keys());
-    return devicesIds.map(deviceId => `id-${deviceId}-of-type-${type}`);
-  }
+describe('DeviceRegistry', () => {
+  let lockfilePath;
+  let registry;
 
   beforeEach(() => {
-    jest.mock('fs-extra');
-    fs = require('fs-extra');
-    fs.existsSync.mockReturnValue(true);
-    fs.readFileSync.mockReturnValue("[]");
-
-    createDevice = jest.fn();
-    getDeviceIdsByType = jest.fn();
+    lockfilePath = tempfile('.test');
+    registry = new DeviceRegistry({ lockfilePath });
   });
 
-  const mockDevices = (devices) => getDeviceIdsByType.mockReturnValue(devices);
-  const mockNoDevices = () => mockDevices([]);
-  const mockBusyDevices = (devices) => fs.readFileSync.mockReturnValue(JSON.stringify(devices));
-  const mockNoBusyDevices = () => mockBusyDevices([]);
-
-  function deviceRegistry() {
-    const DeviceRegistry = require('./DeviceRegistry');
-    const registry = new DeviceRegistry({
-      getDeviceIdsByType,
-      createDevice,
-      lockfile: 'lockfile-path/mock'
-    });
-    registry.clear();
-    return registry;
-  }
-
-  describe(`create device`, () => {
-
-    it(`should create device if there's no device available`, async () => {
-      mockNoDevices();
-
-      const registry = deviceRegistry();
-      await registry.getDevice('iPhone X');
-
-      expect(createDevice).toHaveBeenCalledTimes(1);
-      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);
-      mockNoBusyDevices();
-
-      const registry = deviceRegistry();
-      await registry.getDevice('iPhone X');
-
-      expect(createDevice).not.toHaveBeenCalled();
-    });
+  afterEach(async () => {
+    await fs.remove(lockfilePath);
+  });
 
-    it(`should create device if all available devices are busy`, async () => {
-      const devices = mockDeviceList('iPhone X', 1);
-      mockDevices(devices);
-      mockBusyDevices(devices);
+  it('should throw on attempt to checking if device is busy outside of allocation/disposal context', async () => {
+    const deviceId = 'emulator-5554';
 
-      const registry = deviceRegistry();
-      await registry.getDevice('iPhone X');
+    const assertForbiddenOutOfContext = () =>
+      expect(() => registry.isDeviceBusy(deviceId)).toThrowError();
 
-      expect(createDevice).toHaveBeenCalledTimes(1);
+    assertForbiddenOutOfContext();
+    const result = await registry.allocateDevice(() => {
+      expect(registry.isDeviceBusy(deviceId)).toBe(false);
+      return deviceId;
     });
 
-    it(`should create a lockfile if none exists`, async () => {
-      fs.existsSync.mockReturnValue(false);
+    expect(result).toBe(deviceId);
 
-      deviceRegistry();
-
-      expect(fs.ensureFileSync).toHaveBeenCalledWith('lockfile-path/mock');
-      expect(fs.writeFileSync).toHaveBeenCalledWith('lockfile-path/mock', "[]");
+    assertForbiddenOutOfContext();
+    await registry.disposeDevice(() => {
+      expect(registry.isDeviceBusy(deviceId)).toBe(true);
+      return deviceId;
     });
-  });
-
-  it('should indicate a device is busy', async () => {
-    const deviceList = mockDeviceList('iPhone X', 1);
-    const deviceId = deviceList[0];
-
-    mockDevices(deviceList);
-    mockBusyDevices(deviceList);
-
-    const registry = deviceRegistry();
-    await registry.getDevice('iPhoneX');
-    expect(await registry.isBusy(deviceId)).toBe(true);
-  });
-
-  it('should indicate a device is not busy', async () => {
-    const deviceList = mockDeviceList('iPhone X', 1);
-    const deviceId = deviceList[0];
-
-    mockDevices(deviceList);
-    mockNoBusyDevices();
-
-    const registry = deviceRegistry();
-    await registry.getDevice('iPhoneX');
-    expect(await registry.isBusy(deviceId)).toBe(false);
-  });
 
-  it('should free a busy device', async () => {
-    const deviceList = mockDeviceList('iPhone X', 1);
-    const deviceId = deviceList[0];
-    mockDevices(deviceList);
-    mockBusyDevices(deviceList);
+    assertForbiddenOutOfContext();
+    await registry.allocateDevice(() => {
+      expect(registry.isDeviceBusy(deviceId)).toBe(false);
+      throw new Error();
+    }).catch(() => {});
 
-    const registry = deviceRegistry();
-    await registry.getDevice('iPhoneX');
-    await registry.freeDevice(deviceId);
-    expect(fs.writeFileSync).toHaveBeenCalledWith('lockfile-path/mock', "[]");
+    assertForbiddenOutOfContext();
   });
 });
diff --git a/detox/src/devices/android/ADB.test.js b/detox/src/devices/android/ADB.test.js
index de7a4bcb8..1e23ae0c3 100644
--- a/detox/src/devices/android/ADB.test.js
+++ b/detox/src/devices/android/ADB.test.js
@@ -48,17 +48,31 @@ describe('ADB', () => {
       expect(exec).toHaveBeenCalledTimes(1);
     });
 
-    it(`should query emulator devices' name via telnet`, async () => {
-      exec.mockReturnValue({
-        stdout: 'List of devices attached\n'
-          + 'emulator-5554\tdevice\n'
-          + 'emulator-5555\tdevice\n'
-          + '\n',
-      });
+    it('should query device name lazily', 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\toffline\n'
+        + '\n';
 
-      await adb.devices();
-      expect(mockEmulatorTelnet.connect).toHaveBeenCalledWith("5554");
-      expect(mockEmulatorTelnet.connect).toHaveBeenCalledWith("5555");
+
+      exec.mockReturnValue({ stdout: adbDevices });
+
+      const { devices, stdout } = await adb.devices();
+      expect(stdout).toBe(adbDevices);
+      expect(devices).toHaveLength(4);
+
+      expect(devices).toEqual([
+        { type: 'device', adbName: 'MOCK_SERIAL', status: 'device' },
+        { type: 'genymotion', adbName: '192.168.60.101:6666', status: 'device' },
+        { type: 'emulator', adbName: 'emulator-5554', port: '5554', status: 'device' },
+        { type: 'emulator', adbName: 'emulator-5556', port: '5556', status: 'offline' },
+      ]);
+
+      mockEmulatorTelnet.avdName.mockReturnValue('Nexus_5X_API_29_x86');
+      expect(await devices[2].queryName()).toBe('Nexus_5X_API_29_x86');
+      expect(mockEmulatorTelnet.connect).toHaveBeenCalledWith('5554');
     });
 
     it(`Parse 'adb device' output with devices of all kinds`, async () => {
@@ -72,14 +86,13 @@ describe('ADB', () => {
         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);
+      const { devices } = await adb.devices();
+      expect(devices).toEqual([
+        { "adbName": "192.168.60.101:5555", "type": "genymotion", status: "device" },
+        { "adbName": "emulator-5556", "port": "5556", "type": "emulator", status: "device" },
+        { "adbName": "emulator-5554", "port": "5554", "type": "emulator", status: "device" },
+        { "adbName": "sx432wsds", "type": "device", status: "device" }
+      ]);
     });
 
     it(`should return an empty list if no devices are available`, async () => {
@@ -87,7 +100,7 @@ describe('ADB', () => {
         stdout: 'List of devices attached\n'
       });
 
-      const devices = await adb.devices();
+      const {devices} = await adb.devices();
       expect(devices.length).toEqual(0);
     });
 
@@ -118,27 +131,6 @@ 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 () => {
diff --git a/detox/src/utils/ExclusiveLockfile.test.js b/detox/src/utils/ExclusiveLockfile.test.js
new file mode 100644
index 000000000..569c9c6eb
--- /dev/null
+++ b/detox/src/utils/ExclusiveLockfile.test.js
@@ -0,0 +1,104 @@
+jest.mock('proper-lockfile');
+
+const plock = require('proper-lockfile');
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+const ExclusiveLockFile = require('./ExclusiveLockfile');
+
+describe('ExclusiveLockFile', () => {
+  let filePath;
+
+  beforeEach(() => {
+    filePath = tempfile('.test');
+  });
+
+  afterEach(async () => {
+    await fs.remove(filePath);
+  });
+
+  it('should execute an arbitrary function inside an exclusive lock', async () => {
+    const lockfile = new ExclusiveLockFile(filePath, {
+      getInitialState: () => 42,
+    });
+
+    expect(plock.lockSync).not.toHaveBeenCalled();
+    const result = await lockfile.exclusively(async () => {
+      expect(plock.lockSync).toHaveBeenCalled();
+
+      expect(lockfile.read()).toBe(42);
+      lockfile.write(84);
+      expect(lockfile.read()).toBe(84);
+
+      expect(plock.unlockSync).not.toHaveBeenCalled();
+      return "result";
+    });
+
+    expect(plock.unlockSync).toHaveBeenCalled();
+    expect(result).toBe('result');
+  });
+
+  it('should unlock after an exception inside the function', async () => {
+    const lockfile = new ExclusiveLockFile(filePath);
+
+    await expect(lockfile.exclusively(async () => {
+      throw new Error('synthetic error');
+    })).rejects.toThrow(/synthetic error/);
+
+    expect(plock.unlockSync).toHaveBeenCalled();
+  });
+
+  it('should forbid reading and writting outside of exclusive access', async () => {
+    const lockfile = new ExclusiveLockFile(filePath);
+
+    expect(() => lockfile.read()).toThrowError(/Forbidden.*to read/);
+    expect(() => lockfile.write(0)).toThrowError(/Forbidden.*to write/);
+  });
+
+  it('should create a lockfile if it does not exist', async () => {
+    const lockfile = new ExclusiveLockFile(filePath, {
+      getInitialState: () => 1000,
+    });
+
+    expect(fs.existsSync(filePath)).toBe(false);
+    await lockfile.exclusively(() => {});
+    expect(fs.readFileSync(filePath, 'utf8')).toBe('1000');
+  });
+
+  it('should not overwrite a lockfile if it exists', async () => {
+    const lockfile = new ExclusiveLockFile(filePath);
+
+    await lockfile.exclusively(() => lockfile.write('DETOX'));
+    await lockfile.exclusively(() => {});
+
+    expect(fs.readFileSync(filePath, 'utf8')).toBe('"DETOX"');
+  });
+
+  describe('constructor', () => {
+    it('should have 1 required arg', () => {
+      expect(() => new ExclusiveLockFile()).toThrowError(/non-empty string/);
+    });
+
+    it('should have default options', () => {
+      const lockfile = new ExclusiveLockFile(filePath);
+
+      expect(lockfile.options).toEqual({
+        retry: expect.objectContaining({}),
+        read: { encoding: 'utf8' },
+        getInitialState: expect.any(Function),
+      });
+    });
+
+    it('should have allow customizing options', () => {
+      const getInitialState = () => {};
+      const lockfile = new ExclusiveLockFile(filePath, {
+        getInitialState,
+      });
+
+      expect(lockfile.options).toEqual({
+        retry: expect.objectContaining({}),
+        read: { encoding: 'utf8' },
+        getInitialState,
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/detox/src/utils/argparse.test.js b/detox/src/utils/argparse.test.js
index 9fbe4d48a..3a7da06ef 100644
--- a/detox/src/utils/argparse.test.js
+++ b/detox/src/utils/argparse.test.js
@@ -66,4 +66,33 @@ describe('argparse', () => {
       expect(argparse.getFlag('flag-missing')).toBe(false);
     });
   });
+
+  describe('joinArgs()', () => {
+    let argparse;
+
+    beforeEach(() => {
+      argparse = require('./argparse');
+    });
+
+    it('should convert key-values to args string', () => {
+      expect(argparse.joinArgs({
+        optional: undefined,
+        debug: true,
+        timeout: 3000,
+        logLevel: 'verbose',
+        '-w': 1,
+        'device-name': 'iPhone X'
+      })).toBe('--debug --timeout 3000 --logLevel verbose -w 1 --device-name "iPhone X"');
+    });
+
+    it('should accept options', () => {
+      const options = { prefix: '-', joiner: '=' };
+      const argsObject = {
+        'version': 100,
+        '--help': true
+      };
+
+      expect(argparse.joinArgs(argsObject, options)).toBe('-version=100 --help');
+    });
+  })
 });
diff --git a/detox/src/utils/safeAsync.test.js b/detox/src/utils/safeAsync.test.js
new file mode 100644
index 000000000..926159cc1
--- /dev/null
+++ b/detox/src/utils/safeAsync.test.js
@@ -0,0 +1,19 @@
+const safeAsync = require('./safeAsync');
+
+describe('safeAsync', () => {
+  it(`should wrap value into a promise`, async() => {
+    await expect(safeAsync(5)).resolves.toEqual(5);
+  });
+
+  it(`should call sync function and return its result as a promise`, async() => {
+    await expect(safeAsync(() => 5)).resolves.toEqual(5);
+  });
+
+  it(`should call async function into a promise`, async() => {
+    await expect(safeAsync(async () => 5)).resolves.toEqual(5);
+  });
+
+  it(`should handle sync function errors in the async way`, async() => {
+    await expect(safeAsync( () => { throw 'error'; })).rejects.toEqual('error');
+  });
+});