import _ from 'lodash'; import log from './logger'; import { getBuildInfo, updateBuildInfo, APPIUM_VER } from './config'; import { findMatchingDriver } from './drivers'; import { BaseDriver, errors, isSessionCommand } from 'appium-base-driver'; import B from 'bluebird'; import AsyncLock from 'async-lock'; import { parseCapsForInnerDriver, pullSettings } from './utils'; import { util } from 'appium-support'; const desiredCapabilityConstraints = { automationName: { presence: true, isString: true, }, platformName: { presence: true, isString: true, }, }; const sessionsListGuard = new AsyncLock(); const pendingDriversGuard = new AsyncLock(); class AppiumDriver extends BaseDriver { constructor (args) { // 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 (args.tmpDir) { process.env.APPIUM_TMP_DIR = args.tmpDir; } super(args); this.desiredCapConstraints = desiredCapabilityConstraints; // the main Appium Driver has no new command timeout this.newCommandTimeoutMs = 0; this.args = Object.assign({}, args); // 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 this.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 this.pendingDrivers = {}; 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(); } /** * 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 () { // eslint-disable-line require-await return { build: _.clone(getBuildInfo()), }; } async getSessions () { const sessions = await sessionsListGuard.acquire(AppiumDriver.name, () => this.sessions); return _.toPairs(sessions) .map(([id, driver]) => ({id, capabilities: driver.caps})); } printNewSessionAnnouncement (driverName, driverVersion, driverBaseVersion) { log.info(driverVersion ? `Appium v${APPIUM_VER} creating new ${driverName} (v${driverVersion}) session` : `Appium v${APPIUM_VER} creating new ${driverName} session` ); log.info(`Checking BaseDriver versions for Appium and ${driverName}`); log.info(AppiumDriver.baseVersion ? `Appium's BaseDriver version is ${AppiumDriver.baseVersion}` : `Could not determine Appium's BaseDriver version` ); log.info(driverBaseVersion ? `${driverName}'s BaseDriver version is ${driverBaseVersion}` : `Could not determine ${driverName}'s BaseDriver version` ); } /** * This is just an alias for driver.js's method, which is necessary for * mocking in the test suite */ _findMatchingDriver (...args) { return findMatchingDriver(...args); } /** * Create a new session * @param {Object} jsonwpCaps JSONWP formatted desired capabilities * @param {Object} reqCaps Required capabilities (JSONWP standard) * @param {Object} w3cCapabilities W3C capabilities * @return {Array} Unique session ID and capabilities */ async createSession (jsonwpCaps, reqCaps, w3cCapabilities) { const defaultCapabilities = _.cloneDeep(this.args.defaultCapabilities); const defaultSettings = pullSettings(defaultCapabilities); jsonwpCaps = _.cloneDeep(jsonwpCaps); const jwpSettings = Object.assign({}, defaultSettings, pullSettings(jsonwpCaps)); w3cCapabilities = _.cloneDeep(w3cCapabilities); // It is possible that the client only provides caps using JSONWP standard, // although firstMatch/alwaysMatch properties are still present. // In such case we assume the client understands W3C protocol and merge the given // JSONWP caps to W3C caps const w3cSettings = Object.assign({}, jwpSettings); Object.assign(w3cSettings, pullSettings((w3cCapabilities || {}).alwaysMatch || {})); for (const firstMatchEntry of ((w3cCapabilities || {}).firstMatch || [])) { Object.assign(w3cSettings, pullSettings(firstMatchEntry)); } let protocol; let innerSessionId, dCaps; try { // Parse the caps into a format that the InnerDriver will accept const parsedCaps = parseCapsForInnerDriver( jsonwpCaps, w3cCapabilities, this.desiredCapConstraints, defaultCapabilities ); const {desiredCaps, processedJsonwpCapabilities, processedW3CCapabilities, error} = parsedCaps; protocol = parsedCaps.protocol; // If the parsing of the caps produced an error, throw it in here if (error) { throw error; } const { driver: InnerDriver, version: driverVersion } = this._findMatchingDriver(this.driverConfig, desiredCaps); this.printNewSessionAnnouncement(InnerDriver.name, driverVersion, InnerDriver.baseVersion); if (this.args.sessionOverride) { await this.deleteAllSessions(); } let runningDriversData, otherPendingDriversData; const d = new InnerDriver(this.args); // We want to assign security values directly on the driver. The driver // should not read security values from `this.opts` because those values // could have been set by a malicious user via capabilities, whereas we // want a guarantee the values were set by the appium server admin if (this.args.relaxedSecurityEnabled) { log.info(`Applying relaxed security to '${InnerDriver.name}' as per ` + `server command line argument. All insecure features will be ` + `enabled unless explicitly disabled by --deny-insecure`); d.relaxedSecurityEnabled = true; } if (!_.isEmpty(this.args.denyInsecure)) { log.info('Explicitly preventing use of insecure features:'); this.args.denyInsecure.map((a) => log.info(` ${a}`)); d.denyInsecure = this.args.denyInsecure; } if (!_.isEmpty(this.args.allowInsecure)) { log.info('Explicitly enabling use of insecure features:'); this.args.allowInsecure.map((a) => log.info(` ${a}`)); d.allowInsecure = this.args.allowInsecure; } // This assignment is required for correct web sockets functionality inside the driver d.server = this.server; 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 = this.pendingDrivers[InnerDriver.name].map((drv) => drv.driverData); this.pendingDrivers[InnerDriver.name].push(d); }); try { [innerSessionId, dCaps] = await d.createSession( processedJsonwpCapabilities, reqCaps, processedW3CCapabilities, [...runningDriversData, ...otherPendingDriversData] ); protocol = d.protocol; await sessionsListGuard.acquire(AppiumDriver.name, () => { this.sessions[innerSessionId] = d; }); } finally { await pendingDriversGuard.acquire(AppiumDriver.name, () => { _.pull(this.pendingDrivers[InnerDriver.name], d); }); } this.attachUnexpectedShutdownHandler(d, innerSessionId); log.info(`New ${InnerDriver.name} session created successfully, session ` + `${innerSessionId} added to master session list`); // set the New Command Timeout for the inner driver d.startNewCommandTimeout(); // apply initial values to Appium settings (if provided) if (d.isW3CProtocol() && !_.isEmpty(w3cSettings)) { log.info(`Applying the initial values to Appium settings parsed from W3C caps: ` + JSON.stringify(w3cSettings)); await d.updateSettings(w3cSettings); } else if (d.isMjsonwpProtocol() && !_.isEmpty(jwpSettings)) { log.info(`Applying the initial values to Appium settings parsed from MJSONWP caps: ` + JSON.stringify(jwpSettings)); await d.updateSettings(jwpSettings); } } catch (error) { return { protocol, error, }; } return { protocol, value: [innerSessionId, dCaps, protocol] }; } attachUnexpectedShutdownHandler (driver, innerSessionId) { const removeSessionFromMasterList = (cause = new Error('Unknown error')) => { 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 if (_.isFunction((driver.onUnexpectedShutdown || {}).then)) { // TODO: Remove this block after all the drivers use base driver above v 5.0.0 // Remove the session on unexpected shutdown, so that we are in a position // to open another session later on. driver.onUnexpectedShutdown // eslint-disable-next-line promise/prefer-await-to-then .then(() => { // if we get here, we've had an unexpected shutdown, so error throw new Error('Unexpected shutdown'); }) .catch((e) => { // if we cancelled the unexpected shutdown promise, that means we // no longer care about it, and can safely ignore it if (!(e instanceof B.CancellationError)) { removeSessionFromMasterList(e); } }); // this is a cancellable promise } else if (_.isFunction(driver.onUnexpectedShutdown)) { // since base driver v 5.0.0 driver.onUnexpectedShutdown(removeSessionFromMasterList); } else { log.warn(`Failed to attach the unexpected shutdown listener. ` + `Is 'onUnexpectedShutdown' method available for '${driver.constructor.name}'?`); } } async curSessionDataForDriver (InnerDriver) { const sessions = await sessionsListGuard.acquire(AppiumDriver.name, () => this.sessions); const data = _.values(sessions) .filter((s) => s.constructor.name === InnerDriver.name) .map((s) => s.driverData); for (let datum of data) { if (!datum) { throw new Error(`Problem getting session data for driver type ` + `${InnerDriver.name}; does it implement 'get ` + `driverData'?`); } } return data; } async deleteSession (sessionId) { let protocol; try { let otherSessionsData = null; let dstSession = null; 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); dstSession = this.sessions[sessionId]; protocol = dstSession.protocol; 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]; }); return { protocol, value: await dstSession.deleteSession(sessionId, otherSessionsData), }; } catch (e) { log.error(`Had trouble ending session ${sessionId}: ${e.message}`); return { protocol, error: e, }; } } async deleteAllSessions (opts = {}) { const sessionsCount = _.size(this.sessions); if (0 === sessionsCount) { log.debug('There are no active sessions for cleanup'); return; } const { force = false, reason, } = opts; 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) { 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} - array of plugin instances */ pluginsForSession (sessionId = null) { 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; } return plugins; } /** * 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)); } 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) { // 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; let protocol = null; let driver = this; 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 save the response protocol given that the session driver's protocol might differ protocol = dstSession.protocol; 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 // 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 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(); } 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); }; // 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 plugins.length && this.logPluginHandlerReport({cmd, cmdHandledBy}); return res; } wrapCommandWithPlugins ({driver, 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* // 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 () => { 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 ({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(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 ` + `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; } // We want to override basedriver's proxy avoidance detection since plugins might want to avoid // proxying in order to handle a command, so we use this function to make an extra check whether // plugins might want to do this. proxyRouteIsAvoided (sessionId, method, url, body) { if (super.proxyRouteIsAvoided(sessionId, method, url, body)) { return true; } // if even just one plugin needs to avoid proxying, do so for (const plugin of this.pluginsForSession(sessionId)) { if (plugin.shouldAvoidProxy?.(method, url, body)) { log.info(`Request to ${url} would normally be proxied, but plugin ${plugin.name} wants ` + `to handle it internally. We'll avoid proxying in this case.`); return true; } } return false; } proxyActive (sessionId) { const dstSession = this.sessions[sessionId]; return dstSession && _.isFunction(dstSession.proxyActive) && dstSession.proxyActive(sessionId); } getProxyAvoidList (sessionId) { const dstSession = this.sessions[sessionId]; return dstSession ? dstSession.getProxyAvoidList() : []; } canProxy (sessionId) { const dstSession = this.sessions[sessionId]; return dstSession && dstSession.canProxy(sessionId); } } // help decide which commands should be proxied to sub-drivers and which // should be handled by this, our umbrella driver function isAppiumDriverCommand (cmd) { return !isSessionCommand(cmd) || cmd === 'deleteSession'; } export { AppiumDriver };