import {BasePlugin} from 'appium/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 = /** @type {const} */ ({ '/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 {import('@appium/types').NextPluginCallback} next - standard behaviour for executeDriverScript * @param {import('@appium/types').ExternalDriver} 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 {Promise} - a JSONifiable object representing the return value of * the script * @type {import('@appium/types').PluginCommand} * @throws {Error} */ 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, options: { // 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};