Repository: wix/Detox
Pull request #2676 (2021-03-05 09:22:12)

fix(config): throw err on invalid device type
- [x] This change has been discussed in issue #2675 and the solution has been agreed upon with maintainers.

---

**Description:**

```
DetoxConfigError: Invalid device type "android.apk" inside your configuration.

HINT: Did you mean to use one of these?
* ios.simulator
* ios.none
* android.attached
* android.emulator
* android.genycloud

P.S. If you intended to use a third-party driver, please resolve this error:

Cannot find module 'android.apk'

Please check your Detox config at path:
/Users/yaroslavs/Projects/wix/Detox/detox/test/.detoxrc.json

{
  configurations: {
    debug: {
      binaryPath: 'C:/Users/adema/Desktop/myMoney/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
      build: 'cd android && ./gradlew build assembleDebug ' +
        'assembleAndroidTest -DtestBuildType=debug',
      type: 'android.apk',
      devices: {
        emulator: [Object]
      }
    }
  }
}
```

P. S. @Ademar-SJ, this is the error message you should have been seeing in the perfect world with unicorns and rainbows.


Patch:
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index cec32bbc7..fff602d37 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -148,7 +148,8 @@ class Detox {
     await this._client.connect();
 
     const invocationManager = new InvocationManager(this._client);
-    const deviceDriver = driverRegistry.resolve(this._deviceConfig.type, {
+    const DeviceDriverImpl = driverRegistry.resolve(this._deviceConfig.type);
+    const deviceDriver = new DeviceDriverImpl({
       client: this._client,
       invocationManager,
       emitter: this._eventEmitter,
diff --git a/detox/src/configuration/composeDeviceConfig.js b/detox/src/configuration/composeDeviceConfig.js
index 71ee65fbd..782d9b880 100644
--- a/detox/src/configuration/composeDeviceConfig.js
+++ b/detox/src/configuration/composeDeviceConfig.js
@@ -1,4 +1,5 @@
 const _ = require('lodash');
+const driverRegistry = require('../devices/DriverRegistry').default;
 
 /**
  * @param {DetoxConfigErrorComposer} opts.errorComposer
@@ -94,6 +95,11 @@ function validateDeviceConfig({ deviceConfig, errorComposer, deviceAlias }) {
     throw errorComposer.missingDeviceType(deviceAlias);
   }
 
+  const DriverClass = _.attempt(() => driverRegistry.resolve(deviceConfig.type));
+  if (_.isError(DriverClass)) {
+    throw errorComposer.invalidDeviceType(deviceAlias, deviceConfig, DriverClass);
+  }
+
   if (deviceConfig.utilBinaryPaths) {
     if (!Array.isArray(deviceConfig.utilBinaryPaths)) {
       throw errorComposer.malformedUtilBinaryPaths(deviceAlias);
diff --git a/detox/src/devices/DriverRegistry.js b/detox/src/devices/DriverRegistry.js
index 21e544a14..e9cb72863 100644
--- a/detox/src/devices/DriverRegistry.js
+++ b/detox/src/devices/DriverRegistry.js
@@ -5,22 +5,18 @@ class DriverRegistry {
     this.deviceClasses = deviceClasses;
   }
 
-  resolve(deviceType, opts) {
+  resolve(deviceType) {
     let DeviceDriverClass = this.deviceClasses[deviceType];
 
     if (!DeviceDriverClass) {
-      try {
-        DeviceDriverClass = resolveModuleFromPath(deviceType).DriverClass;
-      } catch (e) {
-        // noop, if we don't find a module to require, we'll hit the unsupported error below
-      }
-    }
+      DeviceDriverClass = resolveModuleFromPath(deviceType).DriverClass;
 
-    if (!DeviceDriverClass) {
-      throw new Error(`'${deviceType}' is not supported`);
+      if (!DeviceDriverClass) {
+        throw new Error(`The custom driver '${deviceType}' does not export DriverClass property`);
+      }
     }
 
-    return new DeviceDriverClass(opts);
+    return DeviceDriverClass;
   }
 }
 
diff --git a/detox/src/errors/DetoxConfigErrorComposer.js b/detox/src/errors/DetoxConfigErrorComposer.js
index 6f9ddcd77..c760ed005 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.js
@@ -253,6 +253,22 @@ Examine your Detox config${this._atPath()}`,
     });
   }
 
+  invalidDeviceType(deviceAlias, deviceConfig, innerError) {
+    return new DetoxConfigError({
+      message: `Invalid device type ${J(deviceConfig.type)} inside your configuration.`,
+      hint: `Did you mean to use one of these?
+${hintConfigurations(deviceAppTypes)}
+
+P.S. If you intended to use a third-party driver, please resolve this error:
+
+${innerError.message}
+
+Please check your Detox config${this._atPath()}`,
+      debugInfo: this._focusOnDeviceConfig(deviceAlias, this._ensureProperty('type')),
+      inspectOptions: { depth: 3 },
+    });
+  }
+
   malformedUtilBinaryPaths(deviceAlias) {
     return new DetoxConfigError({
       message: `Invalid type of "utilBinaryPaths" inside the device configuration.`


Test patch:
diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js
index 3f4ed5569..5f6d6d23e 100644
--- a/detox/local-cli/test.test.js
+++ b/detox/local-cli/test.test.js
@@ -217,8 +217,7 @@ describe('CLI', () => {
     });
 
     test('should omit --grep --invert for custom platforms', async () => {
-      const customDriver = `module.exports = class CustomDriver {};`
-      singleConfig().type = tempfile('.js', customDriver);
+      singleConfig().type = tempfile('.js', aCustomDriverModule());
 
       await run();
       expect(cliCall().command).not.toContain(' --invert ');
@@ -494,8 +493,7 @@ describe('CLI', () => {
     });
 
     test('should omit --testNamePattern for custom platforms', async () => {
-      const customDriver = `module.exports = class CustomDriver {};`
-      singleConfig().type = tempfile('.js', customDriver);
+      singleConfig().type = tempfile('.js', aCustomDriverModule());
 
       await run();
       expect(cliCall().command).not.toContain('--testNamePattern');
@@ -781,4 +779,8 @@ describe('CLI', () => {
   function quote(s, q = isInCMD() ? `"` : `'`) {
     return q + s + q;
   }
+
+  function aCustomDriverModule() {
+    return `class DriverClass {}; module.exports = { DriverClass }`;
+  }
 });
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index b56ceae0a..37bea2293 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -107,14 +107,7 @@ describe('Detox', () => {
         expect(client().connect).toHaveBeenCalled());
 
       it('should resolve a device driver from the registry', () =>
-        expect(FakeDriverRegistry.default.resolve).toHaveBeenCalledWith(
-          detoxConfig.deviceConfig.type,
-          {
-            client: client(),
-            invocationManager: invocationManager(),
-            emitter: expect.anything(),
-          }
-        ));
+        expect(FakeDriverRegistry.default.resolve).toHaveBeenCalledWith(detoxConfig.deviceConfig.type));
 
       it('should resolve matchers implementation', () => {
         const { emitter } = Device.mock.calls[0][0];
diff --git a/detox/src/configuration/composeDeviceConfig.test.js b/detox/src/configuration/composeDeviceConfig.test.js
index e24d67544..41873fa40 100644
--- a/detox/src/configuration/composeDeviceConfig.test.js
+++ b/detox/src/configuration/composeDeviceConfig.test.js
@@ -12,12 +12,18 @@ describe('composeDeviceConfig', () => {
   let globalConfig;
   /** @type {DetoxConfigErrorComposer} */
   let errorComposer;
+  /** @type {DriverRegistry} */
+  let driverRegistry;
 
   beforeEach(() => {
-    composeDeviceConfig = require('./composeDeviceConfig');
+    jest.mock('../devices/DriverRegistry', () => {
+      const DriverRegistry = jest.requireActual('../devices/DriverRegistry');
+      driverRegistry = DriverRegistry.default;
+      return DriverRegistry;
+    });
+
     cliConfig = {};
     localConfig = {};
-
     globalConfig = {
       configurations: {
         someConfig: localConfig,
@@ -27,6 +33,8 @@ describe('composeDeviceConfig', () => {
     errorComposer = new DetoxConfigErrorComposer()
       .setDetoxConfig(globalConfig)
       .setConfigurationName('someConfig');
+
+    composeDeviceConfig = require('./composeDeviceConfig');
   });
 
   const compose = () => composeDeviceConfig({
@@ -72,6 +80,7 @@ describe('composeDeviceConfig', () => {
 
       beforeEach(() => {
         Object.assign(localConfig, values);
+        driverRegistry.resolve = () => (class SomeDriver {});
       });
 
       it('should take it as is, for backward compatibility', () => {
@@ -230,6 +239,18 @@ describe('composeDeviceConfig', () => {
       });
     });
 
+    it('should throw if the device config has invalid type', () => {
+      const someError = new Error('Some error');
+      driverRegistry.resolve = () => { throw someError };
+
+      localConfig.device = { type: 'android.apk' };
+      expect(compose).toThrow(errorComposer.invalidDeviceType(
+        undefined,
+        localConfig.device,
+        someError
+      ));
+    });
+
     describe('missing device matcher properties', () => {
       it.each([
         [['type', 'name', 'id'], 'ios.simulator'],
diff --git a/detox/src/devices/DriverRegistry.test.js b/detox/src/devices/DriverRegistry.test.js
index 832ab28f4..99fa12f3e 100644
--- a/detox/src/devices/DriverRegistry.test.js
+++ b/detox/src/devices/DriverRegistry.test.js
@@ -6,15 +6,10 @@ jest.mock('../utils/resolveModuleFromPath');
 
 describe('DriverRegistry', () => {
   let DriverRegistry;
-  let registry, opts;
+  let registry;
 
   beforeEach(() => {
     DriverRegistry = require('./DriverRegistry');
-
-    opts = {
-      client: { whatever: Math.random() },
-      emitter: { anything: Math.random() },
-    };
   });
 
   describe('by default', () => {
@@ -24,34 +19,30 @@ describe('DriverRegistry', () => {
 
     it('should resolve "ios.none" to IosDriver', () => {
       const IosDriver = require('./drivers/ios/IosDriver');
-      const driver = registry.resolve('ios.none', opts);
+      const ResolvedDriver = registry.resolve('ios.none');
 
-      expect(driver).toBeInstanceOf(IosDriver);
-      expect(IosDriver).toHaveBeenCalledWith(opts);
+      expect(ResolvedDriver).toBe(IosDriver);
     });
 
     it('should resolve "ios.simulator" to SimulatorDriver', () => {
       const SimulatorDriver = require('./drivers/ios/SimulatorDriver');
-      const driver = registry.resolve('ios.simulator', opts);
+      const ResolvedDriver = registry.resolve('ios.simulator');
 
-      expect(driver).toBeInstanceOf(SimulatorDriver);
-      expect(SimulatorDriver).toHaveBeenCalledWith(opts);
+      expect(ResolvedDriver).toBe(SimulatorDriver);
     });
 
     it('should resolve "android.attached" to AttachedAndroidDriver', () => {
       const AttachedAndroidDriver = require('./drivers/android/attached/AttachedAndroidDriver');
-      const driver = registry.resolve('android.attached', opts);
+      const ResolvedDriver = registry.resolve('android.attached');
 
-      expect(driver).toBeInstanceOf(AttachedAndroidDriver);
-      expect(AttachedAndroidDriver).toHaveBeenCalledWith(opts);
+      expect(ResolvedDriver).toBe(AttachedAndroidDriver);
     });
 
     it('should resolve "android.emulator" to EmulatorDriver', () => {
       const EmulatorDriver = require('./drivers/android/emulator/EmulatorDriver');
-      const driver = registry.resolve('android.emulator', opts);
+      const ResolvedDriver = registry.resolve('android.emulator');
 
-      expect(driver).toBeInstanceOf(EmulatorDriver);
-      expect(EmulatorDriver).toHaveBeenCalledWith(opts);
+      expect(ResolvedDriver).toBe(EmulatorDriver);
     });
 
     it('should try to resolve unknown driver as a node.js dependency', () => {
@@ -62,15 +53,22 @@ describe('DriverRegistry', () => {
       });
 
       const FakeDriver = require('./drivers/__mocks__/FakeDriver');
-      const driver = registry.resolve('fake-driver', opts);
+      const ResolvedDriver = registry.resolve('fake-driver');
+
+      expect(ResolvedDriver).toBe(FakeDriver);
+    });
+
+    it('should throw an error if a custom driver does not export DriverClass property', () => {
+      require('../utils/resolveModuleFromPath').mockImplementation(() => {
+        return require('./drivers/__mocks__/FakeDriver');
+      });
 
-      expect(driver).toBeInstanceOf(FakeDriver);
-      expect(driver.constructorArgs).toEqual([opts]);
+      expect(() => registry.resolve('fake-driver')).toThrowError(/does not export DriverClass/);
     });
 
     it('should throw errors if it cannot resolve a driver', () => {
-      expect(() => registry.resolve('imaginary.driver', opts))
-        .toThrowError(/imaginary.driver.*not supported/);
+      require('../utils/resolveModuleFromPath').mockImplementation(() => { throw new Error('BAD IDEA'); });
+      expect(() => registry.resolve('imaginary.driver')).toThrowError('BAD IDEA');
     });
   });
 
@@ -78,10 +76,9 @@ describe('DriverRegistry', () => {
     it('should be extensible', () => {
       const FakeDriver101 = require('./drivers/__mocks__/FakeDriver');
       const registry = new DriverRegistry({ FakeDriver101 });
-      const driver = registry.resolve('FakeDriver101', opts);
+      const driver = registry.resolve('FakeDriver101');
 
-      expect(driver).toBeInstanceOf(FakeDriver101);
-      expect(driver.constructorArgs).toEqual([opts]);
+      expect(driver).toBe(FakeDriver101);
     });
   });
 });
diff --git a/detox/src/errors/DetoxConfigErrorComposer.test.js b/detox/src/errors/DetoxConfigErrorComposer.test.js
index 372b41ad2..6610ce122 100644
--- a/detox/src/errors/DetoxConfigErrorComposer.test.js
+++ b/detox/src/errors/DetoxConfigErrorComposer.test.js
@@ -222,6 +222,30 @@ describe('DetoxConfigErrorComposer', () => {
       });
     });
 
+    describe('.invalidDeviceType', () => {
+      beforeEach(() => {
+        build = (deviceConfig, alias) => {
+          // eslint-disable-next-line node/no-missing-require
+          const err = _.attempt(() => require('android.apk'));
+          return builder.invalidDeviceType(alias, deviceConfig, err);
+        };
+      });
+
+      it('should create an error for inlined configuration', () => {
+        const deviceConfig = config.configurations.inlined.device;
+        deviceConfig.type = 'android.apk';
+        builder.setConfigurationName('inlined');
+        expect(build(deviceConfig)).toMatchSnapshot();
+      });
+
+      it('should create an error for aliased configuration', () => {
+        const deviceConfig = config.devices.aDevice;
+        deviceConfig.type = 'android.apk';
+        builder.setConfigurationName('aliased');
+        expect(build(deviceConfig, 'aDevice')).toMatchSnapshot();
+      });
+    });
+
     describe('.missingDeviceMatcherProperties', () => {
       beforeEach(() => {
         build = (alias) => builder.missingDeviceMatcherProperties(alias, ['foo', 'bar']);
diff --git a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
index 20245517c..da2fa0b1a 100644
--- a/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
+++ b/detox/src/errors/__snapshots__/DetoxConfigErrorComposer.test.js.snap
@@ -557,6 +557,64 @@ Examine your Detox config at path:
 /home/detox/myproject/.detoxrc.json]
 `;
 
+exports[`DetoxConfigErrorComposer (from composeDeviceConfig) .invalidDeviceType should create an error for aliased configuration 1`] = `
+[DetoxConfigError: Invalid device type "android.apk" inside your configuration.
+
+HINT: Did you mean to use one of these?
+* ios.simulator
+* ios.none
+* android.attached
+* android.emulator
+* android.genycloud
+
+P.S. If you intended to use a third-party driver, please resolve this error:
+
+Cannot find module 'android.apk' from 'src/errors/DetoxConfigErrorComposer.test.js'
+
+Please check your Detox config at path:
+/home/detox/myproject/.detoxrc.json
+
+{
+  devices: {
+    aDevice: {
+      type: 'android.apk',
+      device: {
+        type: 'iPhone 12'
+      }
+    }
+  }
+}]
+`;
+
+exports[`DetoxConfigErrorComposer (from composeDeviceConfig) .invalidDeviceType should create an error for inlined configuration 1`] = `
+[DetoxConfigError: Invalid device type "android.apk" inside your configuration.
+
+HINT: Did you mean to use one of these?
+* ios.simulator
+* ios.none
+* android.attached
+* android.emulator
+* android.genycloud
+
+P.S. If you intended to use a third-party driver, please resolve this error:
+
+Cannot find module 'android.apk' from 'src/errors/DetoxConfigErrorComposer.test.js'
+
+Please check your Detox config at path:
+/home/detox/myproject/.detoxrc.json
+
+{
+  configurations: {
+    inlined: {
+      device: {
+        type: 'android.apk',
+        device: [Object]
+      }
+    }
+  }
+}]
+`;
+
 exports[`DetoxConfigErrorComposer (from composeDeviceConfig) .malformedUtilBinaryPaths should create an error for aliased configuration 1`] = `
 [DetoxConfigError: Invalid type of "utilBinaryPaths" inside the device configuration. Expected an array of strings.