Repository: wix/Detox
Pull request #3766 (2022-12-19 08:08:29)

feat(cli): detox start, detox test --start
## Description

- This pull request addresses the issue described here: #3745

In this pull request, I have implemented the following:

1. A new app config property:
```diff
 {
   type: '...',
   binaryPath: '...',
   build: '...',
+  start: 'react-native start', // ← for example
 }
```
2. A new CLI command: `detox start`:
    ```sh
    detox start -c ios.sim.debug # runs RN packager
    ```
3. A new modifier for the existing CLI command: `detox test ... --start=???`:
    ```sh
    detox test -c ios.sim.debug # runs start command if exists
    detox test -c ios.sim.debug --no-start # disables start command
    detox test -c ios.sim.debug --start=force # runs tests even if the start command fails
    ```

---

> _For features/enhancements:_
 - [x] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files. (TODO)

> _For API changes:_
 - [x] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file.


asafkorem: Thinking of this API from a user perspective, it makes so much sense to define all necessary preparation actions for running Detox tests, from the same place where we define the `build` command. I LOVE IT 👑 

Are there any use-cases where defining what should happen on detox test completion would also make sense to define under the app config? 
asafkorem: I think we should add 2 buildkite jobs for testing the start command and for general sanity with both iOS-debug and Android-debug builds (using demo-react-native example project).
stale[bot]: This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.
Thank you for your contributions.
For more information on bots in this repository, read [this discussion](https://github.com/wix/Detox/issues/1305).

stale[bot]: The issue has been closed for inactivity.


Patch:
diff --git a/detox/index.d.ts b/detox/index.d.ts
index 28f0cecba..1101b9520 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -331,6 +331,7 @@ declare global {
             binaryPath: string;
             bundleId?: string;
             build?: string;
+            start?: string;
             launchArgs?: Record<string, any>;
         }
 
@@ -339,6 +340,7 @@ declare global {
             binaryPath: string;
             bundleId?: string;
             build?: string;
+            start?: string;
             testBinaryPath?: string;
             launchArgs?: Record<string, any>;
             /**
diff --git a/detox/local-cli/cli.js b/detox/local-cli/cli.js
index 7c6f3d56b..32eaaa28c 100755
--- a/detox/local-cli/cli.js
+++ b/detox/local-cli/cli.js
@@ -12,7 +12,7 @@ const { isErrorAlreadyLogged } = require('./utils/cliErrorHandling');
 yargs
   .scriptName('detox')
   .parserConfiguration({
-    'boolean-negation': false,
+    'boolean-negation': true,
     'camel-case-expansion': false,
     'dot-notation': false,
     'duplicate-arguments-array': false,
diff --git a/detox/local-cli/start.js b/detox/local-cli/start.js
new file mode 100644
index 000000000..c6e45fe2c
--- /dev/null
+++ b/detox/local-cli/start.js
@@ -0,0 +1,49 @@
+const _ = require('lodash');
+
+const detox = require('../internals');
+
+const AppStartCommand = require('./startCommand/AppStartCommand');
+
+module.exports.command = 'start';
+module.exports.desc = 'Run app "start" scripts inside the selected configuration';
+module.exports.builder = {
+  C: {
+    alias: 'config-path',
+    describe: 'Specify Detox config file path. If not supplied, Detox searches for .detoxrc[.js] or "detox" section in package.json',
+  },
+  c: {
+    alias: ['configuration'],
+    describe:
+      'Select a local configuration from your defined configurations to extract the app "start" scripts from. If not supplied, and there\'s only one configuration, Detox will default to it',
+  },
+  f: {
+    alias: 'force',
+    describe: 'Ignore errors from the "start" scripts and proceed',
+    boolean: true,
+  }
+};
+
+module.exports.handler = async function start(argv) {
+  const { apps: appsConfig } = await detox.resolveConfig({ argv });
+  const startCommands = _(appsConfig)
+    .values()
+    .map(app => app.start)
+    .filter(Boolean)
+    .map(cmd => new AppStartCommand({
+      cmd,
+      passthrough: argv['--'],
+      forceSpawn: argv.force,
+    }))
+    .value();
+
+  if (startCommands.length) {
+    try {
+      await Promise.all(startCommands.map(c => c.execute()));
+    } catch (e) {
+      await Promise.allSettled(startCommands.map(c => c.stop()));
+      throw e;
+    }
+  } else {
+    detox.log.warn('No "start" commands were found in the app configs.');
+  }
+};
diff --git a/detox/local-cli/startCommand/AppStartCommand.js b/detox/local-cli/startCommand/AppStartCommand.js
new file mode 100644
index 000000000..72ba33169
--- /dev/null
+++ b/detox/local-cli/startCommand/AppStartCommand.js
@@ -0,0 +1,65 @@
+const execa = require('execa');
+
+const detox = require('../../internals');
+const { DetoxRuntimeError } = require('../../src/errors');
+const Deferred = require('../../src/utils/Deferred');
+const log = detox.log.child({ cat: ['lifecycle', 'cli'] });
+
+class AppStartCommand {
+  constructor({ cmd, passthrough = [], forceSpawn = false }) {
+    this._id = Math.random();
+    this._cmd = cmd;
+    this._passthrough = passthrough;
+    this._forceSpawn = forceSpawn;
+
+    this._cpHandle = null;
+    this._cpDeferred = new Deferred();
+  }
+
+  execute() {
+    const cmd = [this._cmd, ...this._passthrough].join(' ');
+
+    log.info.begin({ id: this._id }, cmd);
+
+    const onEnd = (msg, code, signal) => {
+      log.trace.end({ id: this._id, code, signal }, msg);
+      this._cpDeferred.resolve();
+    };
+
+    const onError = (msg, code, signal) => {
+      const logLevel = this._forceSpawn ? 'warn' : 'error';
+      log[logLevel].end({ id: this._id, code, signal }, msg);
+      if (this._forceSpawn) {
+        this._cpDeferred.resolve();
+      } else {
+        this._cpDeferred.reject(new DetoxRuntimeError(msg));
+      }
+    };
+
+    this._cpHandle = execa.command(cmd, { stdio: 'inherit', shell: true });
+    this._cpHandle.on('error', onError);
+    this._cpHandle.on('exit', (code, signal) => {
+      const reason = code == null ? `signal ${signal}` : `code ${code}`;
+      const msg = `Command exited with ${reason}: ${cmd}`;
+      if (signal || code === 0) {
+        onEnd(msg, code, signal);
+      } else {
+        onError(msg, code, signal);
+      }
+
+      this._cpHandle = null;
+    });
+
+    return this._cpDeferred.promise;
+  }
+
+  async stop() {
+    if (this._cpHandle) {
+      this._cpHandle.kill();
+    }
+
+    return this._cpDeferred.promise;
+  }
+}
+
+module.exports = AppStartCommand;
diff --git a/detox/package.json b/detox/package.json
index b6b508373..91fefb001 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -63,6 +63,7 @@
     "caf": "^15.0.1",
     "chalk": "^2.4.2",
     "child-process-promise": "^2.2.0",
+    "execa": "^5.1.1",
     "find-up": "^4.1.0",
     "fs-extra": "^4.0.2",
     "funpermaproxy": "^1.1.0",
diff --git a/detox/src/configuration/collectCliConfig.js b/detox/src/configuration/collectCliConfig.js
index b492850ff..e6f8af4bd 100644
--- a/detox/src/configuration/collectCliConfig.js
+++ b/detox/src/configuration/collectCliConfig.js
@@ -55,6 +55,7 @@ function collectCliConfig({ argv }) {
     useCustomLogger: asBoolean(get('use-custom-logger')),
     retries: asNumber(get('retries')),
     inspectBrk: asBoolean(get('inspect-brk')),
+    start: get('start'),
   }, _.isUndefined);
 }
 
diff --git a/docs/cli/start.md b/docs/cli/start.md
new file mode 100644
index 000000000..43b3cbc6a
--- /dev/null
+++ b/docs/cli/start.md
@@ -0,0 +1,42 @@
+# detox start
+
+`detox start [options]`
+
+Runs the [`start` command](../config/apps.mdx#properties) of the app (or apps)
+from the specified [configuration](../config/overview.mdx#config-structure).
+
+| Option                                | Description                                                                                                                                                                              |
+| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| -C, --config-path `<configPath>`      | Specify Detox config file path. If not supplied, Detox searches for .detoxrc\[.js] or "detox" section in package.json.                                                                   |
+| -c, --configuration `<device config>` | Select a local configuration from your defined configurations to extract the app "start" scripts from it. If not supplied, and there’s only one configuration, Detox will default to it. |
+| -f, --force                           | Ignore errors from the "start" scripts and continue.                                                                                                                                     |
+| --help                                | Show help                                                                                                                                                                                |
+
+## Examples
+
+If you have only one configuration, you can simply use:
+
+```bash
+detox start
+```
+
+To choose a specific configuration:
+
+```bash
+# long alias:
+detox start --configuration yourConfiguration
+# short alias:
+detox start -c yourConfiguration
+```
+
+To forward extra arguments to the "start" script, e.g.:
+
+```bash
+detox start -c yourConfiguration -- --port 8082
+```
+
+To ignore errors from the "start" scripts and continue:
+
+```bash
+detox start -c yourConfiguration --force
+```
diff --git a/docs/config/apps.mdx b/docs/config/apps.mdx
index c9b53aef3..74767b7f5 100644
--- a/docs/config/apps.mdx
+++ b/docs/config/apps.mdx
@@ -42,15 +42,16 @@ The format of Detox config allows you to define inside it multiple app configs i
 
 An app config can have the following params:
 
-| Configuration Params | Details                                                                                                                                                                                                               |
-| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `type`               | Mandatory property to discern app types: `ios.app`, `android.apk`.                                                                                                                                                    |
-| `name`               | Use only when working with multiple apps within the same configuration. See an example below.                                                                                                                         |
-| `binaryPath`         | Relative path to the ipa/app/apk due to be tested (make sure you build the app in a project relative path)                                                                                                            |
-| `build`              | **\[optional]** Build command (normally an `xcodebuild` command you use to build your app), which can be called later using Detox CLI tool as a convenience.                                                          |
-| `testBinaryPath`     | (optional, Android only): relative path to the test app (apk)                                                                                                                                                         |
-| `launchArgs`         | **\[optional]** An object specifying arguments (key-values pairs) to pass through into the app, upon launching on the device. For more info, refer to the dedicated [launch-arguments guide](../guide/launch-args.md). |
-| `reversePorts`       | **(optional, Android only)** An array of TCP ports to reverse, so that the network requests to `localhost:{port}` on Android device are going to be forwarded to the host machine.                                    |
+| Configuration Params | Details                                                                                                                                                                                                                                                                                     |
+| -------------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `type`               | Mandatory property to discern app types: `ios.app`, `android.apk`.                                                                                                                                                                                                                          |
+| `name`               | Use only when working with multiple apps within the same configuration. See an example below.                                                                                                                                                                                               |
+| `binaryPath`         | Relative path to the ipa/app/apk due to be tested (make sure you build the app in a project relative path)                                                                                                                                                                                  |
+| `build`              | **\[optional]** Build command, which can be called using [`detox build`] CLI as a convenience.                                                                                                                                                                                              |
+| `start`              | **\[optional]** Start command, which will be called before [`detox test`] CLI starts, or explicitly via [`detox start`] command.                                                                                                                                                            |
+| `testBinaryPath`     | (optional, Android only): relative path to the test app (apk)                                                                                                                                                                                                                               |
+| `launchArgs`         | **\[optional]** An object specifying arguments (key-values pairs) to pass through into the app, upon launching on the device. For more info, refer to the dedicated [launch-arguments guide](../guide/launch-args.md).                                                                      |
+| `reversePorts`       | **(optional, Android only)** An array of TCP ports to reverse, so that the network requests to `localhost:{port}` on Android device are going to be forwarded to the host machine.                                                                                                          |
 
 ## Multiple apps
 
@@ -89,3 +90,7 @@ await device.selectApp('passenger');
 await device.launchApp();
 // ... run tests ...
 ```
+
+[`detox build`]: ../cli/build.md
+[`detox start`]: ../cli/start.md
+[`detox test`]: ../cli/test.md
diff --git a/examples/demo-react-native/detox.config.js b/examples/demo-react-native/detox.config.js
index d0b238388..7ccbb2886 100644
--- a/examples/demo-react-native/detox.config.js
+++ b/examples/demo-react-native/detox.config.js
@@ -20,22 +20,24 @@ module.exports = {
     "ios.release": {
       "type": "ios.app",
       "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/example.app",
-      "build": "export RCT_NO_LAUNCH_PACKAGER=true && xcodebuild -workspace ios/example.xcworkspace -UseNewBuildSystem=NO -scheme example -configuration Release -sdk iphonesimulator -derivedDataPath ios/build -quiet"
+      "build": "export RCT_NO_LAUNCH_PACKAGER=true && xcodebuild -workspace ios/example.xcworkspace -UseNewBuildSystem=NO -scheme example -configuration Release -sdk iphonesimulator -derivedDataPath ios/build -quiet",
     },
     "ios.debug": {
       "type": "ios.app",
       "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/example.app",
-      "build": "xcodebuild -workspace ios/example.xcworkspace -UseNewBuildSystem=NO -scheme example -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build"
+      "build": "xcodebuild -workspace ios/example.xcworkspace -UseNewBuildSystem=NO -scheme example -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
+      "start": "scripts/start-rn.sh ios",
     },
     "android.debug": {
       "type": "android.apk",
       "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
-      "build": "cd android ; ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug ; cd -"
+      "build": "cd android ; ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug ; cd -",
+      "start": "scripts/start-rn.sh android",
     },
     "android.release": {
       "type": "android.apk",
       "binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
-      "build": "cd android ; ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release ; cd -"
+      "build": "cd android ; ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release ; cd -",
     }
   },
   devices: {
diff --git a/examples/demo-react-native/index.js b/examples/demo-react-native/index.ios.js
similarity index 100%
rename from examples/demo-react-native/index.js
rename to examples/demo-react-native/index.ios.js
diff --git a/examples/demo-react-native/ios/example/AppDelegate.m b/examples/demo-react-native/ios/example/AppDelegate.m
index 2eba255e8..63fb62ef2 100644
--- a/examples/demo-react-native/ios/example/AppDelegate.m
+++ b/examples/demo-react-native/ios/example/AppDelegate.m
@@ -16,7 +16,11 @@
 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
 {
   NSURL *jsCodeLocation;
-  jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
+  #ifdef DEBUG
+    jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
+  #else
+    jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
+  #endif
 
   RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                       moduleName:@"example"
diff --git a/examples/demo-react-native/scripts/start-rn.sh b/examples/demo-react-native/scripts/start-rn.sh
new file mode 100755
index 000000000..4c774d59b
--- /dev/null
+++ b/examples/demo-react-native/scripts/start-rn.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+trap 'kill $RN_PID' EXIT
+
+PLATFORM=$1
+
+npm start &
+RN_PID=$!
+sleep 2 && curl>/dev/null http://localhost:8081/index.$PLATFORM.bundle
+wait $RN_PID
diff --git a/scripts/demo-projects.android.sh b/scripts/demo-projects.android.sh
index a4d3124ba..6e19ba24b 100755
--- a/scripts/demo-projects.android.sh
+++ b/scripts/demo-projects.android.sh
@@ -15,10 +15,10 @@ run_f "npm run build:android"
 popd
 
 pushd examples/demo-react-native
-  run_f "npm run build:android-release"
-  run_f "npm run test:android-release"
+  run_f "npm run build:android-debug"
+  run_f "npm run test:android-debug"
 
-  # Run tests with bloated JS bundle:
+  # Run tests in release mode with bloated JS bundle:
   source $SCRIPTS_PATH/demo-rn-bloat-bundle-test.sh android
 popd
 
diff --git a/scripts/demo-projects.ios.sh b/scripts/demo-projects.ios.sh
index ba98ccaa6..45c57e8b7 100755
--- a/scripts/demo-projects.ios.sh
+++ b/scripts/demo-projects.ios.sh
@@ -13,8 +13,8 @@ pushd examples/demo-react-native
     run_f "pod install"
   popd
 
-  run_f "npm run build:ios-release"
-  run_f "npm run test:ios-release"
+  run_f "npm run build:ios-debug"
+  run_f "npm run test:ios-debug"
 
   # Run tests with bloated JS bundle:
   source $SCRIPTS_PATH/demo-rn-bloat-bundle-test.sh ios


Test patch:
diff --git a/detox/__tests__/helpers.js b/detox/__tests__/helpers.js
index 4669bb0d3..397ff89fe 100644
--- a/detox/__tests__/helpers.js
+++ b/detox/__tests__/helpers.js
@@ -25,7 +25,7 @@ function callCli(modulePath, cmd) {
     return yargs
       .scriptName('detox')
       .parserConfiguration({
-        'boolean-negation': false,
+        'boolean-negation': true,
         'camel-case-expansion': false,
         'dot-notation': false,
         'duplicate-arguments-array': false,
diff --git a/detox/__tests__/setupJest.js b/detox/__tests__/setupJest.js
index cee348e55..277f99578 100644
--- a/detox/__tests__/setupJest.js
+++ b/detox/__tests__/setupJest.js
@@ -1,3 +1,3 @@
 jest.mock('proper-lockfile');
-jest.mock('signal-exit');
+jest.mock('signal-exit', () => jest.fn(() => () => {}));
 jest.mock('../src/logger/DetoxLogger');
diff --git a/detox/local-cli/start.test.js b/detox/local-cli/start.test.js
new file mode 100644
index 000000000..75e7653c6
--- /dev/null
+++ b/detox/local-cli/start.test.js
@@ -0,0 +1,104 @@
+const { callCli, buildMockCommand } = require('../__tests__/helpers');
+
+describe('start', () => {
+  let cmd;
+  let detox;
+
+  beforeEach(() => {
+    cmd = null;
+
+    jest.mock('../src/utils/logger');
+    jest.mock('../internals', () => {
+      const DetoxConfigErrorComposer = require('../src/errors/DetoxConfigErrorComposer');
+
+      const config = {
+        apps: {},
+        artifacts: {},
+        behavior: {},
+        errorComposer: new DetoxConfigErrorComposer(),
+        device: {},
+        session: {}
+      };
+
+      return ({
+        config,
+        resolveConfig: jest.fn().mockResolvedValue(config),
+        log: require('../src/utils/logger')
+      });
+    });
+
+    detox = require('../internals');
+  });
+
+  afterEach(async () => {
+    if (cmd) {
+      await cmd.clean();
+    }
+  });
+
+  it('passes argv to resolveConfig', async () => {
+    await callCli('./start', 'start -C /etc/.detoxrc.js -c myconf');
+
+    expect(detox.resolveConfig).toHaveBeenCalledWith({
+      argv: expect.objectContaining({
+        'C': '/etc/.detoxrc.js',
+        'c': 'myconf',
+      }),
+    });
+  });
+
+  it('warns if no start commands were found', async () => {
+    await callCli('./start', 'start');
+    expect(detox.log.warn).toHaveBeenCalledWith(expect.stringContaining('No "start" commands were found'));
+  });
+
+  it('spawns the start script from the composed apps config', async () => {
+    cmd = buildMockCommand();
+    detox.config.apps.default = { start: `${cmd.cmd} --arg1=value1 --arg2 value2` };
+    await callCli('./start', 'start').catch(() => {});
+
+    expect(cmd.calls).toEqual([
+      expect.objectContaining({ argv: [
+        expect.stringContaining('executable'),
+        '--arg1=value1',
+        '--arg2',
+        'value2',
+      ] }),
+    ]);
+  });
+
+  it('forwards passthrough arguments to the start script', async () => {
+    cmd = buildMockCommand();
+    detox.config.apps.default = { start: `${cmd.cmd} --arg1` };
+    await callCli('./start', 'start -- --arg2').catch(() => {});
+
+    expect(cmd.calls).toEqual([
+      expect.objectContaining({ argv: [
+          expect.stringContaining('executable'),
+          '--arg1',
+          '--arg2',
+        ] }),
+    ]);
+  });
+
+  it('stops if one of the scripts is failing', async () => {
+    cmd = buildMockCommand({ exitCode: 0, sleep: 100 });
+    detox.config.apps.app1 = { start: 'node --eval="process.exit(3)"' };
+    detox.config.apps.app2 = { start: cmd.cmd };
+    await expect(callCli('./start', 'start')).rejects.toThrowError(/Command exited with code 3/);
+
+    expect(cmd.calls).toHaveLength(0);
+  });
+
+  it.each([
+    ['-f'],
+    ['--force'],
+  ])('does not stop in %s mode if one of the scripts is failing', async (__force) => {
+    cmd = buildMockCommand({ sleep: 100 });
+    detox.config.apps.app1 = { start: 'node --eval="process.exit(3)"' };
+    detox.config.apps.app2 = { start: cmd.cmd };
+
+    await callCli('./start', `start ${__force}`);
+    expect(cmd.calls).toHaveLength(1);
+  });
+});
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index d21bf84ea..f05ecc8a8 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -169,6 +169,83 @@ describe('CLI', () => {
     expect(cliCall().fullCommand).toMatch(/ DETOX_LOGLEVEL="trace" /);
   });
 
+  test('should run the start commands before the tests', async () => {
+    const startCmd = buildMockCommand({ ...mockExecutable.options });
+    singleConfig().apps.push({
+      name: 'app1',
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --app=1`,
+    }, {
+      name: 'app2',
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --app=2`,
+    });
+
+    await run();
+
+    expect([
+      cliCall(0).argv[1],
+      cliCall(1).argv[1],
+    ].sort()).toEqual(['--app=1', '--app=2']);
+    expect(cliCall(2).argv).toEqual([expect.stringContaining('executable'), '--config', 'e2e/config.json']);
+  });
+
+  test('should kill the start command after the tests', async () => {
+    const startCmd = buildMockCommand({ ...mockExecutable.options, sleep: 10000 });
+    singleConfig().apps.push({
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --some-start=command`,
+    });
+
+    await run();
+    expect(cliCall(0).argv).toEqual([expect.stringContaining('executable'), '--config', 'e2e/config.json']);
+    expect(cliCall(1)).toBe(null); // because the start command had been killed earlier than wrote the call details (10000ms)
+  }, 2000);
+
+  test('should not run tests if the start command fails', async () => {
+    const startCmd = buildMockCommand({ ...mockExecutable.options, exitCode: 1 });
+    singleConfig().apps.push({
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --some-start=command`,
+    });
+
+    await expect(run).rejects.toThrowError(/Command exited with code 1:.*--some-start=command/);
+    expect(cliCall(0).argv[1]).toBe('--some-start=command');
+    expect(cliCall(1)).toBe(null);
+  });
+
+  test('--start=force should run tests even though the start command fails', async () => {
+    const startCmd = buildMockCommand({ ...mockExecutable.options, exitCode: 1 });
+    singleConfig().apps.push({
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --some-start=command`,
+    });
+
+    await run('--start=force');
+    expect(cliCall(0).argv[1]).toBe('--some-start=command');
+    expect(cliCall(1).argv).toEqual([expect.stringContaining('executable'), '--config', 'e2e/config.json']);
+  });
+
+  test.each([
+    ['--no-start'],
+    ['--start=false'],
+  ])('%s should run tests without the start command', async (__start) => {
+    const startCmd = buildMockCommand({ ...mockExecutable.options, exitCode: 1 });
+    singleConfig().apps.push({
+      type: 'ios.app',
+      binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
+      start: `${startCmd.cmd} --some-start=command`,
+    });
+
+    await run(__start);
+    expect(cliCall(0).argv).toEqual([expect.stringContaining('executable'), '--config', 'e2e/config.json']);
+  });
+
   test.each([['-R'], ['--retries']])('%s <value> should execute successful run once', async (__retries) => {
     await run(__retries, 1);
     expect(cliCall(1)).toBe(null);
diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js
index 8a79dfa5a..fcf453473 100644
--- a/detox/local-cli/testCommand/TestRunnerCommand.js
+++ b/detox/local-cli/testCommand/TestRunnerCommand.js
@@ -9,6 +9,8 @@ const log = detox.log.child({ cat: ['lifecycle', 'cli'] });
 const { printEnvironmentVariables, prependNodeModulesBinToPATH } = require('../../src/utils/envUtils');
 const { toSimplePath } = require('../../src/utils/pathUtils');
 const { escapeSpaces, useForwardSlashes } = require('../../src/utils/shellUtils');
+const sleep = require('../../src/utils/sleep');
+const AppStartCommand = require('../startCommand/AppStartCommand');
 const { markErrorAsLogged } = require('../utils/cliErrorHandling');
 
 const TestRunnerError = require('./TestRunnerError');
@@ -23,11 +25,14 @@ class TestRunnerCommand {
     const cliConfig = opts.config.cli;
     const deviceConfig = opts.config.device;
     const runnerConfig = opts.config.testRunner;
+    const appsConfig = opts.config.apps;
 
     this._argv = runnerConfig.args;
     this._retries = runnerConfig.retries;
     this._envHint = this._buildEnvHint(opts.env);
+    this._startCommands = this._prepareStartCommands(appsConfig, cliConfig);
     this._envFwd = {};
+
     if (runnerConfig.forwardEnv) {
       this._envFwd = this._buildEnvOverride(cliConfig, deviceConfig);
       Object.assign(this._envHint, this._envFwd);
@@ -38,6 +43,15 @@ class TestRunnerCommand {
     let runsLeft = 1 + this._retries;
     let launchError = null;
 
+    if (this._startCommands.length > 0) {
+      try {
+        await Promise.race([sleep(1000), ...this._startCommands.map(cmd => cmd.execute())]);
+      } catch (e) {
+        await Promise.allSettled(this._startCommands.map(cmd => cmd.stop()));
+        throw e;
+      }
+    }
+
     do {
       try {
         await this._spawnTestRunner();
@@ -67,6 +81,8 @@ class TestRunnerCommand {
       }
     } while (launchError && runsLeft > 0);
 
+    await Promise.allSettled(this._startCommands.map(cmd => cmd.stop()));
+
     if (launchError) {
       throw launchError;
     }
@@ -80,6 +96,19 @@ class TestRunnerCommand {
       .value();
   }
 
+  _prepareStartCommands(appsConfig, cliConfig) {
+    if (`${cliConfig.start}` === 'false') {
+      return [];
+    }
+
+    return _.values(appsConfig)
+      .filter(app => app.start)
+      .map(app => new AppStartCommand({
+        cmd: app.start,
+        forceSpawn: cliConfig.start === 'force',
+      }));
+  }
+
   /**
    * @param {DetoxInternals.CLIConfig} cliConfig
    * @param {Detox.DetoxDeviceConfig} deviceConfig
diff --git a/detox/local-cli/testCommand/builder.js b/detox/local-cli/testCommand/builder.js
index ae3a0f328..6f59c9b18 100644
--- a/detox/local-cli/testCommand/builder.js
+++ b/detox/local-cli/testCommand/builder.js
@@ -28,6 +28,11 @@ module.exports = {
     describe: 'Reuse existing installed app (do not delete + reinstall) for a faster run.',
     boolean: true,
   },
+  start: {
+    group: 'Execution:',
+    describe: 'Run app "start" scripts before running the tests. Use --no-start to disable that, and --start=force to ignore errors.',
+    default: true,
+  },
   u: {
     alias: 'cleanup',
     group: 'Execution:',
diff --git a/detox/src/configuration/collectCliConfig.test.js b/detox/src/configuration/collectCliConfig.test.js
index c4ee6a811..5d231594d 100644
--- a/detox/src/configuration/collectCliConfig.test.js
+++ b/detox/src/configuration/collectCliConfig.test.js
@@ -89,6 +89,7 @@ describe('collectCliConfig', () => {
     ...asBoolean(['readonlyEmu',          'DETOX_READ_ONLY_EMU',          null]),
     ...asBoolean(['useCustomLogger',      'DETOX_USE_CUSTOM_LOGGER',      'use-custom-logger']),
     ...asBoolean(['inspectBrk',           'DETOX_INSPECT_BRK',            'inspect-brk']),
+    ...asString( ['start',                'DETOX_START',                  'start']),
   ])('.%s property' , (key, envName, argName, input, expected) => {
     beforeEach(() => {
       if (envName) env[envName] = input;
diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js
index adc675893..3b6ae8162 100644
--- a/detox/test/e2e/detox.config.js
+++ b/detox/test/e2e/detox.config.js
@@ -55,6 +55,7 @@ const config = {
       name: 'example',
       binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/example.app',
       build: 'set -o pipefail && xcodebuild -workspace ios/example.xcworkspace -UseNewBuildSystem=YES -scheme example_ci -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build -quiet',
+      start: 'react-native start',
       bundleId: 'com.wix.detox-example',
     },
 
@@ -70,6 +71,7 @@ const config = {
       name: 'example',
       binaryPath: 'android/app/build/outputs/apk/fromBin/debug/app-fromBin-debug.apk',
       build: 'cd android && ./gradlew assembleFromBinDebug assembleFromBinDebugAndroidTest -DtestBuildType=debug && cd ..',
+      start: 'react-native start',
       reversePorts: [8081],
     },
 
diff --git a/docs/cli/test.md b/docs/cli/test.md
index ecd5c2939..4183abd65 100644
--- a/docs/cli/test.md
+++ b/docs/cli/test.md
@@ -51,6 +51,7 @@ DETOX_CONFIGURATION=ios.debug jest --help
 | -H, --headless                                | Launch device in headless mode. Useful when running on CI.                                                                                                                                                                                                                                              |
 | --device-boot-args                            | A list of passthrough-arguments to use when (if) devices (Android emulator / iOS simulator) are launched by Detox.<br />**Note: the value must be specified after an equal sign (`=`) and inside quotes.** Usage example:<br />`--device-boot-args="-http-proxy http://1.1.1.1:8000 -no-snapshot-load"` |
 | --app-launch-args                             | Custom arguments to pass (through) onto the app every time it is launched. The same **note** applies here, as for **--device-boot-args**.<br />See [launch arguments guide](../guide/launch-args.md) for complete info.                                                                                 |
+| --start, --start=force, --no-start            | Control execution of "start" commands in the app configs. By default, they run right before the test runner. Pass `--start=force` to ignore the errors coming from the "start" commands, and run the test runner anyway. Pass `--no-start` to skip the "start" commands altogether.                     |
 | --no-color                                    | Disable colors in log output                                                                                                                                                                                                                                                                            |
 | --use-custom-logger                           | Use Detox' custom console-logging implementation, for logging Detox (non-device) logs. Disabling will fallback to node.js / test runner’s implementation (e.g. Jest).<br />_Default: true_                                                                                                              |
 | --gpu                                         | \[Android Only] Launch Emulator with the specific -gpu \[gpu mode] parameter.                                                                                                                                                                                                                           |