feat: add ability for drivers to update server and add method maps like plugins
also ensure that each session gets its own plugin instances so plugins for different sessions can't share state
MODIFY
lib/appium.js
lib/appium.js
@@ -51,7 +51,9 @@ class AppiumDriver extends BaseDriver {
// It is not recommended to access this property directly from the outside
this.pendingDrivers = {};
- this.plugins = [];
+ this.pluginClasses = []; // list of which plugins are active
+ this.sessionPlugins = {}; // map of sessions to actual plugin instances per session
+ this.sessionlessPlugins = []; // some commands are sessionless, so we need a set of plugins for them
// allow this to happen in the background, so no `await`
updateBuildInfo();
@@ -253,6 +255,7 @@ class AppiumDriver extends BaseDriver {
log.warn(`Closing session, cause was '${cause.message}'`);
log.info(`Removing session '${innerSessionId}' from our master session list`);
delete this.sessions[innerSessionId];
+ delete this.sessionPlugins[innerSessionId];
};
// eslint-disable-next-line promise/prefer-await-to-then
@@ -317,6 +320,7 @@ class AppiumDriver extends BaseDriver {
// make the session unavailable, because who knows what state it might
// be in otherwise
delete this.sessions[sessionId];
+ delete this.sessionPlugins[sessionId];
});
return {
protocol,
@@ -355,10 +359,43 @@ class AppiumDriver extends BaseDriver {
}
}
- pluginsToHandleCmd (cmd) {
+ /**
+ * To get plugins for a command, we either get the plugin instances associated with the
+ * particular command's session, or in the case of sessionless plugins, pull from the set of
+ * plugin instances reserved for sessionless commands (and we lazily create plugin instances on
+ * first use)
+ *
+ * @param {string} cmd - the name of the command to find a plugin to handle
+ * @param {string} sessionId - the particular session for which to find a plugin, or null if
+ * sessionless
+ */
+ pluginsToHandleCmd (cmd, sessionId) {
+ let plugins = [];
+ if (sessionId) {
+ if (!this.sessionPlugins[sessionId]) {
+ this.sessionPlugins[sessionId] = this.createPluginInstances();
+ }
+ plugins = this.sessionPlugins[sessionId];
+ } else {
+ if (_.isEmpty(this.sessionlessPlugins)) {
+ this.sessionlessPlugins = this.createPluginInstances();
+ }
+ plugins = this.sessionlessPlugins;
+ }
// to handle a given command, a plugin should either implement that command as a plugin
// instance method or it should implement a generic 'handle' method
- return this.plugins.filter((p) => _.isFunction(p[cmd]) || _.isFunction(p.handle));
+ return plugins.filter((p) => _.isFunction(p[cmd]) || _.isFunction(p.handle));
+ }
+
+ createPluginInstances (sessionId) {
+ if (!sessionId) {
+ sessionId = 'sessionless';
+ }
+ sessionId = _.truncate(sessionId, {length: 11});
+ return this.pluginClasses.map((PluginClass) => {
+ const name = `${PluginClass.pluginName} (${sessionId})`;
+ return new PluginClass(name);
+ });
}
async executeCommand (cmd, ...args) {
@@ -375,9 +412,6 @@ class AppiumDriver extends BaseDriver {
const isUmbrellaCmd = !isGetStatus && isAppiumDriverCommand(cmd);
const isSessionCmd = !isGetStatus && !isUmbrellaCmd;
- // get any plugins which are registered as handling this command
- const plugins = this.pluginsToHandleCmd(cmd);
-
// 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;
@@ -395,6 +429,9 @@ class AppiumDriver extends BaseDriver {
driver = dstSession;
}
+ // get any plugins which are registered as handling this command
+ const plugins = this.pluginsToHandleCmd(cmd, sessionId);
+
// now we define a 'cmdHandledBy' 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

MODIFY
lib/main.js
lib/main.js
@@ -89,6 +89,65 @@ function logServerPort (address, port) {
logger.info(logMessage);
}
+/**
+ * Find any plugin name which has been installed, and which has been requested for activation by
+ * using the --plugins flag, and turn each one into its class, 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 use them to
+ * wrap command execution
+ *
+ * @param {Object} args - argparser parsed dict
+ * @param {PluginConfig} pluginConfig - a plugin extension config
+ */
+function getActivePlugins (args, pluginConfig) {
+ return Object.keys(pluginConfig.installedExtensions).filter((pluginName) =>
+ _.includes(args.plugins, pluginName) ||
+ (args.plugins.length === 1 && args.plugins[0] === USE_ALL_PLUGINS)
+ ).map((pluginName) => {
+ try {
+ logger.info(`Attempting to load plugin ${pluginName}...`);
+ const PluginClass = pluginConfig.require(pluginName);
+ PluginClass.pluginName = pluginName; // store the plugin name on the class so it can be used later
+ return PluginClass;
+ } catch (err) {
+ logger.error(`Could not load plugin '${pluginName}', so it will not be available. Error ` +
+ `in loading the plugin was: ${err.message}`);
+ logger.debug(err.stack);
+ return false;
+ }
+ }).filter(Boolean);
+}
+
+/**
+ * Find any driver name which has been installed, and turn each one into its class, so we can send
+ * them as objects to the server init in case they need to add methods/routes or update the server.
+ *
+ * @param {DriverConfig} driverConfig - a driver extension config
+ */
+function getActiveDrivers (driverConfig) {
+ return Object.keys(driverConfig.installedExtensions).map((driverName) => {
+ try {
+ logger.info(`Attempting to load driver ${driverName}...`);
+ return driverConfig.require(driverName);
+ } catch (err) {
+ logger.error(`Could not load driver '${driverName}', so it will not be available. Error ` +
+ `in loading the driver was: ${err.message}`);
+ logger.debug(err.stack);
+ return false;
+ }
+ }).filter(Boolean);
+}
+
+function getServerUpdaters (driverClasses, pluginClasses) {
+ return [...driverClasses, ...pluginClasses].map((klass) => klass.updateServer).filter(Boolean);
+}
+
+function getExtraMethodMap (driverClasses, pluginClasses) {
+ return [...driverClasses, ...pluginClasses].reduce(
+ (map, klass) => ({...map, ...klass.newMethodMap}),
+ {}
+ );
+}
+
async function main (args = null) {
let parser = getParser();
let throwInsteadOfExit = false;
@@ -133,30 +192,20 @@ async function main (args = null) {
let appiumDriver = new AppiumDriver(args);
const driverConfig = new DriverConfig(args.appiumHome);
- const pluginConfig = new PluginConfig(args.appiumHome);
+ // set the config on the umbrella driver so it can match drivers to caps
appiumDriver.driverConfig = driverConfig;
+ const pluginConfig = new PluginConfig(args.appiumHome);
await preflightChecks({parser, args, driverConfig, pluginConfig, throwInsteadOfExit});
await logStartupInfo(parser, args);
let routeConfiguringFunction = makeRouter(appiumDriver);
- // find any plugin name which has been installed, and which has been requested for activation by
- // using the --plugins flag, and turn each one into an instantiated plugin object, 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 use them to wrap command execution
- const plugins = Object.keys(pluginConfig.installedExtensions).filter((pluginName) =>
- _.includes(args.plugins, pluginName) ||
- (args.plugins.length === 1 && args.plugins[0] === USE_ALL_PLUGINS)
- ).map((pluginName) => {
- try {
- const PluginClass = pluginConfig.require(pluginName);
- return new PluginClass(pluginName);
- } catch (err) {
- logger.error(`Could not load plugin '${pluginName}', so it will not be available. Error ` +
- `in loading the plugin was: ${err}`);
- return false;
- }
- }).filter(Boolean);
- appiumDriver.plugins = plugins;
+ const driverClasses = getActiveDrivers(driverConfig);
+ const pluginClasses = getActivePlugins(args, pluginConfig);
+ // set the active plugins on the umbrella driver so it can use them for commands
+ appiumDriver.pluginClasses = pluginClasses;
+
+ const serverUpdaters = getServerUpdaters(driverClasses, pluginClasses);
+ const extraMethodMap = getExtraMethodMap(driverClasses, pluginClasses);
const serverOpts = {
routeConfiguringFunction,
@@ -164,12 +213,22 @@ async function main (args = null) {
hostname: args.address,
allowCors: args.allowCors,
basePath: args.basePath,
- plugins,
+ serverUpdaters,
+ extraMethodMap,
};
if (args.keepAliveTimeout) {
serverOpts.keepAliveTimeout = args.keepAliveTimeout * 1000;
}
- let server = await baseServer(serverOpts);
+ let server;
+ try {
+ server = await baseServer(serverOpts);
+ } catch (err) {
+ logger.error(`Could not configure Appium server. It's possible that a driver or plugin tried ` +
+ `to update the server and failed. Original error: ${err.message}`);
+ logger.debug(err.stack);
+ return process.exit(1);
+ }
+
if (args.allowCors) {
logger.warn('You have enabled CORS requests from any host. Be careful not ' +
'to visit sites which could maliciously try to start Appium ' +
@@ -177,9 +236,6 @@ async function main (args = null) {
}
appiumDriver.server = server;
try {
- // TODO prelaunch if args.launch is set
- // TODO: startAlertSocket(server, appiumServer);
-
// configure as node on grid, if necessary
if (args.nodeconfig !== null) {
await registerNode(args.nodeconfig, args.address, args.port, args.basePath);
@@ -208,7 +264,7 @@ async function main (args = null) {
logServerPort(args.address, args.port);
driverConfig.print();
- pluginConfig.print(plugins);
+ pluginConfig.print(pluginClasses.map((p) => p.pluginName));
return server;
}

MODIFY
lib/plugin-config.js
lib/plugin-config.js
@@ -11,9 +11,8 @@ export default class PluginConfig extends ExtensionConfig {
return `${pluginName}@${version}`;
}
- print (activePlugins) {
+ print (activeNames) {
const pluginNames = Object.keys(this.installedExtensions);
- const activeNames = activePlugins.map((p) => p.name);
if (_.isEmpty(pluginNames)) {
log.info(`No plugins have been installed. Use the "appium plugin" ` +
@@ -27,7 +26,7 @@ export default class PluginConfig extends ExtensionConfig {
log.info(` - ${this.extensionDesc(pluginName, pluginData)}${activeTxt}`);
}
- if (_.isEmpty(activePlugins)) {
+ if (_.isEmpty(activeNames)) {
log.info('No plugins activated. Use the --plugins flag with names of plugins to activate');
}
}

MODIFY
package.json
package.json
"dependencies": {
"@appium/base-plugin": "^1.0.0",
"@babel/runtime": "^7.6.0",
- "appium-base-driver": "^8.0.0-beta.2",
+ "appium-base-driver": "^8.0.0-beta.6",
"appium-support": "2.x",
"argparse": "^2.0.1",
"async-lock": "^1.0.0",
"precommit-test"
],
"devDependencies": {
- "@appium/fake-plugin": ">=0.3.0",
- "appium-fake-driver": "^1.0.1",
+ "@appium/fake-plugin": "^1.0.0",
+ "appium-fake-driver": "^2.0.1",
"appium-gulp-plugins": "^5.2.1",
"chai": "4.x",
"chai-as-promised": "7.x",

MODIFY
test/driver-e2e-specs.js
test/driver-e2e-specs.js
@@ -38,9 +38,6 @@ describe('FakeDriver - via HTTP', function () {
let FakeDriver = null;
const baseUrl = `${TEST_SERVER}/session`;
before(async function () {
- const config = new DriverConfig(appiumHome);
- await config.read();
- FakeDriver = config.require('fake');
// first ensure we have fakedriver installed
const driverList = await runExtensionCommand({
appiumHome,
@@ -56,6 +53,10 @@ describe('FakeDriver - via HTTP', function () {
}, DRIVER_TYPE);
}
+ const config = new DriverConfig(appiumHome);
+ await config.read();
+ FakeDriver = config.require('fake');
+
// then start server if we need to
if (shouldStartServer) {
let args = {port: TEST_PORT, host: TEST_HOST, appiumHome};
@@ -69,6 +70,13 @@ describe('FakeDriver - via HTTP', function () {
}
});
+ describe('server updating', function () {
+ it('should allow drivers to update the server in arbitrary ways', async function () {
+ const {data} = await axios.get(`${TEST_SERVER}/fakedriver`);
+ data.should.eql({fakedriver: 'fakeResponse'});
+ });
+ });
+
describe('session handling', function () {
it('should start and stop a session and not allow commands after session stopped', async function () {
let driver = await wdio({...wdOpts, capabilities: caps});
@@ -266,6 +274,17 @@ describe('FakeDriver - via HTTP', function () {
createSessionStub.restore();
});
+
+ it('should allow drivers to update the method map with new routes and commands', async function () {
+ let driver = await wdio({...wdOpts, capabilities: caps});
+ const {sessionId} = driver;
+ try {
+ await axios.post(`${baseUrl}/${sessionId}/fakedriver`, {thing: {yes: 'lolno'}});
+ (await axios.get(`${baseUrl}/${sessionId}/fakedriver`)).data.value.should.eql({yes: 'lolno'});
+ } finally {
+ await driver.deleteSession();
+ }
+ });
});
});

feat: add ability for drivers to update server and add method maps like plugins
also ensure that each session gets its own plugin instances so plugins for different sessions can't share state