UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

515 lines 27.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.deprecatedCommandsLogged = exports.LIST_DRIVER_EXTENSIONS_COMMAND = exports.LIST_DRIVER_COMMANDS_COMMAND = exports.GET_STATUS_COMMAND = exports.DELETE_SESSION_COMMAND = exports.CREATE_SESSION_COMMAND = void 0; exports.determineProtocol = determineProtocol; exports.getSessionId = getSessionId; exports.isSessionCommand = isSessionCommand; exports.checkParams = checkParams; exports.makeArgs = makeArgs; exports.validateExecuteMethodParams = validateExecuteMethodParams; exports.routeConfiguringFunction = routeConfiguringFunction; exports.driverShouldDoJwpProxy = driverShouldDoJwpProxy; const lodash_1 = __importDefault(require("lodash")); const support_1 = require("@appium/support"); const validators_1 = require("./validators"); const errors_1 = require("./errors"); const routes_1 = require("./routes"); const bluebird_1 = __importDefault(require("bluebird")); const helpers_1 = require("./helpers"); const constants_1 = require("../constants"); const capabilities_1 = require("../helpers/capabilities"); const logger_1 = require("../basedriver/logger"); const helpers_2 = require("../basedriver/helpers"); exports.CREATE_SESSION_COMMAND = 'createSession'; exports.DELETE_SESSION_COMMAND = 'deleteSession'; exports.GET_STATUS_COMMAND = 'getStatus'; exports.LIST_DRIVER_COMMANDS_COMMAND = 'listCommands'; exports.LIST_DRIVER_EXTENSIONS_COMMAND = 'listExtensions'; exports.deprecatedCommandsLogged = new Set(); /** * Infer W3C vs MJSONWP from new-session capability payloads. * @param createSessionArgs - Arguments passed to the createSession command */ function determineProtocol(createSessionArgs) { return lodash_1.default.some(createSessionArgs, capabilities_1.isW3cCaps) ? constants_1.PROTOCOLS.W3C : constants_1.PROTOCOLS.MJSONWP; } /** * Extract and validate the sessionId from the Express route parameter. * Express may return route params as string | string[] | undefined. * Appium uses standard routes (e.g., /session/:sessionId) which should always be strings. * Only `*` such as `/session/*sessionId` can return `string[]`. * Then, this method will return the first element as the session id. * It may break existing appium routing handling also, thus this method will log * received parameters as well to help debugging. * @param driver Running driver * @param req The request in Express * @returns The normalized sessionId (string or undefined) */ function getSessionId(driver, req) { if (Array.isArray(req.params.sessionId)) { const sessionId = req.params.sessionId[0]; getLogger(driver, sessionId).warn(`Received malformed sessionId as array from the route: ${req.originalUrl}. ` + `This indicates the route definition issue. The route should start with '/session/:sessionId' (named parameter) ` + `instead of '/session/*sessionId' (wildcard). ` + `Using the first element as session id: ${sessionId}. ` + `Please fix the route definition to prevent this error.`); // This is to not log the message multiple times. req.params.sessionId = sessionId; return sessionId; } return req.params.sessionId; } /** * @param command - Driver command name * @returns Whether the command requires a session id in the URL */ function isSessionCommand(command) { return !lodash_1.default.includes(routes_1.NO_SESSION_ID_COMMANDS, command); } /** * Validate request arguments against a route payload spec and return filtered params. * @param paramSpec - Required/optional parameter definition from the method map * @param args - Raw arguments (e.g. JSON body) * @param protocol - Active protocol, used when a custom validate function is present */ function checkParams(paramSpec, args, protocol) { let requiredParams = []; let optionalParams = []; const actualParamNames = lodash_1.default.keys(args); if (paramSpec.required) { // we might have an array of parameters, // or an array of arrays of parameters, so standardize requiredParams = lodash_1.default.cloneDeep((hasMultipleRequiredParamSets(paramSpec.required) ? paramSpec.required : [paramSpec.required])); } // optional parameters are just an array if (paramSpec.optional) { optionalParams = lodash_1.default.cloneDeep(paramSpec.optional); } // If a function was provided as the 'validate' key, it will here be called with // args as the param. If it returns something falsy, verification will be // considered to have passed. If it returns something else, that will be the // argument to an error which is thrown to the user if (paramSpec.validate) { const message = paramSpec.validate(args, protocol ?? constants_1.PROTOCOLS.W3C); if (message) { throw new errors_1.errors.InvalidArgumentError(lodash_1.default.isString(message) ? message : undefined); } } // some clients pass in the session id in the params if (!lodash_1.default.includes(optionalParams, 'sessionId')) { optionalParams.push('sessionId'); } // some clients pass in an element id in the params if (!lodash_1.default.includes(optionalParams, 'id')) { optionalParams.push('id'); } if (lodash_1.default.isEmpty(requiredParams)) { // if we don't have any required parameters, then just filter out unknown ones return pickKnownParams(args, lodash_1.default.difference(actualParamNames, optionalParams)); } // go through the required parameters and check against our arguments let matchedReqParamSet = []; for (const requiredParamsSet of requiredParams) { if (!lodash_1.default.isArray(requiredParamsSet)) { throw new Error(`The required parameter set item ${JSON.stringify(requiredParamsSet)} ` + `in ${JSON.stringify(paramSpec)} is not an array. ` + `This is a bug in the method map definition.`); } if (lodash_1.default.isEmpty(lodash_1.default.difference(requiredParamsSet, actualParamNames))) { return pickKnownParams(args, lodash_1.default.difference(actualParamNames, requiredParamsSet, optionalParams)); } if (!lodash_1.default.isEmpty(requiredParamsSet) && lodash_1.default.isEmpty(matchedReqParamSet)) { matchedReqParamSet = requiredParamsSet; } } throw new errors_1.BadParametersError({ ...paramSpec, required: matchedReqParamSet, optional: optionalParams, }, actualParamNames); } /** * Build the ordered argument list for a driver command from URL params, JSON body, and route spec. * @param requestParams - Express route parameters (e.g. sessionId, element id) * @param jsonObj - Parsed JSON request body * @param payloadParams - Route payload definition (required/optional/makeArgs) */ function makeArgs(requestParams, jsonObj, payloadParams) { // We want to pass the "url" parameters to the commands in reverse order // since the command will sometimes want to ignore, say, the sessionId. // This has the effect of putting sessionId last, which means in JS we can // omit it from the function signature if we're not going to use it. const urlParams = lodash_1.default.keys(requestParams).reverse(); // In the simple case, the required parameters are a basic array in // payloadParams.required, so start there. It's possible that there are // multiple optional sets of required params, though, so handle that case // too. let requiredParams = payloadParams.required; if (hasMultipleRequiredParamSets(payloadParams.required)) { // If there are optional sets of required params, then we will have an // array of arrays in payloadParams.required, so loop through each set and // pick the one that matches which JSON params were actually sent. We've // already been through validation so we're guaranteed to find a match. const keys = lodash_1.default.keys(jsonObj); for (const params of payloadParams.required) { if (lodash_1.default.without(params, ...keys).length === 0) { requiredParams = params; break; } } } // Now we construct our list of arguments which will be passed to the command let args; if (lodash_1.default.isFunction(payloadParams.makeArgs)) { // In the route spec, a particular route might define a 'makeArgs' function // if it wants full control over how to turn JSON parameters into command // arguments. So we pass it the JSON parameters and it returns an array // which will be applied to the handling command. For example if it returns // [1, 2, 3], we will call `command(1, 2, 3, ...)` (url params are separate // from JSON params and get concatenated below). args = payloadParams.makeArgs(jsonObj); } else { // Otherwise, collect all the required and optional params and flatten them // into an argument array args = lodash_1.default.flatten(requiredParams).map((p) => jsonObj[p]); if (payloadParams.optional) { args = args.concat(lodash_1.default.flatten(payloadParams.optional).map((p) => jsonObj[p])); } } // Finally, get our url params (session id, element id, etc...) on the end of // the list args = args.concat(urlParams.map((u) => requestParams[u])); return args; } /** * Validate parameters for execute/executeAsync script endpoints. * @param params - Raw execute command arguments from the client * @param paramSpec - Optional payload spec for additional validation */ function validateExecuteMethodParams(params, paramSpec) { // the w3c protocol will give us an array of arguments to apply to a javascript function. // that's not what we're doing. we're going to look for a JS object as the first arg, so we // can perform validation on it. we'll ignore everything else. if (!params || !lodash_1.default.isArray(params) || params.length > 1) { throw new errors_1.errors.InvalidArgumentError(`Did not get correct format of arguments for execute method. Expected zero or one ` + `arguments to execute script and instead received: ${JSON.stringify(params)}`); } const args = params[0] ?? {}; if (!lodash_1.default.isPlainObject(args)) { throw new errors_1.errors.InvalidArgumentError(`Did not receive an appropriate execute method parameters object. It needs to be ` + `deserializable as a plain JS object`); } const specToUse = { ...(paramSpec ?? {}), required: paramSpec?.required ?? [], optional: paramSpec?.optional ?? [], }; const filteredArgs = checkParams(specToUse, args); return makeArgs({}, filteredArgs, specToUse); } /** * Returns a function that registers default (and plugin) HTTP routes on an Express app for a driver. * @param driver - Driver instance used to execute commands */ function routeConfiguringFunction(driver) { if (!driver.sessionExists) { throw new Error('Drivers must implement `sessionExists` property'); } if (!(driver.executeCommand || driver.execute)) { throw new Error('Drivers must implement `executeCommand` or `execute` method'); } // return a function which will add all the routes to the driver. Here extraMethods might be // passed in as defined by Appium plugins, so we need to add those to the default list return function addRoutes(app, { basePath = constants_1.DEFAULT_BASE_PATH, extraMethodMap = {} } = {}) { // store basePath on the driver instance so it can use it if necessary // for example in determining proxy avoidance driver.basePath = basePath; const allMethods = { ...routes_1.METHOD_MAP, ...extraMethodMap }; for (const [path, methods] of lodash_1.default.toPairs(allMethods)) { for (const [method, spec] of lodash_1.default.toPairs(methods)) { const isSessCommand = spec.command ? isSessionCommand(spec.command) : false; // set up the express route handler buildHandler(app, method, `${basePath}${path}`, spec, driver, isSessCommand); } } }; } /** * Whether an incoming request should be forwarded to the driver's JWProxy for the given command. * @param driver - Active driver * @param req - Incoming HTTP request * @param command - Resolved driver command name */ function driverShouldDoJwpProxy(driver, req, command) { const sessionId = getSessionId(driver, req); // drivers need to explicitly say when the proxy is active if (!driver.proxyActive(sessionId)) { return false; } // we should never proxy deleteSession because we need to give the containing // driver an opportunity to clean itself up if (command === exports.DELETE_SESSION_COMMAND) { return false; } // validate avoidance schema, and say we shouldn't proxy if anything in the // avoid list matches our req if (driver.proxyRouteIsAvoided(sessionId, req.method, req.originalUrl, req.body)) { return false; } return true; } function extractProtocol(driver, sessionId = null) { const dstDriver = lodash_1.default.isFunction(driver.driverForSession) && sessionId ? driver.driverForSession(sessionId) : driver; if (dstDriver === driver) { // Shortcircuit if the driver instance is not an umbrella driver // or it is Fake driver instance, where `driver.driverForSession` // always returns self instance return driver.protocol ?? constants_1.PROTOCOLS.W3C; } // Extract the protocol for the current session if the given driver is the umbrella one return dstDriver?.protocol ?? constants_1.PROTOCOLS.W3C; } function getLogger(driver, sessionId = null) { const dstDriver = sessionId && lodash_1.default.isFunction(driver.driverForSession) ? driver.driverForSession(sessionId) ?? driver : driver; if (lodash_1.default.isFunction(dstDriver.log?.info)) { return dstDriver.log; } const logPrefix = (0, helpers_2.generateDriverLogPrefix)(dstDriver); return support_1.logger.getLogger(logPrefix); } function wrapParams(paramSets, jsonObj) { /* There are commands like performTouch which take a single parameter (primitive type or array). * Some drivers choose to pass this parameter as a value (eg. [action1, action2...]) while others to * wrap it within an object(eg' {gesture: [action1, action2...]}), which makes it hard to validate. * The wrap option in the spec enforce wrapping before validation, so that all params are wrapped at * the time they are validated and later passed to the commands. */ return (lodash_1.default.isArray(jsonObj) || !lodash_1.default.isObject(jsonObj)) && paramSets.wrap ? { [paramSets.wrap]: jsonObj } : jsonObj; } function unwrapParams(paramSets, jsonObj) { /* There are commands like setNetworkConnection which send parameters wrapped inside a key such as * "parameters". This function unwraps them (eg. {"parameters": {"type": 1}} becomes {"type": 1}). */ return lodash_1.default.isObject(jsonObj) && paramSets.unwrap && jsonObj[paramSets.unwrap] ? jsonObj[paramSets.unwrap] : jsonObj; } function hasMultipleRequiredParamSets(required) { //@ts-expect-error Needed to convince lodash typechecks return Boolean(required && lodash_1.default.isArray(lodash_1.default.first(required))); } function pickKnownParams(args, unknownNames) { if (lodash_1.default.isEmpty(unknownNames)) { return args; } logger_1.log.info(`The following arguments are not known and will be ignored: ${unknownNames}`); return lodash_1.default.pickBy(args, (v, k) => !unknownNames.includes(k)); } function buildHandler(app, method, path, spec, driver, isSessCmd) { const asyncHandler = async (req, res) => { let jsonObj = req.body; let httpResBody = {}; let httpStatus = 200; let newSessionId; const sessionId = getSessionId(driver, req); let currentProtocol = extractProtocol(driver, sessionId); try { // if the route accessed is deprecated, log a warning if (spec.deprecated && spec.command && !exports.deprecatedCommandsLogged.has(spec.command)) { exports.deprecatedCommandsLogged.add(spec.command); getLogger(driver, sessionId).warn(`The ${method} ${path} endpoint has been deprecated and will be removed in a future ` + `version of Appium or your driver/plugin. Please use a different endpoint or contact the ` + `driver/plugin author to add explicit support for the endpoint before it is removed`); } // if this is a session command but we don't have a session, // error out early (especially before proxying) if (isSessCmd && !driver.sessionExists(sessionId)) { throw new errors_1.errors.NoSuchDriverError(); } // if the driver is currently proxying commands to another JSONWP server, bypass all our // checks and assume the upstream server knows what it's doing. But keep this in the // try/catch block so if proxying itself fails, we give a message to the client. Of course we // only want to do these when we have a session command; the Appium driver must be // responsible for start/stop session, etc... We also allow the command spec to declare that // this command should never be proxied (which is useful for plugin developers who add // commands and generally would not want that command to be proxied instead of handled by the // plugin) let didPluginOverrideProxy = false; if (isSessCmd && !spec.neverProxy && spec.command && driverShouldDoJwpProxy(driver, req, spec.command)) { if (!('pluginsToHandleCmd' in driver) || !lodash_1.default.isFunction(driver.pluginsToHandleCmd) || driver.pluginsToHandleCmd(spec.command, sessionId).length === 0) { await doJwpProxy(driver, req, res); return; } getLogger(driver, sessionId).debug(`Would have proxied ` + `command directly, but a plugin exists which might require its value, so will let ` + `its value be collected internally and made part of plugin chain`); didPluginOverrideProxy = true; } // if a command is not in our method map, it's because we // have no plans to ever implement it if (!spec.command) { throw new errors_1.errors.NotImplementedError(); } // wrap params if necessary if (spec.payloadParams && spec.payloadParams.wrap) { jsonObj = wrapParams(spec.payloadParams, jsonObj); } // unwrap params if necessary if (spec.payloadParams && spec.payloadParams.unwrap) { jsonObj = unwrapParams(spec.payloadParams, jsonObj); } if (spec.command === exports.CREATE_SESSION_COMMAND) { // try to determine protocol by session creation args, so we can throw a // properly formatted error if arguments validation fails currentProtocol = determineProtocol(makeArgs(req.params, jsonObj, spec.payloadParams || {})); } // ensure that the json payload conforms to the spec if (spec.payloadParams) { checkParams(spec.payloadParams, jsonObj, currentProtocol); } // turn the command and json payload into an argument list for // the driver methods const args = makeArgs(req.params, jsonObj, spec.payloadParams || {}); let driverRes; // validate command args according to MJSONWP if (validators_1.validators[spec.command]) { validators_1.validators[spec.command](...args); } // run the driver command wrapped inside the argument validators getLogger(driver, sessionId).debug(`Calling %s.%s() with args: %s`, driver.constructor.name, spec.command, support_1.logger.markSensitive(lodash_1.default.truncate(JSON.stringify(args), { length: constants_1.MAX_LOG_BODY_LENGTH }))); if (didPluginOverrideProxy) { // TODO for now we add this information on the args list, but that's mixing purposes here. // We really should add another 'options' parameter to 'executeCommand', but this would be // a breaking change for all drivers so would need to be handled carefully. args.push({ reqForProxy: req }); } driverRes = await driver.executeCommand(spec.command, ...args); // Get the protocol after executeCommand currentProtocol = extractProtocol(driver, sessionId) || currentProtocol; // If `executeCommand` was overridden and the method returns an object // with a protocol and value/error property, re-assign the protocol if (lodash_1.default.isPlainObject(driverRes) && lodash_1.default.has(driverRes, 'protocol')) { currentProtocol = driverRes.protocol || currentProtocol; if (driverRes.error) { throw driverRes.error; } driverRes = driverRes.value; } // unpack createSession response if (spec.command === exports.CREATE_SESSION_COMMAND) { newSessionId = driverRes[0]; getLogger(driver, newSessionId).debug(`Cached the protocol value '${currentProtocol}' for the new session ${newSessionId}`); if (currentProtocol === constants_1.PROTOCOLS.MJSONWP) { driverRes = driverRes[1]; } else if (currentProtocol === constants_1.PROTOCOLS.W3C) { driverRes = { capabilities: driverRes[1], }; } } driverRes = (0, helpers_1.formatResponseValue)(driverRes); // delete should not return anything even if successful if (spec.command === exports.DELETE_SESSION_COMMAND) { getLogger(driver, sessionId).debug(`Received response: ${lodash_1.default.truncate(JSON.stringify(driverRes), { length: constants_1.MAX_LOG_BODY_LENGTH, })}`); getLogger(driver, sessionId).debug('But deleting session, so not returning'); driverRes = null; } // if the status is not 0, throw the appropriate error for status code. if (support_1.util.hasValue(driverRes)) { if (support_1.util.hasValue(driverRes.status) && !isNaN(driverRes.status) && parseInt(driverRes.status, 10) !== 0) { throw (0, errors_1.errorFromMJSONWPStatusCode)(driverRes.status, driverRes.value); } else if (lodash_1.default.isPlainObject(driverRes.value) && driverRes.value.error) { throw (0, errors_1.errorFromW3CJsonCode)(driverRes.value.error, driverRes.value.message, driverRes.value.stacktrace); } } httpResBody.value = driverRes; getLogger(driver, sessionId || newSessionId).debug(`Responding ` + `to client with driver.${spec.command}() result: ${lodash_1.default.truncate(JSON.stringify(driverRes), { length: constants_1.MAX_LOG_BODY_LENGTH, })}`); } catch (err) { // if anything goes wrong, figure out what our response should be // based on the type of error that we encountered let actualErr; if (err instanceof Error || (lodash_1.default.has(err, 'stack') && lodash_1.default.has(err, 'message'))) { actualErr = err; } else { getLogger(driver, sessionId || newSessionId).warn('The thrown error object does not seem to be a valid instance of the Error class. This ' + 'might be a genuine bug of a driver or a plugin.'); actualErr = new Error(`${err ?? 'unknown'}`); } currentProtocol = currentProtocol || extractProtocol(driver, sessionId || newSessionId); let errMsg = err.stacktrace || err.stack; if (!lodash_1.default.includes(errMsg, err.message)) { // if the message has more information, add it. but often the message // is the first part of the stack trace errMsg = `${err.message}${errMsg ? '\n' + errMsg : ''}`; } if ((0, errors_1.isErrorType)(err, errors_1.errors.ProxyRequestError)) { actualErr = err.getActualError(); } else { getLogger(driver, sessionId || newSessionId).debug(`Encountered internal error running command: ${errMsg}`); } [httpStatus, httpResBody] = (0, errors_1.getResponseForW3CError)(actualErr); } // decode the response, which is either a string or json if (lodash_1.default.isString(httpResBody)) { res.status(httpStatus) .setHeader('content-type', 'application/json; charset=utf-8') .send(httpResBody); } else { if (newSessionId && currentProtocol === constants_1.PROTOCOLS.W3C) { httpResBody.value.sessionId = newSessionId; } res.status(httpStatus).json((0, helpers_1.ensureW3cResponse)(httpResBody)); } }; // add the method to the app app[method.toLowerCase()](path, (req, res) => { bluebird_1.default.resolve(asyncHandler(req, res)).done(); }); } async function doJwpProxy(driver, req, res) { const sessionId = getSessionId(driver, req); getLogger(driver, sessionId).info('Driver proxy active, passing request on via HTTP proxy'); // check that the inner driver has a proxy function if (!driver.canProxy(sessionId)) { throw new Error('Trying to proxy to a server but the driver is unable to proxy'); } try { await driver.executeCommand('proxyReqRes', req, res, sessionId); } catch (err) { if ((0, errors_1.isErrorType)(err, errors_1.errors.ProxyRequestError)) { throw err; } else { throw new Error(`Could not proxy. Proxy error: ${err.message}`, { cause: err }); } } } //# sourceMappingURL=protocol.js.map