mirror of
https://github.com/appium/appium.git
synced 2026-10-25 02:00:57 +08:00
524 lines
18 KiB
JavaScript
524 lines
18 KiB
JavaScript
import {
|
|
Protocol, errors, determineProtocol
|
|
} from '../protocol';
|
|
import {
|
|
MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY, PROTOCOLS, DEFAULT_BASE_PATH,
|
|
} from '../constants';
|
|
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 AsyncLock from 'async-lock';
|
|
import { EventEmitter } from 'events';
|
|
|
|
|
|
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';
|
|
const ON_UNEXPECTED_SHUTDOWN_EVENT = 'onUnexpectedShutdown';
|
|
|
|
class BaseDriver extends Protocol {
|
|
|
|
constructor (opts = {}, shouldValidateCaps = true) {
|
|
super();
|
|
|
|
// setup state
|
|
this.sessionId = null;
|
|
this.opts = opts;
|
|
this.caps = null;
|
|
this.helpers = helpers;
|
|
|
|
// basePath is used for several purposes, for example in setting up
|
|
// proxying to other drivers, since we need to know what the base path
|
|
// of any incoming request might look like. We set it to the default
|
|
// initially but it is automatically updated during any actual program
|
|
// execution by the routeConfiguringFunction, which is necessarily run as
|
|
// the entrypoint for any Appium server
|
|
this.basePath = DEFAULT_BASE_PATH;
|
|
|
|
// 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.shutdownUnexpectedly = false;
|
|
this.noCommandTimer = null;
|
|
this.shouldValidateCaps = shouldValidateCaps;
|
|
this.commandsQueueGuard = new AsyncLock();
|
|
|
|
// 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);
|
|
|
|
// 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();
|
|
|
|
// used to handle driver events
|
|
this.eventEmitter = new EventEmitter();
|
|
|
|
this.protocol = null;
|
|
}
|
|
|
|
/**
|
|
* Set a callback handler if needed to execute a custom piece of code
|
|
* when the driver is shut down unexpectedly. Multiple calls to this method
|
|
* will cause the handler to be executed mutiple times
|
|
*
|
|
* @param {Function} handler The code to be executed on unexpected shutdown.
|
|
* The function may accept one argument, which is the actual error instance, which
|
|
* caused the driver to shut down.
|
|
*/
|
|
onUnexpectedShutdown (handler) {
|
|
this.eventEmitter.on(ON_UNEXPECTED_SHUTDOWN_EVENT, handler);
|
|
}
|
|
|
|
/**
|
|
* 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] = [];
|
|
}
|
|
const ts = Date.now();
|
|
const 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 {};
|
|
}
|
|
|
|
// 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 === PROTOCOLS.MJSONWP;
|
|
}
|
|
|
|
isW3CProtocol () {
|
|
return this.protocol === PROTOCOLS.W3C;
|
|
}
|
|
|
|
setProtocolMJSONWP () {
|
|
this.protocol = PROTOCOLS.MJSONWP;
|
|
}
|
|
|
|
setProtocolW3C () {
|
|
this.protocol = PROTOCOLS.W3C;
|
|
}
|
|
|
|
/**
|
|
* 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 = 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 (this.shutdownUnexpectedly) {
|
|
throw new errors.NoSuchDriverError('The driver was unexpectedly shut down!');
|
|
}
|
|
|
|
// 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 unexpectedShutdownListener;
|
|
const commandExecutor = async () => imgElId
|
|
? await ImageElement.execute(this, cmd, imgElId, ...args)
|
|
: await B.race([
|
|
this[cmd](...args),
|
|
new B((resolve, reject) => {
|
|
unexpectedShutdownListener = reject;
|
|
this.eventEmitter.on(ON_UNEXPECTED_SHUTDOWN_EVENT, unexpectedShutdownListener);
|
|
})
|
|
]).finally(() => {
|
|
if (unexpectedShutdownListener) {
|
|
// This is needed to prevent memory leaks
|
|
this.eventEmitter.removeListener(ON_UNEXPECTED_SHUTDOWN_EVENT, unexpectedShutdownListener);
|
|
unexpectedShutdownListener = null;
|
|
}
|
|
});
|
|
const res = this.isCommandsQueueEnabled && cmd !== 'executeDriverScript'
|
|
? await this.commandsQueueGuard.acquire(BaseDriver.name, commandExecutor)
|
|
: await commandExecutor();
|
|
|
|
// 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') {
|
|
// resetting 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.eventEmitter.emit(ON_UNEXPECTED_SHUTDOWN_EVENT, err); // allow others to listen for this
|
|
this.shutdownUnexpectedly = true;
|
|
try {
|
|
await this.deleteSession(this.sessionId);
|
|
} finally {
|
|
this.shutdownUnexpectedly = false;
|
|
}
|
|
}
|
|
|
|
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 === PROTOCOLS.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(new RegExp(`^${_.escapeRegExp(this.basePath)}`), '');
|
|
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);
|
|
}
|
|
}
|
|
|
|
for (let [cmd, fn] of _.toPairs(commands)) {
|
|
BaseDriver.prototype[cmd] = fn;
|
|
}
|
|
|
|
export { BaseDriver };
|
|
export default BaseDriver;
|