UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

546 lines (545 loc) 24.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RealDevice = exports.MAX_IO_CHUNK_SIZE = exports.IO_TIMEOUT_MS = void 0; exports.pullFile = pullFile; exports.pullFolder = pullFolder; exports.pushFile = pushFile; exports.pushFolder = pushFolder; exports.getConnectedDevices = getConnectedDevices; exports.installToRealDevice = installToRealDevice; exports.runRealDeviceReset = runRealDeviceReset; exports.applySafariStartupArgs = applySafariStartupArgs; exports.detectUdid = detectUdid; const lodash_1 = __importDefault(require("lodash")); const support_1 = require("appium/support"); const asyncbox_1 = require("asyncbox"); const node_path_1 = __importDefault(require("node:path")); const utils_1 = require("../utils"); const logger_1 = require("../logger"); const node_devicectl_1 = require("node-devicectl"); const afc_client_1 = require("./afc-client"); const connected_devices_client_1 = require("./connected-devices-client"); const installation_proxy_client_1 = require("./installation-proxy-client"); const notification_client_1 = require("./notification-client"); const lockdown_client_1 = require("./lockdown-client"); const app_termination_client_1 = require("./app-termination-client"); const DEFAULT_APP_INSTALLATION_TIMEOUT_MS = 8 * 60 * 1000; exports.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 exports.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'; class RealDevice { udid; devicectl; driverOpts; _log; constructor(udid, driverOpts, logger) { this.udid = udid; this.driverOpts = driverOpts; this._log = logger ?? logger_1.log; this.devicectl = new node_devicectl_1.Devicectl(this.udid); } get log() { return this._log; } async remove(bundleId) { const useRemoteXPC = (0, utils_1.isIos18OrNewer)(this.driverOpts); const client = await installation_proxy_client_1.InstallationProxyClient.create(this.udid, useRemoteXPC); try { await client.uninstallApplication(bundleId); } finally { await client.close(); } } async removeApp(bundleId) { await this.remove(bundleId); } async install(appPath, bundleId, opts = {}) { const { timeoutMs = exports.IO_TIMEOUT_MS } = opts; const timer = new support_1.timing.Timer().start(); const useRemoteXPC = (0, utils_1.isIos18OrNewer)(this.driverOpts); const afcClient = await afc_client_1.AfcClient.createForDevice(this.udid, useRemoteXPC); try { let bundlePathOnPhone; if ((await support_1.fs.stat(appPath)).isFile()) { // https://github.com/doronz88/pymobiledevice3/blob/6ff5001f5776e03b610363254e82d7fbcad4ef5f/pymobiledevice3/services/installation_proxy.py#L75 bundlePathOnPhone = `/${node_path_1.default.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.stack); let errMessage = `Cannot install the ${bundleId} application`; if (err instanceof utils_1.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, { cause: err }); } finally { await afcClient.close(); } this.log.info(`The installation of '${bundleId}' succeeded after ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } async installOrUpgradeApplication(bundlePathOnPhone, opts) { const { isUpgrade, timeout } = opts; const useRemoteXPC = (0, utils_1.isIos18OrNewer)(this.driverOpts); const notificationClient = await notification_client_1.NotificationClient.create(this.udid, this.log, useRemoteXPC); const installationClient = await installation_proxy_client_1.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 (0, utils_1.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.message); } } finally { await installationClient.close(); await notificationClient.close(); } } /** * Alias for {@linkcode install} */ 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 bundleId The bundleId to ensure it is installed * @returns Returns True if the app is installed on the device under test. */ async isAppInstalled(bundleId) { if (isPreferDevicectlEnabled()) { return lodash_1.default.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, returnAttributes = ['CFBundleIdentifier', 'CFBundleVersion']) { const useRemoteXPC = (0, utils_1.isIos18OrNewer)(this.driverOpts); const client = await installation_proxy_client_1.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) { const platformVersion = this.driverOpts.platformVersion ?? (await this.getPlatformVersion()); const terminationClient = new app_termination_client_1.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) { const useRemoteXPC = (0, utils_1.isIos18OrNewer)(this.driverOpts); const client = await installation_proxy_client_1.InstallationProxyClient.create(this.udid, useRemoteXPC); try { const applications = await client.listApplications({ applicationType: 'User', returnAttributes: ['CFBundleIdentifier', 'CFBundleName'], }); return lodash_1.default.reduce(applications, (acc, { CFBundleName }, key) => { if (CFBundleName === bundleName) { acc.push(key); } return acc; }, []); } finally { await client.close(); } } async getPlatformVersion() { const lockdown = await lockdown_client_1.LockdownClient.createForDevice(this.udid, this.driverOpts, this.log); try { return await lockdown.getOSVersion(); } finally { await lockdown.close(); } } async reset(opts) { const { bundleId, fullReset } = opts; if (!bundleId || !fullReset || bundleId === utils_1.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}'`); } } exports.RealDevice = RealDevice; /** * 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 */ async function pullFile(client, remotePath) { return await (0, utils_1.withTimeout)(client.getFileContents(remotePath), exports.IO_TIMEOUT_MS, `Timed out after ${exports.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 */ async function pullFolder(client, remoteRootPath) { const tmpFolder = await support_1.tempDir.openDir(); try { let localTopItem = null; let countFilesSuccess = 0; let countFolders = 0; await client.pull(remoteRootPath, tmpFolder, { recursive: true, overwrite: true, onEntry: async (remotePath, localPath, isDirectory) => { if (!localTopItem || localPath.split(node_path_1.default.sep).length < localTopItem.split(node_path_1.default.sep).length) { localTopItem = localPath; } if (isDirectory) { ++countFolders; } else { ++countFilesSuccess; } }, }); logger_1.log.info(`Pulled ${support_1.util.pluralize('file', countFilesSuccess, true)} and ${support_1.util.pluralize('folder', countFolders, true)} from '${remoteRootPath}'`); return await support_1.zip.toInMemoryZip(localTopItem ? node_path_1.default.dirname(localTopItem) : tmpFolder, { encodeToBase64: true, }); } finally { await support_1.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 */ async function pushFile(client, localPathOrPayload, remotePath, opts = {}) { const { timeoutMs = exports.IO_TIMEOUT_MS } = opts; const timer = new support_1.timing.Timer().start(); await remoteMkdirp(client, node_path_1.default.dirname(remotePath)); // AfcClient handles the branching internally const pushPromise = Buffer.isBuffer(localPathOrPayload) ? client.setFileContents(remotePath, localPathOrPayload) : client.writeFromStream(remotePath, support_1.fs.createReadStream(localPathOrPayload, { autoClose: true })); // Wrap with timeout const actualTimeout = Math.max(timeoutMs, 60000); await (0, utils_1.withTimeout)(pushPromise, actualTimeout, `Timed out after ${actualTimeout}ms while pushing file to '${remotePath}'`); const fileSize = Buffer.isBuffer(localPathOrPayload) ? localPathOrPayload.length : (await support_1.fs.stat(localPathOrPayload)).size; logger_1.log.debug(`Successfully pushed the file payload (${support_1.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 */ async function pushFolder(client, srcRootPath, dstRootPath, opts = {}) { const { timeoutMs = exports.IO_TIMEOUT_MS, enableParallelPush = false } = opts; const timer = new support_1.timing.Timer().start(); const allItems = /** @type {import('path-scurry').Path[]} */ /** @type {unknown} */ (await support_1.fs.glob('**', { cwd: srcRootPath, withFileTypes: true, })); logger_1.log.debug(`Successfully scanned the tree structure of '${srcRootPath}'`); // top-level folders go first const foldersToPush = allItems .filter((x) => x.isDirectory()) .map((x) => x.relative()) .sort((a, b) => a.split(node_path_1.default.sep).length - b.split(node_path_1.default.sep).length); // larger files go first const filesToPush = allItems .filter((x) => !x.isDirectory()) .sort((a, b) => (b.size ?? 0) - (a.size ?? 0)) .map((x) => x.relative()); logger_1.log.debug(`Got ${support_1.util.pluralize('folder', foldersToPush.length, true)} and ` + `${support_1.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 = lodash_1.default.trimEnd(node_path_1.default.join(dstRootPath, relativeFolderPath), node_path_1.default.sep); if (absoluteFolderPath) { await client.createDirectory(absoluteFolderPath); } } // do not forget about the root folder logger_1.log.debug(`Successfully created the remote folder structure ` + `(${support_1.util.pluralize('item', foldersToPush.length + 1, true)})`); const _pushFile = async (relativePath) => { const absoluteSourcePath = node_path_1.default.join(srcRootPath, relativePath); const readStream = support_1.fs.createReadStream(absoluteSourcePath, { autoClose: true }); const absoluteDestinationPath = node_path_1.default.join(dstRootPath, relativePath); const pushPromise = client.writeFromStream(absoluteDestinationPath, readStream); const actualTimeout = Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000); await (0, utils_1.withTimeout)(pushPromise, actualTimeout, `Timed out after ${actualTimeout}ms while pushing '${relativePath}' to '${absoluteDestinationPath}'`); }; if (enableParallelPush) { logger_1.log.debug(`Proceeding to parallel files push (max ${exports.MAX_IO_CHUNK_SIZE} writers)`); await (0, utils_1.withTimeout)((0, asyncbox_1.asyncmap)(filesToPush, async (relativeFilePath) => { await _pushFile(relativeFilePath); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new utils_1.TimeoutError(`Timed out after ${elapsedMs} ms`); } }, { concurrency: exports.MAX_IO_CHUNK_SIZE }), Math.max(timeoutMs - timer.getDuration().asMilliSeconds, 60000)); } else { logger_1.log.debug(`Proceeding to serial files push`); for (const relativeFilePath of filesToPush) { await _pushFile(relativeFilePath); const elapsedMs = timer.getDuration().asMilliSeconds; if (elapsedMs > timeoutMs) { throw new utils_1.TimeoutError(`Timed out after ${elapsedMs} ms`); } } } logger_1.log.debug(`Successfully pushed ${support_1.util.pluralize('folder', foldersToPush.length, true)} ` + `and ${support_1.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. */ async function getConnectedDevices(opts) { const client = await connected_devices_client_1.ConnectedDevicesClient.create(opts); return await client.getConnectedDevices(); } /** * Install app to real device */ async function installToRealDevice(app, bundleId, opts = {}) { const device = this.device; 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.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.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 */ async function runRealDeviceReset() { if (!this.opts.noReset || this.opts.fullReset) { this.log.debug('Reset: running ios real device reset flow'); if (!this.opts.noReset) { await this.device.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 */ function applySafariStartupArgs() { const prefs = (0, utils_1.buildSafariPreferences)(this.opts); if (lodash_1.default.isEmpty(prefs)) { return false; } const args = lodash_1.default.toPairs(prefs).flatMap(([key, value]) => [ lodash_1.default.startsWith(key, '-') ? key : `-${key}`, String(value), ]); logger_1.log.debug(`Generated Safari command line arguments: ${args.join(' ')}`); const processArguments = this.opts.processArguments; if (processArguments && lodash_1.default.isPlainObject(processArguments)) { processArguments.args = [...(processArguments.args ?? []), ...args]; } else { this.opts.processArguments = { args }; } return true; } /** * Auto-detect device UDID */ async function detectUdid() { this.log.debug('Auto-detecting real device udid...'); const udids = await getConnectedDevices(this.opts); if (lodash_1.default.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() { return ['yes', 'true', '1'].includes(lodash_1.default.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, remoteRoot) { if (remoteRoot === '.' || remoteRoot === '/') { return; } try { await client.listDirectory(remoteRoot); return; } catch { // Directory is missing, create parent first await remoteMkdirp(client, node_path_1.default.dirname(remoteRoot)); } await client.createDirectory(remoteRoot); } // #endregion Private Helper Functions //# sourceMappingURL=real-device-management.js.map