@appium/base-driver
Version:
Base driver class for Appium drivers
285 lines (260 loc) • 9.82 kB
text/typescript
import type {AppiumLogger, HTTPBody, ProxyResponse} from '@appium/types';
import _ from 'lodash';
import {logger, util} from '@appium/support';
import {duplicateKeys} from '../basedriver/helpers';
import {MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY, PROTOCOLS} from '../constants';
export type ProxyFunction = (
url: string,
method: string,
body?: HTTPBody
) => Promise<[ProxyResponse, HTTPBody]>;
export const COMMAND_URLS_CONFLICTS = [
{
commandNames: ['execute', 'executeAsync'],
jsonwpConverter: (url: string) =>
url.replace(/\/execute.*/, url.includes('async') ? '/execute_async' : '/execute'),
w3cConverter: (url: string) =>
url.replace(/\/execute.*/, url.includes('async') ? '/execute/async' : '/execute/sync'),
},
{
commandNames: ['getElementScreenshot'],
jsonwpConverter: (url: string) => url.replace(/\/element\/([^/]+)\/screenshot$/, '/screenshot/$1'),
w3cConverter: (url: string) => url.replace(/\/screenshot\/([^/]+)/, '/element/$1/screenshot'),
},
{
commandNames: ['getWindowHandles', 'getWindowHandle'],
jsonwpConverter(url: string) {
return url.endsWith('/window')
? url.replace(/\/window$/, '/window_handle')
: url.replace(/\/window\/handle(s?)$/, '/window_handle$1');
},
w3cConverter(url: string) {
return url.endsWith('/window_handle')
? url.replace(/\/window_handle$/, '/window')
: url.replace(/\/window_handles$/, '/window/handles');
},
},
{
commandNames: ['getProperty'],
jsonwpConverter: (w3cUrl: string) => {
const w3cPropertyRegex = /\/element\/([^/]+)\/property\/([^/]+)/;
return w3cUrl.replace(w3cPropertyRegex, '/element/$1/attribute/$2');
},
// Don't convert JSONWP URL to W3C. W3C accepts /attribute and /property
w3cConverter: (jsonwpUrl: string) => jsonwpUrl,
},
] as const;
const {MJSONWP, W3C} = PROTOCOLS;
const DEFAULT_LOG = logger.getLogger('Protocol Converter');
export class ProtocolConverter {
private _downstreamProtocol: string | null | undefined = null;
private readonly _log: AppiumLogger | null;
/**
* @param proxyFunc - Function to perform the actual proxy request
* @param log - Logger instance, or null to use the default
*/
constructor(
public proxyFunc: ProxyFunction,
log: AppiumLogger | null = null
) {
this._log = log;
}
get log(): AppiumLogger {
return this._log ?? DEFAULT_LOG;
}
get downstreamProtocol(): string | null | undefined {
return this._downstreamProtocol;
}
set downstreamProtocol(value: string | null | undefined) {
this._downstreamProtocol = value;
}
/**
* Handle "crossing" endpoints for the case when upstream and downstream
* drivers operate different protocols.
*/
async convertAndProxy(
commandName: string,
url: string,
method: string,
body?: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
if (!this.downstreamProtocol) {
return await this.proxyFunc(url, method, body);
}
// Same url, but different arguments
switch (commandName) {
case 'timeouts':
return await this.proxySetTimeouts(url, method, body);
case 'setWindow':
return await this.proxySetWindow(url, method, body);
case 'setValue':
return await this.proxySetValue(url, method, body);
case 'performActions':
return await this.proxyPerformActions(url, method, body);
case 'releaseActions':
return await this.proxyReleaseActions(url, method);
case 'setFrame':
return await this.proxySetFrame(url, method, body);
default:
break;
}
// Same arguments, but different URLs
for (const {commandNames, jsonwpConverter, w3cConverter} of COMMAND_URLS_CONFLICTS) {
if (!(commandNames as readonly string[]).includes(commandName)) {
continue;
}
const rewrittenUrl =
this.downstreamProtocol === MJSONWP ? jsonwpConverter(url) : w3cConverter(url);
if (rewrittenUrl === url) {
this.log.debug(
`Did not know how to rewrite the original URL '${url}' for ${this.downstreamProtocol} protocol`
);
break;
}
this.log.info(
`Rewrote the original URL '${url}' to '${rewrittenUrl}' for ${this.downstreamProtocol} protocol`
);
return await this.proxyFunc(rewrittenUrl, method, body);
}
// No matches found. Proceed normally
return await this.proxyFunc(url, method, body);
}
/**
* W3C /timeouts can take as many as 3 timeout types at once, MJSONWP /timeouts only takes one
* at a time. So if we're using W3C and proxying to MJSONWP and there's more than one timeout type
* provided in the request, we need to do 3 proxies and combine the result.
*/
private getTimeoutRequestObjects(body: HTTPBody): Record<string, unknown>[] {
if (_.isNil(body)) {
return [];
}
const bodyObj = (util.safeJsonParse(body) as Record<string, unknown>) ?? {};
if (this.downstreamProtocol === W3C && _.has(bodyObj, 'ms') && _.has(bodyObj, 'type')) {
const typeToW3C = (x: string) => (x === 'page load' ? 'pageLoad' : x);
return [
{
[typeToW3C(bodyObj.type as string)]: bodyObj.ms,
},
];
}
if (this.downstreamProtocol === MJSONWP && (!_.has(bodyObj, 'ms') || !_.has(bodyObj, 'type'))) {
const typeToJSONWP = (x: string) => (x === 'pageLoad' ? 'page load' : x);
return _.toPairs(bodyObj)
// Only transform the entry if ms value is a valid positive float number
.filter((pair) => /^\d+(?:[.,]\d*?)?$/.test(`${pair[1]}`))
.map((pair) => ({
type: typeToJSONWP(pair[0]),
ms: pair[1],
}));
}
return [bodyObj];
}
/**
* Proxy an array of timeout objects and merge the result.
*/
private async proxySetTimeouts(
url: string,
method: string,
body?: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const timeoutRequestObjects = this.getTimeoutRequestObjects(body);
if (timeoutRequestObjects.length === 0) {
return await this.proxyFunc(url, method, body);
}
this.log.debug(
`Will send the following request bodies to /timeouts: ${JSON.stringify(timeoutRequestObjects)}`
);
let response!: ProxyResponse;
let resBody!: HTTPBody;
for (const timeoutObj of timeoutRequestObjects) {
[response, resBody] = await this.proxyFunc(url, method, timeoutObj as HTTPBody);
// If we got a non-MJSONWP response, return the result, nothing left to do
if (this.downstreamProtocol !== MJSONWP) {
return [response, resBody];
}
// If we got an error, return the error right away
if (response.statusCode >= 400) {
return [response, resBody];
}
// ...Otherwise, continue to the next timeouts call
}
return [response, resBody];
}
private async proxySetWindow(
url: string,
method: string,
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.isPlainObject(bodyObj)) {
const obj = bodyObj as Record<string, unknown>;
if (this.downstreamProtocol === W3C && _.has(bodyObj, 'name') && !_.has(bodyObj, 'handle')) {
this.log.debug(`Copied 'name' value '${obj.name}' to 'handle' as per W3C spec`);
return await this.proxyFunc(url, method, {...obj, handle: obj.name});
}
if (
this.downstreamProtocol === MJSONWP &&
_.has(bodyObj, 'handle') &&
!_.has(bodyObj, 'name')
) {
this.log.debug(`Copied 'handle' value '${obj.handle}' to 'name' as per JSONWP spec`);
return await this.proxyFunc(url, method, {...obj, name: obj.handle});
}
}
return await this.proxyFunc(url, method, body);
}
private async proxySetValue(
url: string,
method: string,
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body) as Record<string, unknown> | undefined;
if (_.isPlainObject(bodyObj) && (util.hasValue(bodyObj?.text) || util.hasValue(bodyObj?.value))) {
let {text, value} = bodyObj;
if (util.hasValue(text) && !util.hasValue(value)) {
value = _.isString(text) ? [...text] : _.isArray(text) ? text : [];
this.log.debug(`Added 'value' property to 'setValue' request body`);
} else if (!util.hasValue(text) && util.hasValue(value)) {
text = _.isArray(value) ? value.join('') : _.isString(value) ? value : '';
this.log.debug(`Added 'text' property to 'setValue' request body`);
}
return await this.proxyFunc(url, method, {...bodyObj, text, value});
}
return await this.proxyFunc(url, method, body);
}
private async proxySetFrame(
url: string,
method: string,
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.has(bodyObj, 'id') && _.isPlainObject(bodyObj.id)) {
return await this.proxyFunc(url, method, {
...(bodyObj as object),
id: duplicateKeys(bodyObj.id as object, MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY),
});
}
return await this.proxyFunc(url, method, body);
}
private async proxyPerformActions(
url: string,
method: string,
body: HTTPBody
): Promise<[ProxyResponse, HTTPBody]> {
const bodyObj = util.safeJsonParse(body);
if (_.isPlainObject(bodyObj)) {
return await this.proxyFunc(
url,
method,
duplicateKeys(bodyObj as object, MJSONWP_ELEMENT_KEY, W3C_ELEMENT_KEY)
);
}
return await this.proxyFunc(url, method, body);
}
private async proxyReleaseActions(
url: string,
method: string
): Promise<[ProxyResponse, HTTPBody]> {
return await this.proxyFunc(url, method);
}
}