UNPKG

appium-xcuitest-driver

Version:

Appium driver for iOS using XCUITest for backend

507 lines (453 loc) 17.5 kB
import {resolveExecutablePath} from './utils'; import {doctor, fs, node} from 'appium/support'; import axios from 'axios'; import type {IDoctorCheck, AppiumLogger, DoctorCheckResult} from '@appium/types'; import '@colors/colors'; import {exec, SubProcess} from 'teen_process'; import memoize from 'lodash/memoize'; export class OptionalSimulatorCheck implements IDoctorCheck { static readonly SUPPORTED_SIMULATOR_PLATFORMS: SimulatorPlatform[] = [ { displayName: 'iOS', name: 'iphonesimulator', }, { displayName: 'tvOS', name: 'appletvsimulator', }, ]; log!: AppiumLogger; async diagnose(): Promise<DoctorCheckResult> { try { // https://github.com/appium/appium/issues/12093#issuecomment-459358120 await exec('xcrun', ['simctl', 'help']); } catch (err: any) { return doctor.nokOptional( `Testing on Simulator is not possible. Cannot run 'xcrun simctl': ${ err?.stderr || (err as Error).message }`, ); } const sdks = await this._listInstalledSdks(); for (const {displayName, name} of OptionalSimulatorCheck.SUPPORTED_SIMULATOR_PLATFORMS) { const errorPrefix = `Testing on ${displayName} Simulator is not possible`; if (!sdks.some(({platform}) => platform === name)) { return doctor.nokOptional(`${errorPrefix}: SDK is not installed`); } } return doctor.okOptional( `The following Simulator SDKs are installed:\n` + sdks .filter(({platform}) => OptionalSimulatorCheck.SUPPORTED_SIMULATOR_PLATFORMS.some( ({name}) => name === platform, ), ) .map(({displayName}) => `\t→ ${displayName}`) .join('\n'), ); } async fix(): Promise<string> { return `Install the desired Simulator SDK from Xcode's Settings -> Components`; } hasAutofix(): boolean { return false; } isOptional(): boolean { return true; } private async _listInstalledSdks(): Promise<InstalledSdk[]> { const {stdout} = await exec('xcodebuild', ['-json', '-showsdks']); return JSON.parse(stdout); } } export const optionalSimulatorCheck = new OptionalSimulatorCheck(); export class OptionalApplesimutilsCommandCheck implements IDoctorCheck { static readonly README_LINK = 'https://github.com/appium/appium-xcuitest-driver/blob/master/docs/reference/execute-methods.md#mobile-setpermission'; log!: AppiumLogger; async diagnose(): Promise<DoctorCheckResult> { const applesimutilsPath = await resolveExecutablePath('applesimutils'); return applesimutilsPath ? doctor.okOptional(`applesimutils is installed at: ${applesimutilsPath}`) : doctor.nokOptional('applesimutils are not installed'); } async fix(): Promise<string> { return `Why ${'applesimutils'.bold} is needed and how to install it: ${OptionalApplesimutilsCommandCheck.README_LINK}`; } hasAutofix(): boolean { return false; } isOptional(): boolean { return true; } } export const optionalApplesimutilsCheck = new OptionalApplesimutilsCommandCheck(); export class OptionalFfmpegCheck implements IDoctorCheck { static readonly FFMPEG_BINARY = 'ffmpeg'; static readonly FFMPEG_INSTALL_LINK = 'https://www.ffmpeg.org/download.html'; log!: AppiumLogger; async diagnose(): Promise<DoctorCheckResult> { const ffmpegPath = await resolveExecutablePath(OptionalFfmpegCheck.FFMPEG_BINARY); return ffmpegPath ? doctor.okOptional(`${OptionalFfmpegCheck.FFMPEG_BINARY} exists at '${ffmpegPath}'`) : doctor.nokOptional(`${OptionalFfmpegCheck.FFMPEG_BINARY} cannot be found`); } async fix(): Promise<string> { return ( `${`${OptionalFfmpegCheck.FFMPEG_BINARY}`.bold} is used to capture screen recordings from the device under test. ` + `Please read ${OptionalFfmpegCheck.FFMPEG_INSTALL_LINK}.` ); } hasAutofix(): boolean { return false; } isOptional(): boolean { return true; } } export const optionalFfmpegCheck = new OptionalFfmpegCheck(); const REMOTE_XPC_PACKAGE_NAME = 'appium-ios-remotexpc'; const isRemoteXpcDependencyAvailable = memoize( async function ensureRemoteXpcDependencyAvailable(): Promise<boolean> { try { // We only care that the module can be imported; we don't need to use it here. await import(REMOTE_XPC_PACKAGE_NAME); return true; } catch { return false; } }, ); const getXcuitestDriverRoot = memoize(function getXcuitestDriverRoot(): string | null { return node.getModuleRootSync('appium-xcuitest-driver', __filename); }); export class OptionalIosRemoteXpcDependencyCheck implements IDoctorCheck { static readonly README_LINK = 'https://github.com/appium/appium-ios-remotexpc'; log!: AppiumLogger; async diagnose(): Promise<DoctorCheckResult> { const available = await isRemoteXpcDependencyAvailable(); if (available) { return doctor.okOptional( `${REMOTE_XPC_PACKAGE_NAME} is installed and can be imported. ` + `Remote XPC-based features are available for real devices (iOS/tvOS 18+).`, ); } return doctor.nokOptional( `${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` + `Install it as an optional dependency if you plan to use Remote XPC-based features ` + `on real devices (iOS/tvOS 18+). Tests may still run without it, but some ` + `advanced functionality might not work or be unavailable.`, ); } async fix(): Promise<string> { const driverRoot = getXcuitestDriverRoot(); const locationHint = driverRoot ? `cd "${driverRoot}"; ` : ''; return ( `${`${REMOTE_XPC_PACKAGE_NAME}`.bold} provides Remote XPC communication ` + `and tunneling support for real devices (iOS/tvOS 18+). ` + `Run '${locationHint}npm install ${REMOTE_XPC_PACKAGE_NAME}'. ` + `For more information, see ${OptionalIosRemoteXpcDependencyCheck.README_LINK}.` ); } hasAutofix(): boolean { return false; } isOptional(): boolean { return true; } } export const optionalIosRemoteXpcDependencyCheck = new OptionalIosRemoteXpcDependencyCheck(); const TUNNEL_SCRIPT_TIMEOUT_MS = 5000; const API_READY_PATTERN = /:\d+\/remotexpc\/tunnels/; export class OptionalTunnelAvailabilityCheck implements IDoctorCheck { static readonly README_LINK = 'https://github.com/appium/appium-ios-tuntap'; static readonly TUNNEL_CREATION_COMMAND = 'sudo appium driver run xcuitest tunnel-creation'; log!: AppiumLogger; async diagnose(): Promise<DoctorCheckResult> { const remoteXpcAvailable = await isRemoteXpcDependencyAvailable(); if (!remoteXpcAvailable) { return doctor.nokOptional( `Remote XPC tunnel availability cannot be checked because ` + `${REMOTE_XPC_PACKAGE_NAME} is not installed or cannot be imported. ` + `Install it first using the '${REMOTE_XPC_PACKAGE_NAME}' optional check.`, ); } const platform = process.platform; if (platform !== 'darwin' && platform !== 'linux') { return doctor.okOptional( `Tunnel availability status cannot be automatically verified on platform '${platform}'.`, ); } const candidatePorts = await this._getListeningTcpPorts(); if (candidatePorts.length > 0) { const registryResult = await this._probeTunnelRegistry(candidatePorts); if (registryResult) { return registryResult; } } return await this._runTunnelCreationScript(); } async fix(): Promise<string> { return ( `The Remote XPC tunnel infrastructure is used for IPv6 tunneling when testing against real ` + `devices (iOS/tvOS 18+). ` + `To explicitly start or verify tunnels when needed, run ` + `'${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' with sudo/root privileges. ` + `See ${OptionalTunnelAvailabilityCheck.README_LINK} for more details about tunnel usage.` ); } hasAutofix(): boolean { return false; } isOptional(): boolean { return true; } /** * Returns listening TCP ports. Uses pure Node on Linux (/proc/net/tcp, tcp6); uses netstat on macOS. */ private async _getListeningTcpPorts(): Promise<number[]> { if (process.platform === 'linux') { return await this._getListeningTcpPortsLinux(); } if (process.platform === 'darwin') { return await this._getListeningTcpPortsDarwin(); } return []; } /** * Linux: parse /proc/net/tcp and /proc/net/tcp6 (pure Node, no exec). State 0A = LISTEN. */ private async _getListeningTcpPortsLinux(): Promise<number[]> { const ports = new Set<number>(); const files = ['/proc/net/tcp', '/proc/net/tcp6'] as const; for (const file of files) { try { const raw = await fs.readFile(file, 'utf8'); const lines = raw.split('\n'); for (let i = 1; i < lines.length; i++) { const parts = lines[i].trim().split(/\s+/); if (parts.length < 4) { continue; } const state = parts[3]; if (state !== '0A') { continue; // 0A = LISTEN } const localAddr = parts[1]; const colon = localAddr.lastIndexOf(':'); if (colon === -1) { continue; } const portHex = localAddr.slice(colon + 1); const port = Number.parseInt(portHex, 16); if (Number.isInteger(port) && port > 0 && port <= 65535) { ports.add(port); } } } catch { // File missing or unreadable (e.g. not Linux or permissions) } } return Array.from(ports); } /** * macOS: netstat -anv -p tcp (Node has no API for system-wide listening ports). */ private async _getListeningTcpPortsDarwin(): Promise<number[]> { try { const {stdout} = await exec('netstat', ['-anv', '-p', 'tcp']); const ports = new Set<number>(); for (const line of stdout.split('\n')) { const trimmed = line.trim(); if (!trimmed || !trimmed.toLowerCase().startsWith('tcp')) { continue; } const parts = trimmed.split(/\s+/); if (parts.length < 4) { continue; } const portMatch = /\.(\d+)$/.exec(parts[3]); if (!portMatch) { continue; } const port = Number.parseInt(portMatch[1], 10); if (Number.isInteger(port) && port > 0) { ports.add(port); } } return Array.from(ports); } catch { return []; } } /** * Probes candidate ports for the tunnel registry API in parallel; resolves as soon as any succeed, else null. */ private async _probeTunnelRegistry(ports: number[]): Promise<DoctorCheckResult | null> { if (ports.length === 0) { return null; } return await new Promise<DoctorCheckResult | null>((resolve) => { let settled = false; let remaining = ports.length; const maybeResolveNull = () => { remaining -= 1; if (!settled && remaining === 0) { settled = true; resolve(null); } }; for (const port of ports) { void (async () => { try { const res = await axios.get(`http://127.0.0.1:${port}/remotexpc/tunnels`, { timeout: 1000, validateStatus: (status) => status === 200, }); const data = res.data as any; if (!settled && data != null && typeof data === 'object' && data.status === 'OK') { settled = true; resolve( doctor.okOptional( `Detected an active Remote XPC tunnel registry process on port ${port}. ` + `The Remote XPC tunnel infrastructure appears to be available, so Remote XPC-based ` + `features for real devices (iOS/tvOS 18+) should be available.`, ), ); return; } } catch { // Ignore individual probe failures; we'll resolve to null only if all fail. } if (!settled) { maybeResolveNull(); } })(); } }); } /** * Runs the tunnel-creation driver script as a subprocess to avoid blocking doctor if the script hangs. * Waits for exit, TUNNEL_SCRIPT_TIMEOUT_MS (5s), or output string indicating registry is up; * then evaluates or stops the process. */ private async _runTunnelCreationScript(): Promise<DoctorCheckResult> { const homeCwd = process.env.HOME || process.cwd(); const driverRoot = getXcuitestDriverRoot(); let combinedOutput = ''; let resolveApiReady: () => void; const apiReadyPromise = new Promise<{reason: 'api'}>((resolve) => { resolveApiReady = () => resolve({reason: 'api'}); }); const sub = driverRoot != null ? new SubProcess(process.execPath, ['./scripts/tunnel-creation.mjs'], {cwd: driverRoot}) : new SubProcess('appium', ['driver', 'run', 'xcuitest', 'tunnel-creation'], { cwd: homeCwd, }); const appendLine = (line: string) => { combinedOutput += line + '\n'; if (API_READY_PATTERN.test(line)) { resolveApiReady(); } }; sub.on('line-stdout', appendLine); sub.on('line-stderr', appendLine); const exitPromise = new Promise<{reason: 'exit'; code?: number; signal?: string}>((resolve) => { sub.once('exit', (code, signal) => resolve({reason: 'exit', code, signal})); }); const timeoutPromise = new Promise<{reason: 'timeout'}>((resolve) => { setTimeout(() => resolve({reason: 'timeout'}), TUNNEL_SCRIPT_TIMEOUT_MS); }); try { await sub.start(0); } catch (err) { const message = ((err as any).stderr || (err as Error).message || '').toString(); return doctor.nokOptional( `Could not start '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}'. ` + `Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work or be unavailable. ` + `Details: ${message}`, ); } const winner = await Promise.race([exitPromise, timeoutPromise, apiReadyPromise]); if (winner.reason === 'exit') { const code = (winner as {reason: 'exit'; code?: number; signal?: string}).code; return this._evaluateTunnelScriptOutput(combinedOutput.trim(), code); } if (sub.isRunning) { try { await sub.stop('SIGTERM', 500); } catch { // Subprocess did not exit within 500ms; force-kill so doctor process can exit if (sub.pid != null) { try { process.kill(sub.pid, 'SIGKILL'); } catch { // ignore if already gone } } } } return doctor.okOptional( `The tunnel script was started; the registry was detected or the check timed out. ` + `Tunnel infrastructure for real devices (iOS/tvOS 18+) should be available when run with sufficient privileges.`, ); } /** * Interprets tunnel-creation script stdout+stderr and optional exit code; returns the appropriate doctor result. * Output pattern matches take priority over a non-zero exit code. */ private _evaluateTunnelScriptOutput( combinedOutput: string, exitCode?: number | null, ): DoctorCheckResult { if (/No devices found/i.test(combinedOutput)) { return doctor.okOptional( `The Remote XPC tunnel-creation script can be invoked via '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}', ` + `but no real devices are currently connected.`, ); } if (/must be run as root|operation not permitted|permission denied/i.test(combinedOutput)) { return doctor.okOptional( `The tunnel-creation script '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}' is available, ` + `but did not run with elevated privileges (e.g. root check or TUN/TAP creation). ` + `This is expected when not running with sudo/root. ` + `When you actually need Remote XPC-based functionality for real devices (iOS/tvOS 18+), ` + `run the same command with sufficient privileges to establish the tunnel.`, ); } if (exitCode != null && exitCode !== 0) { return doctor.nokOptional( `The tunnel script exited with code ${exitCode}. ` + `Without a working tunnel, Remote XPC-based functionality on real devices (iOS/tvOS 18+) might not work. ` + (combinedOutput ? `Output:\n${combinedOutput}` : ''), ); } return doctor.okOptional( `Successfully ran '${OptionalTunnelAvailabilityCheck.TUNNEL_CREATION_COMMAND}'. ` + `The Remote XPC tunnel infrastructure should be available for creating tunnels.` + (combinedOutput ? `\nLast output:\n${combinedOutput}` : ''), ); } } export const optionalTunnelAvailabilityCheck = new OptionalTunnelAvailabilityCheck(); interface SimulatorPlatform { displayName: string; name: string; } interface InstalledSdk { buildID?: string; canonicalName: string; displayName: string; isBaseSdk: boolean; platform: string; platformPath: string; platformVersion: string; productBuildVersion?: string; productCopyright?: string; productName?: string; productVersion?: string; sdkPath: string; sdkVersion: string; }