appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
832 lines (768 loc) • 29.5 kB
text/typescript
import _ from 'lodash';
import B, {TimeoutError} from 'bluebird';
import {fs, tempDir, mkdirp, zip, util, timing} from 'appium/support';
import path from 'path';
import {services, utilities, INSTRUMENT_CHANNEL} from 'appium-ios-device';
import {buildSafariPreferences, SAFARI_BUNDLE_ID} from '../app-utils';
import {log as defaultLogger} from '../logger';
import { Devicectl } from 'node-devicectl';
import type { AppiumLogger } from '@appium/types';
import type { XCUITestDriver } from '../driver';
const DEFAULT_APP_INSTALLATION_TIMEOUT_MS = 8 * 60 * 1000;
export const IO_TIMEOUT_MS = 4 * 60 * 1000;
// Mobile devices use NAND memory modules for the storage,
// and the parallelism there is not as performant as on regular SSDs
const MAX_IO_CHUNK_SIZE = 8;
const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed';
const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000;
const INSTALLATION_STAGING_DIR = 'PublicStaging';
//#region Public File System Functions
/**
* Retrieve a file from a real device
*
* @param afcService Apple File Client service instance from
* 'appium-ios-device' module
* @param remotePath Relative path to the file on the device
* @returns The file content as a buffer
*/
export async function pullFile(afcService: any, remotePath: string): Promise<Buffer> {
const stream = await afcService.createReadStream(remotePath, {autoDestroy: true});
const pullPromise = new B((resolve, reject) => {
stream.on('close', resolve);
stream.on('error', reject);
}).timeout(IO_TIMEOUT_MS);
const buffers: Buffer[] = [];
stream.on('data', (data: Buffer) => buffers.push(data));
await pullPromise;
return Buffer.concat(buffers);
}
/**
* Retrieve a folder from a real device
*
* @param afcService Apple File Client service instance from
* 'appium-ios-device' module
* @param remoteRootPath Relative path to the folder on the device
* @returns The folder content as a zipped base64-encoded buffer
*/
export async function pullFolder(afcService: any, remoteRootPath: string): Promise<Buffer> {
const tmpFolder = await tempDir.openDir();
try {
let localTopItem: string | null = null;
let countFilesSuccess = 0;
let countFilesFail = 0;
let countFolders = 0;
const pullPromises: B<void>[] = [];
await afcService.walkDir(remoteRootPath, true, async (remotePath: string, isDir: boolean) => {
const localPath = path.join(tmpFolder, remotePath);
const dirname = isDir ? localPath : path.dirname(localPath);
if (!(await folderExists(dirname))) {
await mkdirp(dirname);
}
if (!localTopItem || localPath.split(path.sep).length < localTopItem.split(path.sep).length) {
localTopItem = localPath;
}
if (isDir) {
++countFolders;
return;
}
const readStream = await afcService.createReadStream(remotePath, {autoDestroy: true});
const writeStream = fs.createWriteStream(localPath, {autoClose: true});
pullPromises.push(
new B<void>((resolve) => {
writeStream.on('close', () => {
++countFilesSuccess;
resolve();
});
const onStreamingError = (e: Error) => {
readStream.unpipe(writeStream);
defaultLogger.warn(
`Cannot pull '${remotePath}' to '${localPath}'. ` +
`The file will be skipped. Original error: ${e.message}`,
);
++countFilesFail;
resolve();
};
writeStream.on('error', onStreamingError);
readStream.on('error', onStreamingError);
}).timeout(IO_TIMEOUT_MS),
);
readStream.pipe(writeStream);
if (pullPromises.length >= MAX_IO_CHUNK_SIZE) {
await B.any(pullPromises);
for (let i = pullPromises.length - 1; i >= 0; i--) {
if (pullPromises[i].isFulfilled()) {
pullPromises.splice(i, 1);
}
}
}
});
// Wait for the rest of files to be pulled
if (!_.isEmpty(pullPromises)) {
await B.all(pullPromises);
}
defaultLogger.info(
`Pulled ${util.pluralize('file', countFilesSuccess, true)} out of ` +
`${countFilesSuccess + countFilesFail} and ${util.pluralize(
'folder',
countFolders,
true,
)} ` +
`from '${remoteRootPath}'`,
);
return await zip.toInMemoryZip(localTopItem ? path.dirname(localTopItem) : tmpFolder, {
encodeToBase64: true,
});
} finally {
await fs.rimraf(tmpFolder);
}
}
/**
* Pushes a file to a real device
*
* @param afcService afcService Apple File Client service instance from
* 'appium-ios-device' module
* @param localPathOrPayload Either full path to the source file
* or a buffer payload to be written into the remote destination
* @param remotePath Relative path to the file on the device. The remote
* folder structure is created automatically if necessary.
* @param opts Push file options
*/
export async function pushFile(
afcService: any,
localPathOrPayload: string | Buffer,
remotePath: string,
opts: PushFileOptions = {}
): Promise<void> {
const {timeoutMs = IO_TIMEOUT_MS} = opts;
const timer = new timing.Timer().start();
await remoteMkdirp(afcService, path.dirname(remotePath));
const source = Buffer.isBuffer(localPathOrPayload)
? localPathOrPayload
: fs.createReadStream(localPathOrPayload, {autoClose: true});
const writeStream = await afcService.createWriteStream(remotePath, {
autoDestroy: true,
});
writeStream.on('finish', writeStream.destroy);
let pushError: Error | null = null;
const filePushPromise = new B<void>((resolve, reject) => {
writeStream.on('close', () => {
if (pushError) {
reject(pushError);
} else {
resolve();
}
});
const onStreamError = (e: Error) => {
if (!Buffer.isBuffer(source)) {
source.unpipe(writeStream);
}
defaultLogger.debug(e);
pushError = e;
};
writeStream.on('error', onStreamError);
if (!Buffer.isBuffer(source)) {
source.on('error', onStreamError);
}
});
if (Buffer.isBuffer(source)) {
writeStream.write(source);
writeStream.end();
} else {
source.pipe(writeStream);
}
await filePushPromise.timeout(Math.max(timeoutMs, 60000));
const fileSize = Buffer.isBuffer(localPathOrPayload)
? localPathOrPayload.length
: (await fs.stat(localPathOrPayload)).size;
defaultLogger.debug(
`Successfully pushed the file payload (${util.toReadableSizeString(fileSize)}) ` +
`to the remote location '${remotePath}' in ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
}
/**
* Pushes a folder to a real device
*
* @param afcService Apple File Client service instance from
* 'appium-ios-device' module
* @param srcRootPath The full path to the source folder
* @param dstRootPath The relative path to the destination folder. The folder
* will be deleted if already exists.
* @param opts Push folder options
*/
export async function pushFolder(
afcService: any,
srcRootPath: string,
dstRootPath: string,
opts: PushFolderOptions = {}
): Promise<void> {
const {timeoutMs = IO_TIMEOUT_MS, enableParallelPush = false} = opts;
const timer = new timing.Timer().start();
const allItems = /** @type {import('path-scurry').Path[]} */ (
/** @type {unknown} */ (
await fs.glob('**', {
cwd: srcRootPath,
withFileTypes: true,
})
)
) as any[];
defaultLogger.debug(`Successfully scanned the tree structure of '${srcRootPath}'`);
// top-level folders go first
const foldersToPush: string[] = allItems
.filter((x) => x.isDirectory())
.map((x) => x.relative())
.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length);
// larger files go first
const filesToPush: string[] = allItems
.filter((x) => !x.isDirectory())
.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
.map((x) => x.relative());
defaultLogger.debug(
`Got ${util.pluralize('folder', foldersToPush.length, true)} and ` +
`${util.pluralize('file', filesToPush.length, true)} to push`,
);
// create the folder structure first
try {
await afcService.deleteDirectory(dstRootPath);
} catch {}
await afcService.createDirectory(dstRootPath);
for (const relativeFolderPath of foldersToPush) {
// createDirectory does not accept folder names ending with a path separator
const absoluteFolderPath = _.trimEnd(path.join(dstRootPath, relativeFolderPath), path.sep);
if (absoluteFolderPath) {
await afcService.createDirectory(absoluteFolderPath);
}
}
// do not forget about the root folder
defaultLogger.debug(
`Successfully created the remote folder structure ` +
`(${util.pluralize('item', foldersToPush.length + 1, true)})`,
);
const _pushFile = async (relativePath: string): Promise<void> => {
const absoluteSourcePath = path.join(srcRootPath, relativePath);
const readStream = fs.createReadStream(absoluteSourcePath, {autoClose: true});
const absoluteDestinationPath = path.join(dstRootPath, relativePath);
const writeStream = await afcService.createWriteStream(absoluteDestinationPath, {
autoDestroy: true,
});
writeStream.on('finish', writeStream.destroy);
let pushError: Error | null = null;
const filePushPromise = new B<void>((resolve, reject) => {
writeStream.on('close', () => {
if (pushError) {
reject(pushError);
} else {
resolve();
}
});
const onStreamError = (e: Error) => {
readStream.unpipe(writeStream);
defaultLogger.debug(e);
pushError = e;
};
writeStream.on('error', onStreamError);
readStream.on('error', onStreamError);
});
readStream.pipe(writeStream);
await filePushPromise.timeout(Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000));
};
if (enableParallelPush) {
defaultLogger.debug(`Proceeding to parallel files push (max ${MAX_IO_CHUNK_SIZE} writers)`);
const pushPromises: B<void>[] = [];
for (const relativeFilePath of filesToPush) {
pushPromises.push(B.resolve(_pushFile(relativeFilePath)));
// keep the push queue filled
if (pushPromises.length >= MAX_IO_CHUNK_SIZE) {
await B.any(pushPromises);
const elapsedMs = timer.getDuration().asMilliSeconds;
if (elapsedMs > timeoutMs) {
throw new TimeoutError(`Timed out after ${elapsedMs} ms`);
}
}
for (let i = pushPromises.length - 1; i >= 0; i--) {
if (pushPromises[i].isFulfilled()) {
pushPromises.splice(i, 1);
}
}
}
if (!_.isEmpty(pushPromises)) {
const remainingPromises = pushPromises.filter((p) => !p.isFulfilled());
if (remainingPromises.length > 0) {
await B.all(remainingPromises).timeout(
Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000),
);
}
}
} else {
defaultLogger.debug(`Proceeding to serial files push`);
for (const relativeFilePath of filesToPush) {
await _pushFile(relativeFilePath);
const elapsedMs = timer.getDuration().asMilliSeconds;
if (elapsedMs > timeoutMs) {
throw new TimeoutError(`Timed out after ${elapsedMs} ms`);
}
}
}
defaultLogger.debug(
`Successfully pushed ${util.pluralize('folder', foldersToPush.length, true)} ` +
`and ${util.pluralize('file', filesToPush.length, true)} ` +
`within ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`,
);
}
//#endregion
//#region Public Device Connection Functions
/**
* Get list of connected devices
*/
export async function getConnectedDevices(): Promise<string[]> {
if (isPreferDevicectlEnabled()) {
return (await new Devicectl('').listDevices())
.map(({hardwareProperties}) => hardwareProperties?.udid)
.filter(Boolean);
}
return await utilities.getConnectedDevices();
}
//#endregion
//#region Public Real Device Class
export class RealDevice {
readonly udid: string;
private readonly _log: AppiumLogger;
readonly devicectl: Devicectl;
constructor(udid: string, logger?: AppiumLogger) {
this.udid = udid;
this._log = logger ?? defaultLogger;
this.devicectl = new Devicectl(this.udid);
}
get log(): AppiumLogger {
return this._log;
}
async remove(bundleId: string): Promise<void> {
const service = await services.startInstallationProxyService(this.udid);
try {
await service.uninstallApplication(bundleId);
} finally {
service.close();
}
}
async removeApp(bundleId: string): Promise<void> {
await this.remove(bundleId);
}
async install(appPath: string, bundleId: string, opts: RealDeviceInstallOptions = {}): Promise<void> {
const {
timeoutMs = IO_TIMEOUT_MS,
} = opts;
const timer = new timing.Timer().start();
const afcService = await services.startAfcService(this.udid);
try {
let bundlePathOnPhone: string;
if ((await fs.stat(appPath)).isFile()) {
// https://github.com/doronz88/pymobiledevice3/blob/6ff5001f5776e03b610363254e82d7fbcad4ef5f/pymobiledevice3/services/installation_proxy.py#L75
bundlePathOnPhone = `/${path.basename(appPath)}`;
await pushFile(afcService, appPath, bundlePathOnPhone, {
timeoutMs,
});
} else {
bundlePathOnPhone = `${INSTALLATION_STAGING_DIR}/${bundleId}`;
await pushFolder(afcService, appPath, bundlePathOnPhone, {
enableParallelPush: true,
timeoutMs,
});
}
await this.installOrUpgradeApplication(
bundlePathOnPhone,
{
timeout: Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000),
isUpgrade: await this.isAppInstalled(bundleId),
}
);
} catch (err) {
this.log.debug((err as Error).stack);
let errMessage = `Cannot install the ${bundleId} application`;
if (err instanceof TimeoutError) {
errMessage += `. Consider increasing the value of 'appPushTimeout' capability (the current value equals to ${timeoutMs}ms)`;
}
errMessage += `. Original error: ${(err as Error).message}`;
throw new Error(errMessage);
} finally {
afcService.close();
}
this.log.info(
`The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`
);
}
async installOrUpgradeApplication(bundlePathOnPhone: string, opts: InstallOrUpgradeOptions): Promise<void> {
const {isUpgrade, timeout} = opts;
const notificationService = await services.startNotificationProxyService(this.udid);
const installationService = await services.startInstallationProxyService(this.udid);
const appInstalledNotification = new B<void>((resolve) => {
notificationService.observeNotification(APPLICATION_INSTALLED_NOTIFICATION, {
notification: resolve,
});
});
const clientOptions = {PackageType: 'Developer'};
try {
if (isUpgrade) {
this.log.debug(
`An upgrade of the existing application is going to be performed. ` +
`Will timeout in ${timeout.toFixed(0)} ms`
);
await installationService.upgradeApplication(bundlePathOnPhone, clientOptions, timeout);
} else {
this.log.debug(
`A new application installation is going to be performed. ` +
`Will timeout in ${timeout.toFixed(0)} ms`
);
await installationService.installApplication(bundlePathOnPhone, clientOptions, timeout);
}
try {
await appInstalledNotification.timeout(
APPLICATION_NOTIFICATION_TIMEOUT_MS,
`Could not get the application installed notification within ` +
`${APPLICATION_NOTIFICATION_TIMEOUT_MS}ms but we will continue`,
);
} catch (e) {
this.log.warn((e as Error).message);
}
} finally {
installationService.close();
notificationService.close();
}
}
/**
* Alias for {@linkcode install}
*/
async installApp(appPath: string, bundleId: string, opts: RealDeviceInstallOptions = {}): Promise<void> {
return await this.install(appPath, bundleId, opts);
}
/**
* Return an application object if test app has 'bundleid'.
* The target bundleid can be User and System apps.
*
* @param bundleId The bundleId to ensure it is installed
* @returns Returns True if the app is installed on the device under test.
*/
async isAppInstalled(bundleId: string): Promise<boolean> {
if (isPreferDevicectlEnabled()) {
return _.size(await this.devicectl.listApps(bundleId)) > 0;
}
return Boolean(await this.fetchAppInfo(bundleId));
}
/**
* Fetches various attributes, like bundle id, version, entitlements etc. of
* an installed application.
*
* @param bundleId the bundle identifier of an app to check
* @param returnAttributes If provided then
* only fetches the requested attributes of the app into the resulting object.
* Some apps may have too many attributes, so it makes sense to limit these
* by default if you don't need all of them.
* @returns Either app info as an object or undefined if the app is not found.
*/
async fetchAppInfo(
bundleId: string,
returnAttributes: string | string[] = ['CFBundleIdentifier', 'CFBundleVersion']
): Promise<Record<string, any> | undefined> {
const service = await services.startInstallationProxyService(this.udid);
try {
return (
await service.lookupApplications({
bundleIds: bundleId,
// https://github.com/appium/appium/issues/18753
returnAttributes,
})
)[bundleId];
} finally {
service.close();
}
}
async terminateApp(bundleId: string, platformVersion: string): Promise<boolean> {
let instrumentService: any;
let installProxyService: any;
try {
installProxyService = await services.startInstallationProxyService(this.udid);
const apps = await installProxyService.listApplications({
returnAttributes: ['CFBundleIdentifier', 'CFBundleExecutable']
});
if (!apps[bundleId]) {
this.log.info(`The bundle id '${bundleId}' did not exist`);
return false;
}
const executableName = apps[bundleId].CFBundleExecutable;
this.log.debug(`The executable name for the bundle id '${bundleId}' was '${executableName}'`);
// 'devicectl' has overhead (generally?) than the instrument service via appium-ios-device,
// so hre uses the 'devicectl' only for iOS 17+.
if (util.compareVersions(platformVersion, '>=', '17.0')) {
this.log.debug(`Calling devicectl to kill the process`);
const pids = (await this.devicectl.listProcesses())
.filter(({executable}) => executable.endsWith(`/${executableName}`))
.map(({processIdentifier}) => processIdentifier);
if (_.isEmpty(pids)) {
this.log.info(`The process of the bundle id '${bundleId}' was not running`);
return false;
}
await this.devicectl.sendSignalToProcess(pids[0], 2);
} else {
instrumentService = await services.startInstrumentService(this.udid);
// The result of "runningProcesses" includes `bundle_id` key in iOS 16+ (possibly a specific 16.x+)
// then here may not be necessary to find a process with `CFBundleExecutable`
// after dropping older iOS version support.
const processes = await instrumentService.callChannel(
INSTRUMENT_CHANNEL.DEVICE_INFO,
'runningProcesses',
);
const process = processes.selector.find((process: any) => process.name === executableName);
if (!process) {
this.log.info(`The process of the bundle id '${bundleId}' was not running`);
return false;
}
await instrumentService.callChannel(
INSTRUMENT_CHANNEL.PROCESS_CONTROL,
'killPid:',
`${process.pid}`,
);
}
} catch (err) {
this.log.warn(`Failed to kill '${bundleId}'. Original error: ${(err as any).stderr || (err as Error).message}`);
return false;
} finally {
if (installProxyService) {
installProxyService.close();
}
if (instrumentService) {
instrumentService.close();
}
}
return true;
}
/**
* @param bundleName The name of CFBundleName in Info.plist
*
* @returns A list of User level apps' bundle ids which has
* 'CFBundleName' attribute as 'bundleName'.
*/
async getUserInstalledBundleIdsByBundleName(bundleName: string): Promise<string[]> {
const service = await services.startInstallationProxyService(this.udid);
try {
const applications = await service.listApplications({
applicationType: 'User', returnAttributes: ['CFBundleIdentifier', 'CFBundleName']
});
return _.reduce(
applications,
(acc: string[], {CFBundleName}, key: string) => {
if (CFBundleName === bundleName) {
acc.push(key);
}
return acc;
},
[],
);
} finally {
service.close();
}
}
async getPlatformVersion(): Promise<string> {
return await utilities.getOSVersion(this.udid);
}
async reset(opts: {bundleId?: string; fullReset?: boolean}): Promise<void> {
const {bundleId, fullReset} = opts;
if (!bundleId || !fullReset || bundleId === SAFARI_BUNDLE_ID) {
// Safari cannot be removed as system app.
// Safari process handling will be managed by WDA
// with noReset, forceAppLaunch or shouldTerminateApp capabilities.
return;
}
this.log.debug(`Reset: fullReset requested. Will try to uninstall the app '${bundleId}'.`);
if (!(await this.isAppInstalled(bundleId))) {
this.log.debug('Reset: app not installed. No need to uninstall');
return;
}
try {
await this.remove(bundleId);
} catch (err) {
this.log.error(`Reset: could not remove '${bundleId}' from device: ${(err as Error).message}`);
throw err;
}
this.log.debug(`Reset: removed '${bundleId}'`);
}
}
//#endregion
//#region Public Device Management Functions
/**
* Install app to real device
*/
export async function installToRealDevice(
this: XCUITestDriver,
app: string,
bundleId?: string,
opts: ManagementInstallOptions = {}
): Promise<void> {
const device = this.device as RealDevice;
if (!device.udid || !app || !bundleId) {
this.log.debug('No device id, app or bundle id, not installing to real device.');
return;
}
const {
skipUninstall,
timeout = DEFAULT_APP_INSTALLATION_TIMEOUT_MS,
} = opts;
if (!skipUninstall) {
this.log.info(`Reset requested. Removing app with id '${bundleId}' from the device`);
await device.remove(bundleId);
}
this.log.debug(`Installing '${app}' on the device with UUID '${device.udid}'`);
try {
await device.install(app, bundleId, {
timeoutMs: timeout,
});
this.log.debug('The app has been installed successfully.');
} catch (e) {
// Want to clarify the device's application installation state in this situation.
if (!skipUninstall || !(e as Error).message.includes('MismatchedApplicationIdentifierEntitlement')) {
// Other error cases that could not be recoverable by here.
// Exact error will be in the log.
// We cannot recover 'ApplicationVerificationFailed' situation since this reason is clearly the app's provisioning profile was invalid.
// [XCUITest] Error installing app '/path/to.app': Unexpected data: {"Error":"ApplicationVerificationFailed","ErrorDetail":-402620395,"ErrorDescription":"Failed to verify code signature of /path/to.app : 0xe8008015 (A valid provisioning profile for this executable was not found.)"}
throw e;
}
// If the error was by below error case, we could recover the situation
// by uninstalling the device's app bundle id explicitly regard less the app exists on the device or not (e.g. offload app).
// [XCUITest] Error installing app '/path/to.app': Unexpected data: {"Error":"MismatchedApplicationIdentifierEntitlement","ErrorDescription":"Upgrade's application-identifier entitlement string (TEAM_ID.com.kazucocoa.example) does not match installed application's application-identifier string (ANOTHER_TEAM_ID.com.kazucocoa.example); rejecting upgrade."}
this.log.info(`The application identified by '${bundleId}' cannot be installed because it might ` +
`be already cached on the device, probably with a different signature. ` +
`Will try to remove it and install a new copy. Original error: ${(e as Error).message}`);
await device.remove(bundleId);
await device.install(app, bundleId, {
timeoutMs: timeout,
});
this.log.debug('The app has been installed after one retrial.');
}
}
/**
* Run real device reset
*/
export async function runRealDeviceReset(this: XCUITestDriver): Promise<void> {
if (!this.opts.noReset || this.opts.fullReset) {
this.log.debug('Reset: running ios real device reset flow');
if (!this.opts.noReset) {
await (this.device as RealDevice).reset(this.opts);
}
} else {
this.log.debug('Reset: fullReset not set. Leaving as is');
}
}
/**
* Configures Safari startup options based on the given session capabilities.
*
* !!! This method mutates driver options.
*
* @returns true if process arguments have been modified
*/
export function applySafariStartupArgs(this: XCUITestDriver): boolean {
const prefs = buildSafariPreferences(this.opts);
if (_.isEmpty(prefs)) {
return false;
}
const args = _.toPairs(prefs)
.flatMap(([key, value]) => [_.startsWith(key, '-') ? key : `-${key}`, String(value)]);
defaultLogger.debug(`Generated Safari command line arguments: ${args.join(' ')}`);
const processArguments = this.opts.processArguments as {args: string[]} | undefined;
if (processArguments && _.isPlainObject(processArguments)) {
processArguments.args = [...(processArguments.args ?? []), ...args];
} else {
this.opts.processArguments = {args};
}
return true;
}
/**
* Auto-detect device UDID
*/
export async function detectUdid(this: XCUITestDriver): Promise<string> {
this.log.debug('Auto-detecting real device udid...');
const udids = await getConnectedDevices();
if (_.isEmpty(udids)) {
throw new Error('No real devices are connected to the host');
}
const udid = udids[udids.length - 1];
if (udids.length > 1) {
this.log.info(`Multiple devices found: ${udids.join(', ')}`);
this.log.info(`Choosing '${udid}'. Consider settings the 'udid' capability if another device must be selected`);
}
this.log.debug(`Detected real device udid: '${udid}'`);
return udid;
}
//#endregion
//#region Private Helper Functions
/**
* If the environment variable enables APPIUM_XCUITEST_PREFER_DEVICECTL.
* This is a workaround for wireless tvOS.
* @returns True if the APPIUM_XCUITEST_PREFER_DEVICECTL is set.
*/
function isPreferDevicectlEnabled(): boolean {
return ['yes', 'true', '1'].includes(_.toLower(process.env.APPIUM_XCUITEST_PREFER_DEVICECTL));
};
/**
* Checks a presence of a local folder.
*
* @param folderPath Full path to the local folder
* @returns True if the folder exists and is actually a folder
*/
async function folderExists(folderPath: string): Promise<boolean> {
try {
return (await fs.stat(folderPath)).isDirectory();
} catch {
return false;
}
}
/**
* Creates remote folder path recursively. Noop if the given path
* already exists
*
* @param afcService Apple File Client service instance from
* 'appium-ios-device' module
* @param remoteRoot The relative path to the remote folder structure
* to be created
*/
async function remoteMkdirp(afcService: any, remoteRoot: string): Promise<void> {
if (remoteRoot === '.' || remoteRoot === '/') {
return;
}
try {
await afcService.listDirectory(remoteRoot);
return;
} catch {
// This means that the directory is missing and we got an object not found error.
// Therefore, we are going to the parent
await remoteMkdirp(afcService, path.dirname(remoteRoot));
}
await afcService.createDirectory(remoteRoot);
}
//#endregion
//#region Type Definitions
export interface PushFileOptions {
/** The maximum count of milliceconds to wait until file push is completed. Cannot be lower than 60000ms */
timeoutMs?: number;
}
export interface PushFolderOptions {
/** The maximum timeout to wait until a single file is copied */
timeoutMs?: number;
/** Whether to push files in parallel. This usually gives better performance, but might sometimes be less stable. */
enableParallelPush?: boolean;
}
export interface RealDeviceInstallOptions {
/** Application installation timeout in milliseconds */
timeoutMs?: number;
}
export interface InstallOrUpgradeOptions {
/** Install/upgrade timeout in milliseconds */
timeout: number;
/** Whether it is an app upgrade or a new install */
isUpgrade: boolean;
}
export interface ManagementInstallOptions {
/** Whether to skip app uninstall before installing it */
skipUninstall?: boolean;
/** App install timeout */
timeout?: number;
/** Whether to enforce the app uninstallation. e.g. fullReset, or enforceAppInstall is true */
shouldEnforceUninstall?: boolean;
}
//#endregion