appium-xcuitest-driver 7.13.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 +14 -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 +60 -51
  7. package/build/lib/app-utils.d.ts.map +1 -1
  8. package/build/lib/app-utils.js +496 -182
  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 +35 -40
  28. package/build/lib/driver.d.ts.map +1 -1
  29. package/build/lib/driver.js +15 -99
  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 +529 -193
  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 +17 -120
  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 +40 -32
  59. package/package.json +2 -2
package/lib/app-utils.js CHANGED
@@ -1,19 +1,20 @@
1
1
  import _ from 'lodash';
2
2
  import path from 'path';
3
- import {plist, fs, util, tempDir, zip} from 'appium/support';
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';
8
+ import {spawn} from 'node:child_process';
9
+ import assert from 'node:assert';
10
+ import { isTvOs } from './utils.js';
9
11
 
10
12
  const STRINGSDICT_RESOURCE = '.stringsdict';
11
13
  const STRINGS_RESOURCE = '.strings';
12
14
  export const SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
13
15
  export const APP_EXT = '.app';
14
16
  export const IPA_EXT = '.ipa';
15
- /** @type {LRUCache<string, import('@appium/types').StringRecord>} */
16
- const PLIST_CACHE = new LRUCache({max: 20});
17
+ const ZIP_EXT = '.zip';
17
18
  const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({
18
19
  safariAllowPopups: [
19
20
  ['WebKitJavaScriptCanOpenWindowsAutomatically', 'JavaScriptCanOpenWindowsAutomatically'],
@@ -22,146 +23,67 @@ const SAFARI_OPTS_ALIASES_MAP = /** @type {const} */ ({
22
23
  safariIgnoreFraudWarning: [['WarnAboutFraudulentWebsites'], (x) => Number(!x)],
23
24
  safariOpenLinksInBackground: [['OpenLinksInBackground'], (x) => Number(Boolean(x))],
24
25
  });
25
-
26
-
27
- /**
28
- * Retrieves the value of the given entry name from the application's Info.plist.
29
- *
30
- * @this {Object} Optinal instance used for caching. Ususally the driver instance.
31
- * @param {string} app Full path to the app bundle root.
32
- * @param {string} entryName Key name in the plist.
33
- * @returns {Promise<any | undefined>} Either the extracted value or undefined if no such key has been found in the plist.
34
- * @throws {Error} If the application's Info.plist cannot be parsed.
35
- */
36
- async function extractPlistEntry(app, entryName) {
37
- const plistPath = path.resolve(app, 'Info.plist');
38
-
39
- const parseFile = async () => {
40
- try {
41
- return await plist.parsePlistFile(plistPath);
42
- } catch (err) {
43
- throw new Error(`Could not extract Info.plist from '${path.basename(app)}': ${err.message}`);
44
- }
45
- };
46
-
47
- let plistObj = PLIST_CACHE.get(app);
48
- if (!plistObj) {
49
- plistObj = await parseFile();
50
- PLIST_CACHE.set(app, plistObj);
51
- }
52
- return /** @type {import('@appium/types').StringRecord} */ (plistObj)[entryName];
53
- }
54
-
55
- /**
56
- *
57
- * @param {string} app
58
- * @returns {Promise<string>}
59
- */
60
- export async function extractBundleId(app) {
61
- const bundleId = await extractPlistEntry(app, 'CFBundleIdentifier');
62
- log.debug(`Getting bundle ID from app '${app}': '${bundleId}'`);
63
- return bundleId;
64
- }
65
-
66
- /**
67
- *
68
- * @param {string} app
69
- * @returns {Promise<string>}
70
- */
71
- export async function extractBundleVersion(app) {
72
- return await extractPlistEntry(app, 'CFBundleVersion');
73
- }
74
-
75
- /**
76
- *
77
- * @param {string} app
78
- * @returns {Promise<string>}
79
- */
80
- async function extractExecutableName(app) {
81
- return await extractPlistEntry(app, 'CFBundleExecutable');
82
- }
83
-
84
- /**
85
- *
86
- * @param {string} app
87
- * @returns {Promise<string[]>}
88
- */
89
- export async function fetchSupportedAppPlatforms(app) {
90
- try {
91
- const result = await extractPlistEntry(app, 'CFBundleSupportedPlatforms');
92
- if (!_.isArray(result)) {
93
- log.warn(`${path.basename(app)}': CFBundleSupportedPlatforms is not a valid list`);
94
- return [];
95
- }
96
- return result;
97
- } catch (err) {
98
- log.warn(
99
- `Cannot extract the list of supported platforms from '${path.basename(app)}': ${err.message}`,
100
- );
101
- return [];
102
- }
103
- }
104
-
105
- /**
106
- * @typedef {Object} PlatformOpts
107
- *
108
- * @property {boolean} isSimulator - Whether the destination platform is a Simulator
109
- * @property {boolean} isTvOS - Whether the destination platform is a Simulator
110
- */
26
+ const MAX_ARCHIVE_SCAN_DEPTH = 1;
27
+ export const SUPPORTED_EXTENSIONS = [IPA_EXT, APP_EXT];
28
+ const MACOS_RESOURCE_FOLDER = '__MACOSX';
29
+ const SANITIZE_REPLACEMENT = '-';
111
30
 
112
31
  /**
113
32
  * Verify whether the given application is compatible to the
114
33
  * platform where it is going to be installed and tested.
115
34
  *
116
- * @param {string} app - The actual path to the application bundle
117
- * @param {PlatformOpts} expectedPlatform
35
+ * @this {XCUITestDriver}
36
+ * @returns {Promise<void>}
118
37
  * @throws {Error} If bundle architecture does not match the expected device architecture.
119
38
  */
120
- export async function verifyApplicationPlatform(app, expectedPlatform) {
121
- log.debug('Verifying application platform');
39
+ export async function verifyApplicationPlatform() {
40
+ this.log.debug('Verifying application platform');
122
41
 
123
- const supportedPlatforms = await fetchSupportedAppPlatforms(app);
124
- log.debug(`CFBundleSupportedPlatforms: ${JSON.stringify(supportedPlatforms)}`);
125
-
126
- const {isSimulator, isTvOS} = expectedPlatform;
42
+ const supportedPlatforms = await this.appInfosCache.extractAppPlatforms(this.opts.app);
43
+ const isTvOS = isTvOs(this.opts.platformName);
127
44
  const prefix = isTvOS ? 'AppleTV' : 'iPhone';
128
- const suffix = isSimulator ? 'Simulator' : 'OS';
45
+ const suffix = this.isSimulator() ? 'Simulator' : 'OS';
129
46
  const dstPlatform = `${prefix}${suffix}`;
130
- const appFileName = path.basename(app);
131
47
  if (!supportedPlatforms.includes(dstPlatform)) {
132
48
  throw new Error(
133
49
  `${
134
- isSimulator ? 'Simulator' : 'Real device'
135
- } 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. ` +
136
52
  `Make sure the correct deployment target has been selected for its compilation in Xcode.`,
137
53
  );
138
54
  }
139
- if (isSimulator) {
140
- const executablePath = path.resolve(app, await extractExecutableName(app));
141
- const [resFile, resUname] = await B.all([
142
- exec('file', [executablePath]),
143
- exec('uname', ['-m']),
144
- ]);
145
- const bundleExecutableInfo = _.trim(resFile.stdout);
146
- log.debug(bundleExecutableInfo);
147
- const arch = _.trim(resUname.stdout);
148
- const isAppleSilicon = os.cpus()[0].model.includes('Apple');
149
- // We cannot run Simulator builds compiled for arm64 on Intel machines
150
- // Rosetta allows only to run Intel ones on arm64
151
- if (
152
- !_.includes(bundleExecutableInfo, `executable ${arch}`) &&
153
- !(isAppleSilicon && _.includes(bundleExecutableInfo, 'executable x86_64'))
154
- ) {
155
- const bundleId = await extractBundleId(app);
156
- throw new Error(
157
- `The ${bundleId} application does not support the ${arch} Simulator ` +
158
- `architecture:\n${bundleExecutableInfo}\n\n` +
159
- `Please rebuild your application to support the ${arch} platform.`,
160
- );
161
- }
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
+ );
162
79
  }
163
80
  }
164
81
 
82
+ /**
83
+ *
84
+ * @param {string} resourcePath
85
+ * @returns {Promise<import('@appium/types').StringRecord>}
86
+ */
165
87
  async function readResource(resourcePath) {
166
88
  const data = await plist.parsePlistFile(resourcePath);
167
89
  const result = {};
@@ -171,79 +93,113 @@ async function readResource(resourcePath) {
171
93
  return result;
172
94
  }
173
95
 
174
- export async function parseLocalizableStrings(opts) {
175
- 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
+ */
176
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;
177
114
  if (!app) {
178
115
  const message = `Strings extraction is not supported if 'app' capability is not set`;
179
116
  if (strictMode) {
180
117
  throw new Error(message);
181
118
  }
182
- log.info(message);
119
+ this.log.info(message);
183
120
  return {};
184
121
  }
185
122
 
186
- let lprojRoot;
187
- for (const subfolder of [`${language}.lproj`, localizableStringsDir, '']) {
188
- lprojRoot = path.resolve(app, subfolder);
189
- if (await fs.exists(lprojRoot)) {
190
- break;
191
- }
192
- const message = `No '${lprojRoot}' resources folder has been found`;
193
- if (strictMode) {
194
- 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);
195
134
  }
196
- log.debug(message);
197
- }
198
- log.info(`Will extract resource strings from '${lprojRoot}'`);
199
135
 
200
- const resourcePaths = [];
201
- if (stringFile) {
202
- const dstPath = path.resolve(String(lprojRoot), stringFile);
203
- if (await fs.exists(dstPath)) {
204
- resourcePaths.push(dstPath);
205
- } else {
206
- 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`;
207
144
  if (strictMode) {
208
145
  throw new Error(message);
209
146
  }
210
- log.info(message);
211
- log.info(`Getting all the available strings from '${lprojRoot}'`);
147
+ this.log.debug(message);
148
+ }
149
+ if (!lprojRoot) {
150
+ return {};
212
151
  }
213
- }
214
152
 
215
- if (_.isEmpty(resourcePaths) && (await fs.exists(String(lprojRoot)))) {
216
- const resourceFiles = (await fs.readdir(String(lprojRoot)))
217
- .filter((name) => _.some([STRINGS_RESOURCE, STRINGSDICT_RESOURCE], (x) => name.endsWith(x)))
218
- .map((name) => path.resolve(lprojRoot, name));
219
- resourcePaths.push(...resourceFiles);
220
- }
221
- 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
+ }
222
167
 
223
- if (_.isEmpty(resourcePaths)) {
224
- return {};
225
- }
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}'`);
226
175
 
227
- const resultStrings = {};
228
- const toAbsolutePath = function (p) {
229
- return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
230
- };
231
- for (const resourcePath of resourcePaths) {
232
- if (!util.isSubPath(toAbsolutePath(resourcePath), toAbsolutePath(app))) {
233
- // security precaution
234
- throw new Error(`'${resourcePath}' is expected to be located under '${app}'`);
176
+ if (_.isEmpty(resourcePaths)) {
177
+ return {};
235
178
  }
236
- try {
237
- const data = await readResource(resourcePath);
238
- log.debug(`Parsed ${_.keys(data).length} string(s) from '${resourcePath}'`);
239
- _.merge(resultStrings, data);
240
- } catch (e) {
241
- 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
+ }
242
194
  }
243
- }
244
195
 
245
- log.info(`Got ${_.keys(resultStrings).length} string(s) from '${lprojRoot}'`);
246
- 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
+ }
247
203
  }
248
204
 
249
205
  /**
@@ -252,7 +208,7 @@ export async function parseLocalizableStrings(opts) {
252
208
  * @param {string} appPath Possible .app bundle root
253
209
  * @returns {Promise<boolean>} Whether the given path points to an .app bundle
254
210
  */
255
- export async function isAppBundle(appPath) {
211
+ async function isAppBundle(appPath) {
256
212
  return (
257
213
  _.endsWith(_.toLower(appPath), APP_EXT) &&
258
214
  (await fs.stat(appPath)).isDirectory() &&
@@ -261,41 +217,249 @@ export async function isAppBundle(appPath) {
261
217
  }
262
218
 
263
219
  /**
264
- * Extract the given archive and looks for items with given extensions in it
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
+
229
+ /**
230
+ * @typedef {Object} UnzipInfo
231
+ * @property {string} rootDir
232
+ * @property {number} archiveSize
233
+ */
234
+
235
+ /**
236
+ * Unzips a ZIP archive on the local file system.
265
237
  *
266
238
  * @param {string} archivePath Full path to a .zip archive
267
- * @param {Array<string>} appExtensions List of matching item extensions
268
- * @returns {Promise<[string, string[]]>} Tuple, where the first element points to
269
- * a temporary folder root where the archive has been extracted and the second item
270
- * contains a list of relative paths to matched items
239
+ * @returns {Promise<UnzipInfo>} temporary folder root where the archive has been extracted
271
240
  */
272
- export async function findApps(archivePath, appExtensions) {
241
+ export async function unzipFile(archivePath) {
273
242
  const useSystemUnzipEnv = process.env.APPIUM_PREFER_SYSTEM_UNZIP;
274
243
  const useSystemUnzip =
275
244
  _.isEmpty(useSystemUnzipEnv) || !['0', 'false'].includes(_.toLower(useSystemUnzipEnv));
276
245
  const tmpRoot = await tempDir.openDir();
277
- await zip.extractAllTo(archivePath, tmpRoot, {useSystemUnzip});
246
+ try {
247
+ await zip.extractAllTo(archivePath, tmpRoot, {useSystemUnzip});
248
+ } catch (e) {
249
+ await fs.rimraf(tmpRoot);
250
+ throw e;
251
+ }
252
+ return {
253
+ rootDir: tmpRoot,
254
+ archiveSize: (await fs.stat(archivePath)).size,
255
+ };
256
+ }
257
+
258
+ /**
259
+ * Unzips a ZIP archive from a stream.
260
+ * Uses bdstar tool for this purpose.
261
+ * This allows to optimize the time needed to prepare the app under test
262
+ * to MAX(download, unzip) instead of SUM(download, unzip)
263
+ *
264
+ * @param {import('node:stream').Readable} zipStream
265
+ * @returns {Promise<UnzipInfo>}
266
+ */
267
+ export async function unzipStream(zipStream) {
268
+ const tmpRoot = await tempDir.openDir();
269
+ const bsdtarProcess = spawn(await fs.which('bsdtar'), [
270
+ '-x',
271
+ '--exclude', MACOS_RESOURCE_FOLDER,
272
+ '--exclude', `${MACOS_RESOURCE_FOLDER}/*`,
273
+ '-',
274
+ ], {
275
+ cwd: tmpRoot,
276
+ });
277
+ let archiveSize = 0;
278
+ bsdtarProcess.stderr.on('data', (chunk) => {
279
+ const stderr = chunk.toString();
280
+ if (_.trim(stderr)) {
281
+ log.warn(stderr);
282
+ }
283
+ });
284
+ zipStream.on('data', (chunk) => {
285
+ archiveSize += _.size(chunk);
286
+ });
287
+ zipStream.pipe(bsdtarProcess.stdin);
288
+ try {
289
+ await new B((resolve, reject) => {
290
+ zipStream.once('error', reject);
291
+ bsdtarProcess.once('exit', (code, signal) => {
292
+ zipStream.unpipe(bsdtarProcess.stdin);
293
+ log.debug(`bsdtar process exited with code ${code}, signal ${signal}`);
294
+ if (code === 0) {
295
+ resolve();
296
+ } else {
297
+ reject(new Error('Is it a valid ZIP archive?'));
298
+ }
299
+ });
300
+ bsdtarProcess.once('error', (e) => {
301
+ zipStream.unpipe(bsdtarProcess.stdin);
302
+ reject(e);
303
+ });
304
+ });
305
+ } catch (err) {
306
+ bsdtarProcess.kill(9);
307
+ await fs.rimraf(tmpRoot);
308
+ throw new Error(`The response data cannot be unzipped: ${err.message}`);
309
+ } finally {
310
+ bsdtarProcess.removeAllListeners();
311
+ zipStream.removeAllListeners();
312
+ }
313
+ return {
314
+ rootDir: tmpRoot,
315
+ archiveSize,
316
+ };
317
+ }
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
+
424
+ /**
425
+ * Looks for items with given extensions in the given folder
426
+ *
427
+ * @param {string} appPath Full path to an app bundle
428
+ * @param {Array<string>} appExtensions List of matching item extensions
429
+ * @returns {Promise<string[]>} List of relative paths to matched items
430
+ */
431
+ async function findApps(appPath, appExtensions) {
278
432
  const globPattern = `**/*.+(${appExtensions.map((ext) => ext.replace(/^\./, '')).join('|')})`;
279
433
  const sortedBundleItems = (
280
434
  await fs.glob(globPattern, {
281
- cwd: tmpRoot,
435
+ cwd: appPath,
282
436
  })
283
437
  ).sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
284
- return [tmpRoot, sortedBundleItems];
438
+ return sortedBundleItems;
285
439
  }
286
440
 
287
441
  /**
288
442
  * Moves the application bundle to a newly created temporary folder
289
443
  *
290
- * @param {string} appRoot Full path to the .app bundle
444
+ * @param {string} appPath Full path to the .app or .ipa bundle
291
445
  * @returns {Promise<string>} The new path to the app bundle.
292
- * The name of the app bundle remains though
446
+ * The name of the app bundle remains the same
293
447
  */
294
- 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
+
295
459
  const tmpRoot = await tempDir.openDir();
296
- const dstRoot = path.join(tmpRoot, path.basename(appRoot));
297
- await fs.mv(appRoot, dstRoot, {mkdirp: true});
298
- return dstRoot;
460
+ const isolatedRoot = path.join(tmpRoot, appFileName);
461
+ await fs.mv(appPath, isolatedRoot, {mkdirp: true});
462
+ return isolatedRoot;
299
463
  }
300
464
 
301
465
  /**
@@ -318,3 +482,175 @@ export function buildSafariPreferences(opts) {
318
482
  }
319
483
  return safariSettings;
320
484
  }
485
+
486
+ /**
487
+ * Unzip the given archive and find a matching .app bundle in it
488
+ *
489
+ * @this {XCUITestDriver}
490
+ * @param {string|import('node:stream').Readable} appPathOrZipStream The path to the archive.
491
+ * @param {number} depth [0] the current nesting depth. App bundles whose nesting level
492
+ * is greater than 1 are not supported.
493
+ * @returns {Promise<string>} Full path to the first matching .app bundle..
494
+ * @throws If no matching .app bundles were found in the provided archive.
495
+ */
496
+ async function unzipApp(appPathOrZipStream, depth = 0) {
497
+ const errMsg = `The archive did not have any matching ${APP_EXT} or ${IPA_EXT} ` +
498
+ `bundles. Please make sure the provided package is valid and contains at least one matching ` +
499
+ `application bundle which is not nested.`;
500
+ if (depth > MAX_ARCHIVE_SCAN_DEPTH) {
501
+ throw new Error(errMsg);
502
+ }
503
+
504
+ const timer = new timing.Timer().start();
505
+ /** @type {string} */
506
+ let rootDir;
507
+ /** @type {number} */
508
+ let archiveSize;
509
+ try {
510
+ if (_.isString(appPathOrZipStream)) {
511
+ ({rootDir, archiveSize} = await unzipFile(/** @type {string} */ (appPathOrZipStream)));
512
+ } else {
513
+ if (depth > 0) {
514
+ assert.fail('Streaming unzip cannot be invoked for nested archive items');
515
+ }
516
+ ({rootDir, archiveSize} = await unzipStream(
517
+ /** @type {import('node:stream').Readable} */ (appPathOrZipStream))
518
+ );
519
+ }
520
+ } catch (e) {
521
+ this.log.debug(e.stack);
522
+ throw new Error(
523
+ `Cannot prepare the application for testing. Original error: ${e.message}`
524
+ );
525
+ }
526
+ const secondsElapsed = timer.getDuration().asSeconds;
527
+ this.log.info(
528
+ `The file (${util.toReadableSizeString(archiveSize)}) ` +
529
+ `has been ${_.isString(appPathOrZipStream) ? 'extracted' : 'downloaded and extracted'} ` +
530
+ `to '${rootDir}' in ${secondsElapsed.toFixed(3)}s`
531
+ );
532
+ // it does not make much sense to approximate the speed for short downloads
533
+ if (secondsElapsed >= 1) {
534
+ const bytesPerSec = Math.floor(archiveSize / secondsElapsed);
535
+ this.log.debug(`Approximate decompression speed: ${util.toReadableSizeString(bytesPerSec)}/s`);
536
+ }
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
+
563
+ const matchedPaths = await findApps(rootDir, SUPPORTED_EXTENSIONS);
564
+ if (_.isEmpty(matchedPaths)) {
565
+ this.log.debug(`'${path.basename(rootDir)}' has no bundles`);
566
+ } else {
567
+ this.log.debug(
568
+ `Found ${util.pluralize('bundle', matchedPaths.length, true)} in ` +
569
+ `'${path.basename(rootDir)}': ${matchedPaths}`,
570
+ );
571
+ }
572
+ try {
573
+ for (const matchedPath of matchedPaths) {
574
+ const fullPath = path.join(rootDir, matchedPath);
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);
581
+ }
582
+ }
583
+ } finally {
584
+ await fs.rimraf(rootDir);
585
+ }
586
+ throw new Error(errMsg);
587
+ }
588
+
589
+ /**
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}
597
+ * @param {import('@appium/types').DownloadAppOptions} opts
598
+ * @returns {Promise<string>}
599
+ */
600
+ export async function onDownloadApp({stream, headers}) {
601
+ return this.isRealDevice()
602
+ ? await downloadIpa.bind(this)(stream, headers)
603
+ : await unzipApp.bind(this)(stream);
604
+ }
605
+
606
+ /**
607
+ * @this {XCUITestDriver}
608
+ * @param {import('@appium/types').PostProcessOptions} opts
609
+ * @returns {Promise<import('@appium/types').PostProcessResult|false>}
610
+ */
611
+ export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) {
612
+ // Pick the previously cached entry if its integrity has been preserved
613
+ /** @type {import('@appium/types').CachedAppInfo|undefined} */
614
+ const appInfo = _.isPlainObject(cachedAppInfo) ? cachedAppInfo : undefined;
615
+ const cachedPath = appInfo ? /** @type {string} */ (appInfo.fullPath) : undefined;
616
+ if (
617
+ // If cache is present
618
+ appInfo && cachedPath
619
+ // And if the path exists
620
+ && await fs.exists(cachedPath)
621
+ // And if hash matches to the cached one if this is a file
622
+ // Or count of files >= of the cached one if this is a folder
623
+ && (
624
+ ((await fs.stat(cachedPath)).isFile()
625
+ && await fs.hash(cachedPath) === /** @type {any} */ (appInfo.integrity)?.file)
626
+ || (await fs.glob('**/*', {cwd: cachedPath})).length >= /** @type {any} */ (
627
+ appInfo.integrity
628
+ )?.folder
629
+ )
630
+ ) {
631
+ this.log.info(`Using '${cachedPath}' which was cached from '${appPath}'`);
632
+ return {appPath: cachedPath};
633
+ }
634
+
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) {
644
+ return false;
645
+ }
646
+ // Cache the app while unpacking the bundle if necessary
647
+ return {
648
+ appPath: isPackageReadyForInstall
649
+ ? appPath
650
+ : await unzipApp.bind(this)(/** @type {string} */(appPath))
651
+ };
652
+ }
653
+
654
+ /**
655
+ * @typedef {import('./driver').XCUITestDriver} XCUITestDriver
656
+ */