chore(execute-driver-plugin): import execute-driver-plugin
yeah this is my bad.  if we really need the history I can redo this.
no significant changes; just the usual stuff about builds and types.
bonus: I figured out how to get webdriverio types working
MODIFY
package.json
package.json
"@appium/test-support": "file:packages/test-support",
"@appium/types": "file:packages/types",
"@appium/universal-xml-plugin": "file:packages/universal-xml-plugin",
+ "@appium/execute-driver-plugin": "file:packages/execute-driver-plugin",
"appium": "file:packages/appium"
},
"devDependencies": {

ADD
packages/execute-driver-plugin/index.js
+module.exports = require('./build/lib/plugin');

ADD
packages/execute-driver-plugin/lib/execute-child.js
+import _ from 'lodash';
+import B from 'bluebird';
+import { NodeVM } from 'vm2';
+import { logger, util } from '@appium/support';
+import { attach } from 'webdriverio';
+
+const log = logger.getLogger('ExecuteDriver Child');
+let send;
+
+// duplicate defining these keys here so we don't need to re-load a huge appium
+// dependency tree into memory just to run a wdio script
+export const W3C_ELEMENT_KEY = util.W3C_WEB_ELEMENT_IDENTIFIER;
+export const MJSONWP_ELEMENT_KEY = 'ELEMENT';
+
+async function runScript (driverOpts, script, timeoutMs) {
+ if (!_.isNumber(timeoutMs)) {
+ throw new TypeError('Timeout parameter must be a number');
+ }
+
+ // set up fake logger
+ const logLevels = ['error', 'warn', 'log'];
+ const logs = {};
+ const consoleFns = {};
+ for (const level of logLevels) {
+ logs[level] = [];
+ consoleFns[level] = (...logMsgs) => logs[level].push(...logMsgs);
+ }
+
+ const driver = await attach(driverOpts);
+
+ const fullScript = buildScript(script);
+
+ log.info('Running driver script in Node vm');
+
+ const vmCtx = new NodeVM({timeout: timeoutMs});
+ const vmFn = vmCtx.run(fullScript);
+
+ // run the driver script, giving user access to the driver object, a fake
+ // console logger, and a promise library
+ let result = await vmFn(driver, consoleFns, B);
+
+ result = coerceScriptResult(result);
+ log.info('Successfully ensured driver script result is appropriate type for return');
+ return {result, logs};
+}
+
+/**
+ * Embed a user-generated script inside a method which takes only the
+ * predetermined objects we specify
+ *
+ * @param {string} script - the javascript to execute
+ *
+ * @return {string} - the full script to execute
+ */
+function buildScript (script) {
+ return `module.exports = async function execute (driver, console, Promise) {
+ ${script}
+ }`;
+}
+
+/**
+ * We can get any manner of crazy thing back from a vm executing untrusted
+ * code. We might also get WebdriverIO objects that aren't suitable for JSON
+ * response. So make sure we convert the things we know about to their
+ * appropriate response format, and squash other weird things.
+ *
+ * @param {Object} obj - object to convert and sanitize
+ *
+ * @return {Object} - safely converted object
+ */
+function coerceScriptResult (obj) {
+ // first ensure obj is of a type that can be JSON encoded safely. This will
+ // get rid of custom objects, functions, etc... and turn them into POJOs
+ try {
+ obj = JSON.parse(JSON.stringify(obj));
+ } catch (e) {
+ log.warn('Could not convert executeDriverScript to safe response!' +
+ `Result was: ${JSON.stringify(obj)}. Will make it null`);
+ return null;
+ }
+
+ let res;
+
+ // now we begin our recursive case options
+ if (_.isPlainObject(obj)) {
+ // if we have an object, it's either an element object or something else
+ // webdriverio has no monadic object types other than element and driver,
+ // and we don't want to allow special casing return of driver
+ res = {};
+
+ if (obj[MJSONWP_ELEMENT_KEY] || obj[W3C_ELEMENT_KEY]) {
+ // if it's an element object, clear out anything that's not the key, and then return the
+ // object. Note that if the element object contains only one element key type (MJSONWP or
+ // W3C), keep it that way in our response. But if the element contains both key types
+ // (because the client is being backwards compatible) then keep both keys in our response as
+ // well.
+ if (obj[MJSONWP_ELEMENT_KEY]) {
+ res[MJSONWP_ELEMENT_KEY] = obj[MJSONWP_ELEMENT_KEY];
+ }
+
+ if (obj[W3C_ELEMENT_KEY]) {
+ res[W3C_ELEMENT_KEY] = obj[W3C_ELEMENT_KEY];
+ }
+ return res;
+ }
+
+ // otherwise, recurse into the object
+ for (const key of Object.keys(obj)) {
+ res[key] = coerceScriptResult(obj[key]);
+ }
+ return res;
+ }
+
+ // also handle arrays
+ if (_.isArray(obj)) {
+ return obj.map(coerceScriptResult);
+ }
+
+ // base case, if it's not an object or array, return straightaway
+ return obj;
+}
+
+async function main (driverOpts, script, timeoutMs) {
+ let res;
+ try {
+ res = {success: await runScript(driverOpts, script, timeoutMs)};
+ } catch (error) {
+ res = {error: {message: error.message, stack: error.stack}};
+ }
+ await send(res);
+}
+
+// ensure we're running this script in IPC mode
+if (require.main === module && _.isFunction(process.send)) {
+ send = B.promisify(process.send, {context: process});
+ log.info('Running driver execution in child process');
+ process.on('message', ({driverOpts, script, timeoutMs}) => {
+ log.info('Parameters received from parent process');
+ main(driverOpts, script, timeoutMs);
+ });
+}

ADD
packages/execute-driver-plugin/lib/plugin.js
+import BasePlugin from '@appium/base-plugin';
+import _ from 'lodash';
+import cp from 'child_process';
+import { timing } from '@appium/support';
+import B from 'bluebird';
+
+const FEAT_FLAG = 'execute_driver_script';
+const DEFAULT_SCRIPT_TIMEOUT_MS = 1000 * 60 * 60; // default to 1 hour timeout
+const SCRIPT_TYPE_WDIO = 'webdriverio';
+
+export default class ExecuteDriverPlugin extends BasePlugin {
+
+ static newMethodMap = {
+ '/session/:sessionId/appium/execute_driver': {
+ POST: {
+ command: 'executeDriverScript',
+ payloadParams: {required: ['script'], optional: ['type', 'timeout']}
+ }
+ }
+ };
+
+ /**
+ * This method takes a string which is executed as javascript in the context of
+ * a new nodejs VM, and which has available a webdriverio driver object, having
+ * already been attached to the currently running session.
+ *
+ * @param {function} next - standard behaviour for executeDriverScript
+ * @param {BaseDriver} driver - Appium driver handling this command
+ * @param {string} script - the string representing the driver script to run
+ * @param {string} [scriptType='webdriverio'] - the name of the driver script
+ * library (currently only webdriverio is supported)
+ * @param {number} [timeoutMs=3600000] - timeout for the script process
+ *
+ * @returns {Object} - a JSONifiable object representing the return value of
+ * the script
+ */
+ async executeDriverScript (next, driver, script, scriptType = 'webdriverio',
+ timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS) {
+
+ if (!driver.isFeatureEnabled(FEAT_FLAG)) {
+ throw new Error(`Execute driver script functionality is not available ` +
+ `unless server is started with --allow-insecure including ` +
+ `the '${FEAT_FLAG}' flag, e.g., --allow-insecure=${FEAT_FLAG}`);
+ }
+
+ if (scriptType !== SCRIPT_TYPE_WDIO) {
+ throw new TypeError(`Only the '${SCRIPT_TYPE_WDIO}' script type is currently supported`);
+ }
+
+ if (!driver.serverHost || !driver.serverPort) {
+ throw new Error('Address or port of running server were not defined; this ' +
+ 'is required. This is probably a programming error in the driver');
+ }
+
+ if (!_.isNumber(timeoutMs)) {
+ throw new TypeError('Timeout parameter must be a number');
+ }
+
+ const driverOpts = {
+ sessionId: driver.sessionId,
+ // Appium probably won't be behind ssl locally; if it ever is, might need to update this to
+ // provide a user configurable parameter
+ protocol: 'http',
+ hostname: driver.serverHost,
+ port: driver.serverPort,
+ path: driver.serverPath,
+ isW3C: true,
+ isMobile: true,
+ capabilities: driver.caps
+ };
+ this.logger.info(`Constructed webdriverio driver options; W3C mode is ${driverOpts.isW3C ? 'on' : 'off'}`);
+
+
+ // fork the execution script as a child process
+ const childScript = require.resolve('./execute-child.js');
+ this.logger.info(`Forking process to run webdriver script as child using ${childScript}`);
+ const scriptProc = cp.fork(childScript);
+
+ // keep track of whether we have canceled the script timeout, so we can stop
+ // waiting for it and allow this process to finish gracefully
+ let timeoutCanceled = false;
+
+ try {
+ const timer = new timing.Timer();
+ timer.start();
+
+ // promise that deals with the result from the child process
+ const waitForResult = async () => {
+ const res = await new B((res) => {
+ scriptProc.on('message', res); // this is node IPC
+ });
+
+ this.logger.info('Received execute driver script result from child process, shutting it down');
+
+ if (res.error) {
+ throw new Error(res.error.message);
+ }
+
+ return res.success;
+ };
+
+ // promise that waits up to the timeout and throws an error if so, or does
+ // nothing if the timeout is canceled because we got a result from the
+ // child script
+ const waitForTimeout = async () => {
+ while (!timeoutCanceled && timer.getDuration().asMilliSeconds < timeoutMs) {
+ await B.delay(500);
+ }
+
+ if (timeoutCanceled) {
+ return;
+ }
+
+ throw new Error(`Execute driver script timed out after ${timeoutMs}ms. ` +
+ `You can adjust this with the 'timeout' parameter.`);
+ };
+
+ // now that the child script is alive, send it the data it needs to start
+ // running the driver script
+ this.logger.info('Sending driver and script data to child');
+ scriptProc.send({driverOpts, script, timeoutMs});
+
+ // and set up a race between the response from the child and the timeout
+ return await B.race([waitForResult(), waitForTimeout()]);
+ } catch (err) {
+ throw new Error(`Could not execute driver script. Original error was: ${err}`);
+ } finally {
+ // ensure we always cancel the timeout so that the timeout promise stops
+ // spinning and allows this process to die gracefully
+ timeoutCanceled = true;
+
+ if (scriptProc.connected) {
+ this.logger.info('Disconnecting from child proc');
+ scriptProc.disconnect();
+ }
+
+ if (scriptProc.exitCode === null) {
+ this.logger.info('Disconnecting from and killing driver script child proc');
+ scriptProc.kill();
+ } else {
+ this.logger.info('Script already ended on its own, no need to kill it');
+ }
+ }
+ }
+}
+
+export { ExecuteDriverPlugin };

ADD
packages/execute-driver-plugin/package.json
+{
+ "name": "@appium/execute-driver-plugin",
+ "version": "1.0.1",
+ "description": "Plugin for batching and executing driver commands with Appiums",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/appium/appium.git",
+ "directory": "packages/execute-driver-plugin"
+ },
+ "appium": {
+ "pluginName": "execute-driver",
+ "mainClass": "ExecuteDriverPlugin"
+ },
+ "keywords": [
+ "appium",
+ "automation",
+ "webdriver"
+ ],
+ "author": "Appium <maintainers@appium.io>",
+ "license": "Apache-2.0",
+ "bugs": {
+ "url": "https://github.com/appium/appium/issues"
+ },
+ "homepage": "https://github.com/appium/appium#readme",
+ "files": [
+ "build",
+ "lib",
+ "index.js"
+ ],
+ "dependencies": {
+ "@appium/base-plugin": "file:../base-plugin",
+ "@appium/support": "file:../support",
+ "bluebird": "3.7.2",
+ "lodash": "4.17.21",
+ "vm2": "3.9.5",
+ "webdriverio": "7.19.3"
+ },
+ "scripts": {
+ "build": "babel lib --root-mode=upward --out-dir=build/lib",
+ "dev": "npm run build -- --watch",
+ "fix": "npm run lint -- --fix",
+ "lint": "eslint -c ../../.eslintrc --ignore-path ../../.eslintignore .",
+ "test": "npm run test:unit",
+ "test:unit": "mocha \"./test/unit/**/*.spec.js\"",
+ "test:e2e": "mocha -t 160s --slow 20s \"./test/e2e/**/*.spec.js\""
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "gitHead": "5efbd2840a4e5dd078a4646c026472a006ab2211"
+}

ADD
packages/execute-driver-plugin/test/e2e/plugin.e2e.spec.js
+// @ts-check
+
+import path from 'path';
+
+import { e2eSetup as _e2eSetup } from '@appium/base-plugin/build/test/helpers';
+import { remote as wdio } from 'webdriverio';
+import { W3C_ELEMENT_KEY, MJSONWP_ELEMENT_KEY } from '../../lib/execute-child';
+import { fs } from '@appium/support';
+
+const THIS_PLUGIN_DIR = path.join(__dirname, '..', '..');
+const APPIUM_HOME = path.join(THIS_PLUGIN_DIR, 'local_appium_home');
+const FAKE_DRIVER_DIR = path.join(THIS_PLUGIN_DIR, '..', 'fake-driver');
+const TEST_HOST = 'localhost';
+const TEST_FAKE_APP = path.join(APPIUM_HOME, 'node_modules', '@appium', 'fake-driver', 'test', 'fixtures', 'app.xml');
+
+// XXX: remove when the test files get a proper TS configuration
+const e2eSetup = /** @type {import('@appium/base-plugin/test/helpers').e2eSetup} */(_e2eSetup);
+
+const TEST_CAPS = {
+ platformName: 'Fake',
+ 'appium:automationName': 'Fake',
+ 'appium:deviceName': 'Fake',
+ 'appium:app': TEST_FAKE_APP
+};
+const WDIO_OPTS = {
+ hostname: TEST_HOST,
+ connectionRetryCount: 0,
+ capabilities: TEST_CAPS
+};
+
+describe('ExecuteDriverPlugin', function () {
+ /** @type {import('webdriverio').Browser<'async'>} */
+ let driver;
+
+ const basicScript = `return 'foo'`;
+ /**
+ * @type {import('@appium/base-plugin/test/helpers').E2ESetupOpts}
+ */
+ const e2eSetupOpts = {
+ before, after, host: TEST_HOST, driverName: 'fake', driverSource: 'local',
+ driverSpec: FAKE_DRIVER_DIR, pluginName: 'execute-driver', pluginSource: 'local',
+ pluginSpec: THIS_PLUGIN_DIR, appiumHome: APPIUM_HOME
+ };
+
+ after(async function () {
+ await fs.rimraf(APPIUM_HOME);
+ });
+
+ describe('without --allow-insecure set', function () {
+ after(async function () { driver && await driver.deleteSession(); });
+ e2eSetup({...e2eSetupOpts});
+
+ it('should not work unless the allowInsecure feature flag is set', async function () {
+ driver = await wdio({...WDIO_OPTS, port: this.port});
+ await driver.driverScript(basicScript).should.be
+ .rejectedWith(/allow-insecure.+execute_driver_script/i);
+ });
+
+ });
+
+ describe('with --allow-insecure set', function () {
+
+ after(async function () { driver && await driver.deleteSession(); });
+ e2eSetup({...e2eSetupOpts, serverArgs: {allowInsecure: ['execute_driver_script']}});
+ before(async function () { driver = await wdio({...WDIO_OPTS, port: this.port}); });
+
+ it('should execute a webdriverio script in the context of session', async function () {
+ const script = `
+ const timeouts = await driver.getTimeouts();
+ const status = await driver.status();
+ return [timeouts, status];
+ `;
+ const expectedTimeouts = {command: 60000, implicit: 0};
+ const {result, logs} = await driver.driverScript(script);
+ result[0].should.eql(expectedTimeouts);
+ result[1].build.should.exist;
+ result[1].build.version.should.exist;
+ logs.should.eql({error: [], warn: [], log: []});
+ });
+
+ it('should fail with any script type other than webdriverio currently', async function () {
+ const script = `return 'foo'`;
+ await driver.driverScript(script, 'wd')
+ .should.be.rejectedWith(/webdriverio/);
+ });
+
+ it('should execute a webdriverio script that returns elements correctly', async function () {
+ const script = `
+ return await driver.$("#Button1");
+ `;
+ const {result} = await driver.driverScript(script);
+ result.should.eql({
+ [W3C_ELEMENT_KEY]: '1',
+ [MJSONWP_ELEMENT_KEY]: '1',
+ });
+ });
+
+ it('should execute a webdriverio script that returns elements in deep structure', async function () {
+ const script = `
+ const el = await driver.$("#Button1");
+ return {element: el, elements: [el, el]};
+ `;
+ const {result} = await driver.driverScript(script);
+ const elObj = {
+ [W3C_ELEMENT_KEY]: '1',
+ [MJSONWP_ELEMENT_KEY]: '1'
+ };
+ result.should.eql({element: elObj, elements: [elObj, elObj]});
+ });
+
+ it('should store and return logs to the user', async function () {
+ const script = `
+ console.log("foo");
+ console.log("foo2");
+ console.warn("bar");
+ console.error("baz");
+ return null;
+ `;
+ const {logs} = await driver.driverScript(script);
+ logs.should.eql({log: ['foo', 'foo2'], warn: ['bar'], error: ['baz']});
+ });
+
+ it('should have appium specific commands available', async function () {
+ const script = `
+ return typeof driver.lock;
+ `;
+ const {result} = await driver.driverScript(script);
+ result.should.eql('function');
+ });
+
+ it('should correctly handle errors that happen in a webdriverio script', async function () {
+ const script = `
+ return await driver.$("~notfound");
+ `;
+ const {result} = await driver.driverScript(script);
+ result.error.error.should.equal('no such element');
+ result.error.message.should.match(/element could not be located/);
+ result.error.stacktrace.should.includes('NoSuchElementError:');
+ result.selector.should.equal('~notfound');
+ result.sessionId.should.equal(driver.sessionId);
+ });
+
+ it('should correctly handle errors that happen when a script cannot be compiled', async function () {
+ const script = `
+ return {;
+ `;
+ await driver.driverScript(script).should.be.rejectedWith(
+ /Could not execute driver script.+Unexpected token/);
+ });
+
+ it('should be able to set a timeout on a driver script', async function () {
+ const script = `
+ await Promise.delay(1000);
+ return true;
+ `;
+ await driver.driverScript(script, 'webdriverio', 50)
+ .should.be.rejectedWith(/.+50.+timeout.+/);
+ });
+ });
+});

ADD
packages/execute-driver-plugin/test/unit/plugin.spec.js
+import { ExecuteDriverPlugin } from '../../lib/plugin';
+
+describe('execute driver plugin', function () {
+ it('should exist', function () {
+ should.exist(ExecuteDriverPlugin);
+ });
+});

ADD
packages/execute-driver-plugin/tsconfig.json
+{
+ "compilerOptions": {
+ "outDir": "./build",
+ "paths": {
+ "@appium/base-plugin": ["../base-plugin"],
+ "@appium/support": ["../support"],
+ "appium": ["../appium"]
+ },
+ "types": ["webdriverio/async"]
+ },
+ "extends": "../../config/tsconfig.base.json",
+ "include": ["lib"],
+ "references": [
+ {
+ "path": "../base-plugin"
+ },
+ {
+ "path": "../support"
+ },
+ {
+ "path": "../appium"
+ }
+ ]
+}

MODIFY
tsconfig.json
tsconfig.json
},
{
"path": "packages/opencv"
+ },
+ {
+ "path": "packages/execute-driver-plugin"
}
]
}

chore(execute-driver-plugin): import execute-driver-plugin

yeah this is my bad.  if we really need the history I can redo this.

no significant changes; just the usual stuff about builds and types.

bonus: I figured out how to get webdriverio types working