@appium/base-driver
Version:
Base driver class for Appium drivers
515 lines • 27.2 kB
JavaScript
;
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