""" go_ios Runner Module Provides Python functions to interact with the go_ios command line tool for running iOS xctest bundles like Fastbot and WebDriverAgent. """ import logging import os import platform import subprocess from typing import Optional, Dict, List, Tuple logger = logging.getLogger(__name__) def get_ios_executable() -> str: """ Get the path to the platform-specific ios executable. Automatically detects the current platform (macOS, Linux, Windows) and returns the appropriate executable path from the bin directory. :return: Absolute path to the ios executable :raises FileNotFoundError: If the executable for the current platform is not found """ # Get the bin directory path relative to this file current_dir = os.path.dirname(os.path.abspath(__file__)) bin_dir = os.path.join(current_dir, "bin") # Detect current platform and architecture system = platform.system().lower() machine = platform.machine().lower() # Map platform and architecture to executable name if system == "darwin": # macOS - check for arm64 (Apple Silicon) or x86_64 if machine in ("arm64", "aarch64"): executable_name = "ios-darwin-arm64" else: # For Intel Macs, we might need to use arm64 with Rosetta # or add an x86_64 binary if available executable_name = "ios-darwin-arm64" logger.warning( f"No native x86_64 macOS binary found, using arm64 binary " f"(may require Rosetta 2)" ) elif system == "linux": executable_name = "ios-linux-amd64" elif system == "windows": executable_name = "ios-windows-amd64.exe" else: raise FileNotFoundError( f"Unsupported platform: {system}. " f"Supported platforms: darwin (macOS), linux, windows" ) executable_path = os.path.join(bin_dir, executable_name) if not os.path.exists(executable_path): raise FileNotFoundError( f"iOS executable not found at: {executable_path}. " f"Please ensure the go_ios binaries are installed." ) # Ensure the executable has proper permissions on Unix systems if system != "windows": if not os.access(executable_path, os.X_OK): logger.info(f"Setting executable permission for: {executable_path}") os.chmod(executable_path, 0o755) return executable_path def run_xctest( bundle_id: str, test_runner_bundle_id: str, udid: str, xctest_config: str, env_vars: Optional[Dict[str, str]] = None, ios_executable: Optional[str] = None, timeout: Optional[int] = None, background: bool = False ) -> Tuple[Optional[subprocess.Popen], Optional[str], Optional[int]]: """ Run an iOS xctest bundle using go_ios. :param bundle_id: Bundle ID of the test app (e.g., com.tpshos.FastbotRunner.xctrunner) :param test_runner_bundle_id: Bundle ID of the test runner :param udid: Device UDID :param xctest_config: XCTest configuration name (e.g., FastbotRunner.xctest) :param env_vars: Optional dictionary of environment variables to pass to the test :param ios_executable: Optional path to ios executable (auto-detected if not provided) :param timeout: Optional timeout in seconds (only used if background=False) :param background: If True, run in background and return Popen object :return: Tuple of (Popen object or None, stdout or None, return code or None) When background=True: (Popen, None, None) When background=False: (None, stdout, returncode) """ if ios_executable is None: ios_executable = get_ios_executable() # Build the command cmd = [ ios_executable, "runtest", f"--bundle-id={bundle_id}", f"--test-runner-bundle-id={test_runner_bundle_id}", f"--udid={udid}", f"--xctest-config={xctest_config}", ] # Add environment variables if env_vars: for key, value in env_vars.items(): cmd.extend(["--env", f"{key}={value}"]) logger.info(f"Running xctest command: {' '.join(cmd)}") try: if background: # Run in background, return Popen object for later management process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) logger.info(f"Started xctest in background with PID: {process.pid}") return process, None, None else: # Run synchronously and wait for completion result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout ) if result.returncode != 0: logger.error(f"xctest failed with return code {result.returncode}") logger.error(f"stderr: {result.stderr}") logger.error(f"stdout: {result.stdout}") return None, result.stdout + result.stderr, result.returncode except subprocess.TimeoutExpired as e: logger.error(f"xctest timed out after {timeout} seconds") raise except Exception as e: logger.error(f"Failed to run xctest: {e}") raise def start_fastbot( udid: str, target_bundle_id: str, duration: int = 10, throttle: int = 1000, fastbot_bundle_id: str = "com.tpshos.FastbotRunner.xctrunner", fastbot_xctest_config: str = "FastbotRunner.xctest", ios_executable: Optional[str] = None, background: bool = True, timeout: Optional[int] = None ) -> Tuple[Optional[subprocess.Popen], Optional[str], Optional[int]]: """ Start Fastbot for automated iOS app testing. :param udid: Device UDID (e.g., "00008101-0016601022C0001E") :param target_bundle_id: Bundle ID of the target app to test (e.g., "com.apple.AppStore") :param duration: Test duration in seconds (default: 10) :param throttle: Throttle time in milliseconds between actions (default: 1000) :param fastbot_bundle_id: Bundle ID of Fastbot runner (default: com.tpshos.FastbotRunner.xctrunner) :param fastbot_xctest_config: XCTest config name (default: FastbotRunner.xctest) :param ios_executable: Optional path to ios executable (auto-detected if not provided) :param background: If True, run in background (default: True) :param timeout: Optional timeout in seconds (only used if background=False) :return: Tuple of (Popen object or None, stdout or None, return code or None) Example: # Start Fastbot in background process, _, _ = start_fastbot( udid="00008101-0016601022C0001E", target_bundle_id="com.apple.AppStore", duration=60, throttle=500 ) # Later, stop the process process.terminate() """ env_vars = { "BUNDLEID": target_bundle_id, "duration": str(duration), "throttle": str(throttle), } logger.info( f"Starting Fastbot for app {target_bundle_id} on device {udid} " f"(duration={duration}s, throttle={throttle}ms)" ) return run_xctest( bundle_id=fastbot_bundle_id, test_runner_bundle_id=fastbot_bundle_id, udid=udid, xctest_config=fastbot_xctest_config, env_vars=env_vars, ios_executable=ios_executable, background=background, timeout=timeout ) def start_wda( udid: str, port: int = 8100, ip: str = "127.0.0.1", wda_bundle_id: str = "com.tpshos.WebDriverAgentRunner.xctrunner", wda_xctest_config: str = "WebDriverAgentRunner.xctest", ios_executable: Optional[str] = None, background: bool = True, timeout: Optional[int] = None ) -> Tuple[Optional[subprocess.Popen], Optional[str], Optional[int]]: """ Start WebDriverAgent (WDA) for iOS device automation. :param udid: Device UDID (e.g., "00008101-0016601022C0001E") :param port: Port for WDA server (default: 8100) :param ip: IP address for WDA to listen on (default: "127.0.0.1") :param wda_bundle_id: Bundle ID of WDA runner (default: com.tpshos.WebDriverAgentRunner.xctrunner) :param wda_xctest_config: XCTest config name (default: WebDriverAgentRunner.xctest) :param ios_executable: Optional path to ios executable (auto-detected if not provided) :param background: If True, run in background (default: True) :param timeout: Optional timeout in seconds (only used if background=False) :return: Tuple of (Popen object or None, stdout or None, return code or None) Example: # Start WDA in background process, _, _ = start_wda( udid="00008101-0016601022C0001E", port=8100 ) # WDA will be accessible at http://127.0.0.1:8100 # Later, stop the process process.terminate() """ env_vars = { "USE_PORT": str(port), "USE_IP": ip, } logger.info( f"Starting WDA on device {udid} at {ip}:{port}" ) return run_xctest( bundle_id=wda_bundle_id, test_runner_bundle_id=wda_bundle_id, udid=udid, xctest_config=wda_xctest_config, env_vars=env_vars, ios_executable=ios_executable, background=background, timeout=timeout ) def stop_process(process: subprocess.Popen, timeout: int = 5) -> bool: """ Gracefully stop a running process. :param process: Popen object to stop :param timeout: Timeout in seconds to wait for graceful termination :return: True if process was stopped successfully """ if process is None or process.poll() is not None: return True # Already stopped logger.info(f"Stopping process with PID: {process.pid}") try: process.terminate() process.wait(timeout=timeout) return True except subprocess.TimeoutExpired: logger.warning(f"Process {process.pid} did not terminate gracefully, killing...") process.kill() process.wait() return True except Exception as e: logger.error(f"Failed to stop process: {e}") return False def start_port_forward( local_port: int, device_port: int, udid: Optional[str] = None, ios_executable: Optional[str] = None, background: bool = True ) -> Tuple[Optional[subprocess.Popen], Optional[str], Optional[int]]: """ Start port forwarding from local machine to iOS device. :param local_port: Local port to forward from :param device_port: Device port to forward to :param udid: Optional device UDID (uses first device if not provided) :param ios_executable: Optional path to ios executable :param background: If True, run in background (default: True) :return: Tuple of (Popen object or None, stdout or None, return code or None) """ if ios_executable is None: ios_executable = get_ios_executable() cmd = [ios_executable, "forward", str(local_port), str(device_port)] if udid: cmd.extend(["--udid", udid]) logger.info(f"Starting port forwarding: {local_port} -> {device_port} (UDID: {udid or 'auto'})") try: if background: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) logger.info(f"Port forwarding started with PID: {process.pid}") return process, None, None else: result = subprocess.run(cmd, capture_output=True, text=True) return None, result.stdout + result.stderr, result.returncode except Exception as e: logger.error(f"Failed to start port forwarding: {e}") raise def start_tunnel( ios_executable: Optional[str] = None, background: bool = True, use_sudo: bool = False, sudo_password: Optional[str] = None ) -> Tuple[Optional[subprocess.Popen], Optional[str], Optional[int]]: """ Start iOS tunnel (required for device communication). :param ios_executable: Optional path to ios executable :param background: If True, run in background (default: True) :param use_sudo: If True, run with sudo (may be required on some systems) :param sudo_password: Sudo password if use_sudo is True :return: Tuple of (Popen object or None, stdout or None, return code or None) """ if ios_executable is None: ios_executable = get_ios_executable() cmd = [ios_executable, "tunnel", "start"] if use_sudo: if sudo_password: # Use echo to provide password to sudo cmd = ["sudo", "-S"] + cmd logger.info("Starting tunnel with sudo (password provided)") else: cmd = ["sudo"] + cmd logger.info("Starting tunnel with sudo (will prompt for password)") else: logger.info("Starting tunnel") try: if background: if use_sudo and sudo_password: # Provide password via stdin process = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) # Send password process.stdin.write(sudo_password + "\n") process.stdin.flush() else: process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) logger.info(f"Tunnel started with PID: {process.pid}") return process, None, None else: if use_sudo and sudo_password: result = subprocess.run( cmd, input=sudo_password + "\n", capture_output=True, text=True ) else: result = subprocess.run(cmd, capture_output=True, text=True) return None, result.stdout + result.stderr, result.returncode except Exception as e: logger.error(f"Failed to start tunnel: {e}") raise # Convenience class for managing Fastbot and WDA together class GoIOSRunner: """ High-level class for managing go_ios xctest processes. Provides convenient methods to start/stop Fastbot and WDA, with automatic cleanup on destruction. Example: runner = GoIOSRunner(udid="00008101-0016601022C0001E") # Start WDA first runner.start_wda(port=8100) # Then start Fastbot runner.start_fastbot( target_bundle_id="com.apple.AppStore", duration=60 ) # When done runner.stop_all() """ def __init__(self, udid: str, ios_executable: Optional[str] = None, wda_url: Optional[str] = None): """ Initialize GoIOSRunner. :param udid: Device UDID :param ios_executable: Optional path to ios executable :param wda_url: WDA URL (http://... for Client, UDID/empty for USBClient) """ self.udid = udid self.wda_url = wda_url self.ios_executable = ios_executable or get_ios_executable() self._wda_process: Optional[subprocess.Popen] = None self._fastbot_process: Optional[subprocess.Popen] = None self._port_forward_processes: List[subprocess.Popen] = [] # Can have multiple port forwards self._tunnel_process: Optional[subprocess.Popen] = None @property def is_client_mode(self) -> bool: """判断是否为 Client(HTTP) 连接模式 未指定 wda_url 时为了向后兼容默认返回 True 开启端口转发。 否则只有以 http: 开头才判定为 Client 模式(需要端口转发), UDID 或空字符串等则判定为 USBClient 模式(无需端口转发)。 """ if self.wda_url is None: return True return str(self.wda_url).startswith("http:") def start_wda( self, port: int = 8100, ip: str = "127.0.0.1", wda_bundle_id: str = "com.tpshos.WebDriverAgentRunner.xctrunner", wda_xctest_config: str = "WebDriverAgentRunner.xctest" ) -> subprocess.Popen: """ Start WDA server. :return: Popen object for the WDA process """ if self._wda_process and self._wda_process.poll() is None: logger.warning("WDA is already running, stopping first...") self.stop_wda() process, _, _ = start_wda( udid=self.udid, port=port, ip=ip, wda_bundle_id=wda_bundle_id, wda_xctest_config=wda_xctest_config, ios_executable=self.ios_executable, background=True ) self._wda_process = process return process def start_fastbot( self, target_bundle_id: str, duration: int = 10, throttle: int = 1000, fastbot_bundle_id: str = "com.tpshos.FastbotRunner.xctrunner", fastbot_xctest_config: str = "FastbotRunner.xctest" ) -> subprocess.Popen: """ Start Fastbot for automated testing. :return: Popen object for the Fastbot process """ if self._fastbot_process and self._fastbot_process.poll() is None: logger.warning("Fastbot is already running, stopping first...") self.stop_fastbot() process, _, _ = start_fastbot( udid=self.udid, target_bundle_id=target_bundle_id, duration=duration, throttle=throttle, fastbot_bundle_id=fastbot_bundle_id, fastbot_xctest_config=fastbot_xctest_config, ios_executable=self.ios_executable, background=True ) self._fastbot_process = process return process def stop_wda(self, timeout: int = 5) -> bool: """Stop WDA server.""" result = stop_process(self._wda_process, timeout) self._wda_process = None return result def stop_fastbot(self, timeout: int = 5) -> bool: """Stop Fastbot.""" result = stop_process(self._fastbot_process, timeout) self._fastbot_process = None return result def stop_all(self, timeout: int = 5) -> bool: """Stop all running processes.""" return self.stop_fastbot(timeout) and self.stop_wda(timeout) def is_wda_running(self) -> bool: """Check if WDA is currently running.""" return self._wda_process is not None and self._wda_process.poll() is None def is_fastbot_running(self) -> bool: """Check if Fastbot is currently running.""" return self._fastbot_process is not None and self._fastbot_process.poll() is None def start_infrastructure( self, wda_port: int = 8100, fastbot_port: Optional[int] = None, use_tunnel: bool = False, tunnel_sudo_password: Optional[str] = None ) -> bool: """ Start required infrastructure services (tunnel and port forwarding). :param wda_port: Port for WDA (default: 8100) :param fastbot_port: Optional port for Fastbot status (e.g., 9197) :param use_tunnel: If True, start tunnel (may not be needed on all systems) :param tunnel_sudo_password: Sudo password for tunnel if required :return: True if all services started successfully """ try: # Start tunnel if requested if use_tunnel: logger.info("Starting tunnel...") process, _, _ = start_tunnel( ios_executable=self.ios_executable, background=True, use_sudo=bool(tunnel_sudo_password), sudo_password=tunnel_sudo_password ) self._tunnel_process = process import time time.sleep(2) # Give tunnel time to start # Start WDA port forwarding if in Client mode if self.is_client_mode: logger.info(f"Starting WDA port forwarding ({wda_port})...") process, _, _ = start_port_forward( local_port=wda_port, device_port=wda_port, udid=self.udid, ios_executable=self.ios_executable, background=True ) self._port_forward_processes.append(process) else: logger.info(f"USBClient 模式,跳过 WDA 端口转发 ({wda_port})") # Start Fastbot port forwarding if requested if fastbot_port: logger.info(f"Starting Fastbot port forwarding ({fastbot_port})...") process, _, _ = start_port_forward( local_port=fastbot_port, device_port=fastbot_port, udid=self.udid, ios_executable=self.ios_executable, background=True ) self._port_forward_processes.append(process) logger.info("Infrastructure services started successfully") return True except Exception as e: logger.error(f"Failed to start infrastructure: {e}") self.stop_infrastructure() return False def restart_port_forward(self, port: int = 8100) -> bool: """重启端口转发(WDA 恢复时使用) 停止所有旧的端口转发进程,然后重建指定端口的转发。 :param port: 端口号(默认 8100) :return: True 表示重启成功 """ if not self.is_client_mode: logger.info(f"USBClient 模式,跳过重启 WDA 端口转发 ({port})") return True import time # 停止所有旧的端口转发 logger.info(f"重启端口转发: 停止旧进程...") for process in self._port_forward_processes: stop_process(process, timeout=5) self._port_forward_processes.clear() time.sleep(1) # 等待端口释放 # 重建端口转发 try: logger.info(f"重启端口转发: 建立新的 {port} -> {port} 转发...") process, _, _ = start_port_forward( local_port=port, device_port=port, udid=self.udid, ios_executable=self.ios_executable, background=True ) self._port_forward_processes.append(process) logger.info(f"端口转发重启成功 (PID: {process.pid})") return True except Exception as e: logger.error(f"端口转发重启失败: {e}") return False def stop_infrastructure(self, timeout: int = 5) -> bool: """Stop infrastructure services (tunnel and port forwarding).""" all_stopped = True # Stop port forwarding processes for process in self._port_forward_processes: if not stop_process(process, timeout): all_stopped = False self._port_forward_processes.clear() # Stop tunnel if not stop_process(self._tunnel_process, timeout): all_stopped = False self._tunnel_process = None return all_stopped def __del__(self): """Cleanup on destruction.""" try: self.stop_all() except Exception: pass if __name__ == "__main__": # Example usage import argparse logging.basicConfig(level=logging.INFO) parser = argparse.ArgumentParser(description="go_ios runner utilities") parser.add_argument("command", choices=["wda", "fastbot", "info"], help="Command to run") parser.add_argument("--udid", required=True, help="Device UDID") parser.add_argument("--port", type=int, default=8100, help="WDA port (default: 8100)") parser.add_argument("--ip", default="127.0.0.1", help="WDA IP (default: 127.0.0.1)") parser.add_argument("--bundle-id", help="Target app bundle ID for Fastbot") parser.add_argument("--duration", type=int, default=10, help="Fastbot duration (default: 10)") parser.add_argument("--throttle", type=int, default=1000, help="Fastbot throttle (default: 1000)") args = parser.parse_args() if args.command == "info": print(f"iOS executable: {get_ios_executable()}") elif args.command == "wda": print(f"Starting WDA on {args.ip}:{args.port}...") process, _, _ = start_wda( udid=args.udid, port=args.port, ip=args.ip, background=True ) print(f"WDA started with PID: {process.pid}") print("Press Ctrl+C to stop...") try: process.wait() except KeyboardInterrupt: process.terminate() print("\nWDA stopped.") elif args.command == "fastbot": if not args.bundle_id: parser.error("--bundle-id is required for fastbot command") print(f"Starting Fastbot for {args.bundle_id}...") process, _, _ = start_fastbot( udid=args.udid, target_bundle_id=args.bundle_id, duration=args.duration, throttle=args.throttle, background=True ) print(f"Fastbot started with PID: {process.pid}") print("Press Ctrl+C to stop...") try: process.wait() except KeyboardInterrupt: process.terminate() print("\\nFastbot stopped.")