Repository: wix/Detox
Pull request #1919 (2020-02-20 16:19:31)

Add dynamic require for driver plugins
<!---
Thank you for contributing!
Before submitting a pull request that implements a new functionality or fixing a major bug,
please open an issue first and propose your solution. This way we can discuss before time is 
spent on a solution that may not work for us.

Please check the correct option in the below list and delete the irrelevant one.
For the second option, please fill the issue where the solution has been discussed.
-->

- [ ] This is a small change 
- [x] This change has been discussed in issue https://github.com/wix/Detox/issues/1882 and the solution has been agreed upon with maintainers.

---

**Description:**

This PR adds support for external drivers as discussed in https://github.com/wix/Detox/issues/1882#issuecomment-584090146.

```
"ios.sim.release": {
        "binaryPath": "...",
        "build": "...",
        "type": "my-custom-driver-package-name",
        "device": {
	  // can be pretty flexible (according to the device driver), add whatever device specification needed
        }
      },
```



awinograd: One other thing that would be great to resolve as part of this PR is the `binaryPath` issue discussed in https://github.com/wix/Detox/issues/1882#issuecomment-582023492.

Essentially, I want a way to avoid this line which is currently mandatory but doesn't make sense in the context of a website which doesn't have a binaryPath. (Currently I'm using binary path to specify the base web url but have to add a leading `/` to make it absolute and avoid this logic. e.g. `/https://example.com`)

https://github.com/wix/Detox/blob/475d10efc51b2e120d241f4b37b08566029f2319/detox/src/devices/Device.js#L18
rotemmiz: Re binaryPath, let's make sure it is only used where needed (push it into the drivers maybe).
We can introduce `appPath` in Device instead, that will be used for binaryPath in iOS/Android drivers, and for url in your driver.
WDYT? 
awinograd: @rotemmiz thanks for the direction. I just pushed a commit that pushes absolute path logic into the driver layer (removing from device).

I wasn't sure exactly how you wanted me to handle `binaryPath` vs `appPath`

Did you want to totally remove `binaryPath` as an option from the config (a breaking change)?

Or add a new `appPath` field to config that takes precedence over and deprecates `binaryPath` for future removal.

In the grand scheme of things, it's currently not a huge issue to keep the `binaryPath` naming even though it perhaps isn't as accurate for my web driver, so I'm also happy to punt on that until the next major version is on its way.

Let me know how you'd like to proceed!
rotemmiz: Hhy @awinograd, seems like a few unit tests failed, and coverage is missing as well.
awinograd: @rotemmiz, tests + coverage fixed
rotemmiz: Hey @awinograd, sorry for the delay here, I was trying to think and consult on how to proceed here.
So the things we think are still missing to make sure we handle this PR correctly are the following:
1. A new section in documentation, explaining how to create such a plugin, and most importantly, a section about how to use one.
2. A good integration test to make sure we don't break this on the next commit we make for Detox. So I suggest you add another project (either in examples dir on someplace else, but we just need to make sure it runs in CI as well), and add a very simple setup (a package.json, short e2e test, and an empty stub of a driver that just returns resolved promises on anything).

WDYT?
awinograd: @rotemmiz no need to apologize! I appreciate your guidance through the PR process.

1. Yes I can write up a doc on custom drivers
1. Do you want me to configure the setup on circleci, or would a script in `examples/custom-driver/package.json` be sufficient for you to take it and get it on circleci?
rotemmiz: Although it makes little sense to add the project to one of the platform specific test project CI builds, I believe it will be the fastest and easiest, and you'll be able to do it independently.

Try adding your new project to the list here, and see
https://github.com/wix/Detox/blob/master/scripts/demo-projects.android.sh

awinograd: @rotemmiz, sorry for the delay in getting back to this. I just pushed an example project (🤞 that the CI passes!) 

Still need to work on the Doc, but wanted you to take a look at f014adc as I'm not happy with what i've done there. It's related to our discussion above (https://github.com/wix/Detox/pull/1919#issuecomment-589166755) about `binaryPath`. The old testing code assumes that `prepare` will throw if the binary path doesn't exist on disk which is an issue when binaryPath is a URL. I wasn't sure what the best path forward is, so just pushed something that passes but is sort of a hacky workaround. Open to suggestions there
awinograd: Just put up a first pass at a new Guide around Third party drivers. I'm curious to know what areas you think may be interesting to include that i've left off. As of now, it's a pretty high level summary.
awinograd: The CI is failing due to

> 19:05:05 /home/jenkins/.jenkins/workspace/detox-demo-projects-android-59-9-pr/examples/demo-puppeteer/node_modules/puppeteer/.local-chromium/linux-722234/chrome-linux/chrome: error while loading shared libraries: libXss.so.1: cannot open shared object file: No such file or directory

I see two potential solutions:

1. Install the missing package(s)
1. Change the demo to use a trivial, noop driver rather than `detox-puppeteer`

Let me know which direction you prefer. If it's (1), I'll need help
awinograd: I went ahead an chose path (2) since it seemed like simplest way to test the actual integration without worrying about the internal plugin details. If you'd like to go down path (1) and restore `examples/demo-puppeteer` let me know. It's just a simple `revert` away
awinograd: Most recent test failure seems unrelated to my changes
rotemmiz: Good call going with 2, we wouldn't want detox CI to be more complex than what it already is, this demo you added is there only to showcase the API and make sure we don't break it unintentionally
rotemmiz: Though the first error is probably not related (couldn't found the cause scrolling with my phone), the second (in demo projects build)  looks related to your work 

awinograd: @rotemmiz , you're right the 2nd error is related to my test. It looks like it got introduced when merging in master since the detox major version was bumped and `demo-plugin` was installing `detox` from npm rather than as a symlink via lerna.

🤞 this next run passes. It's working locally for me
awinograd: Woohoo! Green tests 😄 
awinograd: @rotemmiz , just merged master to resolve conflicts. Anything left to do to get this PR wrapped up?
awinograd: doesn't seem as if test failures are related to me, but could be wrong
rotemmiz: Not sure where this comes from, retriggered your build
awinograd: I see a new coveralls ci process has been added to the PR and has failed. https://coveralls.io/builds/29961248 

Should I be paying attention to it?
noomorph: @awinograd, thanks for telling. I'll look into it.
awinograd: @rotemmiz thanks for the review! I've done a first pass at addressing your comments
rotemmiz: Can't yet understand why all builds failed, I will try figuring it out tomorrow morning.
Also, do we still need the  checkBinaryPath flag in function calls?
awinograd: @rotemmiz just updated the PR to remove the remaining reference to `checkBinaryPath`. unit tests are passing locally for me.

thanks for taking a look at the remaining failures.
noomorph: @awinograd, the issue with Coveralls was finally solved - could you please merge `master` in?
awinograd: @noomorph . Sure thing. Just merged master
awinograd: Coveralls ran but failed here. 

Jenkins CI is green again!
noomorph: @awinograd, thanks for reporting. I have found why the coverage was below 70ish% and pushed the fix. Could you please merge `master` again? Now it should pass the required coverage.
awinograd: merged!
d4vidi: Just sayin'... https://github.com/wix/Detox/pull/1919/files#r407250431 😄 

Patch:
diff --git a/detox/package.json b/detox/package.json
index a6d33845e..8e901c71a 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -114,7 +114,15 @@
     ],
     "resetMocks": true,
     "resetModules": true,
-    "coverageReporters": [["lcov", {"projectRoot": ".." }], "html"],
+    "coverageReporters": [
+      [
+        "lcov",
+        {
+          "projectRoot": ".."
+        }
+      ],
+      "html"
+    ],
     "coverageThreshold": {
       "global": {
         "statements": 100,
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index 31479ceff..02bef4e54 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -152,7 +152,14 @@ class Detox {
     this._client.setNonresponsivenessListener(this._onNonresnponsivenessEvent.bind(this));
     await this._client.connect();
 
-    const DeviceDriverClass = DEVICE_CLASSES[this._deviceConfig.type];
+    let DeviceDriverClass = DEVICE_CLASSES[this._deviceConfig.type];
+    if (!DeviceDriverClass) {
+      try {
+        DeviceDriverClass = require(this._deviceConfig.type);
+      } catch (e) {
+        // noop, if we don't find a module to require, we'll hit the unsupported error below
+      }
+    }
     if (!DeviceDriverClass) {
       throw new Error(`'${this._deviceConfig.type}' is not supported`);
     }
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index 288131d2d..cf79540ba 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -2,7 +2,7 @@ const _ = require('lodash');
 const fs = require('fs');
 const path = require('path');
 const argparse = require('../utils/argparse');
-const debug = require('../utils/debug'); //debug utils, leave here even if unused
+const debug = require('../utils/debug'); // debug utils, leave here even if unused
 
 class Device {
   constructor({ deviceConfig, deviceDriver, emitter, sessionConfig }) {
@@ -16,16 +16,14 @@ class Device {
   }
 
   async prepare(params = {}) {
-    this._binaryPath = this._getAbsolutePath(this._deviceConfig.binaryPath);
-    this._testBinaryPath = this._deviceConfig.testBinaryPath ? this._getAbsolutePath(this._deviceConfig.testBinaryPath) : null;
     this._deviceId = await this.deviceDriver.acquireFreeDevice(this._deviceConfig.device || this._deviceConfig.name);
-    this._bundleId = await this.deviceDriver.getBundleIdFromBinary(this._binaryPath);
+    this._bundleId = await this.deviceDriver.getBundleIdFromBinary(this._deviceConfig.binaryPath);
 
     await this.deviceDriver.prepare();
 
     if (!argparse.getArgValue('reuse') && !params.reuse) {
       await this.deviceDriver.uninstallApp(this._deviceId, this._bundleId);
-      await this.deviceDriver.installApp(this._deviceId, this._binaryPath, this._testBinaryPath);
+      await this.deviceDriver.installApp(this._deviceId, this._deviceConfig.binaryPath, this._deviceConfig.testBinaryPath);
     }
 
     if (params.launchApp) {
@@ -184,8 +182,8 @@ class Device {
   }
 
   async installApp(binaryPath, testBinaryPath) {
-    const _binaryPath = binaryPath || this._binaryPath;
-    const _testBinaryPath = testBinaryPath || this._testBinaryPath;
+    const _binaryPath = binaryPath || this._deviceConfig.binaryPath;
+    const _testBinaryPath = testBinaryPath || this._deviceConfig.testBinaryPath;
     await this.deviceDriver.installApp(this._deviceId, _binaryPath, _testBinaryPath);
   }
 
@@ -306,19 +304,6 @@ class Device {
     return launchArgs;
   }
 
-  _getAbsolutePath(appPath) {
-    if (path.isAbsolute(appPath)) {
-      return appPath;
-    }
-
-    const absPath = path.join(process.cwd(), appPath);
-    if (fs.existsSync(absPath)) {
-      return absPath;
-    } else {
-      throw new Error(`app binary not found at '${absPath}', did you build it?`);
-    }
-  }
-
   async _terminateApp() {
     await this.deviceDriver.terminate(this._deviceId, this._bundleId);
     this._processes[this._bundleId] = undefined;
@@ -326,7 +311,7 @@ class Device {
 
   async _reinstallApp() {
     await this.deviceDriver.uninstallApp(this._deviceId, this._bundleId);
-    await this.deviceDriver.installApp(this._deviceId, this._binaryPath, this._testBinaryPath);
+    await this.deviceDriver.installApp(this._deviceId, this._deviceConfig.binaryPath, this._deviceConfig.testBinaryPath);
   }
 }
 
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index 7e07e1bfd..aa1dd601f 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -24,6 +24,7 @@ const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const sleep = require('../../../utils/sleep');
 const retry = require('../../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../../utils/exec');
+const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
 const AndroidExpect = require('../../../android/expect');
 const { InstrumentationLogsParser } = require('./InstrumentationLogsParser');
 
@@ -63,12 +64,13 @@ class AndroidDriver extends DeviceDriverBase {
   }
 
   async getBundleIdFromBinary(apkPath) {
-    return await this.aapt.getPackageName(apkPath);
+    return await this.aapt.getPackageName(getAbsoluteBinaryPath(apkPath));
   }
 
   async installApp(deviceId, binaryPath, testBinaryPath) {
+    binaryPath = getAbsoluteBinaryPath(binaryPath);
     await this.adb.install(deviceId, binaryPath);
-    await this.adb.install(deviceId, testBinaryPath ? testBinaryPath : this.getTestApkPath(binaryPath));
+    await this.adb.install(deviceId, testBinaryPath ? getAbsoluteBinaryPath(testBinaryPath) : this.getTestApkPath(binaryPath));
   }
 
   async pressBack(deviceId) {
diff --git a/detox/src/devices/drivers/ios/SimulatorDriver.js b/detox/src/devices/drivers/ios/SimulatorDriver.js
index 36e135d56..c379a68f2 100644
--- a/detox/src/devices/drivers/ios/SimulatorDriver.js
+++ b/detox/src/devices/drivers/ios/SimulatorDriver.js
@@ -9,6 +9,7 @@ const configuration = require('../../../configuration');
 const DetoxRuntimeError = require('../../../errors/DetoxRuntimeError');
 const environment = require('../../../utils/environment');
 const argparse = require('../../../utils/argparse');
+const getAbsoluteBinaryPath = require('../../../utils/getAbsoluteBinaryPath');
 
 class SimulatorDriver extends IosDriver {
 
@@ -56,6 +57,7 @@ class SimulatorDriver extends IosDriver {
   }
 
   async getBundleIdFromBinary(appPath) {
+    appPath = getAbsoluteBinaryPath(appPath);
     try {
       const result = await exec(`/usr/libexec/PlistBuddy -c "Print CFBundleIdentifier" "${path.join(appPath, 'Info.plist')}"`);
       const bundleId = _.trim(result.stdout);
@@ -75,7 +77,7 @@ class SimulatorDriver extends IosDriver {
   }
 
   async installApp(deviceId, binaryPath) {
-    await this.applesimutils.install(deviceId, binaryPath);
+    await this.applesimutils.install(deviceId, getAbsoluteBinaryPath(binaryPath));
   }
 
   async uninstallApp(deviceId, bundleId) {
diff --git a/detox/src/utils/getAbsoluteBinaryPath.js b/detox/src/utils/getAbsoluteBinaryPath.js
new file mode 100644
index 000000000..b5d32daf9
--- /dev/null
+++ b/detox/src/utils/getAbsoluteBinaryPath.js
@@ -0,0 +1,17 @@
+const fs = require('fs');
+const path = require('path');
+
+function getAbsoluteBinaryPath(appPath) {
+  if (path.isAbsolute(appPath)) {
+    return appPath;
+  }
+
+  const absPath = path.join(process.cwd(), appPath);
+  if (fs.existsSync(absPath)) {
+    return absPath;
+  } else {
+    throw new Error(`app binary not found at '${absPath}', did you build it?`);
+  }
+}
+
+module.exports = getAbsoluteBinaryPath;
diff --git a/docs/APIRef.DetoxCLI.md b/docs/APIRef.DetoxCLI.md
index aba0bebbe..f5d8a4409 100644
--- a/docs/APIRef.DetoxCLI.md
+++ b/docs/APIRef.DetoxCLI.md
@@ -77,7 +77,7 @@ Initiating your test suite. <sup>[[1]](#notice-passthrough)</sup>
 | --record-timeline [all/none] | [Jest Only] Record tests and events timeline, for visual display on the [chrome://tracing](chrome://tracing) tool. The default value is **none**. |
 | -r, --reuse                                   | Reuse existing installed app (do not delete + reinstall) for a faster run. |
 | -u, --cleanup                                 | Shutdown simulator when test is over, useful for CI scripts, to make sure detox exists cleanly with no residue |
-| -w, --workers                                 | [iOS Only] Specifies number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest). *Note: For workers > 1, Jest's spec-level reporting is disabled, by default (can be overridden using --jest-report-specs).* |
+| -w, --workers                                 | Specifies number of workers the test runner should spawn, requires a test runner with parallel execution support (Detox CLI currently supports Jest). *Note: For workers > 1, Jest's spec-level reporting is disabled, by default (can be overridden using --jest-report-specs).* |
 | --jest-report-specs | [Jest Only] Whether to output logs per each running spec, in real-time. By default, disabled with multiple workers. |
 | -H, --headless                                | [Android Only] Launch Emulator in headless mode. Useful when running on CI. |
 | --gpu                                         | [Android Only] Launch Emulator with the specific -gpu [gpu mode] parameter. |
diff --git a/docs/Guide.ThirdPartyDrivers.md b/docs/Guide.ThirdPartyDrivers.md
new file mode 100644
index 000000000..3a90197a5
--- /dev/null
+++ b/docs/Guide.ThirdPartyDrivers.md
@@ -0,0 +1,62 @@
+# Third Party Drivers
+
+Detox comes with built-in support for running on Android and iOS by choosing a driver type in your detox configurations. For example, the following configuration uses the "ios.simulator" driver.
+
+```
+"ios.sim": {
+  "binaryPath": "bin/YourApp.app",
+  "type": "ios.simulator",
+}
+```
+
+While React Native officially supports Android and iOS, other platforms such as
+[Web](https://github.com/necolas/react-native-web) and [Windows](https://github.com/microsoft/react-native-windows)
+can be targeted. If your app targets a third party platform, you may which to use a [third party driver](#How-to-use-a-driver) to run your tests on said platform as of Detox v16.3.0. If one doesn't already exist you may want to [write your own](#Writing-a-new-driver)
+
+## How to use a driver
+
+Check to see if a [third party driver](#Existing-Third-party-drivers) already exists for the platform you wish to target. Mostly likely, the driver will have setup instructions. Overall the setup for any third party driver is fairly simple.
+
+1. Add the driver to your `package.json` withh `npm install --save-dev detox-driver-package` or `yarn add --dev detox-driver-package`
+1. Add a new detox configuration to your existing configurations with the `type` set to driver's package name.
+```
+"thirdparty.driver.config": {
+  "binaryPath": "bin/YourApp.app",
+  "type": "detox-driver-package",
+}
+```
+3. Run detox while specifying the name of your new configuration `detox test --configuration detox-driver-package`
+
+## Writing a new driver
+
+### Anatomy of a driver
+
+The architecture of a driver is split into a few different pieces. Understanding the [overall architecture of Detox](https://github.com/wix/Detox/blob/master/docs/Introduction.HowDetoxWorks.md#architecture) will help with this section
+
+1. The Device Driver - code runs on the Detox tester, within the test runner context. It implements the details for the
+[`device` object](https://github.com/wix/Detox/blob/master/docs/APIRef.DeviceObjectAPI.md) that is exposed in your detox tests. The implementation is responsible for managing device clients your tests will run on.
+1. Matchers - code powering the `expect` `element` `waitFor` and `by` globals in your tests.
+These helpers serialize your test code so they can be sent over the network to the device on which your tests are running.
+1. Driver Client - code running on the device being tested. The driver client communicates with the server over
+websocket where it receives information from the serialized matchers, and expectations, and also sends responses
+back of whether each step of your test succeeds or fails. Typically a device client will use an underlying library specific
+to the platform at hand to implement the expectations.
+
+### Implementation details
+
+You may want to read through the source of both the built-in, official drivers as well as
+existing third party drivers to get a sense of how the code is structured. You can also look at
+`examples/demo-plugin/driver.js` for a minimal driver implementation that doesn't really do anything
+useful. Your driver should extend `DeviceDriverBase` and export as `module.exports`.
+
+```
+const DeviceDriverBase = require('detox/src/devices/drivers/DeviceDriverBase');
+class MyNewDriver extends DeviceDriverBase {
+ // ...
+}
+module.exports = MyNewDriver;
+```
+
+## Existing Third party drivers
+
+* [detox-puppeteer](https://github.com/ouihealth/detox-puppeteer)
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index 64a3c8ed5..604a7aa74 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 00dbe2687..9cffb638f 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "devDependencies": {
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-plugin/driver.js b/examples/demo-plugin/driver.js
new file mode 100644
index 000000000..ea236d715
--- /dev/null
+++ b/examples/demo-plugin/driver.js
@@ -0,0 +1,145 @@
+const DeviceDriverBase = require('detox/src/devices/drivers/DeviceDriverBase');
+const Client = require('detox/src/client/Client');
+
+class Expect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+
+    this.by = {
+      accessibilityLabel: (value) => {},
+      label: (value) => {},
+      id: (value) => value,
+      type: (value) => {},
+      traits: (value) => {},
+      value: (value) => {},
+      text: (value) => {},
+    };
+
+    this.element = this.element.bind(this);
+    this.expect = this.expect.bind(this);
+    this.waitFor = this.waitFor.bind(this);
+  }
+
+  expect(element) {
+    return {
+      toBeVisible: () => {
+        if (!element) {
+          throw new Error("Expectation failed");
+        }
+      }
+    }
+  }
+
+  element(matcher) {
+    return matcher === 'welcome';
+  }
+
+  waitFor(element) {
+  }
+}
+
+class LoginTestee {
+  constructor(sessionId) {
+    this.type = 'login';
+    this.params = { sessionId, role: 'testee' };
+    this.messageId;
+  }
+  async handle(response) {
+    if (response.type !== 'loginSuccess') throw new Error('Unexpected response type');
+  }
+}
+
+class PluginTestee {
+  constructor(config) {
+    this.configuration = config.client.configuration;
+    this.client = new Client(this.configuration);
+  }
+
+  async connect() {
+    await this.client.ws.open();
+
+    // NOTE: This is a sample way to handle events in a custom Testee client, but not needed
+    // for the test suite
+    // this.client.ws.ws.on('message', async (str) => {
+    //   const sendResponse = async (response) => {
+    //     this.client.ws.ws.send(JSON.stringify(response));
+    //   };
+
+    //   const action = JSON.parse(str);
+    //   const messageId = action.messageId;
+    //   if (!action.type) {
+    //     return;
+    //   }
+    //   if (action.type === 'loginSuccess') {
+    //     return;
+    //   } else if (action.type === 'deliverPayload') {
+    //     await sendResponse({
+    //       type: 'deliverPayloadDone',
+    //       messageId: action.messageId,
+    //     });
+    //   } else if (action.type === 'currentStatus') {
+    //     await sendResponse(
+    //       { type: 'currentStatusResult', params: { resources: [] } }
+    //     );
+    //   } else {
+    //     try {
+    //       await sendResponse({
+    //         type: 'invokeResult',
+    //         messageId: action.messageId,
+    //       });
+    //     } catch (error) {
+    //       this.client.ws.ws.send(
+    //         JSON.stringify({
+    //           type: 'testFailed',
+    //           messageId,
+    //           params: { details: str + '\n' + error.message },
+    //         }),
+    //       );
+    //     }
+    //   }
+    // });
+
+    await this.client.sendAction(new LoginTestee(this.configuration.sessionId));
+  }
+}
+
+class PluginDriver extends DeviceDriverBase {
+  constructor(config) {
+    super(config);
+    this.matchers = {};
+    this.testee = new PluginTestee(config);
+    this.matchers = new Expect();
+  }
+
+  async launchApp(deviceId, bundleId, launchArgs, languageAndLocale) {
+    await this.emitter.emit('beforeLaunchApp', {
+      bundleId,
+      deviceId,
+      launchArgs,
+    });
+
+    const pid = 'PID';
+    await this.emitter.emit('launchApp', {
+      bundleId,
+      deviceId,
+      launchArgs,
+      pid,
+    });
+
+    return pid;
+  }
+
+
+  validateDeviceConfig(deviceConfig) {
+    this.deviceConfig = deviceConfig;
+    if (!deviceConfig.binaryPath) {
+      configuration.throwOnEmptyBinaryPath();
+    }
+  }
+
+  async waitUntilReady() {
+    await this.testee.connect();
+  }
+}
+
+module.exports = PluginDriver;
diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json
new file mode 100644
index 000000000..c7de8effc
--- /dev/null
+++ b/examples/demo-plugin/package.json
@@ -0,0 +1,25 @@
+{
+  "name": "demo-plugin",
+  "version": "15.1.4",
+  "private": true,
+  "scripts": {
+    "test:plugin": "detox test --configuration plugin -l verbose"
+  },
+  "devDependencies": {
+    "detox": "^16.0.0",
+    "jest": "24.8.x"
+  },
+  "detox": {
+    "test-runner": "jest",
+    "configurations": {
+      "plugin": {
+        "binaryPath": "my/random/path",
+        "device": {
+          "foo": "bar"
+        },
+        "name": "plugin-example",
+        "type": "../../examples/demo-plugin/driver"
+      }
+    }
+  }
+}
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index 4f0df2216..7b55addc8 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 76afd0ad6..15b23e1a3 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,6 +1,6 @@
 {
   "name": "demo-react-native-jest",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "scripts": {
     "test:ios-release": "detox test --configuration ios.sim.release -l verbose",
@@ -13,7 +13,7 @@
     "test:jest-circus:android-release-ci": "detox test --configuration android.emu.release -o e2e/config-circus.json -l verbose --workers 2 --headless --record-logs all --take-screenshots all"
   },
   "devDependencies": {
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "jest": "25.1.x",
     "jest-circus": "25.1.x",
     "sanitize-filename": "^1.6.1"
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index ce585a88a..c4252f217 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -23,7 +23,7 @@
     "react-native": "0.62.x"
   },
   "devDependencies": {
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/lerna.json b/lerna.json
index b8b260600..9bd8d579f 100644
--- a/lerna.json
+++ b/lerna.json
@@ -9,15 +9,17 @@
     "examples/demo-react-native",
     "examples/demo-react-native-detox-instruments",
     "examples/demo-react-native-jest",
+    "examples/demo-plugin",
     "generation",
     "."
   ],
-  "version": "16.2.0",
+  "version": "16.2.1",
   "npmClient": "npm",
   "command": {
     "publish": {
       "ignoreChanges": [
         "demo-react-native-jest",
+        "demo-plugin",
         "examples/demo-react-native-detox-instruments",
         "detox-demo-native-android",
         "detox-demo-native-ios",
diff --git a/package.json b/package.json
index 4dd0b297c..302474b7d 100644
--- a/package.json
+++ b/package.json
@@ -18,5 +18,5 @@
     "semver": "5.x.x",
     "shell-utils": "1.x.x"
   },
-  "version": "16.2.0"
+  "version": "16.2.1"
 }
diff --git a/scripts/demo-projects.android.sh b/scripts/demo-projects.android.sh
index 773041a57..6738ab5a0 100755
--- a/scripts/demo-projects.android.sh
+++ b/scripts/demo-projects.android.sh
@@ -24,3 +24,7 @@ pushd examples/demo-react-native
 run_f "npm run test:android-release-ci"
 run_f "npm run test:android-explicit-require-ci"
 popd
+
+pushd examples/demo-plugin
+run_f "npm run test:plugin"
+popd


Test patch:
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index 1f2ef48bb..c65f4e021 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -259,6 +259,29 @@ describe('Detox', () => {
     }
   });
 
+  it('properly instantiates configuration pointing to a plugin driver', async () => {
+    let instantiated = false;
+    class MockDriverPlugin {
+      constructor(config) {
+        instantiated = true;
+      }
+      on() {}
+      declareArtifactPlugins() {}
+    }
+    jest.mock('driver-plugin', () => MockDriverPlugin, { virtual: true });
+    const pluginDeviceConfig = {
+      "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/example.app",
+      "type": "driver-plugin",
+      "name": "MyPlugin"
+    };
+
+    Detox = require('./Detox');
+    detox = new Detox({deviceConfig: pluginDeviceConfig});
+    await detox.init();
+
+    expect(instantiated).toBe(true);
+  });
+
   it(`should log EMIT_ERROR if the internal emitter throws an error`, async () => {
     Detox = require('./Detox');
     detox = new Detox({deviceConfig: validDeviceConfigWithSession});
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index eb2369183..42fddef50 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -125,22 +125,6 @@ describe('Device', () => {
   });
 
   describe('prepare()', () => {
-    it(`valid scheme, no binary, should throw`, async () => {
-      const device = validDevice();
-      fs.existsSync.mockReturnValue(false);
-      try {
-        await device.prepare();
-        fail('should throw')
-      } catch (ex) {
-        expect(ex.message).toMatch(/app binary not found at/)
-      }
-    });
-
-    it(`valid scheme, no binary, should not throw`, async () => {
-      const device = validDevice();
-      await device.prepare();
-    });
-
     it(`when reuse is enabled in CLI args should not uninstall and install`, async () => {
       const device = validDevice();
       argparse.getArgValue.mockReturnValue(true);
@@ -478,7 +462,7 @@ describe('Device', () => {
 
       await device.installApp('newAppPath');
 
-      expect(driverMock.driver.installApp).toHaveBeenCalledWith(device._deviceId, 'newAppPath', undefined);
+      expect(driverMock.driver.installApp).toHaveBeenCalledWith(device._deviceId, 'newAppPath', device._deviceConfig.testBinaryPath);
     });
 
     it(`with a custom test app path should use custom test app path`, async () => {
@@ -494,7 +478,7 @@ describe('Device', () => {
 
       await device.installApp();
 
-      expect(driverMock.driver.installApp).toHaveBeenCalledWith(device._deviceId, device._binaryPath, device._testBinaryPath);
+      expect(driverMock.driver.installApp).toHaveBeenCalledWith(device._deviceId, device._deviceConfig.binaryPath, device._deviceConfig.testBinaryPath);
     });
   });
 
@@ -512,7 +496,7 @@ describe('Device', () => {
 
       await device.uninstallApp();
 
-      expect(driverMock.driver.uninstallApp).toHaveBeenCalledWith(device._deviceId, device._binaryPath);
+      expect(driverMock.driver.uninstallApp).toHaveBeenCalledWith(device._deviceId, device._bundleId);
     });
   });
 
@@ -759,7 +743,7 @@ describe('Device', () => {
 
   it(`should accept relative path for binary`, async () => {
     const actualPath = await launchAndTestBinaryPath('relativePath');
-    expect(actualPath).toEqual(path.join(process.cwd(), 'abcdef/123'));
+    expect(actualPath).toEqual('abcdef/123');
   });
 
   it(`pressBack() should invoke driver's pressBack()`, async () => {
diff --git a/detox/src/utils/getAbsoluteBinaryPath.test.js b/detox/src/utils/getAbsoluteBinaryPath.test.js
new file mode 100644
index 000000000..ba6d4c82b
--- /dev/null
+++ b/detox/src/utils/getAbsoluteBinaryPath.test.js
@@ -0,0 +1,19 @@
+const fs = require('fs');
+const path = require('path');
+const getAbsoluteBinaryPath = require('./getAbsoluteBinaryPath');
+
+describe('getAbsoluteBinaryPath', () => {
+  it('should return the given path if it is already absolute', async () => {
+    expect(getAbsoluteBinaryPath('/my/absolute/path')).toEqual('/my/absolute/path');
+  });
+
+  it('should return an absolute path if a relative path is passed in', async () => {
+    expect(getAbsoluteBinaryPath('src/utils/getAbsoluteBinaryPath.js')).toEqual(path.join(process.cwd(), 'src/utils/getAbsoluteBinaryPath.js'));
+  });
+
+  it('should throw exception if resulting absolute path does not exist', async () => {
+    expect(() => getAbsoluteBinaryPath('my/relative/path'))
+      .toThrowError();
+  });
+});
+
diff --git a/detox/test/package.json b/detox/test/package.json
index f3d46eb07..e30b33040 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "16.2.0",
+  "version": "16.2.1",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -36,7 +36,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^16.2.0",
+    "detox": "^16.2.1",
     "express": "^4.15.3",
     "jest": "25.3.x",
     "jest-circus": "25.3.x",
diff --git a/examples/demo-plugin/e2e/app-hello.test.js b/examples/demo-plugin/e2e/app-hello.test.js
new file mode 100644
index 000000000..034b00a34
--- /dev/null
+++ b/examples/demo-plugin/e2e/app-hello.test.js
@@ -0,0 +1,15 @@
+describe('Example (hello)', () => {
+  it('should have welcome', async () => {
+    await expect(element(by.id('welcome'))).toBeVisible();
+  });
+
+  it('should not have unwelcome', async () => {
+    let success = true;
+    try {
+      await expect(element(by.id('unwelcome'))).toBeVisible();
+      success = false;
+    } catch (e) {}
+
+    if (!success) throw new Error("test failed");
+  });
+});
diff --git a/examples/demo-plugin/e2e/config.json b/examples/demo-plugin/e2e/config.json
new file mode 100644
index 000000000..523800e2d
--- /dev/null
+++ b/examples/demo-plugin/e2e/config.json
@@ -0,0 +1,6 @@
+{
+  "setupFilesAfterEnv": ["./init.js"],
+  "testEnvironment": "node",
+  "reporters": ["detox/runners/jest/streamlineReporter"],
+  "verbose": true
+}
diff --git a/examples/demo-plugin/e2e/init.js b/examples/demo-plugin/e2e/init.js
new file mode 100644
index 000000000..1d63d35bb
--- /dev/null
+++ b/examples/demo-plugin/e2e/init.js
@@ -0,0 +1,31 @@
+const detox = require('detox');
+const config = require('../package.json').detox;
+const adapter = require('detox/runners/jest/adapter');
+const specReporter = require('detox/runners/jest/specReporter');
+const assignReporter = require('detox/runners/jest/assignReporter');
+
+jasmine.getEnv().addReporter(adapter);
+
+// This takes care of generating status logs on a per-spec basis. By default, jest only reports at file-level.
+// This is strictly optional.
+jasmine.getEnv().addReporter(specReporter);
+
+// This will post which device has assigned to run a suite, which can be useful in a multiple-worker tests run.
+// This is strictly optional.
+jasmine.getEnv().addReporter(assignReporter);
+
+// Set the default timeout
+jest.setTimeout(90000);
+
+beforeAll(async () => {
+  await detox.init(config);
+}, 300000);
+
+beforeEach(async () => {
+  await adapter.beforeEach();
+});
+
+afterAll(async () => {
+  await adapter.afterAll();
+  await detox.cleanup();
+});