@appium/base-driver 9.6.0 → 9.8.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.
@@ -13,8 +13,8 @@ export const {version: BASEDRIVER_VER} = fs.readPackageJsonFrom(__dirname);
13
13
  const IPA_EXT = '.ipa';
14
14
  const ZIP_EXTS = new Set(['.zip', IPA_EXT]);
15
15
  const ZIP_MIME_TYPES = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'];
16
- const CACHED_APPS_MAX_AGE = 1000 * 60 * 60 * 24; // ms
17
- const MAX_CACHED_APPS = 1024;
16
+ const CACHED_APPS_MAX_AGE_MS = 1000 * 60 * toNaturalNumber(60 * 24, 'APPIUM_APPS_CACHE_MAX_AGE');
17
+ const MAX_CACHED_APPS = toNaturalNumber(1024, 'APPIUM_APPS_CACHE_MAX_ITEMS');
18
18
  const HTTP_STATUS_NOT_MODIFIED = 304;
19
19
  const DEFAULT_REQ_HEADERS = Object.freeze({
20
20
  'user-agent': `Appium (BaseDriver v${BASEDRIVER_VER})`,
@@ -23,12 +23,12 @@ const AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC = 2;
23
23
  /** @type {LRUCache<string, import('@appium/types').CachedAppInfo>} */
24
24
  const APPLICATIONS_CACHE = new LRUCache({
25
25
  max: MAX_CACHED_APPS,
26
- ttl: CACHED_APPS_MAX_AGE, // expire after 24 hours
26
+ ttl: CACHED_APPS_MAX_AGE_MS, // expire after 24 hours
27
27
  updateAgeOnGet: true,
28
28
  dispose: ({fullPath}, app) => {
29
29
  logger.info(
30
30
  `The application '${app}' cached at '${fullPath}' has ` +
31
- `expired after ${CACHED_APPS_MAX_AGE}ms`
31
+ `expired after ${CACHED_APPS_MAX_AGE_MS}ms`
32
32
  );
33
33
  if (fullPath) {
34
34
  fs.rimraf(fullPath);
@@ -61,60 +61,6 @@ process.on('exit', () => {
61
61
  }
62
62
  });
63
63
 
64
- /**
65
- * @param {string} app
66
- * @param {string[]} supportedAppExtensions
67
- * @returns {string}
68
- */
69
- function verifyAppExtension(app, supportedAppExtensions) {
70
- if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
71
- return app;
72
- }
73
- throw new Error(
74
- `New app path '${app}' did not have ` +
75
- `${util.pluralize('extension', supportedAppExtensions.length, false)}: ` +
76
- supportedAppExtensions
77
- );
78
- }
79
-
80
- /**
81
- * @param {string} folderPath
82
- * @returns {Promise<number>}
83
- */
84
- async function calculateFolderIntegrity(folderPath) {
85
- return (await fs.glob('**/*', {cwd: folderPath})).length;
86
- }
87
-
88
- /**
89
- * @param {string} filePath
90
- * @returns {Promise<string>}
91
- */
92
- async function calculateFileIntegrity(filePath) {
93
- return await fs.hash(filePath);
94
- }
95
-
96
- /**
97
- * @param {string} currentPath
98
- * @param {import('@appium/types').StringRecord} expectedIntegrity
99
- * @returns {Promise<boolean>}
100
- */
101
- async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) {
102
- if (!(await fs.exists(currentPath))) {
103
- return false;
104
- }
105
-
106
- // Folder integrity check is simple:
107
- // Verify the previous amount of files is not greater than the current one.
108
- // We don't want to use equality comparison because of an assumption that the OS might
109
- // create some unwanted service files/cached inside of that folder or its subfolders.
110
- // Ofc, validating the hash sum of each file (or at least of file path) would be much
111
- // more precise, but we don't need to be very precise here and also don't want to
112
- // overuse RAM and have a performance drop.
113
- return (await fs.stat(currentPath)).isDirectory()
114
- ? (await calculateFolderIntegrity(currentPath)) >= expectedIntegrity?.folder
115
- : (await calculateFileIntegrity(currentPath)) === expectedIntegrity?.file;
116
- }
117
-
118
64
  /**
119
65
  *
120
66
  * @param {string} app
@@ -145,6 +91,7 @@ export async function configureApp(
145
91
  }
146
92
 
147
93
  let newApp = app;
94
+ const originalAppLink = app;
148
95
  let shouldUnzipApp = false;
149
96
  let packageHash = null;
150
97
  /** @type {import('axios').AxiosResponse['headers']|undefined} */
@@ -156,15 +103,16 @@ export async function configureApp(
156
103
  maxAge: null,
157
104
  etag: null,
158
105
  };
159
- const {protocol, pathname} = url.parse(newApp);
160
- const isUrl = protocol === null ? false : ['http:', 'https:'].includes(protocol);
106
+ const {protocol, pathname} = parseAppLink(app);
107
+ const isUrl = isSupportedUrl(app);
108
+ const appCacheKey = toCacheKey(app);
161
109
 
162
- const cachedAppInfo = APPLICATIONS_CACHE.get(app);
110
+ const cachedAppInfo = APPLICATIONS_CACHE.get(appCacheKey);
163
111
  if (cachedAppInfo) {
164
112
  logger.debug(`Cached app data: ${JSON.stringify(cachedAppInfo, null, 2)}`);
165
113
  }
166
114
 
167
- return await APPLICATIONS_CACHE_GUARD.acquire(app, async () => {
115
+ return await APPLICATIONS_CACHE_GUARD.acquire(appCacheKey, async () => {
168
116
  if (isUrl) {
169
117
  // Use the app from remote URL
170
118
  logger.info(`Using downloadable app '${newApp}'`);
@@ -208,7 +156,7 @@ export async function configureApp(
208
156
  `The application at '${cachedAppInfo.fullPath}' does not exist anymore ` +
209
157
  `or its integrity has been damaged. Deleting it from the internal cache`
210
158
  );
211
- APPLICATIONS_CACHE.delete(app);
159
+ APPLICATIONS_CACHE.delete(appCacheKey);
212
160
 
213
161
  if (!stream.closed) {
214
162
  stream.destroy();
@@ -269,6 +217,7 @@ export async function configureApp(
269
217
  }
270
218
  newApp = onDownload
271
219
  ? await onDownload({
220
+ url: originalAppLink,
272
221
  headers: /** @type {import('@appium/types').HTTPHeaders} */ (_.clone(headers)),
273
222
  stream,
274
223
  })
@@ -316,7 +265,7 @@ export async function configureApp(
316
265
  `The application at '${fullPath}' does not exist anymore ` +
317
266
  `or its integrity has been damaged. Deleting it from the cache`
318
267
  );
319
- APPLICATIONS_CACHE.delete(app);
268
+ APPLICATIONS_CACHE.delete(appCacheKey);
320
269
  }
321
270
  const tmpRoot = await tempDir.openDir();
322
271
  try {
@@ -347,7 +296,7 @@ export async function configureApp(
347
296
  } else {
348
297
  integrity.file = await calculateFileIntegrity(appPathToCache);
349
298
  }
350
- APPLICATIONS_CACHE.set(app, {
299
+ APPLICATIONS_CACHE.set(appCacheKey, {
351
300
  ...remoteAppProps,
352
301
  timestamp: Date.now(),
353
302
  packageHash,
@@ -362,6 +311,7 @@ export async function configureApp(
362
311
  /** @type {import('@appium/types').PostProcessOptions<import('axios').AxiosResponseHeaders>} */ ({
363
312
  cachedAppInfo: _.clone(cachedAppInfo),
364
313
  isUrl,
314
+ originalAppLink,
365
315
  headers: _.clone(headers),
366
316
  appPath: newApp,
367
317
  })
@@ -372,12 +322,94 @@ export async function configureApp(
372
322
  }
373
323
 
374
324
  verifyAppExtension(newApp, supportedAppExtensions);
375
- return app !== newApp && (packageHash || _.values(remoteAppProps).some(Boolean))
325
+ return appCacheKey !== toCacheKey(newApp) && (packageHash || _.values(remoteAppProps).some(Boolean))
376
326
  ? await storeAppInCache(newApp)
377
327
  : newApp;
378
328
  });
379
329
  }
380
330
 
331
+ /**
332
+ * @param {string} app
333
+ * @returns {boolean}
334
+ */
335
+ export function isPackageOrBundle(app) {
336
+ return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app);
337
+ }
338
+
339
+ /**
340
+ * Finds all instances 'firstKey' and create a duplicate with the key 'secondKey',
341
+ * Do the same thing in reverse. If we find 'secondKey', create a duplicate with the key 'firstKey'.
342
+ *
343
+ * This will cause keys to be overwritten if the object contains 'firstKey' and 'secondKey'.
344
+
345
+ * @param {*} input Any type of input
346
+ * @param {String} firstKey The first key to duplicate
347
+ * @param {String} secondKey The second key to duplicate
348
+ */
349
+ export function duplicateKeys(input, firstKey, secondKey) {
350
+ // If array provided, recursively call on all elements
351
+ if (_.isArray(input)) {
352
+ return input.map((item) => duplicateKeys(item, firstKey, secondKey));
353
+ }
354
+
355
+ // If object, create duplicates for keys and then recursively call on values
356
+ if (_.isPlainObject(input)) {
357
+ const resultObj = {};
358
+ for (let [key, value] of _.toPairs(input)) {
359
+ const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
360
+ if (key === firstKey) {
361
+ resultObj[secondKey] = recursivelyCalledValue;
362
+ } else if (key === secondKey) {
363
+ resultObj[firstKey] = recursivelyCalledValue;
364
+ }
365
+ resultObj[key] = recursivelyCalledValue;
366
+ }
367
+ return resultObj;
368
+ }
369
+
370
+ // Base case. Return primitives without doing anything.
371
+ return input;
372
+ }
373
+
374
+ /**
375
+ * Takes a desired capability and tries to JSON.parse it as an array,
376
+ * and either returns the parsed array or a singleton array.
377
+ *
378
+ * @param {string|Array<String>} cap A desired capability
379
+ */
380
+ export function parseCapsArray(cap) {
381
+ if (_.isArray(cap)) {
382
+ return cap;
383
+ }
384
+
385
+ let parsedCaps;
386
+ try {
387
+ parsedCaps = JSON.parse(cap);
388
+ if (_.isArray(parsedCaps)) {
389
+ return parsedCaps;
390
+ }
391
+ } catch (ign) {
392
+ logger.warn(`Failed to parse capability as JSON array`);
393
+ }
394
+ if (_.isString(cap)) {
395
+ return [cap];
396
+ }
397
+ throw new Error(`must provide a string or JSON Array; received ${cap}`);
398
+ }
399
+
400
+ /**
401
+ * Generate a string that uniquely describes driver instance
402
+ *
403
+ * @param {import('@appium/types').Core} obj driver instance
404
+ * @param {string?} sessionId session identifier (if exists)
405
+ * @returns {string}
406
+ */
407
+ export function generateDriverLogPrefix(obj, sessionId = null) {
408
+ const instanceName = `${obj.constructor.name}@${node.getObjectId(obj).substring(0, 4)}`;
409
+ return sessionId ? `${instanceName} (${sessionId.substring(0, 8)})` : instanceName;
410
+ }
411
+
412
+
381
413
  /**
382
414
  * Sends a HTTP GET query to fetch the app with caching enabled.
383
415
  * Follows https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
@@ -483,9 +515,7 @@ async function unzipApp(zipPath, dstRoot, supportedAppExtensions) {
483
515
  try {
484
516
  logger.debug(`Unzipping '${zipPath}'`);
485
517
  const timer = new timing.Timer().start();
486
- const useSystemUnzipEnv = process.env.APPIUM_PREFER_SYSTEM_UNZIP;
487
- const useSystemUnzip =
488
- _.isEmpty(useSystemUnzipEnv) || !['0', 'false'].includes(_.toLower(useSystemUnzipEnv));
518
+ const useSystemUnzip = isEnvOptionEnabled('APPIUM_PREFER_SYSTEM_UNZIP', true);
489
519
  /**
490
520
  * Attempt to use use the system `unzip` (e.g., `/usr/bin/unzip`) due
491
521
  * to the significant performance improvement it provides over the native
@@ -539,84 +569,144 @@ async function unzipApp(zipPath, dstRoot, supportedAppExtensions) {
539
569
  }
540
570
 
541
571
  /**
542
- * @param {string} app
543
- * @returns {boolean}
572
+ * Transforms the given app link to the cache key.
573
+ * This is necessary to properly cache apps
574
+ * having the same address, but different query strings,
575
+ * for example ones stored in S3 using presigned URLs.
576
+ *
577
+ * @param {string} app App link.
578
+ * @returns {string} Transformed app link or the original arg if
579
+ * no transfromation is needed.
544
580
  */
545
- export function isPackageOrBundle(app) {
546
- return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app);
581
+ function toCacheKey(app) {
582
+ if (!isEnvOptionEnabled('APPIUM_APPS_CACHE_IGNORE_URL_QUERY') || !isSupportedUrl(app)) {
583
+ return app;
584
+ }
585
+ try {
586
+ const {href, search} = parseAppLink(app);
587
+ if (href && search) {
588
+ return href.replace(search, '');
589
+ }
590
+ if (href) {
591
+ return href;
592
+ }
593
+ } catch {}
594
+ return app;
547
595
  }
548
596
 
549
597
  /**
550
- * Finds all instances 'firstKey' and create a duplicate with the key 'secondKey',
551
- * Do the same thing in reverse. If we find 'secondKey', create a duplicate with the key 'firstKey'.
598
+ * Safely parses the given app link to a URL object
552
599
  *
553
- * This will cause keys to be overwritten if the object contains 'firstKey' and 'secondKey'.
554
-
555
- * @param {*} input Any type of input
556
- * @param {String} firstKey The first key to duplicate
557
- * @param {String} secondKey The second key to duplicate
600
+ * @param {string} appLink
601
+ * @returns {URL|import('@appium/types').StringRecord} Parsed URL object
602
+ * or an empty object if the parsing has failed
558
603
  */
559
- export function duplicateKeys(input, firstKey, secondKey) {
560
- // If array provided, recursively call on all elements
561
- if (_.isArray(input)) {
562
- return input.map((item) => duplicateKeys(item, firstKey, secondKey));
604
+ function parseAppLink(appLink) {
605
+ try {
606
+ return new URL(appLink);
607
+ } catch {
608
+ return {};
563
609
  }
610
+ }
564
611
 
565
- // If object, create duplicates for keys and then recursively call on values
566
- if (_.isPlainObject(input)) {
567
- const resultObj = {};
568
- for (let [key, value] of _.toPairs(input)) {
569
- const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
570
- if (key === firstKey) {
571
- resultObj[secondKey] = recursivelyCalledValue;
572
- } else if (key === secondKey) {
573
- resultObj[firstKey] = recursivelyCalledValue;
574
- }
575
- resultObj[key] = recursivelyCalledValue;
576
- }
577
- return resultObj;
612
+ /**
613
+ * Checks whether we can threat the given app link
614
+ * as a URL,
615
+ *
616
+ * @param {string} app
617
+ * @returns {boolean} True if app is a supported URL
618
+ */
619
+ function isSupportedUrl(app) {
620
+ try {
621
+ const {protocol} = parseAppLink(app);
622
+ return ['http:', 'https:'].includes(protocol);
623
+ } catch {
624
+ return false;
578
625
  }
579
-
580
- // Base case. Return primitives without doing anything.
581
- return input;
582
626
  }
583
627
 
584
628
  /**
585
- * Takes a desired capability and tries to JSON.parse it as an array,
586
- * and either returns the parsed array or a singleton array.
629
+ * Check if the given environment option is enabled
587
630
  *
588
- * @param {string|Array<String>} cap A desired capability
631
+ * @param {string} optionName Option name
632
+ * @param {boolean|null} [defaultValue=null] The value to return if the given env value
633
+ * is not set explcitly
634
+ * @returns {boolean} True if the option is enabled
589
635
  */
590
- export function parseCapsArray(cap) {
591
- if (_.isArray(cap)) {
592
- return cap;
636
+ function isEnvOptionEnabled(optionName, defaultValue = null) {
637
+ const value = process.env[optionName];
638
+ if (!_.isNull(defaultValue) && _.isEmpty(value)) {
639
+ return defaultValue;
593
640
  }
641
+ return !_.isEmpty(value) && !['0', 'false', 'no'].includes(_.toLower(value));
642
+ }
594
643
 
595
- let parsedCaps;
596
- try {
597
- parsedCaps = JSON.parse(cap);
598
- if (_.isArray(parsedCaps)) {
599
- return parsedCaps;
600
- }
601
- } catch (ign) {
602
- logger.warn(`Failed to parse capability as JSON array`);
603
- }
604
- if (_.isString(cap)) {
605
- return [cap];
644
+ /**
645
+ *
646
+ * @param {string} [envVarName]
647
+ * @param {number} defaultValue
648
+ * @returns {number}
649
+ */
650
+ function toNaturalNumber(defaultValue, envVarName) {
651
+ if (!envVarName || _.isUndefined(process.env[envVarName])) {
652
+ return defaultValue;
606
653
  }
607
- throw new Error(`must provide a string or JSON Array; received ${cap}`);
654
+ const num = parseInt(`${process.env[envVarName]}`, 10);
655
+ return num > 0 ? num : defaultValue;
608
656
  }
609
657
 
610
658
  /**
611
- * Generate a string that uniquely describes driver instance
612
- *
613
- * @param {import('@appium/types').Core} obj driver instance
614
- * @param {string?} sessionId session identifier (if exists)
659
+ * @param {string} app
660
+ * @param {string[]} supportedAppExtensions
615
661
  * @returns {string}
616
662
  */
617
- export function generateDriverLogPrefix(obj, sessionId = null) {
618
- const instanceName = `${obj.constructor.name}@${node.getObjectId(obj).substring(0, 4)}`;
619
- return sessionId ? `${instanceName} (${sessionId.substring(0, 8)})` : instanceName;
663
+ function verifyAppExtension(app, supportedAppExtensions) {
664
+ if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
665
+ return app;
666
+ }
667
+ throw new Error(
668
+ `New app path '${app}' did not have ` +
669
+ `${util.pluralize('extension', supportedAppExtensions.length, false)}: ` +
670
+ supportedAppExtensions
671
+ );
672
+ }
673
+
674
+ /**
675
+ * @param {string} folderPath
676
+ * @returns {Promise<number>}
677
+ */
678
+ async function calculateFolderIntegrity(folderPath) {
679
+ return (await fs.glob('**/*', {cwd: folderPath})).length;
680
+ }
681
+
682
+ /**
683
+ * @param {string} filePath
684
+ * @returns {Promise<string>}
685
+ */
686
+ async function calculateFileIntegrity(filePath) {
687
+ return await fs.hash(filePath);
688
+ }
689
+
690
+ /**
691
+ * @param {string} currentPath
692
+ * @param {import('@appium/types').StringRecord} expectedIntegrity
693
+ * @returns {Promise<boolean>}
694
+ */
695
+ async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) {
696
+ if (!(await fs.exists(currentPath))) {
697
+ return false;
698
+ }
699
+
700
+ // Folder integrity check is simple:
701
+ // Verify the previous amount of files is not greater than the current one.
702
+ // We don't want to use equality comparison because of an assumption that the OS might
703
+ // create some unwanted service files/cached inside of that folder or its subfolders.
704
+ // Ofc, validating the hash sum of each file (or at least of file path) would be much
705
+ // more precise, but we don't need to be very precise here and also don't want to
706
+ // overuse RAM and have a performance drop.
707
+ return (await fs.stat(currentPath)).isDirectory()
708
+ ? (await calculateFolderIntegrity(currentPath)) >= expectedIntegrity?.folder
709
+ : (await calculateFileIntegrity(currentPath)) === expectedIntegrity?.file;
620
710
  }
621
711
 
622
712
  /** @type {import('@appium/types').DriverHelpers} */
@@ -2,8 +2,16 @@ import _ from 'lodash';
2
2
  import log from './logger';
3
3
  import {errors} from '../protocol';
4
4
  import {handleIdempotency} from './idempotency';
5
+ import {pathToRegexp} from 'path-to-regexp';
5
6
 
6
- function allowCrossDomain(req, res, next) {
7
+ /**
8
+ *
9
+ * @param {import('express').Request} req
10
+ * @param {import('express').Response} res
11
+ * @param {import('express').NextFunction} next
12
+ * @returns {any}
13
+ */
14
+ export function allowCrossDomain(req, res, next) {
7
15
  try {
8
16
  res.header('Access-Control-Allow-Origin', '*');
9
17
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS, DELETE');
@@ -22,7 +30,11 @@ function allowCrossDomain(req, res, next) {
22
30
  next();
23
31
  }
24
32
 
25
- function allowCrossDomainAsyncExecute(basePath) {
33
+ /**
34
+ * @param {string} basePath
35
+ * @returns {import('express').RequestHandler}
36
+ */
37
+ export function allowCrossDomainAsyncExecute(basePath) {
26
38
  return (req, res, next) => {
27
39
  // there are two paths for async responses, so cover both
28
40
  // https://regex101.com/r/txYiEz/1
@@ -36,12 +48,17 @@ function allowCrossDomainAsyncExecute(basePath) {
36
48
  };
37
49
  }
38
50
 
39
- function fixPythonContentType(basePath) {
51
+ /**
52
+ *
53
+ * @param {string} basePath
54
+ * @returns {import('express').RequestHandler}
55
+ */
56
+ export function fixPythonContentType(basePath) {
40
57
  return (req, res, next) => {
41
58
  // hack because python client library gives us wrong content-type
42
59
  if (
43
60
  new RegExp(`^${_.escapeRegExp(basePath)}`).test(req.path) &&
44
- /^Python/.test(req.headers['user-agent'])
61
+ /^Python/.test(req.headers['user-agent'] ?? '')
45
62
  ) {
46
63
  if (req.headers['content-type'] === 'application/x-www-form-urlencoded') {
47
64
  req.headers['content-type'] = 'application/json; charset=utf-8';
@@ -51,14 +68,55 @@ function fixPythonContentType(basePath) {
51
68
  };
52
69
  }
53
70
 
54
- function defaultToJSONContentType(req, res, next) {
71
+ /**
72
+ *
73
+ * @param {import('express').Request} req
74
+ * @param {import('express').Response} res
75
+ * @param {import('express').NextFunction} next
76
+ * @returns {any}
77
+ */
78
+ export function defaultToJSONContentType(req, res, next) {
55
79
  if (!req.headers['content-type']) {
56
80
  req.headers['content-type'] = 'application/json; charset=utf-8';
57
81
  }
58
82
  next();
59
83
  }
60
84
 
61
- function catchAllHandler(err, req, res, next) {
85
+ /**
86
+ *
87
+ * @param {import('@appium/types').StringRecord<import('@appium/types').WSServer>} webSocketsMapping
88
+ * @returns {import('express').RequestHandler}
89
+ */
90
+ export function handleUpgrade(webSocketsMapping) {
91
+ return (req, res, next) => {
92
+ if (!req.headers?.upgrade || _.toLower(req.headers.upgrade) !== 'websocket') {
93
+ return next();
94
+ }
95
+ let currentPathname;
96
+ try {
97
+ currentPathname = new URL(req.url ?? '').pathname;
98
+ } catch {
99
+ currentPathname = req.url ?? '';
100
+ }
101
+ for (const [pathname, wsServer] of _.toPairs(webSocketsMapping)) {
102
+ if (pathToRegexp(pathname).test(currentPathname)) {
103
+ return wsServer.handleUpgrade(req, req.socket, Buffer.from(''), (ws) => {
104
+ wsServer.emit('connection', ws, req);
105
+ });
106
+ }
107
+ }
108
+ log.info(`Did not match the websocket upgrade request at ${currentPathname} to any known route`);
109
+ next();
110
+ };
111
+ }
112
+
113
+ /**
114
+ * @param {Error} err
115
+ * @param {import('express').Request} req
116
+ * @param {import('express').Response} res
117
+ * @param {import('express').NextFunction} next
118
+ */
119
+ export function catchAllHandler(err, req, res, next) {
62
120
  if (res.headersSent) {
63
121
  return next(err);
64
122
  }
@@ -79,7 +137,11 @@ function catchAllHandler(err, req, res, next) {
79
137
  log.error(err);
80
138
  }
81
139
 
82
- function catch404Handler(req, res) {
140
+ /**
141
+ * @param {import('express').Request} req
142
+ * @param {import('express').Response} res
143
+ */
144
+ export function catch404Handler(req, res) {
83
145
  log.debug(`No route found for ${req.url}`);
84
146
  const error = errors.UnknownCommandError;
85
147
  res.status(error.w3cStatus()).json(
@@ -107,12 +169,4 @@ function patchWithSessionId(req, body) {
107
169
  return body;
108
170
  }
109
171
 
110
- export {
111
- allowCrossDomain,
112
- fixPythonContentType,
113
- defaultToJSONContentType,
114
- catchAllHandler,
115
- allowCrossDomainAsyncExecute,
116
- handleIdempotency,
117
- catch404Handler,
118
- };
172
+ export { handleIdempotency };
@@ -14,6 +14,7 @@ import {
14
14
  catchAllHandler,
15
15
  allowCrossDomainAsyncExecute,
16
16
  handleIdempotency,
17
+ handleUpgrade,
17
18
  catch404Handler,
18
19
  } from './middleware';
19
20
  import {guineaPig, guineaPigScrollable, guineaPigAppBanner, welcome, STATIC_DIR} from './static';
@@ -109,6 +110,7 @@ async function server(opts) {
109
110
  allowCors,
110
111
  basePath,
111
112
  extraMethodMap,
113
+ webSocketsMapping: appiumServer.webSocketsMapping,
112
114
  });
113
115
  // allow extensions to update the app and http server objects
114
116
  for (const updater of serverUpdaters) {
@@ -139,6 +141,7 @@ function configureServer({
139
141
  allowCors = true,
140
142
  basePath = DEFAULT_BASE_PATH,
141
143
  extraMethodMap = {},
144
+ webSocketsMapping = {},
142
145
  }) {
143
146
  basePath = normalizeBasePath(basePath);
144
147
 
@@ -152,7 +155,7 @@ function configureServer({
152
155
  app.use(`${basePath}/produce_error`, produceError);
153
156
  app.use(`${basePath}/crash`, produceCrash);
154
157
 
155
- // add middlewares
158
+ app.use(handleUpgrade(webSocketsMapping));
156
159
  if (allowCors) {
157
160
  app.use(allowCrossDomain);
158
161
  } else {
@@ -195,6 +198,7 @@ function configureHttp({httpServer, reject, keepAliveTimeout}) {
195
198
  * @type {AppiumServer}
196
199
  */
197
200
  const appiumServer = /** @type {any} */ (httpServer);
201
+ appiumServer.webSocketsMapping = {};
198
202
  appiumServer.addWebSocketHandler = addWebSocketHandler;
199
203
  appiumServer.removeWebSocketHandler = removeWebSocketHandler;
200
204
  appiumServer.removeAllWebSocketHandlers = removeAllWebSocketHandlers;
@@ -370,4 +374,5 @@ export {server, configureServer, normalizeBasePath};
370
374
  * @property {boolean} [allowCors]
371
375
  * @property {string} [basePath]
372
376
  * @property {MethodMap} [extraMethodMap]
377
+ * @property {import('@appium/types').StringRecord} [webSocketsMapping={}]
373
378
  */
@@ -1,8 +1,6 @@
1
1
  /* eslint-disable require-await */
2
2
  import _ from 'lodash';
3
- import {URL} from 'url';
4
3
  import B from 'bluebird';
5
- import { pathToRegexp } from 'path-to-regexp';
6
4
 
7
5
  const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
8
6
 
@@ -11,27 +9,6 @@ const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
11
9
  * @type {AppiumServer['addWebSocketHandler']}
12
10
  */
13
11
  async function addWebSocketHandler(handlerPathname, handlerServer) {
14
- if (_.isUndefined(this.webSocketsMapping)) {
15
- this.webSocketsMapping = {};
16
- // https://github.com/websockets/ws/pull/885
17
- this.on('upgrade', (request, socket, head) => {
18
- let currentPathname;
19
- try {
20
- currentPathname = new URL(request.url ?? '').pathname;
21
- } catch {
22
- currentPathname = request.url ?? '';
23
- }
24
- for (const [pathname, wsServer] of _.toPairs(this.webSocketsMapping)) {
25
- if (pathToRegexp(pathname).test(currentPathname)) {
26
- wsServer.handleUpgrade(request, socket, head, (ws) => {
27
- wsServer.emit('connection', ws, request);
28
- });
29
- return;
30
- }
31
- }
32
- socket.destroy();
33
- });
34
- }
35
12
  this.webSocketsMapping[handlerPathname] = handlerServer;
36
13
  }
37
14
 
@@ -40,10 +17,6 @@ async function addWebSocketHandler(handlerPathname, handlerServer) {
40
17
  * @type {AppiumServer['getWebSocketHandlers']}
41
18
  */
42
19
  async function getWebSocketHandlers(keysFilter = null) {
43
- if (_.isEmpty(this.webSocketsMapping)) {
44
- return {};
45
- }
46
-
47
20
  return _.toPairs(this.webSocketsMapping).reduce((acc, [pathname, wsServer]) => {
48
21
  if (!_.isString(keysFilter) || pathname.includes(keysFilter)) {
49
22
  acc[pathname] = wsServer;