@appium/base-driver 10.2.1 → 10.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,493 +0,0 @@
1
- import _ from 'lodash';
2
- import {logger, util} from '@appium/support';
3
- import {getSummaryByCode} from '../jsonwp-status/status';
4
- import {
5
- errors,
6
- isErrorType,
7
- errorFromMJSONWPStatusCode,
8
- errorFromW3CJsonCode,
9
- getResponseForW3CError,
10
- } from '../protocol/errors';
11
- import {isSessionCommand, routeToCommandName} from '../protocol';
12
- import {MAX_LOG_BODY_LENGTH, DEFAULT_BASE_PATH, PROTOCOLS} from '../constants';
13
- import ProtocolConverter from './protocol-converter';
14
- import {formatResponseValue, ensureW3cResponse} from '../protocol/helpers';
15
- import http from 'node:http';
16
- import https from 'node:https';
17
- import { match as pathToRegexMatch } from 'path-to-regexp';
18
- import nodeUrl from 'node:url';
19
- import { ProxyRequest } from './proxy-request';
20
-
21
- const DEFAULT_LOG = logger.getLogger('WD Proxy');
22
- const DEFAULT_REQUEST_TIMEOUT = 240000;
23
- const COMMAND_WITH_SESSION_ID_MATCHER = pathToRegexMatch('{/*prefix}/session/:sessionId{/*command}');
24
-
25
- const {MJSONWP, W3C} = PROTOCOLS;
26
-
27
- const ALLOWED_OPTS = [
28
- 'scheme',
29
- 'server',
30
- 'port',
31
- 'base',
32
- 'reqBasePath',
33
- 'sessionId',
34
- 'timeout',
35
- 'log',
36
- 'keepAlive',
37
- 'headers',
38
- ];
39
-
40
- export class JWProxy {
41
- /** @type {string} */
42
- scheme;
43
- /** @type {string} */
44
- server;
45
- /** @type {number} */
46
- port;
47
- /** @type {string} */
48
- base;
49
- /** @type {string} */
50
- reqBasePath;
51
- /** @type {string?} */
52
- sessionId;
53
- /** @type {number} */
54
- timeout;
55
- /** @type {import('@appium/types').HTTPHeaders | undefined} */
56
- headers;
57
- /** @type {Protocol | null | undefined} */
58
- _downstreamProtocol;
59
- /** @type {ProxyRequest[]} */
60
- _activeRequests;
61
-
62
- /**
63
- * @param {import('@appium/types').ProxyOptions} [opts={}]
64
- */
65
- constructor(opts = {}) {
66
- const filteredOpts = _.pick(opts, ALLOWED_OPTS);
67
- // omit 'log' in the defaults assignment here because 'log' is a getter and we are going to set
68
- // it to this._log (which lies behind the getter) further down
69
- /** @type {import('@appium/types').ProxyOptions} */
70
- const options = _.defaults(_.omit(filteredOpts, 'log'), {
71
- scheme: 'http',
72
- server: 'localhost',
73
- port: 4444,
74
- base: DEFAULT_BASE_PATH,
75
- reqBasePath: DEFAULT_BASE_PATH,
76
- sessionId: null,
77
- timeout: DEFAULT_REQUEST_TIMEOUT,
78
- });
79
- options.scheme = /** @type {string} */ (options.scheme).toLowerCase();
80
- Object.assign(this, options);
81
-
82
- this._activeRequests = [];
83
- this._downstreamProtocol = null;
84
- const agentOpts = {
85
- keepAlive: opts.keepAlive ?? true,
86
- maxSockets: 10,
87
- maxFreeSockets: 5,
88
- };
89
- this.httpAgent = new http.Agent(agentOpts);
90
- this.httpsAgent = new https.Agent(agentOpts);
91
- this.protocolConverter = new ProtocolConverter(this.proxy.bind(this), opts.log);
92
- this._log = opts.log;
93
-
94
- this.log.debug(`${this.constructor.name} options: ${JSON.stringify(options)}`);
95
- }
96
-
97
- get log() {
98
- return this._log ?? DEFAULT_LOG;
99
- }
100
-
101
- /**
102
- * Performs requests to the downstream server
103
- *
104
- * @private - Do not call this method directly,
105
- * it uses client-specific arguments and responses!
106
- *
107
- * @param {import('axios').RawAxiosRequestConfig} requestConfig
108
- * @returns {Promise<import('axios').AxiosResponse>}
109
- */
110
- async request(requestConfig) {
111
- const req = new ProxyRequest(requestConfig);
112
- this._activeRequests.push(req);
113
- try {
114
- return await req.execute();
115
- } finally {
116
- _.pull(this._activeRequests, req);
117
- }
118
- }
119
-
120
- /**
121
- * @returns {number}
122
- */
123
- getActiveRequestsCount() {
124
- return this._activeRequests.length;
125
- }
126
-
127
- cancelActiveRequests() {
128
- for (const ar of this._activeRequests) {
129
- ar.cancel();
130
- }
131
- this._activeRequests = [];
132
- }
133
-
134
- /**
135
- * @param {Protocol | null | undefined} value
136
- */
137
- set downstreamProtocol(value) {
138
- this._downstreamProtocol = value;
139
- this.protocolConverter.downstreamProtocol = value;
140
- }
141
-
142
- get downstreamProtocol() {
143
- return this._downstreamProtocol;
144
- }
145
-
146
- /**
147
- *
148
- * @param {string} url
149
- * @param {string} [method]
150
- * @returns {string}
151
- */
152
- getUrlForProxy(url, method) {
153
- const parsedUrl = this._parseUrl(url);
154
- const normalizedPathname = this._toNormalizedPathname(parsedUrl);
155
- const commandName = normalizedPathname
156
- ? routeToCommandName(
157
- normalizedPathname,
158
- /** @type {import('@appium/types').HTTPMethod | undefined} */ (method)
159
- )
160
- : '';
161
- const requiresSessionId = !commandName || (commandName && isSessionCommand(commandName));
162
- const proxyPrefix = `${this.scheme}://${this.server}:${this.port}${this.base}`;
163
- let proxySuffix = normalizedPathname ? `/${_.trimStart(normalizedPathname, '/')}` : '';
164
- if (parsedUrl.search) {
165
- proxySuffix += parsedUrl.search;
166
- }
167
- if (!requiresSessionId) {
168
- return `${proxyPrefix}${proxySuffix}`;
169
- }
170
- if (!this.sessionId) {
171
- throw new ReferenceError(`Session ID is not set, but saw a URL that requires it (${url})`);
172
- }
173
- return `${proxyPrefix}/session/${this.sessionId}${proxySuffix}`;
174
- }
175
-
176
- /**
177
- *
178
- * @param {string} url
179
- * @param {string} method
180
- * @param {import('@appium/types').HTTPBody} [body=null]
181
- * @returns {Promise<[import('@appium/types').ProxyResponse, import('@appium/types').HTTPBody]>}
182
- */
183
- async proxy(url, method, body = null) {
184
- method = method.toUpperCase();
185
- const newUrl = this.getUrlForProxy(url, method);
186
- const truncateBody = (content) =>
187
- _.truncate(_.isString(content) ? content : JSON.stringify(content), {
188
- length: MAX_LOG_BODY_LENGTH,
189
- });
190
- /** @type {import('axios').RawAxiosRequestConfig} */
191
- const reqOpts = {
192
- url: newUrl,
193
- method,
194
- headers: {
195
- 'content-type': 'application/json; charset=utf-8',
196
- 'user-agent': 'appium',
197
- accept: 'application/json, */*',
198
- ...(this.headers ?? {}),
199
- },
200
- proxy: false,
201
- timeout: this.timeout,
202
- httpAgent: this.httpAgent,
203
- httpsAgent: this.httpsAgent,
204
- };
205
- // GET methods shouldn't have any body. Most servers are OK with this, but WebDriverAgent throws 400 errors
206
- if (util.hasValue(body) && method !== 'GET') {
207
- if (typeof body !== 'object') {
208
- try {
209
- reqOpts.data = JSON.parse(body);
210
- } catch (error) {
211
- this.log.warn('Invalid body payload (%s): %s', error.message, logger.markSensitive(truncateBody(body)));
212
- throw new Error(
213
- 'Cannot interpret the request body as valid JSON. Check the server log for more details.'
214
- );
215
- }
216
- } else {
217
- reqOpts.data = body;
218
- }
219
- }
220
-
221
- this.log.debug(
222
- `Proxying [%s %s] to [%s %s] with ${reqOpts.data ? 'body: %s' : '%s body'}`,
223
- method, url || '/', method, newUrl,
224
- reqOpts.data ? logger.markSensitive(truncateBody(reqOpts.data)) : 'no'
225
- );
226
-
227
- const throwProxyError = (error) => {
228
- const err = /** @type {ProxyError} */ (new Error(`The request to ${url} has failed`));
229
- err.response = {
230
- data: error,
231
- status: 500,
232
- };
233
- throw err;
234
- };
235
- let isResponseLogged = false;
236
- try {
237
- const {data, status, headers} = await this.request(reqOpts);
238
- // `data` might be really big
239
- // Be careful while handling it to avoid memory leaks
240
- if (!_.isPlainObject(data)) {
241
- // The response should be a valid JSON object
242
- // If it cannot be coerced to an object then the response is wrong
243
- throwProxyError(data);
244
- }
245
- this.log.debug(`Got response with status ${status}: ${truncateBody(data)}`);
246
- isResponseLogged = true;
247
- const isSessionCreationRequest = url.endsWith('/session') && method === 'POST';
248
- if (isSessionCreationRequest) {
249
- if (status === 200) {
250
- this.sessionId = data.sessionId || (data.value || {}).sessionId;
251
- }
252
- this.downstreamProtocol = this.getProtocolFromResBody(data);
253
- this.log.info(`Determined the downstream protocol as '${this.downstreamProtocol}'`);
254
- }
255
- if (_.has(data, 'status') && parseInt(data.status, 10) !== 0) {
256
- // Some servers, like chromedriver may return response code 200 for non-zero JSONWP statuses
257
- throwProxyError(data);
258
- }
259
- const headersMap = /** @type {import('@appium/types').HTTPHeaders} */ (headers);
260
- return [{
261
- statusCode: status,
262
- headers: headersMap,
263
- body: data,
264
- }, data];
265
- } catch (e) {
266
- // We only consider an error unexpected if this was not
267
- // an async request module error or if the response cannot be cast to
268
- // a valid JSON
269
- let proxyErrorMsg = e.message;
270
- if (util.hasValue(e.response)) {
271
- if (!isResponseLogged) {
272
- const error = truncateBody(e.response.data);
273
- this.log.info(
274
- util.hasValue(e.response.status)
275
- ? `Got response with status ${e.response.status}: ${error}`
276
- : `Got response with unknown status: ${error}`
277
- );
278
- }
279
- } else {
280
- proxyErrorMsg = `Could not proxy command to the remote server. Original error: ${e.message}`;
281
- this.log.info(e.message);
282
- }
283
- throw new errors.ProxyRequestError(proxyErrorMsg, e.response?.data, e.response?.status);
284
- }
285
- }
286
-
287
- /**
288
- *
289
- * @param {Record<string, any>} resObj
290
- * @returns {Protocol | undefined}
291
- */
292
- getProtocolFromResBody(resObj) {
293
- if (_.isInteger(resObj.status)) {
294
- return MJSONWP;
295
- }
296
- if (!_.isUndefined(resObj.value)) {
297
- return W3C;
298
- }
299
- }
300
-
301
- /**
302
- *
303
- * @param {string} url
304
- * @param {import('@appium/types').HTTPMethod} method
305
- * @param {import('@appium/types').HTTPBody} [body=null]
306
- * @returns {Promise<[import('@appium/types').ProxyResponse, import('@appium/types').HTTPBody]>}
307
- */
308
- async proxyCommand(url, method, body = null) {
309
- const parsedUrl = this._parseUrl(url);
310
- const normalizedPathname = this._toNormalizedPathname(parsedUrl);
311
- const commandName = normalizedPathname ? routeToCommandName(normalizedPathname, method) : '';
312
- if (!commandName) {
313
- return await this.proxy(url, method, body);
314
- }
315
- this.log.debug(`Matched '${url}' to command name '${commandName}'`);
316
-
317
- return await this.protocolConverter.convertAndProxy(commandName, url, method, body);
318
- }
319
-
320
- /**
321
- *
322
- * @param {string} url
323
- * @param {import('@appium/types').HTTPMethod} method
324
- * @param {import('@appium/types').HTTPBody} [body=null]
325
- * @returns {Promise<import('@appium/types').HTTPBody>}
326
- */
327
- async command(url, method, body = null) {
328
- let response;
329
- let resBodyObj;
330
- try {
331
- [response, resBodyObj] = await this.proxyCommand(url, method, body);
332
- } catch (err) {
333
- if (isErrorType(err, errors.ProxyRequestError)) {
334
- throw err.getActualError();
335
- }
336
- throw new errors.UnknownError(err.message);
337
- }
338
- const protocol = this.getProtocolFromResBody(resBodyObj);
339
- if (protocol === MJSONWP) {
340
- // Got response in MJSONWP format
341
- if (response.statusCode === 200 && resBodyObj.status === 0) {
342
- return resBodyObj.value;
343
- }
344
- const status = parseInt(resBodyObj.status, 10);
345
- if (!isNaN(status) && status !== 0) {
346
- let message = resBodyObj.value;
347
- if (_.has(message, 'message')) {
348
- message = message.message;
349
- }
350
- throw errorFromMJSONWPStatusCode(
351
- status,
352
- _.isEmpty(message) ? getSummaryByCode(status) : message
353
- );
354
- }
355
- } else if (protocol === W3C) {
356
- // Got response in W3C format
357
- if (response.statusCode < 300) {
358
- return resBodyObj.value;
359
- }
360
- if (_.isPlainObject(resBodyObj.value) && resBodyObj.value.error) {
361
- throw errorFromW3CJsonCode(
362
- resBodyObj.value.error,
363
- resBodyObj.value.message,
364
- resBodyObj.value.stacktrace
365
- );
366
- }
367
- } else if (response.statusCode === 200) {
368
- // Unknown protocol. Keeping it because of the backward compatibility
369
- return resBodyObj;
370
- }
371
- throw new errors.UnknownError(
372
- `Did not know what to do with response code '${response.statusCode}' ` +
373
- `and response body '${_.truncate(JSON.stringify(resBodyObj), {
374
- length: 300,
375
- })}'`
376
- );
377
- }
378
-
379
- /**
380
- *
381
- * @param {string} url
382
- * @returns {string | null}
383
- */
384
- getSessionIdFromUrl(url) {
385
- const match = url.match(/\/session\/([^/]+)/);
386
- return match ? match[1] : null;
387
- }
388
-
389
- /**
390
- *
391
- * @param {import('express').Request} req
392
- * @param {import('express').Response} res
393
- */
394
- async proxyReqRes(req, res) {
395
- // ! this method must not throw any exceptions
396
- // ! make sure to call res.send before return
397
- /** @type {number} */
398
- let statusCode;
399
- /** @type {import('@appium/types').HTTPBody} */
400
- let resBodyObj;
401
- try {
402
- let response;
403
- [response, resBodyObj] = await this.proxyCommand(
404
- req.originalUrl,
405
- /** @type {import('@appium/types').HTTPMethod} */ (req.method),
406
- req.body
407
- );
408
- statusCode = response.statusCode;
409
- } catch (err) {
410
- [statusCode, resBodyObj] = getResponseForW3CError(
411
- isErrorType(err, errors.ProxyRequestError) ? err.getActualError() : err
412
- );
413
- }
414
- res.setHeader('content-type', 'application/json; charset=utf-8');
415
- if (!_.isPlainObject(resBodyObj)) {
416
- const error = new errors.UnknownError(
417
- `The downstream server response with the status code ${statusCode} is not a valid JSON object: ` +
418
- _.truncate(`${resBodyObj}`, {length: 300})
419
- );
420
- [statusCode, resBodyObj] = getResponseForW3CError(error);
421
- }
422
-
423
- // if the proxied response contains a sessionId that the downstream
424
- // driver has generated, we don't want to return that to the client.
425
- // Instead, return the id from the request or from current session
426
- if (_.has(resBodyObj, 'sessionId')) {
427
- const reqSessionId = this.getSessionIdFromUrl(req.originalUrl);
428
- if (reqSessionId) {
429
- this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${reqSessionId}`);
430
- resBodyObj.sessionId = reqSessionId;
431
- } else if (this.sessionId) {
432
- this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${this.sessionId}`);
433
- resBodyObj.sessionId = this.sessionId;
434
- }
435
- }
436
- resBodyObj.value = formatResponseValue(resBodyObj.value);
437
- res.status(statusCode).json(ensureW3cResponse(resBodyObj));
438
- }
439
-
440
- /**
441
- *
442
- * @param {string} url
443
- * @returns {ParsedUrl}
444
- */
445
- _parseUrl(url) {
446
- // eslint-disable-next-line n/no-deprecated-api -- we need relative URL support
447
- const parsedUrl = nodeUrl.parse(url || '/');
448
- if (
449
- _.isNil(parsedUrl.href) || _.isNil(parsedUrl.pathname)
450
- || (parsedUrl.protocol && !['http:', 'https:'].includes(parsedUrl.protocol))
451
- ) {
452
- throw new Error(`Did not know how to proxy the url '${url}'`);
453
- }
454
- return parsedUrl;
455
- }
456
-
457
- /**
458
- *
459
- * @param {ParsedUrl} parsedUrl
460
- * @returns {string}
461
- */
462
- _toNormalizedPathname(parsedUrl) {
463
- if (!_.isString(parsedUrl.pathname)) {
464
- return '';
465
- }
466
- let pathname = this.reqBasePath && parsedUrl.pathname.startsWith(this.reqBasePath)
467
- ? parsedUrl.pathname.replace(this.reqBasePath, '')
468
- : parsedUrl.pathname;
469
- const match = COMMAND_WITH_SESSION_ID_MATCHER(pathname);
470
- // This is needed for the backward compatibility
471
- // if drivers don't set reqBasePath properly
472
- if (!this.reqBasePath) {
473
- if (match && _.isArray(match.params?.prefix)) {
474
- pathname = pathname.replace(`/${match.params?.prefix.join('/')}`, '');
475
- } else if (_.startsWith(pathname, '/wd/hub')) {
476
- pathname = pathname.replace('/wd/hub', '');
477
- }
478
- }
479
- let result = pathname;
480
- if (match) {
481
- result = _.isArray(match.params?.command) ? `/${match.params.command.join('/')}` : '';
482
- }
483
- return _.trimEnd(result, '/');
484
- }
485
- }
486
-
487
- export default JWProxy;
488
-
489
- /**
490
- * @typedef {Error & {response: {data: import('type-fest').JsonObject, status: import('http-status-codes').StatusCodes}}} ProxyError
491
- * @typedef {nodeUrl.UrlWithStringQuery} ParsedUrl
492
- * @typedef {typeof PROTOCOLS[keyof typeof PROTOCOLS]} Protocol
493
- */