@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.
- package/build/lib/basedriver/capabilities.d.ts +59 -36
- package/build/lib/basedriver/capabilities.d.ts.map +1 -1
- package/build/lib/basedriver/capabilities.js +57 -45
- package/build/lib/basedriver/capabilities.js.map +1 -1
- package/build/lib/basedriver/commands/event.d.ts +1 -1
- package/build/lib/basedriver/commands/event.d.ts.map +1 -1
- package/build/lib/basedriver/commands/event.js.map +1 -1
- package/build/lib/basedriver/commands/execute.d.ts +1 -1
- package/build/lib/basedriver/commands/execute.d.ts.map +1 -1
- package/build/lib/basedriver/commands/execute.js.map +1 -1
- package/build/lib/basedriver/commands/find.d.ts +1 -1
- package/build/lib/basedriver/commands/find.d.ts.map +1 -1
- package/build/lib/basedriver/commands/find.js.map +1 -1
- package/build/lib/basedriver/commands/index.d.ts +7 -7
- package/build/lib/basedriver/commands/index.d.ts.map +1 -1
- package/build/lib/basedriver/commands/index.js +7 -21
- package/build/lib/basedriver/commands/index.js.map +1 -1
- package/build/lib/basedriver/commands/log.d.ts +1 -1
- package/build/lib/basedriver/commands/log.d.ts.map +1 -1
- package/build/lib/basedriver/commands/log.js.map +1 -1
- package/build/lib/basedriver/commands/mixin.d.ts +4 -4
- package/build/lib/basedriver/commands/mixin.d.ts.map +1 -1
- package/build/lib/basedriver/commands/mixin.js +5 -4
- package/build/lib/basedriver/commands/mixin.js.map +1 -1
- package/build/lib/basedriver/commands/session.d.ts +1 -1
- package/build/lib/basedriver/commands/session.d.ts.map +1 -1
- package/build/lib/basedriver/commands/session.js +1 -4
- package/build/lib/basedriver/commands/session.js.map +1 -1
- package/build/lib/basedriver/commands/settings.d.ts +1 -1
- package/build/lib/basedriver/commands/settings.d.ts.map +1 -1
- package/build/lib/basedriver/commands/settings.js.map +1 -1
- package/build/lib/basedriver/commands/timeout.d.ts +1 -1
- package/build/lib/basedriver/commands/timeout.d.ts.map +1 -1
- package/build/lib/basedriver/commands/timeout.js +1 -4
- package/build/lib/basedriver/commands/timeout.js.map +1 -1
- package/build/lib/basedriver/core.d.ts +13 -17
- package/build/lib/basedriver/core.d.ts.map +1 -1
- package/build/lib/basedriver/core.js +32 -20
- package/build/lib/basedriver/core.js.map +1 -1
- package/build/lib/basedriver/device-settings.d.ts +11 -11
- package/build/lib/basedriver/device-settings.d.ts.map +1 -1
- package/build/lib/basedriver/device-settings.js +7 -8
- package/build/lib/basedriver/device-settings.js.map +1 -1
- package/build/lib/basedriver/driver.d.ts +23 -108
- package/build/lib/basedriver/driver.d.ts.map +1 -1
- package/build/lib/basedriver/driver.js +21 -126
- package/build/lib/basedriver/driver.js.map +1 -1
- package/build/lib/basedriver/helpers.d.ts +21 -98
- package/build/lib/basedriver/helpers.d.ts.map +1 -1
- package/build/lib/basedriver/helpers.js +178 -182
- package/build/lib/basedriver/helpers.js.map +1 -1
- package/build/lib/express/server.d.ts +3 -3
- package/build/lib/express/server.d.ts.map +1 -1
- package/build/lib/express/server.js +4 -2
- package/build/lib/express/server.js.map +1 -1
- package/build/lib/express/websocket.d.ts +5 -44
- package/build/lib/express/websocket.d.ts.map +1 -1
- package/build/lib/express/websocket.js +10 -39
- package/build/lib/express/websocket.js.map +1 -1
- package/build/lib/helpers/capabilities.d.ts +2 -2
- package/build/lib/helpers/capabilities.d.ts.map +1 -1
- package/build/lib/helpers/capabilities.js +2 -3
- package/build/lib/helpers/capabilities.js.map +1 -1
- package/build/lib/protocol/protocol.d.ts +1 -1
- package/build/lib/protocol/protocol.d.ts.map +1 -1
- package/build/lib/protocol/protocol.js +10 -2
- package/build/lib/protocol/protocol.js.map +1 -1
- package/build/lib/protocol/routes.d.ts +1 -0
- package/build/lib/protocol/routes.d.ts.map +1 -1
- package/build/lib/protocol/routes.js +12 -10
- package/build/lib/protocol/routes.js.map +1 -1
- package/lib/basedriver/capabilities.js +70 -56
- package/lib/basedriver/commands/event.ts +3 -2
- package/lib/basedriver/commands/execute.ts +2 -1
- package/lib/basedriver/commands/find.ts +1 -0
- package/lib/basedriver/commands/index.ts +7 -7
- package/lib/basedriver/commands/log.ts +2 -4
- package/lib/basedriver/commands/mixin.ts +5 -4
- package/lib/basedriver/commands/session.ts +9 -7
- package/lib/basedriver/commands/settings.ts +1 -0
- package/lib/basedriver/commands/timeout.ts +17 -17
- package/lib/basedriver/core.js +11 -25
- package/lib/basedriver/device-settings.js +9 -11
- package/lib/basedriver/{driver.js → driver.ts} +69 -175
- package/lib/basedriver/helpers.js +214 -212
- package/lib/express/server.js +4 -2
- package/lib/express/websocket.js +10 -39
- package/lib/helpers/capabilities.js +2 -3
- package/lib/protocol/protocol.js +11 -2
- package/lib/protocol/routes.js +12 -13
- 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,
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
`
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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.
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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(`
|
|
413
|
+
throw new Error(`Cannot fetch the application: ${err.message}`);
|
|
440
414
|
}
|
|
441
|
-
|
|
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 {
|
|
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
|
*/
|
package/lib/express/server.js
CHANGED
|
@@ -151,8 +151,10 @@ function configureHttp({httpServer, reject, keepAliveTimeout}) {
|
|
|
151
151
|
notifier: new EventEmitter(),
|
|
152
152
|
closed: false,
|
|
153
153
|
};
|
|
154
|
-
|
|
155
|
-
|
|
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;
|
package/lib/express/websocket.js
CHANGED
|
@@ -6,33 +6,21 @@ import B from 'bluebird';
|
|
|
6
6
|
const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
14
|
+
this.webSocketsMapping = {};
|
|
26
15
|
// https://github.com/websockets/ws/pull/885
|
|
27
|
-
|
|
16
|
+
this.on('upgrade', (request, socket, head) => {
|
|
28
17
|
let currentPathname;
|
|
29
18
|
try {
|
|
30
|
-
|
|
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(
|
|
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
|
-
* @
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
107
|
-
* It is expected this function is called in Express
|
|
108
|
-
* server instance context.
|
|
79
|
+
*
|
|
109
80
|
* @this {AppiumServer}
|
|
110
|
-
* @
|
|
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}
|
|
87
|
-
* @
|
|
88
|
-
* @typedef {import('@appium/types').Capabilities<C, Extra>} Capabilities
|
|
86
|
+
* @template {Constraints} C
|
|
87
|
+
* @typedef {import('@appium/types').Capabilities<C>} Capabilities
|
|
89
88
|
*/
|
package/lib/protocol/protocol.js
CHANGED
|
@@ -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
|
|
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);
|