Repository: wix/Detox
Pull request #2613 (2021-01-24 21:01:29)

Introduce device.setLaunchArg()
## Description

I've decided to take a step forward and reduce the added complexity in e2e's of large-scale apps (as Wix' internal ones) in trying to prepare and set up suitable app launch arguments.

The typical use case is of modules settings up mock servers, in particular.
Some mock servers are provided by frameworks, while some by modules/apps, which are separate - yet all must be aggregated into the same call to `launchApp({ launchArgs: { ... } })`. This API allows the separation to be properly retained, and eliminate hacks such as `launchApp` swizzling, currently used to overcome this.

_This is in a way both a PR and a discussion, BTW. I've went ahead and implemented this ahead of time because there's very little production code involved._

LeoNatan: This is good idea. Any reason why have the API in singles and not just allow the user to provide a set of arguments?
noomorph: Also, I'd suggest renaming the method to something like `setDefaultAppLaunchArgs` (or something a bit less verbose), since it is going to affect all apps, not only a selected app, I assume.
d4vidi: @LeoNatan Since per each argument a key must be provided, handling a set of arguments may end up being a bit tedious in either the API (e.g. handling an array of args) or the implementation (e.g. iterating an object's set of keys). I don't mind adding it - technically, but I'm inclined to say it will never be used... I could be wrong of course.

@noomorph I agree regarding the name - it's not very descriptive on the one hand, but other options seemed a bit verbose to me as well. Not my strong suite I suppose, more suggestions will be welcomed 😄 
As for multiple-apps -- I admit not taking that into account! I don't think it should affect all apps - on the contrary, I think we ought to throw in an additional app-name argument, for the multi-apps case!

LeoNatan: Isn't it just a set of key/values? I think it's OK as API. 
d4vidi: UPDATE: Added `device.clearLaunghArg()`. @noomorph regarding multi-apps: I think it'd be best to associated the args with the currently "selected" app. wdyt?

Patch:
diff --git a/.gitmodules b/.gitmodules
index 957d35db2..d5cbd9364 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -15,4 +15,4 @@
 	url = https://github.com/facebook/fishhook.git
 [submodule "detox/ios/LNViewHierarchyDumper"]
 	path = detox/ios/LNViewHierarchyDumper
-	url = https://github.com/LeoNatan/LNViewHierarchyDumper.git
+	url = https://github.com/wix-playground/LNViewHierarchyDumper.git
diff --git a/detox/index.d.ts b/detox/index.d.ts
index c9ee1be9a..cd7892661 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -283,10 +283,40 @@ 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;
+            /**
+             * Set a launch-argument (name & value) to bundle alongside launch-arguments specified in any future call to {@link launchApp}.
+             *
+             * Equivalent to {@link DeviceLaunchAppConfig#launchArgs}, but is set ahead-of-time rather than on-site - thus allowing for
+             * a gradual, multi-phased setup of a test environment, typically suitable for complex apps.
+             *
+             * @example
+             * // Even without launchArgs specified in call to launchApp() --
+             * // launch it with the value 'world' associated with 'hello':
+             * device.setLaunchArg('hello', 'world');
+             * await device.launchApp();
+             * @example
+             * // On-site arguments (specified in call to launchApp()) take precedence over arguments set using this method.
+             * // For example, here the tested app will be launched with the value of '222' associated with argument 'arg1',
+             * // rather than '111'.
+             * device.setLaunchArg('arg1', '111');
+             * await device.launchApp({ launchArgs: {'arg1': '222'} }};
+             *
+             * @param name Name of argument to provide.
+             * @param value Argument's value. `Undefined` can be used for clearing the argument completely.
+             *
+             * @see DeviceLaunchAppConfig#launchArgs
+             */
+            setLaunchArg(name: string, value: any): void;
+            /**
+             * Clear a launch-argument previously set using {@link setLaunchArg};
+             *
+             * @param name Name of the argument to clear.
+             */
+            clearLaunchArg(name: string): void;
             /**
              * Launch the app
              *
@@ -300,6 +330,11 @@ 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>;
             /**
@@ -780,7 +815,11 @@ 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]
+             *
+             * On iOS, the specified launchArgs will be passed through the launch command to the device and be accessible via `[[NSProcessInfo processInfo] arguments]`.
+             *
+             * On Android, they will be set as bundle-extra's into the activity's intent. They will therefore be accessible on the current activity using:
+             * `currentActivity.getIntent().getBundleExtra("launchArgs")`.
              */
             launchArgs?: any;
             /**
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 59fd48d6f..a87d83294 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -19,6 +19,7 @@ class Device {
     this.deviceDriver = deviceDriver;
     this.deviceDriver.validateDeviceConfig(deviceConfig);
     this.debug = debug;
+    this._launchArgs = {};
   }
 
   async prepare() {
@@ -50,6 +51,7 @@ class Device {
 
     const baseLaunchArgs = {
       ...this._deviceConfig.launchArgs,
+      ...this._launchArgs,
       ...params.launchArgs,
     };
 
@@ -103,6 +105,18 @@ class Device {
     }
   }
 
+  setLaunchArg(key, value) {
+    if (value === undefined) {
+      this.clearLaunchArg(key);
+    } else {
+      this._launchArgs[key] = value;
+    }
+  }
+
+  clearLaunchArg(key) {
+    delete this._launchArgs[key];
+  }
+
   get id() {
     return this._deviceId;
   }
diff --git a/docs/APIRef.DeviceObjectAPI.md b/docs/APIRef.DeviceObjectAPI.md
index 882ef94b2..e51acaf92 100644
--- a/docs/APIRef.DeviceObjectAPI.md
+++ b/docs/APIRef.DeviceObjectAPI.md
@@ -22,6 +22,8 @@ The value will be `undefined` until the device is properly _prepared_ (i.e. in `
 ## Methods
 
 - [`device.launchApp()`](#devicelaunchappparams)
+- [`device.setLaunchArg(name, value)`](#devicesetlaunchargname-value)
+- [`device.clearLaunchArg(name, value)`](#deviceclearlaunchargname)
 - [`device.terminateApp()`](#deviceterminateapp)
 - [`device.sendToHome()`](#devicesendtohome)
 - [`device.reloadReactNative()`](#devicereloadreactnative)
@@ -214,6 +216,16 @@ await device.launchApp({
 
 **Deprecated:** Use `device.launchApp(params)` instead. The method calls `launchApp({newInstance: true})` for backwards compatibility.
 
+### `device.setLaunchArg(name, value)`
+
+Set a launch-argument (name & value) to bundle alongside launch-arguments specified in any future call to `device.launchApp()`. 
+
+Equivalent to specifying on-site `launchArgs` to `device.launchApp()`, but is set ahead-of-time rather than on-site - thus allowing for a gradual, multi-phased setup of a test environment, typically suitable for complex apps.
+
+### `device.clearLaunchArg(name)`
+
+Clear a launch-argument previous set using `device.setLaunchArg()`.
+
 ### `device.terminateApp()`
 By default, `terminateApp()` with no params will terminate the app file defined in the current [`configuration`](APIRef.Configuration.md).
 


Test patch:
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index a5b750ad2..de40fb4e5 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -61,6 +61,24 @@ describe('Device', () => {
       expect(this.driver.launchApp).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
     }
 
+    expectLaunchCalledContainingArgs(device, expectedArgs) {
+      expect(this.driver.launchApp).toHaveBeenCalledWith(
+        device._deviceId,
+        device._bundleId,
+        expect.objectContaining(expectedArgs),
+        undefined);
+    }
+
+    expectLaunchCalledWithLaunchArg(key, value) {
+      const launchArgs = this.driver.launchApp.mock.calls[0][2];
+      expect(launchArgs[key]).toEqual(value);
+    }
+
+    expectLaunchCalledWithoutLaunchArg(argKey) {
+      const launchArgs = this.driver.launchApp.mock.calls[0][2];
+      expect(launchArgs).not.toHaveProperty(argKey);
+    }
+
     expectWaitForLaunchCalled(device, expectedArgs, languageAndLocale) {
       expect(this.driver.waitForAppLaunch).toHaveBeenCalledWith(device._deviceId, device._bundleId, expectedArgs, languageAndLocale);
     }
@@ -300,25 +318,6 @@ describe('Device', () => {
       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);
-    });
-
     it(`with newInstance=false should check if process is in background and reopen it`, async () => {
       const processId = 1;
       const device = validDevice();
@@ -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,124 @@ describe('Device', () => {
 
       expect(device.deviceDriver.deliverPayload).not.toHaveBeenCalled();
     });
+
+    describe('with user launchArgs', () => {
+      it('should pass on-site launch-args to device via driver', 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);
+      });
+
+      it('should keep args 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('should allow for pre-baked launch-args setup using device.setLaunchArg()', async () => {
+        const params = {
+          launchArgs: {
+            some: 'onsiteArg',
+          }
+        };
+        const prebakedArg = {
+          key: 'prebakedArgKey',
+          value: {
+            prebakedArg: 'value',
+          },
+        };
+        const expectedArgs = {
+          some: 'onsiteArg',
+          [prebakedArg.key]: prebakedArg.value,
+        };
+
+        const device = validDevice();
+        device.setLaunchArg(prebakedArg.key, prebakedArg.value);
+        await device.launchApp(params);
+
+        driverMock.expectLaunchCalledContainingArgs(device, expectedArgs);
+      });
+
+      it('should give priority to on-site launch-args over pre-baked launch-args', async () => {
+        const params = {
+          launchArgs: {
+            aLaunchArg: 'aValue?',
+          }
+        };
+        const prebakedArg = {
+          key: 'aLaunchArg',
+          value: 'aValue!',
+        };
+        const expectedArgs = params.launchArgs;
+
+        const device = validDevice();
+        device.setLaunchArg(prebakedArg.key, prebakedArg.value);
+        await device.launchApp(params);
+
+        driverMock.expectLaunchCalledContainingArgs(device, expectedArgs);
+      });
+    });
+
+    it('should allow for explicit clearing of prebaked launch-args', async () => {
+      const prebakedArg1 = {
+        key: 'arg1',
+        value: 'value1',
+      };
+      const prebakedArg2 = {
+        key: 'arg2',
+        value: 'value2',
+      };
+
+      const device = validDevice();
+      device.setLaunchArg(prebakedArg1.key, prebakedArg1.value);
+      device.setLaunchArg(prebakedArg2.key, prebakedArg2.value);
+      device.clearLaunchArg(prebakedArg1.key);
+      await device.launchApp();
+
+      driverMock.expectLaunchCalledWithLaunchArg(prebakedArg2.key, prebakedArg2.value);
+      driverMock.expectLaunchCalledWithoutLaunchArg(prebakedArg1.key);
+    });
+
+    it('should allow for implicit clearing of prebaked launch-args', async () => {
+      const prebakedArg1 = {
+        key: 'arg1',
+        value: 'value1',
+      };
+      const prebakedArg2 = {
+        key: 'arg2',
+        value: 'value2',
+      };
+
+      const device = validDevice();
+      device.setLaunchArg(prebakedArg1.key, prebakedArg1.value);
+      device.setLaunchArg(prebakedArg2.key, prebakedArg2.value);
+      device.setLaunchArg(prebakedArg1.key, undefined);
+      await device.launchApp();
+
+      driverMock.expectLaunchCalledWithLaunchArg(prebakedArg2.key, prebakedArg2.value);
+      driverMock.expectLaunchCalledWithoutLaunchArg(prebakedArg1.key);
+    });
   });
 
   describe('installApp()', () => {
diff --git a/detox/test/e2e/22.launch-args.test.js b/detox/test/e2e/22.launch-args.test.js
index 34a60bc27..7332229dc 100644
--- a/detox/test/e2e/22.launch-args.test.js
+++ b/detox/test/e2e/22.launch-args.test.js
@@ -34,17 +34,36 @@ 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 pre-baked arguments setup', async () => {
+    const launchArgs = {
+      userArg: 'value',
+    };
+
+    device.setLaunchArg('prebakedArg1', 'pie');
+    device.setLaunchArg('prebakedArg2', {
+      tofu: 'with gravy',
+    });
+    device.setLaunchArg('prebakedArg3', 'spoiled milk');
+    device.clearLaunchArg('prebakedArg3');
+    await device.launchApp({newInstance: true, launchArgs});
+    await element(by.text('Launch Args')).tap();
+
+    await assertLaunchArg('userArg', 'value');
+    await assertLaunchArg('prebakedArg1', 'pie');
+    await assertLaunchArg('prebakedArg2', JSON.stringify({ tofu: 'with gravy' }));
+    await assertNoLaunchArg('prebakedArg3');
+  });
+
   // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax
   it('should not pass android instrumentation args through', async () => {
     const launchArgs = {