appium-xcuitest-driver 7.14.0 → 7.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/build/lib/app-infos-cache.d.ts +46 -0
- package/build/lib/app-infos-cache.d.ts.map +1 -0
- package/build/lib/app-infos-cache.js +156 -0
- package/build/lib/app-infos-cache.js.map +1 -0
- package/build/lib/app-utils.d.ts +32 -52
- package/build/lib/app-utils.d.ts.map +1 -1
- package/build/lib/app-utils.js +327 -219
- package/build/lib/app-utils.js.map +1 -1
- package/build/lib/commands/app-management.d.ts +5 -4
- package/build/lib/commands/app-management.d.ts.map +1 -1
- package/build/lib/commands/app-management.js +14 -7
- package/build/lib/commands/app-management.js.map +1 -1
- package/build/lib/commands/app-strings.d.ts +5 -2
- package/build/lib/commands/app-strings.d.ts.map +1 -1
- package/build/lib/commands/app-strings.js +6 -3
- package/build/lib/commands/app-strings.js.map +1 -1
- package/build/lib/commands/file-movement.js +1 -1
- package/build/lib/commands/file-movement.js.map +1 -1
- package/build/lib/commands/types.d.ts +1 -0
- package/build/lib/commands/types.d.ts.map +1 -1
- package/build/lib/commands/xctest-record-screen.d.ts +1 -1
- package/build/lib/desired-caps.d.ts +2 -0
- package/build/lib/desired-caps.d.ts.map +1 -1
- package/build/lib/desired-caps.js +1 -0
- package/build/lib/desired-caps.js.map +1 -1
- package/build/lib/driver.d.ts +30 -27
- package/build/lib/driver.d.ts.map +1 -1
- package/build/lib/driver.js +12 -12
- package/build/lib/driver.js.map +1 -1
- package/build/lib/execute-method-map.d.ts +1 -1
- package/build/lib/execute-method-map.js +1 -1
- package/build/lib/execute-method-map.js.map +1 -1
- package/build/lib/ios-fs-helpers.d.ts +30 -15
- package/build/lib/ios-fs-helpers.d.ts.map +1 -1
- package/build/lib/ios-fs-helpers.js +54 -21
- package/build/lib/ios-fs-helpers.js.map +1 -1
- package/build/lib/real-device-management.d.ts +0 -5
- package/build/lib/real-device-management.d.ts.map +1 -1
- package/build/lib/real-device-management.js +8 -5
- package/build/lib/real-device-management.js.map +1 -1
- package/build/lib/real-device.d.ts +13 -9
- package/build/lib/real-device.d.ts.map +1 -1
- package/build/lib/real-device.js +49 -75
- package/build/lib/real-device.js.map +1 -1
- package/lib/app-infos-cache.js +159 -0
- package/lib/app-utils.js +357 -239
- package/lib/commands/app-management.js +20 -9
- package/lib/commands/app-strings.js +6 -3
- package/lib/commands/file-movement.js +1 -1
- package/lib/commands/types.ts +1 -0
- package/lib/desired-caps.js +1 -0
- package/lib/driver.js +12 -15
- package/lib/execute-method-map.ts +1 -1
- package/lib/ios-fs-helpers.js +57 -23
- package/lib/real-device-management.js +7 -5
- package/lib/real-device.js +62 -88
- package/npm-shrinkwrap.json +13 -5
- 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
|
-
|
|
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
|
-
* @
|
|
122
|
-
* @
|
|
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(
|
|
126
|
-
log.debug('Verifying application platform');
|
|
39
|
+
export async function verifyApplicationPlatform() {
|
|
40
|
+
this.log.debug('Verifying application platform');
|
|
127
41
|
|
|
128
|
-
const supportedPlatforms = await
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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 (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
216
|
-
|
|
147
|
+
this.log.debug(message);
|
|
148
|
+
}
|
|
149
|
+
if (!lprojRoot) {
|
|
150
|
+
return {};
|
|
217
151
|
}
|
|
218
|
-
}
|
|
219
152
|
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
446
|
+
* The name of the app bundle remains the same
|
|
381
447
|
*/
|
|
382
|
-
|
|
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
|
|
385
|
-
await fs.mv(
|
|
386
|
-
return
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
607
|
+
* @this {XCUITestDriver}
|
|
517
608
|
* @param {import('@appium/types').PostProcessOptions} opts
|
|
518
609
|
* @returns {Promise<import('@appium/types').PostProcessResult|false>}
|
|
519
610
|
*/
|
|
@@ -522,32 +613,59 @@ export async function onPostConfigureApp({cachedAppInfo, isUrl, appPath}) {
|
|
|
522
613
|
/** @type {import('@appium/types').CachedAppInfo|undefined} */
|
|
523
614
|
const appInfo = _.isPlainObject(cachedAppInfo) ? cachedAppInfo : undefined;
|
|
524
615
|
const cachedPath = appInfo ? /** @type {string} */ (appInfo.fullPath) : undefined;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
appInfo
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
616
|
+
|
|
617
|
+
const shouldUseCachedApp = async () => {
|
|
618
|
+
if (!appInfo || !cachedPath || !await fs.exists(cachedPath)) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const isCachedPathAFile = (await fs.stat(cachedPath)).isFile();
|
|
623
|
+
if (isCachedPathAFile) {
|
|
624
|
+
return await fs.hash(cachedPath) === /** @type {any} */ (appInfo.integrity)?.file;
|
|
625
|
+
}
|
|
626
|
+
// If the cached path is a folder then it is expected to be previously extracted from
|
|
627
|
+
// an archive located under appPath whose hash is stored as `cachedAppInfo.packageHash`
|
|
628
|
+
if (
|
|
629
|
+
!isCachedPathAFile
|
|
630
|
+
&& cachedAppInfo?.packageHash
|
|
631
|
+
&& await fs.exists(/** @type {string} */ (appPath))
|
|
632
|
+
&& (await fs.stat(/** @type {string} */ (appPath))).isFile()
|
|
633
|
+
&& cachedAppInfo.packageHash === await fs.hash(/** @type {string} */ (appPath))
|
|
634
|
+
) {
|
|
635
|
+
/** @type {number|undefined} */
|
|
636
|
+
const nestedItemsCountInCache = /** @type {any} */ (appInfo.integrity)?.folder;
|
|
637
|
+
if (nestedItemsCountInCache !== undefined) {
|
|
638
|
+
return (await fs.glob('**/*', {cwd: cachedPath})).length >= nestedItemsCountInCache;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return false;
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
if (await shouldUseCachedApp()) {
|
|
540
646
|
this.log.info(`Using '${cachedPath}' which was cached from '${appPath}'`);
|
|
541
|
-
return {appPath: cachedPath};
|
|
647
|
+
return {appPath: /** @type {string} */ (cachedPath)};
|
|
542
648
|
}
|
|
543
649
|
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
650
|
+
const isLocalIpa = await isIpaBundle(/** @type {string} */(appPath));
|
|
651
|
+
const isLocalApp = !isLocalIpa && await isAppBundle(/** @type {string} */(appPath));
|
|
652
|
+
const isPackageReadyForInstall = isLocalApp || (this.isRealDevice() && isLocalIpa);
|
|
653
|
+
if (isPackageReadyForInstall) {
|
|
654
|
+
await this.appInfosCache.put(/** @type {string} */(appPath));
|
|
655
|
+
}
|
|
656
|
+
// Only local .app bundles (real device/Simulator)
|
|
657
|
+
// and .ipa packages for real devices should not be cached
|
|
658
|
+
if (!isUrl && isPackageReadyForInstall) {
|
|
547
659
|
return false;
|
|
548
660
|
}
|
|
549
661
|
// Cache the app while unpacking the bundle if necessary
|
|
550
662
|
return {
|
|
551
|
-
appPath:
|
|
663
|
+
appPath: isPackageReadyForInstall
|
|
664
|
+
? appPath
|
|
665
|
+
: await unzipApp.bind(this)(/** @type {string} */(appPath))
|
|
552
666
|
};
|
|
553
667
|
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* @typedef {import('./driver').XCUITestDriver} XCUITestDriver
|
|
671
|
+
*/
|