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