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