appium
Version:
Automation for Apps.
980 lines (875 loc) • 36 kB
JavaScript
import _ from 'lodash';
import {getBuildInfo, updateBuildInfo, APPIUM_VER} from './config';
import {
BaseDriver,
DriverCore,
errors,
isSessionCommand,
PROTOCOLS,
CREATE_SESSION_COMMAND,
DELETE_SESSION_COMMAND,
GET_STATUS_COMMAND,
LIST_DRIVER_COMMANDS_COMMAND,
LIST_DRIVER_EXTENSIONS_COMMAND,
promoteAppiumOptions,
promoteAppiumOptionsForObject,
generateDriverLogPrefix,
isW3cCaps,
} from '@appium/base-driver';
import AsyncLock from 'async-lock';
import {parseCapsForInnerDriver, pullSettings, makeNonW3cCapsError} from './utils';
import {util} from '@appium/support';
import {getDefaultsForExtension} from './schema';
import {DRIVER_TYPE, BIDI_BASE_PATH, SESSION_DISCOVERY_FEATURE} from './constants';
import * as bidiCommands from './bidi-commands';
import * as insecureFeatures from './insecure-features';
import * as inspectorCommands from './inspector-commands';
const desiredCapabilityConstraints = /** @type {const} */ ({
automationName: {
presence: true,
isString: true,
},
platformName: {
presence: true,
isString: true,
},
});
const sessionsListGuard = new AsyncLock();
const pendingDriversGuard = new AsyncLock();
/**
* @extends {DriverCore<AppiumDriverConstraints>}
*/
class AppiumDriver extends DriverCore {
/**
* Access to sessions list must be guarded with a Semaphore, because
* it might be changed by other async calls at any time
* It is not recommended to access this property directly from the outside
* @type {Record<string,ExternalDriver>}
*/
sessions;
/**
* Access to pending drivers list must be guarded with a Semaphore, because
* it might be changed by other async calls at any time
* It is not recommended to access this property directly from the outside
* @type {Record<string,ExternalDriver[]>}
*/
pendingDrivers;
/**
* Note that {@linkcode AppiumDriver} has no `newCommandTimeout` method.
* `AppiumDriver` does not set and observe its own timeouts; individual
* sessions (managed drivers) do instead.
*/
newCommandTimeoutMs;
/**
* List of active plugins
* @type {Map<PluginClass,string>}
*/
pluginClasses;
/**
* map of sessions to actual plugin instances per session
* @type {Record<string,InstanceType<PluginClass>[]>}
*/
sessionPlugins;
/**
* some commands are sessionless, so we need a set of plugins for them
* @type {InstanceType<PluginClass>[]}
*/
sessionlessPlugins;
/** @type {DriverConfig} */
driverConfig;
/** @type {AppiumServer} */
server;
/** @type {Record<string, import('ws').WebSocket[]>} */
bidiSockets;
/** @type {Record<string, import('ws').WebSocket>} */
bidiProxyClients;
/**
* @type {AppiumDriverConstraints}
* @readonly
*/
desiredCapConstraints;
/** @type {import('@appium/types').DriverOpts<AppiumDriverConstraints>} */
args;
/**
* @param {import('@appium/types').DriverOpts<AppiumDriverConstraints>} opts
*/
constructor(opts) {
// It is necessary to set `--tmp` here since it should be set to
// process.env.APPIUM_TMP_DIR once at an initial point in the Appium lifecycle.
// The process argument will be referenced by BaseDriver.
// Please call @appium/support.tempDir module to apply this benefit.
if (opts.tmpDir) {
process.env.APPIUM_TMP_DIR = opts.tmpDir;
}
super(opts);
this.args = {...opts};
this.sessions = {};
this.pendingDrivers = {};
this.newCommandTimeoutMs = 0;
this.pluginClasses = new Map();
this.sessionPlugins = {};
this.sessionlessPlugins = [];
this.bidiSockets = {};
this.bidiProxyClients = {};
this.desiredCapConstraints = desiredCapabilityConstraints;
this._isShuttingDown = false;
// allow this to happen in the background, so no `await`
(async () => {
try {
await updateBuildInfo();
} catch (e) {
// make sure we catch any possible errors to avoid unhandled rejections
this.log.debug(`Cannot fetch Appium build info: ${e.message}`);
}
})();
}
/**
* Cancel commands queueing for the umbrella Appium driver
*/
get isCommandsQueueEnabled() {
return false;
}
sessionExists(sessionId) {
const dstSession = this.sessions[sessionId];
return dstSession && dstSession.sessionId !== null;
}
driverForSession(sessionId) {
return this.sessions[sessionId];
}
async getStatus() {
// https://www.w3.org/TR/webdriver/#dfn-status
const statusObj = this._isShuttingDown
? {
ready: false,
message: 'The server is shutting down',
}
: {
ready: true,
message: 'The server is ready to accept new connections',
};
return {
...statusObj,
build: _.clone(getBuildInfo()),
};
}
/**
* @param {string|null} reason An optional shutdown reason
*/
async shutdown(reason = null) {
this._isShuttingDown = true;
await this.deleteAllSessions({
force: true,
reason,
});
}
/**
* Retrieve information about all active sessions.
* Results are returned only if the `session_discovery` insecure feature is enabled.
* @returns {Promise<import('@appium/types').TimestampedMultiSessionData[]>}
*/
async getAppiumSessions () {
this.assertFeatureEnabled(SESSION_DISCOVERY_FEATURE);
return _.toPairs(this.sessions).map(([id, driver]) => ({
id,
created: driver.sessionCreationTimestampMs,
capabilities: /** @type {import('@appium/types').DriverCaps<any>} */ (driver.caps),
}));
}
printNewSessionAnnouncement(driverName, driverVersion, driverBaseVersion) {
this.log.info(
driverVersion
? `Appium v${APPIUM_VER} creating new ${driverName} (v${driverVersion}) session`
: `Appium v${APPIUM_VER} creating new ${driverName} session`,
);
this.log.info(`Checking BaseDriver versions for Appium and ${driverName}`);
this.log.info(
AppiumDriver.baseVersion
? `Appium's BaseDriver version is ${AppiumDriver.baseVersion}`
: `Could not determine Appium's BaseDriver version`,
);
this.log.info(
driverBaseVersion
? `${driverName}'s BaseDriver version is ${driverBaseVersion}`
: `Could not determine ${driverName}'s BaseDriver version`,
);
}
/**
* Retrieves all CLI arguments for a specific plugin.
* @param {string} extName - Plugin name
* @returns {StringRecord} Arguments object. If none, an empty object.
*/
getCliArgsForPlugin(extName) {
return /** @type {StringRecord} */ (this.args.plugin?.[extName] ?? {});
}
/**
* Retrieves CLI args for a specific driver.
*
* _Any arg which is equal to its default value will not be present in the returned object._
*
* _Note that this behavior currently (May 18 2022) differs from how plugins are handled_ (see {@linkcode AppiumDriver.getCliArgsForPlugin}).
* @param {string} extName - Driver name
* @returns {StringRecord|undefined} Arguments object. If none, `undefined`
*/
getCliArgsForDriver(extName) {
const allCliArgsForExt = /** @type {StringRecord|undefined} */ (this.args.driver?.[extName]);
if (!_.isEmpty(allCliArgsForExt)) {
const defaults = getDefaultsForExtension(DRIVER_TYPE, extName);
const cliArgs = _.isEmpty(defaults)
? allCliArgsForExt
: _.omitBy(allCliArgsForExt, (value, key) => _.isEqual(defaults[key], value));
if (!_.isEmpty(cliArgs)) {
return cliArgs;
}
}
}
/**
* Create a new session
*
* @param {W3CAppiumDriverCaps} w3cCapabilities1 W3C capabilities
* @param {W3CAppiumDriverCaps} [w3cCapabilities2] W3C capabilities (legacy)
* @param {W3CAppiumDriverCaps} [w3cCapabilities3] W3C capabilities (legacy)
* @returns {Promise<SessionHandlerCreateResult>}
*/
async createSession(w3cCapabilities1, w3cCapabilities2, w3cCapabilities3) {
const defaultCapabilities = _.cloneDeep(this.args.defaultCapabilities);
const defaultSettings = pullSettings(defaultCapabilities);
const w3cCapabilities = _.cloneDeep(
[w3cCapabilities3, w3cCapabilities2, w3cCapabilities1].find(isW3cCaps)
);
if (!w3cCapabilities) {
throw makeNonW3cCapsError();
}
const w3cSettings = {
...defaultSettings,
...pullSettings(w3cCapabilities.alwaysMatch ?? {}),
};
for (const firstMatchEntry of w3cCapabilities.firstMatch ?? []) {
Object.assign(w3cSettings, pullSettings(firstMatchEntry));
}
const protocol = PROTOCOLS.W3C;
let innerSessionId, dCaps;
try {
// Parse the caps into a format that the InnerDriver will accept
const parsedCaps = parseCapsForInnerDriver(
promoteAppiumOptions(/** @type {W3CAppiumDriverCaps} */ (w3cCapabilities)),
this.desiredCapConstraints,
defaultCapabilities ? promoteAppiumOptionsForObject(defaultCapabilities) : undefined,
);
const {desiredCaps, processedW3CCapabilities} =
/** @type {import('./utils').ParsedDriverCaps<AppiumDriverConstraints>} */ (parsedCaps);
const error = /** @type {import('./utils').InvalidCaps<AppiumDriverConstraints>} */ (
parsedCaps
).error;
// If the parsing of the caps produced an error, throw it in here
if (error) {
throw error;
}
const {
driver: InnerDriver,
version: driverVersion,
driverName,
} = await this.driverConfig.findMatchingDriver(desiredCaps);
this.printNewSessionAnnouncement(InnerDriver.name, driverVersion, InnerDriver.baseVersion);
if (this.args.sessionOverride) {
await this.deleteAllSessions();
}
/**
* @type {DriverData[]}
*/
let runningDriversData = [];
/**
* @type {DriverData[]}
*/
let otherPendingDriversData = [];
const driverInstance = /** @type {ExternalDriver} */ (new InnerDriver(this.args, true));
this.configureDriverFeatures(driverInstance, driverName);
// We also want to assign any new Bidi Commands that the driver has specified, including all
// the standard bidi commands. But add a method existence guard since some old driver class
// instances might not have this method
if (_.isFunction(driverInstance.updateBidiCommands)) {
driverInstance.updateBidiCommands(InnerDriver.newBidiCommands ?? {});
}
// Likewise, any driver-specific CLI args that were passed in should be assigned directly to
// the driver so that they cannot be mimicked by a malicious user sending in capabilities
const cliArgs = this.getCliArgsForDriver(driverName);
if (!_.isUndefined(cliArgs)) {
driverInstance.cliArgs = cliArgs;
}
// This assignment is required for correct web sockets functionality inside the driver
// Drivers/plugins might also want to know where they are hosted
// XXX: temporary hack to work around #16747
driverInstance.server = this.server;
driverInstance.serverHost = this.args.address;
driverInstance.serverPort = this.args.port;
driverInstance.serverPath = this.args.basePath;
try {
runningDriversData = (await this.curSessionDataForDriver(InnerDriver)) ?? [];
} catch (e) {
throw new errors.SessionNotCreatedError(e.message);
}
await pendingDriversGuard.acquire(AppiumDriver.name, () => {
this.pendingDrivers[InnerDriver.name] = this.pendingDrivers[InnerDriver.name] || [];
otherPendingDriversData = _.compact(
this.pendingDrivers[InnerDriver.name].map((drv) => drv.driverData),
);
this.pendingDrivers[InnerDriver.name].push(driverInstance);
});
try {
[innerSessionId, dCaps] = await driverInstance.createSession(
/** @type {any} */ (processedW3CCapabilities),
processedW3CCapabilities,
processedW3CCapabilities,
[...runningDriversData, ...otherPendingDriversData],
);
this.sessions[innerSessionId] = driverInstance;
} finally {
await pendingDriversGuard.acquire(AppiumDriver.name, () => {
_.pull(this.pendingDrivers[InnerDriver.name], driverInstance);
});
}
this.attachUnexpectedShutdownHandler(driverInstance, innerSessionId);
this.log.info(
`New ${InnerDriver.name} session created successfully, session ` +
`${innerSessionId} added to master session list`,
);
// set the New Command Timeout for the inner driver
await driverInstance.startNewCommandTimeout();
// apply initial values to Appium settings (if provided)
if (driverInstance.isW3CProtocol() && !_.isEmpty(w3cSettings)) {
this.log.info(
`Applying the initial values to Appium settings parsed from W3C caps: ` +
JSON.stringify(w3cSettings),
);
await driverInstance.updateSettings(w3cSettings);
}
// if the user has asked for bidi support, send our bidi url back to the user. The inner
// driver will need to have already saved any internal bidi urls it might want to proxy to,
// cause we are going to overwrite that information here!
if (dCaps.webSocketUrl) {
const {address, port, basePath} = this.args;
const scheme = `ws${this.server.isSecure() ? 's' : ''}`;
const host = bidiCommands.determineBiDiHost(address);
const bidiUrl = `${scheme}://${host}:${port}${basePath}${BIDI_BASE_PATH}/${innerSessionId}`;
this.log.info(
`Upstream driver responded with webSocketUrl ${dCaps.webSocketUrl}, will rewrite to ` +
`${bidiUrl} for response to client`
);
// @ts-ignore webSocketUrl gets sent by the client as a boolean, but then it is supposed
// to come back from the server as a string. TODO figure out how to express this in our
// capability constraint system
dCaps.webSocketUrl = bidiUrl;
}
} catch (error) {
return {
protocol,
error,
};
}
return {
protocol,
value: [innerSessionId, dCaps, protocol],
};
}
/**
*
* @param {ExternalDriver} driver
* @param {string} innerSessionId
*/
attachUnexpectedShutdownHandler(driver, innerSessionId) {
const onShutdown = (cause = new Error('Unknown error')) => {
this.log.warn(`Ending session, cause was '${cause.message}'`);
if (this.sessionPlugins[innerSessionId]) {
for (const plugin of this.sessionPlugins[innerSessionId]) {
if (_.isFunction(plugin.onUnexpectedShutdown)) {
this.log.debug(
`Plugin ${plugin.name} defines an unexpected shutdown handler; calling it now`,
);
try {
plugin.onUnexpectedShutdown(driver, cause);
} catch (e) {
this.log.warn(
`Got an error when running plugin ${plugin.name} shutdown handler: ${e}`,
);
}
} else {
this.log.debug(`Plugin ${plugin.name} does not define an unexpected shutdown handler`);
}
}
}
this.log.info(`Removing session '${innerSessionId}' from our master session list`);
delete this.sessions[innerSessionId];
delete this.sessionPlugins[innerSessionId];
};
if (_.isFunction(driver.onUnexpectedShutdown)) {
driver.onUnexpectedShutdown(onShutdown);
} else {
this.log.warn(
`Failed to attach the unexpected shutdown listener. ` +
`Is 'onUnexpectedShutdown' method available for '${driver.constructor.name}'?`,
);
}
}
/**
*
* @param {((...args: any[]) => any)|(new(...args: any[]) => any)} InnerDriver
* @returns {Promise<DriverData[]>}}
* @privateRemarks The _intent_ is that `InnerDriver` is the class of a driver, but it only really
* needs to be a function or constructor.
*/
async curSessionDataForDriver(InnerDriver) {
const data = _.compact(
_.values(this.sessions)
.filter((s) => s.constructor.name === InnerDriver.name)
.map((s) => s.driverData),
);
for (const datum of data) {
if (!datum) {
throw new Error(
`Problem getting session data for driver type ` +
`${InnerDriver.name}; does it implement 'get driverData'?`,
);
}
}
return data;
}
/**
* @param {string} sessionId
*/
async deleteSession(sessionId) {
let protocol;
try {
let otherSessionsData;
const dstSession = await sessionsListGuard.acquire(AppiumDriver.name, () => {
if (!this.sessions[sessionId]) {
return;
}
const curConstructorName = this.sessions[sessionId].constructor.name;
otherSessionsData = _.toPairs(this.sessions)
.filter(
([key, value]) => value.constructor.name === curConstructorName && key !== sessionId,
)
.map(([, value]) => value.driverData);
const dstSession = this.sessions[sessionId];
protocol = dstSession.protocol;
this.log.info(`Removing session ${sessionId} from our master session list`);
// regardless of whether the deleteSession completes successfully or not
// make the session unavailable, because who knows what state it might
// be in otherwise
delete this.sessions[sessionId];
delete this.sessionPlugins[sessionId];
this.cleanupBidiSockets(sessionId);
return dstSession;
});
// this may not be correct, but if `dstSession` was falsy, the call to `deleteSession()` would
// throw anyway.
if (!dstSession) {
throw new Error('Session not found');
}
return {
protocol,
value: await dstSession.deleteSession(sessionId, otherSessionsData),
};
} catch (e) {
this.log.error(`Had trouble ending session ${sessionId}: ${e.message}`);
return {
protocol,
error: e,
};
}
}
async deleteAllSessions(opts = {}) {
const sessionsCount = _.size(this.sessions);
if (0 === sessionsCount) {
this.log.debug('There are no active sessions for cleanup');
return;
}
const {force = false, reason} = opts;
this.log.debug(`Cleaning up ${util.pluralize('active session', sessionsCount, true)}`);
const cleanupPromises = force
? _.values(this.sessions).map((drv) =>
drv.startUnexpectedShutdown(reason && new Error(reason)),
)
: _.keys(this.sessions).map((id) => this.deleteSession(id));
for (const cleanupPromise of cleanupPromises) {
try {
await cleanupPromise;
} catch (e) {
this.log.debug(e);
}
}
}
/**
* Get the appropriate plugins for a session (or sessionless plugins)
*
* @param {?string} sessionId - the sessionId (or null) to use to find plugins
* @returns {Array<import('@appium/types').Plugin>} - array of plugin instances
*/
pluginsForSession(sessionId = null) {
if (sessionId) {
if (!this.sessionPlugins[sessionId]) {
const driverId = generateDriverLogPrefix(this.sessions[sessionId]);
this.sessionPlugins[sessionId] = this.createPluginInstances(driverId || null);
}
return this.sessionPlugins[sessionId];
}
if (_.isEmpty(this.sessionlessPlugins)) {
this.sessionlessPlugins = this.createPluginInstances();
}
return this.sessionlessPlugins;
}
/**
* 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 = null) {
// 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.pluginsForSession(sessionId).filter(
(p) => _.isFunction(p[cmd]) || _.isFunction(p.handle),
);
}
/**
* Creates instances of all of the enabled Plugin classes
* @param {string|null} driverId - ID to use for linking a driver to a plugin in logs
* @returns {Plugin[]}
*/
createPluginInstances(driverId = null) {
/** @type {Plugin[]} */
const pluginInstances = [];
for (const [PluginClass, name] of this.pluginClasses.entries()) {
const cliArgs = this.getCliArgsForPlugin(name);
const plugin = new PluginClass(name, cliArgs, driverId);
if (_.isFunction(/** @type {Plugin & ExtensionCore} */(plugin).updateBidiCommands)) {
// some old plugin classes don't have `updateBidiCommands`
/** @type {Plugin & ExtensionCore} */(plugin).updateBidiCommands(PluginClass.newBidiCommands ?? {});
}
pluginInstances.push(plugin);
}
return pluginInstances;
}
/**
*
* @param {string} cmd
* @param {...any} args
* @returns {Promise<{value: any, error?: Error, protocol: string} | import('type-fest').AsyncReturnType<ExternalDriver['executeCommand']>>}
*/
async executeCommand(cmd, ...args) {
// 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 === GET_STATUS_COMMAND;
const isUmbrellaCmd = isAppiumDriverCommand(cmd);
const isSessionCmd = isSessionCommand(cmd);
// if a plugin override proxying for this command and that is why we are here instead of just
// letting the protocol proxy the command entirely, determine that, get the request object for
// use later on, then clean up the args
const reqForProxy = _.last(args)?.reqForProxy;
if (reqForProxy) {
args.pop();
}
// 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;
let protocol = null;
/** @type {this | ExternalDriver} */
// eslint-disable-next-line @typescript-eslint/no-this-alias
let driver = this;
if (isSessionCmd) {
sessionId = _.last(args);
dstSession = this.sessions[sessionId];
if (!dstSession) {
throw new Error(`The session with id '${sessionId}' does not exist`);
}
// now save the response protocol given that the session driver's protocol might differ
protocol = dstSession.protocol;
if (!isUmbrellaCmd) {
driver = dstSession;
}
}
// get any plugins which are registered as handling this command
const plugins = this.pluginsToHandleCmd(cmd, sessionId);
// if any plugins are going to handle this command, we can't guarantee that the default
// driver's executeCommand method will be called, which means we can't guarantee that the
// newCommandTimeout will be cleared. So we do it here as well.
if (plugins.length && dstSession) {
this.log.debug(
'Clearing new command timeout pre-emptively since plugin(s) will handle this command',
);
await dstSession.clearNewCommandTimeout();
}
// 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 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 () => {
// 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
if (plugins.length) {
this.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 (reqForProxy) {
// we would have proxied this command had a plugin not handled it, so the default behavior
// is to do the proxy and retrieve the result internally so it can be passed to the plugin
// in case it calls 'await next()'. This requires that the driver have defined
// 'proxyCommand' and not just 'proxyReqRes'.
if (!dstSession?.proxyCommand) {
throw new NoDriverProxyCommandError();
}
return await dstSession.proxyCommand(
reqForProxy.originalUrl,
reqForProxy.method,
reqForProxy.body,
);
}
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 BaseDriver.prototype.executeCommand.call(this, cmd, ...args);
}
// here we know that we are executing a session command, and have a valid session driver
return await /** @type {any} */ (dstSession).executeCommand(cmd, ...args);
};
// now take our default behavior, wrap it with any number of plugin behaviors, and run it
const wrappedCmd = this.wrapCommandWithPlugins({
driver,
cmd,
args,
plugins,
cmdHandledBy,
next: defaultBehavior,
});
const res = await this.executeWrappedCommand({wrappedCmd, protocol});
// if we had plugins, make sure to log out the helpful report about which plugins ended up
// handling the command and which didn't
this.logPluginHandlerReport(plugins, {cmd, cmdHandledBy});
// if we had plugins, and if they did not ultimately call the default handler, this means our
// new command timeout was not restarted by the default handler's executeCommand call, so
// restart it here using the same logic as in BaseDriver's executeCommand
if (
dstSession &&
!cmdHandledBy.default &&
dstSession.isCommandsQueueEnabled &&
cmd !== DELETE_SESSION_COMMAND
) {
this.log.debug(
'Restarting new command timeout via umbrella driver since plugin did not ' +
'allow default handler to execute',
);
await dstSession.startNewCommandTimeout();
}
// And finally, if the command was createSession, we want to migrate any plugins which were
// previously sessionless to use the new sessionId, so that plugins can share state between
// their createSession method and other instance methods
if (cmd === CREATE_SESSION_COMMAND && this.sessionlessPlugins.length && !res.error) {
const sessionId = _.first(res.value);
this.log.info(
`Promoting ${this.sessionlessPlugins.length} sessionless plugins to be attached ` +
`to session ID ${sessionId}`,
);
this.sessionPlugins[sessionId] = this.sessionlessPlugins;
for (const p of /** @type {(Plugin & ExtensionCore)[]} */(this.sessionPlugins[sessionId])) {
if (_.isFunction(p.updateLogPrefix)) {
// some old plugin classes don't have `updateLogPrefix` yet
p.updateLogPrefix(`${generateDriverLogPrefix(p)} <${generateDriverLogPrefix(this.sessions[sessionId])}>`);
}
}
this.sessionlessPlugins = [];
}
return res;
}
wrapCommandWithPlugins({driver, cmd, args, next, cmdHandledBy, plugins}) {
if (plugins.length) {
this.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.
cmdHandledBy[plugin.name] = false; // we see a new plugin, so add it to the 'cmdHandledBy' object
next = ((_next) => async () => {
this.log.info(`Plugin ${plugin.name} is now handling cmd '${cmd}'`);
cmdHandledBy[plugin.name] = true; // if we make it here, this plugin has attempted to handle cmd
// first attempt to handle the command via a command-specific handler on the plugin
if (plugin[cmd]) {
return await plugin[cmd](_next, driver, ...args);
}
// otherwise, call the generic 'handle' method
return await plugin.handle(_next, driver, cmd, ...args);
})(next);
}
return next;
}
logPluginHandlerReport(plugins, {cmd, cmdHandledBy}) {
if (!plugins.length) {
return;
}
// 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(cmdHandledBy).filter((k) => cmdHandledBy[k]);
const didntHandle = Object.keys(cmdHandledBy).filter((k) => !cmdHandledBy[k]);
if (didntHandle.length > 0) {
this.log.info(
`Command '${cmd}' was *not* handled by the following behaviours or plugins, even ` +
`though they were registered to handle it: ${JSON.stringify(didntHandle)}. The ` +
`command *was* handled by these: ${JSON.stringify(didHandle)}.`,
);
}
}
async executeWrappedCommand({wrappedCmd, protocol}) {
let cmdRes,
cmdErr,
res = {};
try {
// At this point, `wrappedCmd` 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 wrappedCmd();
} catch (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;
res.protocol = protocol;
}
return res;
}
proxyActive(sessionId) {
const dstSession = this.sessions[sessionId];
return dstSession && _.isFunction(dstSession.proxyActive) && dstSession.proxyActive(sessionId);
}
/**
*
* @param {string} sessionId
* @returns {import('@appium/types').RouteMatcher[]}
*/
getProxyAvoidList(sessionId) {
const dstSession = this.sessions[sessionId];
return dstSession ? dstSession.getProxyAvoidList() : [];
}
canProxy(sessionId) {
const dstSession = this.sessions[sessionId];
return dstSession && dstSession.canProxy(sessionId);
}
onBidiConnection = bidiCommands.onBidiConnection;
onBidiMessage = bidiCommands.onBidiMessage;
onBidiServerError = bidiCommands.onBidiServerError;
cleanupBidiSockets = bidiCommands.cleanupBidiSockets;
configureGlobalFeatures = insecureFeatures.configureGlobalFeatures;
configureDriverFeatures = insecureFeatures.configureDriverFeatures;
listCommands = inspectorCommands.listCommands;
listExtensions = inspectorCommands.listExtensions;
}
/**
* Help decide which commands should be proxied to sub-drivers and which
* should be handled by this, our umbrella driver
* @param {string} cmd
* @returns {boolean}
*/
function isAppiumDriverCommand(cmd) {
return !isSessionCommand(cmd)
|| _.includes([
DELETE_SESSION_COMMAND,
LIST_DRIVER_COMMANDS_COMMAND,
LIST_DRIVER_EXTENSIONS_COMMAND,
], cmd);
}
/**
* Thrown when Appium tried to proxy a command using a driver's `proxyCommand` method but the
* method did not exist
*/
export class NoDriverProxyCommandError extends Error {
/**
* @type {Readonly<string>}
*/
code = 'APPIUMERR_NO_DRIVER_PROXYCOMMAND';
constructor() {
super(
`The default behavior for this command was to proxy, but the driver ` +
`did not have the 'proxyCommand' method defined. To fully support ` +
`plugins, drivers should have 'proxyCommand' set to a jwpProxy object's ` +
`'command()' method, in addition to the normal 'proxyReqRes'`,
);
}
}
export {AppiumDriver};
/**
* @typedef {import('@appium/types').DriverData} DriverData
* @typedef {import('@appium/types').ServerArgs} DriverOpts
* @typedef {import('@appium/types').Constraints} Constraints
* @typedef {import('@appium/types').AppiumServer} AppiumServer
* @typedef {import('@appium/types').ExtensionType} ExtensionType
* @typedef {import('./extension/driver-config').DriverConfig} DriverConfig
* @typedef {import('@appium/types').PluginType} PluginType
* @typedef {import('@appium/types').DriverType} DriverType
* @typedef {import('@appium/types').StringRecord} StringRecord
* @typedef {import('@appium/types').ExternalDriver} ExternalDriver
* @typedef {import('@appium/types').PluginClass} PluginClass
* @typedef {import('@appium/types').Plugin} Plugin
* @typedef {import('@appium/base-driver').ExtensionCore} ExtensionCore
* @typedef {import('@appium/types').DriverClass<import('@appium/types').Driver>} DriverClass
*/
/**
* @typedef {import('@appium/types').ISessionHandler<AppiumDriverConstraints,
* SessionHandlerCreateResult, SessionHandlerDeleteResult>} AppiumSessionHandler
*/
/**
* @typedef {SessionHandlerResult<[innerSessionId: string, caps:
* import('@appium/types').DriverCaps<Constraints>, protocol: string|undefined]>} SessionHandlerCreateResult
*/
/**
* @template {Constraints} C
* @typedef {import('@appium/types').Core<C>} Core
*/
/**
* @typedef {SessionHandlerResult<void>} SessionHandlerDeleteResult
*/
/**
* Used by {@linkcode AppiumDriver.createSession} and {@linkcode AppiumDriver.deleteSession} to describe
* result.
* @template V
* @typedef SessionHandlerResult
* @property {V} [value]
* @property {Error} [error]
* @property {string} [protocol]
*/
/**
* @typedef {typeof desiredCapabilityConstraints} AppiumDriverConstraints
* @typedef {import('@appium/types').W3CDriverCaps<AppiumDriverConstraints>} W3CAppiumDriverCaps
*/