Repository: wix/Detox (js)

Issue:
Facilitate testing React Native apps in debug mode on Android
### Describe your idea

Currently, Detox does not reverse TCP port of Metro bundler, which is confusing.

We could do a better job for our `AndroidDriver` implementation upon launchApp:

* if `process.env.RCT_METRO_PORT` is defined, call `device.reverseTcpPort(...)`
* otherwise, call `device.reverseTcpPort(8081)` – that's the default port.


#### Definition Of Done

Rollout the config in Wix

Pull request:
feat(config): add reversePorts to app config
## Description

Resolves #3494.

* Adds `reversePorts: number[]` property to Android app configs.
* Adds `reversePorts: [8081]` to generated template (`android.debug` app config)
* Explains how to use it with Genymotion SaaS.

> _For features/enhancements:_
 - [x] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files.

> _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.


Patch:
diff --git a/detox/index.d.ts b/detox/index.d.ts
index f42bd95b2..db74ed6c6 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -238,6 +238,13 @@ declare global {
             build?: string;
             testBinaryPath?: string;
             launchArgs?: Record<string, any>;
+            /**
+             * TCP ports to `adb reverse` upon the installation.
+             * E.g. 8081 - to be able to access React Native packager in Debug mode.
+             *
+             * @example [8081]
+             */
+            reversePorts?: number[];
         }
 
         interface DetoxCustomAppConfig {
diff --git a/detox/local-cli/init.js b/detox/local-cli/init.js
index d720d0640..ee530d9ea 100644
--- a/detox/local-cli/init.js
+++ b/detox/local-cli/init.js
@@ -94,6 +94,7 @@ function createDefaultConfigurations() {
         type: 'android.apk',
         binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
         build: 'cd android ; ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug ; cd -',
+        reversePorts: [8081],
       },
       'android.release': {
         type: 'android.apk',
diff --git a/detox/src/configuration/composeAppsConfig.js b/detox/src/configuration/composeAppsConfig.js
index 5e72d0dab..93e354e26 100644
--- a/detox/src/configuration/composeAppsConfig.js
+++ b/detox/src/configuration/composeAppsConfig.js
@@ -144,6 +144,10 @@ function validateAppConfig({ appConfig, appPath, deviceConfig, errorComposer })
   if (appConfig.launchArgs && !_.isObject(appConfig.launchArgs)) {
     throw errorComposer.malformedAppLaunchArgs(appPath);
   }
+
+  if (appConfig.type !== 'android.apk' && appConfig.reversePorts) {
+    throw errorComposer.unsupportedReversePorts(appPath);
+  }
 }
 
 module.exports = composeAppsConfig;
diff --git a/detox/src/devices/runtime/RuntimeDevice.js b/detox/src/devices/runtime/RuntimeDevice.js
index 5b96efb79..67e508a8e 100644
--- a/detox/src/devices/runtime/RuntimeDevice.js
+++ b/detox/src/devices/runtime/RuntimeDevice.js
@@ -238,6 +238,16 @@ class RuntimeDevice {
   async installApp(binaryPath, testBinaryPath) {
     const currentApp = binaryPath ? { binaryPath, testBinaryPath } : this._getCurrentApp();
     await this.deviceDriver.installApp(currentApp.binaryPath, currentApp.testBinaryPath);
+
+    // This abstraction leaks because our drivers themselves leak,
+    // so don't blame me - DeviceBaseDriver itself has `reverseTcpPort`,
+    // setting a vicious downward spiral. I can't refactor everything
+    // in a single pull request, so let's bear with it for now.
+    if (Array.isArray(currentApp.reversePorts)) {
+      for (const port of currentApp.reversePorts) {
+        await this.reverseTcpPort(port);
+      }
+    }
   }
 
   async uninstallApp(bundleId) {
diff --git a/detox/src/errors/DetoxConfigErrorComposer.js b/detox/src/errors/DetoxConfigErrorComposer.js
index 5b74279a5..af80ebe5f 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.js
@@ -507,6 +507,14 @@ Examine your Detox config${this._atPath()}`,
     });
   }
 
+  unsupportedReversePorts(appPath) {
+    return new DetoxConfigError({
+      message: `Non-Android app configs cannot have "reversePorts" property:`,
+      debugInfo: this._focusOnAppConfig(appPath),
+      inspectOptions: { depth: 4 },
+    });
+  }
+
   missingAppBinaryPath(appPath) {
     return new DetoxConfigError({
       message: `Missing "binaryPath" property in the app config.\nExpected a string:`,
diff --git a/docs/guide/genymotion-cloud.mdx b/docs/guide/genymotion-cloud.mdx
index 6f1474cc7..974219031 100644
--- a/docs/guide/genymotion-cloud.mdx
+++ b/docs/guide/genymotion-cloud.mdx
@@ -72,21 +72,6 @@ Copy that UUID and use it to create a new device and a new configuration in your
  };
 ```
 
-:::info
-
-In the example above we assume you'll be running a _release configuration_ of your app since this is what
-usually happens on CI. Running _debug builds_ is likely to require more preparations like
-reversing `8081` port (used by React Native packager by default) before you launch your app, e.g.:
-
-```javascript
-await device.reverseTcpPort(8081);
-await device.launchApp();
-```
-
-If you use extra ports, make sure to have them reversed as well. That might also apply to _release configurations_,
-provided you rely on custom communication with _localhost_ (e.g. for mock servers).
-:::
-
 Although the _recipe UUIDs_ are guaranteed to be unique and never change unlike the _recipe names_, you still can use
 the latter if you like – just switch from `recipeUUID` to `recipeName` property like this:
 
@@ -101,6 +86,65 @@ the latter if you like – just switch from `recipeUUID` to `recipeName` propert
    },
 ```
 
+:::info
+
+In the example above we assume you'll be running a _release configuration_ of your app since this is what
+usually happens on CI.
+
+Running _debug builds_ is trickier to set up (and might be less stable), but if you have to do it,
+follow the instructions. You'll be setting up tunneling between your local machine (where React Native packager is
+running on port 8081) and the remote device in the cloud.
+
+First, patch your `MainApplication.java` (or your main activity class) to change `debug_http_host`, e.g.:
+
+```diff
+ package com.example;
+
+ import android.app.Application;
++import android.content.SharedPreferences;
++import android.os.Bundle;
++import android.preference.PreferenceManager;
+
+ import com.facebook.react.ReactApplication;
+ import com.facebook.react.ReactNativeHost;
+@@ -37,6 +40,9 @@ public class MainApplication extends Application implements ReactApplication {
+     public void onCreate() {
+         super.onCreate();
+         SoLoader.init(this, /* native exopackage */ false);
++        SharedPreferences preferences =
++            PreferenceManager.getDefaultSharedPreferences(getApplicationContext());
++        preferences.edit().putString("debug_http_host", "localhost:8081").apply();
+     }
+
+ }
+```
+
+Now, as your React Native app will be forced to use `localhost:8081` to download the bundle, you'll need to make sure
+that there is a tunnel between your local machine and the remote device – add 8081 to `reversePorts` in your app config,
+e.g.:
+
+```diff
+  'android.debug': {
+    type: 'android.apk',
+    binaryPath: 'android/app/build/outputs/apk/fromBin/debug/app-fromBin-debug.apk',
+    build: 'cd android && ./gradlew assembleFromBinDebug assembleFromBinDebugAndroidTest -DtestBuildType=debug && cd ..',
++   reversePorts: [8081],
+  },
+```
+
+Clean your Android build intermediates and build your app again:
+
+```bash
+cd android
+./gradlew clean # remove ./ on Windows
+cd ..
+detox build -c android.emu.debug
+```
+
+If your application is trivial enough, these adjustments should be already enough to run _debug builds_ remotely.
+
+:::
+
 ## Running
 
 Assuming you have created a new `android.genycloud.release` configuration, run:


Test patch:
diff --git a/detox/src/configuration/composeAppsConfig.test.js b/detox/src/configuration/composeAppsConfig.test.js
index 9ffc45674..e7bc43526 100644
--- a/detox/src/configuration/composeAppsConfig.test.js
+++ b/detox/src/configuration/composeAppsConfig.test.js
@@ -275,6 +275,19 @@ describe('composeAppsConfig', () => {
         ));
       });
 
+      test.each([
+        ['ios.app', 'ios.simulator'],
+      ])('known app (device type = %s) has unsupported reversePorts', (appType, deviceType) => {
+        globalConfig.apps.example1.reversePorts = [3000];
+        globalConfig.apps.example1.type = appType;
+        deviceConfig.type = deviceType;
+        localConfig.app = 'example1';
+
+        expect(compose).toThrowError(errorComposer.unsupportedReversePorts(
+          ['apps', 'example1']
+        ));
+      });
+
       test.each([
         ['android.apk', 'ios.simulator'],
         ['ios.app', 'android.attached'],
diff --git a/detox/src/devices/runtime/RuntimeDevice.test.js b/detox/src/devices/runtime/RuntimeDevice.test.js
index f131277a8..4c17bb640 100644
--- a/detox/src/devices/runtime/RuntimeDevice.test.js
+++ b/detox/src/devices/runtime/RuntimeDevice.test.js
@@ -659,6 +659,18 @@ describe('Device', () => {
       const device = await aValidDevice();
       await device.installApp();
       expect(driverMock.driver.installApp).toHaveBeenCalledWith(device._currentApp.binaryPath, device._currentApp.testBinaryPath);
+      expect(driverMock.driver.reverseTcpPort).not.toHaveBeenCalled();
+    });
+
+    it(`with reversePorts, it should reverse the ports`, async () => {
+      const device = await aValidDevice({
+        appsConfig: {
+          default: { reversePorts: [3000] },
+        },
+      });
+
+      await device.installApp();
+      expect(driverMock.driver.reverseTcpPort).toHaveBeenCalledWith(3000);
     });
   });
 
diff --git a/detox/src/errors/DetoxConfigErrorComposer.test.js b/detox/src/errors/DetoxConfigErrorComposer.test.js
index 781ec3821..83586d4c5 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.test.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.test.js
@@ -413,6 +413,24 @@ describe('DetoxConfigErrorComposer', () => {
       });
     });
 
+    describe('.unsupportedReversePorts', () => {
+      beforeEach(() => {
+        build = (appPath) => builder.unsupportedReversePorts(appPath);
+      });
+
+      it('should work with inlined configurations', () => {
+        config.configurations.inlinedMulti.apps[0].reversePorts = [3000];
+        builder.setConfigurationName('inlinedMulti');
+        expect(build(['configurations', 'inlinedMulti', 'apps', 0])).toMatchSnapshot();
+      });
+
+      it('should work with aliased configurations', () => {
+        config.apps.someApp.launchArgs = [3000];
+        builder.setConfigurationName('aliased');
+        expect(build(['apps', 'someApp'])).toMatchSnapshot();
+      });
+    });
+
     describe('.missingAppBinaryPath', () => {
       beforeEach(() => {
         build = (appPath) => builder.missingAppBinaryPath(appPath);
diff --git a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
index e5458b399..3250ef593 100644
--- a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
+++ b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
@@ -486,6 +486,40 @@ HINT: You should create a dictionary of app configurations in Detox config, e.g.
 ]
 `;
 
+exports[`DetoxConfigErrorComposer (from composeAppsConfig) .unsupportedReversePorts should work with aliased configurations 1`] = `
+[DetoxConfigError: Non-Android app configs cannot have "reversePorts" property:
+
+{
+  apps: {
+    someApp: {
+      type: 'ios.app',
+      binaryPath: 'path/to/app',
+      launchArgs: [
+        3000
+      ]
+    }
+  }
+}]
+`;
+
+exports[`DetoxConfigErrorComposer (from composeAppsConfig) .unsupportedReversePorts should work with inlined configurations 1`] = `
+[DetoxConfigError: Non-Android app configs cannot have "reversePorts" property:
+
+{
+  configurations: {
+    inlinedMulti: {
+      apps: [
+        {
+          type: 'ios.app',
+          binaryPath: 'path/to/app',
+          reversePorts: [Array]
+        }
+      ]
+    }
+  }
+}]
+`;
+
 exports[`DetoxConfigErrorComposer (from composeDeviceConfig) .cantResolveDeviceAlias should create a helpful error 1`] = `
 [DetoxConfigError: Failed to find a device config "otherDevice" in the "devices" dictionary of Detox config at path:
 /home/detox/myproject/.detoxrc.json
diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js
index ca7e24bf5..5fe338c68 100644
--- a/detox/test/e2e/detox.config.js
+++ b/detox/test/e2e/detox.config.js
@@ -68,6 +68,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 ..',
+      reversePorts: [8081],
     },
 
     'android.debug.withArgs': {
@@ -75,6 +76,7 @@ const config = {
       name: 'exampleWithArgs',
       binaryPath: 'android/app/build/outputs/apk/fromBin/debug/app-fromBin-debug.apk',
       build: ':',
+      reversePorts: [8081],
       launchArgs,
     },
 
@@ -83,6 +85,7 @@ const config = {
       name: 'example',
       binaryPath: 'android/app/build/outputs/apk/fromSource/debug/app-fromSource-debug.apk',
       build: 'cd android && ./gradlew assembleFromSourceDebug assembleFromSourceDebugAndroidTest -DtestBuildType=debug && cd ..',
+      reversePorts: [8081],
     },
 
     'android.fromSource.withArgs': {
@@ -90,6 +93,7 @@ const config = {
       name: 'example',
       binaryPath: 'android/app/build/outputs/apk/fromSource/debug/app-fromSource-debug.apk',
       build: ':',
+      reversePorts: [8081],
       launchArgs,
     },