UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

1,359 lines (1,223 loc) 80.9 kB
import IDB from 'appium-idb'; import {getSimulator} from 'appium-ios-simulator'; import {WebDriverAgent, type WebDriverAgentArgs} from 'appium-webdriveragent'; import {BaseDriver, DeviceSettings, errors} from 'appium/driver'; import {fs, mjpeg, util, timing} from 'appium/support'; import type { RouteMatcher, DefaultCreateSessionResult, DriverData, StringRecord, ExternalDriver, W3CDriverCaps, DriverCaps, DriverOpts, } from '@appium/types'; 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 * as activeAppInfoCommands from './commands/active-app-info'; import * as alertCommands from './commands/alert'; import * as appManagementCommands from './commands/app-management'; import * as appearanceCommands from './commands/appearance'; import * as appStringsCommands from './commands/app-strings'; import * as auditCommands from './commands/audit'; import * as batteryCommands from './commands/battery'; import * as biometricCommands from './commands/biometric'; import * as certificateCommands from './commands/certificate'; import * as clipboardCommands from './commands/clipboard'; import * as conditionCommands from './commands/condition'; import * as contentSizeCommands from './commands/content-size'; import * as contextCommands from './commands/context'; import * as deviceInfoCommands from './commands/device-info'; import * as elementCommands from './commands/element'; import * as executeCommands from './commands/execute'; import * as fileMovementCommands from './commands/file-movement'; import * as findCommands from './commands/find'; import * as generalCommands from './commands/general'; import * as geolocationCommands from './commands/geolocation'; import * as gestureCommands from './commands/gesture'; import * as iohidCommands from './commands/iohid'; import * as keychainsCommands from './commands/keychains'; import * as keyboardCommands from './commands/keyboard'; import * as localizationCommands from './commands/localization'; import * as locationCommands from './commands/location'; import * as lockCommands from './commands/lock'; import * as logCommands from './commands/log'; import * as memoryCommands from './commands/memory'; import * as navigationCommands from './commands/navigation'; import * as notificationsCommands from './commands/notifications'; import * as pasteboardCommands from './commands/pasteboard'; import * as pcapCommands from './commands/pcap'; import * as performanceCommands from './commands/performance'; import * as permissionsCommands from './commands/permissions'; import * as proxyHelperCommands from './commands/proxy-helper'; import * as recordAudioCommands from './commands/record-audio'; import * as recordScreenCommands from './commands/recordscreen'; import * as screenshotCommands from './commands/screenshots'; import * as sourceCommands from './commands/source'; import * as simctlCommands from './commands/simctl'; import * as timeoutCommands from './commands/timeouts'; import * as webCommands from './commands/web'; import * as xctestCommands from './commands/xctest'; import * as xctestRecordScreenCommands from './commands/xctest-record-screen'; import * as increaseContrastCommands from './commands/increase-contrast'; import {desiredCapConstraints, type XCUITestDriverConstraints} from './desired-caps'; import {DEVICE_CONNECTIONS_FACTORY} from './device/device-connections-factory'; import {executeMethodMap} from './execute-method-map'; import {newMethodMap} from './method-map'; import { Pyidevice } from './device/clients/py-ios-device-client'; import { installToRealDevice, runRealDeviceReset, applySafariStartupArgs, detectUdid, RealDevice, getConnectedDevices, } from './device/real-device-management'; import { createSim, getExistingSim, installToSimulator, runSimulatorReset, setLocalizationPrefs, setSafariPrefs, shutdownOtherSimulators, shutdownSimulator, } from './device/simulator-management'; import { DEFAULT_TIMEOUT_KEY, UDID_AUTO, checkAppPresent, clearSystemFiles, getAndCheckIosSdkVersion, getAndCheckXcodeVersion, getDriverInfo, isLocalHost, markSystemFilesForCleanup, normalizeCommandTimeouts, normalizePlatformVersion, printUser, removeAllSessionWebSocketHandlers, shouldSetInitialSafariUrl, } from './utils'; import { AppInfosCache } from './app-infos-cache'; import { notifyBiDiContextChange } from './commands/context'; import type { CalibrationData, AsyncPromise, LifecycleData } from './types'; import type { WaitingAtoms, LogListener, FullContext } from './commands/types'; import type { PerfRecorder } from './commands/performance'; import type { AudioRecorder } from './commands/record-audio'; import type { TrafficCapture } from './commands/pcap'; import type { ScreenRecorder } from './commands/recordscreen'; import type { DVTServiceWithConnection } from 'appium-ios-remotexpc'; import type { IOSDeviceLog } from './device/log/ios-device-log'; import type { IOSSimulatorLog } from './device/log/ios-simulator-log'; import type { IOSCrashLog } from './device/log/ios-crash-log'; import type { SafariConsoleLog } from './device/log/safari-console-log'; import type { SafariNetworkLog } from './device/log/safari-network-log'; import type { IOSPerformanceLog } from './device/log/ios-performance-log'; import type { RemoteDebugger } from 'appium-remote-debugger'; import type { XcodeVersion } from 'appium-xcode'; import type { Simulator } from 'appium-ios-simulator'; 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 */ const NO_PROXY_NATIVE_LIST: RouteMatcher[] = [ ['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/], ] as RouteMatcher[]; const NO_PROXY_WEB_LIST: RouteMatcher[] = [ ['GET', /attribute/], ['GET', /element/], ['GET', /text/], ['GET', /title/], ['POST', /clear/], ['POST', /click/], ['POST', /element/], ['POST', /forward/], ['POST', /frame/], ['POST', /keys/], ['POST', /refresh/], ...NO_PROXY_NATIVE_LIST, ] as RouteMatcher[]; /* 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']; export class XCUITestDriver extends BaseDriver<XCUITestDriverConstraints, StringRecord> implements ExternalDriver<XCUITestDriverConstraints, FullContext|string, StringRecord> { static newMethodMap = newMethodMap; static executeMethodMap = executeMethodMap; curWindowHandle: string | null | undefined; selectingNewPage: boolean | undefined; contexts: string[]; curContext: string | null; curWebFrames: string[]; webviewCalibrationResult: CalibrationData | null; asyncPromise: AsyncPromise | undefined; asyncWaitMs: number | undefined; _syslogWebsocketListener: ((logRecord: {message: string}) => void) | null; _perfRecorders: PerfRecorder[]; webElementsCache: LRUCache<any, any>; _conditionInducerService: any | null; // needs types _remoteXPCConditionInducerConnection: DVTServiceWithConnection | null; // RemoteXPC DVT connection for iOS>=18 condition inducer _isSafariIphone: boolean | undefined; _isSafariNotched: boolean | undefined; _waitingAtoms: WaitingAtoms; lifecycleData: LifecycleData; _audioRecorder: AudioRecorder | null; xcodeVersion: XcodeVersion | undefined; _trafficCapture: TrafficCapture | null; _recentScreenRecorder: ScreenRecorder | null; _device: Simulator | RealDevice; _iosSdkVersion: string | null; _wda: WebDriverAgent | null; _remote: RemoteDebugger | null; logs: DriverLogs; _bidiServerLogListener: LogListener | undefined; // Additional properties that were missing appInfosCache: AppInfosCache; doesSupportBidi: boolean; jwpProxyActive: boolean; proxyReqRes: ((...args: any[]) => any) | null; safari: boolean; cachedWdaStatus: any; _currentUrl: string | null; pageLoadMs: number; landscapeWebCoordsOffset: number; mjpegStream?: mjpeg.MJpegStream; constructor(opts: 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; this._wda = null; } // Override methods from BaseDriver override async createSession( w3cCaps1: W3CXCUITestDriverCaps, w3cCaps2?: W3CXCUITestDriverCaps, w3cCaps3?: W3CXCUITestDriverCaps, driverData?: DriverData[] ): Promise<DefaultCreateSessionResult<XCUITestDriverConstraints>> { try { const [sessionId, initialCaps] = await super.createSession(w3cCaps1, w3cCaps2, w3cCaps3, driverData); let caps = initialCaps; // 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 = { ...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}); } const wdaSettings: StringRecord = { 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 [ sessionId, caps, ]; } catch (e) { this.log.error(JSON.stringify(e)); await this.deleteSession(); throw e; } } override async deleteSession(sessionId?: string): Promise<void> { await removeAllSessionWebSocketHandlers.bind(this)(); 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._remoteXPCConditionInducerConnection) { try { await this.disableConditionInducer(); } catch (err) { this.log.warn(`Cannot disable condition inducer: ${err.message}`); } } 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() ? this.device as Simulator : 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 shouldResetLocationService = this.isRealDevice() && !!this.opts.resetLocationService; if (shouldResetLocationService) { try { await this.mobileResetLocationService(); } catch { /* Ignore this error since mobileResetLocationService already logged the error */ } } await this.logs.syslog?.stopCapture(); _.values(this.logs).forEach((x: any) => 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(sessionId); } override async executeCommand(cmd: string, ...args: any[]): Promise<any> { this.log.debug(`Executing command '${cmd}'`); if (cmd === 'receiveAsyncResponse') { return await this.receiveAsyncResponse(args[0], args[1]); } // TODO: once this fix gets into base driver remove from here if (cmd === 'getStatus') { return await this.getStatus(); } return await super.executeCommand(cmd, ...args); } override proxyActive(): boolean { return Boolean(this.jwpProxyActive); } override getProxyAvoidList(): RouteMatcher[] { if (this.isWebview()) { return NO_PROXY_WEB_LIST; } return NO_PROXY_NATIVE_LIST; } override canProxy(): boolean { return true; } override validateLocatorStrategy(strategy: string): void { super.validateLocatorStrategy(strategy, this.isWebContext()); } override validateDesiredCaps(caps: any): caps is DriverCaps<XCUITestDriverConstraints> { 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.`, ); } const 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 as string); 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); } else { throw this.log.errorWithException( `'processArguments must be an object, or a string JSON object with format {args : [], env : {a:b, c:d}}. ` + `Both environment and argument can be null.`, ); } } // there is no point in having `keychainPath` without `keychainPassword` if ( (caps.keychainPath && !caps.keychainPassword) || (!caps.keychainPath && caps.keychainPassword) ) { throw this.log.errorWithException( `If 'keychainPath' is set, 'keychainPassword' must also be set (and vice versa).`, ); } // `resetOnSessionStartOnly` should be set to true by default this.opts.resetOnSessionStartOnly = !util.hasValue(this.opts.resetOnSessionStartOnly) || this.opts.resetOnSessionStartOnly; this.opts.useNewWDA = util.hasValue(this.opts.useNewWDA) ? this.opts.useNewWDA : false; if (caps.commandTimeouts) { caps.commandTimeouts = normalizeCommandTimeouts(caps.commandTimeouts as string | Record<string, number>); } if (_.isString(caps.webDriverAgentUrl)) { const {protocol, host} = url.parse(caps.webDriverAgentUrl); if (_.isEmpty(protocol) || _.isEmpty(host)) { throw this.log.errorWithException( `'webDriverAgentUrl' capability is expected to contain a valid WebDriverAgent server URL. ` + `'${caps.webDriverAgentUrl}' is given instead`, ); } } if (caps.browserName) { if (caps.bundleId) { throw this.log.errorWithException( `'browserName' cannot be set together with 'bundleId' capability` ); } // warn if the capabilities have both `app` and `browser, although this // is common with selenium grid if (caps.app) { this.log.warn( `The capabilities should generally not include both an 'app' and a 'browserName'`, ); } } if (caps.permissions) { try { for (const [bundleId, perms] of _.toPairs(JSON.parse(caps.permissions))) { if (!_.isString(bundleId)) { throw new Error(`'${JSON.stringify(bundleId)}' must be a string`); } if (!_.isPlainObject(perms)) { throw new Error(`'${JSON.stringify(perms)}' must be a JSON object`); } } } catch (e) { throw this.log.errorWithException( `'${caps.permissions}' is expected to be a valid object with format ` + `{"<bundleId1>": {"<serviceName1>": "<serviceStatus1>", ...}, ...}. Original error: ${e.message}`, ); } } if (caps.platformVersion && !util.coerceVersion(caps.platformVersion, false)) { throw this.log.errorWithException( `'platformVersion' must be a valid version number. ` + `'${caps.platformVersion}' is given instead.`, ); } // additionalWebviewBundleIds is an array, JSON array, or string if (caps.additionalWebviewBundleIds) { caps.additionalWebviewBundleIds = this.helpers.parseCapsArray( caps.additionalWebviewBundleIds as string | string[], ); } // finally, return true since the superclass check passed, as did this return true; } // Getter methods get wda(): WebDriverAgent { if (!this._wda) { throw new Error('WebDriverAgent is not initialized'); } return this._wda; } get remote(): RemoteDebugger { if (!this._remote) { throw new Error('Remote debugger is not initialized'); } return this._remote; } get driverData(): Record<string, any> { // TODO fill out resource info here return {}; } get device(): Simulator | RealDevice { return this._device; } // Utility methods isSafari(): boolean { return !!this.safari; } isRealDevice(): boolean { return 'devicectl' in (this.device ?? {}); } isSimulator(): boolean { return 'simctl' in (this.device ?? {}); } isXcodebuildNeeded(): boolean { return !(CAP_NAMES_NO_XCODEBUILD_REQUIRED.some((x) => Boolean(this.opts[x]))); } // Core driver methods async onSettingsUpdate(key: string, value: any): Promise<any> { // 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; } async getStatus(): Promise<Record<string, any>> { const status = { ready: true, message: 'The driver is ready to accept new connections', build: await getDriverInfo(), }; if (this.cachedWdaStatus) { (status as any).wda = this.cachedWdaStatus; } return status; } mergeCliArgsToOpts(): boolean { 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; } async handleMjpegOptions(): Promise<void> { 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(); } } async allocateMjpegServerPort(): Promise<void> { 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}`, ); } } } getDefaultUrl(): string { // Setting this to some external URL slows down the session init return `${this.getWdaLocalhostRoot()}/health`; } async start(): Promise<void> { 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}'`, ); (this.device as Simulator).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( { ...this.opts, device: this.device, realDevice: this.isRealDevice(), iosSdkVersion: this._iosSdkVersion ?? undefined, reqBasePath: this.basePath, } as WebDriverAgentArgs, 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 ( async () => { try { await 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 as string))) { await (this.device as Simulator).setPermissions(bundleId, permissionsMapping as StringRecord); } } // 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() || this.getDefaultUrl()); } else { const currentUrl = await this.getUrl(); this.log.info(`Current URL: ${currentUrl}`); this.setCurrentUrl(currentUrl); } } } async runReset(enforceSimulatorShutdown = false): Promise<void> { this.logEvent('resetStarted'); if (this.isRealDevice()) { await runRealDeviceReset.bind(this)(); } else { await runSimulatorReset.bind(this)(enforceSimulatorShutdown); } this.logEvent('resetComplete'); } async stop(): Promise<void> { this.jwpProxyActive = false; this.proxyReqRes = null; if (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 skipped 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); } async initSimulator(): Promise<void> { const device = this.device as Simulator; 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 as Simulator).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'); } const promises: Promise<any>[] = ['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(); // @ts-ignore This is ok. We are going to ditch idb soon anyway 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'); } async startWda(): Promise<void> { // 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; } let shortCircuitError: Error | null = 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(); } if (!this.sessionId) { throw new Error('Session ID is required but was not set'); } this.cachedWdaStatus = await this.wda.launch(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 don't restrict the version, but show what version of WDA is running on the device for debugging purposes. if (this.cachedWdaStatus?.build) { this.log.info(`WebDriverAgent version: '${this.cachedWdaStatus.build.version}'`); } else { this.log.warn( `WebDriverAgent does not provide any version information. ` + `This might indicate either a custom or an outdated build.` ); } // 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; } }); } async configureApp(): Promise<void> { 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 = undefined; return; case 'calendar': this.opts.bundleId = 'com.apple.mobilecal'; this.opts.app = undefined; return; } this.opts.app = await this.helpers.configureApp(this.opts.app as string, { onPostProcess: onPostConfigureApp.bind(this), onDownload: onDownloadApp.bind(this), supportedExtensions: SUPPORTED_EXTENSIONS, }); } async determineDevice(): Promise<{device: Simulator | RealDevice, realDevice: boolean, udid: string}> { // in the one case where we create a sim, we will set this state this.lifecycleData.createSim = false; 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:webDr