appium-xcuitest-driver
Version:
Appium driver for iOS using XCUITest for backend
546 lines (545 loc) • 24.9 kB
JavaScript
;
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