Repository: wix/Detox (js)

Issue:
Allow not encoded launchArgs for AndroidDriver
**Is your feature request related to a problem? Please describe.**
We (Skyscanner) are working on integrating the latest Detox version for React Native in Android with our current setup. It used to work, but since base64 encoding was introduced for launchArgs it stopped working. https://github.com/wix/Detox/commit/a51a88a69942a5d2cd47dc93150e1c7f01097d14#diff-278e2fae566194b7c4923c8238970e49

We are using `device.launchApp()` passing our `launchArgs`. Some of them are custom params and there’s a specific one `class` that we use to specify our test rule for Detox.

I’ve seen that the base64 encoding is not applied to any parameter named with a key that starts with `detox`.

We can prefix our custom parameters with `detox` but we can’t do that with `class` parameter since this is an `adb instrument` parameter  (https://developer.android.com/studio/test/command-line#AMOptionsSyntax). 

So let’s say we prefix `detox` then the generated adb command will look like:
`adb -s emulator-10422 shell am instrument -w -r -e detoxServer ws://localhost:57360 -e detoxSessionId dbd95b5f-bce1-1862-e35e-31562d2937e2 -e detoxclass net.skyscanner.go.detox.MainDetoxTest`

Of course in this case `detoxclass` is completely ignored by `adb am instrument`

**Describe the solution you'd like**
I think a possible approach could be to have unencoded not only `detox` prefixed params but also the different am instrument options.

**Describe alternatives you've considered**
Alternatively, it could be useful to add an option to disable args encoding altogether.

**Additional context**
N/A


Pull request description:
Properly handle instrumentation args passed in as launch-args
- [x] This is a small change 
- [x] This change has been discussed in issue #1804 and the solution has been agreed upon with maintainers.

---

**Description:**

Fixes #1804 by introducing these two changes:
1. On the tester (JS) side, allow reserved instrumentation args to pass-through unencoded.
2. On the Android-native side, refrain from passing such instrumentation args into the activity through the intent's extras.

Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/Detox.java b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
index 665bf2f44..a48ecedbb 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/Detox.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/Detox.java
@@ -11,6 +11,9 @@ import android.os.Looper;
 import androidx.annotation.NonNull;
 import android.util.Base64;
 
+import java.util.Arrays;
+import java.util.List;
+
 import androidx.test.platform.app.InstrumentationRegistry;
 import androidx.test.rule.ActivityTestRule;
 
@@ -71,6 +74,7 @@ public final class Detox {
     private static final String LAUNCH_ARGS_KEY = "launchArgs";
     private static final String DETOX_URL_OVERRIDE_ARG = "detoxURLOverride";
     private static final long ACTIVITY_LAUNCH_TIMEOUT = 10000L;
+    private static final List<String> RESERVED_INSTRUMENTATION_ARGS = Arrays.asList("class", "package", "func", "unit", "size", "perf", "debug", "log", "emma", "coverageFile");
 
     private static ActivityTestRule sActivityTestRule;
 
@@ -234,11 +238,13 @@ public final class Detox {
     }
 
     private static Bundle readLaunchArgs() {
-        final Bundle instrumArgs = InstrumentationRegistry.getArguments();
+        final Bundle instrumentationArgs = InstrumentationRegistry.getArguments();
         final Bundle launchArgs = new Bundle();
 
-        for (String arg : instrumArgs.keySet()) {
-            launchArgs.putString(arg, decodeLaunchArgValue(arg, instrumArgs));
+        for (String arg : instrumentationArgs.keySet()) {
+            if (!RESERVED_INSTRUMENTATION_ARGS.contains(arg)) {
+                launchArgs.putString(arg, decodeLaunchArgValue(arg, instrumentationArgs));
+            }
         }
         return launchArgs;
     }
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index d0cad09a0..36042aa1e 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -2,7 +2,8 @@ const fs = require('fs');
 const URL = require('url').URL;
 const _ = require('lodash');
 const { encodeBase64 } = require('../../utils/encoding');
-const log = require('../../utils/logger').child({ __filename });
+const logger = require('../../utils/logger');
+const log = logger.child({ __filename });
 const invoke = require('../../invoke');
 const InvocationManager = invoke.InvocationManager;
 const ADB = require('../android/ADB');
@@ -22,6 +23,9 @@ const retry = require('../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../utils/exec');
 const AndroidExpect = require('../../android/expect');
 
+const reservedInstrumentationArgs = ['class', 'package', 'func', 'unit', 'size', 'perf', 'debug', 'log', 'emma', 'coverageFile'];
+const isReservedInstrumentationArg = (arg) => reservedInstrumentationArgs.includes(arg);
+
 class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
@@ -202,7 +206,7 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async _launchInstrumentationProcess(deviceId, bundleId, rawLaunchArgs) {
-    const launchArgs = this._prepareLaunchArgs(rawLaunchArgs);
+    const launchArgs = this._prepareLaunchArgs(rawLaunchArgs, true);
     const additionalLaunchArgs = this._prepareLaunchArgs({debug: false});
     const serverPort = new URL(this.client.configuration.server).port;
     await this.adb.reverse(deviceId, serverPort);
@@ -252,13 +256,30 @@ class AndroidDriver extends DeviceDriverBase {
     return this.invocationManager.execute(DetoxApi.launchMainActivity());
   }
 
-  _prepareLaunchArgs(launchArgs) {
-    return _.reduce(launchArgs, (result, value, key) => {
+  _prepareLaunchArgs(launchArgs, verbose = false) {
+    const usedReservedArgs = [];
+    const preparedLaunchArgs = _.reduce(launchArgs, (result, value, key) => {
       const valueAsString = _.isString(value) ? value : JSON.stringify(value);
-      const valueEncoded = (key.startsWith('detox')) ? valueAsString : encodeBase64(valueAsString);
+
+      let valueEncoded = valueAsString;
+      if (isReservedInstrumentationArg(key)) {
+        usedReservedArgs.push(key);
+      } else if (!key.startsWith('detox')) {
+        valueEncoded = encodeBase64(valueAsString);
+      }
+
       result.push('-e', key, valueEncoded);
       return result;
     }, []);
+
+    if (verbose && usedReservedArgs.length) {
+      logger.warn([`Arguments [${usedReservedArgs}] were passed in as launchArgs to device.launchApp() `,
+                   'but are reserved to Android\'s test-instrumentation and will not be passed into the app. ',
+                   'Ignore this message if this is what you meant to do. Refer to ',
+                   'https://developer.android.com/studio/test/command-line#AMOptionsSyntax for ',
+                   'further details.'].join(''));
+    }
+    return preparedLaunchArgs;
   }
 }
 


Test patch:
diff --git a/detox/src/devices/drivers/AndroidDriver.test.js b/detox/src/devices/drivers/AndroidDriver.test.js
index f1e5c2111..9891de3ad 100644
--- a/detox/src/devices/drivers/AndroidDriver.test.js
+++ b/detox/src/devices/drivers/AndroidDriver.test.js
@@ -3,6 +3,7 @@ describe('Android driver', () => {
   const deviceId = 'device-id-mock';
   const bundleId = 'bundle-id-mock';
 
+  let logger;
   let exec;
   beforeEach(() => {
     jest.mock('../../utils/encoding', () => ({
@@ -14,6 +15,15 @@ describe('Android driver', () => {
     jest.mock('../../utils/sleep', () => jest.fn().mockResolvedValue(''));
     jest.mock('../../utils/retry', () => jest.fn().mockResolvedValue(''));
 
+    const mockLogger = {
+      warn: jest.fn(),
+    };
+    jest.mock('../../utils/logger', () => ({
+      child: () => mockLogger,
+      ...mockLogger,
+    }));
+    logger = require('../../utils/logger');
+
     jest.mock('../../utils/exec', () => ({
       spawnAndLog: jest.fn().mockReturnValue({
         childProcess: {
@@ -43,6 +53,7 @@ describe('Android driver', () => {
           expect(spawnedFlags[index]).toEqual('-e');
           expect(spawnedFlags[index + 1]).toEqual(key);
           expect(spawnedFlags[index + 2]).toEqual(value);
+          return index + 3;
         }
       }),
     });
@@ -62,15 +73,73 @@ describe('Android driver', () => {
       const spawnArgs = exec.spawnAndLog.mock.calls[0];
       const spawnedFlags = spawnArgs[1];
 
-      expectSpawnedFlag(spawnedFlags).startingIndex(7).toBe({
+      let index = 7;
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({
         key: 'object-arg',
         value: 'base64({"such":"wow","much":"amaze","very":111})'
       });
-      expectSpawnedFlag(spawnedFlags).startingIndex(10).toBe({
+      expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({
         key: 'string-arg',
         value: 'base64(text, with commas-and-dashes,)'
       });
     });
+
+    // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax
+    it('should whitelist reserved instrumentation args with respect to base64 encoding', async () => {
+      const launchArgs = {
+        // Free arg
+        'user-arg': 'merry christ-nukah',
+
+        // Reserved instrumentation args
+        'class': 'class-value',
+        'package': 'package-value',
+        'func': 'func-value',
+        'unit': 'unit-value',
+        'size': 'size-value',
+        'perf': 'perf-value',
+        'debug': 'debug-value',
+        'log': 'log-value',
+        'emma': 'emma-value',
+        'coverageFile': 'coverageFile-value',
+      };
+
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+
+      const spawnArgs = exec.spawnAndLog.mock.calls[0];
+      const spawnedFlags = spawnArgs[1];
+
+      let index = 10;
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'class', value: 'class-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'package', value: 'package-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'func', value: 'func-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'unit', value: 'unit-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'size', value: 'size-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'perf', value: 'perf-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'debug', value: 'debug-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'log', value: 'log-value' });
+      index = expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'emma', value: 'emma-value' });
+      expectSpawnedFlag(spawnedFlags).startingIndex(index).toBe({ key: 'coverageFile', value: 'coverageFile-value' });
+    });
+
+    it('should log reserved instrumentation args usage warning, if such have been used', async () => {
+      const launchArgs = {
+        'class': 'class-value',
+      };
+
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+
+      expect(logger.warn).toHaveBeenCalled();
+    });
+
+    it('should NOT log instrumentation args usage warning, if none used', async () => {
+      const launchArgs = {
+        'user-arg': 'merry christ-nukah',
+      };
+
+      await uut.launchApp(deviceId, bundleId, launchArgs, '');
+
+      expect(logger.warn).not.toHaveBeenCalled();
+    });
   });
 
   describe('net-port reversing', () => {
diff --git a/detox/test/e2e/22.launch-args.js b/detox/test/e2e/22.launch-args.test.js
similarity index 68%
rename from detox/test/e2e/22.launch-args.js
rename to detox/test/e2e/22.launch-args.test.js
index d3504a6db..0c4bfb139 100644
--- a/detox/test/e2e/22.launch-args.js
+++ b/detox/test/e2e/22.launch-args.test.js
@@ -7,6 +7,10 @@ describe(':android: Launch arguments', () => {
     await expect(element(by.id(`launchArg-${key}.value`))).toHaveText(expectedValue);
   }
 
+  async function assertNoLaunchArg(launchArgKey) {
+    await expect(element(by.id(`launchArg-${launchArgKey}.name`))).toBeNotVisible();
+  }
+
   it('should handle primitive args', async () => {
     const launchArgs = {
       hello: 'world',
@@ -40,4 +44,22 @@ describe(':android: Launch arguments', () => {
     await assertLaunchArg(launchArgs, 'complex', JSON.stringify(launchArgs.complex));
     await assertLaunchArg(launchArgs, 'complexlist', JSON.stringify(launchArgs.complexlist));
   });
+
+  // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax
+  it('should not pass android instrumentation args through', async () => {
+    const launchArgs = {
+      hello: 'world',
+      debug: false,
+      log: false,
+      size: 'large',
+    };
+
+    await device.launchApp({newInstance: true, launchArgs});
+
+    await element(by.text('Launch Args')).tap();
+    await assertLaunchArg(launchArgs, 'hello', 'world');
+    await assertNoLaunchArg('debug');
+    await assertNoLaunchArg('log');
+    await assertNoLaunchArg('size');
+  });
 });