Repository: wix/Detox
Pull request #2624 (2021-02-03 19:47:32)

Introduce launch-args modification API
## Description
A rewrite of #2618, #2613, introducing app launch-args editing according to the following syntax:
```js
device.appLaunchArgs.modify({
  newArg: 'aValue',
  existingArgToOverride: 'newValue',  
  existingArgToDelete: undefined,
});
```

and there's also:
```js
device.appLaunchArgs.reset();
device.appLaunchArgs.get();
```

In the future, we expect to support multiple apps according to the following terms:
- In a multi-apps environment, launch-args will be managed per each, separately (following the equivalent logic of the global Detox config).
- Arguments will be available via the same API, in the form of `device.appLaunchArg('app-name')`.
- In case `app-name` isn't explicitly specified -- will modify the currently "selected" app.



Patch:
diff --git a/detox/index.d.ts b/detox/index.d.ts
index e61cd9d9a..f3d93be4f 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -274,6 +274,55 @@ declare global {
             reuse?: boolean;
         }
 
+        /**
+         * A construct allowing for the querying and modification of user arguments passed to an app upon launch by Detox.
+         *
+         * @see AppLaunchArgs#modify
+         * @see AppLaunchArgs#reset
+         * @see AppLaunchArgs#get
+         */
+        interface AppLaunchArgs {
+            /**
+             * Modify the launch-arguments via a modifier object, according to the following logic:
+             *  - Concrete modifier properties would either set anew or override the value of existing properties with the same name, with
+             *    the specific value.
+             *  - Modifier properties set to either `undefined` or `null` would have the equivalent property deleted.
+             *
+             * @param modifier The modifier object.
+             *
+             * @example
+             * // With current launch arguments set to:
+             * // {
+             * //   mockServerPort: 1234,
+             * //   mockServerCredentials: 'user@test.com:12345678',
+             * // }
+             * device.appLaunchArgs.modify({
+             *   mockServerPort: 4321,
+             *   mockServerCredentials: null,
+             *   mockServerToken: 'abcdef',
+             * };
+             * await device.launchApp();
+             * // ==> launch-arguments become:
+             * // {
+             * //   mockServerPort: 4321,
+             * //   mockServerToken: 'abcdef',
+             * // }
+             */
+            modify(modifier: object): void;
+
+            /**
+             * Complete reset all currently set launch-arguments (i.e. back to an empty JS object).
+             */
+            reset(): void;
+
+            /**
+             * Get all currently set launch-arguments.
+             * @returns An object containing all launch-arguments. Note: Changes on the returned object will not be reflected on the
+             * launch-arguments associated with the device.
+             */
+            get(): object;
+        }
+
         interface Device {
             /**
              * Holds the environment-unique ID of the device - namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS,
@@ -283,12 +332,14 @@ declare global {
              */
             id: string;
             /**
-             * Holds a descriptive name of the device. Example: emulator-5554 (Pixel_API_26)
+             * Holds a descriptive name of the device. Example: emulator-5554 (Pixel_API_29)
              * The value will be undefined until the device is properly prepared (i.e. in detox.init()).
              */
             name: string;
             /**
-             * Launch the app
+             * Launch the app.
+             *
+             * <p>For info regarding launch arguments, refer to the [dedicated guide](https://github.com/wix/Detox/blob/master/docs/APIRef.LaunchArgs.md).
              *
              * @example
              * // Terminate the app and launch it again. If set to false, the simulator will try to bring app from background,
@@ -300,10 +351,40 @@ declare global {
              * @example
              * // Mock opening the app from URL to test your app's deep link handling mechanism.
              * await device.launchApp({url: url});
+             * @example
+             * // Start the app with some custom arguments.
+             * await device.launchApp({
+             *   launchArgs: {arg1: 1, arg2: "2"},
+             * });
              */
             launchApp(config?: DeviceLaunchAppConfig): Promise<void>;
             /**
-             * Terminate the app
+             * Access the user-defined launch-arguments predefined through static scopes such as the Detox configuration file and
+             * command-line arguments. This access allows - through dedicated methods, for both value-querying and
+             * modification (see {@link AppLaunchArgs}).
+             * Refer to the [dedicated guide](https://github.com/wix/Detox/blob/master/docs/APIRef.LaunchArgs.md) for complete details.
+             *
+             * @example
+             * // With Detox being preconfigured statically to use these arguments in app launch:
+             * // {
+             * //   mockServerPort: 1234,
+             * // }
+             * // The following code would result in these arguments eventually passed into the launched app:
+             * // {
+             * //   mockServerPort: 4321,
+             * //   mockServerToken: 'uvwxyz',
+             * // }
+             * device.appLaunchArgs.modify({
+             *   mockServerPort: 4321,
+             *   mockServerToken: 'abcdef',
+             * });
+             * await device.launchApp({ launchArgs: { mockServerToken: 'uvwxyz' } }};
+             *
+             * @see AppLaunchArgs
+             */
+            appLaunchArgs: AppLaunchArgs;
+            /**
+             * Terminate the app.
              *
              * @example
              * // By default, terminateApp() with no params will terminate the app
@@ -604,7 +685,7 @@ declare global {
              */
             longPress(): Promise<Actions<R>>;
             /**
-             * Simulate long press on an element and then drag it to the position of the target element
+             * Simulate long press on an element and then drag it to the position of the target element. (iOS Only)
              * @example await element(by.id('draggable')).longPressAndDrag(2000, NaN, NaN, element(by.id('target')), NaN, NaN, 'fast', 0);
              */
             longPressAndDrag(duration: number, normalizedPositionX: number, normalizedPositionY: number, targetElement: Element,
@@ -791,8 +872,8 @@ declare global {
              */
             delete?: boolean;
             /**
-             * Detox can start the app with additional launch arguments
-             * The added launchArgs will be passed through the launch command to the device and be accessible via [[NSProcessInfo processInfo] arguments]
+             * Arguments to pass-through into the app.
+             * Refer to the [dedicated guide](https://github.com/wix/Detox/blob/master/docs/APIRef.LaunchArgs.md) for complete details.
              */
             launchArgs?: any;
             /**
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 59fd48d6f..9a4804a8b 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -1,7 +1,7 @@
 const _ = require('lodash');
+const LaunchArgs = require('./LaunchArgs');
 const debug = require('../utils/debug'); // debug utils, leave here even if unused
 const { traceCall } = require('../utils/trace');
-const log = require('../utils/logger').child({ __filename });
 
 class Device {
   constructor({
@@ -16,6 +16,7 @@ class Device {
     this._sessionConfig = sessionConfig;
     this._emitter = emitter;
     this._processes = {};
+    this._launchArgs = new LaunchArgs(deviceConfig.launchArgs);
     this.deviceDriver = deviceDriver;
     this.deviceDriver.validateDeviceConfig(deviceConfig);
     this.debug = debug;
@@ -49,7 +50,7 @@ class Device {
     }
 
     const baseLaunchArgs = {
-      ...this._deviceConfig.launchArgs,
+      ...this._launchArgs.get(),
       ...params.launchArgs,
     };
 
@@ -103,6 +104,10 @@ class Device {
     }
   }
 
+  get appLaunchArgs() {
+    return this._launchArgs;
+  }
+
   get id() {
     return this._deviceId;
   }
diff --git a/detox/src/devices/LaunchArgs.js b/detox/src/devices/LaunchArgs.js
new file mode 100644
index 000000000..29c931d08
--- /dev/null
+++ b/detox/src/devices/LaunchArgs.js
@@ -0,0 +1,32 @@
+const _ = require('lodash');
+
+class LaunchArgs {
+  constructor(launchArgs = {}) {
+    this._args = launchArgs;
+  }
+
+  modify(launchArgs) {
+    Object
+      .keys(launchArgs)
+      .forEach((name) =>
+        this._setLaunchArg(name, launchArgs[name]));
+  }
+
+  reset() {
+    this._args = {};
+  }
+
+  get() {
+    return _.cloneDeep(this._args);
+  }
+
+  _setLaunchArg(name, value) {
+    if (value === undefined || value === null) {
+      delete this._args[name];
+    } else {
+      this._args[name] = value;
+    }
+  }
+}
+
+module.exports = LaunchArgs;
diff --git a/docs/APIRef.Configuration.md b/docs/APIRef.Configuration.md
index c635234d8..6c41c5ea3 100644
--- a/docs/APIRef.Configuration.md
+++ b/docs/APIRef.Configuration.md
@@ -20,7 +20,8 @@ Please find the [Detox example app](/examples/demo-react-native/detox.config.js)
 
 ### Device Configuration
 
-`configurations` holds all the device configurations, if there is only one configuration in `configurations` `detox build` and `detox test` will default to it, to choose a specific configuration use `--configuration` param
+`configurations` holds all the device/app-oriented configurations. To select a specific configuration when running Detox in command-line (i.e. `detox build`, `detox test`), use the `--configuration` argument.
+Note: If there is only one configuration in `configurations`, Detox will default to it.
 
 |Configuration Params|Details|
 |---|---|
@@ -30,6 +31,7 @@ Please find the [Detox example app](/examples/demo-react-native/detox.config.js)
 |`utilBinaryPaths`| (optional, Android only): An **array** of relative paths of _utility_ app (apk) binary-files to preinstall on the tested devices - once before the test execution begins.<br />Note: these are not effected by various install-lifecycle events, such as launching an app with `device.launchApp({delete: true})`, which reinstalls the app. A good example of why this might come in handy is [Test Butler](https://github.com/linkedin/test-butler). |
 |`device`| Device query, e.g. `{ "byType": "iPhone 11 Pro" }` for iOS simulator, `{ "avdName": "Pixel_2_API_29" }` for Android emulator or `{ "adbName": "<pattern>" }` for attached Android device with name matching the regex. |
 |`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. |
+|`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](APIRef.LaunchArgs.md). |
 
 **Example:**
 
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index ed93c270f..6cf8b0861 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -90,7 +90,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | -H, --headless                                | [Android Only] Launch Emulator in headless mode. Useful when running on CI. |
 | --gpu                                         | [Android Only] Launch Emulator with the specific -gpu [gpu mode] parameter. |
 | --device-launch-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-launch-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 as for **--device-launch-args**. |
+| --app-launch-args | Custom arguments to pass (through) onto the app every time it is launched. The same **note** applies here, as for **--device-launch-args**.<br />See [launch arguments guide](APIRef.LaunchArgs.md) for complete info. |
 | --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 / Mocha).<br />*Default: true* |
 | --force-adb-install | Due to problems with the `adb install` command on Android, Detox resorts to a different scheme for install APK's. Setting true will disable that and force usage of `adb install`, instead.<br/>This flag is temporary until the Detox way proves stable.<br/>*Default: false* |
diff --git a/docs/APIRef.DeviceObjectAPI.md b/docs/APIRef.DeviceObjectAPI.md
index 882ef94b2..a1e12cc18 100644
--- a/docs/APIRef.DeviceObjectAPI.md
+++ b/docs/APIRef.DeviceObjectAPI.md
@@ -19,9 +19,27 @@ Holds a descriptive name of the device. Example: `emulator-5554 (Pixel_API_26)`
 
 The value will be `undefined` until the device is properly _prepared_ (i.e. in `detox.init()`).
 
+### `device.appLaunchArgs`
+
+Access the launch-arguments predefined by the user in preliminary, static scopes such as the Detox [configuration file](APIRef.Configuration.md) and [command-line arguments](APIRef.DetoxCLI.md). This access allows, through dedicated methods, for both value-querying and modification:
+
+```js
+// Modify some of the predefined arguments:
+device.appLaunchArgs.modify({
+  mockServerPort: 1234,
+});
+// Retrieve the arguments:
+device.appLaunchArgs.get(); // ==> { mockServerPort: 1234 }
+// Reset (i.e. remove all arguments):
+device.appLaunchArgs.reset();
+```
+
+This is the most flexible way of editing the launch arguments. Refer to the [launch arguments guide](APIRef.LaunchArgs.md) for complete details.
+
 ## Methods
 
 - [`device.launchApp()`](#devicelaunchappparams)
+- [`device.appLaunchArgs`](#deviceapplaunchargs)
 - [`device.terminateApp()`](#deviceterminateapp)
 - [`device.sendToHome()`](#devicesendtohome)
 - [`device.reloadReactNative()`](#devicereloadreactnative)
@@ -125,17 +143,18 @@ await device.launchApp({delete: true});
 
 ##### 7. `launchArgs`—Additional Process Launch Arguments
 
-Starts the app's process with the specified launch arguments.
+Launches the app on the device with on-site, user-specified launch arguments:
 
 ```js
-await device.launchApp({launchArgs: {arg1: 1, arg2: "2"}});
+await device.launchApp({
+  launchArgs: {
+    arg1: 1,
+    arg2: "2",
+  }
+});
 ```
 
-On iOS, the specified launch arguments are passed as the process launch arguments and available through normal means.
-
-On Android, the launch arguments are set as bundle-extra into the activity's intent. It will therefore be accessible on the native side via the current activity as: `currentActivity.getIntent().getBundleExtra("launchArgs")`.
-
-Further handling of these launch arguments is up to the user's responsibility and is out of scope for Detox.
+This is the most explicit and straightforward way of setting launch arguments. Refer to the [launch arguments guide](APIRef.LaunchArgs.md) for a complete overview on app launch arguments.
 
 ##### 8. `disableTouchIndicators`—Disable Touch Indicators (iOS Only)
 
@@ -420,4 +439,4 @@ await device.pressBack();
 Exposes UiAutomator's UiDevice API (https://developer.android.com/reference/android/support/test/uiautomator/UiDevice)
 **This is not a part of the official Detox API**, it may break and change whenever an update to UiDevice or UiAutomator gradle dependencies ('androidx.test.uiautomator:uiautomator') is introduced.
 
-[UiDevice's autogenerated code](../detox/src/android/espressoapi/UIDevice.js)
+[UiDevice's autogenerated code](../detox/src/android/espressoapi/UIDevice.js)
\ No newline at end of file
diff --git a/docs/APIRef.LaunchArgs.md b/docs/APIRef.LaunchArgs.md
new file mode 100644
index 000000000..492518772
--- /dev/null
+++ b/docs/APIRef.LaunchArgs.md
@@ -0,0 +1,47 @@
+# Launch Arguments
+
+In Detox, the app under test is launched via an explicit call to [`device.launchApp()`](APIRef.DeviceObjectAPI.md). Through various means, Detox enables specifying a set of user-defined arguments (key-value pairs) to be passed on to the app when launched, so as to make them available inside the launched app itself at runtime (both on the native side, and - if applicable, on the Javascript side).
+
+### Motivation
+
+> If this is clear to you first hand, you can skip right to the section about arguments setup.
+
+In particular, the common use case of using launch argument (although not distinctly), is for [mocking](Guide.Mocking.md) external entities such as servers - replacing them with equivalent _mock servers_, sporting equivalent (yet fake) API-endpoints that run alongside the testing host (i.e. the one running Detox). These mock servers can typically be configured during the test, to return deterministic reponses to network requests coming from the app.
+
+Typically, the process of setting up such servers - especially in a parallel test-execution environment, involves three major steps (within the context of a test set-up):
+
+1. Allocating a port for a mock server, dynamically.
+2. Bringing up a mock server instance bound to that port (e.g. at `localhost:1234`).
+3. Launching the app with a predefined argument that holds the port, for example `mockServerPort=1234`.
+   (It is assumed here that there's designated mocked code inside the app that can read `mockServerPort` and rewire all connections to `localhost:1234` instead of to the real production server).
+
+In this context, launch argument are useful for implementing step #3.
+
+### Arguments Setup
+
+User-defined launch arguments specification is very flexible, and can be defined on 4 levels:
+
+| Level                                     | Description                                                  |
+| ----------------------------------------- | ------------------------------------------------------------ |
+| 1. Static Configuration                   | As a part of a static [Detox configuration](APIRef.Configuration.md), using the `launchArg` property.<br />This is can sufficient, for example, if you only require one mock server instance, and can use the same static port throughout the entire testing execution session. |
+| 2. Static via CLI                         | As arguments specified explicitly in the [command-line](APIRef.DetoxCLI.md) execution of `detox test`, using `--app-launch-args`. |
+| 3.`device.appLaunchArgs`                  | Dynamically, using the [`device.appLaunchArgs`](APIRef.DeviceObjectAPI.md#deviceapplaunchargs) API, which initially holds the static configuration, and then allows for the modification of it before applied through `device.launchApp()`.<br/>Mostly required in complex test environments, where the servers and ports are dynamic, and are deteremined via distinct software components (e.g. separate test kits). |
+| 4. `device.launchApp()` with `launchArgs` | Dynamically and explicitly, using on-site arguments specified in calls to [`device.launchApp()`](APIRef.DeviceObjectAPI.md#devicelaunchappparams) through the `launchArgs` parameter.<br />Ideal for fairly simple test environments, where the ports are dynamic but are in complete control of the user. |
+
+**Important: Arguments specified in each level take precedence over equivalent underying levels**.
+
+Examples:
+
+1. In an environment where `mockServerPort` is statically pre-set to `1001` in Detox configuration, and then set to to `1003` using `device.appLaunchArgs` inside a test, the app would eventually be launched with `1003` as its value, in calls to `device.launchApp()` in that test.
+2. (Scenerio continues) In subsequent calls to `device.launchApp()` with this parameter: `device.launchApp({ launchArgs: {mockServerPort: 1004} })`, the app will be (re-)launched with `1004` as the value for `mockServerPort`.
+
+
+
+### In-App Arguments Access
+
+On iOS, the specified launch arguments are passed as the process launch arguments and available through normal means.
+
+On Android, the launch arguments are set as bundle-extra's into the activity's intent. It will therefore be accessible on the native side via the current activity as: `currentActivity.getIntent().getBundleExtra("launchArgs")`.
+
+Further handling of these launch arguments is up to the user's responsibility and is out of scope for Detox.
+
diff --git a/docs/Guide.Mocking.md b/docs/Guide.Mocking.md
index da1b72fc0..86fae396d 100644
--- a/docs/Guide.Mocking.md
+++ b/docs/Guide.Mocking.md
@@ -1,5 +1,7 @@
 # Mocking
 
+> This guide is a bit out of date. We hope to have it updated soon.
+
 Mocking is an important part of testing. You may want to alter some behavior of your app during test and replace it with a mock. Here are some example reasons why this could be useful:
 
 * Change server endpoints to point to a mock/staging server instead of the regular production server


Test patch:
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index a5b750ad2..2e1512c88 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -57,10 +57,18 @@ describe('Device', () => {
       });
     }
 
-    expectLaunchCalled(device, expectedArgs, languageAndLocale) {
+    expectLaunchCalledWithArgs(device, expectedArgs, languageAndLocale) {
       expect(this.driver.launchApp).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
     }
 
+    expectLaunchCalledContainingArgs(expectedArgs) {
+      expect(this.driver.launchApp).toHaveBeenCalledWith(
+        undefined,
+        undefined,
+        expect.objectContaining(expectedArgs),
+        undefined);
+    }
+
     expectWaitForLaunchCalled(device, expectedArgs, languageAndLocale) {
       expect(this.driver.waitForAppLaunch).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
     }
@@ -95,7 +103,7 @@ describe('Device', () => {
   function schemeDevice(scheme, configuration, overrides) {
     const device = new Device(_.merge({
       behaviorConfig: {},
-      deviceConfig: scheme.configurations[configuration],
+      deviceConfig: { ...scheme.configurations[configuration] },
       deviceDriver: driverMock.driver,
       sessionConfig: scheme.session,
       emitter,
@@ -110,6 +118,16 @@ describe('Device', () => {
     return schemeDevice(validScheme, 'ios.sim.release', overrides);
   }
 
+  function validDeviceWithLaunchArgs(launchArgs) {
+    const overrides = {
+      deviceConfig: {
+        launchArgs,
+      },
+    };
+
+    return schemeDevice(validScheme, 'ios.sim.release', overrides);
+  }
+
   it('should return the name from the driver', async () => {
     driverMock.driver.name = 'mock-device-name-from-driver';
 
@@ -144,7 +162,7 @@ describe('Device', () => {
       const device = validDevice();
       await device.launchApp();
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`given behaviorConfig.launchApp == 'manual' should wait for the app launch`, async () => {
@@ -177,7 +195,7 @@ describe('Device', () => {
 
       await device.relaunchApp();
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with no args should terminate the app before launch - backwards compat`, async () => {
@@ -211,7 +229,7 @@ describe('Device', () => {
       await device.relaunchApp({delete: true});
 
       driverMock.expectReinstallCalled();
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with delete=false when reuse is enabled should not uninstall and install`, async () => {
@@ -222,7 +240,7 @@ describe('Device', () => {
       await device.relaunchApp();
 
       driverMock.expectReinstallNotCalled();
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with url should send the url as a param in launchParams`, async () => {
@@ -231,7 +249,7 @@ describe('Device', () => {
 
       await device.relaunchApp({url: `scheme://some.url`});
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with url should send the url as a param in launchParams`, async () => {
@@ -243,7 +261,7 @@ describe('Device', () => {
       const device = await validDevice();
       await device.relaunchApp({url: `scheme://some.url`, sourceApp: 'sourceAppBundleId'});
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with userNofitication should send the userNotification as a param in launchParams`, async () => {
@@ -257,7 +275,7 @@ describe('Device', () => {
 
       await device.relaunchApp({userNotification: 'json'});
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`(relaunch) with url and userNofitication should throw`, async () => {
@@ -288,7 +306,7 @@ describe('Device', () => {
 
       await device.launchApp({languageAndLocale});
 
-      driverMock.expectLaunchCalled(device, expectedArgs, languageAndLocale);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs, languageAndLocale);
     });
 
     it(`with disableTouchIndicators should send a boolean switch as a param in launchParams`, async () => {
@@ -297,26 +315,7 @@ describe('Device', () => {
 
       await device.launchApp({disableTouchIndicators: true});
 
-      driverMock.expectLaunchCalled(device, expectedArgs);
-    });
-
-    it(`with custom launchArgs should pass to native as launch args`, async () => {
-      const launchArgs = {
-        arg1: "1",
-        arg2: 2,
-      };
-      const expectedArgs = {
-        "detoxServer": "ws://localhost:8099",
-        "detoxSessionId": "test",
-        "arg1": "1",
-        "arg2": 2,
-      };
-
-      const device = validDevice();
-
-      await device.launchApp({launchArgs});
-
-      driverMock.expectLaunchCalled(device, expectedArgs);
+      driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
     });
 
     it(`with newInstance=false should check if process is in background and reopen it`, async () => {
@@ -376,21 +375,6 @@ describe('Device', () => {
       expect(driverMock.driver.deliverPayload).toHaveBeenCalledWith({delayPayload: true, url: 'url://me'});
     });
 
-    it(`should keep user params unmodified`, async () => {
-      const params = {
-        url: 'some.url',
-        launchArgs: {
-          some: 'userArg',
-        }
-      };
-      const paramsClone = _.cloneDeep(params);
-
-      const device = validDevice();
-      await device.launchApp(params);
-
-      expect(params).toEqual(paramsClone);
-    });
-
     it('with userActivity should check if process is in background and if it is use deliverPayload', async () => {
       const launchParams = {userActivity: 'userActivity'};
       const processId = 1;
@@ -458,6 +442,99 @@ describe('Device', () => {
 
       expect(device.deviceDriver.deliverPayload).not.toHaveBeenCalled();
     });
+
+    it('should keep user params unmodified', async () => {
+      const params = {
+        url: 'some.url',
+        launchArgs: {
+          some: 'userArg',
+        }
+      };
+      const paramsClone = _.cloneDeep(params);
+
+      const device = validDevice();
+      await device.launchApp(params);
+
+      expect(params).toStrictEqual(paramsClone);
+    });
+
+    describe('launch arguments', () => {
+      const baseArgs = {
+        detoxServer: 'ws://localhost:8099',
+        detoxSessionId: 'test',
+      };
+      const someLaunchArgs = () => ({
+        argX: 'valX',
+        argY: { value: 'Y' },
+      });
+
+      it('should pass preconfigured launch-args to device via driver', async () => {
+        const launchArgs = someLaunchArgs();
+        const device = validDeviceWithLaunchArgs(launchArgs);
+        await device.launchApp();
+
+        driverMock.expectLaunchCalledContainingArgs(launchArgs);
+      });
+
+      it('should pass on-site launch-args to device via driver', async () => {
+        const launchArgs = someLaunchArgs();
+        const expectedArgs = {
+          ...baseArgs,
+          ...launchArgs,
+        };
+
+        const device = validDevice();
+        await device.launchApp({launchArgs});
+
+        driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
+      });
+
+      it('should allow for launch-args modification', async () => {
+        const launchArgs = someLaunchArgs();
+        const argsModifier = {
+          argY: null,
+          argZ: 'valZ',
+        };
+        const expectedArgs = {
+          argX: 'valX',
+          argZ: 'valZ',
+        };
+
+        const device = validDeviceWithLaunchArgs(launchArgs);
+        device.appLaunchArgs.modify(argsModifier);
+        await device.launchApp();
+
+        driverMock.expectLaunchCalledContainingArgs(expectedArgs);
+      });
+
+      it('should override launch-args with on-site launch-args', async () => {
+        const launchArgs = {
+          aLaunchArg: 'aValue?',
+        };
+
+        const device = validDeviceWithLaunchArgs();
+        device.appLaunchArgs.modify(launchArgs);
+        await device.launchApp({
+          launchArgs: {
+            aLaunchArg: 'aValue!',
+          },
+        });
+
+        driverMock.expectLaunchCalledContainingArgs({ aLaunchArg: 'aValue!' });
+      });
+
+      it('should allow for resetting all args', async () => {
+        const launchArgs = someLaunchArgs();
+        const expectedArgs = { ...baseArgs };
+
+        const device = validDeviceWithLaunchArgs(launchArgs);
+        device.appLaunchArgs.modify({ argZ: 'valZ' });
+        device.appLaunchArgs.reset();
+        await device.launchApp();
+
+        driverMock.expectLaunchCalledWithArgs(device, expectedArgs);
+      });
+    });
   });
 
   describe('installApp()', () => {
diff --git a/detox/src/devices/LaunchArgs.test.js b/detox/src/devices/LaunchArgs.test.js
new file mode 100644
index 000000000..10ecd6c36
--- /dev/null
+++ b/detox/src/devices/LaunchArgs.test.js
@@ -0,0 +1,83 @@
+describe('Device launch-args', () => {
+  const initArgs = {
+    argX: 'valX',
+    argY: { value: 'Y' },
+  };
+
+  let LaunchArgs;
+  beforeEach(() => {
+    LaunchArgs = require('./LaunchArgs');
+  });
+
+  it('should initialize as an empty object', () => {
+    const launchArgs = new LaunchArgs();
+    expect(launchArgs.get()).toEqual({});
+  });
+
+  it('should allow for a custom initial set of args', () => {
+    const launchArgs = new LaunchArgs(initArgs);
+    expect(launchArgs.get()).toEqual(initArgs);
+  });
+
+  it('should allow for arguments setting', () => {
+    const someArgs = {
+      argZ: 'valZ',
+    };
+
+    const launchArgs = new LaunchArgs();
+    launchArgs.modify(someArgs);
+    expect(launchArgs.get()).toEqual(someArgs);
+  });
+
+  it('should return a non-reflecting representation of the launch-arguments via get()', () => {
+    const launchArgs = new LaunchArgs();
+    launchArgs.modify({
+      aLaunchArg: { value: 'aValue?' },
+    });
+
+    launchArgs.get().aLaunchArg.value = 'aValue!';
+    expect(launchArgs.get().aLaunchArg.value).toEqual('aValue?');
+  });
+
+  describe('with custom initial args', () => {
+    it('should merge set arguments into the initial ones', () => {
+      const someArgs = {
+        argZ: 'valZ',
+      };
+      const expectedArgs = {
+        ...initArgs,
+        argZ: 'valZ',
+      };
+
+      const launchArgs = new LaunchArgs(initArgs);
+      launchArgs.modify(someArgs);
+      expect(launchArgs.get()).toEqual(expectedArgs);
+    });
+
+    it('should allow for implicit arguments clearing using undefined as values', () => {
+      const argsModifier = {
+        argX: undefined,
+        argY: null,
+        argZ: 'valZ',
+      };
+      const expectedArgs = {
+        argZ: 'valZ',
+      }
+
+      const launchArgs = new LaunchArgs(initArgs);
+      launchArgs.modify(argsModifier);
+      expect(launchArgs.get()).toStrictEqual(expectedArgs);
+   });
+
+    it('should allow for a complete arguments reset', () => {
+      const someArgs = {
+        argZ: 'valZ',
+      };
+
+      const launchArgs = new LaunchArgs(initArgs);
+      launchArgs.modify(someArgs);
+      launchArgs.reset();
+      expect(launchArgs.get()).toStrictEqual({});
+    });
+  });
+});
diff --git a/detox/test/detox.config.js b/detox/test/detox.config.js
index 1e9f4594e..09487b571 100644
--- a/detox/test/detox.config.js
+++ b/detox/test/detox.config.js
@@ -1,5 +1,11 @@
+const launchArgs = {
+  app: 'le',
+  goo: 'gle?',
+  micro: 'soft',
+};
+
 /** @type {Detox.DetoxConfig} */
-module.exports = {
+const config = {
   testRunner: "nyc jest",
   runnerConfig: "e2e/config.js",
   specs: "e2e/*.test.js",
@@ -99,7 +105,8 @@ module.exports = {
       type: "android.emulator",
       device: {
         avdName: "Pixel_API_28"
-      }
+      },
+      launchArgs,
     },
     "android.emu.release": {
       binaryPath: "android/app/build/outputs/apk/fromBin/release/app-fromBin-release.apk",
@@ -110,7 +117,8 @@ module.exports = {
       type: "android.emulator",
       device: {
         avdName: "Pixel_API_28"
-      }
+      },
+      launchArgs,
     },
     "android.genycloud.release": {
       binaryPath: "android/app/build/outputs/apk/fromBin/release/app-fromBin-release.apk",
@@ -148,3 +156,5 @@ module.exports = {
     }
   }
 };
+
+module.exports = config;
diff --git a/detox/test/e2e/22.launch-args.test.js b/detox/test/e2e/22.launch-args.test.js
index 34a60bc27..564ace39b 100644
--- a/detox/test/e2e/22.launch-args.test.js
+++ b/detox/test/e2e/22.launch-args.test.js
@@ -1,3 +1,5 @@
+const _ = require('lodash');
+
 // Note: Android-only as, according to Leo, on iOS there's no added value here compared to
 // existing tests that check deep-link URLs. Combined with the fact that we do not yet
 // support complex args on iOS -- no point in testing it out.
@@ -11,7 +13,14 @@ describe(':android: Launch arguments', () => {
     await expect(element(by.id(`launchArg-${launchArgKey}.name`))).not.toBeVisible();
   }
 
-  it('should handle primitive args', async () => {
+  function assertPreconfiguredValue(expectedInitArgs) {
+    const initArgs = device.appLaunchArgs.get();
+    if (!_.isEqual(initArgs, expectedInitArgs)) {
+      throw new Error(`Precondition failure: Preconfigured launch arguments (in detox.config.js) do not match the expected value.\nExpected: ${JSON.stringify(expectedInitArgs)}\nReceived: ${JSON.stringify(initArgs)}`);
+    }
+  }
+
+  it('should handle primitive args when used on-site', async () => {
     const launchArgs = {
       hello: 'world',
       seekthe: true,
@@ -26,7 +35,7 @@ describe(':android: Launch arguments', () => {
     await assertLaunchArg('heisthe', '1');
   });
 
-  it('should handle complex args', async () => {
+  it('should handle complex args when used on-site', async () => {
     const launchArgs = {
       complex: {
         bull: ['s', 'h', 1, 't'],
@@ -34,17 +43,50 @@ describe(':android: Launch arguments', () => {
           then: 'so, me',
         }
       },
-      complexlist: ['arguments', 'https://haxorhost:666'],
+      complexlist: ['arguments', 'https://haxorhost:1337'],
     };
 
     await device.launchApp({newInstance: true, launchArgs});
-
     await element(by.text('Launch Args')).tap();
 
     await assertLaunchArg('complex', JSON.stringify(launchArgs.complex));
     await assertLaunchArg('complexlist', JSON.stringify(launchArgs.complexlist));
   });
 
+  it('should allow for arguments modification', async () => {
+    const expectedInitArgs = { app: 'le', goo: 'gle?', micro: 'soft' };
+    assertPreconfiguredValue(expectedInitArgs);
+
+    device.appLaunchArgs.modify({
+      app: undefined, // delete
+      goo: 'gle!', // modify
+      ama: 'zon', // add
+    });
+
+    await device.launchApp({ newInstance: true });
+    await element(by.text('Launch Args')).tap();
+
+    await assertLaunchArg('goo', 'gle!');
+    await assertLaunchArg('ama', 'zon');
+    await assertLaunchArg('micro', 'soft');
+    await assertNoLaunchArg('app');
+  });
+
+  it('should allow for on-site arguments to take precedence', async () => {
+    const launchArgs = {
+      anArg: 'aValue!',
+    };
+
+    device.appLaunchArgs.reset();
+    device.appLaunchArgs.modify({
+      anArg: 'aValue?',
+    });
+
+    await device.launchApp({ newInstance: true, launchArgs });
+    await element(by.text('Launch Args')).tap();
+    await assertLaunchArg('anArg', 'aValue!');
+  });
+
   // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax
   it('should not pass android instrumentation args through', async () => {
     const launchArgs = {