UNPKG

webdriverio-automation

Version:

WebdriverIO-Automation android ios project

344 lines (313 loc) 12.7 kB
import _ from 'lodash'; import { logger, util } from 'appium-support'; import request from 'request-promise'; import { getSummaryByCode } from '../jsonwp-status/status'; import { errors, isErrorType, errorFromMJSONWPStatusCode, errorFromW3CJsonCode } from '../protocol/errors'; import BaseDriver from '../basedriver/driver'; import { routeToCommandName } from '../protocol/routes'; import ProtocolConverter from './protocol-converter'; import { formatResponseValue, formatStatus } from '../protocol/helpers'; import SESSIONS_CACHE from '../protocol/sessions-cache'; const log = logger.getLogger('WD Proxy'); // TODO: Make this value configurable as a server side capability const LOG_OBJ_LENGTH = 1024; // MAX LENGTH Logged to file / console const DEFAULT_REQUEST_TIMEOUT = 240000; const {MJSONWP, W3C} = BaseDriver.DRIVER_PROTOCOL; class JWProxy { constructor (opts = {}) { Object.assign(this, { scheme: 'http', server: 'localhost', port: 4444, base: '/wd/hub', sessionId: null, timeout: DEFAULT_REQUEST_TIMEOUT, keepAlive: false, }, opts); this.scheme = this.scheme.toLowerCase(); this._activeRequests = []; this._downstreamProtocol = null; this.protocolConverter = new ProtocolConverter(this.proxy.bind(this)); } // abstract the call behind a member function // so that we can mock it in tests async request (...args) { const currentRequest = request(...args); this._activeRequests.push(currentRequest); return await currentRequest.finally(() => _.pull(this._activeRequests, currentRequest)); } getActiveRequestsCount () { return this._activeRequests.length; } cancelActiveRequests () { try { for (let r of this._activeRequests) { r.cancel(); } } finally { this._activeRequests = []; } } endpointRequiresSessionId (endpoint) { return !_.includes(['/session', '/sessions', '/status'], endpoint); } set downstreamProtocol (value) { this._downstreamProtocol = value; this.protocolConverter.downstreamProtocol = value; } get downstreamProtocol () { return this._downstreamProtocol; } getUrlForProxy (url) { if (url === '') { url = '/'; } const proxyBase = `${this.scheme}://${this.server}:${this.port}${this.base}`; const endpointRe = '(/(session|status))'; let remainingUrl = ''; if (/^http/.test(url)) { const first = (new RegExp(`(https?://.+)${endpointRe}`)).exec(url); if (!first) { throw new Error('Got a complete url but could not extract JWP endpoint'); } remainingUrl = url.replace(first[1], ''); } else if ((new RegExp('^/')).test(url)) { remainingUrl = url; } else { throw new Error(`Did not know what to do with url '${url}'`); } const stripPrefixRe = new RegExp('^.*?(/(session|status).*)$'); if (stripPrefixRe.test(remainingUrl)) { remainingUrl = stripPrefixRe.exec(remainingUrl)[1]; } if (!(new RegExp(endpointRe)).test(remainingUrl)) { remainingUrl = `/session/${this.sessionId}${remainingUrl}`; } const requiresSessionId = this.endpointRequiresSessionId(remainingUrl); if (requiresSessionId && this.sessionId === null) { throw new Error('Trying to proxy a session command without session id'); } const sessionBaseRe = new RegExp('^/session/([^/]+)'); if (sessionBaseRe.test(remainingUrl)) { // we have something like /session/:id/foobar, so we need to replace // the session id const match = sessionBaseRe.exec(remainingUrl); remainingUrl = remainingUrl.replace(match[1], this.sessionId); } else if (requiresSessionId) { throw new Error(`Could not find :session section for url: ${remainingUrl}`); } remainingUrl = remainingUrl.replace(/\/$/, ''); // can't have trailing slashes return proxyBase + remainingUrl; } syncDownstreamProtocol (resBodyObj, isSessionCreationRequest) { if (!this.downstreamProtocol) { this.downstreamProtocol = this.getProtocolFromResBody(resBodyObj); log.debug(`Determined the downstream protocol as '${this.downstreamProtocol}'`); } else if (isSessionCreationRequest) { // It might be that we proxy API calls to the downstream driver // without creating a session first // and it responds using the default proto, // but then after createSession request is sent the internal proto is changed // to the other one based on the actually provided caps const previousValue = this.downstreamProtocol; this.downstreamProtocol = this.getProtocolFromResBody(resBodyObj); if (previousValue && previousValue !== this.downstreamProtocol) { log.debug(`Updated the downstream protocol to '${this.downstreamProtocol}' ` + `as per session creation request`); } else { log.debug(`Determined the downstream protocol as '${this.downstreamProtocol}' ` + `per session creation request`); } } } async proxy (url, method, body = null) { method = method.toUpperCase(); const newUrl = this.getUrlForProxy(url); const truncateBody = (content) => _.truncate( _.isString(content) ? content : JSON.stringify(content), { length: LOG_OBJ_LENGTH }); const reqOpts = { agent: false, url: newUrl, method, headers: { 'content-type': 'application/json; charset=utf-8', 'user-agent': 'appium', accept: 'application/json, */*', }, resolveWithFullResponse: true, timeout: this.timeout, forever: this.keepAlive, }; if (util.hasValue(body)) { if (typeof body !== 'object') { try { reqOpts.json = JSON.parse(body); } catch (e) { throw new Error(`Cannot interpret the request body as valid JSON: ${truncateBody(body)}`); } } else { reqOpts.json = body; } } // GET methods shouldn't have any body. Most servers are OK with this, but WebDriverAgent throws 400 errors if (method === 'GET') { reqOpts.json = null; } log.debug(`Proxying [${method} ${url || '/'}] to [${method} ${newUrl}] ` + (body ? `with body: ${truncateBody(body)}` : 'with no body')); const throwProxyError = (error) => { const message = `The request to ${url} has failed`; const err = new Error(message); err.message = message; err.error = error; err.statusCode = 500; throw err; }; let isResponseLogged = false; try { const res = await this.request(reqOpts); // `res.body` might be really big // Be careful while handling it to avoid memory leaks const resBodyObj = util.safeJsonParse(res.body); if (!_.isPlainObject(resBodyObj)) { // The response should be a valid JSON object // If it cannot be coerced to an object then the response is wrong throwProxyError(res.body); } log.debug(`Got response with status ${res.statusCode}: ${truncateBody(res.body)}`); isResponseLogged = true; const isSessionCreationRequest = /\/session$/.test(url) && method === 'POST'; if (isSessionCreationRequest && res.statusCode === 200) { this.sessionId = resBodyObj.sessionId || (resBodyObj.value || {}).sessionId; } this.syncDownstreamProtocol(resBodyObj, isSessionCreationRequest); if (res.statusCode < 400 && this.downstreamProtocol === MJSONWP && _.has(resBodyObj, 'status') && parseInt(resBodyObj.status, 10) !== 0) { // Some servers, like chromedriver may return response code 200 for non-zero JSONWP statuses throwProxyError(resBodyObj); } return [res, resBodyObj]; } 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 if (!util.hasValue(e.error)) { log.warn(e.message); log.debug(e.stack); } else { if (!util.hasValue(e.statusCode) || !/^\s*\{/.test(e.error)) { if (isResponseLogged) { log.info('The response has an unknown format'); } else { log.info(`Got an unexpected response with status ${e.statusCode}: ${truncateBody(e.error)}`); } } else if (!isResponseLogged) { log.debug(`Got response with status ${e.statusCode}: ${truncateBody(e.error)}`); isResponseLogged = true; } } throw new errors.ProxyRequestError(`Could not proxy command to remote server. ` + `Original error: ${e.message}`, e.error, e.statusCode); } } getProtocolFromResBody (resObj) { if (_.isInteger(resObj.status)) { return MJSONWP; } if (!_.isUndefined(resObj.value)) { return W3C; } } requestToCommandName (url, method) { const extractCommandName = (pattern) => { const pathMatch = pattern.exec(url); return pathMatch ? routeToCommandName(pathMatch[1], method) : null; }; let commandName = routeToCommandName(url, method); if (!commandName && _.includes(url, '/wd/hub/session/')) { commandName = extractCommandName(/\/wd\/hub\/session\/[^/]+(.+)/); } if (!commandName && _.includes(url, '/wd/hub/')) { commandName = extractCommandName(/\/wd\/hub(\/.+)/); } return commandName; } async proxyCommand (url, method, body = null) { const commandName = this.requestToCommandName(url, method); if (!commandName) { return await this.proxy(url, method, body); } log.debug(`Matched '${url}' to command name '${commandName}'`); return await this.protocolConverter.convertAndProxy(commandName, url, method, body); } async command (url, method, body = null) { let response; let resBodyObj; try { [response, resBodyObj] = await this.proxyCommand(url, method, body); } catch (err) { if (isErrorType(err, errors.ProxyRequestError)) { throw err.getActualError(); } throw new 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 (_.has(message, 'message')) { message = message.message; } throw errorFromMJSONWPStatusCode(status, _.isEmpty(message) ? getSummaryByCode(status) : message); } } else if (protocol === W3C) { // Got response in W3C format if (response.statusCode < 300) { return resBodyObj.value; } if (_.isPlainObject(resBodyObj.value) && resBodyObj.value.error) { throw 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.UnknownError(`Did not know what to do with response code '${response.statusCode}' ` + `and response body '${_.truncate(JSON.stringify(resBodyObj), {length: 300})}'`); } getSessionIdFromUrl (url) { const match = url.match(/\/session\/([^/]+)/); return match ? match[1] : null; } async proxyReqRes (req, res) { const [response, resBodyObj] = await this.proxyCommand(req.originalUrl, req.method, req.body); res.headers = response.headers; res.set('content-type', response.headers['content-type']); // 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 const reqSessionId = this.getSessionIdFromUrl(req.originalUrl); if (_.has(resBodyObj, 'sessionId')) { if (reqSessionId) { log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${reqSessionId}`); resBodyObj.sessionId = reqSessionId; } else if (this.sessionId) { log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${this.sessionId}`); resBodyObj.sessionId = this.sessionId; } } resBodyObj.value = formatResponseValue(resBodyObj.value); formatStatus(resBodyObj, SESSIONS_CACHE.getProtocol(reqSessionId)); res.status(response.statusCode).send(JSON.stringify(resBodyObj)); } } export { JWProxy }; export default JWProxy;