Repository: wix/Detox (js)

Issue:
Artifacts Manager should disable features it cannot support for ios.none configurations
Right now it attempts nonsensical commands with `undefined` and then prints errors:

```
detox[6105] ERROR: [exec.js/EXEC_FAIL, #19] "/usr/bin/xcrun simctl io undefined screenshot "/private/var/folders/d0/q423mrk13bn_bx8wr416nsq40rv2wb/T/18aed3bb-046f-4f2a-a6bd-972cb6c98fd7.detox.png"" failed with code = 148, stdout and stderr:
detox[6105] ERROR: [exec.js/EXEC_FAIL, #19] 
detox[6105] ERROR: [exec.js/EXEC_FAIL, #19] Invalid device: undefined
detox[6105] WARN:  [ArtifactsManager.js/SUPPRESS_PLUGIN_ERROR] Suppressed error inside function call: SimulatorScreenshotter.onTestDone({
  title: ':ios: should choose from multiple elements matching the same matcher using index',
  fullName: 'Matchers :ios: should choose from multiple elements matching the same matcher using index',
  status: 'passed',
  timedOut: false
})
  err: ChildProcessError: Command failed: /usr/bin/xcrun simctl io undefined screenshot "/private/var/folders/d0/q423mrk13bn_bx8wr416nsq40rv2wb/T/18aed3bb-046f-4f2a-a6bd-972cb6c98fd7.detox.png"
  Invalid device: undefined
   `/usr/bin/xcrun simctl io undefined screenshot "/private/var/folders/d0/q423mrk13bn_bx8wr416nsq40rv2wb/T/18aed3bb-046f-4f2a-a6bd-972cb6c98fd7.detox.png"` (exited with error code 148)
      at callback (/Users/lnatan/Desktop/Code/Detox/detox/node_modules/child-process-promise/lib/index.js:33:27)
      at ChildProcess.exithandler (child_process.js:308:5)
      at ChildProcess.emit (events.js:219:5)
      at maybeClose (internal/child_process.js:1027:16)
      at Socket.<anonymous> (internal/child_process.js:442:11)
      at Socket.emit (events.js:219:5)
      at Pipe.<anonymous> (net.js:666:12)
```

Pull request description:
fix(ios): disable artifacts plugins with incomplete support
- [x] This change has been discussed in issue #2162 and the solution has been agreed upon with maintainers.

---

Resolves #2162 for `ios.none` configuration.


