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.
- 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 +60 -51
- package/build/lib/app-utils.d.ts.map +1 -1
- package/build/lib/app-utils.js +496 -182
- 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 +35 -40
- package/build/lib/driver.d.ts.map +1 -1
- package/build/lib/driver.js +15 -99
- 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 +529 -193
- 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 +17 -120
- 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 +40 -32
- 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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
117
|
-
* @
|
|
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(
|
|
121
|
-
log.debug('Verifying application platform');
|
|
39
|
+
export async function verifyApplicationPlatform() {
|
|
40
|
+
this.log.debug('Verifying application platform');
|
|
122
41
|
|
|
123
|
-
const supportedPlatforms = await
|
|
124
|
-
|
|
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
|
|
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 (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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.
|
|
211
|
-
|
|
147
|
+
this.log.debug(message);
|
|
148
|
+
}
|
|
149
|
+
if (!lprojRoot) {
|
|
150
|
+
return {};
|
|
212
151
|
}
|
|
213
|
-
}
|
|
214
152
|
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
|
|
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:
|
|
435
|
+
cwd: appPath,
|
|
282
436
|
})
|
|
283
437
|
).sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
|
|
284
|
-
return
|
|
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}
|
|
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
|
|
446
|
+
* The name of the app bundle remains the same
|
|
293
447
|
*/
|
|
294
|
-
|
|
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
|
|
297
|
-
await fs.mv(
|
|
298
|
-
return
|
|
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
|
+
*/
|