UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

1,456 lines (1,293 loc) 80.4 kB
import IDB from 'appium-idb'; import {getSimulator} from 'appium-ios-simulator'; import {WebDriverAgent} from 'appium-webdriveragent'; import {BaseDriver, DeviceSettings, errors} from 'appium/driver'; import {fs, mjpeg, util, timing} from 'appium/support'; import AsyncLock from 'async-lock'; import {retryInterval} from 'asyncbox'; import B from 'bluebird'; import _ from 'lodash'; import {LRUCache} from 'lru-cache'; import EventEmitter from 'node:events'; import path from 'node:path'; import url from 'node:url'; import { SUPPORTED_EXTENSIONS, SAFARI_BUNDLE_ID, onPostConfigureApp, onDownloadApp, verifyApplicationPlatform, } from './app-utils'; import commands from './commands'; import {desiredCapConstraints} from './desired-caps'; import {DEVICE_CONNECTIONS_FACTORY} from './device-connections-factory'; import {executeMethodMap} from './execute-method-map'; import {newMethodMap} from './method-map'; import { Pyidevice } from './real-device-clients/py-ios-device-client'; import { installToRealDevice, runRealDeviceReset, applySafariStartupArgs, detectUdid, } from './real-device-management'; import { RealDevice, getConnectedDevices, } from './real-device'; import { createSim, getExistingSim, installToSimulator, runSimulatorReset, setLocalizationPrefs, setSafariPrefs, shutdownOtherSimulators, shutdownSimulator, } from './simulator-management'; import { DEFAULT_TIMEOUT_KEY, UDID_AUTO, checkAppPresent, clearSystemFiles, getAndCheckIosSdkVersion, getAndCheckXcodeVersion, getDriverInfo, isLocalHost, markSystemFilesForCleanup, normalizeCommandTimeouts, normalizePlatformVersion, printUser, removeAllSessionWebSocketHandlers, shouldSetInitialSafariUrl, translateDeviceName, } from './utils'; import { AppInfosCache } from './app-infos-cache'; import { notifyBiDiContextChange } from './commands/context'; const SHUTDOWN_OTHER_FEAT_NAME = 'shutdown_other_sims'; const CUSTOMIZE_RESULT_BUNDLE_PATH = 'customize_result_bundle_path'; const defaultServerCaps = { webStorageEnabled: false, locationContextEnabled: false, browserName: '', platform: 'MAC', javascriptEnabled: true, databaseEnabled: false, takesScreenshot: true, networkConnectionEnabled: false, }; const WDA_SIM_STARTUP_RETRIES = 2; const WDA_REAL_DEV_STARTUP_RETRIES = 1; const WDA_REAL_DEV_TUTORIAL_URL = 'https://appium.github.io/appium-xcuitest-driver/latest/preparation/real-device-config/'; const WDA_STARTUP_RETRY_INTERVAL = 10000; const DEFAULT_SETTINGS = { nativeWebTap: false, nativeWebTapStrict: false, useJSONSource: false, webScreenshotMode: 'native', shouldUseCompactResponses: true, elementResponseAttributes: 'type,label', // Read https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Utilities/FBConfiguration.m for following settings' values mjpegServerScreenshotQuality: 25, mjpegServerFramerate: 10, screenshotQuality: 1, mjpegScalingFactor: 100, // set `reduceMotion` to `null` so that it will be verified but still set either true/false reduceMotion: null, pageSourceExcludedAttributes: '' }; // This lock assures, that each driver session does not // affect shared resources of the other parallel sessions const SHARED_RESOURCES_GUARD = new AsyncLock(); const WEB_ELEMENTS_CACHE_SIZE = 500; const SUPPORTED_ORIENATIONS = ['LANDSCAPE', 'PORTRAIT']; const DEFAULT_MJPEG_SERVER_PORT = 9100; /* eslint-disable no-useless-escape */ /** @type {import('@appium/types').RouteMatcher[]} */ const NO_PROXY_NATIVE_LIST = [ ['DELETE', /window/], ['GET', /^\/session\/[^\/]+$/], ['GET', /alert_text/], ['GET', /alert\/[^\/]+/], ['GET', /appium/], ['GET', /attribute/], ['GET', /context/], ['GET', /location/], ['GET', /log/], ['GET', /screenshot/], ['GET', /size/], ['GET', /source/], ['GET', /timeouts$/], ['GET', /url/], ['GET', /window/], ['POST', /accept_alert/], ['POST', /actions$/], ['DELETE', /actions$/], ['POST', /alert_text/], ['POST', /alert\/[^\/]+/], ['POST', /appium/], ['POST', /appium\/device\/is_locked/], ['POST', /appium\/device\/lock/], ['POST', /appium\/device\/unlock/], ['POST', /back/], ['POST', /clear/], ['POST', /context/], ['POST', /dismiss_alert/], ['POST', /element\/active/], // MJSONWP get active element should proxy ['POST', /element$/], ['POST', /elements$/], ['POST', /execute/], ['POST', /keys/], ['POST', /log/], ['POST', /receive_async_response/], // always, in case context switches while waiting ['POST', /session\/[^\/]+\/location/], // geo location, but not element location ['POST', /shake/], ['POST', /timeouts/], ['POST', /url/], ['POST', /value/], ['POST', /window/], ['DELETE', /cookie/], ['GET', /cookie/], ['POST', /cookie/], ]; const NO_PROXY_WEB_LIST = /** @type {import('@appium/types').RouteMatcher[]} */ ([ ['GET', /attribute/], ['GET', /element/], ['GET', /text/], ['GET', /title/], ['POST', /clear/], ['POST', /click/], ['POST', /element/], ['POST', /forward/], ['POST', /frame/], ['POST', /keys/], ['POST', /refresh/], ]).concat(NO_PROXY_NATIVE_LIST); /* eslint-enable no-useless-escape */ const MEMOIZED_FUNCTIONS = ['getStatusBarHeight', 'getDevicePixelRatio', 'getScreenInfo']; // Capabilities that do not have xcodebuild process const CAP_NAMES_NO_XCODEBUILD_REQUIRED = ['webDriverAgentUrl', 'usePreinstalledWDA']; const BUNDLE_VERSION_PATTERN = /CFBundleVersion\s+=\s+"?([^(;|")]+)/; /** * @implements {ExternalDriver<XCUITestDriverConstraints, FullContext|string>} * @extends {BaseDriver<XCUITestDriverConstraints>} * @privateRemarks **This class should be considered "final"**. It cannot be extended * due to use of public class field assignments. If extending this class becomes a hard requirement, refer to the implementation of `BaseDriver` on how to do so. */ export class XCUITestDriver extends BaseDriver { static newMethodMap = newMethodMap; static executeMethodMap = executeMethodMap; /** @type {string|null|undefined} */ curWindowHandle; /** * @type {boolean|undefined} */ selectingNewPage; /** @type {string[]} */ contexts; /** @type {string|null} */ curContext; /** @type {string[]} */ curWebFrames; /** @type {import('./types').CalibrationData|null} */ webviewCalibrationResult; /** * @type {import('./types').Page[]|undefined} */ windowHandleCache; /** @type {import('./types').AsyncPromise|undefined} */ asyncPromise; /** @type {number|undefined} */ asyncWaitMs; /** @type {((logRecord: {message: string}) => void)|null} */ _syslogWebsocketListener; /** @type {import('./commands/performance').PerfRecorder[]} */ _perfRecorders; /** @type {LRUCache} */ webElementsCache; /** * @type {any|null} * @privateRemarks needs types **/ _conditionInducerService; /** @type {boolean|undefined} */ _isSafariIphone; /** @type {boolean|undefined} */ _isSafariNotched; /** @type {import('./commands/types').WaitingAtoms} */ _waitingAtoms; /** @type {import('./types').LifecycleData} */ lifecycleData; /** @type {XCUITestDriverOpts} */ opts; /** @type {import('./commands/record-audio').AudioRecorder|null} */ _audioRecorder; /** @type {XcodeVersion|undefined} */ xcodeVersion; /** @type {import('./commands/pcap').TrafficCapture|null} */ _trafficCapture; /** @type {Simulator|RealDevice} */ _device; /** @type {string|null} */ _iosSdkVersion; /** @type {WebDriverAgent} */ wda; /** @type {import('appium-remote-debugger').RemoteDebugger|null} */ remote; /** @type {DriverLogs} */ logs; /** @type {import('./commands/types').LogListener|undefined} */ _bidiServerLogListener; /** * * @param {XCUITestDriverOpts} opts * @param {boolean} shouldValidateCaps */ constructor(opts = /** @type {XCUITestDriverOpts} */ ({}), shouldValidateCaps = true) { super(opts, shouldValidateCaps); this.locatorStrategies = [ 'xpath', 'id', 'name', 'class name', '-ios predicate string', '-ios class chain', 'accessibility id', 'css selector', ]; this.webLocatorStrategies = [ 'link text', 'css selector', 'tag name', 'link text', 'partial link text', ]; this.curWebFrames = []; this._perfRecorders = []; this.desiredCapConstraints = desiredCapConstraints; this.webElementsCache = new LRUCache({ max: WEB_ELEMENTS_CACHE_SIZE, }); this.webviewCalibrationResult = null; this._waitingAtoms = { count: 0, alertNotifier: new EventEmitter(), alertMonitor: B.resolve(), }; this.resetIos(); this.settings = new DeviceSettings(DEFAULT_SETTINGS, this.onSettingsUpdate.bind(this)); this.logs = {}; this._trafficCapture = null; // memoize functions here, so that they are done on a per-instance basis for (const fn of MEMOIZED_FUNCTIONS) { this[fn] = _.memoize(this[fn]); } this.lifecycleData = {}; this._audioRecorder = null; this.appInfosCache = new AppInfosCache(this.log); this.remote = null; this.doesSupportBidi = true; } async onSettingsUpdate(key, value) { // skip sending the update request to the WDA nor saving it in opts // to not spend unnecessary time. if (['pageSourceExcludedAttributes'].includes(key)) { return; } if (key !== 'nativeWebTap' && key !== 'nativeWebTapStrict') { return await this.proxyCommand('/appium/settings', 'POST', { settings: {[key]: value}, }); } this.opts[key] = !!value; } resetIos() { this.opts = this.opts || {}; // @ts-ignore this is ok this.wda = null; this.jwpProxyActive = false; this.proxyReqRes = null; this.safari = false; this.cachedWdaStatus = null; this.curWebFrames = []; this._currentUrl = null; this.curContext = null; this.xcodeVersion = undefined; this.contexts = []; this.implicitWaitMs = 0; this.pageLoadMs = 6000; this.landscapeWebCoordsOffset = 0; this.remote = null; this._conditionInducerService = null; this.webElementsCache = new LRUCache({ max: WEB_ELEMENTS_CACHE_SIZE, }); this._waitingAtoms = { count: 0, alertNotifier: new EventEmitter(), alertMonitor: B.resolve(), }; } get driverData() { // TODO fill out resource info here return {}; } async getStatus() { const status = { ready: true, message: 'The driver is ready to accept new connections', build: await getDriverInfo(), }; if (this.cachedWdaStatus) { status.wda = this.cachedWdaStatus; } return status; } mergeCliArgsToOpts() { let didMerge = false; // this.cliArgs should never include anything we do not expect. for (const [key, value] of Object.entries(this.cliArgs ?? {})) { if (_.has(this.opts, key)) { this.log.info( `CLI arg '${key}' with value '${value}' overwrites value '${this.opts[key]}' sent in via caps)`, ); didMerge = true; } this.opts[key] = value; } return didMerge; } /** * @returns {Simulator|RealDevice} */ get device() { return this._device; } isXcodebuildNeeded() { return !(CAP_NAMES_NO_XCODEBUILD_REQUIRED.some((x) => Boolean(this.opts[x]))); } async createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData) { try { let [sessionId, caps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData); // merge cli args to opts, and if we did merge any, revalidate opts to ensure the final set // is also consistent if (this.mergeCliArgsToOpts()) { this.validateDesiredCaps({...caps, ...this.cliArgs}); } await this.start(); // merge server capabilities + desired capabilities caps = Object.assign({}, defaultServerCaps, caps); // update the udid with what is actually used caps.udid = this.opts.udid; // ensure we track nativeWebTap capability as a setting as well if (_.has(this.opts, 'nativeWebTap')) { await this.updateSettings({nativeWebTap: this.opts.nativeWebTap}); } // ensure we track nativeWebTapStrict capability as a setting as well if (_.has(this.opts, 'nativeWebTapStrict')) { await this.updateSettings({nativeWebTapStrict: this.opts.nativeWebTapStrict}); } // ensure we track useJSONSource capability as a setting as well if (_.has(this.opts, 'useJSONSource')) { await this.updateSettings({useJSONSource: this.opts.useJSONSource}); } /** @type {import('appium-webdriveragent').WDASettings} */ let wdaSettings = { elementResponseAttributes: DEFAULT_SETTINGS.elementResponseAttributes, shouldUseCompactResponses: DEFAULT_SETTINGS.shouldUseCompactResponses, }; if ('elementResponseAttributes' in this.opts && _.isString(this.opts.elementResponseAttributes)) { wdaSettings.elementResponseAttributes = this.opts.elementResponseAttributes; } if ('shouldUseCompactResponses' in this.opts && _.isBoolean(this.opts.shouldUseCompactResponses)) { wdaSettings.shouldUseCompactResponses = this.opts.shouldUseCompactResponses; } if ('mjpegServerScreenshotQuality' in this.opts && _.isNumber(this.opts.mjpegServerScreenshotQuality)) { wdaSettings.mjpegServerScreenshotQuality = this.opts.mjpegServerScreenshotQuality; } if ('mjpegServerFramerate' in this.opts && _.isNumber(this.opts.mjpegServerFramerate)) { wdaSettings.mjpegServerFramerate = this.opts.mjpegServerFramerate; } if (_.has(this.opts, 'screenshotQuality')) { this.log.info(`Setting the quality of phone screenshot: '${this.opts.screenshotQuality}'`); wdaSettings.screenshotQuality = this.opts.screenshotQuality; } // ensure WDA gets our defaults instead of whatever its own might be await this.updateSettings(wdaSettings); await this.handleMjpegOptions(); return /** @type {[string, import('@appium/types').DriverCaps<XCUITestDriverConstraints>]} */ ([ sessionId, caps, ]); } catch (e) { this.log.error(JSON.stringify(e)); await this.deleteSession(); throw e; } } /** * Handles MJPEG server-related capabilities * @returns {Promise<void>} */ async handleMjpegOptions() { await this.allocateMjpegServerPort(); // turn on mjpeg stream reading if requested if (this.opts.mjpegScreenshotUrl) { this.log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`); this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl); await this.mjpegStream.start(); } } /** * Allocates and configures port forwarding for the MJPEG server * @returns {Promise<void>} * @throws {Error} If port forwarding fails and mjpegServerPort capability value is provided explicitly */ async allocateMjpegServerPort() { const mjpegServerPort = this.opts.mjpegServerPort || DEFAULT_MJPEG_SERVER_PORT; this.log.debug( `Forwarding MJPEG server port ${mjpegServerPort} to local port ${mjpegServerPort}`, ); try { await DEVICE_CONNECTIONS_FACTORY.requestConnection(this.opts.udid, mjpegServerPort, { devicePort: mjpegServerPort, usePortForwarding: this.isRealDevice(), }); } catch (error) { if (_.isUndefined(this.opts.mjpegServerPort)) { this.log.warn( `Cannot forward the device port ${DEFAULT_MJPEG_SERVER_PORT} to the local port ${DEFAULT_MJPEG_SERVER_PORT}. ` + `Certain features, like MJPEG-based screen recording, will be unavailable during this session. ` + `Try to customize the value of 'mjpegServerPort' capability as a possible solution`, ); } else { this.log.debug(error.stack); throw new Error( `Cannot ensure MJPEG broadcast functionality by forwarding the local port ${mjpegServerPort} ` + `requested by the 'mjpegServerPort' capability to the device port ${mjpegServerPort}. ` + `Original error: ${error}`, ); } } } /** * Returns the default URL for Safari browser * @returns {string} The default URL */ getDefaultUrl() { // Setting this to some external URL slows down the session init return `${this.getWdaLocalhostRoot()}/health`; } async start() { this.opts.noReset = !!this.opts.noReset; this.opts.fullReset = !!this.opts.fullReset; await printUser(); this._iosSdkVersion = null; // For WDA and xcodebuild const {device, udid, realDevice} = await this.determineDevice(); this.log.info( `Determining device to run tests on: udid: '${udid}', real device: ${realDevice}`, ); this._device = device; this.opts.udid = udid; if (this.opts.simulatorDevicesSetPath) { if (realDevice) { this.log.info( `The 'simulatorDevicesSetPath' capability is only supported for Simulator devices`, ); } else { this.log.info( `Setting simulator devices set path to '${this.opts.simulatorDevicesSetPath}'`, ); (/** @type {Simulator} */ (this.device)).devicesSetPath = this.opts.simulatorDevicesSetPath; } } // at this point if there is no platformVersion, get it from the device if (!this.opts.platformVersion) { this.opts.platformVersion = await this.device.getPlatformVersion(); this.log.info( `No platformVersion specified. Using device version: '${this.opts.platformVersion}'`, ); } const normalizedVersion = normalizePlatformVersion(this.opts.platformVersion); if (this.opts.platformVersion !== normalizedVersion) { this.log.info( `Normalized platformVersion capability value '${this.opts.platformVersion}' to '${normalizedVersion}'`, ); this.opts.platformVersion = normalizedVersion; } this.caps.platformVersion = this.opts.platformVersion; if (_.isEmpty(this.xcodeVersion) && (this.isXcodebuildNeeded() || this.isSimulator())) { // no `webDriverAgentUrl`, or on a simulator, so we need an Xcode version this.xcodeVersion = await getAndCheckXcodeVersion(); } this.logEvent('xcodeDetailsRetrieved'); if (_.toLower(this.opts.browserName) === 'safari') { this.log.info('Safari test requested'); this.safari = true; this.opts.app = undefined; this.opts.processArguments = this.opts.processArguments || {}; applySafariStartupArgs.bind(this)(); this.opts.bundleId = SAFARI_BUNDLE_ID; this._currentUrl = this.opts.safariInitialUrl || this.getDefaultUrl(); } else if (this.opts.app || this.opts.bundleId) { await this.configureApp(); } this.logEvent('appConfigured'); // fail very early if the app doesn't actually exist // or if bundle id doesn't point to an installed app if (this.opts.app) { await checkAppPresent(this.opts.app); if (!this.opts.bundleId) { this.opts.bundleId = await this.appInfosCache.extractBundleId(this.opts.app); } } await this.runReset(); this.wda = new WebDriverAgent( /** @type {import('appium-xcode').XcodeVersion} */ (this.xcodeVersion), { ...this.opts, device: this.device, realDevice: this.isRealDevice(), iosSdkVersion: this._iosSdkVersion, reqBasePath: this.basePath, }, // @ts-ignore this is ok this.log, ); // Derived data path retrieval is an expensive operation // We could start that now in background and get the cached result // whenever it is needed // eslint-disable-next-line promise/prefer-await-to-then this.wda.retrieveDerivedDataPath().catch((e) => this.log.debug(e)); const memoizedLogInfo = _.memoize(() => { this.log.info( "'skipLogCapture' is set. Skipping starting logs such as crash, system, safari console and safari network.", ); }); const startLogCapture = async () => { if (this.opts.skipLogCapture) { memoizedLogInfo(); return false; } const result = await this.startLogCapture(); if (result) { this.logEvent('logCaptureStarted'); } return result; }; const isLogCaptureStarted = await startLogCapture(); this.log.info(`Setting up ${this.isRealDevice() ? 'real device' : 'simulator'}`); if (this.isSimulator()) { await this.initSimulator(); if (!isLogCaptureStarted) { // Retry log capture if Simulator was not running before await startLogCapture(); } } else if (this.opts.customSSLCert) { await new Pyidevice({ udid, log: this.log, }).installProfile({payload: this.opts.customSSLCert}); this.logEvent('customCertInstalled'); } await this.installAUT(); // if we only have bundle identifier and no app, fail if it is not already installed if ( !this.opts.app && this.opts.bundleId && !this.isSafari() && !(await this.device.isAppInstalled(this.opts.bundleId)) ) { throw this.log.errorWithException(`App with bundle identifier '${this.opts.bundleId}' unknown`); } if (this.isSimulator()) { if (this.opts.permissions) { this.log.debug('Setting the requested permissions before WDA is started'); for (const [bundleId, permissionsMapping] of _.toPairs(JSON.parse(this.opts.permissions))) { await /** @type {Simulator} */ (this.device).setPermissions(bundleId, permissionsMapping); } } // TODO: Deprecate and remove this block together with calendarAccessAuthorized capability if (_.isBoolean(this.opts.calendarAccessAuthorized)) { this.log.warn( `The 'calendarAccessAuthorized' capability is deprecated and will be removed soon. ` + `Consider using 'permissions' one instead with 'calendar' key`, ); const methodName = `${ this.opts.calendarAccessAuthorized ? 'enable' : 'disable' }CalendarAccess`; await this.device[methodName](this.opts.bundleId); } } await this.startWda(); if (_.isString(this.opts.orientation)) { await this.setInitialOrientation(this.opts.orientation); this.logEvent('orientationSet'); } if (this.isSafari() || this.opts.autoWebview) { await this.activateRecentWebview(); } else { // We want to always setup the initial context value upon session startup await notifyBiDiContextChange.bind(this)(); } if (this.isSafari()) { if (shouldSetInitialSafariUrl(this.opts)) { this.log.info(`About to set the initial Safari URL to '${this.getCurrentUrl()}'`); if (_.isNil(this.opts.safariInitialUrl) && _.isNil(this.opts.initialDeeplinkUrl)) { this.log.info(`Use the 'safariInitialUrl' capability to customize it`); }; await this.setUrl(this.getCurrentUrl()); } else { const currentUrl = await this.getUrl(); this.log.info(`Current URL: ${currentUrl}`); this.setCurrentUrl(currentUrl); } } } /** * Start the simulator and initialize based on capabilities */ async initSimulator() { const device = /** @type {Simulator} */ (this.device); if (this.opts.shutdownOtherSimulators) { this.assertFeatureEnabled(SHUTDOWN_OTHER_FEAT_NAME); await shutdownOtherSimulators.bind(this)(); } await this.startSim(); if (this.opts.customSSLCert) { // Simulator must be booted in order to call this helper await device.addCertificate(this.opts.customSSLCert); this.logEvent('customCertInstalled'); } if (await setSafariPrefs.bind(this)()) { this.log.debug('Safari preferences have been updated'); } if (await setLocalizationPrefs.bind(this)()) { this.log.debug('Localization preferences have been updated'); } /** @type {Promise[]} */ const promises = ['reduceMotion', 'reduceTransparency', 'autoFillPasswords'] .filter((optName) => _.isBoolean(this.opts[optName])) .map((optName) => { this.log.info(`Setting ${optName} to ${this.opts[optName]}`); return device[`set${_.upperFirst(optName)}`](this.opts[optName]); }); await B.all(promises); if (this.opts.launchWithIDB) { try { const idb = new IDB({udid: this.opts.udid}); await idb.connect(); device.idb = idb; } catch (e) { this.log.debug(e.stack); this.log.warn( `idb will not be used for Simulator interaction. Original error: ${e.message}`, ); } } this.logEvent('simStarted'); } /** * Start WebDriverAgentRunner */ async startWda() { // Don't cleanup the processes if webDriverAgentUrl is set if (!util.hasValue(this.wda.webDriverAgentUrl)) { await this.wda.cleanupObsoleteProcesses(); } const usePortForwarding = this.isRealDevice() && !this.wda.webDriverAgentUrl && isLocalHost(this.wda.wdaBaseUrl); await DEVICE_CONNECTIONS_FACTORY.requestConnection(this.opts.udid, this.wda.url.port, { devicePort: usePortForwarding ? this.wda.wdaRemotePort : null, usePortForwarding, }); // Let multiple WDA binaries with different derived data folders be built in parallel // Concurrent WDA builds from the same source will cause xcodebuild synchronization errors let synchronizationKey = XCUITestDriver.name; if (this.opts.useXctestrunFile || !(await this.wda.isSourceFresh())) { // First-time compilation is an expensive operation, which is done faster if executed // sequentially. Xcodebuild spreads the load caused by the clang compiler to all available CPU cores const derivedDataPath = await this.wda.retrieveDerivedDataPath(); if (derivedDataPath) { synchronizationKey = path.normalize(derivedDataPath); } } this.log.debug( `Starting WebDriverAgent initialization with the synchronization key '${synchronizationKey}'`, ); if (SHARED_RESOURCES_GUARD.isBusy() && !this.opts.derivedDataPath && !this.opts.bootstrapPath) { this.log.debug( `Consider setting a unique 'derivedDataPath' capability value for each parallel driver instance ` + `to avoid conflicts and speed up the building process`, ); } if (this.opts.usePreinstalledWDA && this.opts.prebuiltWDAPath && !(await fs.exists(this.opts.prebuiltWDAPath))) { throw new Error( `The prebuilt WebDriverAgent app at '${this.opts.prebuiltWDAPath}' provided as 'prebuiltWDAPath' ` + `capability value does not exist or is not accessible` ); } return await SHARED_RESOURCES_GUARD.acquire(synchronizationKey, async () => { if (this.opts.useNewWDA) { this.log.debug(`Capability 'useNewWDA' set to true, so uninstalling WDA before proceeding`); await this.wda.quitAndUninstall(); this.logEvent('wdaUninstalled'); } else if (!util.hasValue(this.wda.webDriverAgentUrl) && this.isXcodebuildNeeded()) { await this.wda.setupCaching(); } // local helper for the two places we need to uninstall wda and re-start it const quitAndUninstall = async (msg) => { this.log.debug(msg); if (!this.isXcodebuildNeeded()) { this.log.debug( `Not quitting/uninstalling WebDriverAgent since at least one of ${CAP_NAMES_NO_XCODEBUILD_REQUIRED} capabilities is provided`, ); throw new Error(msg); } this.log.warn('Quitting and uninstalling WebDriverAgent'); await this.wda.quitAndUninstall(); throw new Error(msg); }; // Used in the following WDA build if (this.opts.resultBundlePath) { this.assertFeatureEnabled(CUSTOMIZE_RESULT_BUNDLE_PATH); } let startupRetries = this.opts.wdaStartupRetries || (this.isRealDevice() ? WDA_REAL_DEV_STARTUP_RETRIES : WDA_SIM_STARTUP_RETRIES); const startupRetryInterval = this.opts.wdaStartupRetryInterval || WDA_STARTUP_RETRY_INTERVAL; // These values help only xcodebuild. if (this.isXcodebuildNeeded()) { this.log.debug( `Trying to start WebDriverAgent ${startupRetries} times with ${startupRetryInterval}ms interval`, ); if ( !util.hasValue(this.opts.wdaStartupRetries) && !util.hasValue(this.opts.wdaStartupRetryInterval) ) { this.log.debug( `These values can be customized by changing wdaStartupRetries/wdaStartupRetryInterval capabilities`, ); } } else { // The startup retry will be one time if the session does not need WDA build this.log.debug(`Trying to start WebDriverAgent once since at least one of ${CAP_NAMES_NO_XCODEBUILD_REQUIRED} capabilities is provided`); startupRetries = 1; } /** @type {Error|null} */ let shortCircuitError = null; let retryCount = 0; await retryInterval(startupRetries, startupRetryInterval, async () => { this.logEvent('wdaStartAttempted'); if (retryCount > 0) { this.log.info(`Retrying WDA startup (${retryCount + 1} of ${startupRetries})`); } try { if (this.opts.usePreinstalledWDA) { await this.preparePreinstalledWda(); } this.cachedWdaStatus = await this.wda.launch(/** @type {string} */ (this.sessionId)); } catch (err) { this.logEvent('wdaStartFailed'); this.log.debug(err.stack); retryCount++; let errorMsg = `Unable to launch WebDriverAgent. Original error: ${err.message}`; if (this.isRealDevice()) { errorMsg += `. Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}`; } if (this.opts.usePreinstalledWDA) { try { // In case the bundle id process start got failed because of // auth popup in the device. Then, the bundle id process itself started. It is safe to stop it here. await this.mobileKillApp(this.wda.bundleIdForXctest); } catch {}; // Mostly it failed to start the WDA process as no the bundle id // e.g. '<bundle id of WDA> not found on device <udid>' errorMsg = `Unable to launch WebDriverAgent. Original error: ${err.message}. ` + `Make sure the application ${this.wda.bundleIdForXctest} exists and it is launchable.`; if (this.isRealDevice()) { errorMsg += ` ${WDA_REAL_DEV_TUTORIAL_URL} may help to complete the preparation.`; }; throw new Error(errorMsg); } else { await quitAndUninstall(errorMsg); } } this.proxyReqRes = this.wda.proxyReqRes.bind(this.wda); this.jwpProxyActive = true; try { this.logEvent('wdaSessionAttempted'); this.log.debug('Sending createSession command to WDA'); this.cachedWdaStatus = this.cachedWdaStatus || (await this.proxyCommand('/status', 'GET')); await this.startWdaSession(this.opts.bundleId, this.opts.processArguments); this.logEvent('wdaSessionStarted'); } catch (err) { this.logEvent('wdaSessionFailed'); this.log.debug(err.stack); if (err instanceof errors.TimeoutError) { // Session startup timed out. There is no point to retry shortCircuitError = err; return; } let errorMsg = `Unable to start WebDriverAgent session. Original error: ${err.message}`; if (this.isRealDevice() && _.includes(err.message, 'xcodebuild')) { errorMsg += ` Make sure you follow the tutorial at ${WDA_REAL_DEV_TUTORIAL_URL}.`; } throw new Error(errorMsg); } if (this.opts.clearSystemFiles && this.isXcodebuildNeeded()) { await markSystemFilesForCleanup(this.wda); } // we expect certain socket errors until this point, but now // mark things as fully working this.wda.fullyStarted = true; this.logEvent('wdaStarted'); }); if (shortCircuitError) { throw shortCircuitError; } }); } /** * * @param {boolean} [enforceSimulatorShutdown=false] */ async runReset(enforceSimulatorShutdown = false) { this.logEvent('resetStarted'); if (this.isRealDevice()) { await runRealDeviceReset.bind(this)(); } else { await runSimulatorReset.bind(this)(enforceSimulatorShutdown); } this.logEvent('resetComplete'); } async deleteSession() { await removeAllSessionWebSocketHandlers(this.server, this.sessionId); for (const recorder of _.compact([ this._recentScreenRecorder, this._audioRecorder, this._trafficCapture, ])) { await recorder.interrupt(true); await recorder.cleanup(); } if (!_.isEmpty(this._perfRecorders)) { await B.all(this._perfRecorders.map((x) => x.stop(true))); this._perfRecorders = []; } if (this._conditionInducerService) { this.disableConditionInducer(); } await this.stop(); if (this.wda && this.isXcodebuildNeeded()) { if (this.opts.clearSystemFiles) { let synchronizationKey = XCUITestDriver.name; const derivedDataPath = await this.wda.retrieveDerivedDataPath(); if (derivedDataPath) { synchronizationKey = path.normalize(derivedDataPath); } await SHARED_RESOURCES_GUARD.acquire(synchronizationKey, async () => { await clearSystemFiles(this.wda); }); } else { this.log.debug('Not clearing log files. Use `clearSystemFiles` capability to turn on.'); } } if (this.remote) { this.log.debug('Found a remote debugger session. Removing...'); await this.stopRemote(); } if (this.opts.resetOnSessionStartOnly === false) { await this.runReset(true); } const simulatorDevice = this.isSimulator() ? /** @type {Simulator} */ (this.device) : null; if (simulatorDevice && this.lifecycleData.createSim) { this.log.debug(`Deleting simulator created for this run (udid: '${simulatorDevice.udid}')`); await shutdownSimulator.bind(this)(); await simulatorDevice.delete(); } const shouldResetLocationServivce = this.isRealDevice() && !!this.opts.resetLocationService; if (shouldResetLocationServivce) { try { await this.mobileResetLocationService(); } catch { /* Ignore this error since mobileResetLocationService already logged the error */ } } await this.logs.syslog?.stopCapture(); _.values(this.logs).forEach((x) => x.removeAllListeners()); if (this._bidiServerLogListener) { this.log.unwrap().off('log', this._bidiServerLogListener); } this.logs = {}; if (this.mjpegStream) { this.log.info('Closing MJPEG stream'); this.mjpegStream.stop(); } this.resetIos(); await super.deleteSession(); } async stop() { this.jwpProxyActive = false; this.proxyReqRes = null; if (this.wda && this.wda.fullyStarted) { if (this.wda.jwproxy) { try { await this.proxyCommand(`/session/${this.sessionId}`, 'DELETE'); } catch (err) { // an error here should not short-circuit the rest of clean up this.log.debug(`Unable to DELETE session on WDA: '${err.message}'. Continuing shutdown.`); } } // The former could cache the xcodebuild, so should not quit the process. // If the session skiped the xcodebuild (this.wda.canSkipXcodebuild), the this.wda instance // should quit properly. if ((!this.wda.webDriverAgentUrl && this.opts.useNewWDA) || this.wda.canSkipXcodebuild) { await this.wda.quit(); } } DEVICE_CONNECTIONS_FACTORY.releaseConnection(this.opts.udid); } /** * * @param {string} cmd * @param {...any} args * @returns {Promise<any>} */ async executeCommand(cmd, ...args) { this.log.debug(`Executing command '${cmd}'`); if (cmd === 'receiveAsyncResponse') { return await this.receiveAsyncResponse(...args); } // TODO: once this fix gets into base driver remove from here if (cmd === 'getStatus') { return await this.getStatus(); } return await super.executeCommand(cmd, ...args); } async configureApp() { function appIsPackageOrBundle(app) { return /^([a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+)+$/.test(app); } // the app name is a bundleId assign it to the bundleId property if (!this.opts.bundleId && appIsPackageOrBundle(this.opts.app)) { this.opts.bundleId = this.opts.app; this.opts.app = ''; } // we have a bundle ID, but no app, or app is also a bundle if ( this.opts.bundleId && appIsPackageOrBundle(this.opts.bundleId) && (this.opts.app === '' || appIsPackageOrBundle(this.opts.app)) ) { this.log.debug('App is an iOS bundle, will attempt to run as pre-existing'); return; } // check for supported build-in apps switch (_.toLower(this.opts.app)) { case 'settings': this.opts.bundleId = 'com.apple.Preferences'; this.opts.app = null; return; case 'calendar': this.opts.bundleId = 'com.apple.mobilecal'; this.opts.app = null; return; } this.opts.app = await this.helpers.configureApp(this.opts.app, { onPostProcess: onPostConfigureApp.bind(this), onDownload: onDownloadApp.bind(this), supportedExtensions: SUPPORTED_EXTENSIONS, }); } async determineDevice() { // in the one case where we create a sim, we will set this state this.lifecycleData.createSim = false; // if we get generic names, translate them this.opts.deviceName = translateDeviceName(this.opts.platformVersion ?? '', this.opts.deviceName); const setupVersionCaps = async () => { this._iosSdkVersion = await getAndCheckIosSdkVersion(); this.log.info(`iOS SDK Version set to '${this._iosSdkVersion}'`); if (!this.opts.platformVersion && this._iosSdkVersion) { this.log.info( `No platformVersion specified. Using the latest version Xcode supports: '${this._iosSdkVersion}'. ` + `This may cause problems if a simulator does not exist for this platform version.`, ); this.opts.platformVersion = normalizePlatformVersion(this._iosSdkVersion); } }; if (this.opts.udid) { if (this.opts.udid.toLowerCase() === UDID_AUTO) { try { this.opts.udid = await detectUdid.bind(this)(); } catch (err) { // Trying to find matching UDID for Simulator this.log.warn( `Cannot detect any connected real devices. Falling back to Simulator. Original error: ${err.message}`, ); await setupVersionCaps(); const device = await getExistingSim.bind(this)(); if (!device) { // No matching Simulator is found. Throw an error throw this.log.errorWithException( `Cannot detect udid for ${this.opts.deviceName} Simulator running iOS ${this.opts.platformVersion}`, ); } this.opts.udid = device.udid; return {device, realDevice: false, udid: device.udid}; } } else { // If the session specified this.opts.webDriverAgentUrl with a real device, // we can assume the user prepared the device properly already. let isRealDeviceUdid = false; const shouldCheckAvailableRealDevices = !this.opts.webDriverAgentUrl; if (shouldCheckAvailableRealDevices) { const devices = await getConnectedDevices(); this.log.debug(`Available real devices: ${devices.join(', ')}`); isRealDeviceUdid = devices.includes(this.opts.udid); } if (!isRealDeviceUdid) { try { const device = await getSimulator(this.opts.udid, { devicesSetPath: this.opts.simulatorDevicesSetPath, // @ts-ignore This is ok logger: this.log, }); return {device, realDevice: false, udid: this.opts.udid}; } catch { if (shouldCheckAvailableRealDevices) { throw new Error(`Unknown device or simulator UDID: '${this.opts.udid}'`); } this.log.debug( 'Skipping checking of the real devices availability since the session specifies appium:webDriverAgentUrl' ); } } } this.log.debug(`Creating iDevice object with udid '${this.opts.udid}'`); const device = new RealDevice(this.opts.udid, this.log); return {device, realDevice: true, udid: this.opts.udid}; } this.log.info( `No real device udid has been provided in capabilities. ` + `Will select a matching simulator to run the test.`, ); await setupVersionCaps(); if (this.opts.enforceFreshSimulatorCreation) { this.log.debug( `New simulator is requested. If this is not wanted, set 'enforceFreshSimulatorCreation' capability to false`, ); } else { // figure out the correct simulator to use, given the desired capabilities const device = await getExistingSim.bind(this)(); // check for an existing simulator if (device) { return {device, realDevice: false, udid: device.udid}; } } // no device of this type exists, or they request new sim, so create one this.log.info('Using desired caps to create a new simulator'); const device = await this.createSim(); return {device, realDevice: false, udid: device.udid}; } async startSim() { /** @type {import('appium-ios-simulator').DevicePreferences} */ const devicePreferences = {}; /** @type {import('appium-ios-simulator').RunOptions} */ const runOpts = { scaleFactor: this.opts.scaleFactor, connectHardwareKeyboard: !!this.opts.connectHardwareKeyboard, pasteboardAutomaticSync: this.opts.simulatorPasteboardAutomaticSync ?? 'off', isHeadless: !!this.opts.isHeadless, tracePointer: this.opts.simulatorTracePointer, devicePreferences, }; // add the window center, if it is specified if (this.opts.simulatorWindowCenter) { devicePreferences.SimulatorWindowCenter = this.opts.simulatorWindowCenter; } if (_.isInteger(this.opts.simulatorStartupTimeout)) { runOpts.startupTimeout = this.opts.simulatorStartupTimeout; } // This is to workaround XCTest bug about changing Simulator // orientation is not synchronized to the actual window orientation const orientation = _.isString(this.opts.orientation) && this.opts.orientation.toUpperCase(); switch (orientation) { case 'LANDSCAPE': devicePreferences.SimulatorWindowOrientation = 'LandscapeLeft'; devicePreferences.SimulatorWindowRotationAngle = 90; break; case 'PORTRAIT': devicePreferences.SimulatorWindowOrientation = 'Portrait'; devicePreferences.SimulatorWindowRotationAngle = 0; break; } await /** @type {Simulator} */ (this.device).run(runOpts); } async createSim() { this.lifecycleData.createSim = true; // create sim for caps const sim = await createSim.bind(this)(); this.log.info(`Created simulator with udid '${sim.udid}'.`); return sim; } async startWdaSession(bundleId, processArguments) { const args = processArguments ? _.cloneDeep(processArguments.args) || [] : []; if (!_.isArray(args)) { throw new Error( `processArguments.args capability is expected to be an array. ` + `${JSON.stringify(args)} is given instead`, ); } const env = processArguments ? _.cloneDeep(processArguments.env) || {} : {}; if (!_.isPlainObject(env)) { throw new Error( `processArguments.env capability is expected to be a dictionary. ` + `${JSON.stringify(env)} is given instead`, ); } if (util.hasValue(this.opts.language)) { args.push('-AppleLanguages', `(${this.opts.language})`); args.push('-NSLanguages', `(${this.opts.language})`); } if (util.hasValue(this.opts.locale)) { args.push('-AppleLocale', this.opts.locale); } if (this.opts.noReset) { if (_.isNil(this.opts.shouldTerminateApp)) { this.opts.shouldTerminateApp = false; } if (_.isNil(this.opts.forceAppLaunch)) { this.opts.forceAppLaunch = false; } } if (util.hasValue(this.opts.appTimeZone)) { // https://developer.apple.com/forums/thread/86951?answerId=263395022#263395022 env.TZ = this.opts.appTimeZone; } /** @type {import('appium-webdriveragent').WDACapabilities} */ const wdaCaps = { bundleId: this.opts.autoLaunch === false ? undefined : bundleId, arguments: args, environment: env, eventloopIdleDelaySec: this.opts.wdaEventloopIdleDelay ?? 0, shouldWaitForQuiescence: this.opts.waitForQuiescence ?? true, shouldUseTestManagerForVisibilityDetection: this.opts.simpleIsVisibleCheck ?? false, maxTypingFrequency: this.opts.maxTypingFrequency ?? 60, shouldUseSingletonTestManager: this.opts.shouldUseSingletonTestManager ?? true, waitForIdleTimeout: this.opts.waitForIdleTimeout, // @ts-expect-error - do not assign arbitrary properties to `this.opts` shouldUseCompactResponses: this.opts.shouldUseCompactResponses, // @ts-expect-error - do not assign arbitrary properties to `this.opts` elementResponseFields: this.opts.elementResponseFields, disableAutomaticScreenshots: this.opts.disableAutomaticScreenshots, shouldTerminateApp: this.opts.shouldTerminateApp ?? true, forceAppLaunch: this.opts.forceAppLaunch ?? true, appLaunchStateTimeoutSec: this.opts.appLaunchStateTimeoutSec, useNativeCachingStrategy: this.opts.useNativeCachingStrategy ?? true, forceSimulatorSoftwareKeyboardPresence: this.opts.forceSimulatorSoftwareKeyboardPresence ?? (this.opts.connectHardwareKeyboard === true ? false : true), }; if (this.opts.autoAcceptAlerts) { wdaCaps.defaultAlertAction = 'accept'; } else if (this.opts.autoDismissAlerts) { wdaCaps.defaultAlertAction = 'dismiss'; } if (this.opts.initialDeeplinkUrl) { this.log.info(`The deeplink URL will be set to ${this.opts.initialDeeplinkUrl}`); wdaCaps.initialUrl = this.opts.initialDeeplinkUrl; } const timer = new timing.Timer().start(); await this.proxyCommand('/session', 'POST', { capabilities: { firstMatch: [wdaCaps], alwaysMatch: {}, }, }); this.log.info(`WDA session startup took ${timer.getDuration().asMilliSeconds.toFixed(0)}ms`); } // Override Proxy methods from BaseDriver proxyActive() { return Boolean(this.jwpProxyActive); } getProxyAvoidList() { if (this.isWebview()) { return NO_PROXY_WEB_LIST; } return NO_PROXY_NATIVE_LIST; } canProxy() { return true; } /** * @returns {boolean} */ isSafari() { return !!this.safari; } /** * @returns {boolean} */ isRealDevice() { return 'devicectl' in (this.device ?? {}); } /** * @returns {boolean} */ isSimulator() { return 'simctl' in (this.device ?? {}); } /** * @param {string} strategy */ validateLocatorStrategy(strategy) { super.validateLocatorStrategy(strategy, this.isWebContext()); } /** * @param {any} caps * @returns {caps is import('@appium/types').DriverCaps<XCUITestDriverConstraints>} */ validateDesiredCaps(caps) { if (!super.validateDesiredCaps(caps)) { return false; } // make sure that the capabilities have one of `app` or `bundleId` if (_.toLower(caps.browserName) !== 'safari' && !caps.app && !caps.bundleId) { this.log.info( 'The desired capabilities include neither an app nor a bundleId. ' + 'WebDriverAgent will be started without the default app', ); } if (!util.coerceVersion(String(caps.platformVersion), false)) { this.log.warn( `'platformVersion' capability ('${caps.platformVersion}') is not a valid version number. ` + `Consider fixing it or be ready to experience an inconsistent driver behavior.`, ); } let verifyProcessArgument = (processArguments) => { const {args, env} = processArguments; if (!_.isNil(args) && !_.isArray(args)) { throw this.log.errorWithException('processArguments.args must be an array of strings'); } if (!_.isNil(env) && !_.isPlainObject(env)) { throw this.log.errorWithException( 'processArguments.env must be an object <key,value> pair {a:b, c:d}', ); } }; // `processArguments` should be JSON string or an object with arguments and/ environment details if (caps.processArguments) { if (_.isString(caps.processArguments)) { try { // try to parse the string as JSON caps.processArguments = JSON.parse(caps.processArguments); verifyProcessArgument(caps.processArguments); } catch (err) { throw this.log.errorWithException( `processArguments must be a JSON format or an object with format {args : [], env : {a:b, c:d}}. ` + `Both environment and argument can be null. Error: ${err}`, ); } } else if (_.isPlainObject(caps.processArguments)) { verifyProcessArgument(caps.processArguments