appium-xcuitest-driver 7.14.0 → 7.15.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/build/lib/app-infos-cache.d.ts +46 -0
  3. package/build/lib/app-infos-cache.d.ts.map +1 -0
  4. package/build/lib/app-infos-cache.js +156 -0
  5. package/build/lib/app-infos-cache.js.map +1 -0
  6. package/build/lib/app-utils.d.ts +32 -52
  7. package/build/lib/app-utils.d.ts.map +1 -1
  8. package/build/lib/app-utils.js +302 -208
  9. package/build/lib/app-utils.js.map +1 -1
  10. package/build/lib/commands/app-management.d.ts +5 -4
  11. package/build/lib/commands/app-management.d.ts.map +1 -1
  12. package/build/lib/commands/app-management.js +14 -7
  13. package/build/lib/commands/app-management.js.map +1 -1
  14. package/build/lib/commands/app-strings.d.ts +5 -2
  15. package/build/lib/commands/app-strings.d.ts.map +1 -1
  16. package/build/lib/commands/app-strings.js +6 -3
  17. package/build/lib/commands/app-strings.js.map +1 -1
  18. package/build/lib/commands/file-movement.js +1 -1
  19. package/build/lib/commands/file-movement.js.map +1 -1
  20. package/build/lib/commands/types.d.ts +1 -0
  21. package/build/lib/commands/types.d.ts.map +1 -1
  22. package/build/lib/commands/xctest-record-screen.d.ts +1 -1
  23. package/build/lib/desired-caps.d.ts +2 -0
  24. package/build/lib/desired-caps.d.ts.map +1 -1
  25. package/build/lib/desired-caps.js +1 -0
  26. package/build/lib/desired-caps.js.map +1 -1
  27. package/build/lib/driver.d.ts +30 -27
  28. package/build/lib/driver.d.ts.map +1 -1
  29. package/build/lib/driver.js +12 -12
  30. package/build/lib/driver.js.map +1 -1
  31. package/build/lib/execute-method-map.d.ts +1 -1
  32. package/build/lib/execute-method-map.js +1 -1
  33. package/build/lib/execute-method-map.js.map +1 -1
  34. package/build/lib/ios-fs-helpers.d.ts +30 -15
  35. package/build/lib/ios-fs-helpers.d.ts.map +1 -1
  36. package/build/lib/ios-fs-helpers.js +54 -21
  37. package/build/lib/ios-fs-helpers.js.map +1 -1
  38. package/build/lib/real-device-management.d.ts +0 -5
  39. package/build/lib/real-device-management.d.ts.map +1 -1
  40. package/build/lib/real-device-management.js +8 -5
  41. package/build/lib/real-device-management.js.map +1 -1
  42. package/build/lib/real-device.d.ts +13 -9
  43. package/build/lib/real-device.d.ts.map +1 -1
  44. package/build/lib/real-device.js +49 -75
  45. package/build/lib/real-device.js.map +1 -1
  46. package/lib/app-infos-cache.js +159 -0
  47. package/lib/app-utils.js +326 -223
  48. package/lib/commands/app-management.js +20 -9
  49. package/lib/commands/app-strings.js +6 -3
  50. package/lib/commands/file-movement.js +1 -1
  51. package/lib/commands/types.ts +1 -0
  52. package/lib/desired-caps.js +1 -0
  53. package/lib/driver.js +12 -15
  54. package/lib/execute-method-map.ts +1 -1
  55. package/lib/ios-fs-helpers.js +57 -23
  56. package/lib/real-device-management.js +7 -5
  57. package/lib/real-device.js +62 -88
  58. package/npm-shrinkwrap.json +13 -5
  59. package/package.json +1 -1
package/lib/app-utils.js CHANGED
@@ -2,20 +2,19 @@ import _ from 'lodash';
2
2
  import path from 'path';
3
3
  import {plist, fs, util, tempDir, zip, timing} from 'appium/support';
4
4
  import log from './logger.js';
5
- import {LRUCache} from 'lru-cache';
6
5
  import os from 'node:os';
7
6
  import {exec} from 'teen_process';
8
7
  import B from 'bluebird';
9
8
  import {spawn} from 'node:child_process';
10
9
  import assert from 'node:assert';
10
+ import { isTvOs } from './utils.js';
11
11
 
12
12
  const STRINGSDICT_RESOURCE = '.stringsdict';
13
13
  const STRINGS_RESOURCE = '.strings';
14
14
  export const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
15
15
  export const APP_EXT = '.app';
16
16
  export const IPA_EXT = '.ipa';
