UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

343 lines (318 loc) 11.5 kB
import {timing, util, fs} from 'appium/support'; import path from 'path'; import {services, utilities, INSTRUMENT_CHANNEL} from 'appium-ios-device'; import B, {TimeoutError} from 'bluebird'; import defaultLogger from './logger'; import _ from 'lodash'; import {SAFARI_BUNDLE_ID} from './app-utils'; import {pushFile, pushFolder, IO_TIMEOUT_MS} from './ios-fs-helpers'; import { Devicectl } from './real-device-clients/devicectl'; const APPLICATION_INSTALLED_NOTIFICATION = 'com.apple.mobile.application_installed'; const APPLICATION_NOTIFICATION_TIMEOUT_MS = 30 * 1000; const INSTALLATION_STAGING_DIR = 'PublicStaging'; /** * @returns {Promise<string[]>} */ export async function getConnectedDevices() { return await utilities.getConnectedDevices(); } /** * @typedef {Object} InstallOptions * @param {number} [timeoutMs=240000] Application installation timeout in milliseconds */ /** * @typedef {Object} InstallOrUpgradeOptions * @property {number} timeout Install/upgrade timeout in milliseconds * @property {boolean} isUpgrade Whether it is an app upgrade or a new install */ export class RealDevice { /** * @param {string} udid * @param {import('@appium/types').AppiumLogger} [logger] */ constructor(udid, logger) { this.udid = udid; this._log = logger ?? defaultLogger; this.devicectl = new Devicectl(this.udid, this._log); } /** * @returns {import('@appium/types').AppiumLogger} */ get log() { return this._log; } /** * @param {string} bundleId */ async remove(bundleId) { const service = await services.startInstallationProxyService(this.udid); try { await service.uninstallApplication(bundleId); } finally { service.close(); } } /** * @param {string} bundleId */ async removeApp(bundleId) { await this.remove(bundleId); } /** * * @param {string} appPath * @param {string} bundleId * @param {InstallOptions} [opts={}] */ async install(appPath, bundleId, opts = {}) { const { timeoutMs = IO_TIMEOUT_MS, } = opts; const timer = new timing.Timer().start(); const afcService = await services.startAfcService(this.udid); try { let bundlePathOnPhone; 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.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.message}`; throw new Error(errMessage); } finally { afcService.close(); } this.log.info( `The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms` ); } /** * @param {string} bundlePathOnPhone * @param {InstallOrUpgradeOptions} opts */ async installOrUpgradeApplication(bundlePathOnPhone, {isUpgrade, timeout}) { const notificationService = await services.startNotificationProxyService(this.udid); const installationService = await services.startInstallationProxyService(this.udid); const appInstalledNotification = new B((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.message); } } finally { installationService.close(); notificationService.close(); } } /** * Alias for {@linkcode install} * @param {string} appPath * @param {string} bundleId * @param {InstallOptions} [opts={}] */ async installApp(appPath, bundleId, opts = {}) { 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 {string} bundleId The bundleId to ensure it is installed * @return {Promise<boolean>} Returns True if the app is installed * on the device under test. */ async isAppInstalled(bundleId) { return Boolean(await this.fetchAppInfo(bundleId)); } /** * Fetches various attributes, like bundle id, version, entitlements etc. of * an installed application. * * @param {string} bundleId the bundle identifier of an app to check * @param {string|string[]|undefined} 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 {Promise<Object|undefined>} Either app info as an object or undefined * if the app is not found. */ async fetchAppInfo(bundleId, returnAttributes = ['CFBundleIdentifier', 'CFBundleVersion']) { 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(); } } /** * @param {string} bundleId * @param {string} platformVersion * @returns {Promise<boolean>} */ async terminateApp(bundleId, platformVersion) { let instrumentService; let installProxyService; 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) => 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.stderr || err.message}`); return false; } finally { if (installProxyService) { installProxyService.close(); } if (instrumentService) { instrumentService.close(); } } return true; } /** * @param {string} bundleName The name of CFBundleName in Info.plist * * @returns {Promise<string[]>} A list of User level apps' bundle ids which has * 'CFBundleName' attribute as 'bundleName'. */ async getUserInstalledBundleIdsByBundleName(bundleName) { const service = await services.startInstallationProxyService(this.udid); try { const applications = await service.listApplications({ applicationType: 'User', returnAttributes: ['CFBundleIdentifier', 'CFBundleName'] }); return _.reduce( applications, (acc, {CFBundleName}, key) => { if (CFBundleName === bundleName) { acc.push(key); } return acc; }, /** @type {string[]} */ ([]), ); } finally { service.close(); } } /** * @returns {Promise<string>} */ async getPlatformVersion() { return await utilities.getOSVersion(this.udid); } /** * @param {import('./driver').XCUITestDriverOpts} opts * @returns {Promise<void>} */ async reset({bundleId, fullReset}) { 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.message}`); throw err; } this.log.debug(`Reset: removed '${bundleId}'`); } } export default RealDevice;