Patch:
diff --git a/detox/local-cli/utils/misc.js b/detox/local-cli/utils/misc.js
index 3d23e2b56..bbda90d42 100644
--- a/detox/local-cli/utils/misc.js
+++ b/detox/local-cli/utils/misc.js
@@ -28,6 +28,8 @@ function prependNodeModulesBinToPATH(env) {
   if (!env[PATH].startsWith(nodeBinariesPath)) {
     env[PATH] = nodeBinariesPath + env[PATH];
   }
+
+  return env[PATH];
 }
 
 module.exports = {
diff --git a/detox/package.json b/detox/package.json
index e76db098d..dba09f197 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -113,6 +113,7 @@
       "src/utils/logger.js",
       "src/utils/onTerminate.js",
       "src/utils/pipeCommands.js",
+      "src/utils/pressAnyKey.js",
       "src/utils/sleep.js",
       "AAPT.js",
       "ADB.js",
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index cab2ee80e..e97ed6743 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -153,6 +153,7 @@ class Detox {
     });
 
     this.device = new Device({
+      behaviorConfig: this._behaviorConfig,
       deviceConfig: this._deviceConfig,
       emitter: this._eventEmitter,
       deviceDriver,
diff --git a/detox/src/configuration/composeArtifactsConfig.js b/detox/src/configuration/composeArtifactsConfig.js
index df0422082..f9db033f1 100644
--- a/detox/src/configuration/composeArtifactsConfig.js
+++ b/detox/src/configuration/composeArtifactsConfig.js
@@ -29,17 +29,7 @@ function composeArtifactsConfig({
     }),
     extendArtifactsConfig(deviceConfig.artifacts),
     extendArtifactsConfig(detoxConfig.artifacts),
-    extendArtifactsConfig({
-      rootDir: 'artifacts',
-      pathBuilder: null,
-      plugins: {
-        log: 'none',
-        screenshot: 'manual',
-        video: 'none',
-        instruments: 'none',
-        timeline: 'none',
-      },
-    }),
+    extendArtifactsConfig(false),
   );
 
   artifactsConfig.rootDir = buildDefaultArtifactsRootDirpath(
@@ -53,6 +43,20 @@ function composeArtifactsConfig({
 }
 
 function extendArtifactsConfig(config) {
+  if (config === false) {
+    return extendArtifactsConfig({
+      rootDir: 'artifacts',
+      pathBuilder: null,
+      plugins: {
+        log: 'none',
+        screenshot: 'manual',
+        video: 'none',
+        instruments: 'none',
+        timeline: 'none',
+      },
+    });
+  }
+
   const p = config && config.plugins;
   if (!p) {
     return config;
diff --git a/detox/src/configuration/composeBehaviorConfig.js b/detox/src/configuration/composeBehaviorConfig.js
index dc5a40ad2..798b154c3 100644
--- a/detox/src/configuration/composeBehaviorConfig.js
+++ b/detox/src/configuration/composeBehaviorConfig.js
@@ -1,35 +1,43 @@
 const _ = require('lodash');
 
 function composeBehaviorConfig({ cliConfig, detoxConfig, deviceConfig, userParams }) {
-  return _.defaultsDeep(
-    {
-      init: {
-        reinstallApp: cliConfig.reuse ? false : undefined,
+  return _.chain({})
+    .defaultsDeep(
+      {
+        init: {
+          reinstallApp: cliConfig.reuse ? false : undefined,
+        },
+        cleanup: {
+          shutdownDevice: cliConfig.cleanup ? true : undefined,
+        },
       },
-      cleanup: {
-        shutdownDevice: cliConfig.cleanup ? true : undefined,
+      userParams && {
+        init: {
+          exposeGlobals: userParams.initGlobals,
+          launchApp: userParams.launchApp,
+          reinstallApp: negateDefined(userParams.reuse),
+        },
       },
-    },
-    userParams && {
-      init: {
-        exposeGlobals: userParams.initGlobals,
-        launchApp: userParams.launchApp,
-        reinstallApp: negateDefined(userParams.reuse),
-      },
-    },
-    deviceConfig.behavior,
-    detoxConfig.behavior,
-    {
-      init: {
-        exposeGlobals: true,
-        reinstallApp: true,
-        launchApp: true,
-      },
-      cleanup: {
-        shutdownDevice: false,
-      },
-    }
-  );
+      deviceConfig.behavior,
+      detoxConfig.behavior,
+      {
+        init: {
+          exposeGlobals: true,
+          reinstallApp: undefined,
+          launchApp: true,
+        },
+        launchApp: 'auto',
+        cleanup: {
+          shutdownDevice: false,
+        },
+      }
+    )
+    .tap(config => {
+      if (config.init.reinstallApp === undefined) {
+        config.init.reinstallApp = config.launchApp !== 'manual';
+      }
+    })
+    .value();
 }
 
 function negateDefined(x) {
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 9c446553a..e54bfd238 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -1,9 +1,17 @@
 const _ = require('lodash');
 const debug = require('../utils/debug'); // debug utils, leave here even if unused
 const { traceCall } = require('../utils/trace');
+const log = require('../utils/logger').child({ __filename });
 
 class Device {
-  constructor({ deviceConfig, deviceDriver, emitter, sessionConfig }) {
+  constructor({
+    behaviorConfig,
+    deviceConfig,
+    deviceDriver,
+    emitter,
+    sessionConfig
+  }) {
+    this._behaviorConfig = behaviorConfig;
     this._deviceConfig = deviceConfig;
     this._sessionConfig = sessionConfig;
     this._emitter = emitter;
@@ -22,9 +30,9 @@ class Device {
   }
 
   async launchApp(params = {newInstance: false}, bundleId) {
-    return traceCall('launchApp', () =>
-      this._doLaunchApp(params, bundleId));
+    return traceCall('launchApp', () => this._doLaunchApp(params, bundleId));
   }
+
   async _doLaunchApp(params, bundleId) {
     const payloadParams = ['url', 'userNotification', 'userActivity'];
     const hasPayload = this._assertHasSingleParam(payloadParams, params);
@@ -67,11 +75,16 @@ class Device {
       }
     }
 
-    const processId = await this.deviceDriver.launchApp(this._deviceId, _bundleId, this._prepareLaunchArgs(baseLaunchArgs), params.languageAndLocale);
+    let processId;
+    if (this._behaviorConfig.launchApp === 'manual') {
+      processId = await this.deviceDriver.waitForAppLaunch(this._deviceId, _bundleId, this._prepareLaunchArgs(baseLaunchArgs), params.languageAndLocale);
+    } else {
+      processId = await this.deviceDriver.launchApp(this._deviceId, _bundleId, this._prepareLaunchArgs(baseLaunchArgs), params.languageAndLocale);
+      await this.deviceDriver.waitUntilReady();
+      await this.deviceDriver.waitForActive();
+    }
     this._processes[_bundleId] = processId;
 
-    await this.deviceDriver.waitUntilReady();
-    await this.deviceDriver.waitForActive();
     await this._emitter.emit('appReady', {
       deviceId: this._deviceId,
       bundleId: _bundleId,
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index 697985bc5..b500dcaf8 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -27,7 +27,11 @@ class DeviceDriverBase {
   }
 
   async launchApp() {
-    return await Promise.resolve('');
+    return await Promise.resolve(NaN);
+  }
+
+  async waitForAppLaunch() {
+    return await Promise.resolve(NaN);
   }
 
   async takeScreenshot(deviceId, screenshotName) {
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index 76275d78f..a768a2ba5 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -24,6 +24,7 @@ const TimelineArtifactPlugin = require('../../../artifacts/timeline/TimelineArti
 const temporaryPath = require('../../../artifacts/utils/temporaryPath');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
+const pressAnyKey = require('../../../utils/pressAnyKey');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
 
 class AndroidDriver extends DeviceDriverBase {
@@ -87,14 +88,43 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
+    return await this._handleLaunchApp({
+      manually: false,
+      deviceId,
+      bundleId,
+      launchArgs,
+      languageAndLocale,
+    });
+  }
+
+  async waitForAppLaunch(deviceId, bundleId, launchArgs, languageAndLocale) {
+    return await this._handleLaunchApp({
+      manually: true,
+      deviceId,
+      bundleId,
+      launchArgs,
+      languageAndLocale,
+    });
+  }
+
+  async _handleLaunchApp({ manually, deviceId, bundleId, launchArgs, languageAndLocale }) {
     const adbName = this._getAdbName(deviceId);
 
     await this.emitter.emit('beforeLaunchApp', { deviceId: adbName, bundleId, launchArgs });
 
     launchArgs = await this._modifyArgsForNotificationHandling(adbName, bundleId, launchArgs);
-    await this._launchApp(adbName, bundleId, launchArgs);
+
+    if (manually) {
+      await this._waitForAppLaunch(adbName, bundleId, launchArgs);
+    } else {
+      await this._launchApp(adbName, bundleId, launchArgs);
+    }
 
     const pid = await this._waitForProcess(adbName, bundleId);
+    if (manually) {
+      log.info({}, `Found the app (${bundleId}) with process ID = ${pid}. Proceeding...`);
+    }
+
     await this.emitter.emit('launchApp', { deviceId: adbName, bundleId, launchArgs, pid });
     return pid;
   }
@@ -246,9 +276,7 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async _launchInstrumentationProcess(adbName, bundleId, userLaunchArgs) {
-    const serverPort = new URL(this.client.configuration.server).port;
-    await this.adb.reverse(adbName, serverPort);
-
+    const serverPort = await this._reverseServerPort(adbName);
     this.instrumentation.setTerminationFn(async () => {
       await this._terminateInstrumentation();
       await this.adb.reverseRemove(adbName, serverPort);
@@ -256,6 +284,12 @@ class AndroidDriver extends DeviceDriverBase {
     await this.instrumentation.launch(adbName, bundleId, userLaunchArgs);
   }
 
+  async _reverseServerPort(adbName) {
+    const serverPort = new URL(this.client.configuration.server).port;
+    await this.adb.reverse(adbName, serverPort);
+    return serverPort;
+  }
+
   async _terminateInstrumentation() {
     await this.instrumentation.terminate();
     await this.instrumentation.setTerminationFn(null);
@@ -303,6 +337,40 @@ class AndroidDriver extends DeviceDriverBase {
   _getAdbName(deviceId) {
     return _.isObjectLike(deviceId) ? deviceId.adbName : deviceId;
   }
+
+  async _waitForAppLaunch(adbName, bundleId, launchArgs) {
+    const instrumentationClass = await this.adb.getInstrumentationRunner(adbName, bundleId);
+    this._printInstrumentationHint({ instrumentationClass, launchArgs });
+    await pressAnyKey();
+    await this._reverseServerPort(adbName);
+  }
+
+  _printInstrumentationHint({ instrumentationClass, launchArgs }) {
+    const keyMaxLength = Math.max(3, _(launchArgs).keys().maxBy('length').length);
+    const valueMaxLength = Math.max(5, _(launchArgs).values().map(String).maxBy('length').length);
+    const rows = _.map(launchArgs, (v, k) => {
+      const paddedKey = k.padEnd(keyMaxLength, ' ');
+      const paddedValue = `${v}`.padEnd(valueMaxLength, ' ');
+      return `${paddedKey} | ${paddedValue}`;
+    });
+
+    const keyHeader = 'Key'.padEnd(keyMaxLength, ' ')
+    const valueHeader = 'Value'.padEnd(valueMaxLength, ' ')
+    const header = `${keyHeader} | ${valueHeader}`;
+    const separator = '-'.repeat(header.length);
+
+    log.info({},
+      'Waiting for you to manually launch your app in Android Studio.\n\n' +
+      `Instrumentation class: ${instrumentationClass}\n` +
+      'Instrumentation arguments:\n' +
+      `${separator}\n` +
+      `${header}\n` +
+      `${separator}\n` +
+      `${rows.join('\n')}\n` +
+      `${separator}\n\n` +
+      'Press any key to continue...'
+    );
+  }
 }
 
 module.exports = AndroidDriver;
diff --git a/detox/src/devices/drivers/ios/IosDriver.js b/detox/src/devices/drivers/ios/IosDriver.js
index ee46eea97..aa1dde487 100644
--- a/detox/src/devices/drivers/ios/IosDriver.js
+++ b/detox/src/devices/drivers/ios/IosDriver.js
@@ -1,32 +1,16 @@
 const path = require('path');
 const fs = require('fs');
 const DeviceDriverBase = require('../DeviceDriverBase');
-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 IosUIHierarchyPlugin = require('../../../artifacts/uiHierarchy/IosUIHierarchyPlugin');
 
 class IosDriver extends DeviceDriverBase {
-  constructor(config) {
-    super(config);
-
-    this.applesimutils = new AppleSimUtils();
-  }
-
   declareArtifactPlugins() {
-    const appleSimUtils = this.applesimutils;
     const client = this.client;
 
     return {
-      instruments: (api) => new SimulatorInstrumentsPlugin({ api, client }),
-      log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }),
-      screenshot: (api) => new SimulatorScreenshotPlugin({ api, appleSimUtils }),
-      video: (api) => new SimulatorRecordVideoPlugin({ api, appleSimUtils }),
-      timeline: (api) => new TimelineArtifactPlugin({api, appleSimUtils}),
+      timeline: (api) => new TimelineArtifactPlugin({ api }),
       uiHierarchy: (api) => new IosUIHierarchyPlugin({ api, client }),
     };
   }
diff --git a/detox/src/devices/drivers/ios/SimulatorDriver.js b/detox/src/devices/drivers/ios/SimulatorDriver.js
index a28181d78..1818b5993 100644
--- a/detox/src/devices/drivers/ios/SimulatorDriver.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.js
@@ -4,18 +4,26 @@ const path = require('path');
 const exec = require('child-process-promise').exec;
 const DeviceRegistry = require('../../DeviceRegistry');
 const IosDriver = require('./IosDriver');
+const AppleSimUtils = require('./tools/AppleSimUtils');
+const SimulatorInstrumentsPlugin = require('../../../artifacts/instruments/ios/SimulatorInstrumentsPlugin');
+const SimulatorLogPlugin = require('../../../artifacts/log/ios/SimulatorLogPlugin');
+const SimulatorRecordVideoPlugin = require('../../../artifacts/video/SimulatorRecordVideoPlugin');
+const SimulatorScreenshotPlugin = require('../../../artifacts/screenshot/SimulatorScreenshotPlugin');
 const temporaryPath = require('../../../artifacts/utils/temporaryPath');
 const DetoxConfigError = require('../../../errors/DetoxConfigError');
 const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const environment = require('../../../utils/environment');
 const argparse = require('../../../utils/argparse');
 const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
+const log = require('../../../utils/logger').child({ __filename });
+const pressAnyKey = require('../../../utils/pressAnyKey');
 
 class SimulatorDriver extends IosDriver {
 
   constructor(config) {
     super(config);
 
+    this.applesimutils = new AppleSimUtils();
     this.deviceRegistry = DeviceRegistry.forIOS();
     this._name = 'Unspecified Simulator';
   }
@@ -24,6 +32,20 @@ class SimulatorDriver extends IosDriver {
     return this._name;
   }
 
+  declareArtifactPlugins() {
+    const appleSimUtils = this.applesimutils;
+    const client = this.client;
+
+    return {
+      ...super.declareArtifactPlugins(),
+
+      log: (api) => new SimulatorLogPlugin({ api, appleSimUtils }),
+      screenshot: (api) => new SimulatorScreenshotPlugin({ api, appleSimUtils }),
+      video: (api) => new SimulatorRecordVideoPlugin({ api, appleSimUtils }),
+      instruments: (api) => new SimulatorInstrumentsPlugin({ api, client }),
+    };
+  }
+
   async prepare() {
     const detoxFrameworkPath = await environment.getFrameworkPath();
 
@@ -90,6 +112,27 @@ class SimulatorDriver extends IosDriver {
     return pid;
   }
 
+  async waitForAppLaunch(deviceId, bundleId, launchArgs, languageAndLocale) {
+    await this.emitter.emit('beforeLaunchApp', {bundleId, deviceId, launchArgs});
+
+    this.applesimutils.printLaunchHint(deviceId, bundleId, launchArgs, languageAndLocale);
+    await pressAnyKey();
+
+    const pid = await this.applesimutils.getPid(deviceId, bundleId);
+    if (Number.isNaN(pid)) {
+      throw new DetoxRuntimeError({
+        message: `Failed to find a process corresponding to the app bundle identifier (${bundleId}).`,
+        hint: `Make sure that the app is running on the device (${deviceId}), visually or via CLI:\n` +
+              `xcrun simctl spawn ${deviceId} launchctl list | grep -F '${bundleId}'\n`,
+      });
+    } else {
+      log.info({}, `Found the app (${bundleId}) with process ID = ${pid}. Proceeding...`);
+    }
+
+    await this.emitter.emit('launchApp', {bundleId, deviceId, launchArgs, pid});
+    return pid;
+  }
+
   async terminate(deviceId, bundleId) {
     await this.emitter.emit('beforeTerminateApp', { deviceId, bundleId });
     await this.applesimutils.terminate(deviceId, bundleId);
diff --git a/detox/src/devices/drivers/ios/tools/AppleSimUtils.js b/detox/src/devices/drivers/ios/tools/AppleSimUtils.js
index baaa9da39..c30e560dc 100644
--- a/detox/src/devices/drivers/ios/tools/AppleSimUtils.js
+++ b/detox/src/devices/drivers/ios/tools/AppleSimUtils.js
@@ -116,6 +116,15 @@ class AppleSimUtils {
     return this._parseLaunchId(result);
   }
 
+  printLaunchHint(udid, bundleId, launchArgs, languageAndLocale) {
+    log.info({},
+      'Waiting for you to manually launch your app in Xcode.\n' +
+      'Make sure to pass the launch arguments listed below:\n' +
+      this._mergeLaunchArgs(launchArgs, languageAndLocale).map(pair => `  ${pair}\n`).join(''),
+      '\nPress any key to continue...'
+    );
+  }
+
   async sendToHome(udid) {
     await this._execSimctl({ cmd: `launch ${udid} com.apple.springboard`, retries: 10 });
   }
@@ -296,36 +305,41 @@ class AppleSimUtils {
     return parsed;
   }
 
-  _joinLaunchArgs(launchArgs) {
-    return _.map(launchArgs, (v, k) => `-${k} "${v}"`).join(' ').trim();
+  _mergeLaunchArgs(launchArgs, languageAndLocale) {
+    const args = {
+      ...launchArgs,
+    };
+
+    if (languageAndLocale) {
+      if (languageAndLocale.language) {
+        args.AppleLanguages = `(${languageAndLocale.language})`;
+      }
+
+      if (languageAndLocale.locale) {
+        args.AppleLocale = languageAndLocale.locale;
+      }
+    }
+
+    return _.map(args, (v, k) => `-${k} "${v}"`); // TODO: replace naive quoting
   }
 
   async _launchMagically(frameworkPath, udid, bundleId, launchArgs, languageAndLocale) {
-    const args = this._joinLaunchArgs(launchArgs);
-
     let dylibs = `${frameworkPath}/Detox`;
     if (process.env.SIMCTL_CHILD_DYLD_INSERT_LIBRARIES) {
       dylibs = `${process.env.SIMCTL_CHILD_DYLD_INSERT_LIBRARIES}:${dylibs}`;
     }
 
+    const cmdArgs = this._mergeLaunchArgs(launchArgs, languageAndLocale).join(' ');
     let launchBin = `SIMCTL_CHILD_DYLD_INSERT_LIBRARIES="${dylibs}" ` +
-      `/usr/bin/xcrun simctl launch ${udid} ${bundleId} --args ${args}`;
-
-      if (!!languageAndLocale && !!languageAndLocale.language) {
-        launchBin += ` -AppleLanguages "(${languageAndLocale.language})"`;
-      }
+      `/usr/bin/xcrun simctl launch ${udid} ${bundleId} ${cmdArgs}`;
 
-      if (!!languageAndLocale && !!languageAndLocale.locale) {
-        launchBin += ` -AppleLocale ${languageAndLocale.locale}`;
-      }
-
-    const options = {
+    const result = await exec.execWithRetriesAndLogs(launchBin, {
       retries: 1,
       statusLogs: {
         trying: `Launching ${bundleId}...`,
       },
-    };
-    const result = await exec.execWithRetriesAndLogs(launchBin, options);
+    });
+
     return result;
   }
 
@@ -369,6 +383,20 @@ class AppleSimUtils {
   async statusBarReset(udid) {
     await this._execSimctl({ cmd: `status_bar ${udid} clear` });
   }
+
+  async getPid(udid, bundleId) {
+    const result = await this._execSimctl({
+      cmd: `spawn ${udid} launchctl list | grep -F '${bundleId}' || true`,
+      retries: 0,
+    });
+
+    if (result && result.stdout) {
+      const [pid] = result.stdout.split(/\s/);
+      return Number(pid);
+    }
+
+    return Number.NaN;
+  }
 }
 
 module.exports = AppleSimUtils;
diff --git a/detox/src/utils/pressAnyKey.js b/detox/src/utils/pressAnyKey.js
new file mode 100644
index 000000000..eb1001b35
--- /dev/null
+++ b/detox/src/utils/pressAnyKey.js
@@ -0,0 +1,25 @@
+function isCtrlC(chunk) {
+  const [chr1] = Array.from(chunk);
+  return chr1 === 3;
+}
+
+async function pressAnyKey() {
+  return new Promise((resolve) => {
+    process.stdin.resume();
+    process.stdin.setRawMode(true);
+    process.stdin.once('data', onData);
+
+    function onData(chunk) {
+      process.stdin.removeListener('data', onData);
+      process.stdin.setRawMode(false);
+      process.stdin.pause();
+      process.nextTick(resolve);
+
+      if (isCtrlC(chunk)) {
+        process.kill(process.pid, 'SIGINT');
+      }
+    }
+  });
+};
+
+module.exports = pressAnyKey;
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index 3da28c9cd..2079f4a64 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -157,6 +157,7 @@ you have a few options to change. These are the default behavior values:
         "launchApp": true,
         "exposeGlobals": true
       },
+      "launchApp": "auto",
       "cleanup": {
         "shutdownDevice": false
       }
@@ -165,8 +166,8 @@ you have a few options to change. These are the default behavior values:
 }
 ```
 
-For example, if you want to launch your tested app manually, e.g. via `device.launchApp()`,
-you should specify in the Detox configuration:
+For example, if you want to launch your tested app explicitly, i.e. via `device.launchApp()`,
+and not as an implicit part of the Detox initialization, you should specify in the Detox configuration:
 
 ```json
 {
@@ -178,8 +179,15 @@ you should specify in the Detox configuration:
 }
 ```
 
+There is one more `launchApp` property in the root of `behavior` object configuration.
+
+That `launchApp: "auto"` setting can be changed to `"manual"` for cases when you want to debug the
+native codebase when running Detox tests. Usually **you never need that**, but if you do, follow the
+[Debugging Apps in Android Studio During a Test](Guide.DebuggingInAndroidStudio.md) guide to learn
+more about this. When set to `manual`, it changes the default value of `reinstallApp` to `false`.
+
 Setting `reinstallApp: false` will make the tests reuse the currently installed app on the device,
-provided you have installed it beforehand.
+provided you have installed it beforehand explicitly or manually.
 
 If you do not wish to leak Detox globals (`expect`, `device`, `by`, etc.) to the global
 scope, you can set `"exposeGlobals": false` and destructure them respectively from the
@@ -201,7 +209,22 @@ Detox can either initialize a server using a generated configuration, or can be
   }
 ```
 
-Session can be set also per configuration — then it takes a higher priority over the global session config:
+When you define a session config, the Detox server won't start automatically anymore — it is assumed that
+you will be running it independently via `detox run-server` CLI command. Alternatively, you can set the
+`autoStart` property to be explicitly `true`:
+
+```diff
+   "session": {
++    "autoStart": true,
+     "server": "ws://localhost:8099",
+     "sessionId": "YourProjectSessionId"
+```
+
+Defining an explicit session config with `server` and `sessionId` also means you cannot use multiple workers,
+since the specified port will become busy for any test worker next to the first one to occupy it.
+
+Session can be set also per device configuration — then it takes a higher priority over the global
+session config:
 
 ```json
 {  
@@ -230,6 +253,8 @@ The status will be printed if the action takes more than _[N]_ ms to complete.
 }
 ```
 
+To disable `debugSynchronization` explicitly, use `0`.
+
 ## detox-cli
 
 ### Build Configuration
diff --git a/docs/Guide.DebuggingInAndroidStudio.md b/docs/Guide.DebuggingInAndroidStudio.md
new file mode 100644
index 000000000..6c5153199
--- /dev/null
+++ b/docs/Guide.DebuggingInAndroidStudio.md
@@ -0,0 +1,155 @@
+# Debugging in Android Studio During Detox Tests
+
+> This flow is not standard and serves mostly for investigating weird crashes or
+when contributing to Detox itself.  
+> **Don't use it unless you have a good reason.**
+
+### Setting Detox up as a compiling dependency
+
+Before you go anywhere further, follow the
+[Setting Detox up as a compiling dependency](Introduction.Android.md#setting-detox-up-as-a-compiling-dependency)
+section from the **Detox for Android** document.
+
+### Add "manual" device configurations to your .detoxrc
+
+Locate your `.detoxrc` config file or the corresponding `detox` section in your `package.json`
+and add a configuration similar to this one:
+
+```json
+{
+  "configurations": {
+    "android.manual": {
+      "type": "android.emulator",
+      "binaryPath": "<path to your app binary built before>",
+      "device": {
+        "avdName": "<e.g., Pixel_API_28>"
+      },
+      "behavior": {
+        "launchApp": "manual"
+      }
+    },
+  }
+}
+```
+
+While the `behavior` section is a **mandatory** thing to include, there are a few more optional
+parameters to disable various side effects and make life easier when debugging:
+
+```diff
+   "behavior": {
+     "launchApp": "manual"
+   },
++  "session": {
++    "autoStart": true,
++    "debugSynchronization": 0,
++    "server": "ws://localhost:8099",
++    "sessionId": "test"
++  },
++  "artifacts": false
+```
+
+* Using a preconfigured `session` with an autostarting server removes the legwork of copying and
+pasting values to the instrumentation runner launch arguments dialog every time before any launch
+from the IDE. Otherwise, by default when the `session` object omitted, `server` and `sessionId`
+are randomly generated for every new test session.
+
+    * The `debugSynchronization: 0` override matters only if you have a global `session` config
+with `debugSynchronization` set to a positive integer value. Otherwise, it is not needed. The point
+is to disable regular app polling requests during debugging, since that only can hinder the debugging.
+
+* Setting `artifacts: false` override also matters only if you have a global `artifacts` config.
+The motivation is to disable unrelevant taxing activities on the device such as capturing logs
+screenshots, videos and so on.
+    * If your investigation addresses a specific artifact plugin glitch on the native side, then just
+    disable all the other plugins. See [Detox Configuration](APIRef.Configuration.md) document
+    for the reference.
+
+### Run a specific test
+
+Usually, you would want to focus on a specific test suite to save time, e.g.:
+
+```
+detox test -c android.manual e2e/someSuite.test.js
+```
+
+If you are using Jest as a test runner, you might want to increase the test timeout via appending
+`--testTimeout 999999` to the command. For Mocha, that would be `--timeout 999999`.
+
+Also, if there is something you want to do step by step in JS code while debugging native, append
+`--inspect-brk` flag. To learn more about debugging with `--inspect-brk`, refer to
+[Debugging — Getting Started](https://nodejs.org/en/docs/guides/debugging-getting-started/) on the offical Node.js website.
+
+Afterwards, you should see your test suite starting as usual until it reaches the app launch, where
+Detox stops instead and prompts you to launch the app from the IDE:
+
+```
+detox[53038] INFO:  [AndroidDriver.js] Waiting for you to manually launch your app in Android Studio.
+
+Instrumentation class: com.wix.detox.test.test/com.example.DetoxTestAppJUnitRunner
+Instrumentation arguments:
+------------------------------------
+Key            | Value
+------------------------------------
+detoxServer    | ws://localhost:8099
+detoxSessionId | test
+------------------------------------
+
+Press any key to continue...
+```
+
+Now it is time to switch back to Android Studio.  However, if for some reason you wish to terminate the process, use Ctrl+C to exit.
+
+## Launching the app
+
+Before you launch the app from Android Studio, make sure to put breakpoints at the points of interest.
+
+You'll need to run your instrumentation runner with the said arguments, this is why you
+should create a debug configuration of `Android Instrumented Tests` type similar to the
+one below:
+
+![Android Debug Configuration - Android Instrumented Tests](img/android-studio-debug-configuration.png)
+
+The moment you see the app has started on the device, go back to the Terminal where Detox is
+running and press any key.
+
+As a result, you are expected to see a confirmation from Detox, e.g.:
+
+```
+Found the app (com.wix.detox-example) with process ID = 16854. Proceeding...
+```
+
+Now the entire test will run as usual until it sends an action to the app, which gets trapped
+in your breakpoint.
+
+![Breakpoint is active](img/android-happy-debugging.png)
+
+Happy debugging!
+
+## Extra tweaks
+
+If you feel like you see too often this timeout error while debugging:
+
+```
+Waited for the new RN-context for too long! (60 seconds)
+If you think that's not long enough, consider applying a custom Detox runtime-config in DetoxTest.runTests().
+```
+
+you can temporarily hack the timeout here:
+
+```diff
+diff --git a/detox/android/detox/src/main/java/com/wix/detox/config/DetoxConfig.kt b/detox/android/detox/src/main/java/com/wix/detox/config/DetoxConfig.kt
+index b33b2086..aaf8e9e2 100644
+--- a/detox/android/detox/src/main/java/com/wix/detox/config/DetoxConfig.kt
++++ b/detox/android/detox/src/main/java/com/wix/detox/config/DetoxConfig.kt
+@@ -1,8 +1,10 @@
+ package com.wix.detox.config
+
+ class DetoxConfig {
+     @JvmField var idlePolicyConfig: DetoxIdlePolicyConfig = DetoxIdlePolicyConfig()
+-    @JvmField var rnContextLoadTimeoutSec = 60
++    @JvmField var rnContextLoadTimeoutSec = Int.MAX_VALUE
+
+     fun apply() {
+         idlePolicyConfig.apply()
+```
+
diff --git a/docs/README.md b/docs/README.md
index f1251128d..8ca456598 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -41,6 +41,7 @@
 - [Developing Your App While Writing Tests](Guide.DevelopingWhileWritingTests.md)
 - [Running Tests on CI (like Travis)](Guide.RunningOnCI.md)
 - [Debugging Apps in Xcode During a Test](Guide.DebuggingInXcode.md)
+- [Debugging Apps in Android Studio During a Test](Guide.DebuggingInAndroidStudio.md)
 - [Advanced Mocking With Detox](Guide.Mocking.md)
 - [Migration Between Detox Versions](Guide.Migration.md)
 - [Using Jest as Test Runner](Guide.Jest.md)


Test patch:
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index d95e5a799..25e795828 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -2,6 +2,7 @@ const _ = require('lodash');
 const cp = require('child_process');
 const path = require('path');
 const unparse = require('yargs-unparser');
+const whichSync = require('which').sync;
 const { parse, quote } = require('./utils/shellQuote');
 const splitArgv = require('./utils/splitArgv');
 const DetoxRuntimeError = require('../src/errors/DetoxRuntimeError');
@@ -37,7 +38,11 @@ module.exports.handler = async function test(argv) {
   });
 
   if (detoxArgs['inspect-brk']) {
-    forwardedArgs.argv.$0 = `node --inspect-brk ${runnerConfig.testRunner}`;
+    const runnerBinary = whichSync(runner, {
+      path: prependNodeModulesBinToPATH({ ...process.env }),
+    });
+
+    forwardedArgs.argv.$0 = `node --inspect-brk ${require.resolve(runnerBinary)}`;
   } else {
     forwardedArgs.argv.$0 = runnerConfig.testRunner;
   }
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 4b1405b59..24160e505 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -573,7 +573,7 @@ describe('CLI', () => {
     });
 
     test.each([
-      ['--inspect-brk e2eFolder', /^node --inspect-brk jest .* e2eFolder$/, {}],
+      ['--inspect-brk e2eFolder', /^node --inspect-brk .*jest\.js .* e2eFolder$/, {}],
       ['-d e2eFolder', / e2eFolder$/, { debugSynchronization: 3000 }],
       ['--debug-synchronization e2eFolder', / e2eFolder$/, { debugSynchronization: 3000 }],
       ['-r e2eFolder', / e2eFolder$/, { reuse: true }],
@@ -660,7 +660,8 @@ describe('CLI', () => {
 
     test('--inspect-brk should prepend "node --inspect-brk" to the command', async () => {
       await run('--inspect-brk');
-      expect(cliCall().command).toMatch(RegExp(`^node --inspect-brk ${testRunner}`));
+      const absolutePathToTestRunnerJs = require.resolve(`.bin/${testRunner}`);
+      expect(cliCall().command).toMatch(RegExp(`^node --inspect-brk ${absolutePathToTestRunnerJs}`));
     });
 
     test('should append $DETOX_ARGV_OVERRIDE to detox test ... command and print a warning', async () => {
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index db2a54398..769d3bba5 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -130,6 +130,7 @@ describe('Detox', () => {
 
       it('should instantiate Device', () =>
         expect(Device).toHaveBeenCalledWith({
+          behaviorConfig: detoxConfig.behaviorConfig,
           deviceConfig: detoxConfig.deviceConfig,
           emitter: expect.anything(),
           deviceDriver: expect.anything(),
diff --git a/detox/src/configuration/composeArtifactsConfig.test.js b/detox/src/configuration/composeArtifactsConfig.test.js
index cc9ea0bf2..94b58a291 100644
--- a/detox/src/configuration/composeArtifactsConfig.test.js
+++ b/detox/src/configuration/composeArtifactsConfig.test.js
@@ -63,6 +63,28 @@ describe('composeArtifactsConfig', () => {
     });
   });
 
+  it('should disable global artifacts config if deviceConfig.artifacts = false', () => {
+    expect(composeArtifactsConfig({
+      configurationName: 'abracadabra',
+      deviceConfig: {
+        artifacts: false,
+      },
+      detoxConfig: {
+        artifacts: {
+          ...schemes.allArtifactsConfiguration,
+          rootDir: 'otherPlace',
+          pathBuilder: _.noop,
+        }
+      },
+      cliConfig: {},
+    })).toMatchObject({
+      pathBuilder: expect.objectContaining({
+        rootDir: expect.stringMatching(/^artifacts[\\\/]abracadabra\.\d{4}/),
+      }),
+      plugins: schemes.pluginsDefaultsResolved,
+    });
+  });
+
   it('should use CLI config', () => {
     expect(composeArtifactsConfig({
       configurationName: 'abracadabra',
diff --git a/detox/src/configuration/composeBehaviorConfig.test.js b/detox/src/configuration/composeBehaviorConfig.test.js
index 2110ee1ef..a82b2d435 100644
--- a/detox/src/configuration/composeBehaviorConfig.test.js
+++ b/detox/src/configuration/composeBehaviorConfig.test.js
@@ -27,12 +27,28 @@ describe('composeBehaviorConfig', () => {
         reinstallApp: true,
         launchApp: true,
       },
+      launchApp: 'auto',
       cleanup: {
         shutdownDevice: false,
       },
     })
   });
 
+  describe('if a custom config has only .launchApp = "manual" override', () => {
+    beforeEach(() => {
+      detoxConfig = {
+        behavior: { launchApp: 'manual' }
+      };
+    });
+
+    it('should implicitly override behavior.init.reinstallApp = false', () => {
+      const expected = _.cloneDeep(detoxConfig.behavior);
+      const actual = composed();
+
+      expect(actual.init.reinstallApp).toBe(false);
+    });
+  });
+
   describe('if detox config is set', () => {
     beforeEach(() => {
       detoxConfig = {
@@ -42,6 +58,7 @@ describe('composeBehaviorConfig', () => {
             reinstallApp: false,
             launchApp: false,
           },
+          launchApp: 'manual',
           cleanup: {
             shutdownDevice: true,
           },
@@ -65,6 +82,7 @@ describe('composeBehaviorConfig', () => {
               reinstallApp: true,
               launchApp: true,
             },
+            launchApp: 'auto',
             cleanup: {
               shutdownDevice: false,
             },
@@ -95,6 +113,7 @@ describe('composeBehaviorConfig', () => {
               reinstallApp: true,
               launchApp: false,
             },
+            launchApp: 'auto',
             cleanup: {
               shutdownDevice: false,
             }
@@ -114,6 +133,7 @@ describe('composeBehaviorConfig', () => {
                 reinstallApp: false,
                 launchApp: false,
               },
+              launchApp: 'auto',
               cleanup: {
                 shutdownDevice: true,
               }
@@ -124,4 +144,3 @@ describe('composeBehaviorConfig', () => {
     });
   });
 });
-
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index c39dde2b0..a5b750ad2 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -61,6 +61,10 @@ describe('Device', () => {
       expect(this.driver.launchApp).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
     }
 
+    expectWaitForLaunchCalled(device, expectedArgs, languageAndLocale) {
+      expect(this.driver.waitForAppLaunch).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
+    }
+
     expectReinstallCalled() {
       expect(this.driver.uninstallApp).toHaveBeenCalled();
       expect(this.driver.installApp).toHaveBeenCalled();
@@ -90,6 +94,7 @@ describe('Device', () => {
 
   function schemeDevice(scheme, configuration, overrides) {
     const device = new Device(_.merge({
+      behaviorConfig: {},
       deviceConfig: scheme.configurations[configuration],
       deviceDriver: driverMock.driver,
       sessionConfig: scheme.session,
@@ -142,6 +147,17 @@ describe('Device', () => {
       driverMock.expectLaunchCalled(device, expectedArgs);
     });
 
+    it(`given behaviorConfig.launchApp == 'manual' should wait for the app launch`, async () => {
+      const expectedArgs = expectedDriverArgs;
+      const device = validDevice({
+        behaviorConfig: { launchApp: 'manual' }
+      });
+      await device.launchApp();
+
+      expect(driverMock.driver.launchApp).not.toHaveBeenCalled();
+      driverMock.expectWaitForLaunchCalled(device, expectedArgs);
+    });
+
     it(`args should launch app and emit appReady`, async () => {
       driverMock.driver.launchApp = async () => 42;
 
diff --git a/detox/test/package.json b/detox/test/package.json
index f7ceb1a48..1d59d01bf 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -55,7 +55,6 @@
     "specs": "e2e/*.test.js",
     "behavior": {
       "init": {
-        "reinstallApp": true,
         "launchApp": true,
         "exposeGlobals": true
       },
@@ -71,10 +70,8 @@
       "plugins": {
         "log": "all",
         "screenshot": {
-          "shouldTakeAutomaticSnapshots": false,
-          "takeWhen": {
-            "testDone": true
-          }
+          "shouldTakeAutomaticSnapshots": true,
+          "takeWhen": {}
         },
         "timeline": "all",
         "uiHierarchy": "enabled"
@@ -111,6 +108,39 @@
           "sessionId": "com.wix.detox-example"
         }
       },
+      "ios.manual": {
+        "type": "ios.simulator",
+        "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/example.app",
+        "artifacts": false,
+        "behavior": {
+          "launchApp": "manual"
+        },
+        "device": {
+          "type": "iPhone 11 Pro"
+        },
+        "session": {
+          "autoStart": true,
+          "server": "ws://localhost:8099",
+          "sessionId": "com.wix.detox-example"
+        }
+      },
+      "android.manual": {
+        "binaryPath": "android/app/build/outputs/apk/fromBin/debug/app-fromBin-debug.apk",
+        "build": ":",
+        "type": "android.emulator",
+        "artifacts": false,
+        "device": {
+          "avdName": "Pixel_API_28"
+        },
+        "behavior": {
+          "launchApp": "manual"
+        },
+        "session": {
+          "autoStart": true,
+          "server": "ws://localhost:8099",
+          "sessionId": "test"
+        }
+      },
       "android.emu.debug": {
         "binaryPath": "android/app/build/outputs/apk/fromBin/debug/app-fromBin-debug.apk",
         "utilBinaryPaths": [