UNPKG

@appium/base-driver

Version:

Base driver class for Appium drivers

406 lines 16.7 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 = require("./protocol-converter"); const helpers_1 = require("../protocol/helpers"); const node_http_1 = __importDefault(require("node:http")); const node_https_1 = __importDefault(require("node: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', 'headers', ]; class JWProxy { scheme; server; port; base; reqBasePath; sessionId; timeout; headers; httpAgent; httpsAgent; protocolConverter; _downstreamProtocol; _activeRequests; _log; constructor(opts = {}) { const filteredOpts = lodash_1.default.pick(opts, ALLOWED_OPTS); 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 = 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 node_http_1.default.Agent(agentOpts); this.httpsAgent = new node_https_1.default.Agent(agentOpts); this.protocolConverter = new protocol_converter_1.ProtocolConverter(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; } /** * Gets the protocol used by the downstream server (W3C or MJSONWP). */ get downstreamProtocol() { return this._downstreamProtocol; } /** * Sets the protocol used by the downstream server (W3C or MJSONWP). */ set downstreamProtocol(value) { this._downstreamProtocol = value; this.protocolConverter.downstreamProtocol = value; } /** * Returns the number of active downstream HTTP requests. */ getActiveRequestsCount() { return this._activeRequests.length; } /** * Cancels all currently active downstream HTTP requests. */ cancelActiveRequests() { for (const ar of this._activeRequests) { ar.cancel(); } this._activeRequests = []; } /** * Builds a full downstream URL (including base path and session) for a given upstream URL. */ getUrlForProxy(url, method) { const parsedUrl = this._parseUrl(url); const normalizedPathname = this._toNormalizedPathname(parsedUrl); const commandName = normalizedPathname ? (0, protocol_1.routeToCommandName)(normalizedPathname, 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}`; } /** * Proxies a raw WebDriver command to the downstream server. */ 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, }); const reqOpts = { url: newUrl, method, headers: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'appium', accept: 'application/json, */*', ...(this.headers ?? {}), }, 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 (error) { this.log.warn('Invalid body payload (%s): %s', error.message, support_1.logger.markSensitive(truncateBody(body))); throw new Error('Cannot interpret the request body as valid JSON. Check the server log for more details.', { cause: error }); } } else { reqOpts.data = body; } } this.log.debug(`Proxying [%s %s] to [%s %s] with ${reqOpts.data ? 'body: %s' : '%s body'}`, method, url || '/', method, newUrl, reqOpts.data ? support_1.logger.markSensitive(truncateBody(reqOpts.data)) : 'no'); const throwProxyError = (error) => { const err = 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 = url.endsWith('/session') && method === 'POST'; if (isSessionCreationRequest) { if (status === 200) { const value = data.value; const raw = data.sessionId ?? value?.sessionId; this.sessionId = typeof raw === 'string' ? raw : raw != null ? String(raw) : null; } this.downstreamProtocol = this.getProtocolFromResBody(data) ?? this.downstreamProtocol; this.log.info(`Determined the downstream protocol as '${this.downstreamProtocol}'`); } if (lodash_1.default.has(data, 'status') && parseInt(data.status, 10) !== 0) { throwProxyError(data); } return [ { statusCode: status, headers: headers, body: data, }, data, ]; } catch (e) { const err = e; let proxyErrorMsg = err.message; if (support_1.util.hasValue(err.response)) { if (!isResponseLogged) { const error = truncateBody(err.response.data); this.log.info(support_1.util.hasValue(err.response.status) ? `Got response with status ${err.response.status}: ${error}` : `Got response with unknown status: ${error}`); } } else { proxyErrorMsg = `Could not proxy command to the remote server. Original error: ${err.message}`; this.log.info(err.message); } throw new errors_1.errors.ProxyRequestError(proxyErrorMsg, err.response?.data, err.response?.status); } } /** * Detects the downstream protocol from a response body. */ getProtocolFromResBody(resObj) { if (lodash_1.default.isInteger(resObj.status)) { return MJSONWP; } if (!lodash_1.default.isUndefined(resObj.value)) { return W3C; } } /** * Proxies a command identified by its HTTP method and URL to the downstream server. */ 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); } /** * Executes a WebDriver command and returns the unwrapped `value` field (or throws). */ 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 resBody = resBodyObj; const protocol = this.getProtocolFromResBody(resBody); if (protocol === MJSONWP) { if (response.statusCode === 200 && resBody.status === 0) { return resBody.value; } const status = parseInt(resBody.status, 10); if (!isNaN(status) && status !== 0) { let message = resBody.value; if (lodash_1.default.isPlainObject(message) && 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) { if (response.statusCode < 300) { return resBody.value; } if (lodash_1.default.isPlainObject(resBody.value) && resBody.value.error) { const value = resBody.value; throw (0, errors_1.errorFromW3CJsonCode)(value.error, value.message ?? '', value.stacktrace); } } else if (response.statusCode === 200) { 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, })}'`); } /** * Extracts a session id from a WebDriver-style URL. */ getSessionIdFromUrl(url) { const match = url.match(/\/session\/([^/]+)/); return match ? match[1] : null; } /** * Proxies an Express `Request`/`Response` pair to the downstream server, * converting any downstream errors into a proper W3C HTTP response. * * This method must not throw; it always writes a response. */ async proxyReqRes(req, res) { let statusCode; let resBodyObj; try { const [response, body] = await this.proxyCommand(req.originalUrl, req.method, req.body); statusCode = response.statusCode; resBodyObj = body; } 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); } const resBody = resBodyObj; if (lodash_1.default.has(resBody, 'sessionId')) { const reqSessionId = this.getSessionIdFromUrl(req.originalUrl); if (reqSessionId) { this.log.info(`Replacing sessionId ${resBody.sessionId} with ${reqSessionId}`); resBody.sessionId = reqSessionId; } else if (this.sessionId) { this.log.info(`Replacing sessionId ${resBody.sessionId} with ${this.sessionId}`); resBody.sessionId = this.sessionId; } } resBody.value = (0, helpers_1.formatResponseValue)(resBody.value); res.status(statusCode).json((0, helpers_1.ensureW3cResponse)(resBody)); } /** * Performs requests to the downstream server * * @private - Do not call this method directly, * it uses client-specific arguments and responses! */ 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); } } _parseUrl(url) { // eslint-disable-next-line n/no-deprecated-api -- we need relative URL support 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; } _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 && match.params && 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 && match.params) { const command = match.params.command; result = lodash_1.default.isArray(command) ? `/${command.join('/')}` : ''; } return lodash_1.default.trimEnd(result, '/'); } } exports.JWProxy = JWProxy; //# sourceMappingURL=proxy.js.map