UNPKG

webdriverio-automation

Version:

WebdriverIO-Automation android ios project

557 lines (483 loc) 19.9 kB
import { Protocol, errors } from '../protocol'; import os from 'os'; import commands from './commands'; import * as helpers from './helpers'; import log from './logger'; import DeviceSettings from './device-settings'; import { desiredCapabilityConstraints } from './desired-caps'; import { validateCaps } from './capabilities'; import B from 'bluebird'; import _ from 'lodash'; import { util } from 'appium-support'; import { ImageElement, makeImageElementCache, getImgElFromArgs } from './image-element'; import { W3C_ELEMENT_KEY, MJSONWP_ELEMENT_KEY } from '../protocol/protocol'; B.config({ cancellation: true, }); const NEW_COMMAND_TIMEOUT_MS = 60 * 1000; const EVENT_SESSION_INIT = 'newSessionRequested'; const EVENT_SESSION_START = 'newSessionStarted'; const EVENT_SESSION_QUIT_START = 'quitSessionRequested'; const EVENT_SESSION_QUIT_DONE = 'quitSessionFinished'; class BaseDriver extends Protocol { constructor (opts = {}, shouldValidateCaps = true) { super(); // setup state this.sessionId = null; this.opts = opts; this.caps = null; this.helpers = helpers; // initialize security modes this.relaxedSecurityEnabled = false; this.allowInsecure = []; this.denyInsecure = []; // timeout initialization this.newCommandTimeoutMs = NEW_COMMAND_TIMEOUT_MS; this.implicitWaitMs = 0; this._constraints = _.cloneDeep(desiredCapabilityConstraints); this.locatorStrategies = []; this.webLocatorStrategies = []; // use a custom tmp dir to avoid losing data and app when computer is // restarted this.opts.tmpDir = this.opts.tmpDir || process.env.APPIUM_TMP_DIR || os.tmpdir(); // base-driver internals this.curCommand = B.resolve(); // see note in execute this.curCommandCancellable = B.resolve(); // see note in execute this.shutdownUnexpectedly = false; this.noCommandTimer = null; this.shouldValidateCaps = shouldValidateCaps; // settings should be instantiated by drivers which extend BaseDriver, but // we set it to an empty DeviceSettings instance here to make sure that the // default settings are applied even if an extending driver doesn't utilize // the settings functionality itself this.settings = new DeviceSettings({}, _.noop); this.resetOnUnexpectedShutdown(); // keeping track of initial opts this.initialOpts = _.cloneDeep(this.opts); // allow subclasses to have internal drivers this.managedDrivers = []; // store event timings this._eventHistory = { commands: [] // commands get a special place }; // cache the image elements this._imgElCache = makeImageElementCache(); this.protocol = null; } /** * This property is used by AppiumDriver to store the data of the * specific driver sessions. This data can be later used to adjust * properties for driver instances running in parallel. * Override it in inherited driver classes if necessary. * * @return {object} Driver properties mapping */ get driverData () { return {}; } /** * This property controls the way {#executeCommand} method * handles new driver commands received from the client. * Override it for inherited classes only in special cases. * * @return {boolean} If the returned value is true (default) then all the commands * received by the particular driver instance are going to be put into the queue, * so each following command will not be executed until the previous command * execution is completed. False value disables that queue, so each driver command * is executed independently and does not wait for anything. */ get isCommandsQueueEnabled () { return true; } /* * make eventHistory a property and return a cloned object so a consumer can't * inadvertently change data outside of logEvent */ get eventHistory () { return _.cloneDeep(this._eventHistory); } /* * API method for driver developers to log timings for important events */ logEvent (eventName) { if (eventName === 'commands') { throw new Error('Cannot log commands directly'); } if (typeof eventName !== 'string') { throw new Error(`Invalid eventName ${eventName}`); } if (!this._eventHistory[eventName]) { this._eventHistory[eventName] = []; } let ts = Date.now(); let logTime = (new Date(ts)).toTimeString(); this._eventHistory[eventName].push(ts); log.debug(`Event '${eventName}' logged at ${ts} (${logTime})`); } /* * Overridden in appium driver, but here so that individual drivers can be * tested with clients that poll */ async getStatus () { // eslint-disable-line require-await return {}; } /* * Initialize a new onUnexpectedShutdown promise, cancelling existing one. */ resetOnUnexpectedShutdown () { if (this.onUnexpectedShutdown && !this.onUnexpectedShutdown.isFulfilled()) { this.onUnexpectedShutdown.cancel(); } this.onUnexpectedShutdown = new B((resolve, reject, onCancel) => { onCancel(() => reject(new B.CancellationError())); this.unexpectedShutdownDeferred = {resolve, reject}; }); // noop handler to avoid warning. this.onUnexpectedShutdown.catch(() => {}); } // we only want subclasses to ever extend the contraints set desiredCapConstraints (constraints) { this._constraints = Object.assign(this._constraints, constraints); // 'presence' means different things in different versions of the validator, // when we say 'true' we mean that it should not be able to be empty for (const [, value] of _.toPairs(this._constraints)) { if (value && value.presence === true) { value.presence = { allowEmpty: false, }; } } } get desiredCapConstraints () { return this._constraints; } // method required by MJSONWP in order to determine whether it should // respond with an invalid session response sessionExists (sessionId) { if (!sessionId) return false; // eslint-disable-line curly return sessionId === this.sessionId; } // method required by MJSONWP in order to determine if the command should // be proxied directly to the driver driverForSession (/*sessionId*/) { return this; } logExtraCaps (caps) { let extraCaps = _.difference(_.keys(caps), _.keys(this._constraints)); if (extraCaps.length) { log.warn(`The following capabilities were provided, but are not ` + `recognized by Appium:`); for (const cap of extraCaps) { log.warn(` ${cap}`); } } } validateDesiredCaps (caps) { if (!this.shouldValidateCaps) { return true; } try { validateCaps(caps, this._constraints); } catch (e) { log.errorAndThrow(new errors.SessionNotCreatedError(`The desiredCapabilities object was not valid for the ` + `following reason(s): ${e.message}`)); } this.logExtraCaps(caps); return true; } isMjsonwpProtocol () { return this.protocol === BaseDriver.DRIVER_PROTOCOL.MJSONWP; } isW3CProtocol () { return this.protocol === BaseDriver.DRIVER_PROTOCOL.W3C; } setProtocolMJSONWP () { this.protocol = BaseDriver.DRIVER_PROTOCOL.MJSONWP; } setProtocolW3C () { this.protocol = BaseDriver.DRIVER_PROTOCOL.W3C; } /** * Test createSession inputs to see if this is a W3C Session or a MJSONWP Session */ static determineProtocol (desiredCapabilities, requiredCapabilities, capabilities) { return _.isPlainObject(capabilities) ? BaseDriver.DRIVER_PROTOCOL.W3C : BaseDriver.DRIVER_PROTOCOL.MJSONWP; } /** * Check whether a given feature is enabled via its name * * @param {string} name - name of feature/command * * @returns {Boolean} */ isFeatureEnabled (name) { // if we have explicitly denied this feature, return false immediately if (this.denyInsecure && _.includes(this.denyInsecure, name)) { return false; } // if we specifically have allowed the feature, return true if (this.allowInsecure && _.includes(this.allowInsecure, name)) { return true; } // otherwise, if we've globally allowed insecure features and not denied // this one, return true if (this.relaxedSecurityEnabled) { return true; } // if we haven't allowed anything insecure, then reject return false; } /** * Assert that a given feature is enabled and throw a helpful error if it's * not * * @param {string} name - name of feature/command */ ensureFeatureEnabled (name) { if (!this.isFeatureEnabled(name)) { throw new Error(`Potentially insecure feature '${name}' has not been ` + `enabled. If you want to enable this feature and accept ` + `the security ramifications, please do so by following ` + `the documented instructions at https://github.com/appium` + `/appium/blob/master/docs/en/writing-running-appium/security.md`); } } // This is the main command handler for the driver. It wraps command // execution with timeout logic, checking that we have a valid session, // and ensuring that we execute commands one at a time. This method is called // by MJSONWP's express router. async executeCommand (cmd, ...args) { // get start time for this command, and log in special cases let startTime = Date.now(); if (cmd === 'createSession') { // If creating a session determine if W3C or MJSONWP protocol was requested and remember the choice this.protocol = BaseDriver.determineProtocol(...args); this.logEvent(EVENT_SESSION_INIT); } else if (cmd === 'deleteSession') { this.logEvent(EVENT_SESSION_QUIT_START); } // if we had a command timer running, clear it now that we're starting // a new command and so don't want to time out this.clearNewCommandTimeout(); // If we don't have this command, it must not be implemented // If the target element is ImageElement, we must try to call `ImageElement.execute` which exist following lines // since ImageElement supports few commands by itself const imgElId = getImgElFromArgs(args); if (!this[cmd] && !imgElId) { throw new errors.NotYetImplementedError(); } let res; if (this.isCommandsQueueEnabled && cmd !== 'executeDriverScript') { // What we're doing here is pretty clever. this.curCommand is always // a promise representing the command currently being executed by the // driver, or the last command executed by the driver (it starts off as // essentially a pre-resolved promise). When a command comes in, we tack it // to the end of this.curCommand, essentially saying we want to execute it // whenever this.curCommand is done. We call this new promise nextCommand, // and its resolution is what we ultimately will return to whomever called // us. Meanwhile, we reset this.curCommand to _be_ nextCommand (but // ignoring any rejections), so that if another command comes into the // server, it gets tacked on to the end of nextCommand. Thus we create // a chain of promises that acts as a queue with single concurrency. const nextCommand = this.curCommand.then(() => { // eslint-disable-line promise/prefer-await-to-then // if we unexpectedly shut down, we need to reject every command in // the queue before we actually try to run it if (this.shutdownUnexpectedly) { return B.reject(new errors.NoSuchDriverError('The driver was unexpectedly shut down!')); } // We also need to turn the command into a cancellable promise so if we // have an unexpected shutdown event, for example, we can cancel it from // outside, rejecting the current command immediately let reject; this.curCommandCancellable = B.resolve().then(() => { // eslint-disable-line promise/prefer-await-to-then // in order to abort the promise, we need to have it in a race // with one we can reject from outside const cancelPromise = new B(function (_, _reject) { // eslint-disable-line no-unused-vars reject = _reject; }); // if one of the args is an image element, handle it separately return B.race([ imgElId ? ImageElement.execute(this, cmd, imgElId, ...args) : this[cmd](...args), cancelPromise, ]); }); // override the B#cancel function, which just turns off listeners this.curCommandCancellable.cancel = function cancel (err) { if (reject) { reject(err); } }; return this.curCommandCancellable; }); this.curCommand = nextCommand.catch(() => {}); res = await nextCommand; } else { // If we've gotten here because we're running executeDriverScript, we // never want to add the command to the queue. This is because it runs // other commands _inside_ it, so those commands would never start if we // were waiting for executeDriverScript to finish. So it is a special // case. if (this.shutdownUnexpectedly) { throw new errors.NoSuchDriverError('The driver was unexpectedly shut down!'); } res = await this[cmd](...args); } // if we have set a new command timeout (which is the default), start a // timer once we've finished executing this command. If we don't clear // the timer (which is done when a new command comes in), we will trigger // automatic session deletion in this.onCommandTimeout. Of course we don't // want to trigger the timer when the user is shutting down the session // intentionally if (this.isCommandsQueueEnabled && cmd !== 'deleteSession') { // reseting existing timeout this.startNewCommandTimeout(); } // log timing information about this command const endTime = Date.now(); this._eventHistory.commands.push({cmd, startTime, endTime}); if (cmd === 'createSession') { this.logEvent(EVENT_SESSION_START); } else if (cmd === 'deleteSession') { this.logEvent(EVENT_SESSION_QUIT_DONE); } return res; } async startUnexpectedShutdown (err = new errors.NoSuchDriverError('The driver was unexpectedly shut down!')) { this.unexpectedShutdownDeferred.reject(err); // allow others to listen for this this.shutdownUnexpectedly = true; await this.deleteSession(this.sessionId); this.shutdownUnexpectedly = false; this.curCommandCancellable.cancel(err); } validateLocatorStrategy (strategy, webContext = false) { let validStrategies = this.locatorStrategies; log.debug(`Valid locator strategies for this request: ${validStrategies.join(', ')}`); if (webContext) { validStrategies = validStrategies.concat(this.webLocatorStrategies); } if (!_.includes(validStrategies, strategy)) { throw new errors.InvalidSelectorError(`Locator Strategy '${strategy}' is not supported for this session`); } } /* * Restart the session with the original caps, * preserving the timeout config. */ async reset () { log.debug('Resetting app mid-session'); log.debug('Running generic full reset'); // preserving state let currentConfig = {}; for (let property of ['implicitWaitMs', 'newCommandTimeoutMs', 'sessionId', 'resetOnUnexpectedShutdown']) { currentConfig[property] = this[property]; } // We also need to preserve the unexpected shutdown, and make sure it is not cancelled during reset. this.resetOnUnexpectedShutdown = () => {}; // Construct the arguments for createSession depending on the protocol type const args = this.protocol === BaseDriver.DRIVER_PROTOCOL.W3C ? [undefined, undefined, {alwaysMatch: this.caps, firstMatch: [{}]}] : [this.caps]; try { await this.deleteSession(this.sessionId); log.debug('Restarting app'); await this.createSession(...args); } finally { // always restore state. for (let [key, value] of _.toPairs(currentConfig)) { this[key] = value; } } this.clearNewCommandTimeout(); } async getSwipeOptions (gestures, touchCount = 1) { let startX = this.helpers.getCoordDefault(gestures[0].options.x), startY = this.helpers.getCoordDefault(gestures[0].options.y), endX = this.helpers.getCoordDefault(gestures[2].options.x), endY = this.helpers.getCoordDefault(gestures[2].options.y), duration = this.helpers.getSwipeTouchDuration(gestures[1]), element = gestures[0].options.element, destElement = gestures[2].options.element || gestures[0].options.element; // there's no destination element handling in bootstrap and since it applies to all platforms, we handle it here if (util.hasValue(destElement)) { let locResult = await this.getLocationInView(destElement); let sizeResult = await this.getSize(destElement); let offsetX = (Math.abs(endX) < 1 && Math.abs(endX) > 0) ? sizeResult.width * endX : endX; let offsetY = (Math.abs(endY) < 1 && Math.abs(endY) > 0) ? sizeResult.height * endY : endY; endX = locResult.x + offsetX; endY = locResult.y + offsetY; // if the target element was provided, the coordinates for the destination need to be relative to it. if (util.hasValue(element)) { let firstElLocation = await this.getLocationInView(element); endX -= firstElLocation.x; endY -= firstElLocation.y; } } // clients are responsible to use these options correctly return {startX, startY, endX, endY, duration, touchCount, element}; } proxyActive (/* sessionId */) { return false; } getProxyAvoidList (/* sessionId */) { return []; } canProxy (/* sessionId */) { return false; } /** * Whether a given command route (expressed as method and url) should not be * proxied according to this driver * * @param {string} sessionId - the current sessionId (in case the driver runs * multiple session ids and requires it). This is not used in this method but * should be made available to overridden methods. * @param {string} method - HTTP method of the route * @param {string} url - url of the route * * @returns {boolean} - whether the route should be avoided */ proxyRouteIsAvoided (sessionId, method, url) { for (let avoidSchema of this.getProxyAvoidList(sessionId)) { if (!_.isArray(avoidSchema) || avoidSchema.length !== 2) { throw new Error('Proxy avoidance must be a list of pairs'); } let [avoidMethod, avoidPathRegex] = avoidSchema; if (!_.includes(['GET', 'POST', 'DELETE'], avoidMethod)) { throw new Error(`Unrecognized proxy avoidance method '${avoidMethod}'`); } if (!_.isRegExp(avoidPathRegex)) { throw new Error('Proxy avoidance path must be a regular expression'); } let normalizedUrl = url.replace(/^\/wd\/hub/, ''); if (avoidMethod === method && avoidPathRegex.test(normalizedUrl)) { return true; } } return false; } addManagedDriver (driver) { this.managedDrivers.push(driver); } getManagedDrivers () { return this.managedDrivers; } registerImageElement (imgEl) { this._imgElCache.set(imgEl.id, imgEl); const protoKey = this.isW3CProtocol() ? W3C_ELEMENT_KEY : MJSONWP_ELEMENT_KEY; return imgEl.asElement(protoKey); } } BaseDriver.DRIVER_PROTOCOL = { W3C: 'W3C', MJSONWP: 'MJSONWP', }; for (let [cmd, fn] of _.toPairs(commands)) { BaseDriver.prototype[cmd] = fn; } export { BaseDriver }; export default BaseDriver;