Repository: wix/Detox
Pull request #1980 (2020-03-30 07:22:18)

feat(config): configure which screenshots to take
**Action:**

I decided to add the `takeWhen` config for `screenshot` plugin in package.json for those who explicitly want to configure which automatic screenshots to take, and which not:

```json
  "detox": {
    "artifacts": {
      "plugins": {
        "screenshot": {
          "takeWhen": {
            "testDone": true
          }
        }
      }
    }
  }
```

**Motivation:**

1.  `appNotReady.png` screenshot (https://github.com/wix/Detox/pull/1971) is hopefully going to be one of the first we will be adding to the plugin.
2. a `testStart` screenshot of the current test is usually identical to a `testDone` of the previous test.


Patch:
diff --git a/detox/package.json b/detox/package.json
index cba22edb1..15391f73c 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -85,12 +85,13 @@
       "src/artifacts/instruments",
       "src/artifacts/screenshot",
       "src/artifacts/video",
-      "src/devices/ios/AppleSimUtils.js",
+      "src/devices/drivers/ios/tools/AppleSimUtils.js",
       "src/server/DetoxServer.js",
       ".*Driver.js",
       "MissingDetox.js",
       "EmulatorTelnet.js",
-      "Emulator.js",
+      "EmulatorExec.js",
+      "EmulatorLauncher.js",
       "DeviceDriverBase.js",
       "GREYConfiguration.js",
       "src/utils/environment.js",
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index 9b0f57a59..c98ef2e41 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -3,10 +3,10 @@ const util = require('util');
 const logger = require('./utils/logger');
 const log = logger.child({ __filename });
 const Device = require('./devices/Device');
-const IosDriver = require('./devices/drivers/IosDriver');
-const SimulatorDriver = require('./devices/drivers/SimulatorDriver');
-const EmulatorDriver = require('./devices/drivers/EmulatorDriver');
-const AttachedAndroidDriver = require('./devices/drivers/AttachedAndroidDriver');
+const IosDriver = require('./devices/drivers/ios/IosDriver');
+const SimulatorDriver = require('./devices/drivers/ios/SimulatorDriver');
+const EmulatorDriver = require('./devices/drivers/android/EmulatorDriver');
+const AttachedAndroidDriver = require('./devices/drivers/android/AttachedAndroidDriver');
 const DetoxRuntimeError = require('./errors/DetoxRuntimeError');
 const argparse = require('./utils/argparse');
 const MissingDetox = require('./utils/MissingDetox');
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index cae11ecf7..f599afe51 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -2,7 +2,6 @@ const _ = require('lodash');
 const funpermaproxy = require('funpermaproxy');
 const Detox = require('./Detox');
 const DetoxConstants = require('./DetoxConstants');
-const argparse = require('./utils/argparse');
 const log = require('./utils/logger').child({ __filename });
 const configuration = require('./configuration');
 
diff --git a/detox/src/devices/android/Emulator.js b/detox/src/devices/android/Emulator.js
deleted file mode 100644
index 6696af089..000000000
--- a/detox/src/devices/android/Emulator.js
+++ /dev/null
@@ -1,139 +0,0 @@
-const _ = require('lodash');
-const fs = require('fs');
-const os = require('os');
-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 {getAndroidEmulatorPath} = require('../../utils/environment');
-const argparse = require('../../utils/argparse');
-const retry = require('../../utils/retry');
-
-const isUnknownEmulatorError = (err) => (err.message || '').includes('failed with code null');
-
-class Emulator {
-  constructor() {
-    this.emulatorBin = getAndroidEmulatorPath();
-  }
-
-  async listAvds() {
-    const output = await this.exec(`-list-avds --verbose`);
-    const avds = output.trim().split('\n');
-    return avds;
-  }
-
-  async exec(cmd) {
-    return (await exec(`"${this.emulatorBin}" ${cmd}`)).stdout;
-  }
-
-  async boot(emulatorName, options = {port: undefined}) {
-    const emulatorArgs = this._getEmulatorArgs(emulatorName, options);
-
-    return await retry({
-      retries: 2,
-      interval: 100,
-      conditionFn: isUnknownEmulatorError,
-    }, () => this._spawnEmulator(emulatorName, emulatorArgs, options));
-  }
-
-  _getEmulatorArgs(emulatorName, options) {
-    const deviceLaunchArgs = (argparse.getArgValue('deviceLaunchArgs') || '').split(/\s+/);
-    const emulatorArgs = _.compact([
-      '-verbose',
-      '-no-audio',
-      '-no-boot-anim',
-      argparse.getArgValue('headless') === 'true' ? '-no-window' : '',
-      argparse.getArgValue('readOnlyEmu') === 'true' ? '-read-only' : '',
-      options.port ? `-port` : '',
-      options.port ? `${options.port}` : '',
-      ...deviceLaunchArgs,
-      `@${emulatorName}`
-    ]);
-
-    const gpuMethod = this.gpuMethod();
-    if (gpuMethod) {
-      emulatorArgs.push('-gpu', gpuMethod);
-    }
-
-    return emulatorArgs;
-  }
-
-  gpuMethod() {
-    const gpuArgument = argparse.getArgValue('gpu');
-    if (gpuArgument) {
-      return gpuArgument;
-    }
-
-    if (argparse.getArgValue('headless')) {
-      switch (os.platform()) {
-        case 'darwin':
-          return 'host';
-        case 'linux':
-          return 'swiftshader_indirect';
-        case 'win32':
-          return 'angle_indirect';
-        default:
-          return 'auto';
-      }
-    }
-
-    return undefined;
-  }
-
-  _spawnEmulator(emulatorName, emulatorArgs, options) {
-    let childProcessOutput;
-    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 = {
-      useWatchFile: true,
-      fsWatchOptions: {
-        interval: 1500,
-      },
-    };
-    const tail = new Tail(tempLog, tailOptions)
-      .on("line", (line) => {
-        if (line.includes('Adb connected, start proxing data')) {
-          childProcessPromise._cpResolve();
-        }
-      });
-
-    function detach() {
-      if (childProcessOutput) {
-        return;
-      }
-
-      childProcessOutput = fs.readFileSync(tempLog, 'utf8');
-
-      tail.unwatch();
-      fs.closeSync(stdout);
-      fs.closeSync(stderr);
-      fs.unlink(tempLog, _.noop);
-    }
-
-    let log = unitLogger.child({ fn: 'boot' });
-    log.debug({ event: 'SPAWN_CMD' }, this.emulatorBin, ...emulatorArgs);
-    const childProcessPromise = spawn(this.emulatorBin, emulatorArgs, { detached: true, stdio: ['ignore', stdout, stderr] });
-    childProcessPromise.childProcess.unref();
-    log = log.child({ child_pid: childProcessPromise.childProcess.pid });
-
-    return childProcessPromise.then(() => true).catch((err) => {
-      detach();
-
-      if (childProcessOutput.includes(`There's another emulator instance running with the current AVD`)) {
-        return false;
-      }
-
-      log.error({ event: 'SPAWN_FAIL', error: true, err }, err.message);
-      log.error({ event: 'SPAWN_FAIL', stderr: true }, childProcessOutput);
-      throw err;
-    }).then((coldBoot) => {
-      detach();
-      log.debug({ event: 'SPAWN_SUCCESS', stdout: true }, childProcessOutput);
-      return coldBoot;
-    });
-  }
-}
-
-module.exports = Emulator;
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
similarity index 87%
rename from detox/src/devices/drivers/AndroidDriver.js
rename to detox/src/devices/drivers/android/AndroidDriver.js
index ab2843e99..5b7568fe0 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -1,30 +1,30 @@
 const fs = require('fs');
 const URL = require('url').URL;
 const _ = require('lodash');
-const { encodeBase64 } = require('../../utils/encoding');
-const logger = require('../../utils/logger');
+const { encodeBase64 } = require('../../../utils/encoding');
+const logger = require('../../../utils/logger');
 const log = logger.child({ __filename });
-const invoke = require('../../invoke');
+const invoke = require('../../../invoke');
 const InvocationManager = invoke.InvocationManager;
-const ADB = require('../android/ADB');
-const AAPT = require('../android/AAPT');
-const APKPath = require('../android/APKPath');
-const DeviceDriverBase = require('./DeviceDriverBase');
-const DetoxApi = require('../../android/espressoapi/Detox');
-const EspressoDetoxApi = require('../../android/espressoapi/EspressoDetox');
-const UiDeviceProxy = require('../../android/espressoapi/UiDeviceProxy');
-const AndroidInstrumentsPlugin = require('../../artifacts/instruments/android/AndroidInstrumentsPlugin');
-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 TimelineArtifactPlugin = require('../../artifacts/timeline/TimelineArtifactPlugin');
-const temporaryPath = require('../../artifacts/utils/temporaryPath');
-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 ADB = require('./tools/ADB');
+const AAPT = require('./tools/AAPT');
+const APKPath = require('./tools/APKPath');
+const DeviceDriverBase = require('../DeviceDriverBase');
+const DetoxApi = require('../../../android/espressoapi/Detox');
+const EspressoDetoxApi = require('../../../android/espressoapi/EspressoDetox');
+const UiDeviceProxy = require('../../../android/espressoapi/UiDeviceProxy');
+const AndroidInstrumentsPlugin = require('../../../artifacts/instruments/android/AndroidInstrumentsPlugin');
+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 TimelineArtifactPlugin = require('../../../artifacts/timeline/TimelineArtifactPlugin');
+const temporaryPath = require('../../../artifacts/utils/temporaryPath');
+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 { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
 
 const reservedInstrumentationArgs = ['class', 'package', 'func', 'unit', 'size', 'perf', 'debug', 'log', 'emma', 'coverageFile'];
diff --git a/detox/src/devices/drivers/AttachedAndroidDriver.js b/detox/src/devices/drivers/android/AttachedAndroidDriver.js
similarity index 93%
rename from detox/src/devices/drivers/AttachedAndroidDriver.js
rename to detox/src/devices/drivers/android/AttachedAndroidDriver.js
index da6a5e5e2..159cc9ba8 100644
--- a/detox/src/devices/drivers/AttachedAndroidDriver.js
+++ b/detox/src/devices/drivers/android/AttachedAndroidDriver.js
@@ -1,6 +1,6 @@
 const _ = require('lodash');
 const AndroidDriver = require('./AndroidDriver');
-const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
+const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 
 class AttachedAndroidDriver extends AndroidDriver {
 
diff --git a/detox/src/devices/drivers/EmulatorDriver.js b/detox/src/devices/drivers/android/EmulatorDriver.js
similarity index 59%
rename from detox/src/devices/drivers/EmulatorDriver.js
rename to detox/src/devices/drivers/android/EmulatorDriver.js
index 6b44376da..647cb0bb2 100644
--- a/detox/src/devices/drivers/EmulatorDriver.js
+++ b/detox/src/devices/drivers/android/EmulatorDriver.js
@@ -1,16 +1,19 @@
 const _ = require('lodash');
 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 environment = require('../../utils/environment');
-const retry = require('../../utils/retry');
-const log = require('../../utils/logger').child({ __filename });
+const FreeEmulatorFinder = require('./emulator/FreeEmulatorFinder');
+const AVDValidator = require('./emulator/AVDValidator');
+const EmulatorLauncher = require('./emulator/EmulatorLauncher');
+const { EmulatorExec } = require('./tools/EmulatorExec');
+const EmulatorTelnet = require('./tools/EmulatorTelnet');
+const EmulatorVersionResolver = require('./emulator/EmulatorVersionResolver');
+const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
+const DeviceRegistry = require('../../DeviceRegistry');
+const environment = require('../../../utils/environment');
+const retry = require('../../../utils/retry');
+const log = require('../../../utils/logger').child({ __filename });
 
 const DetoxEmulatorsPortRange = {
   min: 10000,
@@ -18,15 +21,21 @@ const DetoxEmulatorsPortRange = {
 };
 
 const ACQUIRE_DEVICE_EV = 'ACQUIRE_DEVICE';
+const EMU_BIN_STABLE_SKIN_VER = 28;
 
 class EmulatorDriver extends AndroidDriver {
   constructor(config) {
     super(config);
 
-    this.emulator = new Emulator();
     this.deviceRegistry = new DeviceRegistry({
       lockfilePath: environment.getDeviceLockFilePathAndroid(),
     });
+
+    const emulatorExec = new EmulatorExec();
+    this._emuVersionResolver = new EmulatorVersionResolver(emulatorExec);
+    this._emuLauncher = new EmulatorLauncher(emulatorExec);
+    this._avdValidator = new AVDValidator(emulatorExec);
+
     this.pendingBoots = {};
     this._name = 'Unspecified Emulator';
   }
@@ -38,35 +47,10 @@ class EmulatorDriver extends AndroidDriver {
   async acquireFreeDevice(deviceQuery) {
     const avdName = _.isPlainObject(deviceQuery) ? deviceQuery.avdName : deviceQuery;
 
-    await this._validateAvd(avdName);
+    await this._avdValidator.validate(avdName);
     await this._fixEmulatorConfigIniSkinNameIfNeeded(avdName);
 
-    log.debug({ event: ACQUIRE_DEVICE_EV }, `Looking up a device based on ${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;
-            log.debug({ event: ACQUIRE_DEVICE_EV }, `Found ${candidate.adbName}!`);
-            break;
-          }
-          log.debug({ event: ACQUIRE_DEVICE_EV }, `${candidate.adbName} is available but AVD is different`);
-        } else {
-          log.debug({ event: ACQUIRE_DEVICE_EV }, `${candidate.adbName} is not a free emulator`, isEmulator, !isBusy);
-        }
-      }
-
-      return freeEmulatorAdbName || this._createDevice();
-    });
-
-    log.debug({ event: ACQUIRE_DEVICE_EV }, `Settled on ${adbName}`);
+    const adbName = await this._allocateDevice(avdName);
 
     await this._boot(avdName, adbName);
 
@@ -82,12 +66,16 @@ class EmulatorDriver extends AndroidDriver {
     await super.cleanup(adbName, bundleId);
   }
 
+  /*async*/ binaryVersion() {
+    return this._emuVersionResolver.resolve();
+  }
+
   async _boot(avdName, adbName) {
     const coldBoot = !!this.pendingBoots[adbName];
 
     if (coldBoot) {
       const port = this.pendingBoots[adbName];
-      await this.emulator.boot(avdName, {port});
+      await this._emuLauncher.launch(avdName, { port });
       delete this.pendingBoots[adbName];
     }
 
@@ -95,22 +83,6 @@ class EmulatorDriver extends AndroidDriver {
     await this.emitter.emit('bootDevice', { coldBoot, deviceId: adbName, type: adbName });
   }
 
-  async _validateAvd(avdName) {
-    const avds = await this.emulator.listAvds();
-    if (!avds) {
-      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 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.`);
-    }
-
-    if (_.indexOf(avds, avdName) === -1) {
-      throw new Error(`Can not boot Android Emulator with the name: '${avdName}',
-      make sure you choose one of the available emulators: ${avds.toString()}`);
-    }
-  }
-
   async _waitForBootToComplete(deviceId) {
     await retry({ retries: 240, interval: 2500 }, async () => {
       const isBootComplete = await this.adb.isBootComplete(deviceId);
@@ -133,6 +105,11 @@ class EmulatorDriver extends AndroidDriver {
   }
 
   async _fixEmulatorConfigIniSkinNameIfNeeded(avdName) {
+    const binaryVersion = _.get(await this.binaryVersion(), 'major', EMU_BIN_STABLE_SKIN_VER - 1);
+    if (binaryVersion >= EMU_BIN_STABLE_SKIN_VER) {
+      return;
+    }
+
     const avdPath = environment.getAvdDir(avdName);
     const configFile = path.join(avdPath, 'config.ini');
     const config = ini.parse(fs.readFileSync(configFile, 'utf-8'));
@@ -148,7 +125,19 @@ class EmulatorDriver extends AndroidDriver {
       config['skin.name'] = `${width}x${height}`;
       fs.writeFileSync(configFile, ini.stringify(config));
     }
-    return config;
+  }
+
+  async _allocateDevice(avdName) {
+    log.debug({ event: ACQUIRE_DEVICE_EV }, `Looking up a device based on ${avdName}`);
+    const adbName = await this.deviceRegistry.allocateDevice(() => this._doAllocateDevice(avdName));
+    log.debug({ event: ACQUIRE_DEVICE_EV }, `Settled on ${adbName}`);
+    return adbName;
+  }
+
+  async _doAllocateDevice(avdName) {
+    const freeEmulatorFinder = new FreeEmulatorFinder(this.adb, this.deviceRegistry, avdName);
+    const freeEmulatorAdbName = await freeEmulatorFinder.findFreeDevice();
+    return freeEmulatorAdbName || this._createDevice();
   }
 
   async _createDevice() {
diff --git a/detox/src/devices/drivers/InstrumentationLogsParser.js b/detox/src/devices/drivers/android/InstrumentationLogsParser.js
similarity index 100%
rename from detox/src/devices/drivers/InstrumentationLogsParser.js
rename to detox/src/devices/drivers/android/InstrumentationLogsParser.js
diff --git a/detox/src/devices/drivers/InstrumentationLogsParser.test.js b/detox/src/devices/drivers/android/InstrumentationLogsParser.test.js
similarity index 100%
rename from detox/src/devices/drivers/InstrumentationLogsParser.test.js
rename to detox/src/devices/drivers/android/InstrumentationLogsParser.test.js
diff --git a/detox/src/devices/drivers/android/emulator/AVDValidator.js b/detox/src/devices/drivers/android/emulator/AVDValidator.js
new file mode 100644
index 000000000..dd6c89592
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/AVDValidator.js
@@ -0,0 +1,28 @@
+const _ = require('lodash');
+const path = require('path');
+const AVDsResolver = require('./AVDsResolver');
+const environment = require('../../../../utils/environment');
+
+class AVDValidator {
+  constructor(emulatorExec) {
+    this._avdsResolver = new AVDsResolver(emulatorExec);
+  }
+
+  async validate(avdName) {
+    const avds = await this._avdsResolver.resolve(avdName);
+    if (!avds) {
+      const avdmanagerPath = path.join(environment.getAndroidSDKPath(), 'tools', 'bin', 'avdmanager');
+
+      throw new Error(`Could not find any configured Android Emulator.\n
+        Try creating a device first, example: ${avdmanagerPath} create avd --force --name Pixel_API_28 --abi x86_64 --package "system-images;android-28;default;x86_64" --device "pixel"
+        or go to https://developer.android.com/studio/run/managing-avds.html for details on how to create an Emulator.`);
+    }
+
+    if (_.indexOf(avds, avdName) === -1) {
+      throw new Error(`Can not boot Android Emulator with the name: '${avdName}',
+        make sure you choose one of the available emulators: ${avds.toString()}`);
+    }
+  }
+}
+
+ module.exports = AVDValidator;
diff --git a/detox/src/devices/drivers/android/emulator/AVDsResolver.js b/detox/src/devices/drivers/android/emulator/AVDsResolver.js
new file mode 100644
index 000000000..910ad9c08
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/AVDsResolver.js
@@ -0,0 +1,15 @@
+const { ListAVDsCommand } = require('../tools/EmulatorExec');
+
+class AVDsResolver {
+  constructor(emulatorExec) {
+    this._emulatorExec = emulatorExec;
+  }
+
+  async resolve() {
+    const output = await this._emulatorExec.exec(new ListAVDsCommand());
+    const avds = output.trim().split('\n');
+    return avds;
+  }
+}
+
+module.exports = AVDsResolver;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorLauncher.js b/detox/src/devices/drivers/android/emulator/EmulatorLauncher.js
new file mode 100644
index 000000000..c3c8ba310
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorLauncher.js
@@ -0,0 +1,81 @@
+const _ = require('lodash');
+const fs = require('fs');
+const Tail = require('tail').Tail;
+const { LaunchCommand } = require('../tools/EmulatorExec');
+const unitLogger = require('../../../../utils/logger').child({ __filename });
+const retry = require('../../../../utils/retry');
+
+const isUnknownEmulatorError = (err) => (err.message || '').includes('failed with code null');
+
+class EmulatorLauncher {
+  constructor(emulatorExec) {
+    this._emulatorExec = emulatorExec;
+  }
+
+  async launch(emulatorName, options = {port: undefined}) {
+    const launchCommand = new LaunchCommand(emulatorName, options);
+
+    return await retry({
+      retries: 2,
+      interval: 100,
+      conditionFn: isUnknownEmulatorError,
+    }, () => this._launchEmulator(emulatorName, launchCommand));
+  }
+
+  _launchEmulator(emulatorName, launchCommand) {
+    let childProcessOutput;
+    const portName = launchCommand.port ? `-${launchCommand.port}` : '';
+    const tempLog = `./${emulatorName}${portName}.log`;
+    const stdout = fs.openSync(tempLog, 'a');
+    const stderr = fs.openSync(tempLog, 'a');
+    const tailOptions = {
+      useWatchFile: true,
+      fsWatchOptions: {
+        interval: 1500,
+      },
+    };
+    const tail = new Tail(tempLog, tailOptions)
+      .on("line", (line) => {
+        if (line.includes('Adb connected, start proxing data')) {
+          childProcessPromise._cpResolve();
+        }
+      });
+
+    function detach() {
+      if (childProcessOutput) {
+        return;
+      }
+
+      childProcessOutput = fs.readFileSync(tempLog, 'utf8');
+
+      tail.unwatch();
+      fs.closeSync(stdout);
+      fs.closeSync(stderr);
+      fs.unlink(tempLog, _.noop);
+    }
+
+    let log = unitLogger.child({ fn: 'boot' });
+    log.debug({ event: 'SPAWN_CMD' }, this._emulatorExec.toString(), launchCommand.toString());
+    const childProcessPromise = this._emulatorExec.spawn(launchCommand, stdout, stderr);
+    childProcessPromise.childProcess.unref();
+    log = log.child({ child_pid: childProcessPromise.childProcess.pid });
+
+    return childProcessPromise.then(() => true).catch((err) => {
+      detach();
+
+      if (childProcessOutput.includes(`There's another emulator instance running with the current AVD`)) {
+        return false;
+      }
+
+      log.error({ event: 'SPAWN_FAIL', error: true, err }, err.message);
+      log.error({ event: 'SPAWN_FAIL', stderr: true }, childProcessOutput);
+      throw err;
+    }).then((coldBoot) => {
+      detach();
+      log.debug({ event: 'SPAWN_SUCCESS', stdout: true }, childProcessOutput);
+      return coldBoot;
+    });
+  }
+}
+
+module.exports = EmulatorLauncher;
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.js b/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.js
new file mode 100644
index 000000000..e9d2666d6
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.js
@@ -0,0 +1,41 @@
+const {QueryVersionCommand} = require('../tools/EmulatorExec');
+const log = require('../../../../utils/logger').child({ __filename });
+
+const EMU_BIN_VERSION_DETECT_EV = 'EMU_BIN_VERSION_DETECT';
+
+class EmulatorVersionResolver {
+  constructor(emulatorExec) {
+    this._emulatorExec = emulatorExec;
+    this.version = undefined;
+  }
+
+  async resolve() {
+    if (!this.version) {
+      this.version = await this._resolve();
+    }
+    return this.version;
+  }
+
+  async _resolve() {
+    const rawOutput = await this._emulatorExec.exec(new QueryVersionCommand()) || '';
+    const matches = rawOutput.match(/Android emulator version ([0-9]+\.[0-9]+\.[0-9]+\.[0-9]*)/);
+    if (!matches) {
+      log.warn({ event: EMU_BIN_VERSION_DETECT_EV, success: false }, 'Could not detect emulator binary version, got:', rawOutput);
+      return null;
+    }
+
+    const version = this._parseVersionString(matches[1]);
+    log.debug({ event: EMU_BIN_VERSION_DETECT_EV, success: true }, 'Detected emulator binary version', version);
+    return version;
+  }
+
+  _parseVersionString(versionRaw) {
+    const [major, minor, patch] = versionRaw.split('\.');
+    return {
+      major: Number(major),
+      minor: Number(minor),
+      patch: Number(patch),
+    };
+  }
+}
+module.exports = EmulatorVersionResolver;
diff --git a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js
new file mode 100644
index 000000000..2ad8e4f96
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.js
@@ -0,0 +1,36 @@
+const AdbDevicesHelper = require('../tools/AdbDevicesHelper');
+const log = require('../../../../utils/logger').child({ __filename });
+
+const ACQUIRE_DEVICE_EV = 'ACQUIRE_DEVICE';
+
+class FreeEmulatorFinder {
+  constructor(adb, deviceRegistry, avdName) {
+    this.adbDevicesHelper = new AdbDevicesHelper(adb);
+    this.deviceRegistry = deviceRegistry;
+    this.avdName = avdName;
+
+    this._matcherFn = this._matcherFn.bind(this);
+  }
+
+  async findFreeDevice() {
+    return await this.adbDevicesHelper.lookupDevice(this._matcherFn);
+  }
+
+  async _matcherFn(candidate) {
+    const isEmulator = candidate.type === 'emulator';
+    const isBusy = this.deviceRegistry.isDeviceBusy(candidate.adbName);
+
+    if (isEmulator && !isBusy) {
+      if (await candidate.queryName() === this.avdName) {
+        log.debug({ event: ACQUIRE_DEVICE_EV }, `Found ${candidate.adbName}!`);
+        return true;
+      }
+      log.debug({ event: ACQUIRE_DEVICE_EV }, `${candidate.adbName} is available but AVD is different`);
+    } else {
+      log.debug({ event: ACQUIRE_DEVICE_EV }, `${candidate.adbName} is not a free emulator`, isEmulator, !isBusy);
+    }
+    return false;
+  }
+}
+
+module.exports = FreeEmulatorFinder;
diff --git a/detox/src/devices/android/AAPT.js b/detox/src/devices/drivers/android/tools/AAPT.js
similarity index 63%
rename from detox/src/devices/android/AAPT.js
rename to detox/src/devices/drivers/android/tools/AAPT.js
index 814765e06..39b973139 100644
--- a/detox/src/devices/android/AAPT.js
+++ b/detox/src/devices/drivers/android/tools/AAPT.js
@@ -1,10 +1,10 @@
 const _ = require('lodash');
-const {getAaptPath} = require('../../utils/environment');
+const {getAaptPath} = require('../../../../utils/environment');
 const path = require('path');
-const exec = require('../../utils/exec').execWithRetriesAndLogs;
-const escape = require('../../utils/pipeCommands').escape.inQuotedString;
-const egrep = require('../../utils/pipeCommands').search.fragment;
-const fsext = require('../../utils/fsext');
+const exec = require('../../../../utils/exec').execWithRetriesAndLogs;
+const escape = require('../../../../utils/pipeCommands').escape.inQuotedString;
+const egrep = require('../../../../utils/pipeCommands').search.fragment;
+const fsext = require('../../../../utils/fsext');
 
 class AAPT {
   constructor() {
diff --git a/detox/src/devices/android/ADB.js b/detox/src/devices/drivers/android/tools/ADB.js
similarity index 96%
rename from detox/src/devices/android/ADB.js
rename to detox/src/devices/drivers/android/tools/ADB.js
index fc686c9a4..0b38d6081 100644
--- a/detox/src/devices/android/ADB.js
+++ b/detox/src/devices/drivers/android/tools/ADB.js
@@ -1,10 +1,10 @@
 const _ = require('lodash');
-const {execWithRetriesAndLogs, spawnAndLog} = require('../../utils/exec');
-const {escape} = require('../../utils/pipeCommands');
-const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
+const {execWithRetriesAndLogs, spawnAndLog} = require('../../../../utils/exec');
+const {escape} = require('../../../../utils/pipeCommands');
+const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
 const EmulatorTelnet = require('./EmulatorTelnet');
-const {getAdbPath} = require('../../utils/environment');
-const {encodeBase64} = require('../../utils/encoding');
+const {getAdbPath} = require('../../../../utils/environment');
+const {encodeBase64} = require('../../../../utils/encoding');
 
 class ADB {
   constructor() {
diff --git a/detox/src/devices/android/APKPath.js b/detox/src/devices/drivers/android/tools/APKPath.js
similarity index 94%
rename from detox/src/devices/android/APKPath.js
rename to detox/src/devices/drivers/android/tools/APKPath.js
index b8708feab..41c4bebae 100644
--- a/detox/src/devices/android/APKPath.js
+++ b/detox/src/devices/drivers/android/tools/APKPath.js
@@ -1,6 +1,6 @@
 const path = require('path');
 const _ = require('lodash');
-const string = require('../../utils/string');
+const string = require('../../../../utils/string');
 
 class APKPath {
 
diff --git a/detox/src/devices/android/APKPath.test.js b/detox/src/devices/drivers/android/tools/APKPath.test.js
similarity index 100%
rename from detox/src/devices/android/APKPath.test.js
rename to detox/src/devices/drivers/android/tools/APKPath.test.js
diff --git a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js
new file mode 100644
index 000000000..be6cf1548
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.js
@@ -0,0 +1,17 @@
+class AdbDevicesHelper {
+  constructor(adb) {
+    this.adb = adb;
+  }
+
+  async lookupDevice(matchFn) {
+    const { devices } = await this.adb.devices();
+    for (const candidate of devices) {
+      if (await matchFn(candidate)) {
+        return candidate.adbName;
+      }
+    }
+    return null;
+  }
+}
+
+module.exports = AdbDevicesHelper;
diff --git a/detox/src/devices/drivers/android/tools/EmulatorExec.js b/detox/src/devices/drivers/android/tools/EmulatorExec.js
new file mode 100644
index 000000000..316315dc3
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/EmulatorExec.js
@@ -0,0 +1,110 @@
+const _ = require('lodash');
+const os = require('os');
+const spawn = require('child-process-promise').spawn;
+const exec = require('../../../../utils/exec').execWithRetriesAndLogs;
+const argparse = require('../../../../utils/argparse');
+const {getAndroidEmulatorPath} = require('../../../../utils/environment');
+
+class EmulatorExec {
+  constructor() {
+    this.binary = getAndroidEmulatorPath();
+  }
+
+  toString() {
+    return this.binary;
+  }
+
+  async exec(command) {
+    return (await exec(`"${this.binary}" ${command._getArgsString()}`)).stdout;
+  }
+
+  spawn(command, stdout, stderr) {
+    return spawn(this.binary, command._getArgs(), { detached: true, stdio: ['ignore', stdout, stderr] });
+  }
+}
+
+class EmulatorCommand {
+  toString() {
+    return this._getArgsString();
+  }
+
+  _getArgs() {}
+  _getArgsString() {
+    return this._getArgs().join(' ');
+  }
+}
+
+class ListAVDsCommand extends EmulatorCommand {
+  _getArgs() {
+    return ['-list-avds', '--verbose'];
+  }
+}
+
+class QueryVersionCommand extends EmulatorCommand {
+  _getArgs() {
+    return ['-version'];
+  }
+}
+
+class LaunchCommand extends EmulatorCommand {
+  constructor(emulatorName, options) {
+    super();
+    this._args = this._getEmulatorArgs(emulatorName, options);
+    this.port = options.port;
+  }
+
+  _getArgs() {
+    return this._args;
+  }
+
+  _getEmulatorArgs(emulatorName, options) {
+    const deviceLaunchArgs = (argparse.getArgValue('deviceLaunchArgs') || '').split(/\s+/);
+    const emulatorArgs = _.compact([
+      '-verbose',
+      '-no-audio',
+      '-no-boot-anim',
+      argparse.getArgValue('headless') === 'true' ? '-no-window' : '',
+      argparse.getArgValue('readOnlyEmu') === 'true' ? '-read-only' : '',
+      options.port ? `-port` : '',
+      options.port ? `${options.port}` : '',
+      ...deviceLaunchArgs,
+      `@${emulatorName}`
+    ]);
+
+    const gpuMethod = this._gpuMethod();
+    if (gpuMethod) {
+      emulatorArgs.push('-gpu', gpuMethod);
+    }
+
+    return emulatorArgs;
+  }
+
+  _gpuMethod() {
+    const gpuArgument = argparse.getArgValue('gpu');
+    if (gpuArgument) {
+      return gpuArgument;
+    }
+
+    if (argparse.getArgValue('headless')) {
+      switch (os.platform()) {
+        case 'darwin':
+          return 'host';
+        case 'linux':
+          return 'swiftshader_indirect';
+        case 'win32':
+          return 'angle_indirect';
+        default:
+          return 'auto';
+      }
+    }
+
+    return undefined;
+  }
+}
+
+module.exports = {
+  EmulatorExec,
+  ListAVDsCommand,
+  QueryVersionCommand,
+  LaunchCommand,
+};
diff --git a/detox/src/devices/android/EmulatorTelnet.js b/detox/src/devices/drivers/android/tools/EmulatorTelnet.js
similarity index 96%
rename from detox/src/devices/android/EmulatorTelnet.js
rename to detox/src/devices/drivers/android/tools/EmulatorTelnet.js
index b5af8c876..d1207c9f4 100644
--- a/detox/src/devices/android/EmulatorTelnet.js
+++ b/detox/src/devices/drivers/android/tools/EmulatorTelnet.js
@@ -2,7 +2,7 @@ const Telnet = require('telnet-client');
 const path = require('path');
 const os = require('os');
 const fs = require('fs-extra');
-const log = require('../../utils/logger').child({ __filename });
+const log = require('../../../../utils/logger').child({ __filename });
 
 class EmulatorTelnet {
   constructor() {
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/ios/IosDriver.js
similarity index 65%
rename from detox/src/devices/drivers/IosDriver.js
rename to detox/src/devices/drivers/ios/IosDriver.js
index 5746beb91..d23572005 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/ios/IosDriver.js
@@ -1,20 +1,20 @@
 const path = require('path');
 const fs = require('fs');
-const log = require('../../utils/logger').child({ __filename });
-const DeviceDriverBase = require('./DeviceDriverBase');
-const InvocationManager = require('../../invoke').InvocationManager;
-const invoke = require('../../invoke');
-const GREYConfigurationApi = require('../../ios/earlgreyapi/GREYConfiguration');
-const GREYConfigurationDetox = require('../../ios/earlgreyapi/GREYConfigurationDetox');
-const EarlyGreyImpl = require('../../ios/earlgreyapi/EarlGreyImpl');
-const AppleSimUtils = require('../ios/AppleSimUtils');
+const log = require('../../../utils/logger').child({ __filename });
+const DeviceDriverBase = require('../DeviceDriverBase');
+const InvocationManager = require('../../../invoke').InvocationManager;
+const invoke = require('../../../invoke');
+const GREYConfigurationApi = require('../../../ios/earlgreyapi/GREYConfiguration');
+const GREYConfigurationDetox = require('../../../ios/earlgreyapi/GREYConfigurationDetox');
+const EarlyGreyImpl = require('../../../ios/earlgreyapi/EarlGreyImpl');
+const AppleSimUtils = require('./tools/AppleSimUtils');
 
-const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin');
-const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
-const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
-const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
-const TimelineArtifactPlugin = require('../../artifacts/timeline/TimelineArtifactPlugin');
-const IosExpect = require('../../ios/expect');
+const SimulatorLogPlugin = require('../../../artifacts/log/ios/SimulatorLogPlugin');
+const SimulatorScreenshotPlugin = require('../../../artifacts/screenshot/SimulatorScreenshotPlugin');
+const SimulatorRecordVideoPlugin = require('../../../artifacts/video/SimulatorRecordVideoPlugin');
+const SimulatorInstrumentsPlugin = require('../../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
+const TimelineArtifactPlugin = require('../../../artifacts/timeline/TimelineArtifactPlugin');
+const IosExpect = require('../../../ios/expect');
 
 class IosDriver extends DeviceDriverBase {
   constructor(config) {
diff --git a/detox/src/devices/drivers/SimulatorDriver.js b/detox/src/devices/drivers/ios/SimulatorDriver.js
similarity index 95%
rename from detox/src/devices/drivers/SimulatorDriver.js
rename to detox/src/devices/drivers/ios/SimulatorDriver.js
index d64156dec..36e135d56 100644
--- a/detox/src/devices/drivers/SimulatorDriver.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.js
@@ -2,13 +2,13 @@ const _ = require('lodash');
 const fs = require('fs');
 const path = require('path');
 const exec = require('child-process-promise').exec;
-const DeviceRegistry = require('../DeviceRegistry');
+const DeviceRegistry = require('../../DeviceRegistry');
 const IosDriver = require('./IosDriver');
-const temporaryPath = require('../../artifacts/utils/temporaryPath');
-const configuration = require('../../configuration');
-const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
-const environment = require('../../utils/environment');
-const argparse = require('../../utils/argparse');
+const temporaryPath = require('../../../artifacts/utils/temporaryPath');
+const configuration = require('../../../configuration');
+const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
+const environment = require('../../../utils/environment');
+const argparse = require('../../../utils/argparse');
 
 class SimulatorDriver extends IosDriver {
 
diff --git a/detox/src/devices/ios/AppleSimUtils.js b/detox/src/devices/drivers/ios/tools/AppleSimUtils.js
similarity index 97%
rename from detox/src/devices/ios/AppleSimUtils.js
rename to detox/src/devices/drivers/ios/tools/AppleSimUtils.js
index 8e9daac13..0dce5c864 100644
--- a/detox/src/devices/ios/AppleSimUtils.js
+++ b/detox/src/devices/drivers/ios/tools/AppleSimUtils.js
@@ -1,9 +1,9 @@
 const _ = require('lodash');
 const path = require('path');
-const {joinArgs} = require('../../utils/argparse');
-const exec = require('../../utils/exec');
-const log = require('../../utils/logger').child({ __filename });
-const environment = require('../../utils/environment');
+const {joinArgs} = require('../../../../utils/argparse');
+const exec = require('../../../../utils/exec');
+const log = require('../../../../utils/logger').child({ __filename });
+const environment = require('../../../../utils/environment');
 
 class AppleSimUtils {
   async setPermissions(udid, bundleId, permissionsObj) {
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index dddd9ae7d..736461710 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -69,7 +69,11 @@ Detox can control artifacts collection via settings from `package.json`:
         "log": { "enabled": true },
         "screenshot": {
           "shouldTakeAutomaticSnapshots": true,
-          "keepOnlyFailedTestsArtifacts": true
+          "keepOnlyFailedTestsArtifacts": true,
+          "takeWhen": {
+            "testStart": false,
+            "testDone": true,
+          },
         },
         "video": {
           "android": {
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index 8bd7113a1..c753855b5 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index c6c0d2cad..bc695e079 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "devDependencies": {
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index ea576ea03..807d16fed 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 3e49ddc35..c97d76016 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,6 +1,6 @@
 {
   "name": "demo-react-native-jest",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -13,7 +13,7 @@
     "test:jest-circus:android-release-ci": "detox test --configuration android.emu.release -o e2e/config-circus.json -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "jest": "25.1.x",
     "jest-circus": "25.1.x",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index 549417af9..499ac5fa0 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -23,7 +23,7 @@
     "react-native": "0.60.x"
   },
   "devDependencies": {
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/lerna.json b/lerna.json
index e22d4d0e5..6a40c40ec 100644
--- a/lerna.json
+++ b/lerna.json
@@ -12,7 +12,7 @@
     "generation",
     "."
   ],
-  "version": "16.0.1",
+  "version": "16.0.2",
   "npmClient": "npm",
   "command": {
     "publish": {
diff --git a/package.json b/package.json
index 4bc6db193..d556939cb 100644
--- a/package.json
+++ b/package.json
@@ -18,5 +18,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "16.0.1"
+  "version": "16.0.2"
 }


Test patch:
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index cacfb95d9..e1631dd21 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -72,8 +72,8 @@ describe('Detox', () => {
 
     global.device = undefined;
 
-    jest.mock('./devices/drivers/IosDriver');
-    jest.mock('./devices/drivers/SimulatorDriver');
+    jest.mock('./devices/drivers/ios/IosDriver');
+    jest.mock('./devices/drivers/ios/SimulatorDriver');
     jest.mock('./devices/Device');
     jest.mock('./server/DetoxServer');
   });
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
index c72754c10..9d1add16a 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.js
@@ -9,6 +9,11 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
 
     this.shouldTakeAutomaticSnapshots = this.api.userConfig.shouldTakeAutomaticSnapshots;
     this.keepOnlyFailedTestsArtifacts = this.api.userConfig.keepOnlyFailedTestsArtifacts;
+    this.takeAutomaticSnapshots = this.api.userConfig.takeWhen || {
+      testStart: true,
+      testDone: true,
+    };
+
     this.snapshots = {
       fromTest: {},
       fromSession: {},
@@ -68,7 +73,9 @@ class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
 
   async _takeAutomaticSnapshot(name) {
     if (this.enabled && this.shouldTakeAutomaticSnapshots) {
-      await this._takeSnapshot(name);
+      if (this.takeAutomaticSnapshots[name]) {
+        await this._takeSnapshot(name);
+      }
     }
   }
 
diff --git a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
index 259393772..47a9da9d3 100644
--- a/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
+++ b/detox/src/artifacts/templates/plugin/TwoSnapshotsPerTestPlugin.test.js
@@ -16,6 +16,7 @@ describe('TwoSnapshotsPerTestPlugin', () => {
         keepOnlyFailedTestsArtifacts: false,
       },
     });
+
     plugin = new FakeTwoSnapshotsPerTestPlugin({ api });
   });
 
@@ -58,221 +59,306 @@ describe('TwoSnapshotsPerTestPlugin', () => {
     });
   });
 
-  describe('when onTestStart called', function() {
-    beforeEach(async () => {
-      await plugin.onTestStart(testSummaries.running());
-    });
+  describe('when takeWhen.testStart is false', () => {
+    beforeEach(() => plugin.configureAutomaticSnapshots({ testStart: false }));
 
-    it('should create test artifact', () => {
-      expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
-    });
+    describe('when onTestStart called', function() {
+      beforeEach(async () => {
+        await plugin.onTestStart(testSummaries.running());
+      });
 
-    it('should start and stop recording in the artifact', () => {
-      expect(plugin.snapshots.fromTest['testStart'].start).toHaveBeenCalledTimes(1);
-      expect(plugin.snapshots.fromTest['testStart'].stop).toHaveBeenCalledTimes(1);
+      it('should not create any tests artifacts', () => {
+        expect(plugin.createTestArtifact).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['testStart']).toBe(undefined);
+      });
     });
+  });
+
+  describe('when takeWhen.testStart is true', () => {
+    beforeEach(() => plugin.configureAutomaticSnapshots({ testStart: true }));
+
+    describe('when onTestStart called', function() {
+      beforeEach(async () => {
+        await plugin.onTestStart(testSummaries.running());
+      });
+
+      it('should create test artifact', () => {
+        expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
+      });
+
+      it('should start and stop recording in the artifact', () => {
+        expect(plugin.snapshots.fromTest['testStart'].start).toHaveBeenCalledTimes(1);
+        expect(plugin.snapshots.fromTest['testStart'].stop).toHaveBeenCalledTimes(1);
+      });
 
-    it('should put the artifact under tracking', () => {
-      expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testStart']);
+      it('should put the artifact under tracking', () => {
+        expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testStart']);
+      });
     });
   });
 
-  describe('onCreateExternalArtifact', () => {
-    it('should throw error if { artifact } is not defined', async () => {
-      await expect(plugin.onCreateExternalArtifact({ name: 'Hello'})).rejects.toThrowError();
+  describe('when takeWhen.testDone is false', () => {
+    beforeEach(() => plugin.configureAutomaticSnapshots({ testDone: false }));
+
+    describe('when onTestStart and onTestEnd called', function() {
+      beforeEach(async () => {
+        await plugin.onTestStart(testSummaries.running());
+        await plugin.onTestDone(testSummaries.passed());
+      });
+
+      it('should not create any test artifacts', () => {
+        expect(plugin.createTestArtifact).not.toHaveBeenCalled();
+        expect(plugin.snapshots.fromTest['testDone']).toBe(undefined);
+      });
     });
 
-    it('should set snapshot in key-value map and track it', async () => {
-      const artifact = new ArtifactMock('test');
-      await plugin.onCreateExternalArtifact({ name: 'hello', artifact });
+    describe('and takeWhen.testStart is true', () => {
+      beforeEach(() => plugin.configureAutomaticSnapshots({ testStart: true }));
 
-      expect(plugin.snapshots.fromSession['hello']).toBe(artifact);
-      expect(api.trackArtifact).toHaveBeenCalledWith(artifact);
+      describe('when onTestStart and onTestEnd called', function() {
+        it('should save a test artifact from testStart', async () => {
+          await plugin.onTestStart(testSummaries.running());
+          expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
+
+          const startArtifact = plugin.snapshots.fromTest['testStart'];
+          expect(startArtifact.save).not.toHaveBeenCalled();
+
+          await plugin.onTestDone(testSummaries.passed());
+          await api.emulateRunningAllIdleCallbacks();
+
+          expect(startArtifact.save).toHaveBeenCalled();
+        });
+      });
     });
   });
 
-  describe('when the plugin should keep a test artifact', () => {
-    beforeEach(() => plugin.configureToKeepArtifacts(true));
+  describe('when takeWhen.testDone is true', () => {
+    beforeEach(() => plugin.configureAutomaticSnapshots({ testDone: true }));
 
-    describe('when onTestStart and onTestDone are called', () => {
+    describe('when onTestStart and onTestDone called', function() {
       beforeEach(async () => {
         await plugin.onTestStart(testSummaries.running());
         await plugin.onTestDone(testSummaries.passed());
       });
 
-      it('should create the second test artifact', () => {
-        expect(plugin.createTestArtifact).toHaveBeenCalledTimes(2);
+      it('should create 1 test artifact', () => {
+        expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
       });
 
-      it('should start and stop the second test artifact', () => {
+      it('should start and stop recording in the "testDone" artifact', () => {
         expect(plugin.snapshots.fromTest['testDone'].start).toHaveBeenCalledTimes(1);
         expect(plugin.snapshots.fromTest['testDone'].stop).toHaveBeenCalledTimes(1);
       });
 
-      it('should put the second test artifact under tracking', () => {
+      it('should put the "testDone" artifact under tracking', () => {
         expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testDone']);
       });
 
-      it('should schedule two saving operations and specify itself as an initiator', () => {
-        expect(api.requestIdleCallback).toHaveBeenCalledTimes(2);
-        expect(api.requestIdleCallback.mock.calls[0]).toEqual([expect.any(Function)]);
-        expect(api.requestIdleCallback.mock.calls[1]).toEqual([expect.any(Function)]);
+      it('should eventually save the "testDone" artifact', async () => {
+        await api.emulateRunningAllIdleCallbacks();
+        expect(plugin.snapshots.fromTest['testDone'].save).toHaveBeenCalledTimes(1);
       });
+    });
+  });
 
-      it('should schedule to save and untrack the first artifact', async () => {
-        const [saveRequest] = api.requestIdleCallback.mock.calls[0];
+  describe('onCreateExternalArtifact', () => {
+    it('should throw error if { artifact } is not defined', async () => {
+      await expect(plugin.onCreateExternalArtifact({ name: 'Hello'})).rejects.toThrowError();
+    });
 
-        expect(plugin.snapshots.fromTest['testStart'].save).not.toHaveBeenCalled();
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+    it('should set snapshot in key-value map and track it', async () => {
+      const artifact = new ArtifactMock('test');
+      await plugin.onCreateExternalArtifact({ name: 'hello', artifact });
 
-        await saveRequest();
+      expect(plugin.snapshots.fromSession['hello']).toBe(artifact);
+      expect(api.trackArtifact).toHaveBeenCalledWith(artifact);
+    });
+  });
 
-        expect(plugin.snapshots.fromTest['testStart'].save).toBeCalledWith('test/testStart.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
-      });
+  describe('when takeWhen.testStart and takeWhen.testDone have default values', function() {
+    describe('when the plugin should keep a test artifact', () => {
+      beforeEach(() => plugin.configureToKeepArtifacts(true));
 
-      it('should ultimately save and untrack the second artifact', async () => {
-        const [saveRequest] = api.requestIdleCallback.mock.calls[1];
+      describe('when onTestStart and onTestDone are called', () => {
+        beforeEach(async () => {
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
+        });
 
-        expect(plugin.snapshots.fromTest['testDone'].save).not.toHaveBeenCalled();
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+        it('should create the second test artifact', () => {
+          expect(plugin.createTestArtifact).toHaveBeenCalledTimes(2);
+        });
 
-        await saveRequest();
+        it('should start and stop the second test artifact', () => {
+          expect(plugin.snapshots.fromTest['testDone'].start).toHaveBeenCalledTimes(1);
+          expect(plugin.snapshots.fromTest['testDone'].stop).toHaveBeenCalledTimes(1);
+        });
 
-        expect(plugin.snapshots.fromTest['testDone'].save).toBeCalledWith('test/testDone.png');
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testDone']);
-      });
-    });
+        it('should put the second test artifact under tracking', () => {
+          expect(api.trackArtifact).toHaveBeenCalledWith(plugin.snapshots.fromTest['testDone']);
+        });
+
+        it('should schedule two saving operations and specify itself as an initiator', () => {
+          expect(api.requestIdleCallback).toHaveBeenCalledTimes(2);
+          expect(api.requestIdleCallback.mock.calls[0]).toEqual([expect.any(Function)]);
+          expect(api.requestIdleCallback.mock.calls[1]).toEqual([expect.any(Function)]);
+        });
 
-    describe('when an external snapshot is created in the midst of a test', function() {
-      let artifact;
+        it('should schedule to save and untrack the first artifact', async () => {
+          const [saveRequest] = api.requestIdleCallback.mock.calls[0];
 
-      beforeEach(async () => {
-        artifact = new ArtifactMock('screenshot');
+          expect(plugin.snapshots.fromTest['testStart'].save).not.toHaveBeenCalled();
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        await plugin.onTestStart(testSummaries.running());
-        await plugin.onCreateExternalArtifact({
-          artifact,
-          name: 'final_name',
+          await saveRequest();
+
+          expect(plugin.snapshots.fromTest['testStart'].save).toBeCalledWith('test/testStart.png');
+          expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
         });
-        await plugin.onTestDone(testSummaries.passed());
-      });
 
-      it('should be saved using the suggested name and untracked', async () => {
-        expect(artifact.save).not.toBeCalledWith('test/final_name.png');
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+        it('should ultimately save and untrack the second artifact', async () => {
+          const [saveRequest] = api.requestIdleCallback.mock.calls[1];
+
+          expect(plugin.snapshots.fromTest['testDone'].save).not.toHaveBeenCalled();
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+          await saveRequest();
 
-        expect(artifact.save).toBeCalledWith('test/final_name.png');
-        expect(api.untrackArtifact).toHaveBeenCalled();
+          expect(plugin.snapshots.fromTest['testDone'].save).toBeCalledWith('test/testDone.png');
+          expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testDone']);
+        });
       });
-    });
 
-    describe('when an external snapshot is created before beforeEach', function() {
-      let artifact;
+      describe('when an external snapshot is created in the midst of a test', function() {
+        let artifact;
 
-      beforeEach(async () => {
-        artifact = new ArtifactMock('screenshot');
+        beforeEach(async () => {
+          artifact = new ArtifactMock('screenshot');
 
-        await plugin.onCreateExternalArtifact({
-          artifact,
-          name: 'final_name',
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onCreateExternalArtifact({
+            artifact,
+            name: 'final_name',
+          });
+          await plugin.onTestDone(testSummaries.passed());
         });
-        await plugin.onTestStart(testSummaries.running());
-      });
 
-      it('should be saved using the suggested name and untracked', async () => {
-        expect(artifact.save).not.toBeCalledWith('final_name.png');
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+        it('should be saved using the suggested name and untracked', async () => {
+          expect(artifact.save).not.toBeCalledWith('test/final_name.png');
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+          await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
 
-        expect(artifact.save).toBeCalledWith('final_name.png');
-        expect(api.untrackArtifact).toHaveBeenCalled();
+          expect(artifact.save).toBeCalledWith('test/final_name.png');
+          expect(api.untrackArtifact).toHaveBeenCalled();
+        });
       });
-    });
 
-    describe('when an external snapshot is created before Detox cleanup', function() {
-      let artifact;
+      describe('when an external snapshot is created before beforeEach', function() {
+        let artifact;
 
-      beforeEach(async () => {
-        artifact = new ArtifactMock('screenshot');
+        beforeEach(async () => {
+          artifact = new ArtifactMock('screenshot');
 
-        await plugin.onTestStart(testSummaries.running());
-        await plugin.onTestDone(testSummaries.passed());
-        await plugin.onCreateExternalArtifact({
-          artifact,
-          name: 'final_name',
+          await plugin.onCreateExternalArtifact({
+            artifact,
+            name: 'final_name',
+          });
+          await plugin.onTestStart(testSummaries.running());
+        });
+
+        it('should be saved using the suggested name and untracked', async () => {
+          expect(artifact.save).not.toBeCalledWith('final_name.png');
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
+
+          await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+          expect(artifact.save).toBeCalledWith('final_name.png');
+          expect(api.untrackArtifact).toHaveBeenCalled();
         });
-        await plugin.onBeforeCleanup();
       });
 
-      it('should be saved using the suggested name and untracked', async () => {
-        expect(artifact.save).not.toBeCalledWith('final_name.png');
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+      describe('when an external snapshot is created before Detox cleanup', function() {
+        let artifact;
+
+        beforeEach(async () => {
+          artifact = new ArtifactMock('screenshot');
+
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
+          await plugin.onCreateExternalArtifact({
+            artifact,
+            name: 'final_name',
+          });
+          await plugin.onBeforeCleanup();
+        });
 
-        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+        it('should be saved using the suggested name and untracked', async () => {
+          expect(artifact.save).not.toBeCalledWith('final_name.png');
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        expect(artifact.save).toBeCalledWith('final_name.png');
-        expect(api.untrackArtifact).toHaveBeenCalled();
+          await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+
+          expect(artifact.save).toBeCalledWith('final_name.png');
+          expect(api.untrackArtifact).toHaveBeenCalled();
+        });
       });
     });
-  });
 
-  describe('when the plugin should not keep a test artifact', () => {
-    beforeEach(() => plugin.configureToKeepArtifacts(false));
+    describe('when the plugin should not keep a test artifact', () => {
+      beforeEach(() => plugin.configureToKeepArtifacts(false));
 
-    describe('when onTestStart and onTestDone are called', () => {
-      beforeEach(async () => {
-        await plugin.onTestStart(testSummaries.running());
-        await plugin.onTestDone(testSummaries.passed());
-      });
+      describe('when onTestStart and onTestDone are called', () => {
+        beforeEach(async () => {
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onTestDone(testSummaries.passed());
+        });
 
-      it('should not create the second test artifact', () => {
-        expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
-      });
+        it('should not create the second test artifact', () => {
+          expect(plugin.createTestArtifact).toHaveBeenCalledTimes(1);
+        });
 
-      it('should schedule a discard operation for the first artifact and specify itself as an initiator', () => {
-        expect(api.requestIdleCallback).toHaveBeenCalledTimes(1);
-        expect(api.requestIdleCallback.mock.calls[0]).toEqual([expect.any(Function)]);
-      });
+        it('should schedule a discard operation for the first artifact and specify itself as an initiator', () => {
+          expect(api.requestIdleCallback).toHaveBeenCalledTimes(1);
+          expect(api.requestIdleCallback.mock.calls[0]).toEqual([expect.any(Function)]);
+        });
 
-      it('should ultimately discard and untrack the first artifact', async () => {
-        const [discardRequest] = api.requestIdleCallback.mock.calls[0];
+        it('should ultimately discard and untrack the first artifact', async () => {
+          const [discardRequest] = api.requestIdleCallback.mock.calls[0];
 
-        expect(plugin.snapshots.fromTest['testStart'].discard).not.toHaveBeenCalled();
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+          expect(plugin.snapshots.fromTest['testStart'].discard).not.toHaveBeenCalled();
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        await discardRequest();
+          await discardRequest();
 
-        expect(plugin.snapshots.fromTest['testStart'].discard).toHaveBeenCalledTimes(1);
-        expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
+          expect(plugin.snapshots.fromTest['testStart'].discard).toHaveBeenCalledTimes(1);
+          expect(api.untrackArtifact).toBeCalledWith(plugin.snapshots.fromTest['testStart']);
+        });
       });
-    });
 
-    describe('when an external snapshot is created in the midst of a test', function() {
-      let artifact;
+      describe('when an external snapshot is created in the midst of a test', function() {
+        let artifact;
 
-      beforeEach(async () => {
-        artifact = new ArtifactMock('screenshot');
+        beforeEach(async () => {
+          artifact = new ArtifactMock('screenshot');
 
-        await plugin.onTestStart(testSummaries.running());
-        await plugin.onCreateExternalArtifact({
-          artifact,
-          name: 'final_name',
+          await plugin.onTestStart(testSummaries.running());
+          await plugin.onCreateExternalArtifact({
+            artifact,
+            name: 'final_name',
+          });
+          await plugin.onTestDone(testSummaries.passed());
         });
-        await plugin.onTestDone(testSummaries.passed());
-      });
 
-      it('should be discarded and untracked', async () => {
-        expect(artifact.discard).not.toHaveBeenCalled();
-        expect(api.untrackArtifact).not.toHaveBeenCalled();
+        it('should be discarded and untracked', async () => {
+          expect(artifact.discard).not.toHaveBeenCalled();
+          expect(api.untrackArtifact).not.toHaveBeenCalled();
 
-        await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
+          await Promise.all(api.requestIdleCallback.mock.calls.map(s => s[0]()));
 
-        expect(artifact.discard).toHaveBeenCalled();
-        expect(api.untrackArtifact).toHaveBeenCalledWith(artifact);
+          expect(artifact.discard).toHaveBeenCalled();
+          expect(api.untrackArtifact).toHaveBeenCalledWith(artifact);
+        });
       });
     });
   });
@@ -288,6 +374,10 @@ class FakeTwoSnapshotsPerTestPlugin extends TwoSnapshotsPerTestPlugin {
     this.snapshots.fromTest = new Proxy(this.snapshots.fromTest, nonDeletable);
   }
 
+  configureAutomaticSnapshots(value) {
+    this.takeAutomaticSnapshots = value;
+  }
+
   configureToKeepArtifacts(shouldKeep) {
     this.shouldKeepArtifactOfTest = this.shouldKeepArtifactOfSession = () => shouldKeep;
   }
diff --git a/detox/src/configuration.test.js b/detox/src/configuration.test.js
index e0c865a8a..7db45ea76 100644
--- a/detox/src/configuration.test.js
+++ b/detox/src/configuration.test.js
@@ -190,6 +190,47 @@ describe('configuration', () => {
         rootDir: '.artifacts/',
       });
     });
+
+    it('should allow passing custom plugin configurations', () => {
+      expect(configuration.composeArtifactsConfig({
+        configurationName: 'custom',
+        cliConfig: {
+          takeScreenshots: 'all',
+        },
+        detoxConfig: {
+          artifacts: {
+            rootDir: 'configuration',
+            pathBuilder: _.identity,
+            plugins: {
+              screenshot: {
+                takeWhen: {
+                  testDone: true,
+                },
+              },
+              video: {
+                android: { bitRate: 4000000 },
+                simulator: { codec: "hevc" },
+              }
+            },
+          },
+        },
+        deviceConfig: {},
+      })).toMatchObject({
+        plugins: expect.objectContaining({
+          screenshot: {
+            ...schemes.pluginsAllResolved.screenshot,
+            takeWhen: {
+              testDone: true,
+            },
+          },
+          video: {
+            ...schemes.pluginsDefaultsResolved.video,
+            android: { bitRate: 4000000 },
+            simulator: { codec: "hevc" },
+          },
+        }),
+      });
+    });
   });
 
   function testFaultySession(config) {
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index ce06976d4..49d9db697 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -28,7 +28,7 @@ describe('Device', () => {
     jest.mock('./drivers/DeviceDriverBase');
     DeviceDriverBase = require('./drivers/DeviceDriverBase');
 
-    SimulatorDriver = require('./drivers/SimulatorDriver');
+    SimulatorDriver = require('./drivers/ios/SimulatorDriver');
 
     jest.mock('../client/Client');
     Client = require('../client/Client');
diff --git a/detox/src/devices/drivers/AndroidDriver.test.js b/detox/src/devices/drivers/android/AndroidDriver.test.js
similarity index 93%
rename from detox/src/devices/drivers/AndroidDriver.test.js
rename to detox/src/devices/drivers/android/AndroidDriver.test.js
index f9d2ae620..b6b709279 100644
--- a/detox/src/devices/drivers/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.test.js
@@ -7,14 +7,14 @@ describe('Android driver', () => {
   let client;
   let exec;
   beforeEach(() => {
-    jest.mock('../../utils/encoding', () => ({
+    jest.mock('../../../utils/encoding', () => ({
       encodeBase64: (x) => `base64(${x})`,
     }));
 
-    jest.mock('../android/ADB', () => mockADBClass);
-    jest.mock('../../utils/AsyncEmitter', () => mockAsyncEmitter);
-    jest.mock('../../utils/sleep', () => jest.fn().mockResolvedValue(''));
-    jest.mock('../../utils/retry', () => jest.fn().mockResolvedValue(''));
+    jest.mock('./tools/ADB', () => mockADBClass);
+    jest.mock('../../../utils/AsyncEmitter', () => mockAsyncEmitter);
+    jest.mock('../../../utils/sleep', () => jest.fn().mockResolvedValue(''));
+    jest.mock('../../../utils/retry', () => jest.fn().mockResolvedValue(''));
 
     jest.mock('./InstrumentationLogsParser', () => ({
       InstrumentationLogsParser: mockInstrumentationLogsParserClass,
@@ -23,13 +23,13 @@ describe('Android driver', () => {
     const mockLogger = {
       warn: jest.fn(),
     };
-    jest.mock('../../utils/logger', () => ({
+    jest.mock('../../../utils/logger', () => ({
       child: () => mockLogger,
       ...mockLogger,
     }));
-    logger = require('../../utils/logger');
+    logger = require('../../../utils/logger');
 
-    jest.mock('../../utils/exec', () => ({
+    jest.mock('../../../utils/exec', () => ({
       spawnAndLog: jest.fn().mockReturnValue({
         childProcess: {
           on: jest.fn(),
@@ -41,7 +41,7 @@ describe('Android driver', () => {
       }),
       interruptProcess: jest.fn(),
     }));
-    exec = require('../../utils/exec');
+    exec = require('../../../utils/exec');
 
     client = {
       configuration: {
diff --git a/detox/src/devices/drivers/android/emulator/AVDValidator.test.js b/detox/src/devices/drivers/android/emulator/AVDValidator.test.js
new file mode 100644
index 000000000..6ea639e7c
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/AVDValidator.test.js
@@ -0,0 +1,53 @@
+describe('AVD validator', () => {
+  let mockAvdsResolver;
+
+  class MockAVDsResolver {
+    constructor(...args) {
+      mockAvdsResolver.ctor(...args);
+      this.resolve = mockAvdsResolver.resolve;
+    }
+  }
+
+  const emulatorExec = {};
+  let uut;
+  beforeEach(() => {
+    mockAvdsResolver = {
+      ctor: jest.fn(),
+      resolve: jest.fn().mockResolvedValue(['mock-avd-name']),
+    };
+    jest.mock('./AVDsResolver', () => MockAVDsResolver);
+
+    const AVDValidator = require('./AVDValidator');
+    uut = new AVDValidator(emulatorExec);
+  });
+
+  it('should use an AVDs resolver', async () => {
+    await uut.validate('mock-avd-name');
+
+    expect(mockAvdsResolver.ctor).toHaveBeenCalledWith(emulatorExec);
+    expect(mockAvdsResolver.resolve).toHaveBeenCalledWith('mock-avd-name');
+  });
+
+  it('should return safely if AVD exists', async () => {
+    mockAvdsResolver.resolve.mockResolvedValue(['mock-avd-name']);
+    await uut.validate('mock-avd-name');
+  });
+
+  it('should throw if no AVDs found', async () => {
+    mockAvdsResolver.resolve.mockResolvedValue(undefined);
+
+    try {
+      await uut.validate();
+      fail('expected to throw');
+    } catch (err) {}
+  });
+
+  it('should throw if specific AVD not found', async () => {
+    mockAvdsResolver.resolve.mockResolvedValue(['other-avd', 'yet-another']);
+
+    try {
+      await uut.validate();
+      fail('expected to throw');
+    } catch (err) {}
+  });
+});
diff --git a/detox/src/devices/drivers/android/emulator/AVDsResolver.test.js b/detox/src/devices/drivers/android/emulator/AVDsResolver.test.js
new file mode 100644
index 000000000..45747d672
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/AVDsResolver.test.js
@@ -0,0 +1,28 @@
+describe('AVDs resolver', () => {
+  let MockListAVDsCommand;
+  let emulatorExec;
+  let uut;
+  beforeEach(() => {
+    MockListAVDsCommand = jest.genMockFromModule('../tools/EmulatorExec').ListAVDsCommand;
+    jest.mock('../tools/EmulatorExec', () => ({
+      ListAVDsCommand: MockListAVDsCommand,
+    }));
+
+    emulatorExec = {
+      exec: jest.fn().mockResolvedValue(''),
+    };
+
+    const AVDsResolver = require('./AVDsResolver');
+    uut = new AVDsResolver(emulatorExec);
+  });
+
+  it('should exec command', async () => {
+    await uut.resolve();
+    expect(emulatorExec.exec).toHaveBeenCalledWith(expect.any(MockListAVDsCommand));
+  });
+
+  it('should parse emulators list given as text', async () => {
+    emulatorExec.exec.mockResolvedValue('avd-device1\navd-device2\n');
+    expect(await uut.resolve()).toEqual(['avd-device1', 'avd-device2']);
+  });
+});
diff --git a/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.test.js b/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.test.js
new file mode 100644
index 000000000..53409ed52
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/EmulatorVersionResolver.test.js
@@ -0,0 +1,86 @@
+describe('Emulator binary version', () => {
+  const versionResult = [
+    'Android emulator version 30.1.2.3 (build_id 6306047) (CL:N/A)',
+    "Copyright (C) 2006-2017 The Android Open Source Project and many others.",
+    "This program is a derivative of the QEMU CPU emulator (www.qemu.org).",
+    "  This software is licensed under the terms of the GNU General Public",
+    "  License version 2, as published by the Free Software Foundation, and",
+    "  may be copied, distributed, and modified under those terms.",
+    "  This program is distributed in the hope that it will be useful,",
+    "  but WITHOUT ANY WARRANTY; without even the implied warranty of",
+    "  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the",
+    "  GNU General Public License for more details.",
+  ].join('\n');
+  const expectedVersion = {
+    major: 30,
+    minor: 1,
+    patch: 2,
+  };
+
+  let MockQueryVersionCommand;
+  let emulatorExec;
+  let log;
+  let uut;
+  beforeEach(() => {
+    MockQueryVersionCommand = jest.genMockFromModule('../tools/EmulatorExec').QueryVersionCommand;
+    jest.mock('../tools/EmulatorExec', () => ({
+      QueryVersionCommand: MockQueryVersionCommand,
+    }));
+
+    emulatorExec = {
+      exec: jest.fn().mockResolvedValue(versionResult),
+    };
+
+    jest.mock('../../../../utils/logger', () => ({
+      child: jest.fn().mockReturnValue({
+        debug: jest.fn(),
+        warn: jest.fn(),
+      }),
+    }));
+    log = require('../../../../utils/logger').child();
+
+    const EmulatorVersionResolver = require('./EmulatorVersionResolver');
+    uut = new EmulatorVersionResolver(emulatorExec);
+  });
+
+  it('should query the emulator', async () => {
+    await uut.resolve();
+    expect(emulatorExec.exec).toHaveBeenCalledWith(expect.any(MockQueryVersionCommand));
+  });
+
+  it('should extract version from common log', async () => {
+    const version = await uut.resolve();
+    expect(version).toEqual(expectedVersion);
+  });
+
+  it('should return null for an empty query result', async () => {
+    emulatorExec.exec.mockResolvedValue(undefined);
+    const version = await uut.resolve();
+    expect(version).toEqual(null);
+  });
+
+  it('should return null for a query result that has an invalid syntax', async () => {
+    emulatorExec.exec.mockResolvedValue('Android emulator version \<invalid\> (build_id 6306047) (CL:N/A)');
+    const version = await uut.resolve();
+    expect(version).toEqual(null);
+  });
+
+  it('should cache the version', async () => {
+    await uut.resolve();
+    const version = await uut.resolve();
+
+    expect(emulatorExec.exec).toHaveBeenCalledTimes(1);
+    expect(version).toEqual(expectedVersion);
+  });
+
+  it('should log the version', async () => {
+    await uut.resolve();
+    expect(log.debug).toHaveBeenCalledWith({event: 'EMU_BIN_VERSION_DETECT', success: true}, expect.any(String), expectedVersion);
+  });
+
+  it('should log in case of an error', async () => {
+    emulatorExec.exec.mockResolvedValue('mock result');
+    await uut.resolve();
+    expect(log.warn).toHaveBeenCalledWith({event: 'EMU_BIN_VERSION_DETECT', success: false}, expect.any(String), 'mock result');
+  });
+});
diff --git a/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
new file mode 100644
index 000000000..627636dd8
--- /dev/null
+++ b/detox/src/devices/drivers/android/emulator/FreeEmulatorFinder.test.js
@@ -0,0 +1,106 @@
+describe('emulators lookup helper', () => {
+  const mockAdb = {};
+  const mockAvdName = 'mock-avd';
+
+  class MockAdbDevicesHelper {
+    constructor(...args) {
+      adbDevicesHelper.ctor(...args);
+    }
+
+    lookupDevice(...args) {
+      return adbDevicesHelper.lookupDevice(...args);
+    }
+  }
+
+  let adbDevicesHelper;
+  beforeEach(() => {
+    const AdbDevicesHelper = jest.genMockFromModule('../tools/AdbDevicesHelper');
+    adbDevicesHelper = new AdbDevicesHelper();
+    adbDevicesHelper.ctor = jest.fn();
+    jest.mock('../tools/AdbDevicesHelper', () => MockAdbDevicesHelper);
+  });
+
+  let mockDeviceRegistry;
+  let uut;
+  beforeEach(() => {
+    jest.mock('../../../../utils/logger', () => ({
+      child: jest.fn().mockReturnValue({
+        debug: jest.fn(),
+      }),
+    }));
+
+    mockDeviceRegistry = {
+      isDeviceBusy: jest.fn().mockReturnValue(false),
+    };
+
+    const FreeEmulatorFinder = require('./FreeEmulatorFinder');
+    uut = new FreeEmulatorFinder(mockAdb, mockDeviceRegistry, mockAvdName);
+  });
+
+  it('should create an adb devices helper', async () => {
+    expect(adbDevicesHelper.ctor).toHaveBeenCalledWith(mockAdb);
+  });
+
+  it('should pass a custom matcher func onto adb-devices helper for finding a free emulator', async () => {
+    await uut.findFreeDevice();
+    expect(adbDevicesHelper.lookupDevice).toHaveBeenCalledWith(expect.any(Function));
+  });
+
+  it('should return the matched emulator', async () => {
+    const adbName = 'mock-adb-name';
+    adbDevicesHelper.lookupDevice.mockReturnValue(adbName);
+
+    const deviceName = await uut.findFreeDevice();
+    expect(deviceName).toEqual(adbName);
+  });
+
+  describe('Device lookup matcher function', () => {
+    it('should restrict matching to emulators', async () => {
+      const emuDevice = anEmulatorDevice();
+      const nonEmuDevice = aNonEmuDevice();
+
+      await uut.findFreeDevice();
+
+      const matcherFn = resolveMatcherFn();
+      expect(await matcherFn(emuDevice)).toEqual(true);
+      expect(await matcherFn(nonEmuDevice)).toEqual(false);
+    });
+
+    it('should restrict to free (unoccupied) devices', async () => {
+      const device = anEmulatorDevice();
+      mockDeviceRegistry.isDeviceBusy.mockReturnValue(true);
+
+      await uut.findFreeDevice();
+
+      const matcherFn = resolveMatcherFn();
+      expect(await matcherFn(device)).toEqual(false);
+      expect(mockDeviceRegistry.isDeviceBusy).toHaveBeenCalledWith('mock-name');
+    });
+
+    it('should restrict to the AVD name in question', async () => {
+      const device = {
+        ...anEmulatorDevice(),
+        queryName: jest.fn().mockResolvedValue('other ' + mockAvdName),
+      };
+
+      await uut.findFreeDevice();
+
+      const matcherFn = resolveMatcherFn();
+      expect(await matcherFn(device)).toEqual(false);
+    });
+
+    const resolveMatcherFn = () => adbDevicesHelper.lookupDevice.mock.calls[0][0];
+  });
+
+  const anEmulatorDevice = () => ({
+    type: 'emulator',
+    adbName: 'mock-name',
+    queryName: jest.fn().mockResolvedValue(mockAvdName),
+  });
+
+  const aNonEmuDevice = () => ({
+    type: 'non-emulator',
+    adbName: 'mock-name',
+    queryName: jest.fn().mockResolvedValue(mockAvdName),
+  });
+});
diff --git a/detox/src/devices/android/AAPT.test.js b/detox/src/devices/drivers/android/tools/AAPT.test.js
similarity index 97%
rename from detox/src/devices/android/AAPT.test.js
rename to detox/src/devices/drivers/android/tools/AAPT.test.js
index 4fc2c5bb4..245c08e17 100644
--- a/detox/src/devices/android/AAPT.test.js
+++ b/detox/src/devices/drivers/android/tools/AAPT.test.js
@@ -5,7 +5,7 @@ xdescribe('AAPT', () => {
   let exec;
 
   beforeEach(() => {
-    jest.mock('../../utils/logger');
+    jest.mock('../../../utils/logger');
     jest.mock('child-process-promise');
     exec = require('child-process-promise').exec;
 
@@ -67,4 +67,4 @@ supports-screens: 'small' 'normal' 'large' 'xlarge'
 supports-any-density: 'true'
 locales: '--_--' 'ca' 'da' 'fa' 'ja' 'nb' 'de' 'af' 'bg' 'th' 'fi' 'hi' 'vi' 'sk' 'uk' 'el' 'nl' 'pl' 'sl' 'tl' 'am' 'in' 'ko' 'ro' 'ar' 'fr' 'hr' 'sr' 'sr-Latn' 'tr' 'cs' 'es' 'it' 'lt' 'pt' 'hu' 'ru' 'zu' 'lv' 'sv' 'iw' 'sw' 'bs-BA' 'fr-CA' 'lo-LA' 'en-GB' 'bn-BD' 'et-EE' 'ka-GE' 'ky-KG' 'km-KH' 'zh-HK' 'si-LK' 'mk-MK' 'ur-PK' 'sq-AL' 'hy-AM' 'my-MM' 'zh-CN' 'pa-IN' 'ta-IN' 'te-IN' 'ml-IN' 'en-IN' 'kn-IN' 'mr-IN' 'gu-IN' 'mn-MN' 'ne-NP' 'pt-BR' 'gl-ES' 'eu-ES' 'is-IS' 'es-US' 'pt-PT' 'en-AU' 'zh-TW' 'be-BY' 'ms-MY' 'az-AZ' 'kk-KZ' 'uz-UZ'
 densities: '160' '240' '320' '480' '640'
-native-code: 'armeabi-v7a' 'x86'`;
\ No newline at end of file
+native-code: 'armeabi-v7a' 'x86'`;
diff --git a/detox/src/devices/android/ADB.test.js b/detox/src/devices/drivers/android/tools/ADB.test.js
similarity index 96%
rename from detox/src/devices/android/ADB.test.js
rename to detox/src/devices/drivers/android/tools/ADB.test.js
index fd6e1878a..bcfac0f21 100644
--- a/detox/src/devices/android/ADB.test.js
+++ b/detox/src/devices/drivers/android/tools/ADB.test.js
@@ -8,11 +8,11 @@ describe('ADB', () => {
   let exec;
 
   beforeEach(() => {
-    jest.mock('../../utils/logger');
-    jest.mock('../../utils/environment');
-    require('../../utils/environment').getAdbPath.mockReturnValue(adbBinPath);
+    jest.mock('../../../../utils/logger');
+    jest.mock('../../../../utils/environment');
+    require('../../../../utils/environment').getAdbPath.mockReturnValue(adbBinPath);
 
-    jest.mock('../../utils/encoding', () => ({
+    jest.mock('../../../../utils/encoding', () => ({
       encodeBase64: (text) => `base64(${text})`,
     }));
 
@@ -30,12 +30,12 @@ describe('ADB', () => {
     }
     jest.mock('./EmulatorTelnet', () => MockEmulatorTelnet);
 
-    jest.mock('../../utils/exec', () => {
+    jest.mock('../../../../utils/exec', () => {
       const exec = jest.fn();
       exec.mockReturnValue({ stdout: '' });
       return { execWithRetriesAndLogs: exec };
     });
-    exec = require('../../utils/exec').execWithRetriesAndLogs;
+    exec = require('../../../../utils/exec').execWithRetriesAndLogs;
 
     ADB = require('./ADB');
     adb = new ADB();
diff --git a/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js
new file mode 100644
index 000000000..f8cec008f
--- /dev/null
+++ b/detox/src/devices/drivers/android/tools/AdbDevicesHelper.test.js
@@ -0,0 +1,81 @@
+describe('ADB devices helper', () => {
+  let adbMock;
+  let DeviceAllocation;
+  let deviceAllocation;
+
+  beforeEach(() => {
+    adbMock = {
+      devices: jest.fn().mockResolvedValue(new Error('todo in unit test')),
+    };
+
+    DeviceAllocation = require('./AdbDevicesHelper');
+    deviceAllocation = new DeviceAllocation(adbMock);
+  });
+
+  describe('find device', () => {
+    it('should query for devices', async () => {
+      const matchFn = jest.fn().mockResolvedValue(true);
+
+      mockNoAdbDevices();
+      await deviceAllocation.lookupDevice(matchFn);
+      expect(adbMock.devices).toHaveBeenCalled();
+    });
+
+    it('should return null if there are no devices', async () => {
+      const matchFn = jest.fn().mockResolvedValue(true);
+
+      mockNoAdbDevices();
+      const device = await deviceAllocation.lookupDevice(matchFn);
+      expect(device).toEqual(null);
+    });
+
+    it('should look up a device', async () => {
+      const matchFn = jest.fn().mockResolvedValue(true);
+
+      const device = aDevice();
+      mockAdbDevices([device]);
+
+      const matchingDeviceName = await deviceAllocation.lookupDevice(matchFn);
+      expect(matchingDeviceName).toEqual(device.adbName);
+    });
+
+    it('should return null for non-matching device', async () => {
+      const matchFn = jest.fn().mockResolvedValue(false);
+
+      const device = aDevice();
+      mockAdbDevices([device]);
+
+      const matchingDeviceName = await deviceAllocation.lookupDevice(matchFn);
+      expect(matchingDeviceName).toEqual(null);
+    });
+
+    it('should return first suitable device', async () => {
+      const matchFn = jest.fn()
+        .mockResolvedValueOnce(false)
+        .mockResolvedValueOnce(true)
+        .mockResolvedValueOnce(true);
+
+      mockAdbDevices([
+        aDevice('device1'),
+        aDevice('device2'),
+        aDevice('device3'),
+      ]);
+
+      const matchinDeviceName = await deviceAllocation.lookupDevice(matchFn);
+      expect(matchinDeviceName).toEqual('device2');
+    });
+  });
+
+  const aDevice = (adbName = 'mock-name') => ({
+    type: 'mock-type',
+    adbName,
+  });
+
+  const mockAdbDevices = (devices) => {
+    adbMock.devices.mockResolvedValue({
+      devices,
+    });
+  };
+
+  const mockNoAdbDevices = () => mockAdbDevices([]);
+});
diff --git a/detox/src/devices/drivers/SimulatorDriver.test.js b/detox/src/devices/drivers/ios/SimulatorDriver.test.js
similarity index 98%
rename from detox/src/devices/drivers/SimulatorDriver.test.js
rename to detox/src/devices/drivers/ios/SimulatorDriver.test.js
index 57c0071ec..1b597b95f 100644
--- a/detox/src/devices/drivers/SimulatorDriver.test.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.test.js
@@ -5,7 +5,7 @@ describe('IOS simulator driver', () => {
   const bundleId = 'bundle-id-mock';
 
   beforeEach(() => {
-    jest.mock('../ios/AppleSimUtils', () => mockAppleSimUtils);
+    jest.mock('./tools/AppleSimUtils', () => mockAppleSimUtils);
   });
 
   describe('launch args', () => {
@@ -88,7 +88,7 @@ describe('IOS simulator driver', () => {
       uut = new SimulatorDriver({ client: {} });
       jest.spyOn(uut.deviceRegistry, 'isDeviceBusy').mockReturnValue(false);
       applesimutils = uut.applesimutils;
-      applesimutils.list.mockImplementation(async () => require('../ios/applesimutils.mock')['--list']);
+      applesimutils.list.mockImplementation(async () => require('./tools/applesimutils.mock')['--list']);
     });
 
     it('should accept string as device type', async () => {
diff --git a/detox/src/devices/ios/applesimutils.mock.json b/detox/src/devices/drivers/ios/tools/applesimutils.mock.json
similarity index 100%
rename from detox/src/devices/ios/applesimutils.mock.json
rename to detox/src/devices/drivers/ios/tools/applesimutils.mock.json
diff --git a/detox/src/devices/ios/xcrunSimctlList.mock.json b/detox/src/devices/drivers/ios/tools/xcrunSimctlList.mock.json
similarity index 100%
rename from detox/src/devices/ios/xcrunSimctlList.mock.json
rename to detox/src/devices/drivers/ios/tools/xcrunSimctlList.mock.json
diff --git a/detox/test/package.json b/detox/test/package.json
index 094a96722..1e65cb11e 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "16.0.1",
+  "version": "16.0.2",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -31,7 +31,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^16.0.1",
+    "detox": "^16.0.2",
     "express": "^4.15.3",
     "jest": "25.1.x",
     "jest-circus": "25.1.x",
@@ -46,6 +46,15 @@
       "server": "ws://localhost:8099",
       "sessionId": "test"
     },
+    "artifacts": {
+      "plugins": {
+        "screenshot": {
+          "takeWhen": {
+            "testDone": true
+          }
+        }
+      }
+    },
     "configurations": {
       "ios.sim.debug": {
         "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/example.app",