UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

454 lines 19.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.JWProxy = void 0; const lodash_1 = __importDefault(require("lodash")); const support_1 = require("@appium/support"); const status_1 = require("../jsonwp-status/status"); const errors_1 = require("../protocol/errors"); const protocol_1 = require("../protocol"); const constants_1 = require("../constants"); const protocol_converter_1 = __importDefault(require("./protocol-converter")); const helpers_1 = require("../protocol/helpers"); const http_1 = __importDefault(require("http")); const https_1 = __importDefault(require("https")); const path_to_regexp_1 = require("path-to-regexp"); const node_url_1 = __importDefault(require("node:url")); const proxy_request_1 = require("./proxy-request"); const DEFAULT_LOG = support_1.logger.getLogger('WD Proxy'); const DEFAULT_REQUEST_TIMEOUT = 240000; const COMMAND_WITH_SESSION_ID_MATCHER = (0, path_to_regexp_1.match)('{/*prefix}/session/:sessionId{/*command}'); const { MJSONWP, W3C } = constants_1.PROTOCOLS; const ALLOWED_OPTS = [ 'scheme', 'server', 'port', 'base', 'reqBasePath', 'sessionId', 'timeout', 'log', 'keepAlive', ]; class JWProxy { /** * @param {import('@appium/types').ProxyOptions} [opts={}] */ constructor(opts = {}) { const filteredOpts = lodash_1.default.pick(opts, ALLOWED_OPTS); // omit 'log' in the defaults assignment here because 'log' is a getter and we are going to set // it to this._log (which lies behind the getter) further down /** @type {import('@appium/types').ProxyOptions} */ const options = lodash_1.default.defaults(lodash_1.default.omit(filteredOpts, 'log'), { scheme: 'http', server: 'localhost', port: 4444, base: constants_1.DEFAULT_BASE_PATH, reqBasePath: constants_1.DEFAULT_BASE_PATH, sessionId: null, timeout: DEFAULT_REQUEST_TIMEOUT, }); options.scheme = /** @type {string} */ (options.scheme).toLowerCase(); Object.assign(this, options); this._activeRequests = []; this._downstreamProtocol = null; const agentOpts = { keepAlive: opts.keepAlive ?? true, maxSockets: 10, maxFreeSockets: 5, }; this.httpAgent = new http_1.default.Agent(agentOpts); this.httpsAgent = new https_1.default.Agent(agentOpts); this.protocolConverter = new protocol_converter_1.default(this.proxy.bind(this), opts.log); this._log = opts.log; this.log.debug(`${this.constructor.name} options: ${JSON.stringify(options)}`); } get log() { return this._log ?? DEFAULT_LOG; } /** * Performs requests to the downstream server * * @private - Do not call this method directly, * it uses client-specific arguments and responses! * * @param {import('axios').RawAxiosRequestConfig} requestConfig * @returns {Promise<import('axios').AxiosResponse>} */ async request(requestConfig) { const req = new proxy_request_1.ProxyRequest(requestConfig); this._activeRequests.push(req); try { return await req.execute(); } finally { lodash_1.default.pull(this._activeRequests, req); } } /** * @returns {number} */ getActiveRequestsCount() { return this._activeRequests.length; } cancelActiveRequests() { for (const ar of this._activeRequests) { ar.cancel(); } this._activeRequests = []; } /** * @param {Protocol | null | undefined} value */ set downstreamProtocol(value) { this._downstreamProtocol = value; this.protocolConverter.downstreamProtocol = value; } get downstreamProtocol() { return this._downstreamProtocol; } /** * * @param {string} url * @param {string} [method] * @returns {string} */ getUrlForProxy(url, method) { const parsedUrl = this._parseUrl(url); const normalizedPathname = this._toNormalizedPathname(parsedUrl); const commandName = normalizedPathname ? (0, protocol_1.routeToCommandName)(normalizedPathname, /** @type {import('@appium/types').HTTPMethod | undefined} */ (method)) : ''; const requiresSessionId = !commandName || (commandName && (0, protocol_1.isSessionCommand)(commandName)); const proxyPrefix = `${this.scheme}://${this.server}:${this.port}${this.base}`; let proxySuffix = normalizedPathname ? `/${lodash_1.default.trimStart(normalizedPathname, '/')}` : ''; if (parsedUrl.search) { proxySuffix += parsedUrl.search; } if (!requiresSessionId) { return `${proxyPrefix}${proxySuffix}`; } if (!this.sessionId) { throw new ReferenceError(`Session ID is not set, but saw a URL that requires it (${url})`); } return `${proxyPrefix}/session/${this.sessionId}${proxySuffix}`; } /** * * @param {string} url * @param {string} method * @param {import('@appium/types').HTTPBody} [body=null] * @returns {Promise<[import('@appium/types').ProxyResponse, import('@appium/types').HTTPBody]>} */ async proxy(url, method, body = null) { method = method.toUpperCase(); const newUrl = this.getUrlForProxy(url, method); const truncateBody = (content) => lodash_1.default.truncate(lodash_1.default.isString(content) ? content : JSON.stringify(content), { length: constants_1.MAX_LOG_BODY_LENGTH, }); /** @type {import('axios').RawAxiosRequestConfig} */ const reqOpts = { url: newUrl, method, headers: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'appium', accept: 'application/json, */*', }, proxy: false, timeout: this.timeout, httpAgent: this.httpAgent, httpsAgent: this.httpsAgent, }; // GET methods shouldn't have any body. Most servers are OK with this, but WebDriverAgent throws 400 errors if (support_1.util.hasValue(body) && method !== 'GET') { if (typeof body !== 'object') { try { reqOpts.data = JSON.parse(body); } catch { throw new Error(`Cannot interpret the request body as valid JSON: ${truncateBody(body)}`); } } else { reqOpts.data = body; } } this.log.debug(`Proxying [${method} ${url || '/'}] to [${method} ${newUrl}] ` + (reqOpts.data ? `with body: ${truncateBody(reqOpts.data)}` : 'with no body')); const throwProxyError = (error) => { const err = /** @type {ProxyError} */ (new Error(`The request to ${url} has failed`)); err.response = { data: error, status: 500, }; throw err; }; let isResponseLogged = false; try { const { data, status, headers } = await this.request(reqOpts); // `data` might be really big // Be careful while handling it to avoid memory leaks if (!lodash_1.default.isPlainObject(data)) { // The response should be a valid JSON object // If it cannot be coerced to an object then the response is wrong throwProxyError(data); } this.log.debug(`Got response with status ${status}: ${truncateBody(data)}`); isResponseLogged = true; const isSessionCreationRequest = /\/session$/.test(url) && method === 'POST'; if (isSessionCreationRequest) { if (status === 200) { this.sessionId = data.sessionId || (data.value || {}).sessionId; } this.downstreamProtocol = this.getProtocolFromResBody(data); this.log.info(`Determined the downstream protocol as '${this.downstreamProtocol}'`); } if (lodash_1.default.has(data, 'status') && parseInt(data.status, 10) !== 0) { // Some servers, like chromedriver may return response code 200 for non-zero JSONWP statuses throwProxyError(data); } const headersMap = /** @type {import('@appium/types').HTTPHeaders} */ (headers); return [{ statusCode: status, headers: headersMap, body: data, }, data]; } catch (e) { // We only consider an error unexpected if this was not // an async request module error or if the response cannot be cast to // a valid JSON let proxyErrorMsg = e.message; if (support_1.util.hasValue(e.response)) { if (!isResponseLogged) { const error = truncateBody(e.response.data); this.log.info(support_1.util.hasValue(e.response.status) ? `Got response with status ${e.response.status}: ${error}` : `Got response with unknown status: ${error}`); } } else { proxyErrorMsg = `Could not proxy command to the remote server. Original error: ${e.message}`; this.log.info(e.message); } throw new errors_1.errors.ProxyRequestError(proxyErrorMsg, e.response?.data, e.response?.status); } } /** * * @param {Record<string, any>} resObj * @returns {Protocol | undefined} */ getProtocolFromResBody(resObj) { if (lodash_1.default.isInteger(resObj.status)) { return MJSONWP; } if (!lodash_1.default.isUndefined(resObj.value)) { return W3C; } } /** * @deprecated This method is not used anymore and will be removed * * @param {string} url * @param {import('@appium/types').HTTPMethod} method * @returns {string|undefined} */ requestToCommandName(url, method) { /** * * @param {RegExp} pattern * @returns {string|undefined} */ const extractCommandName = (pattern) => { const pathMatch = pattern.exec(url); if (pathMatch) { return (0, protocol_1.routeToCommandName)(pathMatch[1], method, this.reqBasePath); } }; let commandName = (0, protocol_1.routeToCommandName)(url, method, this.reqBasePath); if (!commandName && lodash_1.default.includes(url, `${this.reqBasePath}/session/`)) { commandName = extractCommandName(new RegExp(`${lodash_1.default.escapeRegExp(this.reqBasePath)}/session/[^/]+(.+)`)); } if (!commandName && lodash_1.default.includes(url, this.reqBasePath)) { commandName = extractCommandName(new RegExp(`${lodash_1.default.escapeRegExp(this.reqBasePath)}(/.+)`)); } return commandName; } /** * * @param {string} url * @param {import('@appium/types').HTTPMethod} method * @param {import('@appium/types').HTTPBody} [body=null] * @returns {Promise<[import('@appium/types').ProxyResponse, import('@appium/types').HTTPBody]>} */ async proxyCommand(url, method, body = null) { const parsedUrl = this._parseUrl(url); const normalizedPathname = this._toNormalizedPathname(parsedUrl); const commandName = normalizedPathname ? (0, protocol_1.routeToCommandName)(normalizedPathname, method) : ''; if (!commandName) { return await this.proxy(url, method, body); } this.log.debug(`Matched '${url}' to command name '${commandName}'`); return await this.protocolConverter.convertAndProxy(commandName, url, method, body); } /** * * @param {string} url * @param {import('@appium/types').HTTPMethod} method * @param {import('@appium/types').HTTPBody} [body=null] * @returns {Promise<import('@appium/types').HTTPBody>} */ async command(url, method, body = null) { let response; let resBodyObj; try { [response, resBodyObj] = await this.proxyCommand(url, method, body); } catch (err) { if ((0, errors_1.isErrorType)(err, errors_1.errors.ProxyRequestError)) { throw err.getActualError(); } throw new errors_1.errors.UnknownError(err.message); } const protocol = this.getProtocolFromResBody(resBodyObj); if (protocol === MJSONWP) { // Got response in MJSONWP format if (response.statusCode === 200 && resBodyObj.status === 0) { return resBodyObj.value; } const status = parseInt(resBodyObj.status, 10); if (!isNaN(status) && status !== 0) { let message = resBodyObj.value; if (lodash_1.default.has(message, 'message')) { message = message.message; } throw (0, errors_1.errorFromMJSONWPStatusCode)(status, lodash_1.default.isEmpty(message) ? (0, status_1.getSummaryByCode)(status) : message); } } else if (protocol === W3C) { // Got response in W3C format if (response.statusCode < 300) { return resBodyObj.value; } if (lodash_1.default.isPlainObject(resBodyObj.value) && resBodyObj.value.error) { throw (0, errors_1.errorFromW3CJsonCode)(resBodyObj.value.error, resBodyObj.value.message, resBodyObj.value.stacktrace); } } else if (response.statusCode === 200) { // Unknown protocol. Keeping it because of the backward compatibility return resBodyObj; } throw new errors_1.errors.UnknownError(`Did not know what to do with response code '${response.statusCode}' ` + `and response body '${lodash_1.default.truncate(JSON.stringify(resBodyObj), { length: 300, })}'`); } /** * * @param {string} url * @returns {string | null} */ getSessionIdFromUrl(url) { const match = url.match(/\/session\/([^/]+)/); return match ? match[1] : null; } /** * * @param {import('express').Request} req * @param {import('express').Response} res */ async proxyReqRes(req, res) { // ! this method must not throw any exceptions // ! make sure to call res.send before return /** @type {number} */ let statusCode; /** @type {import('@appium/types').HTTPBody} */ let resBodyObj; try { let response; [response, resBodyObj] = await this.proxyCommand(req.originalUrl, /** @type {import('@appium/types').HTTPMethod} */ (req.method), req.body); statusCode = response.statusCode; } catch (err) { [statusCode, resBodyObj] = (0, errors_1.getResponseForW3CError)((0, errors_1.isErrorType)(err, errors_1.errors.ProxyRequestError) ? err.getActualError() : err); } res.setHeader('content-type', 'application/json; charset=utf-8'); if (!lodash_1.default.isPlainObject(resBodyObj)) { const error = new errors_1.errors.UnknownError(`The downstream server response with the status code ${statusCode} is not a valid JSON object: ` + lodash_1.default.truncate(`${resBodyObj}`, { length: 300 })); [statusCode, resBodyObj] = (0, errors_1.getResponseForW3CError)(error); } // if the proxied response contains a sessionId that the downstream // driver has generated, we don't want to return that to the client. // Instead, return the id from the request or from current session if (lodash_1.default.has(resBodyObj, 'sessionId')) { const reqSessionId = this.getSessionIdFromUrl(req.originalUrl); if (reqSessionId) { this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${reqSessionId}`); resBodyObj.sessionId = reqSessionId; } else if (this.sessionId) { this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${this.sessionId}`); resBodyObj.sessionId = this.sessionId; } } resBodyObj.value = (0, helpers_1.formatResponseValue)(resBodyObj.value); res.status(statusCode).send(JSON.stringify((0, helpers_1.formatStatus)(resBodyObj))); } /** * * @param {string} url * @returns {ParsedUrl} */ _parseUrl(url) { const parsedUrl = node_url_1.default.parse(url || '/'); if (lodash_1.default.isNil(parsedUrl.href) || lodash_1.default.isNil(parsedUrl.pathname) || (parsedUrl.protocol && !['http:', 'https:'].includes(parsedUrl.protocol))) { throw new Error(`Did not know how to proxy the url '${url}'`); } return parsedUrl; } /** * * @param {ParsedUrl} parsedUrl * @returns {string} */ _toNormalizedPathname(parsedUrl) { if (!lodash_1.default.isString(parsedUrl.pathname)) { return ''; } let pathname = this.reqBasePath && parsedUrl.pathname.startsWith(this.reqBasePath) ? parsedUrl.pathname.replace(this.reqBasePath, '') : parsedUrl.pathname; const match = COMMAND_WITH_SESSION_ID_MATCHER(pathname); // This is needed for the backward compatibility // if drivers don't set reqBasePath properly if (!this.reqBasePath) { if (match && lodash_1.default.isArray(match.params?.prefix)) { pathname = pathname.replace(`/${match.params?.prefix.join('/')}`, ''); } else if (lodash_1.default.startsWith(pathname, '/wd/hub')) { pathname = pathname.replace('/wd/hub', ''); } } let result = pathname; if (match) { result = lodash_1.default.isArray(match.params?.command) ? `/${match.params.command.join('/')}` : ''; } return lodash_1.default.trimEnd(result, '/'); } } exports.JWProxy = JWProxy; exports.default = JWProxy; /** * @typedef {Error & {response: {data: import('type-fest').JsonObject, status: import('http-status-codes').StatusCodes}}} ProxyError * @typedef {nodeUrl.UrlWithStringQuery} ParsedUrl * @typedef {typeof PROTOCOLS[keyof typeof PROTOCOLS]} Protocol */ //# sourceMappingURL=proxy.js.map