UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

701 lines (643 loc) 24.7 kB
import _ from 'lodash'; import {fs, tempDir, zip, util, timing} from 'appium/support'; import {asyncmap} from 'asyncbox'; import path from 'node:path'; import { buildSafariPreferences, SAFARI_BUNDLE_ID, isIos18OrNewer, withTimeout, TimeoutError, } from '../utils'; import {log as defaultLogger} from '../logger'; import {Devicectl} from 'node-devicectl'; import type {AppiumLogger} from '@appium/types'; import type {XCUITestDriver, XCUITestDriverOpts} from '../driver'; import {AfcClient} from './afc-client'; import {ConnectedDevicesClient} from './connected-devices-client'; import {InstallationProxyClient} from './installation-proxy-client'; import {NotificationClient} from './notification-client'; import {LockdownClient} from './lockdown-client'; import {AppTerminationClient} from './app-termination-client'; 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 export 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'; export interface PushFileOptions { /** The maximum count of milliseconds 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; } export class RealDevice { readonly udid: string; readonly devicectl: Devicectl; readonly driverOpts: XCUITestDriverOpts; private readonly _log: AppiumLogger; constructor(udid: string, driverOpts: XCUITestDriverOpts, logger?: AppiumLogger) { this.udid = udid; this.driverOpts = driverOpts; this._log = logger ?? defaultLogger; this.devicectl = new Devicectl(this.udid); } get log(): AppiumLogger { return this._log; } async remove(bundleId: string): Promise<void> { const useRemoteXPC = isIos18OrNewer(this.driverOpts); const client = await InstallationProxyClient.create(this.udid, useRemoteXPC); try { await client.uninstallApplication(bundleId); } finally { await client.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 useRemoteXPC = isIos18OrNewer(this.driverOpts); const afcClient = await AfcClient.createForDevice(this.udid, useRemoteXPC); 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(afcClient, appPath, bundlePathOnPhone, { timeoutMs, }); } else { bundlePathOnPhone = `${INSTALLATION_STAGING_DIR}/${bundleId}`; await pushFolder(afcClient, 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, {cause: err}); } finally { await afcClient.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 useRemoteXPC = isIos18OrNewer(this.driverOpts); const notificationClient = await NotificationClient.create(this.udid, this.log, useRemoteXPC); const installationClient = await InstallationProxyClient.create(this.udid, useRemoteXPC); const appInstalledNotification = notificationClient.observeNotification( APPLICATION_INSTALLED_NOTIFICATION, ); 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 installationClient.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 installationClient.installApplication(bundlePathOnPhone, clientOptions, timeout); } try { await withTimeout( appInstalledNotification, 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 { await installationClient.close(); await notificationClient.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 useRemoteXPC = isIos18OrNewer(this.driverOpts); const client = await InstallationProxyClient.create(this.udid, useRemoteXPC); try { return ( await client.lookupApplications({ bundleIds: bundleId, // https://github.com/appium/appium/issues/18753 returnAttributes: Array.isArray(returnAttributes) ? returnAttributes : [returnAttributes], }) )[bundleId]; } finally { await client.close(); } } /** * Terminates the application with the given bundle identifier on the real device. * On iOS 18+ uses RemoteXPC DVT processControl first; on connection/execution error * falls back to the legacy path (InstallationProxy + devicectl). On older iOS uses * the legacy path only. * * @param bundleId - Bundle identifier of the app to terminate * @returns `true` if the app was running and was terminated, `false` otherwise */ async terminateApp(bundleId: string): Promise<boolean> { const platformVersion = this.driverOpts.platformVersion ?? (await this.getPlatformVersion()); const terminationClient = new AppTerminationClient( this.udid, platformVersion, this.devicectl, this.log, ); return await terminationClient.terminate(bundleId); } /** * ! This method is used by appium-webdriveragent package * * @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 useRemoteXPC = isIos18OrNewer(this.driverOpts); const client = await InstallationProxyClient.create(this.udid, useRemoteXPC); try { const applications = await client.listApplications({ applicationType: 'User', returnAttributes: ['CFBundleIdentifier', 'CFBundleName'], }); return _.reduce( applications, (acc: string[], {CFBundleName}, key: string) => { if (CFBundleName === bundleName) { acc.push(key); } return acc; }, [], ); } finally { await client.close(); } } async getPlatformVersion(): Promise<string> { const lockdown = await LockdownClient.createForDevice(this.udid, this.driverOpts, this.log); try { return await lockdown.getOSVersion(); } finally { await lockdown.close(); } } 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}'`); } } /** * Retrieve a file from a real device * * @param client AFC client instance * @param remotePath Relative path to the file on the device * @returns The file content as a buffer */ export async function pullFile(client: AfcClient, remotePath: string): Promise<Buffer> { return await withTimeout( client.getFileContents(remotePath), IO_TIMEOUT_MS, `Timed out after ${IO_TIMEOUT_MS}ms while pulling file from '${remotePath}'`, ); } /** * Retrieve a folder from a real device * * @param client AFC client instance * @param remoteRootPath Relative path to the folder on the device * @returns The folder content as a zipped base64-encoded buffer */ export async function pullFolder(client: AfcClient, remoteRootPath: string): Promise<Buffer> { const tmpFolder = await tempDir.openDir(); try { let localTopItem: string | null = null; let countFilesSuccess = 0; let countFolders = 0; await client.pull(remoteRootPath, tmpFolder, { recursive: true, overwrite: true, onEntry: async (remotePath: string, localPath: string, isDirectory: boolean) => { if ( !localTopItem || localPath.split(path.sep).length < localTopItem.split(path.sep).length ) { localTopItem = localPath; } if (isDirectory) { ++countFolders; } else { ++countFilesSuccess; } }, }); defaultLogger.info( `Pulled ${util.pluralize('file', countFilesSuccess, true)} 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 client AFC client instance * @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( client: AfcClient, localPathOrPayload: string | Buffer, remotePath: string, opts: PushFileOptions = {}, ): Promise<void> { const {timeoutMs = IO_TIMEOUT_MS} = opts; const timer = new timing.Timer().start(); await remoteMkdirp(client, path.dirname(remotePath)); // AfcClient handles the branching internally const pushPromise = Buffer.isBuffer(localPathOrPayload) ? client.setFileContents(remotePath, localPathOrPayload) : client.writeFromStream( remotePath, fs.createReadStream(localPathOrPayload, {autoClose: true}), ); // Wrap with timeout const actualTimeout = Math.max(timeoutMs, 60000); await withTimeout( pushPromise, actualTimeout, `Timed out after ${actualTimeout}ms while pushing file to '${remotePath}'`, ); 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 client AFC client instance * @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( client: AfcClient, 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 try { await client.deleteDirectory(dstRootPath); } catch {} await client.createDirectory(dstRootPath); for (const relativeFolderPath of foldersToPush) { const absoluteFolderPath = _.trimEnd(path.join(dstRootPath, relativeFolderPath), path.sep); if (absoluteFolderPath) { await client.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 pushPromise = client.writeFromStream(absoluteDestinationPath, readStream); const actualTimeout = Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000); await withTimeout( pushPromise, actualTimeout, `Timed out after ${actualTimeout}ms while pushing '${relativePath}' to '${absoluteDestinationPath}'`, ); }; if (enableParallelPush) { defaultLogger.debug(`Proceeding to parallel files push (max ${MAX_IO_CHUNK_SIZE} writers)`); await withTimeout( asyncmap( filesToPush, async (relativeFilePath) => { await _pushFile(relativeFilePath); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new TimeoutError(`Timed out after ${elapsedMs} ms`); } }, {concurrency: MAX_IO_CHUNK_SIZE}, ), 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`, ); } /** * Get list of connected devices. * @param opts - Driver options; used to decide if tunnel registry is used. */ export async function getConnectedDevices(opts: XCUITestDriverOpts): Promise<string[]> { const client = await ConnectedDevicesClient.create(opts); return await client.getConnectedDevices(); } /** * 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(this.opts); 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; } // #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)); } /** * Creates remote folder path recursively. Noop if the given path * already exists * * @param client AFC client instance * @param remoteRoot The relative path to the remote folder structure * to be created */ async function remoteMkdirp(client: AfcClient, remoteRoot: string): Promise<void> { if (remoteRoot === '.' || remoteRoot === '/') { return; } try { await client.listDirectory(remoteRoot); return; } catch { // Directory is missing, create parent first await remoteMkdirp(client, path.dirname(remoteRoot)); } await client.createDirectory(remoteRoot); } // #endregion Private Helper Functions