UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

292 lines (268 loc) 9.81 kB
import {exec, SubProcess} from 'teen_process'; import {util} from 'appium/support'; import _ from 'lodash'; const XCRUN = 'xcrun'; /** * @typedef {Object} ProcessInfo * @property {number} processIdentifier * @property {string} executable */ /* Example: { "executable" : "file:///sbin/launchd", "processIdentifier" : 1 }, */ /** * @typedef {Object} AppInfo * @property {boolean} appClip * @property {boolean} builtByDeveloper * @property {string} bundleIdentifier * @property {string} bundleVersion * @property {boolean} defaultApp * @property {boolean} hidden * @property {boolean} internalApp * @property {string} name * @property {boolean} removable * @property {string} url * @property {string} version */ /* Example: { "appClip" : false, "builtByDeveloper" : false, "bundleIdentifier" : "com.apple.mobilesafari", "bundleVersion" : "8617.1.17.10.9", "defaultApp" : true, "hidden" : false, "internalApp" : false, "name" : "Safari", "removable" : false, "url" : "file:///Applications/MobileSafari.app/", "version" : "17.2" } */ /** * @typedef {Object} ExecuteOptions * @property {boolean} [logStdout=false] * @property {boolean} [asJson=true] * @property {boolean} [asynchronous=false] * @property {string[]|string} [subcommandOptions] * @property {number} [timeout] */ /** * @typedef {{asynchronous: true}} TAsyncOpts */ /** * @typedef {Object} ListFilesOptions * @property {string} [username] The username of the user we should target. Only relevant for certain domains. * @property {string} [subdirectory] A subdirectory within the domain. If not specified, defaults to the root. */ /** * @typedef {Object} PullFileOptions * @property {string} [username] The username of the user we should target. Only relevant for certain domains. * @property {string} domainType The file service domain. Valid values are: temporary, rootStaging, appDataContainer, appGroupDataContainer, * systemCrashLogs. You must specify a valid domain and identifier pair. Each domain is accompanied by an identifier * that provides additional context. For example, if the domain is an app data container, the identifier is the bundle * ID of the app. For temporary directories and root staging areas, the identifier is a unique client-provided string * which is used to get your own space, separate from those of other clients. * @property {string} domainIdentifier A unique string used to provide additional context to the domain. * @property {number} [timeout=120000] The timeout for pulling a file in milliseconds. */ /** * An option for launchApp method by devicectl. * @typedef {Object} LaunchAppOptions * @property {import('@appium/types').StringRecord<string|number>} [env] Bundle id to Environment variables for the launching app process. * @property {boolean} [terminateExisting=false] Whether terminating the already running app. */ export class Devicectl { /** * @since Xcode 15, iOS 17 * @param {string} udid * @param {import('@appium/types').AppiumLogger} log */ constructor(udid, log) { this.udid = udid; this.log = log; } /** * @template {ExecuteOptions} TExecOpts * @param {string[]} subcommand * @param {TExecOpts} [opts] * @return {Promise<TExecOpts extends TAsyncOpts ? import('teen_process').SubProcess : import('teen_process').TeenProcessExecResult>} */ async execute(subcommand, opts) { const { logStdout = false, asynchronous = false, asJson = true, subcommandOptions, timeout, } = opts ?? {}; const finalArgs = [ 'devicectl', ...subcommand, '--device', this.udid, ]; if (subcommandOptions && !_.isEmpty(subcommandOptions)) { finalArgs.push( ...(Array.isArray(subcommandOptions) ? subcommandOptions : [subcommandOptions]) ); } if (asJson) { finalArgs.push('--quiet', '--json-output', '-'); } const cmdStr = util.quote([XCRUN, ...finalArgs]); this.log.debug(`Executing ${cmdStr}`); try { if (asynchronous) { const result = new SubProcess(XCRUN, finalArgs); await result.start(0); // @ts-ignore TS does not understand it return result; } const result = await exec( XCRUN, finalArgs, ...(_.isNumber(timeout) ? [{timeout}] : []), ); if (logStdout) { this.log.debug(`Command output: ${result.stdout}`); } // @ts-ignore TS does not understand it return result; } catch (e) { throw new Error(`'${cmdStr}' failed. Original error: ${e.stderr || e.stdout || e.message}`); } } /** * Simulates memory warning for the process with the given PID * * @param {number|string} pid The process identifier to simulate the Low Memory warning for * @return {Promise<void>} */ async sendMemoryWarning(pid) { await this.execute(['device', 'process', 'sendMemoryWarning'], { subcommandOptions: ['--pid', `${pid}`] }); } /** * Lists running processes on the device * * @returns {Promise<ProcessInfo[]>} */ async listProcesses() { const {stdout} = await this.execute(['device', 'info', 'processes']); return JSON.parse(stdout).result.runningProcesses; } /** * Lists files at a specified path on the device * * @param {string} domainType The file service domain. Valid values are: temporary, rootStaging, appDataContainer, appGroupDataContainer, * systemCrashLogs. You must specify a valid domain and identifier pair. Each domain is accompanied by an identifier * that provides additional context. For example, if the domain is an app data container, the identifier is the bundle * ID of the app. For temporary directories and root staging areas, the identifier is a unique client-provided string * which is used to get your own space, separate from those of other clients. * @param {string} domainIdentifier A unique string used to provide additional context to the domain. * @param {ListFilesOptions} [opts={}] * @returns {Promise<string[]>} List of file names (could be empty) */ async listFiles(domainType, domainIdentifier, opts = {}) { const subcommandOptions = [ '--domain-type', domainType, '--domain-identifier', domainIdentifier, ]; if (opts.username) { subcommandOptions.push('--username', opts.username); } if (opts.subdirectory) { subcommandOptions.push('--subdirectory', opts.subdirectory); } const {stdout} = await this.execute(['device', 'info', 'files'], { subcommandOptions, }); return JSON.parse(stdout).result.files.map(({name}) => name); } /** * Pulls a file from the specified path on the device to a local file system * * @param {string} from The item which should be copied. * @param {string} to The location to which the item should be copied. * @param {PullFileOptions} opts * @returns {Promise<string>} The destination path (same as `to`) */ async pullFile(from, to, opts) { const subcommandOptions = [ '--domain-type', opts.domainType, '--domain-identifier', opts.domainIdentifier, '--source', from, '--destination', to, ]; if (opts.username) { subcommandOptions.push('--user', opts.username); } await this.execute(['device', 'copy', 'from'], { subcommandOptions, timeout: opts.timeout ?? 120000, asJson: false, }); return to; } /** * Send POSIX signal to the running process * * @param {number|string} pid The process identifier to send a signal to * @param {number|string} signal The signal to send to a process. See 'man signal' for a list of signals * @returns {Promise<void>} */ async sendSignalToProcess(pid, signal) { await this.execute(['device', 'process', 'signal'], { subcommandOptions: ['--signal', `${signal}`, '--pid', `${pid}`] }); } /** * Retrieves the list of installed apps from the device * * @param {string?} [bundleId=null] Provide the target bundle identifier * to speed up the lookup. * @returns {Promise<AppInfo[]>} Empty array is returned if no matching apps are found */ async listApps(bundleId = null) { const subcommandOptions = ['--include-all-apps']; if (bundleId) { subcommandOptions.push('--bundle-id', bundleId); } const {stdout} = await this.execute(['device', 'info', 'apps'], { subcommandOptions, }); return JSON.parse(stdout).result.apps; } /** * Launch the given bundle id application with the given environment variable. * This method is over devicectl command, this it may take additional seconds to launch the app. * Please use via WDA or via appium-ios-device as primary method to launch app if possible. * * @param {string} bundleId Bundle id to launch. * @param {LaunchAppOptions} opts launching app with devicectl command options. * @returns {Promise<void>} * @throws {Error} If the launching app command fails. For example, the given bundle id did not exist. */ async launchApp(bundleId, opts) { const { env, terminateExisting = false } = opts; const subcommandOptions = []; if (terminateExisting) { subcommandOptions.push('--terminate-existing'); }; if (!_.isEmpty(env)) { subcommandOptions.push('--environment-variables', JSON.stringify(_.mapValues(env, (v) => _.toString(v)))); }; // The bundle id should be the last to apply arguments properly. // devicectl command might not raise exception while the order is wrong. subcommandOptions.push(bundleId); await this.execute(['device', 'process', 'launch'], { subcommandOptions, asJson: false}); } }