UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

321 lines (288 loc) 10.1 kB
import type {AppiumLogger} from '@appium/types'; import type {DevicePortForwarder, LockdownService} from 'appium-ios-remotexpc'; import {isIos18OrNewerPlatform} from '../../utils'; import {getLastRemoteXPCImportError, tryLoadRemoteXPCModule} from './module-loader'; import {isDeviceListedInUsbmux} from './usbmux-utils'; import { formatRemoteXPCFallbackLog, isTunnelAvailabilityError, RemoteXPCUnavailableError, wrapRemoteXPCConnectionError, type RemoteXPCEsmModule, type RemoteXPCServices, type RemoteXPCTestAttachment, type RemoteXPCTestRunner, } from './utils'; const TUNNEL_REGISTRY_PORT_PROBE_TIMEOUT_MS = 3000; /** * Per-driver-session RemoteXPC availability state. * * Initialized lazily on the first remotexpc operation. When the initial tunnel registry probe * fails with {@link isTunnelAvailabilityError}, remotexpc is disabled for the remainder of the * session so callers fall back without re-probing on every service call. * Later per-operation tunnel failures do not flip that cached state. */ export class RemoteXPCFacade { private initPromise: Promise<void> | null = null; private initialized = false; private enabled = false; private module: RemoteXPCEsmModule | null = null; private services: RemoteXPCServices | null = null; private useUsbMuxPath = false; private sessionFallbackLogged = false; private lastImportError: Error | null = null; constructor( readonly udid: string, private readonly platformVersion: string | undefined, private readonly log: AppiumLogger, private readonly isRealDevice: boolean, ) {} get eligible(): boolean { return this.isRealDevice && isIos18OrNewerPlatform(this.platformVersion); } static async tryGetServicesStatic(platformVersion: string | null | undefined): Promise<RemoteXPCServices | null> { if (platformVersion && !isIos18OrNewerPlatform(platformVersion)) { return null; } const mod = await tryLoadRemoteXPCModule(); if (!mod) { return null; } return mod.Services; } /** * Whether remotexpc should be used for service operations in this session. */ async determineAvailability(): Promise<boolean> { await this.ensureInitialized(); return this.enabled; } /** * Loaded remotexpc Services export when {@link determineAvailability} is true. */ async getServices(): Promise<RemoteXPCServices> { await this.ensureInitialized(); if (!this.enabled || !this.services) { throw new Error(`RemoteXPC is not available for device '${this.udid}' in this session`); } return this.services; } async tryGetServices(): Promise<RemoteXPCServices | null> { if (!(await this.determineAvailability())) { return null; } return this.services; } /** * XCTestRunner class from the loaded module. */ async getXCTestRunner(): Promise<RemoteXPCTestRunner> { return (await this.requireModule()).XCTestRunner; } /** * XCTestAttachment class from the loaded module. */ async getXCTestAttachment(): Promise<RemoteXPCTestAttachment> { return (await this.requireModule()).XCTestAttachment; } /** * Resolves which RemoteXPC lockdown connector to use for this device. * * @throws When RemoteXPC lockdown cannot be used */ async resolveLockdownStrategy(): Promise<'remotexpc-usbmux' | 'remotexpc-tunnel'> { if (!this.eligible) { throw new RemoteXPCUnavailableError(); } try { await this.requireModule(); } catch { throw new RemoteXPCUnavailableError(); } if (this.useUsbMuxPath) { return 'remotexpc-usbmux'; } return 'remotexpc-tunnel'; } /** * Creates a RemoteXPC device port forwarder when eligible and available. * * @throws When RemoteXPC port forwarding cannot be used */ async createDevicePortForwarder(localPort: number, devicePort: number): Promise<DevicePortForwarder> { if (!this.eligible) { throw new RemoteXPCUnavailableError(); } let mod: RemoteXPCEsmModule; try { mod = await this.requireModule(); } catch { throw new RemoteXPCUnavailableError(); } if (this.useUsbMuxPath) { this.log.debug(`Using RemoteXPC usbmux strategy for '${this.udid}'`); return new mod.DevicePortForwarder(localPort, devicePort, { primaryConnector: () => mod.connectViaUsbmux(this.udid, devicePort), }); } if (!(await this.determineAvailability())) { throw wrapRemoteXPCConnectionError( new Error('RemoteXPC tunnel is not available for this session'), `Cannot create port forwarder via RemoteXPC tunnel for '${this.udid}'`, ); } this.log.debug(`Using RemoteXPC tunnel strategy for '${this.udid}'`); return new mod.DevicePortForwarder(localPort, devicePort, { primaryConnector: () => mod.connectViaTunnel(this.udid, devicePort), }); } /** * Runs a lockdown operation over the usbmux path for this device. */ async withUsbMuxLockdown<T>( operation: (lockdown: LockdownService) => Promise<T | undefined>, ): Promise<T | undefined> { try { const mod = await this.requireModule(); const {lockdownService} = await mod.createLockdownServiceByUDID(this.udid); try { return await operation(lockdownService); } finally { lockdownService.close(); } } catch (err) { throw new Error( `Failed to read lockdown via appium-ios-remotexpc USBMUX path for '${this.udid}': ` + `${(err as Error).message}`, {cause: err}, ); } } /** * Runs a lockdown operation over the RSD tunnel for this device. */ async withTunnelLockdown<T>( operation: (lockdown: LockdownService) => Promise<T | undefined>, ): Promise<T | undefined> { try { const mod = await this.requireModule(); const lockdown = await mod.createLockdownServiceForTunnel(this.udid); try { return await operation(lockdown); } finally { lockdown.close(); } } catch (err) { throw wrapRemoteXPCConnectionError(err, `Tunnel lockdown failed for '${this.udid}'`); } } /** * Runs a RemoteXPC service operation when the session allows it. * * Tunnel availability failures during an operation are logged and return `null` without * disabling remotexpc for the session (unlike the one-time init probe). * Other failures are logged per call and also return `null` so callers can fall back once. */ async attemptService<T>(feature: string, operation: (services: RemoteXPCServices) => Promise<T>): Promise<T | null> { if (!(await this.determineAvailability())) { return null; } try { return await operation(await this.getServices()); } catch (err) { this.handleServiceError(feature, err, 'log'); return null; } } /** * Runs a RemoteXPC service operation when the session allows it. * * @throws When remotexpc is disabled for the session or the operation fails. */ async requireService<T>(feature: string, operation: (services: RemoteXPCServices) => Promise<T>): Promise<T> { if (!(await this.determineAvailability())) { throw wrapRemoteXPCConnectionError( this.lastImportError ?? new Error('RemoteXPC is not available for this session'), `Failed ${feature} via RemoteXPC for '${this.udid}'`, ); } try { return await operation(await this.getServices()); } catch (err) { this.handleServiceError(feature, err, 'throw'); throw wrapRemoteXPCConnectionError(err, `Failed ${feature} via RemoteXPC for '${this.udid}'`); } } /** * Disable remotexpc for the remainder of this session after the initial tunnel registry probe fails. */ noteTunnelUnavailable(feature: string, err: unknown): void { if (!isTunnelAvailabilityError(err)) { return; } this.enabled = false; this.services = null; if (!this.sessionFallbackLogged) { this.log.warn(formatRemoteXPCFallbackLog(feature, err)); this.sessionFallbackLogged = true; } else { this.log.debug(`RemoteXPC ${feature} skipped: tunnel unavailable for this session`); } } private async ensureInitialized(): Promise<void> { if (this.initialized) { return; } if (!this.initPromise) { this.initPromise = this.initialize(); } await this.initPromise; this.initialized = true; } private async initialize(): Promise<void> { if (!this.eligible) { return; } const loadedModule = await tryLoadRemoteXPCModule(); if (!loadedModule) { const err = getLastRemoteXPCImportError(); this.lastImportError = err; this.log.warn(`appium-ios-remotexpc unavailable for '${this.udid}': ${err?.message ?? 'unknown'}`); return; } this.module = loadedModule; this.useUsbMuxPath = await isDeviceListedInUsbmux(loadedModule, this.udid, this.log); try { await loadedModule.Services.getTunnelForDevice(this.udid, { waitMs: TUNNEL_REGISTRY_PORT_PROBE_TIMEOUT_MS, }); this.services = loadedModule.Services; this.enabled = true; this.log.debug(`RemoteXPC enabled for '${this.udid}' (tunnel registry reachable)`); } catch (err) { this.noteTunnelUnavailable('session initialization', err); } } private async requireModule(): Promise<RemoteXPCEsmModule> { await this.ensureInitialized(); if (!this.module) { throw wrapRemoteXPCConnectionError( this.lastImportError ?? new Error('appium-ios-remotexpc is not available'), `RemoteXPC module is not available for '${this.udid}'`, ); } return this.module; } private handleServiceError(feature: string, err: unknown, onNonTunnelFailure: 'log' | 'throw'): void { const message = formatRemoteXPCFallbackLog(feature, err); if (isTunnelAvailabilityError(err)) { if (onNonTunnelFailure === 'log') { this.log.warn(message); } return; } if (onNonTunnelFailure === 'log') { this.log.error(message); } } }