feat: allow use of --plugins=all to activate all plugins, and show helpful startup text
MODIFY
lib/appium.js
lib/appium.js
@@ -347,7 +347,10 @@ class AppiumDriver extends BaseDriver {
}
pluginsToHandleCmd (cmd) {
- return this.plugins.filter((p) => p.commands === true || _.includes(p.commands, cmd));
+ return this.plugins.filter((p) =>
+ p.commands === true ||
+ (_.isArray(p.commands) && _.includes(p.commands, cmd))
+ );
}
async executeCommand (cmd, ...args) {
@@ -364,6 +367,10 @@ 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);
+ console.log(plugins);
+
// 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;
@@ -379,21 +386,26 @@ class AppiumDriver extends BaseDriver {
protocol = dstSession.protocol;
}
- // now we define a 'handled' object which will keep track of which plugins have handled this
+ // 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
// 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};
+ const cmdHandledBy = {default: false};
// 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
const defaultBehavior = 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 we're running with plugins, make sure we log that the default behavior is actually
+ // happening so we can tell when the plugin call chain is unwrapping to the default behavior
+ // if that's what happens
+ plugins.length && log.info(`Executing default handling behavior for command '${cmd}'`);
+
+ // if we make it here, we know that the default behavior is handled
+ cmdHandledBy.default = true;
if (isGetStatus) {
return await this.getStatus();
@@ -410,18 +422,17 @@ class AppiumDriver extends BaseDriver {
};
// now take our default behavior, wrap it with any number of plugin behaviors, and run it
- const wrappedCmd = this.wrapCommandWithPlugins({cmd, args, handled, next: defaultBehavior});
+ const wrappedCmd = this.wrapCommandWithPlugins({cmd, args, plugins, cmdHandledBy, next: defaultBehavior});
const res = await this.executeWrappedCommand({wrappedCmd, protocol});
- this.logPluginHandlerReport({cmd, handled});
+ // if we had plugins, make sure to log out the helpful report about which plugins ended up
+ // handling the command and which didn't
+ plugins.length && this.logPluginHandlerReport({cmd, cmdHandledBy});
return res;
}
- wrapCommandWithPlugins ({cmd, args, next, handled}) {
- // get any plugins which are registered as handling this command
- const plugins = this.pluginsToHandleCmd(cmd);
-
+ wrapCommandWithPlugins ({cmd, args, next, cmdHandledBy, plugins}) {
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*
@@ -430,10 +441,10 @@ class AppiumDriver extends BaseDriver {
// 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
+ cmdHandledBy[plugin.name] = false; // we see a new plugin, so add it to the 'cmdHandledBy' 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
+ cmdHandledBy[plugin.name] = true; // if we make it here, this plugin has attempted to handle cmd
return await plugin.handle(_next, this, cmd, ...args);
})(next);
}
@@ -441,15 +452,15 @@ class AppiumDriver extends BaseDriver {
return next;
}
- logPluginHandlerReport ({cmd, handled}) {
+ logPluginHandlerReport ({cmd, cmdHandledBy}) {
// 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]);
+ const didHandle = Object.keys(cmdHandledBy).filter((k) => cmdHandledBy[k]);
+ const didntHandle = Object.keys(cmdHandledBy).filter((k) => !cmdHandledBy[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 ` +

MODIFY
lib/cli/args.js
lib/cli/args.js
@@ -4,6 +4,7 @@ import { INSTALL_TYPES, DEFAULT_APPIUM_HOME, DRIVER_TYPE, PLUGIN_TYPE } from '..
const DRIVER_EXAMPLE = 'xcuitest';
const PLUGIN_EXAMPLE = 'find_by_image';
+const USE_ALL_PLUGINS = 'all';
// sharedArgs will be added to every subcommand
const sharedArgs = [
@@ -35,7 +36,9 @@ const serverArgs = [
[['--plugins'], {
required: false,
defaultValue: [],
- help: 'A comma-separated list of installed plugin names that should be active for this server',
+ help: `A comma-separated list of installed plugin names that should be active for this ` +
+ `server. To activate all plugins, you can use the single string "${USE_ALL_PLUGINS}" ` +
+ `as the value (e.g. --plugins=${USE_ALL_PLUGINS})`,
type: parsePluginNames,
dest: 'plugins',
}],
@@ -436,4 +439,5 @@ export {
sharedArgs,
serverArgs,
extensionArgs,
+ USE_ALL_PLUGINS,
};

MODIFY
lib/cli/extension-command.js
lib/cli/extension-command.js
@@ -15,6 +15,19 @@ class NoUpdatesAvailableError extends Error {}
export default class ExtensionCommand {
+ /**
+ * @typedef {Object} ExtensionCommandConstructor
+ * @property {Object} config - the DriverConfig or PluginConfig object used for this command
+ * @property {boolean} json - whether the output of this command should be JSON or text
+ * @property {string} type - DRIVER_TYPE or PLUGIN_TYPE
+ */
+
+ /**
+ * Build an ExtensionCommand
+ *
+ * @param {ExtensionCommandConstructor} opts
+ * @return {ExtensionCommand}
+ */
constructor ({config, json, type}) {
this.config = config;
this.type = type;
@@ -23,14 +36,33 @@ export default class ExtensionCommand {
this.knownExtensions = {}; // this needs to be overridden in final class
}
+ /**
+ * Take a CLI parse and run an extension command based on its type
+ *
+ * @param {object} args - a key/value object with CLI flags and values
+ * @return {object} the result of the specific command which is executed
+ */
async execute (args) {
const cmd = args[`${this.type}Command`];
if (!_.isFunction(ExtensionCommand.prototype[cmd])) {
throw new Error(`Cannot handle ${this.type} command ${cmd}`);
}
- return await this[cmd].bind(this)(args);
+ const executeCmd = this[cmd].bind(this);
+ return await executeCmd(args);
}
+ /**
+ * @typedef {Object} ListArgs
+ * @property {boolean} showInstalled - whether should show only installed extensions
+ * @property {boolean} showUpdates - whether should show available updates
+ */
+
+ /**
+ * List extensions
+ *
+ * @param {ListArgs} args
+ * @return {object} map of extension names to extension data
+ */
async list ({showInstalled, showUpdates}) {
const lsMsg = `Listing ${showInstalled ? 'installed' : 'available'} ${this.type}s`;
const installedNames = Object.keys(this.config.installedExtensions);
@@ -106,6 +138,19 @@ export default class ExtensionCommand {
return exts;
}
+ /**
+ * @typedef {Object} InstallArgs
+ * @property {string} ext - the name or spec of an extension to install
+ * @property {string} installType - how to install this extension. One of the INSTALL_TYPES
+ * @property {string} [packageName] - for git/github installs, the extension node package name
+ */
+
+ /**
+ * Install an extension
+ *
+ * @param {InstallArgs} args
+ * @return {object} map of all installed extension names to extension data
+ */
async install ({ext, installType, packageName}) {
log(this.isJsonOutput, `Attempting to find and install ${this.type} '${ext}'`);
@@ -197,6 +242,18 @@ export default class ExtensionCommand {
return this.config.installedExtensions;
}
+ /**
+ * @typedef {Object} InstallViaNpmArgs
+ * @property {string} ext - the name or spec of an extension to install
+ * @property {string} pkgName - the NPM package name of the extension
+ * @property {string} [pkgVer] - the specific version of the NPM package
+ */
+
+ /**
+ * Install an extension via NPM
+ *
+ * @param {InstallViaNpmArgs} args
+ */
async installViaNpm ({ext, pkgName, pkgVer}) {
const npmSpec = `${pkgName}${pkgVer ? '@' + pkgVer : ''}`;
const specMsg = npmSpec === ext ? '' : ` using NPM install spec '${npmSpec}'`;
@@ -217,7 +274,19 @@ export default class ExtensionCommand {
}
}
- getPostInstallText () {
+ /**
+ * @typedef {Object} ExtensionArgs
+ * @property {string} extName - the name of an extension
+ * @property {object} extData - the data for an installed extension
+ */
+
+ /**
+ * Get the text which should be displayed to the user after an extension has been installed. This
+ * is designed to be overridden by drivers/plugins with their own particular text.
+ *
+ * @param {ExtensionArgs} args
+ */
+ getPostInstallText (/*{extName, extData}*/) {
throw new Error('Must be implemented in final class');
}
@@ -227,7 +296,7 @@ export default class ExtensionCommand {
* load as the main driver class, or to be able to detect incompatibilities between driver and
* appium versions.
*
- * @param [object] pkgJsonData - the package.json data for a driver module, as if it had been
+ * @param {object} pkgJsonData - the package.json data for a driver module, as if it had been
* straightforwardly 'require'd
*/
getExtensionFields (pkgJsonData) {
@@ -236,14 +305,34 @@ export default class ExtensionCommand {
`package.json file as expected`);
}
const {appium, name, version} = pkgJsonData;
+ this.validateExtensionFields(appium);
return {...appium, pkgName: name, version};
}
+ /**
+ * For any package.json fields which a particular type of extension requires, validate the
+ * presence and form of those fields on the package.json data, throwing an error if anything is
+ * amiss.
+ *
+ * @param {object} appiumPkgData - the data in the "appium" field of package.json for an
+ * extension
+ */
validateExtensionFields (/*appiumPkgData*/) {
throw new Error('Must be implemented in final class');
}
+ /**
+ * @typedef {Object} UninstallArgs
+ * @property {string} ext - the name or spec of an extension to uninstall
+ */
+
+ /**
+ * Uninstall an extension
+ *
+ * @param {UninstallArgs} args
+ * @return {object} map of all installed extension names to extension data
+ */
async uninstall ({ext}) {
if (!this.config.isInstalled(ext)) {
throw new Error(`Can't uninstall ${this.type} '${ext}'; it is not installed`);
@@ -280,7 +369,7 @@ export default class ExtensionCommand {
* Attempt to update one or more drivers using NPM
*
* @param {ExtensionUpdateOpts} updateSpec
- * @returns {ExtensionUpdateResult}
+ * @return {ExtensionUpdateResult}
*/
async update ({ext, unsafe}) {
const shouldUpdateAll = ext === UPDATE_ALL;
@@ -359,7 +448,7 @@ export default class ExtensionCommand {
* highest possible safe upgrade.
*
* @param {string} ext - name of extension
- * @returns {PossibleUpdates}
+ * @return {PossibleUpdates}
*/
async checkForExtensionUpdate (ext) {
// TODO decide how we want to handle beta versions?

MODIFY
lib/cli/npm.js
lib/cli/npm.js
@@ -107,11 +107,11 @@ export default class NPM {
return safeUpgradeVer;
}
- async installPackage ({pkgDir, pkgName, ver}) {
+ async installPackage ({pkgDir, pkgName, pkgVer}) {
const res = await this.exec('install', [
'--no-save',
'--no-package-lock',
- ver ? `${pkgName}@${ver}` : pkgName
+ pkgVer ? `${pkgName}@${pkgVer}` : pkgName
], {
cwd: pkgDir,
json: true,

MODIFY
lib/cli/parser-helpers.js
lib/cli/parser-helpers.js
@@ -29,9 +29,12 @@ function parseSecurityFeatures (features) {
}
function parsePluginNames (names) {
- const splitter = (splitOn, str) => `${str}`.split(splitOn).map((s) => s.trim()).filter(Boolean);
+ if (!_.isString(names)) {
+ throw new Error('To parse plugin names, names must be a CSV string');
+ }
+
try {
- return splitter(',', names);
+ return names.split(',').map((s) => s.trim()).filter(Boolean);
} catch (err) {
throw new Error('Could not parse value of --plugins. Should be a list of plugin names ' +
'separated by commas. Plugin names are those found when running `appium ' +

MODIFY
lib/config.js
lib/config.js
@@ -100,7 +100,7 @@ async function getGitTimestamp (commitSha, useGithubApiFallback = false) {
}
/**
- * @returns Mutable object containing Appium build information. By default it
+ * @return Mutable object containing Appium build information. By default it
* only contains the Appium version, but is updated with the build timestamp
* and git commit hash asynchronously as soon as `updateBuildInfo` is called
* and succeeds.

MODIFY
lib/main.js
lib/main.js
@@ -7,6 +7,7 @@ import _ from 'lodash';
import { server as baseServer, routeConfiguringFunction as makeRouter } from 'appium-base-driver';
import { asyncify } from 'asyncbox';
import { default as getParser, getDefaultServerArgs } from './cli/parser';
+import { USE_ALL_PLUGINS } from './cli/args';
import { logger as logFactory, util } from 'appium-support';
import {
showConfig, checkNodeOk, validateServerArgs,
@@ -143,11 +144,18 @@ async function main (args = null) {
// 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)
+ _.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;
let server = await baseServer({
@@ -195,7 +203,8 @@ async function main (args = null) {
}
logServerPort(args.address, args.port);
- appiumDriver.driverConfig.print();
+ driverConfig.print();
+ pluginConfig.print(plugins);
return server;
}

MODIFY
lib/plugin-config.js
lib/plugin-config.js
+import _ from 'lodash';
import ExtensionConfig, { PLUGIN_TYPE } from './extension-config';
+import log from './logger';
export default class PluginConfig extends ExtensionConfig {
constructor (appiumHome, logFn = null) {
@@ -8,5 +10,25 @@ export default class PluginConfig extends ExtensionConfig {
extensionDesc (pluginName, {version}) {
return `${pluginName}@${version}`;
}
+
+ print (activePlugins) {
+ 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" ` +
+ 'command to install the one(s) you want to use.');
+ return;
+ }
+
+ log.info(`Available plugins:`);
+ for (const [pluginName, pluginData] of _.toPairs(this.installedExtensions)) {
+ const activeTxt = _.includes(activeNames, pluginName) ? ' (ACTIVE)' : '';
+ log.info(` - ${this.extensionDesc(pluginName, pluginData)}${activeTxt}`);
}
+ if (_.isEmpty(activePlugins)) {
+ log.info('No plugins activated. Use the --plugins flag with names of plugins to activate');
+ }
+ }
+}

MODIFY
lib/utils.js
lib/utils.js
@@ -169,7 +169,7 @@ function parseCapsForInnerDriver (jsonwpCapabilities, w3cCapabilities, constrain
*
* @param {Object} w3cCaps W3C capabilities
* @param {Object} jsonwpCaps JSONWP capabilities
- * @returns {Object} Fixed W3C capabilities
+ * @return {Object} Fixed W3C capabilities
*/
function fixW3cCapabilities (w3cCaps, jsonwpCaps) {
const result = {
@@ -267,7 +267,7 @@ function getPackageVersion (pkgName) {
*
* @param {?Object} caps - Capabilities dictionary. It is mutated if
* one or more settings have been pulled from it
- * @returns {Object} - An empty dictionary if the given caps contains no
+ * @return {Object} - An empty dictionary if the given caps contains no
* setting items or a dictionary containing parsed Appium setting names along with
* their values.
*/

MODIFY
test/cli-e2e-specs.js
test/cli-e2e-specs.js
@@ -94,7 +94,8 @@ describe('Driver CLI', function () {
});
it('should install a driver from github', async function () {
await clear();
- const ret = JSON.parse(await run('install', ['appium/appium-fake-driver', '--source', 'github', '--json']));
+ const ret = JSON.parse(await run('install', ['appium/appium-fake-driver', '--source',
+ 'github', '--package', 'appium-fake-driver', '--json']));
ret.fake.pkgName.should.eql('appium-fake-driver');
ret.fake.installType.should.eql('github');
ret.fake.installSpec.should.eql('appium/appium-fake-driver');
@@ -104,7 +105,8 @@ describe('Driver CLI', function () {
});
it('should install a driver from git', async function () {
await clear();
- const ret = JSON.parse(await run('install', ['git+https://github.com/appium/appium-fake-driver.git', '--source', 'git', '--json']));
+ const ret = JSON.parse(await run('install', ['git+https://github.com/appium/appium-fake-driver.git',
+ '--source', 'git', '--package', 'appium-fake-driver', '--json']));
ret.fake.pkgName.should.eql('appium-fake-driver');
ret.fake.installType.should.eql('git');
ret.fake.installSpec.should.eql('git+https://github.com/appium/appium-fake-driver');

MODIFY
test/plugin-e2e-specs.js
test/plugin-e2e-specs.js
@@ -93,11 +93,13 @@ describe('FakePlugin', function () {
});
});
- describe('with plugin registered', function () {
+ for (const registrationType of ['explicit', 'all']) {
+ describe(`with plugin registered via type ${registrationType}`, function () {
let server = null;
before(async function () {
// then start server if we need to
- const args = {port: TEST_PORT, host: TEST_HOST, appiumHome, plugins: ['fake']};
+ const plugins = registrationType === 'explicit' ? ['fake', 'p2', 'p3'] : ['all'];
+ const args = {port: TEST_PORT, host: TEST_HOST, appiumHome, plugins};
server = await appiumServer(args);
});
after(async function () {
@@ -142,4 +144,5 @@ describe('FakePlugin', function () {
}
});
});
+ }
});

feat: allow use of --plugins=all to activate all plugins, and show helpful startup text