From c878093a6d848e355777f7507cafc56e9edfbb6b Mon Sep 17 00:00:00 2001 From: Alec Winograd Date: Wed, 22 Apr 2020 11:51:30 -0500 Subject: [PATCH] Custom driver plugin support(#1919) * Add dynamic require for driver plugins * Push absolute path logic into driver layer https://github.com/wix/Detox/pull/1919#issuecomment-593073179 * fix existing tests to use new internal implementation details in assertions also fix code coverage * Add test for support of driver plugin * Add demo-puppeteer * Fix tests Need to maintain 100% coverage while not breaking prepare for drivers whose binaryPath isn't an actual path on disk * Create Guide.ThirdPartyDrivers.md * Use headless driver for jenkins compatibility * Add demo-plugin project * Remove demo-puppeteer * bump detox to v16 in examples/demo-plugin * remove demo-puppeteer from lerna.json * Update README with clarifications * Add getAbsoluteBinaryPath.test.js to avoid testing it's logic in Device.test.js * remove checkBinaryPath argument --- detox/src/Detox.js | 9 +- detox/src/Detox.test.js | 23 +++ detox/src/devices/Device.js | 27 +--- detox/src/devices/Device.test.js | 24 +-- .../devices/drivers/android/AndroidDriver.js | 6 +- .../devices/drivers/ios/SimulatorDriver.js | 4 +- detox/src/utils/getAbsoluteBinaryPath.js | 17 ++ detox/src/utils/getAbsoluteBinaryPath.test.js | 19 +++ docs/Guide.ThirdPartyDrivers.md | 62 ++++++++ examples/demo-plugin/driver.js | 145 ++++++++++++++++++ examples/demo-plugin/e2e/app-hello.test.js | 15 ++ examples/demo-plugin/e2e/config.json | 6 + examples/demo-plugin/e2e/init.js | 31 ++++ examples/demo-plugin/package.json | 25 +++ lerna.json | 2 + scripts/demo-projects.android.sh | 4 + 16 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 detox/src/utils/getAbsoluteBinaryPath.js create mode 100644 detox/src/utils/getAbsoluteBinaryPath.test.js create mode 100644 docs/Guide.ThirdPartyDrivers.md create mode 100644 examples/demo-plugin/driver.js create mode 100644 examples/demo-plugin/e2e/app-hello.test.js create mode 100644 examples/demo-plugin/e2e/config.json create mode 100644 examples/demo-plugin/e2e/init.js create mode 100644 examples/demo-plugin/package.json diff --git a/detox/src/Detox.js b/detox/src/Detox.js index 31479ceff3..02bef4e54e 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/Detox.test.js b/detox/src/Detox.test.js index 1f2ef48bb5..c65f4e0215 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.js b/detox/src/devices/Device.js index 288131d2d6..cf79540bac 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/Device.test.js b/detox/src/devices/Device.test.js index eb23691834..42fddef50c 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/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js index 7e07e1bfde..aa1dd601f9 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 36e135d56f..c379a68f22 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 0000000000..b5d32daf9e --- /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/detox/src/utils/getAbsoluteBinaryPath.test.js b/detox/src/utils/getAbsoluteBinaryPath.test.js new file mode 100644 index 0000000000..ba6d4c82b6 --- /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/docs/Guide.ThirdPartyDrivers.md b/docs/Guide.ThirdPartyDrivers.md new file mode 100644 index 0000000000..3a90197a51 --- /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-plugin/driver.js b/examples/demo-plugin/driver.js new file mode 100644 index 0000000000..ea236d715d --- /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/e2e/app-hello.test.js b/examples/demo-plugin/e2e/app-hello.test.js new file mode 100644 index 0000000000..034b00a34f --- /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 0000000000..523800e2d2 --- /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 0000000000..1d63d35bb8 --- /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(); +}); diff --git a/examples/demo-plugin/package.json b/examples/demo-plugin/package.json new file mode 100644 index 0000000000..c7de8effc8 --- /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/lerna.json b/lerna.json index d09f0f36c6..9bd8d579fd 100644 --- a/lerna.json +++ b/lerna.json @@ -9,6 +9,7 @@ "examples/demo-react-native", "examples/demo-react-native-detox-instruments", "examples/demo-react-native-jest", + "examples/demo-plugin", "generation", "." ], @@ -18,6 +19,7 @@ "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/scripts/demo-projects.android.sh b/scripts/demo-projects.android.sh index 773041a574..6738ab5a05 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