#!/usr/bin/env python3 """ iOS 自动化测试启动脚本 与 start.py 不同,此脚本直接通过 bundle_id 参数指定被测应用, 无需通过列表选择。适用于 iOS 自动化测试场景。 使用示例: python start_ios.py -bundle_id com.example.app python start_ios.py -bundle_id com.example.app -wda_url http://localhost:8100 python start_ios.py -bundle_id com.example.app -timeout 1800 -policy memory_guided """ import os import argparse import csv import sys import time import subprocess from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict from typing import Optional, List, Dict, Tuple # 找到项目根目录 ROOT = Path(__file__).resolve().parent # 确保根目录在Python路径中 if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) # 导入统一的日志配置模块 from logging_config import setup_logging, get_logger # 导入 DroidBot from DroidBot.droidbot import DroidBot from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker # 导入异常类 try: from DroidBot.exceptions import FATAL_EXCEPTIONS, WDA_RECOVERABLE_EXCEPTIONS from DroidBot.platforms.ios.wda.exceptions import WDAStuckError # WDA_EXCEPTIONS 用于 run_test 中单独捕获 WDA 连接异常 WDA_EXCEPTIONS = WDA_RECOVERABLE_EXCEPTIONS + (WDAStuckError,) except ImportError: FATAL_EXCEPTIONS = (KeyboardInterrupt, SystemExit) WDA_EXCEPTIONS = () WDAStuckError = None def load_env_variables(): """从 .env 文件加载环境变量到系统环境中""" env_file = ROOT / ".env" if not env_file.exists(): print(f"[WARN] .env 文件不存在: {env_file}") return try: with open(env_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() # 跳过空行和注释 if not line or line.startswith('#'): continue # 解析 KEY=VALUE 格式 if '=' in line: key, value = line.split('=', 1) key = key.strip() value = value.strip() # 设置到环境变量 os.environ[key] = value print(f"[INFO] 已从 .env 加载环境变量") except Exception as e: print(f"[WARN] 加载 .env 文件失败: {e}") def setup_iphone_proxy(): """调用 fix_proxy.sh 脚本为 iPhone 设置代理网络""" import subprocess # 确保已加载环境变量 if 'SUDO' not in os.environ: print("[WARN] SUDO 环境变量未设置,尝试从 .env 加载...") load_env_variables() # fix_proxy.sh 脚本路径 script_path = ROOT / "utils_ios" / "scripts" / "fix_proxy.sh" if not script_path.exists(): print(f"[ERROR] fix_proxy.sh 脚本不存在: {script_path}") return False print(f"\n{'='*50}") print("[INFO] 正在为 iPhone 设置代理网络...") print(f"{'='*50}\n") try: # 执行脚本,传递环境变量 result = subprocess.run( ['bash', str(script_path)], env=os.environ.copy(), # 传递包含 SUDO 的环境变量 capture_output=True, text=True, timeout=30 ) # 打印输出 if result.stdout: print(result.stdout) if result.stderr: print(result.stderr, file=sys.stderr) if result.returncode == 0: print("[SUCCESS] iPhone 代理设置完成\n") return True else: print(f"[ERROR] 代理设置失败,退出码: {result.returncode}\n") return False except subprocess.TimeoutExpired: print("[ERROR] 代理设置超时\n") return False except Exception as e: print(f"[ERROR] 执行 fix_proxy.sh 失败: {e}\n") return False # 状态码定义 (符合 Linux 惯例) EXIT_SUCCESS = 0 EXIT_ERROR_USER = 1 EXIT_WDA_ERROR = 2 EXIT_WDA_STUCK = 6 # WDA 卡死且多次恢复失败 EXIT_NETWORK_ERROR = 3 EXIT_ERROR_GENERAL = 5 @dataclass class TaskResult: """单次测试任务的执行结果""" status: str # "SUCCESS" | "FAILED" | "INTERRUPTED" exit_code: int # 退出码 error_reason: str # 错误原因(成功时为空字符串) droidbot_steps: int # DroidBot 执行步数 guiagent_steps: int # GuiAgent 执行步数 total_steps: int # 总步数 duration_seconds: float # 执行耗时(秒) test_timestamp: str = "" # 测试目录名中的时间戳(%Y%m%d_%H%M%S),与输出目录名对应 class IOSBatchStatistics: """iOS 批量测试统计管理器""" def __init__(self, output_dir: str): self.output_dir = output_dir self.start_time = datetime.now() self.records = [] # 存储所有测试记录 self.csv_filepath = None def add_record(self, index: int, app_name: str, raw_app_name: str, app_id: str, bundle_id: str, result: TaskResult): """添加一条测试记录""" # 优先使用目录名中的时间戳,保持与输出目录一一对应;格式转换 %Y%m%d_%H%M%S -> %Y-%m-%d %H:%M:%S if result.test_timestamp: timestamp_str = result.test_timestamp else: timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S") self.records.append({ "index": index, "app_name": app_name, "raw_app_name": raw_app_name, "app_id": app_id, "bundle_id": bundle_id, "status": result.status, "exit_code": result.exit_code, "error_reason": result.error_reason, "droidbot_steps": result.droidbot_steps, "guiagent_steps": result.guiagent_steps, "total_steps": result.total_steps, "duration_seconds": round(result.duration_seconds, 2), "timestamp": timestamp_str }) def add_skip_record(self, index: int, app_name: str, raw_app_name: str, app_id: str, reason: str): """添加一条跳过记录(安装失败等)""" self.records.append({ "index": index, "app_name": app_name, "raw_app_name": raw_app_name, "app_id": app_id, "bundle_id": "", "status": "SKIPPED", "exit_code": -1, "error_reason": reason, "droidbot_steps": 0, "guiagent_steps": 0, "total_steps": 0, "duration_seconds": 0, "timestamp": datetime.now().strftime("%Y%m%d_%H%M%S") }) def add_fail_record(self, index: int, app_name: str, raw_app_name: str, app_id: str, reason: str): """添加一条失败记录(应用不可用、付费应用等永久性失败,不应重试)""" self.records.append({ "index": index, "app_name": app_name, "raw_app_name": raw_app_name, "app_id": app_id, "bundle_id": "", "status": "FAILED", "exit_code": -1, "error_reason": reason, "droidbot_steps": 0, "guiagent_steps": 0, "total_steps": 0, "duration_seconds": 0, "timestamp": datetime.now().strftime("%Y%m%d_%H%M%S") }) def save_csv(self): """保存统计结果到 CSV 文件(每次调用覆盖写入)""" os.makedirs(self.output_dir, exist_ok=True) filename = f"ios_batch_result_{self.start_time.strftime('%Y%m%d_%H%M%S')}.csv" filepath = os.path.join(str(Path(self.output_dir).parent), filename) fieldnames = ["index", "app_name", "raw_app_name", "app_id", "bundle_id", "status", "exit_code", "error_reason", "droidbot_steps", "guiagent_steps", "total_steps", "duration_seconds", "timestamp"] with open(filepath, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(self.records) self.csv_filepath = filepath print(f"[INFO] 批量统计结果已保存到: {filepath}") return filepath def print_summary(self): """打印批量测试摘要""" total = len(self.records) success = sum(1 for r in self.records if r["status"] == "SUCCESS") failed = sum(1 for r in self.records if r["status"] == "FAILED") skipped = sum(1 for r in self.records if r["status"] == "SKIPPED") interrupted = sum(1 for r in self.records if r["status"] == "INTERRUPTED") total_duration = sum(r["duration_seconds"] for r in self.records) print(f"\n{'='*60}") print(f" iOS 批量测试摘要") print(f"{'='*60}") print(f" 总数: {total}") print(f" 成功: {success} | 失败: {failed} | 跳过: {skipped} | 中断: {interrupted}") print(f" 总耗时: {total_duration:.1f} 秒 ({total_duration/3600:.1f} 小时)") if self.csv_filepath: print(f" 结果文件: {self.csv_filepath}") print(f"{'='*60}\n") class TeeOutput: """将输出同时写入控制台和文件,包括标准输出、标准错误和所有logging输出""" def __init__(self, file_path): self.file_path = file_path self.file = open(file_path, 'w', encoding='utf-8', buffering=1) # 行缓冲 self.stdout = sys.stdout self.stderr = sys.stderr self.log_handler = None def write(self, message): try: self.stdout.write(message) self.file.write(message) except Exception: pass # 忽略写入错误,防止程序崩溃 def flush(self): try: self.stdout.flush() self.file.flush() except Exception: pass def fileno(self): """返回文件描述符,使某些库兼容""" return self.stdout.fileno() def isatty(self): """检查是否为终端""" return self.stdout.isatty() def setup_logging(self, debug_mode: bool = False): """设置根日志记录器,捕获所有logging模块输出到文件和终端""" import logging root_logger = logging.getLogger() # debug_mode 时将根 logger 降到 DEBUG,否则保持 INFO root_level = logging.DEBUG if debug_mode else logging.INFO root_logger.setLevel(root_level) # 文件处理器:始终捕获所有级别 self.log_handler = logging.FileHandler(self.file_path, mode='a', encoding='utf-8') self.log_handler.setLevel(logging.DEBUG) file_formatter = logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s') self.log_handler.setFormatter(file_formatter) root_logger.addHandler(self.log_handler) # 终端处理器:debug_mode 时输出 DEBUG,否则只输出 INFO 及以上(避免重复添加) if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler) for h in root_logger.handlers): stream_handler = logging.StreamHandler() stream_handler.setLevel(logging.DEBUG if debug_mode else logging.INFO) stream_formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', datefmt='%H:%M:%S') stream_handler.setFormatter(stream_formatter) root_logger.addHandler(stream_handler) # 抑制第三方库的 DEBUG 日志(即使 debug_mode 开启也不输出) from logging_config import THIRD_PARTY_LOGGERS for logger_name in THIRD_PARTY_LOGGERS: logging.getLogger(logger_name).setLevel(logging.WARNING) def close(self): """清理资源""" import logging if self.log_handler: logging.getLogger().removeHandler(self.log_handler) self.log_handler.close() self.log_handler = None if self.file and not self.file.closed: self.file.close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() return False class IOSTestConfig: """iOS 测试配置参数""" # --- 默认配置 --- DEFAULT_WDA_URL: str = "http://localhost:8100" DEFAULT_POLICY: str = "memory_guided" DEFAULT_TIMEOUT: int = 3600 DEFAULT_EVENT_INTERVAL: int = 1 DEFAULT_EVENT_COUNT: int = 10000 # --- 输出配置 --- OUTPUT_BASE_DIR: str = "./output/ios_test" # --- 日志配置 --- LOG_LEVEL: str = "DEBUG" class IOSTestRunner: """iOS 测试执行器""" def __init__(self, config: IOSTestConfig): self.config = config def run_test(self, bundle_id: str, wda_url: str, policy: str, timeout: int, event_interval: int, event_count: int, cv_mode: bool, debug_mode: bool, enable_guiagent: bool, keep_app: bool, random_input: bool, output_dir: str = None, model_name: str = "iOS", wda_health_monitor=None) -> TaskResult: """执行 iOS 测试任务,返回 TaskResult""" print(f"\n{'='*50}") print(f"[INFO] 准备测试 iOS 应用: {bundle_id}") print(f"[INFO] WDA URL: {wda_url}") print(f"[INFO] 测试策略: {policy}") print(f"[INFO] 超时时间: {timeout} 秒") start_time = time.time() ret_code = EXIT_ERROR_GENERAL status = "FAILED" error_reason = "" # 重置全局步数计数器,确保每轮任务独立统计 GuiAgentDecisionMaker.total_steps = 0 # 初始化步数统计,确保 finally 块中能访问到 droidbot_steps = 0 guiagent_steps = 0 total_steps = 0 user_interrupted = False # 追踪用户中断,用于向上传递 # 生成带时间戳的输出目录 if output_dir is None: # 格式: ./output/BundleId_ModelName_YYYYMMDD_HHMMSS # 将 bundle_id 中的点替换为下划线 sanitized_bundle_id = bundle_id.replace('.', '_').replace(' ', '_') timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = f"{sanitized_bundle_id}_{model_name}_{timestamp}" output_dir = os.path.join(self.config.OUTPUT_BASE_DIR, folder_name) # 将日志目录写入 .env 文件(使用全局独立的 agent_logs 目录) agent_log_dir = os.path.join(ROOT, "output", "agent_logs") env_path = os.path.join(ROOT, "GuiAgent", ".env") if os.path.exists(env_path): with open(env_path, "r", encoding="utf-8") as f: lines = f.readlines() with open(env_path, "w", encoding="utf-8") as f: found = False for line in lines: if line.startswith("AGENT_LOG_DIR="): f.write(f"AGENT_LOG_DIR={agent_log_dir}\n") found = True else: f.write(line) if not found: f.write(f"AGENT_LOG_DIR={agent_log_dir}\n") else: os.makedirs(os.path.dirname(env_path), exist_ok=True) with open(env_path, "w", encoding="utf-8") as f: f.write(f"AGENT_LOG_DIR={agent_log_dir}\n") # 确保目录存在 os.makedirs(output_dir, exist_ok=True) print(f"[INFO] 输出目录已创建: {output_dir}") cmd_log_path = os.path.join(output_dir, "cmd_output.txt") # ====== 配置 logging ====== # 使用统一的日志配置模块 import logging setup_logging( level=logging.DEBUG if debug_mode else logging.INFO, log_file=None, # 日志文件通过TeeOutput处理 debug_mode=debug_mode ) # 确保所有相关模块都使用正确的日志等级 logging_level = logging.DEBUG if debug_mode else logging.INFO for logger_name in ['DroidBot', 'IOSDevice', 'InputEventManager', 'MemoryGuidedPolicy']: logging.getLogger(logger_name).setLevel(logging_level) if debug_mode: print(f"[INFO] Debug 模式已启用,DroidBot 相关日志等级设置为 DEBUG,第三方库设置为 WARNING") # ====== logging 配置完成 ====== # 直接调用 DroidBot(支持断点调试) print(f"[INFO] 启动 DroidBot(函数调用模式,支持断点调试)") print(f"{'='*50}\n") # 设置输出重定向 tee = TeeOutput(cmd_log_path) old_stdout = sys.stdout old_stderr = sys.stderr sys.stdout = tee sys.stderr = tee tee.setup_logging(debug_mode=debug_mode) # 设置日志处理器,捕获所有logging模块输出 try: droidbot = DroidBot( package_name=bundle_id, device_serial=None, # iOS 不需要 is_emulator=False, output_dir=output_dir, policy_name=policy, random_input=random_input, event_interval=event_interval, timeout=timeout, event_count=event_count, cv_mode=cv_mode, debug_mode=debug_mode, keep_app=keep_app, keep_env=False, profiling_method=None, grant_perm=False, enable_accessibility_hard=False, humanoid=None, ignore_ad=False, replay_output=None, enable_guiagent=enable_guiagent, platform="ios", wda_url=wda_url, ) # 注入 WDA 健康监控(如果提供) if wda_health_monitor and hasattr(droidbot, 'device'): droidbot.device.set_health_monitor(wda_health_monitor) droidbot.start() ret_code = EXIT_SUCCESS status = "SUCCESS" error_reason = "" # 统计步数 droidbot_steps = droidbot.input_manager.total_exploring_steps guiagent_steps = GuiAgentDecisionMaker.total_steps total_steps = droidbot_steps + guiagent_steps print(f"\n[SUCCESS] iOS 测试任务完成: {bundle_id}") except KeyboardInterrupt: print(f"\n[WARN] 用户手动中断测试: {bundle_id}") ret_code = EXIT_ERROR_USER status = "INTERRUPTED" error_reason = "用户手动中断" user_interrupted = True # 标记需要向上传递中断 except WDA_EXCEPTIONS as e: import traceback # 区分 WDAStuckError(多次恢复失败)和普通 WDA 异常 if WDAStuckError and isinstance(e, WDAStuckError): print(f"\n[ERROR] WDA 卡死且恢复失败: {e}") print(traceback.format_exc()) ret_code = EXIT_WDA_STUCK status = "FAILED" error_reason = f"WDA 卡死恢复失败: {e}" else: print(f"\n[ERROR] WDA 连接异常: {e}") print(traceback.format_exc()) ret_code = EXIT_WDA_ERROR status = "FAILED" error_reason = f"WDA 连接异常: {e}" except Exception as e: import traceback print(f"\n[ERROR] DroidBot 执行异常: {e}") print(traceback.format_exc()) ret_code = EXIT_ERROR_GENERAL status = "FAILED" error_reason = f"执行异常: {e}" finally: # 尝试获取步数(即使异常也尽量获取) try: guiagent_steps = GuiAgentDecisionMaker.total_steps except Exception: pass total_steps = droidbot_steps + guiagent_steps print(f"[INFO] DroidBot 步数: {droidbot_steps}") print(f"[INFO] GUI Agent 步数: {guiagent_steps}") print(f"[INFO] 总步数: {total_steps}") # 恢复原来的stdout/stderr sys.stdout = old_stdout sys.stderr = old_stderr tee.close() end_time = time.time() duration = end_time - start_time hours, rem = divmod(duration, 3600) minutes, seconds = divmod(rem, 60) time_str = f"{int(hours)}小时 {int(minutes)}分 {int(seconds)}秒" print(f"[INFO] 任务耗时: {time_str}\n") if os.path.exists(output_dir): try: time_file_path = os.path.join(output_dir, "time_cost.txt") with open(time_file_path, "w", encoding="utf-8") as f: f.write(f"Test Duration: {time_str}\n") f.write(f"Start Time: {datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f"End Time: {datetime.fromtimestamp(end_time).strftime('%Y-%m-%d %H:%M:%S')}\n") except Exception as e: print(f"[ERROR] 无法记录耗时文件: {e}") result = TaskResult( status=status, exit_code=ret_code, error_reason=error_reason, droidbot_steps=droidbot_steps, guiagent_steps=guiagent_steps, total_steps=total_steps, duration_seconds=duration ) # 如果是用户中断,返回结果后重新抛出异常,让上层能够捕获并退出 if user_interrupted: raise KeyboardInterrupt(result) return result def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser( description="iOS 自动化测试启动脚本", formatter_class=argparse.RawTextHelpFormatter ) # 必需参数 parser.add_argument("-bundle_id", action="store", dest="bundle_id", default="com.apple.AppStore", help="被测 iOS 应用的 Bundle ID (默认: com.apple.AppStore)") # WDA 配置 parser.add_argument("-wda_url", action="store", dest="wda_url", default=IOSTestConfig.DEFAULT_WDA_URL, help=f"WDA Server URL (默认: {IOSTestConfig.DEFAULT_WDA_URL})") # 测试配置 parser.add_argument("-policy", action="store", dest="policy", default=IOSTestConfig.DEFAULT_POLICY, help=f"测试策略 (默认: {IOSTestConfig.DEFAULT_POLICY})\n" "支持的策略:\n" " \"none\" -- 不发送事件,用户手动操作\n" " \"memory_guided\" -- 内存引导的探索策略\n" " \"manual\" -- 手动控制设备") parser.add_argument("-timeout", action="store", dest="timeout", type=int, default=IOSTestConfig.DEFAULT_TIMEOUT, help=f"超时时间(秒)(默认: {IOSTestConfig.DEFAULT_TIMEOUT})") parser.add_argument("-interval", action="store", dest="interval", type=int, default=IOSTestConfig.DEFAULT_EVENT_INTERVAL, help=f"事件间隔时间(秒)(默认: {IOSTestConfig.DEFAULT_EVENT_INTERVAL})") parser.add_argument("-count", action="store", dest="count", type=int, default=IOSTestConfig.DEFAULT_EVENT_COUNT, help=f"事件总数 (默认: {IOSTestConfig.DEFAULT_EVENT_COUNT})") # 输出配置 parser.add_argument("-o", action="store", dest="output_dir", help="输出目录 (默认: 自动生成带时间戳的目录)") # 模式开关 parser.add_argument("-cv", action="store_true", dest="cv_mode", help="启用 CV 模式(使用 OmniParser 进行 UI 识别)") parser.add_argument("-debug", action="store_true", dest="debug_mode", help="启用调试模式") parser.add_argument("-enable_guiagent", action="store_true", dest="enable_guiagent", help="启用 GuiAgent 进行智能 UI 交互") parser.add_argument("-keep_app", action="store_true", dest="keep_app", help="测试后保留应用状态") parser.add_argument("-random", action="store_true", dest="random_input", help="在输入事件中添加随机性") # 额外配置 parser.add_argument("-model", action="store", dest="model_name", default="iOS", help="模型名称,用于输出目录命名 (默认: iOS)") # Fastbot 模式 parser.add_argument("-fastbot", action="store_true", dest="fastbot_mode", help="启用 Fastbot 测试模式(不使用 DroidBot)") parser.add_argument("-duration", action="store", dest="duration", type=int, default=None, help="Fastbot 测试持续时间(秒)(默认: 使用 timeout 值)") parser.add_argument("-throttle", action="store", dest="throttle", type=int, default=1000, help="Fastbot 事件间隔(毫秒)(默认: 1000)") parser.add_argument("-udid", action="store", dest="udid", help="设备 UDID(可选,默认使用第一个设备)") parser.add_argument("-fastbot_port", action="store", dest="fastbot_port", type=int, default=9197, help="Fastbot 状态监控端口 (默认: 9197)") # 应用安装和登录 parser.add_argument("-install_app", action="store", dest="install_app_id", help="从 App Store 安装应用 (提供 app_id)") parser.add_argument("-enable_login", action="store_true", dest="enable_login", help="启用 Apple ID 登录和权限处理") parser.add_argument("-skip_onboarding", action="store_true", dest="skip_onboarding", default=True, help="跳过应用引导页 (默认开启)") # 批量测试参数 parser.add_argument("-batch", action="store_true", dest="batch_mode", help="启用批量测试模式,从CSV文件加载应用列表") parser.add_argument("-app_list", action="store", dest="app_list", default=str(ROOT / "output" / "ios" / "app_magic_summary_apple_no_google.csv"), help="应用列表CSV文件路径 (默认: output/ios/app_magic_summary_apple_no_google.csv)") parser.add_argument("-start_index", action="store", dest="start_index", type=int, default=0, help="批量测试起始索引 (默认: 0),用于断点续测") parser.add_argument("-batch_count", action="store", dest="batch_count", type=int, default=0, help="批量测试数量 (默认: 0=全部)") parser.add_argument("-enable_pcap", action="store_true", dest="enable_pcap", help="启用 pcap 抓包") return parser.parse_args() def get_app_info_from_csv(app_id: str, csv_file: str = "appstore_mapping.csv") -> dict: """从CSV映射文件中读取应用信息(bundle_id 和 app_name) 从后往前读取,以最新的记录为准。 Returns: dict: {'bundle_id': str, 'app_name': str},未找到时返回空 dict """ import csv if not os.path.exists(csv_file): return {} try: with open(csv_file, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) rows = list(reader) # 从后往前遍历,以最新记录为准 for row in reversed(rows): if row.get('app_id', '').strip() == app_id: return { 'bundle_id': row.get('bundle_id', '').strip(), 'app_name': row.get('app_name', '').strip(), } except Exception as e: print(f"[WARN] 读取CSV映射失败: {e}") return {} def get_bundle_id_from_csv(app_id: str, csv_file: str = "appstore_mapping.csv") -> Optional[str]: """从CSV映射文件中读取bundle_id(兼容旧接口)""" info = get_app_info_from_csv(app_id, csv_file) return info.get('bundle_id') or None def load_apps_from_csv(csv_path: str, start_index: int = 0, count: int = 0, index_set: set = None) -> List[Dict[str, str]]: """从 iOS 应用列表 CSV 中加载 app_id 和 app_name 每条记录包含 'index' 字段(CSV 中去掉表头后的 0-based 行号), 该值是每个应用的唯一编号,与 batch_result CSV 中的 index 列一一对应。 Args: csv_path: CSV 文件路径 start_index: 起始索引(0-based),当 index_set 为 None 时生效 count: 加载数量,0 表示全部,当 index_set 为 None 时生效 index_set: 若指定,则只加载行号在此集合中的应用(忽略 start_index/count) Returns: 包含 index、app_id、name 的字典列表,顺序与 CSV 一致 """ apps = [] try: with open(csv_path, 'r', encoding='utf-8-sig') as f: reader = csv.DictReader(f) for i, row in enumerate(reader): if index_set is not None: # 精确加载指定行 if i not in index_set: continue else: if i < start_index: continue if count > 0 and len(apps) >= count: break app_id = row.get('App_ID', '').strip() name = row.get('Name', '').strip() if app_id: apps.append({'index': i, 'app_id': app_id, 'name': name}) except FileNotFoundError: print(f"[ERROR] 应用列表文件不存在: {csv_path}") except Exception as e: print(f"[ERROR] 读取应用列表失败: {e}") return apps def start_pcap(bundle_id: str, udid: str, output_dir: str, ios_executable: str = None, app_id: str = None, app_name: str = None, raw_app_name: str = None) -> Optional[subprocess.Popen]: """启动 pcap 抓包 Args: bundle_id: 应用的 Bundle ID udid: 设备 UDID output_dir: 输出目录,pcap 文件保存路径 ios_executable: go-ios 可执行文件路径 Returns: 抓包进程对象,失败返回 None """ if ios_executable is None: sys.path.insert(0, str(ROOT / "utils_ios" / "go_ios")) from go_ios_runner import get_ios_executable ios_executable = get_ios_executable() os.makedirs(output_dir, exist_ok=True) pcap_file = os.path.join(output_dir, "capture.pcap") try: cmd = [ ios_executable, "pcap", f"--bundle={bundle_id}", f"--udid={udid}", f"--output={output_dir}", "--allmark", f"--appid={app_id}", f"--appname={app_name}", f"--rawappname={raw_app_name}", ] print(f"[INFO] 启动抓包: {' '.join(cmd)}") process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print(f"[SUCCESS] 抓包已启动 (PID: {process.pid})") return process except Exception as e: print(f"[WARN] 启动抓包失败: {e}") return None def stop_pcap(process: Optional[subprocess.Popen]): """停止 pcap 抓包进程""" if process and process.poll() is None: try: process.terminate() process.wait(timeout=5) print("[INFO] 抓包已停止") except Exception: process.kill() print("[WARN] 抓包进程被强制终止") def check_app_installed(bundle_id: str, udid: Optional[str] = None, ssh_host: Optional[str] = None, go_ios: str = 'ios') -> bool: """ 检查应用是否已安装 Args: bundle_id: 应用的 Bundle ID udid: 设备 UDID ssh_host: SSH主机地址 go_ios: go-ios 可执行文件路径 Returns: 应用是否已安装 """ import subprocess try: base_cmd = [go_ios, "apps", "--list"] if udid: base_cmd.extend(["--udid", udid]) if ssh_host: cmd = ["ssh", ssh_host, " ".join(base_cmd)] else: cmd = base_cmd result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: return False # 检查 bundle_id 是否在输出中 for line in result.stdout.strip().split('\n'): if line.strip() and line.split()[0] == bundle_id: return True return False except Exception as e: print(f"[WARN] 检查应用安装状态失败: {e}") return False def handle_app_installation(wda_url: str, app_id: str, udid: Optional[str] = None, health_monitor=None) -> Tuple[Optional[str], str]: """ 处理应用安装 Returns: Tuple[Optional[str], str]: (成功安装后的 bundle_id, 失败原因说明) """ print(f"\n{'='*50}") print(f"[INFO] 开始安装应用: {app_id}") print(f"{'='*50}\n") # 导入安装器 sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from package_init.ios_appstore_installer import install_from_appstore # 执行安装 success, reason = install_from_appstore( server_url=wda_url, app_id=app_id, timeout=600, udid=udid, ssh_host=None, go_ios='ios', alert_strategy="allow", health_monitor=health_monitor ) if not success: print(f"[ERROR] 应用安装失败: {reason}") return None, reason print("[SUCCESS] 应用安装成功") # 从CSV读取bundle_id(使用与安装器相同的路径) # 注意:安装器内部会将纯数字 app_id 格式化为 id{app_id} 保存到 CSV csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv") bundle_id = get_bundle_id_from_csv(app_id, csv_file) if not bundle_id and app_id.isdigit(): # 尝试带 id 前缀的格式(安装器保存时使用的格式) bundle_id = get_bundle_id_from_csv(f"id{app_id}", csv_file) if bundle_id: print(f"[INFO] 从CSV获取到 Bundle ID: {bundle_id}") return bundle_id, "" else: print("[WARN] 无法从CSV获取Bundle ID,请手动指定") return None, "无法从CSV获取Bundle ID" def handle_apple_id_login(wda_url: str, bundle_id: str, skip_onboarding: bool = True) -> bool: """ 处理Apple ID登录和权限 Returns: 是否成功 """ print(f"\n{'='*50}") print(f"[INFO] 开始处理Apple ID登录和权限") print(f"[INFO] Bundle ID: {bundle_id}") print(f"{'='*50}\n") # 导入登录器 from utils_ios.package_init.ios_appleid_signer import sign_with_appleid # 执行登录 success = sign_with_appleid( server_url=wda_url, bundle_id=bundle_id, alert_strategy="allow", skip_onboarding=skip_onboarding, timeout=120, try_login=True ) if success: print("[SUCCESS] Apple ID登录和权限处理完成") else: print("[WARN] Apple ID登录和权限处理未完全成功") return success def setup_infrastructure(args): """启动基础设施:tunnel、端口转发、WDA、代理网络、设备解锁 Returns: (runner, udid) 元组,失败时 sys.exit(1) """ sys.path.insert(0, str(ROOT / "utils_ios")) from go_ios import go_ios_runner GoIOSRunner = go_ios_runner.GoIOSRunner udid = args.udid or "00008101-0016601022C0001E" runner = GoIOSRunner(udid=udid, wda_url=args.wda_url) print("[INFO] 正在启动 WDA 基础设施...") if not runner.start_infrastructure(wda_port=8100, use_tunnel=True, tunnel_sudo_password=os.getenv("SUDO")): print("[ERROR] WDA 基础设施启动失败") sys.exit(1) print("[INFO] WDA 基础设施已启动,等待稳定...") time.sleep(3) # 设置 iPhone 代理网络 setup_iphone_proxy() # 启动 WDA print("[INFO] 正在启动 WDA...") wda_process = runner.start_wda(port=8100) print(f"[SUCCESS] WDA 已启动 (PID: {wda_process.pid})") time.sleep(3) # 等待 WDA 就绪后再操作设备 sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from wda import Client as WDAClient, USBClient as WDAUSBClient # 根据 wda_url 格式选择连接方式 if args.wda_url and args.wda_url.startswith("http:"): c = WDAClient(args.wda_url) else: c = WDAUSBClient(udid=args.wda_url or udid or "") print("[INFO] 等待 WDA 就绪...") if not c.wait_ready(timeout=30): print("[WARN] WDA 未就绪,继续尝试...") # 解锁设备 try: if c.locked(): print("[INFO] 设备已锁定,正在解锁...") c.unlock() time.sleep(1) else: print("[INFO] 设备未锁定") except Exception as e: print(f"[WARN] 设备解锁失败: {e}") return runner, udid def install_app_and_get_bundle_id(app_id: str, wda_url: str, udid: str, health_monitor=None) -> Tuple[Optional[str], str]: """安装应用并获取 bundle_id 流程:CSV缓存查找 → 检查已安装 → 执行安装 Returns: Tuple[Optional[str], str]: (bundle_id, 失败原因) 失败时 bundle_id 为 None """ csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv") # 1. 先从 CSV 查找是否有记录(兼容 id 前缀格式) cached_bundle_id = get_bundle_id_from_csv(app_id, csv_file) if not cached_bundle_id and app_id.isdigit(): cached_bundle_id = get_bundle_id_from_csv(f"id{app_id}", csv_file) if cached_bundle_id: print(f"[INFO] 从CSV找到记录: {app_id} -> {cached_bundle_id}") # 2. 检查应用是否已安装 if check_app_installed(cached_bundle_id, udid=udid): print(f"[SUCCESS] 应用已安装,跳过安装步骤: {cached_bundle_id}") return cached_bundle_id, "" else: print(f"[INFO] 应用未安装,开始安装: {app_id}") else: print(f"[INFO] CSV中无记录,开始安装: {app_id}") # 3. 执行安装 installed_bundle_id, reason = handle_app_installation( wda_url=wda_url, app_id=app_id, udid=udid, health_monitor=health_monitor ) if installed_bundle_id: print(f"[INFO] 安装成功,Bundle ID: {installed_bundle_id}") else: print(f"[ERROR] 安装失败: {app_id} - {reason}") return installed_bundle_id, reason def run_batch_mode(args): """批量测试模式主逻辑""" print(f"\n{'='*60}") print(f" iOS 批量测试模式") print(f"{'='*60}") print(f"[INFO] 应用列表: {args.app_list}") print(f"[INFO] 起始索引: {args.start_index}") print(f"[INFO] 批量数量: {args.batch_count or '全部'}") # 1. 加载应用列表 apps = load_apps_from_csv( csv_path=args.app_list, start_index=args.start_index, count=args.batch_count ) if not apps: print("[ERROR] 应用列表为空,退出") sys.exit(1) print(f"[INFO] 共加载 {len(apps)} 个应用") # 2. 初始化批量统计 batch_output_dir = os.path.join(ROOT, "output") stats = IOSBatchStatistics(batch_output_dir) # 3. 启动基础设施(tunnel、端口转发、WDA、代理) runner, udid = setup_infrastructure(args) # 4. 初始化测试配置和运行器 config = IOSTestConfig() test_runner = IOSTestRunner(config) # 4b. 初始化 WDA 健康监控(用于卡死检测与异步恢复) from utils_ios.wda_health import WDAHealthMonitor wda_health_monitor = WDAHealthMonitor( wda_url=args.wda_url, go_ios_runner=runner ) print("[INFO] WDA 健康监控已初始化") # 5. 获取 go-ios 可执行文件路径(用于抓包) ios_executable = runner.ios_executable # 6. 遍历应用列表,逐个测试 try: for idx, app_info in enumerate(apps): app_id = app_info['app_id'] app_name = app_info['name'] real_index = app_info['index'] # CSV 中的 0-based 唯一行号 print(f"\n{'#'*60}") print(f" [index={real_index}] ({idx+1}/{len(apps)}) {app_name}") print(f" App ID: {app_id}") print(f"{'#'*60}") # 6a. 安装应用并获取 bundle_id bundle_id = install_app_and_get_bundle_id( app_id=app_id, wda_url=args.wda_url, udid=udid ) if not bundle_id: print(f"[SKIP] 跳过 {app_name}: 安装失败") stats.add_skip_record(real_index, app_name, app_name, app_id, "安装失败") stats.save_csv() continue # 6b. 处理Apple ID登录(如果启用) if args.enable_login: handle_apple_id_login( wda_url=args.wda_url, bundle_id=bundle_id, skip_onboarding=args.skip_onboarding ) # 6c. 启动抓包(如果启用) pcap_process = None if args.enable_pcap: pcap_output_dir = os.path.join( config.OUTPUT_BASE_DIR, f"{bundle_id.replace('.', '_')}_iOS_{datetime.now().strftime('%Y%m%d_%H%M%S')}" ) pcap_process = start_pcap( bundle_id=bundle_id, udid=udid, output_dir=pcap_output_dir, ios_executable=ios_executable ) # 6d. 执行测试 try: result = test_runner.run_test( bundle_id=bundle_id, wda_url=args.wda_url, policy=args.policy, timeout=args.timeout, event_interval=args.interval, event_count=args.count, cv_mode=args.cv_mode, debug_mode=args.debug_mode, enable_guiagent=args.enable_guiagent, keep_app=False, # 批量模式不保留应用 random_input=args.random_input, model_name=args.model_name, wda_health_monitor=wda_health_monitor ) stats.add_record(real_index, app_name, app_id, bundle_id, result) print(f"[RESULT] {app_name}: {result.status} " f"(步数: {result.total_steps}, 耗时: {result.duration_seconds:.1f}s)") except KeyboardInterrupt as e: # run_test 中用户中断会抛出 KeyboardInterrupt(TaskResult) if e.args and isinstance(e.args[0], TaskResult): result = e.args[0] stats.add_record(real_index, app_name, app_id, bundle_id, result) else: stats.add_skip_record(real_index, app_name, app_name, app_id, "用户中断") # 用户中断时保存结果并退出整个批量循环 print(f"\n[WARN] 用户中断,停止批量测试") stats.save_csv() stats.print_summary() raise # 重新抛出,让外层处理 except Exception as e: print(f"[ERROR] 测试 {app_name} 异常: {e}") stats.add_skip_record(real_index, app_name, app_name, app_id, f"异常: {e}") finally: # 停止抓包 stop_pcap(pcap_process) # 6e. 实时保存 CSV stats.save_csv() except KeyboardInterrupt: print("\n[WARN] 批量测试被用户中断") finally: # 7. 打印最终摘要 stats.save_csv() stats.print_summary() # 8. 清理基础设施 print("[INFO] 正在清理资源...") runner.stop_all() print("[INFO] 清理完成") def main(): """主函数""" args = parse_args() # 加载环境变量(包括 SUDO) load_env_variables() print("--- iOS 自动化测试脚本启动 ---") # ====== 批量测试模式 ====== if args.batch_mode: run_batch_mode(args) return print(f"[INFO] Bundle ID: {args.bundle_id}") # 判断运行模式 if args.fastbot_mode: # Fastbot 模式 print(f"[INFO] 运行模式: Fastbot") # 如果未指定 duration,使用 timeout test_duration = args.duration if args.duration else args.timeout print(f"[INFO] Duration: {test_duration}s, Throttle: {args.throttle}ms, Timeout: {args.timeout}s") print(f"[INFO] UDID: {args.udid or 'auto'}") # Import Fastbot runner import sys sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from utils_ios.go_ios.go_ios_runner import GoIOSRunner import time import requests udid = args.udid or "00008101-0016601022C0001E" # Default UDID runner = GoIOSRunner(udid=udid, wda_url="http://localhost:8100") try: # 启动基础设施 print("[INFO] 正在启动基础设施...") if not runner.start_infrastructure(wda_port=8100, fastbot_port=args.fastbot_port): print("[ERROR] 基础设施启动失败") sys.exit(1) print("[INFO] 基础设施已启动,等待稳定...") time.sleep(3) # 设置 iPhone 代理网络 setup_iphone_proxy() # 等待 WDA 就绪后再操作设备 from wda import Client as WDAClient, USBClient as WDAUSBClient # Fastbot 模式中使用 UDID 连接 c = WDAUSBClient(udid=udid) print("[INFO] 等待 WDA 就绪...") if not c.wait_ready(timeout=30): print("[WARN] WDA 未就绪,继续尝试...") # 解锁设备 try: if c.locked(): print("[INFO] 设备已锁定,正在解锁...") c.unlock() time.sleep(1) else: print("[INFO] 设备未锁定") except Exception as e: print(f"[WARN] 设备解锁失败: {e}") # 处理应用安装(如果指定) if args.install_app_id: bundle_id = install_app_and_get_bundle_id( app_id=args.install_app_id, wda_url="http://localhost:8100", udid=udid ) if bundle_id: args.bundle_id = bundle_id elif not args.bundle_id or args.bundle_id == "com.apple.AppStore": print("[ERROR] 安装失败且未指定Bundle ID,无法继续") sys.exit(1) # 处理Apple ID登录(如果启用) if args.enable_login: handle_apple_id_login( wda_url="http://localhost:8100", bundle_id=args.bundle_id, skip_onboarding=args.skip_onboarding ) # 启动 Fastbot print(f"[INFO] 正在启动 Fastbot 测试...") fastbot_process = runner.start_fastbot( target_bundle_id=args.bundle_id, duration=test_duration, throttle=args.throttle ) print(f"[SUCCESS] Fastbot 已启动 (PID: {fastbot_process.pid})") # 监控状态(使用 timeout 作为总时长限制) print(f"[INFO] 开始监控 Fastbot 状态 (端口 {args.fastbot_port})...") status_url = f"http://127.0.0.1:{args.fastbot_port}/status" last_status = None start_time = time.time() check_interval = 5 # 每5秒检查一次 while True: # 检查是否超时 elapsed = time.time() - start_time if elapsed >= args.timeout: print(f"[WARN] 已达到超时时间 ({args.timeout}s),停止监控") fastbot_process.terminate() break # 检查进程是否结束 if fastbot_process.poll() is not None: print(f"[INFO] Fastbot 进程已结束 (退出码: {fastbot_process.returncode}, 耗时: {elapsed:.1f}s)") break try: response = requests.get(status_url, timeout=2) if response.status_code == 200: data = response.json() running = data.get("runingStatus", False) if running != last_status: if running: print(f"[STATUS] Fastbot 正在运行 ✓ (已运行 {elapsed:.1f}s / {args.timeout}s)") else: print(f"[STATUS] Fastbot 已停止 (耗时: {elapsed:.1f}s)") break last_status = running elif running: # 每30秒打印一次进度 if int(elapsed) % 30 == 0 and int(elapsed) > 0: remaining = args.timeout - elapsed print(f"[STATUS] Fastbot 运行中... (已运行 {elapsed:.1f}s, 剩余 {remaining:.1f}s)") except requests.RequestException: pass # Silently ignore connection errors except KeyboardInterrupt: print(f"\n[WARN] 用户中断测试 (已运行 {elapsed:.1f}s)") fastbot_process.terminate() break time.sleep(check_interval) # 等待进程完全结束 fastbot_process.wait() total_elapsed = time.time() - start_time print(f"[INFO] Fastbot 测试完成 (总耗时: {total_elapsed:.1f}s)") except KeyboardInterrupt: print("\n[WARN] 用户中断测试") finally: print("[INFO] 正在清理资源...") runner.stop_all() print("[INFO] 清理完成") else: # DroidBot 模式(单个应用) print(f"[INFO] 运行模式: DroidBot") print(f"[INFO] WDA URL: {args.wda_url}") # 初始化WDA(如果需要) if args.wda_url.startswith("http://localhost") or args.wda_url.startswith("http://127.0.0.1"): runner, udid = setup_infrastructure(args) else: udid = args.udid # 处理应用安装(如果指定) if args.install_app_id: bundle_id = install_app_and_get_bundle_id( app_id=args.install_app_id, wda_url=args.wda_url, udid=udid or "00008101-0016601022C0001E" ) if bundle_id: args.bundle_id = bundle_id elif not args.bundle_id or args.bundle_id == "com.apple.AppStore": print("[ERROR] 安装失败且未指定Bundle ID,无法继续") sys.exit(1) # 处理Apple ID登录(如果启用) if args.enable_login: handle_apple_id_login( wda_url=args.wda_url, bundle_id=args.bundle_id, skip_onboarding=args.skip_onboarding ) # 初始化配置与运行器 config = IOSTestConfig() test_runner = IOSTestRunner(config) # 执行测试 test_runner.run_test( bundle_id=args.bundle_id, wda_url=args.wda_url, policy=args.policy, timeout=args.timeout, event_interval=args.interval, event_count=args.count, cv_mode=args.cv_mode, debug_mode=args.debug_mode, enable_guiagent=args.enable_guiagent, keep_app=args.keep_app, random_input=args.random_input, output_dir=args.output_dir, model_name=args.model_name ) if __name__ == "__main__": main()