Repository: wix/Detox
Pull request #2314 (2020-09-08 09:13:38)

Restrict test-butler assertion to devices actually incorporating the TB service
- [x] This is a small change 
- [ ] This change has been discussed in issue #<?> and the solution has been agreed upon with maintainers.

---

**Description:**
This isn't really essential for Detox itself, as test butler is hard-installed on the running device in each global setup (at least for the _test_ app). This is more of a reference for other projects. Nevertheless it's good practice to have, so keeping it nonetheless.


Patch:
diff --git a/detox/local-cli/cli.js b/detox/local-cli/cli.js
index 9331c4d57..0e9f2c129 100755
--- a/detox/local-cli/cli.js
+++ b/detox/local-cli/cli.js
@@ -7,8 +7,10 @@ yargs
   .scriptName('detox')
   .parserConfiguration({
     'boolean-negation': false,
+    'camel-case-expansion': false,
     'dot-notation': false,
     'duplicate-arguments-array': false,
+    'populate--': true,
   })
   .commandDir('./', {
     exclude: function(path) {
diff --git a/detox/local-cli/utils/collectExtraArgs.js b/detox/local-cli/utils/collectExtraArgs.js
deleted file mode 100644
index 4c01026f4..000000000
--- a/detox/local-cli/utils/collectExtraArgs.js
+++ /dev/null
@@ -1,56 +0,0 @@
-const _ = require('lodash');
-const parseArgv = require('yargs-parser');
-
-function collectBlacklistedArgs(builder) {
-  return Object.entries(builder).reduce(
-    (set, [key, option]) => {
-      if (option.alias) {
-        if (Array.isArray(option.alias)) {
-          for (const value of option.alias) {
-            set.add(value);
-          }
-        } else {
-          set.add(option.alias);
-        }
-      }
-
-      return set.add(key);
-    },
-    new Set(['$0', '_', '--'])
-  );
-}
-
-function configureCollectExtraArgs(builder) {
-  const blacklistedArgs = collectBlacklistedArgs(builder);
-
-  /***
-   * @param {Object} argv
-   * @returns {string[]}
-   */
-  function collectExtraArgs(argv) {
-    const parsed = parseArgv(argv, {
-      configuration: {
-        'boolean-negation': false,
-        'camel-case-expansion': false,
-        'dot-notation': false,
-        'parse-numbers': false,
-        'duplicate-arguments-array': false
-      }
-    });
-
-    const passthrough = _.chain(parsed)
-      .omitBy((_value, key) => blacklistedArgs.has(key))
-      .entries()
-      .map(([key, value]) => {
-        return value === true ? `--${key}` : `--${key} ${value}`;
-      })
-      .concat(parsed['_'])
-      .value();
-
-    return passthrough;
-  }
-
-  return collectExtraArgs;
-}
-
-module.exports = configureCollectExtraArgs;
diff --git a/detox/local-cli/utils/misc.js b/detox/local-cli/utils/misc.js
new file mode 100644
index 000000000..e61cf1ca4
--- /dev/null
+++ b/detox/local-cli/utils/misc.js
@@ -0,0 +1,22 @@
+function getPlatformSpecificString(platform) {
+  switch (platform) {
+    case 'ios': return ':android:';
+    case 'android': return ':ios:';
+    default: return undefined;
+  }
+}
+
+function printEnvironmentVariables(envObject) {
+  return Object.entries(envObject).reduce((cli, [key, value]) => {
+    if (value == null || value === '') {
+      return cli;
+    }
+
+    return `${cli}${key}=${JSON.stringify(value)} `;
+  }, '');
+}
+
+module.exports = {
+  getPlatformSpecificString,
+  printEnvironmentVariables,
+};
diff --git a/detox/local-cli/utils/splitArgv.js b/detox/local-cli/utils/splitArgv.js
new file mode 100644
index 000000000..dcc04d676
--- /dev/null
+++ b/detox/local-cli/utils/splitArgv.js
@@ -0,0 +1,96 @@
+const _ = require('lodash');
+const testCommandArgs = require('./testCommandArgs');
+
+function extractKnownKeys(yargsBuilder) {
+  return Object.entries(yargsBuilder).reduce(
+    (set, [key, option]) => {
+      if (option.alias) {
+        if (Array.isArray(option.alias)) {
+          for (const value of option.alias) {
+            set.add(value);
+          }
+        } else {
+          set.add(option.alias);
+        }
+      }
+
+      return set.add(key);
+    },
+    new Set()
+  );
+}
+
+function disengageBooleanArgs(argv, booleanKeys) {
+  const result = {};
+  const passthrough = [];
+
+  for (const entry of Object.entries(argv)) {
+    const [key, value] = entry;
+    if (key === '_') {
+      continue;
+    }
+
+    const positiveKey = key.startsWith('no-') ? key.slice(3) : key;
+    if (booleanKeys.has(positiveKey) && typeof value !== 'boolean') {
+      result[positiveKey] = key === positiveKey;
+      passthrough.push(value);
+    } else {
+      result[key] = value;
+    }
+  }
+
+  return {
+    specs: passthrough.concat(argv._),
+    passthrough: {
+      _: [],
+      ...result,
+    },
+  };
+}
+
+function getJestBooleanArgs() {
+  return _(require('jest-cli/build/cli/args'))
+    .thru(args => args.options)
+    .pickBy(({ type }) => type === 'boolean')
+    .thru(extractKnownKeys)
+    .value();
+}
+
+function getMochaBooleanArgs() {
+  const metadata = require('mocha/lib/cli/run-option-metadata');
+
+  return _(metadata.types.boolean)
+    .flatMap(key => [key, ...(metadata.aliases[key] || [])])
+    .thru(keys => new Set(keys))
+    .value();
+}
+
+function splitDetoxArgv(argv) {
+  const aliases = extractKnownKeys(testCommandArgs);
+  const isDetoxArg = (_value, key) => aliases.has(key);
+
+  const detoxArgs = _.pickBy(argv, isDetoxArg);
+  const runnerArgs = _.omitBy(argv, isDetoxArg);
+  runnerArgs._ = runnerArgs._.slice(1); // omit 'test' string, as in 'detox test'
+  if (typeof detoxArgs['debug-synchronization'] === 'string') {
+    const erroneousPassthrough = detoxArgs['debug-synchronization'];
+    detoxArgs['debug-synchronization'] = 3000;
+    runnerArgs._.unshift(erroneousPassthrough);
+  }
+
+  return { detoxArgs, runnerArgs };
+}
+
+function splitMochaArgv(argv) {
+  return disengageBooleanArgs(argv, getMochaBooleanArgs());
+}
+
+function splitJestArgv(argv) {
+  return disengageBooleanArgs(argv, getJestBooleanArgs());
+}
+
+module.exports = {
+  detox: splitDetoxArgv,
+  jest: splitJestArgv,
+  mocha: splitMochaArgv,
+};
diff --git a/detox/package.json b/detox/package.json
index adb47bfc4..9627624ca 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -31,7 +31,10 @@
   "devDependencies": {
     "eslint": "^4.11.0",
     "eslint-plugin-node": "^6.0.1",
-    "jest": "26.x.x",
+    "jest-circus": ">=26.0.0",
+    "jest-cli": ">=25.0.0",
+    "jest-environment-node": ">=25.0.0",
+    "mocha": ">=6.0.0",
     "mockdate": "^2.0.1",
     "prettier": "1.7.0",
     "react-native": "0.62.x",
@@ -59,7 +62,14 @@
     "which": "^1.3.1",
     "ws": "^3.3.1",
     "yargs": "^13.0.0",
-    "yargs-parser": "^13.0.0"
+    "yargs-parser": "^13.0.0",
+    "yargs-unparser": "^1.6.3"
+  },
+  "peerDependencies": {
+    "mocha": ">=6.0.0",
+    "jest-circus": ">=26.0.0",
+    "jest-cli": ">=25.0.0",
+    "jest-environment-node": ">=25.0.0"
   },
   "engines": {
     "node": ">=8.3.0"
@@ -108,7 +118,6 @@
       "src/android/matcher.js",
       "src/ios/matchers.js",
       "appdatapath.js",
-      ".test.js",
       ".mock.js",
       "src/ios/expect.js"
     ],
diff --git a/detox/runners/jest-circus/environment.js b/detox/runners/jest-circus/environment.js
index aa1473c3d..47b6d228a 100644
--- a/detox/runners/jest-circus/environment.js
+++ b/detox/runners/jest-circus/environment.js
@@ -1,5 +1,5 @@
 const _ = require('lodash');
-const NodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-extraneous-require
+const NodeEnvironment = require('jest-environment-node');
 const DetoxCoreListener = require('./listeners/DetoxCoreListener');
 const DetoxInitErrorListener = require('./listeners/DetoxInitErrorListener');
 const assertJestCircus26 = require('./utils/assertJestCircus26');
diff --git a/detox/runners/jest-circus/listeners/DetoxCoreListener.js b/detox/runners/jest-circus/listeners/DetoxCoreListener.js
index 9a1e0d165..4f52b3a23 100644
--- a/detox/runners/jest-circus/listeners/DetoxCoreListener.js
+++ b/detox/runners/jest-circus/listeners/DetoxCoreListener.js
@@ -8,14 +8,27 @@ const {
   onTestDone,
   onRunDescribeFinish,
 } = require('../../integration').lifecycle;
+const { RETRY_TIMES } = require('jest-circus/build/types');
 
 class DetoxCoreListener {
-  constructor({ detox }) {
+  constructor({ detox, env }) {
     this._startedTests = new WeakSet();
     this._testsFailedBeforeStart = new WeakSet();
+    this._env = env;
+    this._testRunTimes = 1;
     this.detox = detox;
   }
 
+  _getTestInvocations(test) {
+    const {DETOX_RERUN_INDEX} = process.env;
+
+    if (!isNaN(DETOX_RERUN_INDEX)) {
+      return Number(DETOX_RERUN_INDEX) * this._testRunTimes + test.invocations;
+    } else {
+      return test.invocations;
+    }
+  }
+
   async run_describe_start({describeBlock: {name, children}}) {
     if (children.length) {
       await this.detox[onRunDescribeStart]({ name });
@@ -32,6 +45,9 @@ class DetoxCoreListener {
     if (!_.isEmpty(test.errors)) {
       this._testsFailedBeforeStart.add(test);
     }
+
+    const circusRetryTimes = +this._env.global[RETRY_TIMES];
+    this._testRunTimes = isNaN(circusRetryTimes) ? 1 : 1 + circusRetryTimes;
   }
 
   async hook_start(_event, state) {
@@ -64,7 +80,7 @@ class DetoxCoreListener {
       title: test.name,
       fullName: getFullTestName(test),
       status: 'running',
-      invocations: test.invocations,
+      invocations: this._getTestInvocations(test),
     });
   }
 
@@ -74,9 +90,11 @@ class DetoxCoreListener {
         title: test.name,
         fullName: getFullTestName(test),
         status: test.errors.length ? 'failed' : 'passed',
-        invocations: test.invocations,
+        invocations: this._getTestInvocations(test),
         timedOut: hasTimedOut(test)
       });
+
+      this._startedTests.delete(test);
     }
   }
 }
diff --git a/detox/runners/jest/DetoxAdapterImpl.js b/detox/runners/jest/DetoxAdapterImpl.js
index 148a3f18c..963a63d17 100644
--- a/detox/runners/jest/DetoxAdapterImpl.js
+++ b/detox/runners/jest/DetoxAdapterImpl.js
@@ -1,7 +1,16 @@
 const DetoxRuntimeError = require('../../src/errors/DetoxRuntimeError');
+const log = require('../../src/utils/logger').child({ __filename });
 
 class DetoxAdapterImpl {
   constructor(detox, describeInitErrorFn) {
+    if (process.env.DETOX_RERUN_INDEX) {
+      log.warn(
+        'While Detox CLI supports "-R <N>, --retries <N>" mechanism, ' +
+        'this outdated Jest integration does not — expect artifacts issues. ' +
+        'Please migrate to the new Jest Circus integration.\n\n' +
+        'See: https://github.com/wix/Detox/blob/master/docs/Guide.Jest.md\n');
+    }
+
     this.detox = detox;
     this._describeInitError = describeInitErrorFn;
     this._currentTest = null;
diff --git a/detox/runners/jest/JestCircusEnvironment.js b/detox/runners/jest/JestCircusEnvironment.js
index 6b98d3c8b..273236b53 100644
--- a/detox/runners/jest/JestCircusEnvironment.js
+++ b/detox/runners/jest/JestCircusEnvironment.js
@@ -1,4 +1,4 @@
-const NodeEnvironment = require('jest-environment-node'); // eslint-disable-line node/no-extraneous-require
+const NodeEnvironment = require('jest-environment-node');
 
 /**
  * @see https://www.npmjs.com/package/jest-circus#overview
diff --git a/detox/runners/jest/streamlineReporter.js b/detox/runners/jest/streamlineReporter.js
index a2ddbf01d..ced6c248f 100644
--- a/detox/runners/jest/streamlineReporter.js
+++ b/detox/runners/jest/streamlineReporter.js
@@ -1,6 +1,21 @@
 /*
  * This is here to make it easier for users to apply this reporter in config.json (naming-wise)
+ * TODO: rename this file to reporter.js in the next major version (Detox 18)
  */
 
-const Reporter = require('./DetoxStreamlineJestReporter');
-module.exports = Reporter;
+const DetoxStreamlineJestReporter = require('./DetoxStreamlineJestReporter');
+const FailingTestsReporter = require('./FailingTestsReporter');
+
+class DetoxReporter extends DetoxStreamlineJestReporter {
+  constructor(globalConfig) {
+    super(globalConfig);
+    this._failingTestsReporter = new FailingTestsReporter();
+  }
+
+  async onRunComplete(contexts, results) {
+    await super.onRunComplete(contexts, results);
+    await this._failingTestsReporter.onRunComplete(contexts, results);
+  }
+}
+
+module.exports = DetoxReporter;
diff --git a/detox/src/artifacts/utils/ArtifactPathBuilder.js b/detox/src/artifacts/utils/ArtifactPathBuilder.js
index cad5e2025..dae456204 100644
--- a/detox/src/artifacts/utils/ArtifactPathBuilder.js
+++ b/detox/src/artifacts/utils/ArtifactPathBuilder.js
@@ -44,8 +44,6 @@ class ArtifactPathBuilder {
   }
 
   _getStatusSign(testSummary) {
-    const invocations = testSummary.invocations || 1;
-
     switch (testSummary.status) {
       case 'passed': return '✓ ';
       case 'failed': return '✗ ';
diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js
index f0ed09ebc..86a95668e 100644
--- a/detox/src/utils/environment.js
+++ b/detox/src/utils/environment.js
@@ -17,6 +17,7 @@ const MISSING_SDK_ERROR = `$ANDROID_SDK_ROOT is not defined, set the path to the
 Go to https://developer.android.com/studio/command-line/variables.html for more details`;
 const DEVICE_LOCK_FILE_PATH_IOS = path.join(DETOX_LIBRARY_ROOT_PATH, 'device.registry.state.lock');
 const DEVICE_LOCK_FILE_PATH_ANDROID = path.join(DETOX_LIBRARY_ROOT_PATH, 'android-device.registry.state.lock');
+const LAST_FAILED_TESTS_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'last-failed.txt');
 
 function getAndroidSDKPath() {
   return process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME || '';
@@ -157,6 +158,10 @@ function getDeviceLockFilePathAndroid() {
   return DEVICE_LOCK_FILE_PATH_ANDROID;
 }
 
+function getLastFailedTestsPath() {
+  return LAST_FAILED_TESTS_PATH;
+}
+
 function getHomeDir() {
   return os.homedir();
 }
@@ -173,5 +178,6 @@ module.exports = {
   getDetoxLibraryRootPath,
   getDeviceLockFilePathIOS,
   getDeviceLockFilePathAndroid,
+  getLastFailedTestsPath,
   getHomeDir,
 };
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index 8a337f321..a8caa02d7 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -77,6 +77,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | --record-videos [failing/all/none]            | Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only. The default value is **none**. |
 | --record-performance [all/none]               | [iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory. The default value is **none**. |
 | --record-timeline [all/none] | [Jest Only] Record tests and events timeline, for visual display on the [chrome://tracing](chrome://tracing) tool. The default value is **none**. |
+| -R, --retries                                 | [Jest Circus Only] Re-spawn the test runner for individual failing suite files until they pass, or &lt;N&gt; times at least.|
 | -r, --reuse                                   | Reuse existing installed app (do not delete + reinstall) for a faster run. |
 | -u, --cleanup                                 | Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue |
 | -w, --workers                                 | Specifies number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest). *Note: For workers > 1, Jest's spec-level reporting is disabled, by default (can be overridden using --jest-report-specs).* |


Test patch:
diff --git a/detox/__tests__/setupJest.js b/detox/__tests__/setupJest.js
index c1c6ec896..66385dd10 100644
--- a/detox/__tests__/setupJest.js
+++ b/detox/__tests__/setupJest.js
@@ -22,6 +22,12 @@ function callCli(modulePath, cmd) {
 
     return yargs
       .scriptName('detox')
+      .parserConfiguration({
+        'boolean-negation': false,
+        'camel-case-expansion': false,
+        'dot-notation': false,
+        'duplicate-arguments-array': false,
+      })
       .command(spiedModule)
       .exitProcess(false)
       .fail((msg, err) => reject(err || msg))
diff --git a/detox/local-cli/__snapshots__/test.test.js.snap b/detox/local-cli/__snapshots__/test.test.js.snap
deleted file mode 100644
index 8577141dc..000000000
--- a/detox/local-cli/__snapshots__/test.test.js.snap
+++ /dev/null
@@ -1,7 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`test fails with a different runner 1`] = `
-"\\"ava\\" is not supported in Detox CLI tools.
-
-HINT: You can still run your tests with the runner's own CLI tool"
-`;
diff --git a/detox/local-cli/test.js b/detox/local-cli/test.js
index 65d5cb0e8..6efca8168 100644
--- a/detox/local-cli/test.js
+++ b/detox/local-cli/test.js
@@ -1,138 +1,152 @@
 const _ = require('lodash');
-const path = require('path');
 const cp = require('child_process');
-const DeviceRegistry = require('../src/devices/DeviceRegistry');
+const path = require('path');
+const unparse = require('yargs-unparser');
 const DetoxRuntimeError = require('../src/errors/DetoxRuntimeError');
-
+const DeviceRegistry = require('../src/devices/DeviceRegistry');
+const { loadLastFailedTests, resetLastFailedTests } = require('../src/utils/lastFailedTests');
+const { composeDetoxConfig } = require('../src/configuration');
 const log = require('../src/utils/logger').child({ __filename });
 const shellQuote = require('./utils/shellQuote');
-const { composeDetoxConfig } = require('../src/configuration');
+const splitArgv = require('./utils/splitArgv');
+const { getPlatformSpecificString, printEnvironmentVariables } = require('./utils/misc');
 
 module.exports.command = 'test';
 module.exports.desc = 'Run your test suite with the test runner specified in package.json';
 module.exports.builder = require('./utils/testCommandArgs');
-
-const collectExtraArgs = require('./utils/collectExtraArgs')(module.exports.builder);
-
-module.exports.handler = async function test(program) {
-  const { cliConfig, deviceConfig, runnerConfig } = await composeDetoxConfig({ argv: program });
+module.exports.handler = async function test(argv) {
+  const { detoxArgs, runnerArgs } = splitArgv.detox(argv);
+  const { cliConfig, deviceConfig, runnerConfig } = await composeDetoxConfig({ argv: detoxArgs });
   const [ platform ] = deviceConfig.type.split('.');
+  const runner = deduceTestRunner(runnerConfig.testRunner);
+
+  const prepareArgs = choosePrepareArgs({
+    cliConfig,
+    runner,
+    platform,
+    detoxArgs,
+  });
+
+  const forwardedArgs = prepareArgs({
+    cliConfig,
+    runnerConfig,
+    runnerArgs,
+    platform,
+  });
+
+  if (detoxArgs['inspect-brk']) {
+    forwardedArgs.argv.$0 = `node --inspect-brk ${runnerConfig.testRunner}`;
+  } else {
+    forwardedArgs.argv.$0 = runnerConfig.testRunner;
+  }
 
   if (!cliConfig.keepLockFile) {
-    await clearDeviceRegistryLockFile();
+    await resetLockFile({ platform });
   }
 
-  function run() {
-    if (runnerConfig.testRunner.includes('jest')) {
-      return runJest();
-    }
+  const retries = runner === 'jest' ? detoxArgs.retries : 0;
+  await runTestRunnerWithRetries(forwardedArgs, retries);
+};
 
-    if (runnerConfig.testRunner.includes('mocha')) {
-      return runMocha();
+function choosePrepareArgs({ cliConfig, detoxArgs, runner, platform }) {
+  if (runner === 'mocha') {
+    if (hasMultipleWorkers(cliConfig)) {
+      log.warn('Cannot use -w, --workers. Parallel test execution is only supported with iOS and Jest');
     }
 
-    throw new DetoxRuntimeError({
-      message: `"${runnerConfig.testRunner}" is not supported in Detox CLI tools.`,
-      hint: `You can still run your tests with the runner's own CLI tool`,
-    });
-  }
-
-  function getPassthroughArguments() {
-    const args = collectExtraArgs(process.argv.slice(3));
-
-    const hasFolders = args.some(arg => arg && !arg.startsWith('-'));
-    return hasFolders ? args : [...args, runnerConfig.specs];
-  }
-
-  function safeGuardArguments(args) {
-    if (_.last(args).includes(' ')) {
-      return args;
+    if (detoxArgs.retries > 0) {
+      log.warn('Cannot use -R, --retries. The test retry mechanism is only supported with Jest runner');
     }
 
-    const safeArg = _.findLast(args, a => a.includes(' '));
-    if (!safeArg) {
-      return args;
+    if (cliConfig.recordTimeline) {
+      log.warn('Cannot use --record-timeline. This artifact type is only supported with Jest runner');
     }
 
-    return [..._.pull(args, safeArg), safeArg]
+    return prepareMochaArgs;
   }
 
-  function runMocha() {
-    if (cliConfig.workers != 1) {
-      log.warn('Can not use -w, --workers. Parallel test execution is only supported with iOS and Jest');
+  if (runner === 'jest') {
+    if (platform === 'android' && hasMultipleWorkers(cliConfig)) {
+      log.warn('Multiple workers is an experimental feature on Android and requires an emulator binary of version 28.0.16 or higher. ' +
+        'Check your version by running: $ANDROID_HOME/tools/bin/sdkmanager --list');
     }
 
-    const configParam = path.extname(runnerConfig.runnerConfig) === '.opts'
-      ? 'opts'
-      : 'config';
-
-    const command = _.compact([
-      cliConfig.inspectBrk ? 'node --inspect-brk' : '',
-      (path.join('node_modules', '.bin', runnerConfig.testRunner)),
-      ...safeGuardArguments([
-        (runnerConfig.runnerConfig ? `--${configParam} ${runnerConfig.runnerConfig}` : ''),
-        (cliConfig.configPath ? `--config-path ${cliConfig.configPath}` : ''),
-        (cliConfig.configuration ? `--configuration ${cliConfig.configuration}` : ''),
-        (cliConfig.loglevel ? `--loglevel ${cliConfig.loglevel}` : ''),
-        (cliConfig.noColor ? '--no-colors' : ''),
-        (cliConfig.cleanup ? `--cleanup` : ''),
-        (cliConfig.reuse ? `--reuse` : ''),
-        (isFinite(cliConfig.debugSynchronization) ? `--debug-synchronization ${cliConfig.debugSynchronization}` : ''),
-        (platform ? `--invert --grep ${getPlatformSpecificString()}` : ''),
-        (cliConfig.headless ? `--headless` : ''),
-        (cliConfig.gpu ? `--gpu ${cliConfig.gpu}` : ''),
-        (cliConfig.recordLogs ? `--record-logs ${cliConfig.recordLogs}` : ''),
-        (cliConfig.takeScreenshots ? `--take-screenshots ${cliConfig.takeScreenshots}` : ''),
-        (cliConfig.recordVideos ? `--record-videos ${cliConfig.recordVideos}` : ''),
-        (cliConfig.recordPerformance ? `--record-performance ${cliConfig.recordPerformance}` : ''),
-        (cliConfig.artifactsLocation ? `--artifacts-location "${cliConfig.artifactsLocation}"` : ''),
-        (cliConfig.deviceName ? `--device-name "${cliConfig.deviceName}"` : ''),
-        (cliConfig.useCustomLogger ? `--use-custom-logger "${cliConfig.useCustomLogger}"` : ''),
-        (cliConfig.forceAdbInstall ? `--force-adb-install "${cliConfig.forceAdbInstall}"` : ''),
-      ]),
-      ...getPassthroughArguments(),
-    ]).join(' ');
-
-    const detoxEnvironmentVariables = _.pick(cliConfig, [
-      'deviceLaunchArgs',
-    ]);
-
-    launchTestRunner(command, detoxEnvironmentVariables);
+    return prepareJestArgs;
   }
 
-  function runJest() {
-    const hasMultipleWorkers = cliConfig.workers != 1;
+  throw new DetoxRuntimeError({
+    message: `"${runner}" is not supported in Detox CLI tools.`,
+    hint: `You can still run your tests with the runner's own CLI tool`
+  });
+}
 
-    if (platform === 'android') {
-      cliConfig.readOnlyEmu = false;
-      if (hasMultipleWorkers) {
-        cliConfig.readOnlyEmu = true;
-        log.warn('Multiple workers is an experimental feature on Android and requires an emulator binary of version 28.0.16 or higher. ' +
-          'Check your version by running: $ANDROID_HOME/tools/bin/sdkmanager --list');
-      }
-    }
+function deduceTestRunner(command) {
+  if (command.includes('mocha')) {
+    return 'mocha';
+  }
 
-    const jestReportSpecsArg = cliConfig.jestReportSpecs;
-    if (!_.isUndefined(jestReportSpecsArg)) {
-      cliConfig.reportSpecs = `${jestReportSpecsArg}` === 'true';
-    } else {
-      cliConfig.reportSpecs = !hasMultipleWorkers;
-    }
+  if (command.includes('jest')) {
+    return 'jest';
+  }
 
-    const command = _.compact([
-      cliConfig.inspectBrk ? 'node --inspect-brk' : '',
-      path.join('node_modules', '.bin', runnerConfig.testRunner),
-      ...safeGuardArguments([
-        cliConfig.noColor ? ' --no-color' : '',
-        runnerConfig.runnerConfig ? `--config ${runnerConfig.runnerConfig}` : '',
-        platform ? shellQuote(`--testNamePattern=^((?!${getPlatformSpecificString()}).)*$`) : '',
-        `--maxWorkers ${cliConfig.workers}`,
-      ]),
-      ...getPassthroughArguments(),
-    ]).join(' ');
-
-    const detoxEnvironmentVariables = {
-      ..._.pick(cliConfig, [
+  return command;
+}
+
+function prepareMochaArgs({ cliConfig, runnerArgs, runnerConfig, platform }) {
+  const { specs, passthrough } = splitArgv.mocha(runnerArgs);
+  const configParam = path.extname(runnerConfig.runnerConfig) === '.opts'
+    ? 'opts'
+    : 'config';
+
+  const platformFilter = getPlatformSpecificString(platform);
+
+  return {
+    argv: {
+      [configParam]: runnerConfig.runnerConfig /* istanbul ignore next */ || undefined,
+      cleanup: Boolean(cliConfig.cleanup) || undefined,
+      colors: !cliConfig.noColor && undefined,
+      configuration: cliConfig.configuration || undefined,
+      gpu: cliConfig.gpu || undefined,
+      // TODO: check if we can --grep from user
+      grep: platformFilter || undefined,
+      invert: Boolean(platformFilter) || undefined,
+      headless: Boolean(cliConfig.headless) || undefined,
+      loglevel: cliConfig.loglevel || undefined,
+      reuse: cliConfig.reuse || undefined,
+      'artifacts-location': cliConfig.artifactsLocation || undefined,
+      'config-path': cliConfig.configPath /* istanbul ignore next */ || undefined,
+      'debug-synchronization': isFinite(cliConfig.debugSynchronization) ? cliConfig.debugSynchronization : undefined,
+      'device-name': cliConfig.deviceName || undefined,
+      'force-adb-install': platform === 'android' && cliConfig.forceAdbInstall || undefined,
+      'record-logs': cliConfig.recordLogs || undefined,
+      'record-performance': cliConfig.recordPerformance || undefined,
+      'record-videos': cliConfig.recordVideos || undefined,
+      'take-screenshots': cliConfig.takeScreenshots || undefined,
+      'use-custom-logger': cliConfig.useCustomLogger && 'true' || undefined,
+
+      ...passthrough,
+    },
+    env: _.pick(cliConfig, ['deviceLaunchArgs']),
+    specs: _.isEmpty(specs) ? [runnerConfig.specs] : specs,
+  };
+}
+
+function prepareJestArgs({ cliConfig, runnerArgs, runnerConfig, platform }) {
+  const { specs, passthrough } = splitArgv.jest(runnerArgs);
+  const platformFilter = getPlatformSpecificString(platform);
+
+  return {
+    argv: {
+      color: !cliConfig.noColor && undefined,
+      config: runnerConfig.runnerConfig /* istanbul ignore next */ || undefined,
+      testNamePattern: platformFilter ? shellQuote(`^((?!${platformFilter}).)*$`) : undefined,
+      maxWorkers: cliConfig.workers,
+
+      ...passthrough,
+    },
+
+    env: _.omitBy({
+      ..._.pick(cliConfig, _.compact([
         'configPath',
         'configuration',
         'loglevel',
@@ -148,59 +162,81 @@ module.exports.handler = async function test(program) {
         'recordPerformance',
         'recordTimeline',
         'deviceName',
-        'reportSpecs',
-        'readOnlyEmu',
         'deviceLaunchArgs',
         'useCustomLogger',
-        'forceAdbInstall',
-      ]),
+        platform === 'android' && 'forceAdbInstall',
+      ])),
       DETOX_START_TIMESTAMP: Date.now(),
-    };
-
-    launchTestRunner(command, detoxEnvironmentVariables);
+      readOnlyEmu: platform === 'android' ? hasMultipleWorkers(cliConfig) : undefined,
+      reportSpecs: _.isUndefined(cliConfig.jestReportSpecs)
+        ? !hasMultipleWorkers(cliConfig)
+        : `${cliConfig.jestReportSpecs}` === 'true',
+    }, _.isUndefined),
+
+    specs: _.isEmpty(specs) ? [runnerConfig.specs] : specs,
+  };
+}
+
+async function resetLockFile({ platform }) {
+  if (platform === 'ios') {
+    await DeviceRegistry.forIOS().reset();
   }
 
-  function printEnvironmentVariables(envObject) {
-    return Object.entries(envObject).reduce((cli, [key, value]) => {
-      if (value == null || value === '') {
-        return cli;
-      }
-
-      return `${cli}${key}=${JSON.stringify(value)} `;
-    }, '');
+  if (platform === 'android') {
+    await DeviceRegistry.forAndroid().reset();
   }
+}
+
+function launchTestRunner({ argv, env, specs, rerunIndex }) {
+  const { $0: command, ...restArgv } = argv;
+  const fullCommand = [command, ...unparse(restArgv), ...specs].join(' ');
+
+  log.info(printEnvironmentVariables(env) + fullCommand);
+
+  cp.execSync(fullCommand, {
+    stdio: 'inherit',
+    env: _.omitBy({
+      ...process.env,
+      ...env,
+    }, _.isUndefined),
+  });
+}
+
+function hasMultipleWorkers(cliConfig) {
+  return cliConfig.workers != 1;
+}
+
+async function runTestRunnerWithRetries(forwardedArgs, retries) {
+  let runsLeft = 1 + retries;
+  let launchError;
+
+  do {
+    try {
+      if (launchError) {
+        const list = forwardedArgs.specs.map((file, index) => `  ${index + 1}. ${file}`).join('\n');
+        log.error(
+          `There were failing tests in the following files:\n${list}\n\n` +
+          'Detox CLI is going to restart the test runner with those files...\n'
+        );
+      }
 
-  function getPlatformSpecificString() {
-    let platformRevertString;
-    if (platform === 'ios') {
-      platformRevertString = ':android:';
-    } else if (platform === 'android') {
-      platformRevertString = ':ios:';
-    }
-
-    return platformRevertString;
-  }
+      await resetLastFailedTests();
+      launchTestRunner(forwardedArgs);
+      launchError = null;
+    } catch (e) {
+      launchError = e;
 
-  async function clearDeviceRegistryLockFile() {
-    if (platform === 'ios') {
-      await DeviceRegistry.forIOS().reset();
-    }
+      const lastFailedTests = await loadLastFailedTests();
+      if (_.isEmpty(lastFailedTests)) {
+        throw e;
+      }
 
-    if (platform === 'android') {
-      await DeviceRegistry.forAndroid().reset();
+      forwardedArgs.specs = lastFailedTests;
+      forwardedArgs.env.DETOX_RERUN_INDEX = 1 + (forwardedArgs.env.DETOX_RERUN_INDEX || 0);
     }
-  }
+  } while (launchError && --runsLeft > 0);
 
-  function launchTestRunner(command, detoxEnvironmentVariables) {
-    log.info(printEnvironmentVariables(detoxEnvironmentVariables) + command);
-    cp.execSync(command, {
-      stdio: 'inherit',
-      env: {
-        ...process.env,
-        ...detoxEnvironmentVariables
-      }
-    });
+  if (launchError) {
+    throw launchError;
   }
-
-  run();
-};
+}
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 5039b2594..3fee279a8 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -1,316 +1,666 @@
-jest.mock('../src/utils/logger');
-jest.mock('../src/configuration');
 jest.mock('child_process');
+jest.mock('../src/utils/logger');
+jest.mock('../src/devices/DeviceRegistry');
+jest.mock('../src/utils/lastFailedTests');
 
-const path = require('path');
-const {normalize} = require('path');
-const shellQuote = require('./utils/shellQuote');
-
-describe('test', () => {
-  let _argv, logger, execSync;
+const _ = require('lodash');
+const fs = require('fs-extra');
+const yargs = require('yargs');
 
-  beforeAll(() => {
-    _argv = process.argv;
-  });
+describe('CLI', () => {
+  let cp;
+  let logger;
+  let temporaryFiles;
+  let detoxConfig;
+  let detoxConfigPath;
+  let DeviceRegistry;
 
   beforeEach(() => {
-    process.argv = ['node', 'jest', 'test'];
+    detoxConfig = {
+      configurations: {
+        single: {
+          type: 'ios.simulator',
+          device: 'iPhone X',
+        },
+      },
+    };
 
+    cp = require('child_process');
     logger = require('../src/utils/logger');
-    execSync = require('child_process').execSync;
+    temporaryFiles = [];
+    DeviceRegistry = require('../src/devices/DeviceRegistry')
+    DeviceRegistry.forAndroid = DeviceRegistry.forIOS = () => new DeviceRegistry();
   });
 
-  afterEach(() => {
-    process.argv = _argv;
+  afterEach(async () => {
+    await Promise.all(temporaryFiles.map(name => fs.remove(name)));
   });
 
-  const mockAndroidJestConfiguration = () => mockConfiguration('android.emulator', 'jest');
-  const mockIOSJestConfiguration = () => mockConfiguration('ios.sim', 'jest');
-  const mockAndroidMochaConfiguration = (overrides) => mockConfiguration('android.emulator', undefined, overrides);
-  const mockIOSMochaConfiguration = () => mockConfiguration('ios.sim');
-  const mockConfiguration = (deviceType, runner, overrides) => {
-    require('../src/configuration').composeDetoxConfig.mockImplementation(async (options) => {
-      return jest.requireActual('../src/configuration').composeDetoxConfig({
-        ...options,
-        override: {
-          'test-runner': runner,
-          configurations: {
-            only: {
-              type: deviceType,
-              name: 'MyDevice',
-            }
-          },
-          ...overrides
-        },
+  test('by default, should attempt to load config from package.json or .detoxrc', async () => {
+    const expectedError = /^Cannot run Detox without a configuration/;
+    await expect(runRaw('test')).rejects.toThrowError(expectedError);
+  });
+
+  describe('(mocha)', () => {
+    describe('given no extra args', () => {
+      beforeEach(async () => run());
+
+      test('should default to mocha', () => expect(cliCall().command).toMatch(/^mocha/));
+      test('should default to --opts e2e/mocha.opts', () => expect(cliCall().command).toMatch(/--opts e2e\/mocha.opts/));
+      test('should default to "e2e" test folder', () => expect(cliCall().command).toMatch(/ e2e$/));
+      test('should pass --use-custom-logger true', () => expect(cliCall().command).toMatch(/--use-custom-logger true/));
+      test('should not override process.env', () => expect(cliCall().env).toStrictEqual({}));
+      test('should produce a default command (integration test)', () => {
+        const args = `--opts e2e/mocha.opts --grep :android: --invert --config-path ${detoxConfigPath} --use-custom-logger true`;
+        expect(cliCall().command).toBe(`mocha ${args} e2e`);
       });
     });
-  };
 
-  describe('mocha', () => {
-    it('runs successfully', async () => {
-      mockAndroidMochaConfiguration();
+    test.each([['-o'], ['--runner-config']])('%s <path> should be aware of mocha.opts extension', async (__runnerConfig) => {
+      await run(`${__runnerConfig} e2e/custom.opts`);
+      expect(cliCall().command).toContain(`--opts e2e/custom.opts`);
+    });
 
-      await callCli('./test', 'test');
+    test.each([['-o'], ['--runner-config']])('%s <path> should be aware of .mocharc extension', async (__runnerConfig) => {
+      await run(`${__runnerConfig} e2e/.mocharc`);
+      expect(cliCall().command).toContain(`--config e2e/.mocharc`);
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        `${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --invert --grep :ios: --use-custom-logger "true" e2e`,
-        expect.anything()
-      );
+    test.each([['-l'], ['--loglevel']])('%s <value> should be passed as CLI argument', async (__loglevel) => {
+      await run(`${__loglevel} verbose`);
+      expect(cliCall().command).toContain(`--loglevel verbose`);
     });
 
-    it('passes custom Detox config', async () => {
-      mockAndroidMochaConfiguration();
+    test('--no-color should be passed as CLI argument', async () => {
+      await run(`--no-color`);
+      expect(cliCall().command).toContain(' --no-colors ');
+    });
 
-      await callCli('./test', `test -C ${path.join(__dirname, '__mocks__/detox.config.js')}`);
+    test.each([['-R'], ['--retries']])('%s <value> should print warning', async (__retries) => {
+      await run(`${__retries} 1`);
+      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Cannot use -R, --retries.'));
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.stringMatching(/mocha.* --config-path .*__mocks__.detox\.config\.js/),
-        expect.anything()
-      );
+    test.each([['-R'], ['--retries']])('%s <value> should be ignored', async (__retries) => {
+      cp.execSync.mockImplementation(() => { throw new Error });
+      await run(`${__retries} 1`).catch(_.noop);
+
+      expect(cliCall(0)).toBeDefined()
+      expect(cliCall(1)).toBe(null);
     });
 
-    it('changes --opts to --config, when given non ".opts" file extension', async () => {
-      mockAndroidMochaConfiguration({
-        'runner-config': 'e2e/.mocharc.json'
-      });
+    test.each([['-r'], ['--reuse']])('%s <value> should be passed as CLI argument', async (__reuse) => {
+      await run(`${__reuse}`);
+      expect(cliCall().command).toContain('--reuse')
+    });
 
-      await callCli('./test', 'test');
+    test.each([['-u'], ['--cleanup']])('%s <value> should be passed as CLI argument', async (__cleanup) => {
+      await run(`${__cleanup}`);
+      expect(cliCall().command).toContain('--cleanup')
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.stringContaining(`${normalize('node_modules/.bin/mocha')} --config e2e/.mocharc.json `),
-        expect.anything()
-      );
+    test.each([['-d'], ['--debug-synchronization']])('%s <value> should be passed as CLI argument', async (__debug_synchronization) => {
+      await run(`${__debug_synchronization} 5000`);
+      expect(cliCall().command).toContain('--debug-synchronization 5000')
     });
 
-    it('passes artifact recording options', async () => {
-      mockAndroidMochaConfiguration({
-        'runner-config': 'e2e/.mocharc.json'
-      });
+    test.each([['-d'], ['--debug-synchronization']])('%s <value> should have default value = 3000', async (__debug_synchronization) => {
+      await run(`${__debug_synchronization}`);
+      expect(cliCall().command).toContain('--debug-synchronization 3000')
+    });
 
-      await callCli('./test', 'test --record-logs none --take-screenshots manual --record-videos none --record-performance none');
+    test.each([['-a'], ['--artifacts-location']])('%s <value> should be passed as CLI argument', async (__artifacts_location) => {
+      await run(`${__artifacts_location} someLocation`);
+      expect(cliCall().command).toContain('--artifacts-location someLocation')
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.stringContaining(`--record-logs none --take-screenshots manual --record-videos none --record-performance none`),
-        expect.anything()
-      );
+    test('--record-logs <value> should be passed as CLI argument', async () => {
+      await run(`--record-logs all`);
+      expect(cliCall().command).toContain('--record-logs all')
     });
 
-    it('should pass in device-launch-args as an environment variable', async () => {
-      mockAndroidMochaConfiguration();
+    test('--take-screenshots <value> should be passed as CLI argument', async () => {
+      await run(`--take-screenshots failing`);
+      expect(cliCall().command).toContain('--take-screenshots failing')
+    });
 
-      await callCli('./test', 'test --device-launch-args="-mocked -launched -args"');
+    test('--record-videos <value> should be passed as CLI argument', async () => {
+      await run(`--record-videos failing`);
+      expect(cliCall().command).toContain('--record-videos failing')
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.anything(),
-        expect.objectContaining({
-          env: expect.objectContaining({
-            deviceLaunchArgs: '-mocked -launched -args',
-          }),
-        }),
-      );
+    test('--record-performance <value> should be passed as CLI argument', async () => {
+      await run(`--record-performance all`);
+      expect(cliCall().command).toContain('--record-performance all')
     });
-  });
 
-  describe('jest', () => {
-    it('runs successfully', async () => {
-      mockAndroidJestConfiguration();
-
-      await callCli('./test', 'test');
-
-      expect(execSync).toHaveBeenCalledWith(
-        expect.stringContaining(
-          `${normalize('node_modules/.bin/jest')} --config e2e/config.json ${shellQuote('--testNamePattern=^((?!:ios:).)*$')} --maxWorkers 1 e2e`
-        ),
-        expect.objectContaining({
-          env: expect.objectContaining({
-            reportSpecs: true,
-            useCustomLogger: true,
-          }),
-        })
-      );
+    test('--record-timeline <value> should print "unsupported" warning', async () => {
+      await run(`--record-timeline all`);
+      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Cannot use --record-timeline.'));
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should print "unsupported" warning', async (__workers) => {
+      await run(`${__workers} 2`);
+      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Cannot use -w, --workers.'));
     });
 
-    it('passes custom Detox config', async () => {
-      mockAndroidJestConfiguration();
+    test.each([['-H'], ['--headless']])('%s <value> should be passed as CLI argument', async (__headless) => {
+      await run(`${__headless}`);
+      expect(cliCall().command).toContain('--headless');
+    });
 
-      await callCli('./test', `test -C ${path.join(__dirname, '__mocks__/detox.config.js')}`);
+    test('--gpu <value> should be passed as CLI argument', async () => {
+      await run(`--gpu angle_indirect`);
+      expect(cliCall().command).toContain('--gpu angle_indirect');
+    });
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.stringContaining(
-          `${normalize('node_modules/.bin/jest')} --config e2e/config.json`
-        ),
-        expect.objectContaining({
-          env: expect.objectContaining({
-            configPath: expect.stringMatching(/__mocks__.detox\.config\.js$/),
-          }),
-        })
-      );
+    test('--device-launch-args should be passed as an environment variable', async () => {
+      await run(`--device-launch-args "--verbose"`);
+      expect(cliCall().env).toEqual({
+        deviceLaunchArgs: '--verbose',
+      });
     });
 
-    it('should pass in device-launch-args as an environment variable', async () => {
-      mockAndroidJestConfiguration();
+    test('--use-custom-logger false should be prevent passing CLI argument', async () => {
+      await run(`--use-custom-logger false`);
+      expect(cliCall().command).not.toContain('--use-custom-logger');
+    });
 
-      await callCli('./test', 'test --device-launch-args="-mocked -launched -args"');
+    test('--force-adb-install should be ignored for iOS', async () => {
+      singleConfig().type = 'ios.simulator';
+      await run(`--force-adb-install`);
 
-      expect(execSync).toHaveBeenCalledWith(
-        expect.anything(),
-        expect.objectContaining({
-          env: expect.objectContaining({
-            deviceLaunchArgs: '-mocked -launched -args',
-          }),
-        }),
-      );
+      expect(cliCall().command).not.toContain('--force-adb-install');
     });
-  });
 
-  it('fails with a different runner', async () => {
-    mockConfiguration('android.emulator', 'ava');
-    await expect(callCli('./test', 'test')).rejects.toThrowErrorMatchingSnapshot();
-    expect(execSync).not.toHaveBeenCalled();
-  });
+    test('--force-adb-install should be passed as CLI argument for Android', async () => {
+      singleConfig().type = 'android.emulator';
+      await run(`--force-adb-install`);
 
-  describe('Jest specs reporting (propagated) switch', () => {
-    const expectReportSpecsArg = ({value}) => expect(execSync).toHaveBeenCalledWith(
-      expect.anything(),
-      expect.objectContaining({
-        env: expect.objectContaining({
-          reportSpecs: value,
-        }),
-      })
-    );
+      expect(cliCall().command).toContain('--force-adb-install');
+    });
 
-    const expectWorkersArg = ({value}) => expect(execSync).toHaveBeenCalledWith(
-      expect.stringContaining(`--maxWorkers ${value}`),
-      expect.anything(),
-    );
+    test.each([['-n'], ['--device-name']])('%s <value> should be passed as CLI argument', async (__device_name) => {
+      await run(`${__device_name} TheDevice`);
+      expect(cliCall().command).toContain('--device-name TheDevice');
+    });
 
-    describe('ios', () => {
-      beforeEach(mockIOSJestConfiguration);
+    test('should omit --grep --invert for custom platforms', async () => {
+      const customDriver = `module.exports = class CustomDriver {};`
+      singleConfig().type = tempfile('.js', customDriver);
 
-      it('should use one worker', async () => {
-        await callCli('./test', 'test');
-        expectWorkersArg({value: '1'});
-      });
+      await run();
+      expect(cliCall().command).not.toContain(' --invert ');
+      expect(cliCall().command).not.toContain(' --grep ');
+    });
 
-      it('should still use one worker', async () => {
-        await callCli('./test', 'test --workers 1');
-        expectWorkersArg({value: '1'});
-      });
+    test('specifying direct test paths', async () => {
+      await run(`e2e/01.sanity.test.js e2e/02.sanity.test.js`);
+      expect(cliCall().command).not.toMatch(/ e2e /);
+      expect(cliCall().command).not.toMatch(/ e2e$/);
+      expect(cliCall().command).toMatch(/ e2e\/01.sanity.test.js e2e\/02.sanity.test.js$/);
+    });
 
-      it('should still use two workers', async () => {
-        await callCli('./test', 'test --workers 2');
-        expectWorkersArg({value: '2'});
-      });
+    test('e.g., --bail should be passed through', async () => {
+      await run(`--bail`);
+      expect(cliCall().command).toContain('--bail');
+    });
 
-      it('should use 100% workers', async () => {
-        await callCli('./test', 'test --workers 100%');
-        expectWorkersArg({value: '100%'});
-      });
+    test('e.g., --reporter spec should be passed through', async () => {
+      await run(`--reporter spec`);
+      expect(cliCall().command).toContain('--reporter spec');
+    });
+
+    test('e.g., --bail e2e/Login.test.js should be split to --bail and e2e/Login.test.js', async () => {
+      await run(`--bail e2e/Login.test.js --reporter spec`);
+      expect(cliCall().command).toContain('--bail --reporter spec e2e/Login.test.js');
+    });
+
+  });
+
+  describe('(jest)', () => {
+    beforeEach(() => {
+      detoxConfig.testRunner = 'jest';
+    });
 
-      it('should be enabled for a single worker', async () => {
-        await callCli('./test', 'test --workers 1');
-        expectReportSpecsArg({value: true});
+    describe('given no extra args (iOS)', () => {
+      beforeEach(async () => {
+        singleConfig().type = 'ios.simulator';
+        await run();
       });
 
-      it('should be disabled for multiple workers', async () => {
-        await callCli('./test', 'test --workers 2');
-        expectReportSpecsArg({value: false});
+      test('should produce a default command (integration test, ios)', () => {
+        const args = `--config e2e/config.json --testNamePattern '^((?!:android:).)*$' --maxWorkers 1`;
+        expect(cliCall().command).toBe(`jest ${args} e2e`);
       });
 
-      it('should be enabled in case no specific workers config has been specified', async () => {
-        await callCli('./test', 'test');
-        expectReportSpecsArg({value: true});
+      test('should put default environment variables (integration test, ios)', () => {
+        expect(cliCall().env).toEqual({
+          DETOX_START_TIMESTAMP: expect.any(Number),
+          configPath: expect.any(String),
+          reportSpecs: true,
+          useCustomLogger: true,
+        });
       });
+    });
 
-      it('should be enabled if custom --jest-report-specs switch is specified', async () => {
-        await callCli('./test', 'test --workers 2 --jest-report-specs');
-        expectReportSpecsArg({value: true});
+    describe('given no extra args (Android)', () => {
+      beforeEach(async () => {
+        singleConfig().type = 'android.emulator';
+        await run();
       });
 
-      it('should be disabled if custom switch has non-true value', async () => {
-        await callCli('./test', 'test --jest-report-specs meh');
-        expectReportSpecsArg({value: false});
+      test('should produce a default command (integration test)', () => {
+        const args = `--config e2e/config.json --testNamePattern '^((?!:ios:).)*$' --maxWorkers 1`;
+        expect(cliCall().command).toBe(`jest ${args} e2e`);
       });
 
-      it('should be enabled if custom switch has explicit value of \'true\'', async () => {
-        await callCli('./test', 'test --workers 2 --jest-report-specs true');
-        expectReportSpecsArg({value: true});
+      test('should put default environment variables (integration test)', () => {
+        expect(cliCall().env).toEqual({
+          DETOX_START_TIMESTAMP: expect.any(Number),
+          configPath: expect.any(String),
+          reportSpecs: true,
+          useCustomLogger: true,
+          forceAdbInstall: false,
+          readOnlyEmu: false,
+        });
       });
     });
 
-    describe('android', () => {
-      it('should adhere to custom --jest-report-specs switch, as with ios', async () => {
-        mockAndroidJestConfiguration();
-        await callCli('./test', 'test --workers 2 --jest-report-specs false');
-        expectReportSpecsArg({value: false});
-      });
+    test.each([['-c'], ['--configuration']])(
+      '%s <configuration> should provide inverted --testNamePattern that configuration (jest)',
+      async (__configuration) => {
+        detoxConfig.configurations.iosTest = { ...detoxConfig.configurations.single };
+        detoxConfig.configurations.iosTest.type = 'ios.simulator';
+        detoxConfig.configurations.androidTest = { ...detoxConfig.configurations.single };
+        detoxConfig.configurations.androidTest.type = 'android.emulator';
+
+        await run(`${__configuration} androidTest`);
+        expect(cliCall(0).command).toContain(`--testNamePattern '^((?!:ios:).)*$'`);
+        expect(cliCall(0).env.configuration).toBe('androidTest');
+
+        await run(`${__configuration} iosTest`);
+        expect(cliCall(1).command).toContain(`--testNamePattern '^((?!:android:).)*$'`);
+        expect(cliCall(1).env.configuration).toBe('iosTest');
+      }
+    );
+
+    test.each([['-o'], ['--runner-config']])('%s <path> should point to the specified Jest config', async (__runnerConfig) => {
+      await run(`${__runnerConfig} e2e/custom.config.js`);
+      expect(cliCall().command).toContain(`--config e2e/custom.config.js`);
+    });
+
+    test.each([['-l'], ['--loglevel']])('%s <value> should be passed as environment variable', async (__loglevel) => {
+      await run(`${__loglevel} trace`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ loglevel: 'trace' }));
+    });
+
+    test('--no-color should be passed as CLI argument', async () => {
+      await run(`--no-color`);
+      expect(cliCall().command).toContain(' --no-color ');
+    });
+
+    test.each([['-R'], ['--retries']])('%s <value> should execute successful run once', async (__retries) => {
+      await run(`-R 1`);
+      expect(cliCall(1)).toBe(null);
+    });
+
+    test.each([['-R'], ['--retries']])('%s <value> should clear failed tests file', async (__retries) => {
+      await run(`-R 1`);
+      const { resetLastFailedTests } = require('../src/utils/lastFailedTests');
+      expect(resetLastFailedTests).toHaveBeenCalled();
+    });
+
+    test.each([['-R'], ['--retries']])('%s <value> should execute unsuccessful run N extra times', async (__retries) => {
+      const { loadLastFailedTests } = require('../src/utils/lastFailedTests');
+      loadLastFailedTests.mockReturnValueOnce(['e2e/failing1.test.js', 'e2e/failing2.test.js']);
+      loadLastFailedTests.mockReturnValueOnce(['e2e/failing2.test.js']);
+      cp.execSync.mockImplementation(() => { throw new Error; });
+
+      await run(`-R 2`).catch(_.noop);
+      expect(cliCall(0).command).toMatch(/ e2e$/);
+      expect(cliCall(0).env).not.toHaveProperty('DETOX_RERUN_INDEX');
+
+      expect(cliCall(1).command).toMatch(/ e2e\/failing1.test.js e2e\/failing2.test.js$/);
+      expect(cliCall(1).env.DETOX_RERUN_INDEX).toBe(1);
+
+      expect(cliCall(2).command).toMatch(/ e2e\/failing2.test.js$/);
+      expect(cliCall(2).env.DETOX_RERUN_INDEX).toBe(2);
+    });
+
+    test.each([['-R'], ['--retries']])('%s <value> should not restart test runner if there are no failing tests paths', async (__retries) => {
+      const { loadLastFailedTests } = require('../src/utils/lastFailedTests');
+      loadLastFailedTests.mockReturnValueOnce([]);
+      cp.execSync.mockImplementation(() => { throw new Error; });
+
+      await run(`-R 1`).catch(_.noop);
+      expect(cliCall(0)).not.toBe(null);
+      expect(cliCall(1)).toBe(null);
+    });
+
+    test.each([['-R'], ['--retries']])('%s <value> should retain -- <...explicitPassthroughArgs>', async (__retries) => {
+      const { loadLastFailedTests } = require('../src/utils/lastFailedTests');
+      loadLastFailedTests.mockReturnValue(['tests/failing.test.js']);
+      cp.execSync.mockImplementation(() => { throw new Error; });
+
+      await run(`-R 1 tests -- --debug`).catch(_.noop);
+      expect(cliCall(0).command).toMatch(/ --debug tests$/);
+      expect(cliCall(1).command).toMatch(/ --debug tests\/failing.test.js$/);
+    });
+
+    test.each([['-r'], ['--reuse']])('%s <value> should be passed as environment variable', async (__reuse) => {
+      await run(`${__reuse}`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ reuse: true }));
+    });
+
+    test.each([['-u'], ['--cleanup']])('%s <value> should be passed as environment variable', async (__cleanup) => {
+      await run(`${__cleanup}`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ cleanup: true }));
+    });
+
+    test.each([['-d'], ['--debug-synchronization']])('%s <value> should be passed as environment variable', async (__debug_synchronization) => {
+      await run(`${__debug_synchronization} 5000`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ debugSynchronization: 5000 }));
+    });
+
+    test.each([['-d'], ['--debug-synchronization']])('%s <value> should have default value = 3000', async (__debug_synchronization) => {
+      await run(`${__debug_synchronization}`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ debugSynchronization: 3000 }));
+    });
+
+    test.each([['-a'], ['--artifacts-location']])('%s <value> should be passed as environment variable', async (__artifacts_location) => {
+      await run(`${__artifacts_location} /tmp`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ artifactsLocation: '/tmp' }));
+    });
+
+    test('--record-logs <value> should be passed as environment variable', async () => {
+      await run(`--record-logs all`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ recordLogs: 'all' }));
+    });
+
+    test('--take-screenshots <value> should be passed as environment variable', async () => {
+      await run(`--take-screenshots failing`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ takeScreenshots: 'failing' }));
+    });
+
+    test('--record-videos <value> should be passed as environment variable', async () => {
+      await run(`--record-videos failing`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ recordVideos: 'failing' }));
+    });
+
+    test('--record-performance <value> should be passed as environment variable', async () => {
+      await run(`--record-performance all`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ recordPerformance: 'all' }));
+    });
+
+    test('--record-timeline <value> should be passed as environment variable', async () => {
+      await run(`--record-timeline all`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ recordTimeline: 'all' }));
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should be passed as CLI argument', async (__workers) => {
+      await run(`${__workers} 2`);
+      expect(cliCall().command).toContain('--maxWorkers 2');
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should not warn anything for iOS', async (__workers) => {
+      singleConfig().type = 'ios.simulator';
+      await run(`${__workers} 2`);
+      expect(logger.warn).not.toHaveBeenCalled();
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should not put readOnlyEmu environment variable for iOS', async (__workers) => {
+      singleConfig().type = 'ios.simulator';
+      await run(`${__workers} 2`);
+      expect(cliCall().env).not.toHaveProperty('readOnlyEmu');
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should warn about experimental Android support', async (__workers) => {
+      singleConfig().type = 'android.emulator';
+      await run(`${__workers} 2`);
+      expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('an experimental feature'));
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should put readOnlyEmu environment variable for Android if there is a single worker', async (__workers) => {
+      singleConfig().type = 'android.emulator';
+      await run(`${__workers} 1`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ readOnlyEmu: false }));
+    });
+
+    test.each([['-w'], ['--workers']])('%s <value> should put readOnlyEmu environment variable for Android if there are multiple workers', async (__workers) => {
+      singleConfig().type = 'android.emulator';
+      await run(`${__workers} 2`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ readOnlyEmu: true }));
+    });
+
+    test('should omit --testNamePattern for custom platforms', async () => {
+      const customDriver = `module.exports = class CustomDriver {};`
+      singleConfig().type = tempfile('.js', customDriver);
+
+      await run();
+      expect(cliCall().command).not.toContain('--testNamePattern');
+    });
+
+    test('--jest-report-specs, by default, should be true, as environment variable', async () => {
+      await run();
+      expect(cliCall().env).toEqual(expect.objectContaining({ reportSpecs: true }));
+    });
+
+    test('--jest-report-specs, by default, should be false, if multiple workers are enabled', async () => {
+      await run('--workers 2');
+      expect(cliCall().env).toEqual(expect.objectContaining({ reportSpecs: false }));
+    });
+
+    test('--jest-report-specs, set explicitly, should override single worker defaults', async () => {
+      await run('--jest-report-specs false');
+      expect(cliCall().env).toEqual(expect.objectContaining({ reportSpecs: false }));
+    });
+
+    test('--jest-report-specs, set explicitly, should override multiple workers defaults', async () => {
+      await run('--workers 2 --jest-report-specs');
+      expect(cliCall().env).toEqual(expect.objectContaining({ reportSpecs: true }));
+    });
+
+    test.each([['-H'], ['--headless']])('%s <value> should be passed as environment variable', async (__headless) => {
+      await run(`${__headless}`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ headless: true }));
+    });
+
+    test('--gpu <value> should be passed as environment variable', async () => {
+      await run(`--gpu angle_indirect`);
+      expect(cliCall().env).toEqual(expect.objectContaining({ gpu: 'angle_indirect' }));
+    });
+
+    test('--device-launch-args should be passed as environment variable', async () => {
+      await run(`--device-launch-args "--verbose"`);
+      expect(cliCall().env).toEqual(expect.objectContaining({
+        deviceLaunchArgs: '--verbose'
+      }));
+    });
+
+    test('--use-custom-logger false should be prevent passing environment variable', async () => {
+      await run(`--use-custom-logger false`);
+      expect(cliCall().env).toEqual(expect.objectContaining({
+        useCustomLogger: false
+      }));
+    });
+
+    test('--force-adb-install should be ignored for iOS', async () => {
+      singleConfig().type = 'ios.simulator';
+      await run(`--force-adb-install`);
+      expect(cliCall().env).not.toHaveProperty('forceAdbInstall');
+    });
+
+    test('--force-adb-install should be passed as environment variable', async () => {
+      singleConfig().type = 'android.emulator';
+      await run(`--force-adb-install`);
+      expect(cliCall().env).toEqual(expect.objectContaining({
+        forceAdbInstall: true,
+      }));
+    });
+
+    test.each([['-n'], ['--device-name']])('%s <value> should be passed as environment variable', async (__device_name) => {
+      await run(`${__device_name} TheDevice`);
+      expect(cliCall().env).toEqual(expect.objectContaining({
+        deviceName: 'TheDevice',
+      }));
+    });
+
+    test('specifying direct test paths', async () => {
+      await run(`e2e/01.sanity.test.js e2e/02.sanity.test.js`);
+      expect(cliCall().command).not.toMatch(/ e2e /);
+      expect(cliCall().command).not.toMatch(/ e2e$/);
+      expect(cliCall().command).toMatch(/ e2e\/01.sanity.test.js e2e\/02.sanity.test.js$/);
+    });
+
+    test.each([
+      ['--inspect-brk e2eFolder', /^node --inspect-brk jest .* e2eFolder$/, {}],
+      ['-d e2eFolder', / e2eFolder$/, { debugSynchronization: 3000 }],
+      ['--debug-synchronization e2eFolder', / e2eFolder$/, { debugSynchronization: 3000 }],
+      ['-r e2eFolder', / e2eFolder$/, { reuse: true }],
+      ['--reuse e2eFolder', / e2eFolder$/, { reuse: true }],
+      ['-u e2eFolder', / e2eFolder$/, { cleanup: true }],
+      ['--cleanup e2eFolder', / e2eFolder$/, { cleanup: true }],
+      ['--jest-report-specs e2eFolder', / e2eFolder$/, { reportSpecs: true }],
+      ['-H e2eFolder', / e2eFolder$/, { headless: true }],
+      ['--headless e2eFolder', / e2eFolder$/, { headless: true }],
+      ['--keepLockFile e2eFolder', / e2eFolder$/, {}],
+      ['--use-custom-logger e2eFolder', / e2eFolder$/, { useCustomLogger: true }],
+      ['--force-adb-install e2eFolder', / e2eFolder$/, { forceAdbInstall: true }],
+    ])('"%s" should be disambigued correctly', async (command, commandMatcher, envMatcher) => {
+      singleConfig().type = 'android.emulator';
+      await run(command);
+
+      expect(cliCall().command).toMatch(commandMatcher);
+      expect(cliCall().env).toEqual(expect.objectContaining(envMatcher));
+    });
+
+    test('e.g., --debug should be passed through', async () => {
+      await run(`--debug`);
+      expect(cliCall().command).toContain('--debug');
+    });
+
+    test('e.g., --coverageProvider v8 should be passed through', async () => {
+      await run(`--coverageProvider v8`);
+      expect(cliCall().command).toContain('--coverageProvider v8');
+    });
+
+    test('e.g., --debug e2e/Login.test.js should be split to --debug and e2e/Login.test.js', async () => {
+      await run(`--debug e2e/Login.test.js --coverageProvider v8`);
+      expect(cliCall().command).toMatch(/--debug --coverageProvider v8 e2e\/Login.test.js$/);
     });
   });
 
-  describe('Jest read-only mode for emulators (propagated) switch', () => {
-    const expectReadOnlyEmulatorsArg = ({value}) => expect(execSync).toHaveBeenCalledWith(
-      expect.anything(),
-      expect.objectContaining({
-        env: expect.objectContaining({
-          readOnlyEmu: value,
-        }),
-      })
-    );
+  describe.each([['mocha'], ['jest']])('(%s)', (testRunner) => {
+    beforeEach(() => {
+      detoxConfig.testRunner = testRunner;
+    });
 
-    const expectNoReadOnlyEmulatorsArg = () => expect(execSync).toHaveBeenCalledWith(
-      expect.anything(),
-      expect.objectContaining({
-        env: expect.not.objectContaining({
-          readOnlyEmu: expect.anything(),
-        }),
-      })
-    );
+    test(`should deduce wrapped ${testRunner} CLI`, async () => {
+      detoxConfig.testRunner = `nyc ${testRunner}`;
+      await run();
+      expect(cliCall().command).toMatch(RegExp(`nyc ${testRunner} .* e2e$`));
+    });
+
+    describe.each([['ios.simulator'], ['android.emulator']])('for %s', (deviceType) => {
+      beforeEach(() => {
+        Object.values(detoxConfig.configurations)[0].type = deviceType;
+      });
+
+      test('--keepLockFile should be suppress clearing the device lock file', async () => {
+        await run('--keepLockFile');
+        expect(DeviceRegistry).not.toHaveBeenCalled();
+      });
 
-    it('should be enabled if more than 1 worker is set', async () => {
-      mockAndroidJestConfiguration();
-      await callCli('./test', 'test --workers 2');
-      expectReadOnlyEmulatorsArg({value: true});
+      test('--keepLockFile omission means clearing the device lock file', async () => {
+        await run();
+        expect(DeviceRegistry.mock.instances[0].reset).toHaveBeenCalled();
+      });
     });
 
-    it('should be disabled by default', async () => {
-      mockAndroidJestConfiguration();
-      await callCli('./test', 'test');
-      expectReadOnlyEmulatorsArg({value: false});
+    test('-- <...explicitPassthroughArgs> should be forwarded to the test runner CLI as-is', async () => {
+      await run('--device-launch-args detoxArgs e2eFolder -- a -a --a --device-launch-args runnerArgs');
+      expect(cliCall().command).toMatch(/a -a --a --device-launch-args runnerArgs e2eFolder$/);
+      expect(cliCall().env).toEqual(expect.objectContaining({ deviceLaunchArgs: 'detoxArgs' }));
     });
 
-    it('should be disabled on iOS', async () => {
-      mockIOSJestConfiguration();
-      await callCli('./test', 'test --workers 2');
-      expectNoReadOnlyEmulatorsArg();
+    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}`));
     });
   });
 
-  it('sets default value for debugSynchronization', async () => {
-    mockAndroidMochaConfiguration();
+  test('should fail for unrecognized test runner', async () => {
+    detoxConfig.testRunner = 'ava';
+    await expect(run('--inspect-brk')).rejects.toThrowError(/ava.*is not supported/);
+  });
+
+  // Helpers
+
+  function tempfile(extension, content) {
+    const tempFilePath = require('tempfile')(extension);
 
-    try {
-      await callCli('./test', 'test --debug-synchronization');
-    } catch (e) {
-      console.log(e);
+    fs.ensureFileSync(tempFilePath);
+    if (content) {
+      fs.writeFileSync(tempFilePath, content);
     }
-    expect(execSync).toHaveBeenCalledWith(
-      expect.stringContaining(
-        `${normalize('node_modules/.bin/mocha')} --opts e2e/mocha.opts --debug-synchronization 3000 --invert --grep :ios:`
-      ),
-      expect.anything()
-    );
-  });
 
-  it('passes extra args to the test runner', async () => {
-    mockAndroidMochaConfiguration();
+    temporaryFiles.push(tempFilePath);
+    return tempFilePath;
+  }
+
+  async function runRaw(command = '') {
+    return new Promise((resolve, reject) => {
+      const testCommand = require('./test');
+      const originalHandler = testCommand.handler;
+
+      const parser = yargs()
+        .scriptName('detox')
+        .parserConfiguration({
+          'boolean-negation': false,
+          'camel-case-expansion': false,
+          'dot-notation': false,
+          'duplicate-arguments-array': false,
+          'populate--': true,
+        })
+        .command({
+          ...testCommand,
+          async handler(argv) {
+            try {
+              await originalHandler(argv);
+              resolve();
+            } catch (e) {
+              reject(e);
+            }
+          },
+        })
+        .wrap(null);
 
-    try {
-      process.argv = [...process.argv, '--unknown-property', '42', '--flag'];
-      await callCli('./test', 'test --unknown-property 42 --flag');
-    } catch (e) {
-      console.log(e);
+      parser.parse(command, err => err && reject(err));
+    });
+  }
+
+  async function run(command = '') {
+    detoxConfigPath = tempfile('.json', JSON.stringify(detoxConfig));
+    const __configPath = Math.random() > 0.5 ? '-C' : '--config-path';
+    return runRaw(`test ${__configPath} ${detoxConfigPath} ${command}`);
+  }
+
+  function cliCall(index = 0) {
+    const mockCall = cp.execSync.mock.calls[index];
+    if (!mockCall) {
+      return null;
     }
 
-    expect(execSync).toHaveBeenCalledWith(expect.stringContaining('--unknown-property 42 --flag'), expect.anything());
-  });
+    const [command, opts] = mockCall;
+
+    return {
+      command,
+      env: _.omitBy(opts.env, (_value, key) => key in process.env),
+    };
+  }
+
+  function singleConfig() {
+    return Object.values(detoxConfig.configurations)[0];
+  }
 });
diff --git a/detox/local-cli/utils/testCommandArgs.js b/detox/local-cli/utils/testCommandArgs.js
index 1fc15db04..e7ba33661 100644
--- a/detox/local-cli/utils/testCommandArgs.js
+++ b/detox/local-cli/utils/testCommandArgs.js
@@ -8,7 +8,7 @@ module.exports = {
     alias: ['configuration'],
     group: 'Configuration:',
     describe:
-      'Select a device configuration from your defined configurations, if not supplied, and there\'s only one configuration, detox will default to it'
+      'Select a device configuration from your defined configurations, if not supplied, and there\'s only one configuration, detox will default to it',
   },
   o: {
     alias: 'runner-config',
@@ -19,27 +19,36 @@ module.exports = {
     alias: 'loglevel',
     group: 'Debugging:',
     choices: ['fatal', 'error', 'warn', 'info', 'verbose', 'trace'],
-    describe: 'Log level'
+    describe: 'Log level',
   },
   'no-color': {
     describe: 'Disable colors in log output',
     boolean: true,
   },
+  R: {
+    alias: 'retries',
+    group: 'Execution:',
+    describe: '[Jest Circus Only] Re-spawn the test runner for individual failing suite files until they pass, or <N> times at least.',
+    number: true,
+    default: 0,
+  },
   r: {
     alias: 'reuse',
     group: 'Execution:',
-    describe: 'Reuse existing installed app (do not delete + reinstall) for a faster run.'
+    describe: 'Reuse existing installed app (do not delete + reinstall) for a faster run.',
+    boolean: true,
   },
   u: {
     alias: 'cleanup',
     group: 'Execution:',
-    describe: 'Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue'
+    describe: 'Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue',
+    boolean: true,
   },
   d: {
     alias: 'debug-synchronization',
     group: 'Debugging:',
     coerce(value) {
-      if (value == null) {
+      if (value == null || value === false || value === 'false') {
         return undefined;
       }
 
@@ -47,75 +56,78 @@ module.exports = {
         return 3000;
       }
 
-      return Number(value);
+      return Number.isNaN(+value) ? value : +value;
     },
     describe:
       'When an action/expectation takes a significant amount of time use this option to print device synchronization status.' +
-      'The status will be printed if the action takes more than [value]ms to complete'
+      'The status will be printed if the action takes more than [value]ms to complete',
   },
   a: {
     alias: 'artifacts-location',
     group: 'Debugging:',
-    describe: 'Artifacts (logs, screenshots, etc) root directory.'
+    describe: 'Artifacts (logs, screenshots, etc) root directory.',
   },
   'record-logs': {
     group: 'Debugging:',
     choices: ['failing', 'all', 'none'],
-    describe: 'Save logs during each test to artifacts directory. Pass "failing" to save logs of failing tests only.'
+    describe: 'Save logs during each test to artifacts directory. Pass "failing" to save logs of failing tests only.',
   },
   'take-screenshots': {
     group: 'Debugging:',
     choices: ['manual', 'failing', 'all', 'none'],
-    describe: 'Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only.'
+    describe: 'Save screenshots before and after each test to artifacts directory. Pass "failing" to save screenshots of failing tests only.',
   },
   'record-videos': {
     group: 'Debugging:',
     choices: ['failing', 'all', 'none'],
-    describe: 'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.'
+    describe: 'Save screen recordings of each test to artifacts directory. Pass "failing" to save recordings of failing tests only.',
   },
   'record-performance': {
     group: 'Debugging:',
     choices: ['all', 'none'],
-    describe: '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.'
+    describe: '[iOS Only] Save Detox Instruments performance recordings of each test to artifacts directory.',
   },
   'record-timeline': {
     group: 'Debugging:',
     choices: ['all', 'none'],
-    describe: '[Jest Only] Record tests and events timeline, for visual display on the chrome://tracing tool.'
+    describe: '[Jest Only] Record tests and events timeline, for visual display on the chrome://tracing tool.',
   },
   w: {
     alias: 'workers',
     group: 'Execution:',
     describe:
       '[iOS Only] Specifies the number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest)',
-    string: true,
-    default: '1'
+    number: true,
+    default: 1,
   },
   'jest-report-specs': {
     group: 'Execution:',
     describe: '[Jest Only] Whether to output logs per each running spec, in real-time. By default, disabled with multiple workers.',
+    boolean: true,
   },
   H: {
     alias: 'headless',
     group: 'Execution:',
-    describe: '[Android Only] Launch emulator in headless mode. Useful when running on CI.'
+    describe: '[Android Only] Launch emulator in headless mode. Useful when running on CI.',
+    boolean: true,
   },
   gpu: {
     group: 'Execution:',
-    describe: '[Android Only] Launch emulator with the specific -gpu [gpu mode] parameter.'
+    describe: '[Android Only] Launch emulator with the specific -gpu [gpu mode] parameter.',
   },
   keepLockFile:{
     group: 'Configuration:',
-    describe:'Keep the device lock file when running Detox tests'
+    describe:'Keep the device lock file when running Detox tests',
+    boolean: true,
   },
   n: {
     alias: 'device-name',
     group: 'Configuration:',
-    describe: 'Override the device name specified in a configuration. Useful for running a single build configuration on multiple devices.'
+    describe: 'Override the device name specified in a configuration. Useful for running a single build configuration on multiple devices.',
   },
   'device-launch-args': {
     group: 'Execution:',
-    describe: 'Custom arguments to pass (through) onto the device (emulator/simulator) binary when launched.'
+    describe: 'Custom arguments to pass (through) onto the device (emulator/simulator) binary when launched.',
   },
   'use-custom-logger': {
     boolean: true,
@@ -132,5 +144,6 @@ module.exports = {
   'inspect-brk': {
     group: 'Debugging:',
     describe: 'Allows debugging of the underlying test runner',
+    boolean: true,
   }
 };
diff --git a/detox/runners/jest/FailingTestsReporter.js b/detox/runners/jest/FailingTestsReporter.js
new file mode 100644
index 000000000..201d46d91
--- /dev/null
+++ b/detox/runners/jest/FailingTestsReporter.js
@@ -0,0 +1,15 @@
+const path = require('path');
+const { saveLastFailedTests } = require('../../src/utils/lastFailedTests');
+
+class FailingTestsReporter {
+  async onRunComplete(_contexts, { testResults }) {
+    const cwd = process.cwd();
+    const failedFiles = testResults
+      .filter(result => result.numFailingTests > 0)
+      .map(result => path.relative(cwd, result.testFilePath));
+
+    await saveLastFailedTests(failedFiles);
+  }
+}
+
+module.exports = FailingTestsReporter;
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index e3108f225..6c5d11342 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -258,6 +258,15 @@ describe('index (regular)', () => {
   });
 });
 
+describe(':ios: test', () => {
+  it('should pass', () => {});
+});
+
+describe(':android: test', () => {
+  it('should pass 1', () => {});
+  it('should pass 2', () => {});
+});
+
 describe('index (global detox variable injected with Jest Circus)', () => {
   beforeEach(() => {
     if (global.detox) {
diff --git a/detox/src/utils/lastFailedTests.js b/detox/src/utils/lastFailedTests.js
new file mode 100644
index 000000000..d2ea44c63
--- /dev/null
+++ b/detox/src/utils/lastFailedTests.js
@@ -0,0 +1,37 @@
+const fs = require('fs-extra');
+const environment = require('./environment');
+
+async function resetLastFailedTests() {
+  const lastFailedTxt = environment.getLastFailedTestsPath();
+  await fs.remove(lastFailedTxt);
+}
+
+async function loadLastFailedTests() {
+  const lastFailedTxt = environment.getLastFailedTestsPath();
+  const lastFailedTests = await fs.exists(lastFailedTxt)
+    ? (await fs.readFile(lastFailedTxt, 'utf8')).trim()
+    : '';
+
+  if (lastFailedTests) {
+    return lastFailedTests.split('\n');
+  } else {
+    return [];
+  }
+}
+
+/**
+ * @async
+ * @param {string[]} failedFiles - paths to the test suites
+ */
+async function saveLastFailedTests(failedFiles) {
+  const failedTestsPath = environment.getLastFailedTestsPath();
+
+  await fs.ensureFile(failedTestsPath);
+  await fs.writeFile(failedTestsPath, failedFiles.join('\n'));
+}
+
+module.exports = {
+  resetLastFailedTests,
+  loadLastFailedTests,
+  saveLastFailedTests,
+};
diff --git a/detox/src/utils/lastFailedTests.test.js b/detox/src/utils/lastFailedTests.test.js
new file mode 100644
index 000000000..72b1535f5
--- /dev/null
+++ b/detox/src/utils/lastFailedTests.test.js
@@ -0,0 +1,75 @@
+jest.mock('./environment');
+
+const fs = require('fs-extra');
+const tempfile = require('tempfile');
+
+describe('lastFailedTests', () => {
+  let lastFailedTestsPath;
+
+  beforeEach(() => {
+    lastFailedTestsPath = tempfile('.txt');
+    require('./environment').getLastFailedTestsPath.mockReturnValue(lastFailedTestsPath);
+  });
+
+  afterEach(async () => {
+    await fs.remove(lastFailedTestsPath);
+  });
+
+  describe('loadLastFailedTests', () => {
+    let loadLastFailedTests;
+
+    beforeEach(() => {
+      loadLastFailedTests = require('./lastFailedTests').loadLastFailedTests;
+    });
+
+    it('should return an empty array if the file does not exist', async () => {
+      expect(await loadLastFailedTests()).toEqual([]);
+    });
+
+    it('should return an empty array if the file is empty', async () => {
+      await fs.writeFile(lastFailedTestsPath, '');
+      expect(await loadLastFailedTests()).toEqual([]);
+    });
+
+    it('should split file contents by newline and return as a string array', async () => {
+      await fs.writeFile(lastFailedTestsPath, '1\n2\n3');
+      expect(await loadLastFailedTests()).toEqual(['1', '2', '3']);
+    });
+  });
+
+  describe('saveLastFailedTests', () => {
+    let saveLastFailedTests;
+
+    beforeEach(() => {
+      saveLastFailedTests = require('./lastFailedTests').saveLastFailedTests;
+    });
+
+    it('should save empty string to the file when given an empty array', async () => {
+      await saveLastFailedTests([]);
+      expect(await fs.readFile(lastFailedTestsPath, 'utf8')).toBe('');
+    });
+
+    it('should save a given array of strings to the file, separating them by newline', async () => {
+      await saveLastFailedTests(['1', '2', '3']);
+      expect(await fs.readFile(lastFailedTestsPath, 'utf8')).toBe('1\n2\n3');
+    });
+  });
+
+  describe('resetLastFailedTests', () => {
+    let resetLastFailedTests;
+
+    beforeEach(() => {
+      resetLastFailedTests = require('./lastFailedTests').resetLastFailedTests;
+    });
+
+    it('should delete the file', async () => {
+      await resetLastFailedTests();
+      expect(await fs.exists(lastFailedTestsPath)).toBe(false);
+    });
+
+    it('should not fail if the file does not exist', async () => {
+      await resetLastFailedTests();
+      await expect(resetLastFailedTests()).resolves.not.toThrow();
+    });
+  });
+});
diff --git a/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
index 61ae85071..822ad61ec 100644
--- a/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
+++ b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
@@ -1,9 +1,7 @@
 package com.example;
 
 import android.os.Bundle;
-import android.view.Surface;
 
-import com.linkedin.android.testbutler.TestButler;
 import com.wix.detox.Detox;
 
 import org.junit.Rule;
@@ -40,17 +38,12 @@ public class DetoxTest {
 
     @Test
     public void runDetoxTests() {
-        verifyTestButlerReady();
+        TestButlerProbe.assertReadyIfInstalled();
 
         final ActivityTestRule<?> rule = resolveTestRule();
         Detox.runTests(rule);
     }
 
-    private void verifyTestButlerReady() {
-        // This has no effect if test-butler is running. However, if it is not, then unlike TestButler.setup(), it will hard-fail.
-        TestButler.setRotation(Surface.ROTATION_0);
-    }
-
     private ActivityTestRule<?> resolveTestRule() {
         final Bundle arguments = InstrumentationRegistry.getArguments();
         final boolean useSingleTaskActivity = Boolean.parseBoolean(arguments.getString(USE_SINGLE_INSTANCE_ACTIVITY_ARG, "false"));
diff --git a/detox/test/android/app/src/androidTest/java/com/example/TestButlerProbe.java b/detox/test/android/app/src/androidTest/java/com/example/TestButlerProbe.java
new file mode 100644
index 000000000..a0bc1c7ac
--- /dev/null
+++ b/detox/test/android/app/src/androidTest/java/com/example/TestButlerProbe.java
@@ -0,0 +1,49 @@
+package com.example;
+
+import android.content.pm.PackageManager;
+import android.util.Log;
+import android.view.Surface;
+
+import com.linkedin.android.testbutler.TestButler;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+class TestButlerProbe {
+
+    private static final String LOG_TAG = TestButlerProbe.class.getSimpleName();
+    private static final String TEST_BUTLER_PACKAGE_NAME = "com.linkedin.android.testbutler";
+
+    private TestButlerProbe() {
+    }
+
+    static void assertReadyIfInstalled() {
+        Log.i(LOG_TAG, "Test butler service verification started...");
+
+        if (!isTestButlerServiceInstalled()) {
+            Log.w(LOG_TAG, "Test butler not installed on device - skipping verification");
+            return;
+        }
+
+        assertTestButlerServiceReady();
+        Log.i(LOG_TAG, "Test butler service is up and running!");
+    }
+
+    static private boolean isTestButlerServiceInstalled() {
+        try {
+            PackageManager pm = InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageManager();
+            pm.getPackageInfo(TEST_BUTLER_PACKAGE_NAME, 0);
+            return true;
+        } catch (PackageManager.NameNotFoundException e) {
+            return false;
+        }
+    }
+
+    static private void assertTestButlerServiceReady() {
+        try {
+            // This has no effect if test-butler is running. However, if it is not, then unlike TestButler.setup(), it would hard-fail.
+            TestButler.setRotation(Surface.ROTATION_0);
+        } catch (Exception e) {
+            throw new RuntimeException("Test butler service is NOT ready!", e);
+        }
+    }
+}
diff --git a/detox/test/package.json b/detox/test/package.json
index 4c520b8ad..8d95c10c9 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -14,12 +14,12 @@
     "e2e:ios": "detox test -c ios.sim.release --debug-synchronization",
     "e2e:android": "detox test -c android.emu.release",
     "e2e:android-debug": "detox test -c android.emu.debug",
-    "e2e:ios-ci": "npm run e2e:ios -- --workers 3",
-    "e2e:ios-timeout-ci": "node scripts/assert_timeout.js npm run e2e:ios-ci -- e2e/25.timeout.test.js",
-    "e2e:android-ci": "npm run e2e:android -- --workers 3 --headless --jest-report-specs --loglevel verbose",
-    "e2e:android-timeout-ci": "node scripts/assert_timeout.js npm run e2e:android-ci -- e2e/25.timeout.test.js",
-    "e2e:legacy-jasmine:ios-timeout-ci": "node scripts/assert_timeout.js npm run e2e:ios -- -o e2e/config-jasmine.js --take-screenshots failing -- e2e/25.timeout.test.js",
-    "e2e:legacy-jasmine:android-timeout-ci": "node scripts/assert_timeout.js npm run e2e:android -- -o e2e/config-jasmine.js --take-screenshots failing -- e2e/25.timeout.test.js",
+    "e2e:ios-ci": "npm run e2e:ios -- --workers 3 --retries 1",
+    "e2e:ios-timeout-ci": "node scripts/assert_timeout.js npm run e2e:ios -- e2e/25.timeout.test.js",
+    "e2e:android-ci": "npm run e2e:android -- --workers 3 --retries 1 --headless --jest-report-specs --loglevel verbose",
+    "e2e:android-timeout-ci": "node scripts/assert_timeout.js npm run e2e:android -- --headless e2e/25.timeout.test.js",
+    "e2e:legacy-jasmine:ios-timeout-ci": "node scripts/assert_timeout.js npm run e2e:ios -- -o e2e/config-jasmine.js --take-screenshots failing e2e/25.timeout.test.js",
+    "e2e:legacy-jasmine:android-timeout-ci": "node scripts/assert_timeout.js npm run e2e:android -- -o e2e/config-jasmine.js --take-screenshots failing e2e/25.timeout.test.js",
     "build:ios": "detox build -c ios.sim.release",
     "build:android": "detox build -c android.emu.release",
     "build:android-debug": "detox build -c android.emu.debug",