Repository: wix/Detox
Pull request #2480 (2020-11-23 15:13:56)

Prevalidate gmsaas: binary and auth
## Description

As per insightful notes by @noomorph - added a validation of that `gmsaas` is in path (so as to avoid pointless retires to take place in trying to execute initial setup command, resulting in obscure, unfriendly errors).

Actually, with genymotion's SaaS, there are two things to handle before enabling access to the cloud:
1. Setting up `gmsaas` in path
2. Logging in

This PR effectively handles both aspects.


Patch:
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index aef822896..3e2401a12 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -13,10 +13,10 @@ class Device {
   }
 
   async prepare() {
+    await this.deviceDriver.prepare();
+
     this._deviceId = await this.deviceDriver.acquireFreeDevice(this._deviceConfig.device || this._deviceConfig.name);
     this._bundleId = await this.deviceDriver.getBundleIdFromBinary(this._deviceConfig.binaryPath);
-
-    await this.deviceDriver.prepare();
   }
 
   async launchApp(params = {newInstance: false}, bundleId) {
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
index 1564f84e1..2cc0df560 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.js
@@ -6,16 +6,18 @@ const RecipesService = require('./services/GenyRecipesService');
 const InstanceLookupService = require('./services/GenyInstanceLookupService');
 const InstanceLifecycleService = require('./services/GenyInstanceLifecycleService');
 const InstanceNaming = require('./services/GenyInstanceNaming');
+const AuthService = require('./services/GenyAuthService');
 const DeviceQueryHelper = require('./helpers/GenyDeviceQueryHelper');
 const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
 const logger = require('../../../../utils/logger').child({ __filename });
+const environment = require('../../../../utils/environment');
 
 class GenyCloudDriver extends AndroidDriver {
   constructor(config) {
     super(config);
     this._name = 'Unspecified Genymotion Cloud Emulator';
 
-    const exec = new GenyCloudExec();
+    const exec = new GenyCloudExec(environment.getGmsaasPath());
     const instanceNaming = new InstanceNaming(); // TODO should consider a permissive impl for debug/dev mode. Maybe even a custom arg in package.json (Detox > ... > genycloud > sharedAccount: false)
     this._deviceRegistry = GenyDeviceRegistryFactory.forRuntime();
     this._deviceCleanupRegistry = GenyDeviceRegistryFactory.forGlobalShutdown();
@@ -25,12 +27,18 @@ class GenyCloudDriver extends AndroidDriver {
     this._instanceLifecycleService = new InstanceLifecycleService(exec, instanceNaming);
     this._deviceQueryHelper = new DeviceQueryHelper(recipeService);
     this._deviceAllocator = new GenyCloudDeviceAllocator(this._deviceRegistry, this._deviceCleanupRegistry, instanceLookupService, this._instanceLifecycleService);
+
+    this._authService = new AuthService(exec);
   }
 
   get name() {
     return this._name;
   }
 
+  async prepare() {
+    return this._validateGmsaasAuth();
+  }
+
   async acquireFreeDevice(deviceQuery) {
     const recipe = await this._deviceQueryHelper.getRecipeFromQuery(deviceQuery);
     this._assertRecipe(deviceQuery, recipe);
@@ -75,9 +83,19 @@ class GenyCloudDriver extends AndroidDriver {
     }
   }
 
+  async _validateGmsaasAuth() {
+    if (!await this._authService.getLoginEmail()) {
+      throw new DetoxRuntimeError({
+        message: 'Cannot run tests using a Genymotion-cloud emulator, because Genymotion was not logged-in to!',
+        hint: `Log-in to Genymotion-cloud by running this command (and following instructions):\n${environment.getGmsaasPath()} auth login --help`,
+      });
+    }
+  }
+
   static async globalCleanup(instanceLifecycleService) {
     if (!instanceLifecycleService) {
-      instanceLifecycleService = new InstanceLifecycleService(new GenyCloudExec(), null);
+      const exec = new GenyCloudExec(environment.getGmsaasPath());
+      instanceLifecycleService = new InstanceLifecycleService(exec, null);
     }
 
     const deviceCleanupRegistry = GenyDeviceRegistryFactory.forGlobalShutdown();
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
index 691392388..84a8cd2e0 100644
--- a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.js
@@ -1,6 +1,14 @@
 const exec = require('../../../../../utils/exec').execWithRetriesAndLogs;
 
 class GenyCloudExec {
+  constructor(binaryPath) {
+    this.binaryExec = `"${binaryPath}" --format compactjson`;
+  }
+
+  whoAmI() {
+    return this._exec(`auth whoami`);
+  }
+
   getRecipe(name) {
     return this._exec(`recipes list --name "${name}"`);
   }
@@ -40,7 +48,7 @@ class GenyCloudExec {
         retrying: true,
       },
     };
-    return (await exec(`"gmsaas" --format compactjson ${args}`, options )).stdout;
+    return (await exec(`${this.binaryExec} ${args}`, options )).stdout;
   }
 }
 
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.js b/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.js
new file mode 100644
index 000000000..46aa0fc7f
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.js
@@ -0,0 +1,12 @@
+class GenyAuthService {
+  constructor(genyCloudExec) {
+    this.genyCloudExec = genyCloudExec;
+  }
+
+  async getLoginEmail() {
+    const whoAmI = await this.genyCloudExec.whoAmI();
+    return whoAmI.auth.email;
+  }
+}
+
+module.exports = GenyAuthService;
diff --git a/detox/src/utils/environment.js b/detox/src/utils/environment.js
index c3ae9b765..429e6f2d6 100644
--- a/detox/src/utils/environment.js
+++ b/detox/src/utils/environment.js
@@ -119,6 +119,10 @@ function getAdbPath() {
   throwSdkIntegrityError(sdkRoot, 'platform-tools/adb');
 }
 
+function getGmsaasPath() {
+  return which('gmsaas') || throwMissingGmsaasError();
+}
+
 function throwMissingSdkError() {
   throw new Error(MISSING_SDK_ERROR);
 }
@@ -145,6 +149,10 @@ function throwSdkIntegrityError(sdkRoot, relativeExecutablePath) {
   );
 }
 
+function throwMissingGmsaasError() {
+  throw new Error(`Failed to locate Genymotion\'s gmsaas executable. Please add it to your $PATH variable!\nPATH is currently set to: ${process.env.PATH}`)
+}
+
 function getDetoxVersion() {
   return require(path.join(__dirname, '../../package.json')).version;
 }
@@ -187,6 +195,7 @@ module.exports = {
   getAvdDir,
   getAvdManagerPath,
   getAndroidSdkManagerPath,
+  getGmsaasPath,
   getDetoxVersion,
   getFrameworkPath,
   getAndroidSDKPath,


Test patch:
diff --git a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
index 95e44ea80..d7551a697 100644
--- a/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
+++ b/detox/src/devices/drivers/android/genycloud/GenyCloudDriver.test.js
@@ -1,4 +1,5 @@
 const _ = require('lodash');
+
 const latestInstanceOf = (clazz) => _.last(clazz.mock.instances);
 
 const mockBaseClassesDependencies = () => {
@@ -15,11 +16,13 @@ const mockBaseClassesDependencies = () => {
 };
 
 const mockDirectDependencies = () => {
+  jest.mock('../../../../utils/environment');
   jest.mock('./exec/GenyCloudExec');
   jest.mock('./GenyDeviceRegistryFactory');
   jest.mock('./services/GenyRecipesService');
   jest.mock('./services/GenyInstanceLookupService');
   jest.mock('./services/GenyInstanceLifecycleService');
+  jest.mock('./services/GenyAuthService');
   jest.mock('./services/GenyInstanceNaming');
   jest.mock('../../../../utils/getAbsoluteBinaryPath');
 };
@@ -45,12 +48,15 @@ describe('Genymotion-cloud driver', () => {
 
   let logger;
   let emitter;
+  let environment;
   let adbObj;
+  let Exec;
   let deviceQueryHelper;
   let deviceRegistry;
   let deviceCleanupRegistry;
   let deviceAllocator;
   let InstanceLifecycleService;
+  let authServiceObj;
   let GenyCloudDriver;
   let uut;
   beforeEach(() => {
@@ -59,12 +65,17 @@ describe('Genymotion-cloud driver', () => {
     const Emitter = jest.genMockFromModule('../../../../utils/AsyncEmitter');
     emitter = new Emitter();
 
+    environment = require('../../../../utils/environment');
+    environment.getGmsaasPath.mockReturnValue('/path/to/gmsaas');
+
     const { InvocationManager } = jest.genMockFromModule('../../../../invoke');
     const invocationManager = new InvocationManager();
 
     const ADB = require('../exec/ADB');
     adbObj = () => latestInstanceOf(ADB);
 
+    Exec = require('./exec/GenyCloudExec');
+
     const GenyDeviceRegistryFactory = require('./GenyDeviceRegistryFactory');
     const DeviceRegistry = jest.genMockFromModule('../../../DeviceRegistry');
     deviceRegistry = new DeviceRegistry();
@@ -84,6 +95,9 @@ describe('Genymotion-cloud driver', () => {
 
     InstanceLifecycleService = require('./services/GenyInstanceLifecycleService');
 
+    const AuthService = require('./services/GenyAuthService');
+    authServiceObj = () => latestInstanceOf(AuthService);
+
     GenyCloudDriver = require('./GenyCloudDriver');
     uut = new GenyCloudDriver({
       invocationManager,
@@ -96,6 +110,30 @@ describe('Genymotion-cloud driver', () => {
     expect(uut.name).toEqual('Unspecified Genymotion Cloud Emulator');
   });
 
+  it('should initialize the common executable using the path set by the environment', async () => {
+    expect(Exec).toHaveBeenCalledWith('/path/to/gmsaas');
+  });
+
+  describe('preparation', () => {
+    it('should throw error if not logged-in to gmsaas', async () => {
+      authServiceObj().getLoginEmail.mockResolvedValue(null);
+
+      try {
+        await uut.prepare();
+        fail('Expected an error');
+      } catch (e) {
+        expect(e.constructor.name).toEqual('DetoxRuntimeError');
+        expect(e.toString()).toContain('Cannot run tests using a Genymotion-cloud emulator, because Genymotion was not logged-in to!');
+        expect(e.toString()).toContain(`HINT: Log-in to Genymotion-cloud by running this command (and following instructions):\n/path/to/gmsaas auth login --help`);
+      }
+    });
+
+    it('should not throw an error if properly logged in to gmsaas', async () => {
+      authServiceObj().getLoginEmail.mockResolvedValue('detox@wix.com');
+      await uut.prepare();
+    });
+  });
+
   describe('device (instance) allocation', () => {
     const givenNoRecipes = () => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(null);
     const givenResolvedRecipeForQuery = (recipe) => deviceQueryHelper().getRecipeFromQuery.mockResolvedValue(recipe);
@@ -322,8 +360,12 @@ describe('Genymotion-cloud driver', () => {
     });
 
     it('should fallback to a default lifecycle-service', async () => {
+      Exec.mockReset();
+
       deviceCleanupRegistry.readRegisteredDevices.mockResolvedValue([]);
       await GenyCloudDriver.globalCleanup();
+
+      expect(Exec).toHaveBeenCalledWith('/path/to/gmsaas');
     });
 
     it('should warn of instances cleanup rejects', async () => {
@@ -345,7 +387,7 @@ describe('Genymotion-cloud driver', () => {
 
     it('should not warn of cleanup rejects if all went well', async () => {
       const instanceLifecycleService = anInstanceLifecycleService();
-      instanceLifecycleService.deleteInstance.mockResolvedValue(anInstance);
+      instanceLifecycleService.deleteInstance.mockResolvedValue(anInstance());
 
       const deviceUUIDs = ['device-uuid1'];
       deviceCleanupRegistry.readRegisteredDevices.mockResolvedValue(deviceUUIDs);
diff --git a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
index 2de56e35f..41d5419f4 100644
--- a/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
+++ b/detox/src/devices/drivers/android/genycloud/exec/GenyCloudExec.test.js
@@ -30,38 +30,44 @@ describe('Genymotion-cloud executable', () => {
     exec = require('../../../../../utils/exec').execWithRetriesAndLogs;
 
     const GenyCloudExec = require('./GenyCloudExec');
-    uut = new GenyCloudExec;
+    uut = new GenyCloudExec('mock/path/to/gmsaas');
   });
 
   const recipeName = 'mock-recipe-name';
   const recipeUUID = 'mock-recipe-uuid';
   const instanceUUID = 'mock-uuid';
   const instanceName = 'detox-instance1';
+
   [
+    {
+      commandName: 'whoami',
+      commandExecFn: () => uut.whoAmI(),
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson auth whoami`,
+    },
     {
       commandName: 'Get Recipe',
       commandExecFn: () => uut.getRecipe(recipeName),
-      expectedExec: `"gmsaas" --format compactjson recipes list --name "${recipeName}"`,
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson recipes list --name "${recipeName}"`,
     },
     {
       commandName: 'Get Instances',
       commandExecFn: () => uut.getInstances(),
-      expectedExec: `"gmsaas" --format compactjson instances list -q`,
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson instances list -q`,
     },
     {
       commandName: 'Start Instance',
       commandExecFn: () => uut.startInstance(recipeUUID, instanceName),
-      expectedExec: `"gmsaas" --format compactjson instances start --stop-when-inactive --no-wait ${recipeUUID} "${instanceName}"`,
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson instances start --stop-when-inactive --no-wait ${recipeUUID} "${instanceName}"`,
     },
     {
       commandName: 'ADB Connect',
       commandExecFn: () => uut.adbConnect(instanceUUID),
-      expectedExec: `"gmsaas" --format compactjson instances adbconnect ${instanceUUID}`,
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson instances adbconnect ${instanceUUID}`,
     },
     {
       commandName: 'Stop Instance',
       commandExecFn: () => uut.stopInstance(instanceUUID),
-      expectedExec: `"gmsaas" --format compactjson instances stop ${instanceUUID}`,
+      expectedExec: `"mock/path/to/gmsaas" --format compactjson instances stop ${instanceUUID}`,
       expectedExecOptions: { retries: 3 },
     },
   ].forEach((testCase) => {
diff --git a/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.test.js b/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.test.js
new file mode 100644
index 000000000..2220121f8
--- /dev/null
+++ b/detox/src/devices/drivers/android/genycloud/services/GenyAuthService.test.js
@@ -0,0 +1,32 @@
+describe('Genymotion-cloud authentication service', () => {
+  let exec;
+  let uut;
+  beforeEach(() => {
+    const GenyCloudExec = jest.genMockFromModule('../exec/GenyCloudExec');
+    exec = new GenyCloudExec();
+
+    const GenyAuthService = require('./GenyAuthService');
+    uut = new GenyAuthService(exec);
+  });
+
+  const givenLoginEmail = (email) => exec.whoAmI.mockResolvedValue({
+    auth: {
+      email,
+    },
+  });
+  const givenLoggedOut = () => givenLoginEmail(null);
+
+  it('should return logged-in user email', async () => {
+    givenLoginEmail('mock@wix.com');
+
+    const result = await uut.getLoginEmail();
+    expect(result).toEqual('mock@wix.com');
+  });
+
+  it('should return null if logged-out', async () => {
+    givenLoggedOut();
+
+    const result = await uut.getLoginEmail();
+    expect(result).toEqual(null);
+  });
+});
diff --git a/detox/src/utils/environment.test.js b/detox/src/utils/environment.test.js
index e90bb599b..755d6f167 100644
--- a/detox/src/utils/environment.test.js
+++ b/detox/src/utils/environment.test.js
@@ -262,5 +262,20 @@ describe('Environment', () => {
         await expect(asyncGetter()).rejects.toThrow(/\$ANDROID_SDK_ROOT is not defined/);
       });
     }
+
+    describe('Genymotion SaaS executable - getGmsaasPath', () => {
+      it('should resolve based on $PATH', async () => {
+        const executable = 'gmsaas';
+        process.env.PATH = path.join(tempSdkPath, 'somewhere');
+        await genExec(`somewhere/${executable}`);
+        expect(Environment.getGmsaasPath()).toMatch(new RegExp(`${tempSdkPath}.+somewhere.${executable}`));
+      });
+
+      it('should throw error if gmsaas is not in $PATH', async () => {
+        const pathToNowhere = path.join('one', 'mock', 'directory');
+        process.env.PATH = pathToNowhere;
+        await expect(async () => Environment.getGmsaasPath()).rejects.toThrow(`Failed to locate Genymotion\'s gmsaas executable. Please add it to your $PATH variable!\nPATH is currently set to: ${pathToNowhere}`);
+      });
+    });
   });
 });