autool/utils_ios/go_ios/go_ios_runner.py
2026-06-17 19:44:18 +08:00

722 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.")