@appium/base-driver 9.3.3 → 9.3.5

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.
Files changed (91) hide show
  1. package/build/lib/basedriver/capabilities.d.ts +59 -36
  2. package/build/lib/basedriver/capabilities.d.ts.map +1 -1
  3. package/build/lib/basedriver/capabilities.js +57 -45
  4. package/build/lib/basedriver/capabilities.js.map +1 -1
  5. package/build/lib/basedriver/commands/event.d.ts +1 -1
  6. package/build/lib/basedriver/commands/event.d.ts.map +1 -1
  7. package/build/lib/basedriver/commands/event.js.map +1 -1
  8. package/build/lib/basedriver/commands/execute.d.ts +1 -1
  9. package/build/lib/basedriver/commands/execute.d.ts.map +1 -1
  10. package/build/lib/basedriver/commands/execute.js.map +1 -1
  11. package/build/lib/basedriver/commands/find.d.ts +1 -1
  12. package/build/lib/basedriver/commands/find.d.ts.map +1 -1
  13. package/build/lib/basedriver/commands/find.js.map +1 -1
  14. package/build/lib/basedriver/commands/index.d.ts +7 -7
  15. package/build/lib/basedriver/commands/index.d.ts.map +1 -1
  16. package/build/lib/basedriver/commands/index.js +7 -21
  17. package/build/lib/basedriver/commands/index.js.map +1 -1
  18. package/build/lib/basedriver/commands/log.d.ts +1 -1
  19. package/build/lib/basedriver/commands/log.d.ts.map +1 -1
  20. package/build/lib/basedriver/commands/log.js.map +1 -1
  21. package/build/lib/basedriver/commands/mixin.d.ts +4 -4
  22. package/build/lib/basedriver/commands/mixin.d.ts.map +1 -1
  23. package/build/lib/basedriver/commands/mixin.js +5 -4
  24. package/build/lib/basedriver/commands/mixin.js.map +1 -1
  25. package/build/lib/basedriver/commands/session.d.ts +1 -1
  26. package/build/lib/basedriver/commands/session.d.ts.map +1 -1
  27. package/build/lib/basedriver/commands/session.js +1 -4
  28. package/build/lib/basedriver/commands/session.js.map +1 -1
  29. package/build/lib/basedriver/commands/settings.d.ts +1 -1
  30. package/build/lib/basedriver/commands/settings.d.ts.map +1 -1
  31. package/build/lib/basedriver/commands/settings.js.map +1 -1
  32. package/build/lib/basedriver/commands/timeout.d.ts +1 -1
  33. package/build/lib/basedriver/commands/timeout.d.ts.map +1 -1
  34. package/build/lib/basedriver/commands/timeout.js +1 -4
  35. package/build/lib/basedriver/commands/timeout.js.map +1 -1
  36. package/build/lib/basedriver/core.d.ts +13 -17
  37. package/build/lib/basedriver/core.d.ts.map +1 -1
  38. package/build/lib/basedriver/core.js +32 -20
  39. package/build/lib/basedriver/core.js.map +1 -1
  40. package/build/lib/basedriver/device-settings.d.ts +11 -11
  41. package/build/lib/basedriver/device-settings.d.ts.map +1 -1
  42. package/build/lib/basedriver/device-settings.js +7 -8
  43. package/build/lib/basedriver/device-settings.js.map +1 -1
  44. package/build/lib/basedriver/driver.d.ts +23 -108
  45. package/build/lib/basedriver/driver.d.ts.map +1 -1
  46. package/build/lib/basedriver/driver.js +21 -126
  47. package/build/lib/basedriver/driver.js.map +1 -1
  48. package/build/lib/basedriver/helpers.d.ts +21 -98
  49. package/build/lib/basedriver/helpers.d.ts.map +1 -1
  50. package/build/lib/basedriver/helpers.js +178 -182
  51. package/build/lib/basedriver/helpers.js.map +1 -1
  52. package/build/lib/express/server.d.ts +3 -3
  53. package/build/lib/express/server.d.ts.map +1 -1
  54. package/build/lib/express/server.js +4 -2
  55. package/build/lib/express/server.js.map +1 -1
  56. package/build/lib/express/websocket.d.ts +5 -44
  57. package/build/lib/express/websocket.d.ts.map +1 -1
  58. package/build/lib/express/websocket.js +10 -39
  59. package/build/lib/express/websocket.js.map +1 -1
  60. package/build/lib/helpers/capabilities.d.ts +2 -2
  61. package/build/lib/helpers/capabilities.d.ts.map +1 -1
  62. package/build/lib/helpers/capabilities.js +2 -3
  63. package/build/lib/helpers/capabilities.js.map +1 -1
  64. package/build/lib/protocol/protocol.d.ts +1 -1
  65. package/build/lib/protocol/protocol.d.ts.map +1 -1
  66. package/build/lib/protocol/protocol.js +10 -2
  67. package/build/lib/protocol/protocol.js.map +1 -1
  68. package/build/lib/protocol/routes.d.ts +1 -0
  69. package/build/lib/protocol/routes.d.ts.map +1 -1
  70. package/build/lib/protocol/routes.js +12 -10
  71. package/build/lib/protocol/routes.js.map +1 -1
  72. package/lib/basedriver/capabilities.js +70 -56
  73. package/lib/basedriver/commands/event.ts +3 -2
  74. package/lib/basedriver/commands/execute.ts +2 -1
  75. package/lib/basedriver/commands/find.ts +1 -0
  76. package/lib/basedriver/commands/index.ts +7 -7
  77. package/lib/basedriver/commands/log.ts +2 -4
  78. package/lib/basedriver/commands/mixin.ts +5 -4
  79. package/lib/basedriver/commands/session.ts +9 -7
  80. package/lib/basedriver/commands/settings.ts +1 -0
  81. package/lib/basedriver/commands/timeout.ts +17 -17
  82. package/lib/basedriver/core.js +11 -25
  83. package/lib/basedriver/device-settings.js +9 -11
  84. package/lib/basedriver/{driver.js → driver.ts} +69 -175
  85. package/lib/basedriver/helpers.js +214 -212
  86. package/lib/express/server.js +4 -2
  87. package/lib/express/websocket.js +10 -39
  88. package/lib/helpers/capabilities.js +2 -3
  89. package/lib/protocol/protocol.js +11 -2
  90. package/lib/protocol/routes.js +12 -13
  91. package/package.json +10 -6
