UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

1,432 lines (1,259 loc) 79.6 kB
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 _ from 'lodash'; import {LRUCache} from 'lru-cache'; import EventEmitter from 'node:events'; import path from 'node:path'; import {setTimeout as delay} from 'node:timers/promises'; import { SUPPORTED_EXTENSIONS, SAFARI_BUNDLE_ID, onPostConfigureApp, onDownloadApp, verifyApplicationPlatform, DEFAULT_TIMEOUT_KEY, UDID_AUTO, checkAppPresent, clearSystemFiles, getAndCheckIosSdkVersion, getAndCheckXcodeVersion, getDriverInfo, isLocalHost, markSystemFilesForCleanup, normalizeCommandTimeouts, normalizePlatformVersion, printUser, removeAllSessionWebSocketHandlers, shouldSetInitialSafariUrl, } from './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 networkMonitorCommands from './commands/network-monitor'; 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 {DeviceConnectionsFactory} from './device/device-connections-factory'; import {executeMethodMap} from './execute-method-map'; import {newMethodMap} from './method-map'; 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 {AppInfosCache} from './app-infos-cache'; import {notifyBiDiContextChange} from './commands/context'; import type {CalibrationData, IConditionInducer, 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 {NetworkMonitorSession} from './device/network-monitor-session'; import type {ScreenRecorder} from './commands/recordscreen'; 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', /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 type AutInstallationStateOptions = Pick< XCUITestDriverOpts, 'enforceAppInstall' | 'fullReset' | 'noReset' | 'bundleId' | 'app' >; export interface AutInstallationState { install: boolean; // If the given app should install, or not need to install. skipUninstall: boolean; // If the installed app should be uninstalled, or not. } export type XCUITestDriverOpts = DriverOpts<XCUITestDriverConstraints>; export type W3CXCUITestDriverCaps = W3CDriverCaps<XCUITestDriverConstraints>; export interface DriverLogs { syslog?: IOSDeviceLog | IOSSimulatorLog; crashlog?: IOSCrashLog; safariConsole?: SafariConsoleLog; safariNetwork?: SafariNetworkLog; performance?: IOSPerformanceLog; } 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; asyncWaitMs: number | undefined; _syslogWebsocketListener: ((logRecord: {message: string}) => void) | null; _perfRecorders: PerfRecorder[]; webElementsCache: LRUCache<any, any>; _conditionInducer: IConditionInducer | null; // Condition inducer facade that abstracts implementation details _isSafariIphone: boolean | undefined; _isSafariNotched: boolean | undefined; _waitingAtoms: WaitingAtoms; lifecycleData: LifecycleData; _audioRecorder: AudioRecorder | null; xcodeVersion: XcodeVersion | undefined; _networkMonitorSession: NetworkMonitorSession | 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; readonly deviceConnectionsFactory: DeviceConnectionsFactory; /*---------------+ | ACTIVEAPPINFO | +---------------+*/ mobileGetActiveAppInfo = activeAppInfoCommands.mobileGetActiveAppInfo; /*-------+ | ALERT | +-------+*/ getAlertText = alertCommands.getAlertText; setAlertText = alertCommands.setAlertText; postAcceptAlert = alertCommands.postAcceptAlert; postDismissAlert = alertCommands.postDismissAlert; getAlertButtons = alertCommands.getAlertButtons; mobileHandleAlert = alertCommands.mobileHandleAlert; /*---------------+ | APPMANAGEMENT | +---------------+*/ mobileInstallApp = appManagementCommands.mobileInstallApp; mobileIsAppInstalled = appManagementCommands.mobileIsAppInstalled; mobileRemoveApp = appManagementCommands.mobileRemoveApp; mobileLaunchApp = appManagementCommands.mobileLaunchApp; mobileTerminateApp = appManagementCommands.mobileTerminateApp; mobileActivateApp = appManagementCommands.mobileActivateApp; mobileKillApp = appManagementCommands.mobileKillApp; mobileQueryAppState = appManagementCommands.mobileQueryAppState; installApp = appManagementCommands.installApp; activateApp = appManagementCommands.activateApp; isAppInstalled = appManagementCommands.isAppInstalled; terminateApp = appManagementCommands.terminateApp; queryAppState = appManagementCommands.queryAppState; mobileListApps = appManagementCommands.mobileListApps; mobileClearApp = appManagementCommands.mobileClearApp; /*------------+ | APPEARANCE | +------------+*/ mobileSetAppearance = appearanceCommands.mobileSetAppearance; mobileGetAppearance = appearanceCommands.mobileGetAppearance; /*------------+ | INCREASE CONTRAST | +------------+*/ mobileSetIncreaseContrast = increaseContrastCommands.mobileSetIncreaseContrast; mobileGetIncreaseContrast = increaseContrastCommands.mobileGetIncreaseContrast; /*------------+ | CONTENT SIZE | +------------+*/ mobileSetContentSize = contentSizeCommands.mobileSetContentSize; mobileGetContentSize = contentSizeCommands.mobileGetContentSize; /*------------+ | AUDIT | +------------+*/ mobilePerformAccessibilityAudit = auditCommands.mobilePerformAccessibilityAudit; /*---------+ | BATTERY | +---------+*/ mobileGetBatteryInfo = batteryCommands.mobileGetBatteryInfo; /*-----------+ | BIOMETRIC | +-----------+*/ mobileEnrollBiometric = biometricCommands.mobileEnrollBiometric; mobileSendBiometricMatch = biometricCommands.mobileSendBiometricMatch; mobileIsBiometricEnrolled = biometricCommands.mobileIsBiometricEnrolled; /*-------------+ | CERTIFICATE | +-------------+*/ mobileInstallCertificate = certificateCommands.mobileInstallCertificate; mobileListCertificates = certificateCommands.mobileListCertificates; mobileRemoveCertificate = certificateCommands.mobileRemoveCertificate; /*-----------+ | CLIPBOARD | +-----------+*/ setClipboard = clipboardCommands.setClipboard; getClipboard = clipboardCommands.getClipboard; /*-----------+ | CONDITION | +-----------+*/ listConditionInducers = conditionCommands.listConditionInducers; enableConditionInducer = conditionCommands.enableConditionInducer; disableConditionInducer = conditionCommands.disableConditionInducer; /*---------+ | CONTEXT | +---------+*/ getContexts = contextCommands.getContexts; getCurrentContext = contextCommands.getCurrentContext; getWindowHandle = contextCommands.getWindowHandle; getWindowHandles = contextCommands.getWindowHandles; setContext = contextCommands.setContext; setWindow = contextCommands.setWindow; activateRecentWebview = contextCommands.activateRecentWebview; connectToRemoteDebugger = contextCommands.connectToRemoteDebugger; getContextsAndViews = contextCommands.getContextsAndViews; listWebFrames = contextCommands.listWebFrames; mobileGetContexts = contextCommands.mobileGetContexts; onPageChange = contextCommands.onPageChange; getCurrentUrl = contextCommands.getCurrentUrl; getNewRemoteDebugger = contextCommands.getNewRemoteDebugger; getRecentWebviewContextId = contextCommands.getRecentWebviewContextId; isWebContext = contextCommands.isWebContext; isWebview = contextCommands.isWebview; setCurrentUrl = contextCommands.setCurrentUrl; stopRemote = contextCommands.stopRemote; /*------------+ | DEVICEINFO | +------------+*/ mobileGetDeviceInfo = deviceInfoCommands.mobileGetDeviceInfo; /*---------+ | ELEMENT | +---------+*/ elementDisplayed = elementCommands.elementDisplayed; elementEnabled = elementCommands.elementEnabled; elementSelected = elementCommands.elementSelected; getName = elementCommands.getName; getNativeAttribute = elementCommands.getNativeAttribute; getAttribute = elementCommands.getAttribute; getProperty = elementCommands.getProperty; getText = elementCommands.getText; getElementRect = elementCommands.getElementRect; getLocation = elementCommands.getLocation; getLocationInView = elementCommands.getLocationInView; getSize = elementCommands.getSize; /** @deprecated */ setValueImmediate = elementCommands.setValueImmediate; setValue = elementCommands.setValue; setValueWithWebAtom = elementCommands.setValueWithWebAtom; keys = elementCommands.keys; clear = elementCommands.clear; getContentSize = elementCommands.getContentSize; getNativeRect = elementCommands.getNativeRect; /*---------+ | EXECUTE | +---------+*/ execute = executeCommands.execute; executeAsync = executeCommands.executeAsync; // Note: executeMobile is handled internally via execute method mobileSimctl = simctlCommands.mobileSimctl; /*--------------+ | FILEMOVEMENT | +--------------+*/ pushFile = fileMovementCommands.pushFile; mobilePushFile = fileMovementCommands.mobilePushFile; pullFile = fileMovementCommands.pullFile; mobilePullFile = fileMovementCommands.mobilePullFile; mobileDeleteFolder = fileMovementCommands.mobileDeleteFolder; mobileDeleteFile = fileMovementCommands.mobileDeleteFile; pullFolder = fileMovementCommands.pullFolder; mobilePullFolder = fileMovementCommands.mobilePullFolder; /*--------+ | MEMORY | +--------+*/ mobileSendMemoryWarning = memoryCommands.mobileSendMemoryWarning; /*------+ | FIND | +------+*/ findElOrEls = findCommands.findElOrEls; findNativeElementOrElements = findCommands.findNativeElementOrElements; doNativeFind = findCommands.doNativeFind; getFirstVisibleChild = findCommands.getFirstVisibleChild; /*---------+ | GENERAL | +---------+*/ active = generalCommands.active; background = appManagementCommands.background; touchId = generalCommands.touchId; toggleEnrollTouchId = generalCommands.toggleEnrollTouchId; getWindowSize = generalCommands.getWindowSize; getDeviceTime = generalCommands.getDeviceTime; mobileGetDeviceTime = generalCommands.mobileGetDeviceTime; getWindowRect = generalCommands.getWindowRect; getStrings = appStringsCommands.getStrings; removeApp = generalCommands.removeApp; launchApp = generalCommands.launchApp; closeApp = generalCommands.closeApp; setUrl = generalCommands.setUrl; getViewportRect = generalCommands.getViewportRect; getScreenInfo = generalCommands.getScreenInfo; getStatusBarHeight = generalCommands.getStatusBarHeight; getDevicePixelRatio = generalCommands.getDevicePixelRatio; mobilePressButton = generalCommands.mobilePressButton; mobileSiriCommand = generalCommands.mobileSiriCommand; /*-------------+ | GEOLOCATION | +-------------+*/ mobileGetSimulatedLocation = geolocationCommands.mobileGetSimulatedLocation; mobileSetSimulatedLocation = geolocationCommands.mobileSetSimulatedLocation; mobileResetSimulatedLocation = geolocationCommands.mobileResetSimulatedLocation; /*---------+ | GESTURE | +---------+*/ mobileShake = gestureCommands.mobileShake; click = gestureCommands.click; releaseActions = gestureCommands.releaseActions; performActions = gestureCommands.performActions; nativeClick = gestureCommands.nativeClick; mobileScrollToElement = gestureCommands.mobileScrollToElement; mobileScroll = gestureCommands.mobileScroll; mobileSwipe = gestureCommands.mobileSwipe; mobilePinch = gestureCommands.mobilePinch; mobileDoubleTap = gestureCommands.mobileDoubleTap; mobileTwoFingerTap = gestureCommands.mobileTwoFingerTap; mobileTouchAndHold = gestureCommands.mobileTouchAndHold; mobileTap = gestureCommands.mobileTap; mobileDragFromToForDuration = gestureCommands.mobileDragFromToForDuration; mobileDragFromToWithVelocity = gestureCommands.mobileDragFromToWithVelocity; mobileTapWithNumberOfTaps = gestureCommands.mobileTapWithNumberOfTaps; mobileForcePress = gestureCommands.mobileForcePress; mobileSelectPickerWheelValue = gestureCommands.mobileSelectPickerWheelValue; mobileRotateElement = gestureCommands.mobileRotateElement; /*-------+ | IOHID | +-------+*/ mobilePerformIoHidEvent = iohidCommands.mobilePerformIoHidEvent; /*-----------+ | KEYCHAINS | +-----------+*/ mobileClearKeychains = keychainsCommands.mobileClearKeychains; /*----------+ | KEYBOARD | +----------+*/ hideKeyboard = keyboardCommands.hideKeyboard; mobileHideKeyboard = keyboardCommands.mobileHideKeyboard; isKeyboardShown = keyboardCommands.isKeyboardShown; mobileKeys = keyboardCommands.mobileKeys; /*--------------+ | LOCALIZATION | +--------------+*/ mobileConfigureLocalization = localizationCommands.mobileConfigureLocalization; /*----------+ | LOCATION | +----------+*/ getGeoLocation = locationCommands.getGeoLocation; setGeoLocation = locationCommands.setGeoLocation; mobileResetLocationService = locationCommands.mobileResetLocationService; /*------+ | LOCK | +------+*/ lock = lockCommands.lock; unlock = lockCommands.unlock; isLocked = lockCommands.isLocked; /*-----+ | LOG | +-----+*/ extractLogs = logCommands.extractLogs; supportedLogTypes = logCommands.supportedLogTypes; startLogCapture = logCommands.startLogCapture; mobileStartLogsBroadcast = logCommands.mobileStartLogsBroadcast; mobileStopLogsBroadcast = logCommands.mobileStopLogsBroadcast; /*------------+ | NAVIGATION | +------------+*/ back = navigationCommands.back; forward = navigationCommands.forward; closeWindow = navigationCommands.closeWindow; nativeBack = navigationCommands.nativeBack; mobileDeepLink = navigationCommands.mobileDeepLink; /*---------------+ | NOTIFICATIONS | +---------------+*/ mobilePushNotification = notificationsCommands.mobilePushNotification; mobileExpectNotification = notificationsCommands.mobileExpectNotification; /*------------+ | PASTEBOARD | +------------+*/ mobileSetPasteboard = pasteboardCommands.mobileSetPasteboard; mobileGetPasteboard = pasteboardCommands.mobileGetPasteboard; /*------------------+ | NETWORK MONITOR | +------------------+*/ mobileStartNetworkMonitor = networkMonitorCommands.mobileStartNetworkMonitor; mobileStopNetworkMonitor = networkMonitorCommands.mobileStopNetworkMonitor; /*-------------+ | PERFORMANCE | +-------------+*/ mobileStartPerfRecord = performanceCommands.mobileStartPerfRecord; mobileStopPerfRecord = performanceCommands.mobileStopPerfRecord; /*-------------+ | PERMISSIONS | +-------------+*/ mobileResetPermission = permissionsCommands.mobileResetPermission; mobileGetPermission = permissionsCommands.mobileGetPermission; mobileSetPermissions = permissionsCommands.mobileSetPermissions; /*-------------+ | PROXYHELPER | +-------------+*/ proxyCommand = proxyHelperCommands.proxyCommand; /*-------------+ | RECORDAUDIO | +-------------+*/ startAudioRecording = recordAudioCommands.startAudioRecording; stopAudioRecording = recordAudioCommands.stopAudioRecording; /*--------------+ | RECORDSCREEN | +--------------+*/ // Note: _recentScreenRecorder is a property, not a function, so it's handled internally in recordscreen.js startRecordingScreen = recordScreenCommands.startRecordingScreen; stopRecordingScreen = recordScreenCommands.stopRecordingScreen; mobileStartScreenRecording = recordScreenCommands.mobileStartScreenRecording; mobileStopScreenRecording = recordScreenCommands.mobileStopScreenRecording; /*-------------+ | SCREENSHOTS | +-------------+*/ getScreenshot = screenshotCommands.getScreenshot; getElementScreenshot = screenshotCommands.getElementScreenshot; getViewportScreenshot = screenshotCommands.getViewportScreenshot; /*--------+ | SOURCE | +--------+*/ getPageSource = sourceCommands.getPageSource; mobileGetSource = sourceCommands.mobileGetSource; /*----------+ | TIMEOUTS | +----------+*/ pageLoadTimeoutW3C = timeoutCommands.pageLoadTimeoutW3C; pageLoadTimeoutMJSONWP = timeoutCommands.pageLoadTimeoutMJSONWP; scriptTimeoutW3C = timeoutCommands.scriptTimeoutW3C; scriptTimeoutMJSONWP = timeoutCommands.scriptTimeoutMJSONWP; asyncScriptTimeout = timeoutCommands.asyncScriptTimeout; setPageLoadTimeout = timeoutCommands.setPageLoadTimeout; setAsyncScriptTimeout = timeoutCommands.setAsyncScriptTimeout; /*-----+ | WEB | +-----+*/ setFrame = webCommands.setFrame; getCssProperty = webCommands.getCssProperty; submit = webCommands.submit; refresh = webCommands.refresh; getUrl = webCommands.getUrl; title = webCommands.title; getCookies = webCommands.getCookies; setCookie = webCommands.setCookie; deleteCookie = webCommands.deleteCookie; deleteCookies = webCommands.deleteCookies; cacheWebElement = webCommands.cacheWebElement; cacheWebElements = webCommands.cacheWebElements; executeAtom = webCommands.executeAtom; executeAtomAsync = webCommands.executeAtomAsync; getAtomsElement = webCommands.getAtomsElement; convertElementsForAtoms = webCommands.convertElementsForAtoms; getElementId = webCommands.getElementId; hasElementId = webCommands.hasElementId; findWebElementOrElements = webCommands.findWebElementOrElements; clickWebCoords = webCommands.clickWebCoords; getSafariIsIphone = webCommands.getSafariIsIphone; getSafariDeviceSize = webCommands.getSafariDeviceSize; getSafariIsNotched = webCommands.getSafariIsNotched; getExtraTranslateWebCoordsOffset = webCommands.getExtraTranslateWebCoordsOffset; getExtraNativeWebTapOffset = webCommands.getExtraNativeWebTapOffset; nativeWebTap = webCommands.nativeWebTap; translateWebCoords = webCommands.translateWebCoords; checkForAlert = webCommands.checkForAlert; waitForAtom = webCommands.waitForAtom; mobileWebNav = webCommands.mobileWebNav; getWdaLocalhostRoot = webCommands.getWdaLocalhostRoot; mobileCalibrateWebToRealCoordinatesTranslation = webCommands.mobileCalibrateWebToRealCoordinatesTranslation; mobileUpdateSafariPreferences = webCommands.mobileUpdateSafariPreferences; /*--------+ | XCTEST | +--------+*/ mobileRunXCTest = xctestCommands.mobileRunXCTest; mobileInstallXCTestBundle = xctestCommands.mobileInstallXCTestBundle; mobileListXCTestBundles = xctestCommands.mobileListXCTestBundles; /*----------------------+ | XCTEST SCREEN RECORD | +---------------------+*/ mobileStartXctestScreenRecording = xctestRecordScreenCommands.mobileStartXctestScreenRecording; mobileGetXctestScreenRecordingInfo = xctestRecordScreenCommands.mobileGetXctestScreenRecordingInfo; mobileStopXctestScreenRecording = xctestRecordScreenCommands.mobileStopXctestScreenRecording; constructor(opts: XCUITestDriverOpts, shouldValidateCaps = true) { super(opts, shouldValidateCaps); this.deviceConnectionsFactory = new DeviceConnectionsFactory(this.log); 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: undefined, alertMonitorAbortController: undefined, }; this.resetIos(); this.settings = new DeviceSettings(DEFAULT_SETTINGS, this.onSettingsUpdate.bind(this)); this.logs = {}; this._networkMonitorSession = 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; } // 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; } // 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])) { await recorder.interrupt(true); await recorder.cleanup(); } await this._networkMonitorSession?.interrupt(); this._networkMonitorSession = null; if (!_.isEmpty(this._perfRecorders)) { await Promise.all(this._perfRecorders.map((x) => x.stop(true))); this._perfRecorders = []; } if (this._conditionInducer) { 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}'`); // 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)) { try { new URL(caps.webDriverAgentUrl); } catch { 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[], ); } // ignoredWebviewBundleIds is an array, JSON array, or string if (caps.ignoredWebviewBundleIds) { caps.ignoredWebviewBundleIds = this.helpers.parseCapsArray( caps.ignoredWebviewBundleIds as string | string[], ); } // finally, return true since the superclass check passed, as did this return true; } // 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 = Number(this.opts.mjpegServerPort || DEFAULT_MJPEG_SERVER_PORT); this.log.debug( `Forwarding MJPEG server port ${mjpegServerPort} to local port ${mjpegServerPort}`, ); try { await this.deviceConnectionsFactory.requestConnection(this.opts.udid, mjpegServerPort, { devicePort: mjpegServerPort, platformVersion: this.opts.platformVersion, 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}`, {cause: 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 void (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 certificateCommands.installCustomSslCertFromCapability.bind(this)(); 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, ); } } } 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.activateRecentW