17
- /** @type {LRUCache<string, import('@appium/types').StringRecord>} */
18
- const PLIST_CACHE = new LRUCache({max: 20});
17
+ const ZIP_EXT = '.zip';
19
18
  const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({
20
19
  safariAllowPopups: [
21
20
  ['WebKitJavaScriptCanOpenWindowsAutomatically', 'JavaScriptCanOpenWindowsAutomatically'],
@@ -27,146 +26,64 @@ const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({
27
26
  const MAX_ARCHIVE_SCAN_DEPTH = 1;
28
27
  export const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT];
29
28
  const MACOS_RESOURCE_FOLDER = '__MACOSX';
30
-
31
-
32
- /**
33
- * Retrieves the value of the given entry name from the application's Info.plist.
34
- *
35
- * @this {Object} Optinal instance used for caching. Ususally the driver instance.
36
- * @param {string} app Full path to the app bundle root.
37
- * @param {string} entryName Key name in the plist.
38
- * @returns {Promise<any | undefined>} Either the extracted value or undefined if no such key has been found in the plist.
39
- * @throws {Error} If the application's Info.plist cannot be parsed.
40
- */
41
- async function extractPlistEntry(app, entryName) {
42
- const plistPath = path.resolve(app, 'Info.plist');
43
-
44
- const parseFile = async () => {
45
- try {
46
- return await plist.parsePlistFile(plistPath);
47
- } catch (err) {
48
- throw new Error(`Could not extract Info.plist from '${path.basename(app)}': ${err.message}`);
49
- }
50
- };
51
-
52
- let plistObj = PLIST_CACHE.get(app);
53
- if (!plistObj) {
54
- plistObj = await parseFile();
55
- PLIST_CACHE.set(app, plistObj);
56
- }
57
- return /** @type {import('@appium/types').StringRecord} */ (plistObj)[entryName];
58
- }
59
-
60
- /**
61
- *
62
- * @param {string} app
63
- * @returns {Promise<string>}
64
- */
65
- export async function extractBundleId(app) {
66
- const bundleId = await extractPlistEntry(app, 'CFBundleIdentifier');
67
- log.debug(`Getting bundle ID from app '${app}': '${bundleId}'`);
68
- return bundleId;
69
- }
70
-
71
- /**
72
- *
73
- * @param {string} app
74
- * @returns {Promise<string>}
75
- */
76
- export async function extractBundleVersion(app) {
77
- return await extractPlistEntry(app, 'CFBundleVersion');
78
- }
79
-
80
- /**
81
- *
82
- * @param {string} app
83
- * @returns {Promise<string>}
84
- */
85
- async function extractExecutableName(app) {
86
- return await extractPlistEntry(app, 'CFBundleExecutable');
87
- }
88
-
89
- /**
90
- *
91
- * @param {string} app
92
- * @returns {Promise<string[]>}
93
- */
94
- export async function fetchSupportedAppPlatforms(app) {
95
- try {
96
- const result = await extractPlistEntry(app, 'CFBundleSupportedPlatforms');
97
- if (!_.isArray(result)) {
98
- log.warn(`${path.basename(app)}': CFBundleSupportedPlatforms is not a valid list`);
99
- return [];
100
- }
101
- return result;
102
- } catch (err) {
103
- log.warn(
104
- `Cannot extract the list of supported platforms from '${path.basename(app)}': ${err.message}`,
105
- );
106
- return [];
107
- }
108
- }
109
-
110
- /**
111
- * @typedef {Object} PlatformOpts
112
- *
113
- * @property {boolean} isSimulator - Whether the destination platform is a Simulator
114
- * @property {boolean} isTvOS - Whether the destination platform is a Simulator
115
- */
29
+ const SANITIZE_REPLACEMENT = '-';
116
30
 
117
31
  /**
118
32
  * Verify whether the given application is compatible to the
119
33
  * platform where it is going to be installed and tested.
120
34
  *
121
- * @param {string} app - The actual path to the application bundle
122
- * @param {PlatformOpts} expectedPlatform
35
+ * @this {XCUITestDriver}
36
+ * @returns {Promise<void>}
123
37
  * @throws {Error} If bundle architecture does not match the expected device architecture.
124
38
  */
125
- export async function verifyApplicationPlatform(app, expectedPlatform) {
126
- log.debug('Verifying application platform');
127
-
128
- const supportedPlatforms = await fetchSupportedAppPlatforms(app);
129
- log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(supportedPlatforms)}`);
39
+ export async function verifyApplicationPlatform() {
40
+ this.log.debug('Verifying application platform');
130
41
 
131
- const {isSimulator, isTvOS} = expectedPlatform;
42
+ const supportedPlatforms = await this.appInfosCache.extractAppPlatforms(this.opts.app);
43
+ const isTvOS = isTvOs(this.opts.platformName);
132
44
  const prefix = isTvOS ? 'AppleTV' : 'iPhone';
133
- const suffix = isSimulator ? 'Simulator' : 'OS';
45
+ const suffix = this.isSimulator() ? 'Simulator' : 'OS';
134
46
  const dstPlatform = `${prefix}${suffix}`;
135
- const appFileName = path.basename(app);
136
47
  if (!supportedPlatforms.includes(dstPlatform)) {
137
48
  throw new Error(
138
49
  `${
139
- isSimulator ? 'Simulator' : 'Real device'
140
- } architecture is not supported by the '${appFileName}' application. ` +
50
+ this.isSimulator() ? 'Simulator' : 'Real device'
51
+ } architecture is not supported by the ${this.opts.bundleId} application. ` +
141
52
  `Make sure the correct deployment target has been selected for its compilation in Xcode.`,
142
53
  );
143
54
  }
144
- if (isSimulator) {
145
- const executablePath = path.resolve(app, await extractExecutableName(app));
146
- const [resFile, resUname] = await B.all([
147
- exec('file', [executablePath]),
148
- exec('uname', ['-m']),
149
- ]);
150
- const bundleExecutableInfo = _.trim(resFile.stdout);
151
- log.debug(bundleExecutableInfo);
152
- const arch = _.trim(resUname.stdout);
153
- const isAppleSilicon = os.cpus()[0].model.includes('Apple');
154
- // We cannot run Simulator builds compiled for arm64 on Intel machines
155
- // Rosetta allows only to run Intel ones on arm64
156
- if (
157
- !_.includes(bundleExecutableInfo, `executable ${arch}`) &&
158
- !(isAppleSilicon && _.includes(bundleExecutableInfo, 'executable x86_64'))
159
- ) {
160
- const bundleId = await extractBundleId(app);
161
- throw new Error(
162
- `The ${bundleId} application does not support the ${arch} Simulator ` +
163
- `architecture:\n${bundleExecutableInfo}\n\n` +
164
- `Please rebuild your application to support the ${arch} platform.`,
165
- );
166
- }
55
+ if (this.isRealDevice()) {
56
+ return;
57
+ }
58
+
59
+ const executablePath = path.resolve(this.opts.app, await this.appInfosCache.extractExecutableName(this.opts.app));
60
+ const [resFile, resUname] = await B.all([
61
+ exec('file', [executablePath]),
62
+ exec('uname', ['-m']),
63
+ ]);
64
+ const bundleExecutableInfo = _.trim(resFile.stdout);
65
+ this.log.debug(bundleExecutableInfo);
66
+ const arch = _.trim(resUname.stdout);
67
+ const isAppleSilicon = os.cpus()[0].model.includes('Apple');
68
+ // We cannot run Simulator builds compiled for arm64 on Intel machines
69
+ // Rosetta allows only to run Intel ones on arm64
70
+ if (
71
+ !_.includes(bundleExecutableInfo, `executable ${arch}`) &&
72
+ !(isAppleSilicon && _.includes(bundleExecutableInfo, 'executable x86_64'))
73
+ ) {
74
+ throw new Error(
75
+ `The ${this.opts.bundleId} application does not support the ${arch} Simulator ` +
76
+ `architecture:\n${bundleExecutableInfo}\n\n` +
77
+ `Please rebuild your application to support the ${arch} platform.`,
78
+ );
167
79
  }
168
80
  }
169
81
 
82
+ /**
83
+ *
84
+ * @param {string} resourcePath
85
+ * @returns {Promise<import('@appium/types').StringRecord>}
86
+ */
170
87
  async function readResource(resourcePath) {
171
88
  const data = await plist.parsePlistFile(resourcePath);
172
89
  const result = {};
@@ -176,79 +93,113 @@ async function readResource(resourcePath) {
176
93
  return result;
177
94
  }
178
95
 
179
- export async function parseLocalizableStrings(opts) {
180
- const {app, language = 'en', localizableStringsDir, stringFile, strictMode} = opts;
96
+ /**
97
+ * @typedef {Object} LocalizableStringsOptions
98
+ * @property {string} [app]
99
+ * @property {string} [language='en']
100
+ * @property {string} [localizableStringsDir]
101
+ * @property {string} [stringFile]
102
+ * @property {boolean} [strictMode]
103
+ */
181
104
 
105
+ /**
106
+ * Extracts string resources from an app
107
+ *
108
+ * @this {XCUITestDriver}
109
+ * @param {LocalizableStringsOptions} opts
110
+ * @returns {Promise<import('@appium/types').StringRecord>}
111
+ */
112
+ export async function parseLocalizableStrings(opts = {}) {
113
+ const {app, language = 'en', localizableStringsDir, stringFile, strictMode} = opts;
182
114
  if (!app) {
183
115
  const message = `Strings extraction is not supported if 'app' capability is not set`;
184
116
  if (strictMode) {
185
117
  throw new Error(message);
186
118
  }
187
- log.info(message);
119
+ this.log.info(message);
188
120
  return {};
189
121
  }
190
122
 
191
- let lprojRoot;
192
- for (const subfolder of [`${language}.lproj`, localizableStringsDir, '']) {
193
- lprojRoot = path.resolve(app, subfolder);
194
- if (await fs.exists(lprojRoot)) {
195
- break;
196
- }
197
- const message = `No '${lprojRoot}' resources folder has been found`;
198
- if (strictMode) {
199
- throw new Error(message);
123
+ let bundleRoot = app;
124
+ const isArchive = (await fs.stat(app)).isFile();
125
+ let tmpRoot;
126
+ try {
127
+ if (isArchive) {
128
+ tmpRoot = await tempDir.openDir();
129
+ this.log.info(`Extracting '${app}' into a temporary location to parse its resources`);
130
+ await zip.extractAllTo(app, tmpRoot);
131
+ const relativeBundleRoot = /** @type {string} */ (_.first(await findApps(tmpRoot, [APP_EXT])));
132
+ this.log.info(`Selecting '${relativeBundleRoot}'`);
133
+ bundleRoot = path.join(tmpRoot, relativeBundleRoot);
200
134
  }
201
- log.debug(message);
202
- }
203
- log.info(`Will extract resource strings from '${lprojRoot}'`);
204
135
 
205
- const resourcePaths = [];
206
- if (stringFile) {
207
- const dstPath = path.resolve(String(lprojRoot), stringFile);
208
- if (await fs.exists(dstPath)) {
209
- resourcePaths.push(dstPath);
210
- } else {
211
- const message = `No '${dstPath}' resource file has been found for '${app}'`;
136
+ /** @type {string|undefined} */
137
+ let lprojRoot;
138
+ for (const subfolder of [`${language}.lproj`, localizableStringsDir, ''].filter(_.isString)) {
139
+ lprojRoot = path.resolve(bundleRoot, /** @type {string} */ (subfolder));
140
+ if (await fs.exists(lprojRoot)) {
141
+ break;
142
+ }
143
+ const message = `No '${lprojRoot}' resources folder has been found`;
212
144
  if (strictMode) {
213
145
  throw new Error(message);
214
146
  }
215
- log.info(message);
216
- log.info(`Getting all the available strings from '${lprojRoot}'`);
147
+ this.log.debug(message);
148
+ }
149
+ if (!lprojRoot) {
150
+ return {};
217
151
  }
218
- }
219
152
 
220
- if (_.isEmpty(resourcePaths) && (await fs.exists(String(lprojRoot)))) {
221
- const resourceFiles = (await fs.readdir(String(lprojRoot)))
222
- .filter((name) => _.some([STRINGS_RESOURCE, STRINGSDICT_RESOURCE], (x) => name.endsWith(x)))
223
- .map((name) => path.resolve(lprojRoot, name));
224
- resourcePaths.push(...resourceFiles);
225
- }
226
- log.info(`Got ${resourcePaths.length} resource file(s) in '${lprojRoot}'`);
153
+ this.log.info(`Retrieving resource strings from '${lprojRoot}'`);
154
+ const resourcePaths = [];
155
+ if (stringFile) {
156
+ const dstPath = path.resolve(/** @type {string} */ (lprojRoot), stringFile);
157
+ if (await fs.exists(dstPath)) {
158
+ resourcePaths.push(dstPath);
159
+ } else {
160
+ const message = `No '${dstPath}' resource file has been found for '${app}'`;
161
+ if (strictMode) {
162
+ throw new Error(message);
163
+ }
164
+ this.log.info(message);
165
+ }
166
+ }
227
167
 
228
- if (_.isEmpty(resourcePaths)) {
229
- return {};
230
- }
168
+ if (_.isEmpty(resourcePaths) && (await fs.exists(lprojRoot))) {
169
+ const resourceFiles = (await fs.readdir(lprojRoot))
170
+ .filter((name) => _.some([STRINGS_RESOURCE, STRINGSDICT_RESOURCE], (x) => name.endsWith(x)))
171
+ .map((name) => path.resolve(lprojRoot, name));
172
+ resourcePaths.push(...resourceFiles);
173
+ }
174
+ this.log.info(`Got ${util.pluralize('resource file', resourcePaths.length, true)} in '${lprojRoot}'`);
231
175
 
232
- const resultStrings = {};
233
- const toAbsolutePath = function (p) {
234
- return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
235
- };
236
- for (const resourcePath of resourcePaths) {
237
- if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(app))) {
238
- // security precaution
239
- throw new Error(`'${resourcePath}' is expected to be located under '${app}'`);
176
+ if (_.isEmpty(resourcePaths)) {
177
+ return {};
240
178
  }
241
- try {
242
- const data = await readResource(resourcePath);
243
- log.debug(`Parsed ${_.keys(data).length} string(s) from '${resourcePath}'`);
244
- _.merge(resultStrings, data);
245
- } catch (e) {
246
- log.warn(`Cannot parse '${resourcePath}' resource. Original error: ${e.message}`);
179
+
180
+ const resultStrings = {};
181
+ const toAbsolutePath = (/** @type {string} */ p) => path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
182
+ for (const resourcePath of resourcePaths) {
183
+ if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(bundleRoot))) {
184
+ // security precaution
185
+ throw new Error(`'${resourcePath}' is expected to be located under '${bundleRoot}'`);
186
+ }
187
+ try {
188
+ const data = await readResource(resourcePath);
189
+ this.log.debug(`Parsed ${util.pluralize('string', _.keys(data).length, true)} from '${resourcePath}'`);
190
+ _.merge(resultStrings, data);
191
+ } catch (e) {
192
+ this.log.warn(`Cannot parse '${resourcePath}' resource. Original error: ${e.message}`);
193
+ }
247
194
  }
248
- }
249
195
 
250
- log.info(`Got ${_.keys(resultStrings).length} string(s) from '${lprojRoot}'`);
251
- return resultStrings;
196
+ this.log.info(`Retrieved ${util.pluralize('string', _.keys(resultStrings).length, true)} from '${lprojRoot}'`);
197
+ return resultStrings;
198
+ } finally {
199
+ if (tmpRoot) {
200
+ await fs.rimraf(tmpRoot);
201
+ }
202
+ }
252
203
  }
253
204
 
254
205
  /**
@@ -257,7 +208,7 @@ export async function parseLocalizableStrings(opts) {
257
208
  * @param {string} appPath Possible .app bundle root
258
209
  * @returns {Promise<boolean>} Whether the given path points to an .app bundle
259
210
  */
260
- export async function isAppBundle(appPath) {
211
+ async function isAppBundle(appPath) {
261
212
  return (
262
213
  _.endsWith(_.toLower(appPath), APP_EXT) &&
263
214
  (await fs.stat(appPath)).isDirectory() &&
@@ -265,6 +216,16 @@ export async function isAppBundle(appPath) {
265
216
  );
266
217
  }
267
218
 
219
+ /**
220
+ * Check whether the given path on the file system points to the .ipa file
221
+ *
222
+ * @param {string} appPath Possible .ipa file
223
+ * @returns {Promise<boolean>} Whether the given path points to an .ipa bundle
224
+ */
225
+ async function isIpaBundle(appPath) {
226
+ return _.endsWith(_.toLower(appPath), IPA_EXT) && (await fs.stat(appPath)).isFile();
227
+ }
228
+
268
229
  /**
269
230
  * @typedef {Object} UnzipInfo
270
231
  * @property {string} rootDir
@@ -355,6 +316,111 @@ export async function unzipStream(zipStream) {
355
316
  };
356
317
  }
357
318
 
319
+ /**
320
+ * Used to parse the file name value from response headers
321
+ *
322
+ * @param {import('@appium/types').HTTPHeaders} headers
323
+ * @returns {string?}
324
+ */
325
+ function parseFileName(headers) {
326
+ const contentDisposition = headers['content-disposition'];
327
+ if (!_.isString(contentDisposition)) {
328
+ return null;
329
+ }
330
+
331
+ if (/^attachment/i.test(/** @type {string} */ (contentDisposition))) {
332
+ const match = /filename="([^"]+)/i.exec(/** @type {string} */ (contentDisposition));
333
+ if (match) {
334
+ return fs.sanitizeName(match[1], {replacement: SANITIZE_REPLACEMENT});
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Downloads and verifies remote applications for real devices
342
+ *
343
+ * @this {XCUITestDriver}
344
+ * @param {import('node:stream').Readable} stream
345
+ * @param {import('@appium/types').HTTPHeaders} headers
346
+ * @returns {Promise<string>}
347
+ */
348
+ async function downloadIpa(stream, headers) {
349
+ const timer = new timing.Timer().start();
350
+
351
+ const logPerformance = (/** @type {string} */ dstPath, /** @type {number} */ fileSize, /** @type {string} */ action) => {
352
+ const secondsElapsed = timer.getDuration().asSeconds;
353
+ this.log.info(
354
+ `The remote file (${util.toReadableSizeString(fileSize)}) ` +
355
+ `has been ${action} to '${dstPath}' in ${secondsElapsed.toFixed(3)}s`
356
+ );
357
+ if (secondsElapsed >= 1) {
358
+ const bytesPerSec = Math.floor(fileSize / secondsElapsed);
359
+ this.log.debug(`Approximate speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
360
+ }
361
+ };
362
+
363
+ // Check if the file to be downloaded is a .zip rather than .ipa
364
+ const fileName = parseFileName(headers) ?? `appium-app-${new Date().getTime()}${IPA_EXT}`;
365
+ if (fileName.toLowerCase().endsWith(ZIP_EXT)) {
366
+ const {rootDir, archiveSize} = await unzipStream(stream);
367
+ logPerformance(rootDir, archiveSize, 'downloaded and unzipped');
368
+ try {
369
+ const matchedPaths = await findApps(rootDir, [IPA_EXT]);
370
+ if (!_.isEmpty(matchedPaths)) {
371
+ this.log.debug(
372
+ `Found ${util.pluralize(`${IPA_EXT} applicaition`, matchedPaths.length, true)} in ` +
373
+ `'${path.basename(rootDir)}': ${matchedPaths}`
374
+ );
375
+ }
376
+ for (const matchedPath of matchedPaths) {
377
+ try {
378
+ await this.appInfosCache.put(matchedPath);
379
+ } catch (e) {
380
+ this.log.info(e.message);
381
+ continue;
382
+ }
383
+ this.log.debug(`Selecting the application at '${matchedPath}'`);
384
+ const isolatedPath = path.join(await tempDir.openDir(), path.basename(matchedPath));
385
+ await fs.mv(matchedPath, isolatedPath);
386
+ return isolatedPath;
387
+ }
388
+ throw new Error(`The remote archive does not contain any valid ${IPA_EXT} applications`);
389
+ } finally {
390
+ await fs.rimraf(rootDir);
391
+ }
392
+ }
393
+
394
+ const ipaPath = await tempDir.path({
395
+ prefix: fileName,
396
+ suffix: fileName.toLowerCase().endsWith(IPA_EXT) ? '' : IPA_EXT,
397
+ });
398
+ try {
399
+ const writer = fs.createWriteStream(ipaPath);
400
+ stream.pipe(writer);
401
+
402
+ await new B((resolve, reject) => {
403
+ stream.once('error', reject);
404
+ writer.once('finish', resolve);
405
+ writer.once('error', (e) => {
406
+ stream.unpipe(writer);
407
+ reject(e);
408
+ });
409
+ });
410
+ } catch (err) {
411
+ throw new Error(`Cannot fetch the remote file: ${err.message}`);
412
+ }
413
+ const {size} = await fs.stat(ipaPath);
414
+ logPerformance(ipaPath, size, 'downloaded');
415
+ try {
416
+ await this.appInfosCache.put(ipaPath);
417
+ } catch (e) {
418
+ await fs.rimraf(ipaPath);
419
+ throw e;
420
+ }
421
+ return ipaPath;
422
+ }
423
+
358
424
  /**
359
425
  * Looks for items with given extensions in the given folder
360
426
  *
@@ -375,15 +441,25 @@ async function findApps(appPath, appExtensions) {
375
441
  /**
376
442
  * Moves the application bundle to a newly created temporary folder
377
443
  *
378
- * @param {string} appRoot Full path to the .app bundle
444
+ * @param {string} appPath Full path to the .app or .ipa bundle
379
445
  * @returns {Promise<string>} The new path to the app bundle.
380
- * The name of the app bundle remains though
446
+ * The name of the app bundle remains the same
381
447
  */
382
- export async function isolateAppBundle(appRoot) {
448
+ async function isolateApp(appPath) {
449
+ const appFileName = path.basename(appPath);
450
+ if ((await fs.stat(appPath)).isFile()) {
451
+ const isolatedPath = await tempDir.path({
452
+ prefix: appFileName,
453
+ suffix: '',
454
+ });
455
+ await fs.mv(appPath, isolatedPath, {mkdirp: true});
456
+ return isolatedPath;
457
+ }
458
+
383
459
  const tmpRoot = await tempDir.openDir();
384
- const dstRoot = path.join(tmpRoot, path.basename(appRoot));
385
- await fs.mv(appRoot, dstRoot, {mkdirp: true});
386
- return dstRoot;
460
+ const isolatedRoot = path.join(tmpRoot, appFileName);
461
+ await fs.mv(appPath, isolatedRoot, {mkdirp: true});
462
+ return isolatedRoot;
387
463
  }
388
464
 
389
465
  /**
@@ -410,7 +486,7 @@ export function buildSafariPreferences(opts) {
410
486
  /**
411
487
  * Unzip the given archive and find a matching .app bundle in it
412
488
  *
413
- * @this {import('./driver').XCUITestDriver}
489
+ * @this {XCUITestDriver}
414
490
  * @param {string|import('node:stream').Readable} appPathOrZipStream The path to the archive.
415
491
  * @param {number} depth [0] the current nesting depth. App bundles whose nesting level
416
492
  * is greater than 1 are not supported.
@@ -418,7 +494,7 @@ export function buildSafariPreferences(opts) {
418
494
  * @throws If no matching .app bundles were found in the provided archive.
419
495
  */
420
496
  async function unzipApp(appPathOrZipStream, depth = 0) {
421
- const errMsg = `The archive '${this.opts.app}' did not have any matching ${APP_EXT} or ${IPA_EXT} ` +
497
+ const errMsg = `The archive did not have any matching ${APP_EXT} or ${IPA_EXT} ` +
422
498
  `bundles. Please make sure the provided package is valid and contains at least one matching ` +
423
499
  `application bundle which is not nested.`;
424
500
  if (depth > MAX_ARCHIVE_SCAN_DEPTH) {
@@ -432,22 +508,24 @@ async function unzipApp(appPathOrZipStream, depth = 0) {
432
508
  let archiveSize;
433
509
  try {
434
510
  if (_.isString(appPathOrZipStream)) {
435
- ({rootDir, archiveSize} = await unzipFile(appPathOrZipStream));
511
+ ({rootDir, archiveSize} = await unzipFile(/** @type {string} */ (appPathOrZipStream)));
436
512
  } else {
437
513
  if (depth > 0) {
438
514
  assert.fail('Streaming unzip cannot be invoked for nested archive items');
439
515
  }
440
- ({rootDir, archiveSize} = await unzipStream(appPathOrZipStream));
516
+ ({rootDir, archiveSize} = await unzipStream(
517
+ /** @type {import('node:stream').Readable} */ (appPathOrZipStream))
518
+ );
441
519
  }
442
520
  } catch (e) {
443
521
  this.log.debug(e.stack);
444
522
  throw new Error(
445
- `Cannot prepare the application at '${this.opts.app}' for testing. Original error: ${e.message}`
523
+ `Cannot prepare the application for testing. Original error: ${e.message}`
446
524
  );
447
525
  }
448
526
  const secondsElapsed = timer.getDuration().asSeconds;
449
527
  this.log.info(
450
- `The app '${this.opts.app}' (${util.toReadableSizeString(archiveSize)}) ` +
528
+ `The file (${util.toReadableSizeString(archiveSize)}) ` +
451
529
  `has been ${_.isString(appPathOrZipStream) ? 'extracted' : 'downloaded and extracted'} ` +
452
530
  `to '${rootDir}' in ${secondsElapsed.toFixed(3)}s`
453
531
  );
@@ -457,6 +535,31 @@ async function unzipApp(appPathOrZipStream, depth = 0) {
457
535
  this.log.debug(`Approximate decompression speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
458
536
  }
459
537
 
538
+ const isCompatibleWithCurrentPlatform = async (/** @type {string} */ appPath) => {
539
+ let platforms;
540
+ try {
541
+ platforms = await this.appInfosCache.extractAppPlatforms(appPath);
542
+ } catch (e) {
543
+ this.log.info(e.message);
544
+ return false;
545
+ }
546
+ if (this.isSimulator() && !platforms.some((p) => _.includes(p, 'Simulator'))) {
547
+ this.log.info(
548
+ `'${appPath}' does not have Simulator devices in the list of supported platforms ` +
549
+ `(${platforms.join(',')}). Skipping it`
550
+ );
551
+ return false;
552
+ }
553
+ if (this.isRealDevice() && !platforms.some((p) => _.includes(p, 'OS'))) {
554
+ this.log.info(
555
+ `'${appPath}' does not have real devices in the list of supported platforms ` +
556
+ `(${platforms.join(',')}). Skipping it`
557
+ );
558
+ return false;
559
+ }
560
+ return true;
561
+ };
562
+
460
563
  const matchedPaths = await findApps(rootDir, SUPPORTED_EXTENSIONS);
461
564
  if (_.isEmpty(matchedPaths)) {
462
565
  this.log.debug(`'${path.basename(rootDir)}' has no bundles`);
@@ -469,32 +572,12 @@ async function unzipApp(appPathOrZipStream, depth = 0) {
469
572
  try {
470
573
  for (const matchedPath of matchedPaths) {
471
574
  const fullPath = path.join(rootDir, matchedPath);
472
- if (await isAppBundle(fullPath)) {
473
- const supportedPlatforms = await fetchSupportedAppPlatforms(fullPath);
474
- if (this.isSimulator() && !supportedPlatforms.some((p) => _.includes(p, 'Simulator'))) {
475
- this.log.info(
476
- `'${matchedPath}' does not have Simulator devices in the list of supported platforms ` +
477
- `(${supportedPlatforms.join(',')}). Skipping it`,
478
- );
479
- continue;
480
- }
481
- if (this.isRealDevice() && !supportedPlatforms.some((p) => _.includes(p, 'OS'))) {
482
- this.log.info(
483
- `'${matchedPath}' does not have real devices in the list of supported platforms ` +
484
- `(${supportedPlatforms.join(',')}). Skipping it`,
485
- );
486
- continue;
487
- }
488
- this.log.info(
489
- `'${matchedPath}' is the resulting application bundle selected from '${rootDir}'`,
490
- );
491
- return await isolateAppBundle(fullPath);
492
- } else if (_.endsWith(_.toLower(fullPath), IPA_EXT) && (await fs.stat(fullPath)).isFile()) {
493
- try {
494
- return await unzipApp.bind(this)(fullPath, depth + 1);
495
- } catch (e) {
496
- this.log.warn(`Skipping processing of '${matchedPath}': ${e.message}`);
497
- }
575
+ if (
576
+ (await isAppBundle(fullPath) || (this.isRealDevice() && await isIpaBundle(fullPath)))
577
+ && await isCompatibleWithCurrentPlatform(fullPath)
578
+ ) {
579
+ this.log.debug(`Selecting the application at '${matchedPath}'`);
580
+ return await isolateApp(fullPath);
498
581
  }
499
582
  }
500
583
  } finally {
@@ -504,16 +587,24 @@ async function unzipApp(appPathOrZipStream, depth = 0) {
504
587
  }
505
588
 
506
589
  /**
507
- * @this {import('./driver').XCUITestDriver}
590
+ * The callback invoked by configureApp helper
591
+ * when it is necessary to download the remote application.
592
+ * We assume the remote file could be anythingm, but only
593
+ * .zip and .ipa formats are supported.
594
+ * A .zip archive can contain one or more
595
+ *
596
+ * @this {XCUITestDriver}
508
597
  * @param {import('@appium/types').DownloadAppOptions} opts
509
598
  * @returns {Promise<string>}
510
599
  */
511
- export async function onDownloadApp({stream}) {
512
- return await unzipApp.bind(this)(stream);
600
+ export async function onDownloadApp({stream, headers}) {
601
+ return this.isRealDevice()
602
+ ? await downloadIpa.bind(this)(stream, headers)
603
+ : await unzipApp.bind(this)(stream);
513
604
  }
514
605
 
515
606
  /**
516
- * @this {import('./driver').XCUITestDriver}
607
+ * @this {XCUITestDriver}
517
608
  * @param {import('@appium/types').PostProcessOptions} opts
518
609
  * @returns {Promise<import('@appium/types').PostProcessResult|false>}
519
610
  */
@@ -541,13 +632,25 @@ export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) {
541
632
  return {appPath: cachedPath};
542
633
  }
543
634
 
544
- const isBundleAlreadyUnpacked = await isAppBundle(/** @type {string} */(appPath));
545
- // Only local .app bundles that are available in-place should not be cached
546
- if (!isUrl && isBundleAlreadyUnpacked) {
635
+ const isLocalIpa = await isIpaBundle(/** @type {string} */(appPath));
636
+ const isLocalApp = !isLocalIpa && await isAppBundle(/** @type {string} */(appPath));
637
+ const isPackageReadyForInstall = isLocalApp || (this.isRealDevice() && isLocalIpa);
638
+ if (isPackageReadyForInstall) {
639
+ await this.appInfosCache.put(/** @type {string} */(appPath));
640
+ }
641
+ // Only local .app bundles (real device/Simulator)
642
+ // and .ipa packages for real devices should not be cached
643
+ if (!isUrl && isPackageReadyForInstall) {
547
644
  return false;
548
645
  }
549
646
  // Cache the app while unpacking the bundle if necessary
550
647
  return {
551
- appPath: isBundleAlreadyUnpacked ? appPath : await unzipApp.bind(this)(/** @type {string} */(appPath))
648
+ appPath: isPackageReadyForInstall
649
+ ? appPath
650
+ : await unzipApp.bind(this)(/** @type {string} */(appPath))
552
651
  };
553
652
  }
653
+
654
+ /**
655
+ * @typedef {import('./driver').XCUITestDriver} XCUITestDriver
656
+ */