@@ -2,16 +2,24 @@ import _ from 'lodash';
2
2
  import path from 'path';
3
3
  import url from 'url';
4
4
  import logger from './logger';
5
- import {tempDir, fs, util, zip, net, timing, node} from '@appium/support';
5
+ import {tempDir, fs, util, zip, timing, node} from '@appium/support';
6
6
  import LRU from 'lru-cache';
7
7
  import AsyncLock from 'async-lock';
8
8
  import axios from 'axios';
9
+ import B from 'bluebird';
9
10
 
11
+ // for compat with running tests transpiled and in-place
12
+ const {version: BASEDRIVER_VER} = fs.readPackageJsonFrom(__dirname);
10
13
  const IPA_EXT = '.ipa';
11
- const ZIP_EXTS = ['.zip', IPA_EXT];
14
+ const ZIP_EXTS = new Set(['.zip', IPA_EXT]);
12
15
  const ZIP_MIME_TYPES = ['application/zip', 'application/x-zip-compressed', 'multipart/x-zip'];
13
16
  const CACHED_APPS_MAX_AGE = 1000 * 60 * 60 * 24; // ms
14
17
  const MAX_CACHED_APPS = 1024;
18
+ const HTTP_STATUS_NOT_MODIFIED = 304;
19
+ const DEFAULT_REQ_HEADERS = Object.freeze({
20
+ 'user-agent': `Appium (BaseDriver v${BASEDRIVER_VER})`,
21
+ });
22
+ const AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC = 2;
15
23
  const APPLICATIONS_CACHE = new LRU({
16
24
  max: MAX_CACHED_APPS,
17
25
  ttl: CACHED_APPS_MAX_AGE, // expire after 24 hours
@@ -52,77 +60,6 @@ process.on('exit', () => {
52
60
  }
53
61
  });
54
62
 
55
- /**
56
- *
57
- * @param {string} url
58
- * @returns {Promise<import('axios').AxiosResponse['headers']>}
59
- */
60
- async function retrieveHeaders(url) {
61
- try {
62
- return (
63
- await axios({
64
- url,
65
- method: 'HEAD',
66
- timeout: 5000,
67
- })
68
- ).headers;
69
- } catch (e) {
70
- logger.info(`Cannot send HEAD request to '${url}'. Original error: ${e.message}`);
71
- }
72
- return {};
73
- }
74
-
75
- function getCachedApplicationPath(link, currentAppProps = {}, cachedAppInfo = {}) {
76
- const refresh = () => {
77
- logger.debug(`A fresh copy of the application is going to be downloaded from ${link}`);
78
- return null;
79
- };
80
-
81
- if (!_.isPlainObject(cachedAppInfo) || !_.isPlainObject(currentAppProps)) {
82
- // if an invalid arg is passed then assume cache miss
83
- return refresh();
84
- }
85
-
86
- const {
87
- lastModified: currentModified,
88
- immutable: currentImmutable,
89
- // maxAge is in seconds
90
- maxAge: currentMaxAge,
91
- } = currentAppProps;
92
- const {
93
- // Date instance
94
- lastModified,
95
- // boolean
96
- immutable,
97
- // Unix time in milliseconds
98
- timestamp,
99
- fullPath,
100
- } = cachedAppInfo;
101
- if (lastModified && currentModified) {
102
- if (currentModified.getTime() <= lastModified.getTime()) {
103
- logger.debug(`The application at ${link} has not been modified since ${lastModified}`);
104
- return fullPath;
105
- }
106
- logger.debug(`The application at ${link} has been modified since ${lastModified}`);
107
- return refresh();
108
- }
109
- if (immutable && currentImmutable) {
110
- logger.debug(`The application at ${link} is immutable`);
111
- return fullPath;
112
- }
113
- if (currentMaxAge && timestamp) {
114
- const msLeft = timestamp + currentMaxAge * 1000 - Date.now();
115
- if (msLeft > 0) {
116
- logger.debug(
117
- `The cached application '${path.basename(fullPath)}' will expire in ${msLeft / 1000}s`
118
- );
119
- return fullPath;
120
- }
121
- logger.debug(`The cached application '${path.basename(fullPath)}' has expired`);
122
- }
123
- return refresh();
124
- }
125
-
126
63
  function verifyAppExtension(app, supportedAppExtensions) {
127
64
  if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
128
65
  return app;
@@ -160,56 +97,14 @@ async function isAppIntegrityOk(currentPath, expectedIntegrity = {}) {
160
97
  }
161
98
 
162
99
  /**
163
- * @typedef PostProcessOptions
164
- * @property {?Object} cachedAppInfo The information about the previously cached app instance (if exists):
165
- * - packageHash: SHA1 hash of the package if it is a file and not a folder
166
- * - lastModified: Optional Date instance, the value of file's `Last-Modified` header
167
- * - immutable: Optional boolean value. Contains true if the file has an `immutable` mark
168
- * in `Cache-control` header
169
- * - maxAge: Optional integer representation of `maxAge` parameter in `Cache-control` header
170
- * - timestamp: The timestamp this item has been added to the cache (measured in Unix epoch
171
- * milliseconds)
172
- * - integrity: An object containing either `file` property with SHA1 hash of the file
173
- * or `folder` property with total amount of cached files and subfolders
174
- * - fullPath: the full path to the cached app
175
- * @property {boolean} isUrl Whether the app has been downloaded from a remote URL
176
- * @property {?Object} headers Optional headers object. Only present if `isUrl` is true and if the server
177
- * responds to HEAD requests. All header names are normalized to lowercase.
178
- * @property {string} appPath A string containing full path to the preprocessed application package (either
179
- * downloaded or a local one)
180
- */
181
-
182
- /**
183
- * @typedef PostProcessResult
184
- * @property {string} appPath The full past to the post-processed application package on the
185
- * local file system (might be a file or a folder path)
186
- */
187
-
188
- /**
189
- * @typedef ConfigureAppOptions
190
- * @property {(obj: PostProcessOptions) => (Promise<PostProcessResult|undefined>|PostProcessResult|undefined)} [onPostProcess]
191
- * Optional function, which should be applied
192
- * to the application after it is downloaded/preprocessed. This function may be async
193
- * and is expected to accept single object parameter.
194
- * The function is expected to either return a falsy value, which means the app must not be
195
- * cached and a fresh copy of it is downloaded each time. If this function returns an object
196
- * containing `appPath` property then the integrity of it will be verified and stored into
197
- * the cache.
198
- * @property {string[]} supportedExtensions List of supported application extensions (
199
- * including starting dots). This property is mandatory and must not be empty.
200
- */
201
-
202
- /**
203
- * Prepares an app to be used in an automated test. The app gets cached automatically
204
- * if it is an archive or if it is downloaded from an URL.
205
- * If the downloaded app has `.zip` extension, this method will unzip it.
206
- * The unzip does not work when `onPostProcess` is provided.
207
100
  *
208
- * @param {string} app Either a full path to the app or a remote URL
209
- * @param {string|string[]|ConfigureAppOptions} options
210
- * @returns The full path to the resulting application bundle
101
+ * @param {string} app
102
+ * @param {string|string[]|import('@appium/types').ConfigureAppOptions} options
211
103
  */
212
- async function configureApp(app, options = /** @type {ConfigureAppOptions} */ ({})) {
104
+ async function configureApp(
105
+ app,
106
+ options = /** @type {import('@appium/types').ConfigureAppOptions} */ ({})
107
+ ) {
213
108
  if (!_.isString(app)) {
214
109
  // immediately shortcircuit if not given an app
215
110
  return;
@@ -233,13 +128,14 @@ async function configureApp(app, options = /** @type {ConfigureAppOptions} */ ({
233
128
  let newApp = app;
234
129
  let shouldUnzipApp = false;
235
130
  let packageHash = null;
236
- /** @type {import('axios').AxiosResponse['headers']?} */
237
- let headers = null;
131
+ /** @type {import('axios').AxiosResponse['headers']|undefined} */
132
+ let headers = undefined;
238
133
  /** @type {RemoteAppProps} */
239
134
  const remoteAppProps = {
240
135
  lastModified: null,
241
136
  immutable: false,
242
137
  maxAge: null,
138
+ etag: null,
243
139
  };
244
140
  const {protocol, pathname} = url.parse(newApp);
245
141
  const isUrl = protocol === null ? false : ['http:', 'https:'].includes(protocol);
@@ -250,94 +146,121 @@ async function configureApp(app, options = /** @type {ConfigureAppOptions} */ ({
250
146
  if (isUrl) {
251
147
  // Use the app from remote URL
252
148
  logger.info(`Using downloadable app '${newApp}'`);
253
- headers = await retrieveHeaders(newApp);
254
- if (!_.isEmpty(headers)) {
255
- if (headers['last-modified']) {
256
- remoteAppProps.lastModified = new Date(headers['last-modified']);
257
- }
258
- logger.debug(`Last-Modified: ${headers['last-modified']}`);
259
- if (headers['cache-control']) {
260
- remoteAppProps.immutable = /\bimmutable\b/i.test(headers['cache-control']);
261
- const maxAgeMatch = /\bmax-age=(\d+)\b/i.exec(headers['cache-control']);
262
- if (maxAgeMatch) {
263
- remoteAppProps.maxAge = parseInt(maxAgeMatch[1], 10);
264
- }
265
- }
266
- logger.debug(`Cache-Control: ${headers['cache-control']}`);
149
+ const reqHeaders = {
150
+ ...DEFAULT_REQ_HEADERS,
151
+ };
152
+ if (cachedAppInfo?.etag) {
153
+ reqHeaders['if-none-match'] = remoteAppProps.etag;
154
+ } else if (cachedAppInfo?.lastModified) {
155
+ reqHeaders['if-modified-since'] = remoteAppProps.lastModified?.toString();
267
156
  }
268
- const cachedPath = getCachedApplicationPath(app, remoteAppProps, cachedAppInfo);
269
- if (cachedPath) {
270
- if (await isAppIntegrityOk(cachedPath, cachedAppInfo?.integrity)) {
271
- logger.info(`Reusing previously downloaded application at '${cachedPath}'`);
272
- return verifyAppExtension(cachedPath, supportedAppExtensions);
157
+
158
+ let {headers, stream, status} = await queryAppLink(newApp, reqHeaders);
159
+ try {
160
+ if (!_.isEmpty(headers)) {
161
+ logger.debug(`Etag: ${remoteAppProps?.etag} -> ${headers.etag}`);
162
+ if (headers.etag) {
163
+ remoteAppProps.etag = headers.etag;
164
+ }
165
+ logger.debug(
166
+ `Last-Modified: ${remoteAppProps?.['last-modified']} -> ${headers['last-modified']}`
167
+ );
168
+ if (headers['last-modified']) {
169
+ remoteAppProps.lastModified = new Date(headers['last-modified']);
170
+ }
171
+ logger.debug(
172
+ `Cache-Control: ${remoteAppProps?.['cache-control']} -> ${headers['cache-control']}`
173
+ );
174
+ if (headers['cache-control']) {
175
+ remoteAppProps.immutable = /\bimmutable\b/i.test(headers['cache-control']);
176
+ const maxAgeMatch = /\bmax-age=(\d+)\b/i.exec(headers['cache-control']);
177
+ if (maxAgeMatch) {
178
+ remoteAppProps.maxAge = parseInt(maxAgeMatch[1], 10);
179
+ }
180
+ }
273
181
  }
274
- logger.info(
275
- `The application at '${cachedPath}' does not exist anymore ` +
276
- `or its integrity has been damaged. Deleting it from the internal cache`
277
- );
278
- APPLICATIONS_CACHE.delete(app);
279
- }
182
+ if (cachedAppInfo && status === HTTP_STATUS_NOT_MODIFIED) {
183
+ if (await isAppIntegrityOk(cachedAppInfo.fullPath, cachedAppInfo.integrity)) {
184
+ logger.info(`Reusing previously downloaded application at '${cachedAppInfo.fullPath}'`);
185
+ return verifyAppExtension(cachedAppInfo.fullPath, supportedAppExtensions);
186
+ }
187
+ logger.info(
188
+ `The application at '${cachedAppInfo.fullPath}' does not exist anymore ` +
189
+ `or its integrity has been damaged. Deleting it from the internal cache`
190
+ );
191
+ APPLICATIONS_CACHE.delete(app);
280
192
 
281
- let fileName = null;
282
- const basename = fs.sanitizeName(path.basename(decodeURIComponent(pathname ?? '')), {
283
- replacement: SANITIZE_REPLACEMENT,
284
- });
285
- const extname = path.extname(basename);
286
- // to determine if we need to unzip the app, we have a number of places
287
- // to look: content type, content disposition, or the file extension
288
- if (ZIP_EXTS.includes(extname)) {
289
- fileName = basename;
290
- shouldUnzipApp = true;
291
- }
292
- if (headers['content-type']) {
293
- const ct = headers['content-type'];
294
- logger.debug(`Content-Type: ${ct}`);
295
- // the filetype may not be obvious for certain urls, so check the mime type too
296
- if (
297
- ZIP_MIME_TYPES.some((mimeType) =>
298
- new RegExp(`\\b${_.escapeRegExp(mimeType)}\\b`).test(ct)
299
- )
300
- ) {
301
- if (!fileName) {
302
- fileName = `${DEFAULT_BASENAME}.zip`;
193
+ if (!stream.closed) {
194
+ stream.destroy();
303
195
  }
196
+ ({stream, headers, status} = await queryAppLink(newApp, {...DEFAULT_REQ_HEADERS}));
197
+ }
198
+
199
+ let fileName = null;
200
+ const basename = fs.sanitizeName(path.basename(decodeURIComponent(pathname ?? '')), {
201
+ replacement: SANITIZE_REPLACEMENT,
202
+ });
203
+ const extname = path.extname(basename);
204
+ // to determine if we need to unzip the app, we have a number of places
205
+ // to look: content type, content disposition, or the file extension
206
+ if (ZIP_EXTS.has(extname)) {
207
+ fileName = basename;
304
208
  shouldUnzipApp = true;
305
209
  }
306
- }
307
- if (headers['content-disposition'] && /^attachment/i.test(headers['content-disposition'])) {
308
- logger.debug(`Content-Disposition: ${headers['content-disposition']}`);
309
- const match = /filename="([^"]+)/i.exec(headers['content-disposition']);
310
- if (match) {
311
- fileName = fs.sanitizeName(match[1], {
312
- replacement: SANITIZE_REPLACEMENT,
313
- });
314
- shouldUnzipApp = shouldUnzipApp || ZIP_EXTS.includes(path.extname(fileName));
210
+ if (headers['content-type']) {
211
+ const ct = headers['content-type'];
212
+ logger.debug(`Content-Type: ${ct}`);
213
+ // the filetype may not be obvious for certain urls, so check the mime type too
214
+ if (
215
+ ZIP_MIME_TYPES.some((mimeType) =>
216
+ new RegExp(`\\b${_.escapeRegExp(mimeType)}\\b`).test(ct)
217
+ )
218
+ ) {
219
+ if (!fileName) {
220
+ fileName = `${DEFAULT_BASENAME}.zip`;
221
+ }
222
+ shouldUnzipApp = true;
223
+ }
315
224
  }
316
- }
317
- if (!fileName) {
318
- // assign the default file name and the extension if none has been detected
319
- const resultingName = basename
320
- ? basename.substring(0, basename.length - extname.length)
321
- : DEFAULT_BASENAME;
322
- let resultingExt = extname;
323
- if (!supportedAppExtensions.includes(resultingExt)) {
324
- logger.info(
325
- `The current file extension '${resultingExt}' is not supported. ` +
326
- `Defaulting to '${_.first(supportedAppExtensions)}'`
327
- );
328
- resultingExt = /** @type {string} */ (_.first(supportedAppExtensions));
225
+ if (headers['content-disposition'] && /^attachment/i.test(headers['content-disposition'])) {
226
+ logger.debug(`Content-Disposition: ${headers['content-disposition']}`);
227
+ const match = /filename="([^"]+)/i.exec(headers['content-disposition']);
228
+ if (match) {
229
+ fileName = fs.sanitizeName(match[1], {
230
+ replacement: SANITIZE_REPLACEMENT,
231
+ });
232
+ shouldUnzipApp = shouldUnzipApp || ZIP_EXTS.has(path.extname(fileName));
233
+ }
234
+ }
235
+ if (!fileName) {
236
+ // assign the default file name and the extension if none has been detected
237
+ const resultingName = basename
238
+ ? basename.substring(0, basename.length - extname.length)
239
+ : DEFAULT_BASENAME;
240
+ let resultingExt = extname;
241
+ if (!supportedAppExtensions.includes(resultingExt)) {
242
+ logger.info(
243
+ `The current file extension '${resultingExt}' is not supported. ` +
244
+ `Defaulting to '${_.first(supportedAppExtensions)}'`
245
+ );
246
+ resultingExt = /** @type {string} */ (_.first(supportedAppExtensions));
247
+ }
248
+ fileName = `${resultingName}${resultingExt}`;
249
+ }
250
+ const targetPath = await tempDir.path({
251
+ prefix: fileName,
252
+ suffix: '',
253
+ });
254
+ newApp = await fetchApp(stream, targetPath);
255
+ } finally {
256
+ if (!stream.closed) {
257
+ stream.destroy();
329
258
  }
330
- fileName = `${resultingName}${resultingExt}`;
331
259
  }
332
- const targetPath = await tempDir.path({
333
- prefix: fileName,
334
- suffix: '',
335
- });
336
- newApp = await downloadApp(newApp, targetPath);
337
260
  } else if (await fs.exists(newApp)) {
338
261
  // Use the local app
339
262
  logger.info(`Using local app '${newApp}'`);
340
- shouldUnzipApp = ZIP_EXTS.includes(path.extname(newApp));
263
+ shouldUnzipApp = ZIP_EXTS.has(path.extname(newApp));
341
264
  } else {
342
265
  let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;
343
266
  // protocol value for 'C:\\temp' is 'c:', so we check the length as well
@@ -411,12 +334,14 @@ async function configureApp(app, options = /** @type {ConfigureAppOptions} */ ({
411
334
  };
412
335
 
413
336
  if (_.isFunction(onPostProcess)) {
414
- const result = await onPostProcess({
415
- cachedAppInfo: _.clone(cachedAppInfo),
416
- isUrl,
417
- headers: _.clone(headers),
418
- appPath: newApp,
419
- });
337
+ const result = await onPostProcess(
338
+ /** @type {import('@appium/types').PostProcessOptions<import('axios').AxiosResponseHeaders>} */ ({
339
+ cachedAppInfo: _.clone(cachedAppInfo),
340
+ isUrl,
341
+ headers: _.clone(headers),
342
+ appPath: newApp,
343
+ })
344
+ );
420
345
  return !result?.appPath || app === result?.appPath || !(await fs.exists(result?.appPath))
421
346
  ? newApp
422
347
  : await storeAppInCache(result.appPath);
@@ -429,16 +354,78 @@ async function configureApp(app, options = /** @type {ConfigureAppOptions} */ ({
429
354
  });
430
355
  }
431
356
 
432
- async function downloadApp(app, targetPath) {
433
- const {href} = url.parse(app);
357
+ /**
358
+ * Sends a HTTP GET query to fetch the app with caching enabled.
359
+ * Follows https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
360
+ *
361
+ * @param {string} appLink The URL to download an app from
362
+ * @param {import('axios').RawAxiosRequestHeaders} reqHeaders Additional HTTP request headers
363
+ * @returns {Promise<RemoteAppData>}
364
+ */
365
+ async function queryAppLink(appLink, reqHeaders) {
366
+ const {href} = url.parse(appLink);
367
+ /**
368
+ * @type {import('axios').RawAxiosRequestConfig}
369
+ */
370
+ const requestOpts = {
371
+ url: href,
372
+ responseType: 'stream',
373
+ timeout: APP_DOWNLOAD_TIMEOUT_MS,
374
+ validateStatus: (status) =>
375
+ (status >= 200 && status < 300) || status === HTTP_STATUS_NOT_MODIFIED,
376
+ headers: reqHeaders,
377
+ };
378
+ try {
379
+ const {data: stream, headers, status} = await axios(requestOpts);
380
+ return {
381
+ stream,
382
+ headers,
383
+ status,
384
+ };
385
+ } catch (err) {
386
+ throw new Error(`Cannot download the app from ${href}: ${err.message}`);
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Retrieves app payload from the given stream. Also meters the download performance.
392
+ *
393
+ * @param {import('stream').Readable} srcStream The incoming stream
394
+ * @param {string} dstPath The target file path to be written
395
+ * @returns {Promise<string>} The same dstPath
396
+ * @throws {Error} If there was a failure while downloading the file
397
+ */
398
+ async function fetchApp(srcStream, dstPath) {
399
+ const timer = new timing.Timer().start();
434
400
  try {
435
- await net.downloadFile(href, targetPath, {
436
- timeout: APP_DOWNLOAD_TIMEOUT_MS,
401
+ const writer = fs.createWriteStream(dstPath);
402
+ srcStream.pipe(writer);
403
+
404
+ await new B((resolve, reject) => {
405
+ srcStream.once('error', reject);
406
+ writer.once('finish', resolve);
407
+ writer.once('error', (e) => {
408
+ srcStream.unpipe(writer);
409
+ reject(e);
410
+ });
437
411
  });
438
412
  } catch (err) {
439
- throw new Error(`Unable to download the app: ${err.message}`);
413
+ throw new Error(`Cannot fetch the application: ${err.message}`);
440
414
  }
441
- return targetPath;
415
+
416
+ const secondsElapsed = timer.getDuration().asSeconds;
417
+ const {size} = await fs.stat(dstPath);
418
+ logger.debug(
419
+ `The application (${util.toReadableSizeString(size)}) ` +
420
+ `has been downloaded to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`
421
+ );
422
+ // it does not make much sense to approximate the speed for short downloads
423
+ if (secondsElapsed >= AVG_DOWNLOAD_SPEED_MEASUREMENT_THRESHOLD_SEC) {
424
+ const bytesPerSec = Math.floor(size / secondsElapsed);
425
+ logger.debug(`Approximate download speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
426
+ }
427
+
428
+ return dstPath;
442
429
  }
443
430
 
444
431
  /**
@@ -606,11 +593,26 @@ export default {
606
593
  parseCapsArray,
607
594
  generateDriverLogPrefix,
608
595
  };
609
- export {configureApp, isPackageOrBundle, duplicateKeys, parseCapsArray, generateDriverLogPrefix};
596
+ export {
597
+ configureApp,
598
+ isPackageOrBundle,
599
+ duplicateKeys,
600
+ parseCapsArray,
601
+ generateDriverLogPrefix,
602
+ BASEDRIVER_VER,
603
+ };
610
604
 
611
605
  /**
612
606
  * @typedef RemoteAppProps
613
607
  * @property {Date?} lastModified
614
608
  * @property {boolean} immutable
615
609
  * @property {number?} maxAge
610
+ * @property {string?} etag
611
+ */
612
+
613
+ /**
614
+ * @typedef RemoteAppData Properties of the remote application (e.g. GET HTTP response) to be downloaded.
615
+ * @property {number} status The HTTP status of the response
616
+ * @property {import('stream').Readable} stream The HTTP response body represented as readable stream
617
+ * @property {import('axios').RawAxiosResponseHeaders | import('axios').AxiosResponseHeaders} headers HTTP response headers
616
618
  */
@@ -151,8 +151,10 @@ function configureHttp({httpServer, reject, keepAliveTimeout}) {
151
151
  notifier: new EventEmitter(),
152
152
  closed: false,
153
153
  };
154
- // TS does not love monkeypatching.
155
- const appiumServer = /** @type {AppiumServer} */ (/** @type {unknown} */ (httpServer));
154
+ /**
155
+ * @type {AppiumServer}
156
+ */
157
+ const appiumServer = /** @type {any} */ (httpServer);
156
158
  appiumServer.addWebSocketHandler = addWebSocketHandler;
157
159
  appiumServer.removeWebSocketHandler = removeWebSocketHandler;
158
160
  appiumServer.removeAllWebSocketHandlers = removeAllWebSocketHandlers;
@@ -6,33 +6,21 @@ import B from 'bluebird';
6
6
  const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
7
7
 
8
8
  /**
9
- * Adds websocket handler to express server instance.
10
- * It is expected this function is called in Express
11
- * server instance context.
12
- *
13
- * @this {AppiumServer} - An instance of express HTTP server.
14
- * @param {string} handlerPathname - Web socket endpoint path starting with
15
- * a single slash character. It is recommended to always add
16
- * DEFAULT_WS_PATHNAME_PREFIX to all web socket pathnames.
17
- * @param {import('ws').Server} handlerServer - WebSocket server instance. See
18
- * https://github.com/websockets/ws/pull/885 for more details
19
- * on how to configure the handler properly.
20
- * @returns {Promise<void>}
9
+ * @this {AppiumServer}
10
+ * @type {AppiumServer['addWebSocketHandler']}
21
11
  */
22
12
  async function addWebSocketHandler(handlerPathname, handlerServer) {
23
- const server = /** @type {AppiumServer} */ (this);
24
13
  if (_.isUndefined(this.webSocketsMapping)) {
25
- server.webSocketsMapping = {};
14
+ this.webSocketsMapping = {};
26
15
  // https://github.com/websockets/ws/pull/885
27
- server.on('upgrade', (request, socket, head) => {
16
+ this.on('upgrade', (request, socket, head) => {
28
17
  let currentPathname;
29
18
  try {
30
- // @ts-expect-error
31
- currentPathname = new URL(request.url).pathname;
19
+ currentPathname = new URL(/** @type {string} */ (request.url)).pathname;
32
20
  } catch {
33
21
  currentPathname = request.url;
34
22
  }
35
- for (const [pathname, wsServer] of _.toPairs(server.webSocketsMapping)) {
23
+ for (const [pathname, wsServer] of _.toPairs(this.webSocketsMapping)) {
36
24
  if (currentPathname === pathname) {
37
25
  wsServer.handleUpgrade(request, socket, head, (ws) => {
38
26
  wsServer.emit('connection', ws, request);
@@ -47,17 +35,9 @@ async function addWebSocketHandler(handlerPathname, handlerServer) {
47
35
  }
48
36
 
49
37
  /**
50
- * Returns web socket handlers registered for the given server
51
- * instance.
52
- * It is expected this function is called in Express
53
- * server instance context.
54
- *
55
38
  * @this {AppiumServer}
56
- * @param {string?} [keysFilter] - Only include pathnames with given
57
- * `keysFilter` value if set. All pairs will be included by default.
58
- * @returns {Promise<Record<string, import('ws').Server>>} pathnames to websocket server instances mapping matching the search criteria or an empty object otherwise.
39
+ * @type {AppiumServer['getWebSocketHandlers']}
59
40
  */
60
- // eslint-disable-next-line require-await
61
41
  async function getWebSocketHandlers(keysFilter = null) {
62
42
  if (_.isEmpty(this.webSocketsMapping)) {
63
43
  return {};
@@ -72,16 +52,9 @@ async function getWebSocketHandlers(keysFilter = null) {
72
52
  }
73
53
 
74
54
  /**
75
- * Removes existing websocket handler from express server instance.
76
- * The call is ignored if the given `handlerPathname` handler
77
- * is not present in the handlers list.
78
- * It is expected this function is called in Express
79
- * server instance context.
80
55
  * @this {AppiumServer}
81
- * @param {string} handlerPathname - Websocket endpoint path.
82
- * @returns {Promise<boolean>} true if the handlerPathname was found and deleted
56
+ * @type {AppiumServer['removeWebSocketHandler']}
83
57
  */
84
- // eslint-disable-next-line require-await
85
58
  async function removeWebSocketHandler(handlerPathname) {
86
59
  const wsServer = this.webSocketsMapping?.[handlerPathname];
87
60
  if (!wsServer) {
@@ -103,11 +76,9 @@ async function removeWebSocketHandler(handlerPathname) {
103
76
  }
104
77
 
105
78
  /**
106
- * Removes all existing websocket handler from express server instance.
107
- * It is expected this function is called in Express
108
- * server instance context.
79
+ *
109
80
  * @this {AppiumServer}
110
- * @returns {Promise<boolean>} true if at least one handler has been deleted
81
+ * @type {AppiumServer['removeAllWebSocketHandlers']}
111
82
  */
112
83
  async function removeAllWebSocketHandlers() {
113
84
  if (_.isEmpty(this.webSocketsMapping)) {
@@ -83,7 +83,6 @@ export {isW3cCaps, fixCaps};
83
83
  */
84
84
 
85
85
  /**
86
- * @template {Constraints} [C=BaseDriverCapConstraints]
87
- * @template {StringRecord|void} [Extra=void]
88
- * @typedef {import('@appium/types').Capabilities<C, Extra>} Capabilities
86
+ * @template {Constraints} C
87
+ * @typedef {import('@appium/types').Capabilities<C>} Capabilities
89
88
  */
@@ -277,7 +277,7 @@ function routeConfiguringFunction(driver) {
277
277
  `${basePath}${path}`,
278
278
  spec,
279
279
  driver,
280
- isSessionCommand(spec.command)
280
+ isSessionCommand(/** @type {import('@appium/types').DriverMethodDef} */ (spec).command)
281
281
  );
282
282
  }
283
283
  }
@@ -454,7 +454,16 @@ function buildHandler(app, method, path, spec, driver, isSessCmd) {
454
454
  } catch (err) {
455
455
  // if anything goes wrong, figure out what our response should be
456
456
  // based on the type of error that we encountered
457
- let actualErr = err;
457
+ let actualErr;
458
+ if (err instanceof Error || (_.has(err, 'stack') && _.has(err, 'message'))) {
459
+ actualErr = err;
460
+ } else {
461
+ getLogger(driver, req.params.sessionId || newSessionId).warn(
462
+ 'The thrown error object does not seem to be a valid instance of the Error class. This ' +
463
+ 'might be a genuine bug of a driver or a plugin.'
464
+ );
465
+ actualErr = new Error(`${err ?? 'unknown'}`);
466
+ }
458
467
 
459
468
  currentProtocol =
460
469
  currentProtocol || extractProtocol(driver, req.params.sessionId || newSessionId);