feat: allow installed plugins to modify server, method map, and act as command handlers
MODIFY
lib/appium.js
lib/appium.js
@@ -51,6 +51,8 @@ class AppiumDriver extends BaseDriver {
// It is not recommended to access this property directly from the outside
this.pendingDrivers = {};
+ this.plugins = [];
+
// allow this to happen in the background, so no `await`
updateBuildInfo();
}
@@ -344,32 +346,136 @@ class AppiumDriver extends BaseDriver {
}
}
- async executeCommand (cmd, ...args) {
- // getStatus command should not be put into queue. If we do it as part of super.executeCommand, it will be added to queue.
- // There will be lot of status commands in queue during createSession command, as createSession can take up to or more than a minute.
- if (cmd === 'getStatus') {
- return await this.getStatus();
+ pluginsToWrapCmd (cmd) {
+ return this.plugins.filter((plugin) => plugin.wrapCommands.includes(cmd));
}
- if (isAppiumDriverCommand(cmd)) {
- return await super.executeCommand(cmd, ...args);
+ pluginsToHandleCmd (cmd) {
+ return this.plugins.filter((plugin) => {
+ if (plugin.commands === true) {
+ return true;
+ }
+ if (_.isArray(plugin.commands) && plugin.commands.includes(cmd)) {
+ return true;
+ }
+ return false;
+ });
}
- const sessionId = _.last(args);
- const dstSession = await sessionsListGuard.acquire(AppiumDriver.name, () => this.sessions[sessionId]);
+ async executeCommand (cmd, ...args) {
+ // first things first, let's create a result object which we'll ultimately return.
+ let res = {};
+
+ // now we have basically three cases for how to handle commands:
+ // 1. handle getStatus (we do this as a special out of band case so it doesn't get added to an
+ // execution queue, and can be called while e.g. createSession is in progress)
+ // 2. handle commands that this umbrella driver should handle, rather than the actual session
+ // driver (for example, deleteSession, or other non-session commands)
+ // 3. handle session driver commands.
+ // The tricky part is that because we support command plugins, we need to wrap any of these
+ // cases with plugin handling.
+
+ const isGetStatus = cmd === 'getStatus';
+ const isUmbrellaCmd = !isGetStatus && isAppiumDriverCommand(cmd);
+ const isSessionCmd = !isGetStatus && !isUmbrellaCmd;
+
+ // first do some error checking. If we're requesting a session command execution, then make
+ // sure that session actually exists on the session driver, and set the session driver itself
+ let sessionId = null;
+ let dstSession = null;
+ if (isSessionCmd) {
+ sessionId = _.last(args);
+ dstSession = await sessionsListGuard.acquire(AppiumDriver.name, () => this.sessions[sessionId]);
if (!dstSession) {
throw new Error(`The session with id '${sessionId}' does not exist`);
}
+ // now reset the response protocol given that the session driver's protocol might differ
+ res.protocol = dstSession.protocol;
+ }
+
+ // now we define a 'handled' object which will keep track of which plugins have handled this
+ // command. we care about this because (a) multiple plugins can handle the same command, and
+ // (b) there's no guarantee that a plugin will actually call the next() method which runs the
+ // original command execution. This results in a situation where the command might be handled
+ // by some but not all plugins, or by plugin(s) but not by the default behavior. So start out
+ // this object declaring that the default handler has not been executed.
+ const handled = {default: false};
- let res = {
- protocol: dstSession.protocol
+ // now we define an async function which will be passed to plugins, and successively wrapped
+ // if there is more than one plugin that can handle the command. To start off with, the async
+ // function is defined as calling the default behavior, i.e., whichever of the 3 cases above is
+ // the appropriate one
+ let next = async () => {
+ log.info(`Executing default handling behavior for command '${cmd}'`);
+ handled.default = true; // if we make it here, we know that the default behavior is handled
+
+ if (isGetStatus) {
+ return await this.getStatus();
+ }
+
+ if (isUmbrellaCmd) {
+ // some commands, like deleteSession, we want to make sure to handle on *this* driver,
+ // not the platform driver
+ return await super.executeCommand(cmd, ...args);
+ }
+
+ // here we know that we are executing a session command, and have a valid session driver
+ return await dstSession.executeCommand(cmd, ...args);
};
+ // then we get any plugins which are registered as handling this command
+ const plugins = this.pluginsToHandleCmd(cmd);
+
+ plugins.length && log.info(`Plugins which can handle cmd '${cmd}': ${plugins.map((p) => p.name)}`);
+
+ // now we can go through each plugin and wrap `next` around its own handler, passing the *old*
+ // next in so that it can call it if it wants to
+ for (const plugin of plugins) {
+ // need an IIFE here because we want the value of next that's passed to plugin.handle to be
+ // exactly the value of next here before reassignment; we don't want it to be lazily
+ // evaluated, otherwise we end up with infinite recursion of the last `next` to be defined.
+ handled[plugin.name] = false; // we see a new plugin, so add it to the 'handled' object
+ next = ((_next) => async () => {
+ log.info(`Plugin ${plugin.name} is now handling cmd '${cmd}'`);
+ handled[plugin.name] = true; // if we make it here, this plugin has attempted to handle cmd
+ return await plugin.handle(_next, this, cmd, ...args);
+ })(next);
+ }
+
+ let cmdRes, cmdErr;
try {
- res.value = await dstSession.executeCommand(cmd, ...args);
+ // ok now we're done with all the plugin wrapping. At this point, `next` defines a whole
+ // sequence of plugin handlers, culminating in our default handler. Whatever it returns is
+ // what we're going to want to send back to the user.
+ cmdRes = await next();
} catch (e) {
- res.error = e;
+ cmdErr = e;
+ }
+
+ // Sadly, we don't know exactly what kind of object will be returned. It will either be a bare
+ // object, or a protocol-aware object with protocol and error/value keys. So we need to sniff
+ // it and make sure we don't double-wrap it if it's the latter kind.
+ if (_.isPlainObject(cmdRes) && _.has(cmdRes, 'protocol')) {
+ res = cmdRes;
+ } else {
+ res.value = cmdRes;
+ res.error = cmdErr;
}
+
+ // at the end of the day, we have an object representing which plugins ended up getting
+ // their code run as part of handling this command. Because plugins can choose *not* to
+ // pass control to other plugins or to the default driver behavior, this is information
+ // which is probably useful to the user (especially in situations where plugins might not
+ // interact well together, and it would be hard to debug otherwise without this kind of
+ // message).
+ const didHandle = Object.keys(handled).filter((k) => handled[k]);
+ const didntHandle = Object.keys(handled).filter((k) => !handled[k]);
+ if (didntHandle.length > 0) {
+ log.info(`Command '${cmd}' was not handled by the following beahviors or plugins, even ` +
+ `though they were registered to handle it: ${JSON.stringify(didntHandle)}. The ` +
+ `command *was* handled by these: ${JSON.stringify(didHandle)}.`);
+ }
+
return res;
}

MODIFY
lib/cli/utils.js
lib/cli/utils.js
@@ -14,6 +14,9 @@ function errAndQuit (json, msg) {
console.log(JSON.stringify({error: `${msg}`}, null, JSON_SPACES));
} else {
console.error(`${msg}`.red);
+ if (msg.stderr) {
+ console.error(`${msg.stderr}`.red);
+ }
}
process.exit(1);
}

MODIFY
lib/driver-config.js
lib/driver-config.js
@@ -35,6 +35,8 @@ export default class DriverConfig extends ExtensionConfig {
});
}
automationNames.push(automationName);
+
+ return problems;
}
extensionDesc (driverName, {version, automationName}) {

MODIFY
lib/main.js
lib/main.js
@@ -14,6 +14,7 @@ import {
getGitRev, APPIUM_VER
} from './config';
import DriverConfig from './driver-config';
+import PluginConfig from './plugin-config';
import { DRIVER_TYPE, PLUGIN_TYPE } from './extension-config';
import { runExtensionCommand } from './cli/extension';
import { AppiumDriver } from './appium';
@@ -21,7 +22,7 @@ import registerNode from './grid-register';
import { inspectObject } from './utils';
-async function preflightChecks ({parser, args, driverConfig, throwInsteadOfExit = false}) {
+async function preflightChecks ({parser, args, driverConfig, pluginConfig, throwInsteadOfExit = false}) {
try {
checkNodeOk();
if (args.longStacktrace) {
@@ -34,6 +35,7 @@ async function preflightChecks ({parser, args, driverConfig, throwInsteadOfExit
warnNodeDeprecations();
validateServerArgs(parser, args);
await driverConfig.read();
+ await pluginConfig.read();
if (args.tmpDir) {
await validateTmpDir(args.tmpDir);
}
@@ -130,16 +132,26 @@ async function main (args = null) {
let appiumDriver = new AppiumDriver(args);
const driverConfig = new DriverConfig(args.appiumHome);
+ const pluginConfig = new PluginConfig(args.appiumHome);
appiumDriver.driverConfig = driverConfig;
- await preflightChecks({parser, args, driverConfig, throwInsteadOfExit});
+ await preflightChecks({parser, args, driverConfig, pluginConfig, throwInsteadOfExit});
await logStartupInfo(parser, args);
let routeConfiguringFunction = makeRouter(appiumDriver);
+ // turn all of the installed plugins into instantiated plugin class objects, so we can send them
+ // as objects to the server init. we also want to send/assign them to the umbrella driver so it can
+ // activate them on a per-request basis as well
+ const plugins = Object.keys(pluginConfig.installedExtensions).map((plugin) => {
+ const PluginClass = pluginConfig.require(plugin);
+ return new PluginClass(plugin);
+ });
+ appiumDriver.plugins = plugins;
let server = await baseServer({
routeConfiguringFunction,
port: args.port,
hostname: args.address,
allowCors: args.allowCors,
basePath: args.basePath,
+ plugins,
});
if (args.allowCors) {
logger.warn('You have enabled CORS requests from any host. Be careful not ' +

MODIFY
package.json
package.json
"precommit-test"
],
"devDependencies": {
- "appium-gulp-plugins": "^5.2.1",
+ "@appium/fake-plugin": "^0.2.0",
"appium-fake-driver": "0.x",
+ "appium-gulp-plugins": "^5.2.1",
"chai": "4.x",
"chai-as-promised": "7.x",
"eslint-config-appium": "^4.0.1",

MODIFY
test/driver-e2e-specs.js
test/driver-e2e-specs.js
// transpile:mocha
import _ from 'lodash';
+import path from 'path';
import B from 'bluebird';
import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import wd from 'wd';
import axios from 'axios';
import { main as appiumServer } from '../lib/main';
-import { DEFAULT_APPIUM_HOME, INSTALL_TYPE_NPM, DRIVER_TYPE } from '../lib/extension-config';
+import { DEFAULT_APPIUM_HOME, INSTALL_TYPE_LOCAL, DRIVER_TYPE } from '../lib/extension-config';
import { TEST_FAKE_APP, TEST_HOST, TEST_PORT } from './helpers';
import { BaseDriver } from 'appium-base-driver';
import { FakeDriver } from 'appium-fake-driver';
@@ -16,6 +17,8 @@ import sinon from 'sinon';
chai.use(chaiAsPromised);
+const FAKE_DRIVER_DIR = path.resolve(__dirname, '..', '..', 'node_modules', 'appium-fake-driver');
+
const should = chai.should();
const shouldStartServer = process.env.USE_RUNNING_SERVER !== '0';
const caps = {
@@ -40,8 +43,8 @@ describe('FakeDriver - via HTTP', function () {
await runExtensionCommand({
appiumHome,
driverCommand: 'install',
- driver: 'appium-fake-driver',
- installType: INSTALL_TYPE_NPM,
+ driver: FAKE_DRIVER_DIR,
+ installType: INSTALL_TYPE_LOCAL,
}, DRIVER_TYPE);
}
@@ -283,10 +286,10 @@ describe('FakeDriver - via HTTP', function () {
return res;
});
- const {value} = (await axios.post(baseUrl, combinedCaps)).data;
- const {capabilities, sessionId} = value;
+ const {value, sessionId, status} = (await axios.post(baseUrl, combinedCaps)).data;
+ status.should.exist;
sessionId.should.exist;
- capabilities.should.deep.equal(caps);
+ value.should.deep.equal(caps);
createSessionStub.restore();

ADD
test/plugin-e2e-specs.js
+// transpile:mocha
+
+import _ from 'lodash';
+import path from 'path';
+import chai from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+import wd from 'wd';
+import axios from 'axios';
+import { main as appiumServer } from '../lib/main';
+import { DEFAULT_APPIUM_HOME, INSTALL_TYPE_LOCAL, DRIVER_TYPE, PLUGIN_TYPE } from '../lib/extension-config';
+import { TEST_FAKE_APP, TEST_HOST, TEST_PORT } from './helpers';
+import { runExtensionCommand } from '../lib/cli/extension';
+
+chai.use(chaiAsPromised);
+const FAKE_PLUGIN_DIR = path.resolve(__dirname, '..', '..', 'node_modules', '@appium', 'fake-plugin');
+const FAKE_DRIVER_DIR = path.resolve(__dirname, '..', '..', 'node_modules', 'appium-fake-driver');
+
+const shouldStartServer = process.env.USE_RUNNING_SERVER !== '0';
+const caps = {
+ automationName: 'Fake',
+ platformName: 'Fake',
+ deviceName: 'Fake',
+ app: TEST_FAKE_APP
+};
+
+describe('FakePlugin', function () {
+ let server = null;
+ const appiumHome = DEFAULT_APPIUM_HOME;
+ const baseUrl = `http://${TEST_HOST}:${TEST_PORT}/wd/hub/session`;
+ before(async function () {
+ // first ensure we have fakedriver installed
+ const driverList = await runExtensionCommand({
+ appiumHome,
+ driverCommand: 'list',
+ showInstalled: true,
+ }, DRIVER_TYPE);
+ if (!_.has(driverList, 'fake')) {
+ await runExtensionCommand({
+ appiumHome,
+ driverCommand: 'install',
+ driver: FAKE_DRIVER_DIR,
+ installType: INSTALL_TYPE_LOCAL,
+ }, DRIVER_TYPE);
+ }
+
+ const pluginList = await runExtensionCommand({
+ appiumHome,
+ pluginCommand: 'list',
+ showInstalled: true,
+ }, PLUGIN_TYPE);
+ if (!_.has(pluginList, 'fake')) {
+ await runExtensionCommand({
+ appiumHome,
+ pluginCommand: 'install',
+ plugin: FAKE_PLUGIN_DIR,
+ installType: INSTALL_TYPE_LOCAL,
+ }, PLUGIN_TYPE);
+ }
+ // then start server if we need to
+ if (shouldStartServer) {
+ let args = {port: TEST_PORT, host: TEST_HOST, appiumHome};
+ server = await appiumServer(args);
+ }
+ });
+
+ after(async function () {
+ if (server) {
+ await server.close();
+ }
+ });
+
+ it('should update the server', async function () {
+ const res = {fake: 'fakeResponse'};
+ (await axios.post(`http://${TEST_HOST}:${TEST_PORT}/fake`)).data.should.eql(res);
+ });
+
+ it('should modify the method map with new commands', async function () {
+ const driver = wd.promiseChainRemote(TEST_HOST, TEST_PORT);
+ const [sessionId] = await driver.init(caps);
+ try {
+ await axios.post(`${baseUrl}/${sessionId}/fake_data`, {data: {fake: 'data'}});
+ (await axios.get(`${baseUrl}/${sessionId}/fake_data`)).data.value.should.eql({fake: 'data'});
+ } finally {
+ await driver.quit();
+ }
+ });
+
+ it('should handle commands and not call the original', async function () {
+ const driver = wd.promiseChainRemote(TEST_HOST, TEST_PORT);
+ const [sessionId] = await driver.init(caps);
+ try {
+ await driver.source().should.eventually.eql(`<Fake>${JSON.stringify([sessionId])}</Fake>`);
+ } finally {
+ await driver.quit();
+ }
+ });
+
+ it('should handle commands and call the original if designed', async function () {
+ const driver = wd.promiseChainRemote(TEST_HOST, TEST_PORT);
+ const [sessionId] = await driver.init(caps);
+ try {
+ const el = (await axios.post(`${baseUrl}/${sessionId}/element`, {using: 'xpath', value: '//MockWebView'})).data.value;
+ el.should.have.property('fake');
+ } finally {
+ await driver.quit();
+ }
+ });
+});

feat: allow installed plugins to modify server, method map, and act as command handlers