#!/usr/bin/env python3 """ iOS Fastbot + GuiAgent 混合测试脚本 测试流程: 1. 复用 ios_start.py 基础设施(任务加载/管理、日志、安装) 2. 阶段1: AppleID + GuiAgent 处理权限/登录 3. 阶段2: Fastbot 快速自动化采集 + 后台页面卡住检测 4. 分级响应: 10次无变化→back, 30次无变化→暂停Fastbot→Agent处理→重启Fastbot 使用示例: python ios_test.py -bundle_id com.apple.AppStore -duration 120 python ios_test.py -batch -app_list utils_ios/ios_only_apps.csv -batch_count 5 """ import os import sys import time import csv import hashlib import json import threading import argparse import subprocess import logging from pathlib import Path from datetime import datetime from typing import Optional, Dict, Tuple # 找到项目根目录 ROOT = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from utils_ios.cert_manager import ensure_certs_valid, send_weCom_alert # ============================================================ # 从 ios_start.py 复用模块 # ============================================================ from ios_start import ( TaskResult, IOSBatchStatistics, TeeOutput, load_env_variables, setup_iphone_proxy, setup_infrastructure, install_app_and_get_bundle_id, get_app_info_from_csv, load_apps_from_csv, start_pcap, stop_pcap, EXIT_SUCCESS, EXIT_ERROR_USER, EXIT_WDA_ERROR, EXIT_WDA_STUCK, EXIT_NETWORK_ERROR, EXIT_ERROR_GENERAL, ) # DroidBot 及 GuiAgent 步数统计(droidbot 模式需要) from DroidBot.droidbot import DroidBot from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker # WDA 异常类(droidbot 模式异常处理) try: from DroidBot.exceptions import WDA_RECOVERABLE_EXCEPTIONS from DroidBot.platforms.ios.wda.exceptions import WDAStuckError WDA_EXCEPTIONS = WDA_RECOVERABLE_EXCEPTIONS + (WDAStuckError,) except ImportError: WDA_EXCEPTIONS = () WDAStuckError = None logger = logging.getLogger(__name__) # ============================================================ # 退出码扩展 # ============================================================ EXIT_FASTBOT_ERROR = 7 # Fastbot 启动/运行失败 # ============================================================ # 组件1: PageStuckDetector — 页面卡住检测器 # ============================================================ class PageStuckDetector: """ 后台异步检测应用是否卡在某个页面 检测机制(双重): 1. WDA 交互元素指纹:获取 Button/TextField 等元素的 label+value, 计算特征哈希,连续 N 次相同则判定"疑似卡住" 2. pcap 流量检测:监控 .flows.jsonl 文件行数增量, 无新增行说明无网络活动,辅助判定 分级响应: - back_threshold 次无新特征 → 需要发送 back - agent_threshold 次无新特征 → 需要调用 Agent """ # 用于提取指纹的交互元素类型 INTERACTIVE_TYPES = ['Button', 'TextField', 'SecureTextField', 'SearchField'] def __init__(self, wda_url: str, check_interval: int = 20, back_threshold: int = 10, agent_threshold: int = 30, pcap_csv_path: str = None, health_monitor=None): """ Args: wda_url: WDA 服务地址 check_interval: 检测间隔(秒) back_threshold: 发送 back 的阈值 agent_threshold: 调用 agent 的阈值 pcap_csv_path: pcap .flows.csv 文件路径 """ self.wda_url = wda_url self.check_interval = check_interval self.back_threshold = back_threshold self.agent_threshold = agent_threshold self.pcap_csv_path = pcap_csv_path self.health_monitor = health_monitor # 内部状态 self._stuck_count = 0 self._last_fingerprint = None self._stuck_count = 0 self._last_fingerprint = None self._seen_domains = set() self._stuck_info = {} self._running = False self._lock = threading.Lock() self._thread = None self._wda_client = None def _get_wda_client(self): """获取或创建 WDA 客户端(http: 格式用 Client,UDID/空用 USBClient)""" if self._wda_client is None: sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from wda import Client as WDAClient, USBClient as WDAUSBClient if self.wda_url and self.wda_url.startswith("http:"): self._wda_client = WDAClient(self.wda_url) else: self._wda_client = WDAUSBClient(udid=self.wda_url or "") return self._wda_client def _compute_page_fingerprint(self) -> Optional[str]: """ 通过 WDA source(format='json') 提取交互元素的 label+value 特征字符串, 计算 hash 作为页面指纹。 使用 source(format='json') 一次性获取所有元素信息, 比逐个 find_elements 效率更高。 """ try: client = self._get_wda_client() # 使用 JSON 格式获取页面结构,超时 10 秒 page_source = client.source(format='json', timeout=10) # 递归提取交互元素的 label 和 value features = [] self._extract_interactive_features(page_source, features) if not features: return None # 排序以保证一致性,计算 hash features.sort() fingerprint = hashlib.md5("||".join(features).encode()).hexdigest() return fingerprint except Exception as e: logger.warning(f"[StuckDetector] 获取页面指纹失败(可能 WDA 断联): {e}") if self.health_monitor: logger.info("[StuckDetector] 触发 WDA 恢复并等待...") recovered = self.health_monitor.trigger_recovery_and_wait(timeout=120) if recovered: logger.info("[StuckDetector] WDA 已恢复,重置客户端") self._wda_client = None # 重置以便下次循环重建连接 else: logger.error("[StuckDetector] WDA 恢复失败") return None def _extract_interactive_features(self, node: dict, features: list): """递归遍历 JSON 页面结构,提取交互元素的 label + value""" if not isinstance(node, dict): return # 检查当前节点是否为交互元素 node_type = node.get('type', '') # JSON source 中 type 可能带 XCUIElementType 前缀 short_type = node_type.replace('XCUIElementType', '') if short_type in self.INTERACTIVE_TYPES: label = str(node.get('label', '')) value = str(node.get('value', '')) features.append(f"{short_type}:{label}|{value}") # 递归遍历子节点 children = node.get('children', []) if isinstance(children, list): for child in children: self._extract_interactive_features(child, features) def _check_flow_activity(self) -> bool: """检测 pcap CSV 是否有新增域名(网络活动)""" if not self.pcap_csv_path or not os.path.exists(self.pcap_csv_path): return True # 无法检测时默认有活动 try: new_activity = False # csv 可能会被持续写入,使用 errors='ignore' 避免编码问题 with open(self.pcap_csv_path, 'r', encoding='utf-8', errors='ignore') as f: reader = csv.DictReader(f) for row in reader: domain = row.get('TargetDomain', '').strip() if domain and domain not in self._seen_domains: self._seen_domains.add(domain) new_activity = True return new_activity except Exception as e: logger.debug(f"[StuckDetector] 读取 flows.csv 失败: {e}") return True # 读取失败时默认有活动 def _detection_loop(self): """后台检测循环""" logger.info(f"[StuckDetector] 检测线程已启动 (间隔={self.check_interval}s, " f"back阈值={self.back_threshold}, agent阈值={self.agent_threshold})") while self._running: try: current_fingerprint = self._compute_page_fingerprint() has_flow_activity = self._check_flow_activity() with self._lock: # 页面指纹相同 且 无网络活动 → 卡住计数+1 if (current_fingerprint is not None and current_fingerprint == self._last_fingerprint and not has_flow_activity): self._stuck_count += 1 logger.debug(f"[StuckDetector] 页面无变化 " f"(count={self._stuck_count}, " f"fingerprint={current_fingerprint[:8]}...)") # 仅页面指纹相同(有网络活动)→ 也计数但较慢 elif (current_fingerprint is not None and current_fingerprint == self._last_fingerprint): self._stuck_count += 0.5 logger.debug(f"[StuckDetector] 页面无变化但有网络活动 " f"(count={self._stuck_count})") else: # 页面变化了,重置计数 if self._stuck_count > 0: logger.debug(f"[StuckDetector] 页面已变化,重置计数 " f"(was {self._stuck_count})") self._stuck_count = 0 self._last_fingerprint = current_fingerprint # 更新卡住详情 if self._stuck_count >= self.back_threshold: self._stuck_info = { 'stuck_count': self._stuck_count, 'fingerprint': current_fingerprint, 'has_flow_activity': has_flow_activity, 'timestamp': datetime.now().isoformat(), } except Exception as e: logger.warning(f"[StuckDetector] 检测循环异常: {e}") # 等待下次检测 for _ in range(self.check_interval): if not self._running: break time.sleep(1) logger.info("[StuckDetector] 检测线程已退出") def start(self): """启动后台检测线程""" if self._running: return self._running = True self._stuck_count = 0 self._last_fingerprint = None self._stuck_count = 0 self._last_fingerprint = None self._seen_domains = set() self._thread = threading.Thread(target=self._detection_loop, daemon=True) self._thread.start() def stop(self): """停止后台检测线程""" self._running = False if self._thread and self._thread.is_alive(): self._thread.join(timeout=self.check_interval + 5) self._thread = None def get_action_needed(self) -> Optional[str]: """ 查询当前需要执行的动作 Returns: 'agent' — 需要调用 Agent 处理 'back' — 需要发送 back 键 None — 无需操作 """ with self._lock: if self._stuck_count >= self.agent_threshold: return 'agent' elif self._stuck_count >= self.back_threshold: return 'back' return None def get_stuck_info(self) -> dict: """获取卡住详情""" with self._lock: return self._stuck_info.copy() def reset(self): """重置计数(Agent 处理完后调用)""" with self._lock: self._stuck_count = 0 self._last_fingerprint = None self._stuck_info = {} logger.info("[StuckDetector] 计数已重置") # ============================================================ # 组件2: FastbotAgentController — Fastbot + Agent 混合控制器 # ============================================================ class FastbotAgentController: """ 协调 Fastbot 和 GuiAgent 的交替执行 核心流程: 1. 启动 Fastbot 2. 后台监控循环,检测页面卡住 3. 10次无新特征 → 通过 WDA 发送 back 键 4. 30次无新特征 → 停止 Fastbot → Agent 处理 → 重启 Fastbot 5. Fastbot 正常结束或超时 → 退出 """ def __init__(self, runner, wda_url: str, bundle_id: str, app_name: str = "", check_interval: int = 20, back_threshold: int = 10, agent_threshold: int = 30, agent_timeout: int = 120, pcap_csv_path: str = None, health_monitor=None): """ Args: runner: GoIOSRunner 实例 wda_url: WDA 服务地址 bundle_id: 被测应用 Bundle ID app_name: 应用真实名称(用于 Agent 提示词,如 'Snapchat') check_interval: 检测间隔(秒) back_threshold: 发送 back 的阈值 agent_threshold: 调用 agent 的阈值 agent_timeout: Agent 单次处理超时(秒) pcap_csv_path: pcap .flows.csv 文件路径 """ self.runner = runner self.wda_url = wda_url self.bundle_id = bundle_id self.app_name = app_name or bundle_id # 回退到 bundle_id self.agent_timeout = agent_timeout self.health_monitor = health_monitor # 页面卡住检测器 self.detector = PageStuckDetector( wda_url=wda_url, check_interval=check_interval, back_threshold=back_threshold, agent_threshold=agent_threshold, pcap_csv_path=pcap_csv_path, health_monitor=health_monitor, # 传入统一健康监控 ) # WDA 客户端(懒加载) self._wda_client = None # GuiAgent(懒加载) self._agent_bridge = None # 统计 self.back_count = 0 # 执行 back 的次数 self.agent_count = 0 # 调用 Agent 的次数 self.fastbot_restarts = 0 # Fastbot 重启次数 def _get_wda_client(self): """获取 WDA 客户端(http: 格式用 Client,UDID/空用 USBClient)""" if self._wda_client is None: sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from wda import Client as WDAClient, USBClient as WDAUSBClient if self.wda_url and self.wda_url.startswith("http:"): self._wda_client = WDAClient(self.wda_url) else: self._wda_client = WDAUSBClient(udid=self.wda_url or "") return self._wda_client def _init_agent(self): """初始化 GuiAgent for stuck_handler 场景""" if self._agent_bridge is not None: return try: sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios")) from wda import Client as WDAClient, USBClient as WDAUSBClient from DroidBot.platforms.ios.ios_device import IOSDevice from DroidBot.guiagent_bridge import GuiAgentBridge # 创建轻量级 IOSDevice 用于 Agent 操作 # IOSDevice 需要 wda_url,用于截图和操作 device = IOSDevice.__new__(IOSDevice) device.wda_url = self.wda_url # 根据 wda_url 格式选择连接方式 if self.wda_url and self.wda_url.startswith("http:"): _wda_client = WDAClient(self.wda_url) else: _wda_client = WDAUSBClient(udid=self.wda_url or "") device._wda_client = _wda_client device._platform_name = "ios" device.get_platform_name = lambda: "ios" # GuiAgentBridge._init_agent 需要的属性 device.display_info = None device.output_dir = os.path.join( self.output_dir, "agent_stuck" ) if hasattr(self, 'output_dir') and self.output_dir else "/tmp/agent_stuck" os.makedirs(device.output_dir, exist_ok=True) device.logger = logging.getLogger("IOSDevice.lightweight.fastbot") device._window_size = None device._consecutive_failures = 0 device.WDA_FAILURE_THRESHOLD = 3 device._wda_health_monitor = None device._cached_state = None device.bundle_id = self.bundle_id device.pause_sending_event = False device.cv_mode = False # WDA 健康检查与恢复 _runner = self.runner if hasattr(self, 'runner') else None _wda_url = self.wda_url def _fastbot_wait_wda_ready(timeout=30): try: return _wda_client.wait_ready(timeout=timeout, noprint=True) except Exception as e: logging.getLogger("IOSDevice.lightweight.fastbot").warning( f"_wait_wda_ready 异常: {e}") return False def _fastbot_on_wda_failure(reason=""): """WDA 失败处理:委托给 WDAHealthMonitor 统一处理""" _logger = logging.getLogger("IOSDevice.lightweight.fastbot") _logger.warning(f"WDA 操作失败 ({reason}),触发统一恢复...") _health_mon = self.health_monitor if _health_mon: recovered = _health_mon.trigger_recovery_and_wait(timeout=120) if recovered: _logger.info("WDA 已恢复") else: _logger.error("WDA 恢复失败") else: _logger.warning("无 WDAHealthMonitor,无法自动恢复") device._wait_wda_ready = _fastbot_wait_wda_ready device._on_wda_failure = _fastbot_on_wda_failure # 创建 GuiAgentBridge(使用真实应用名而非 bundle_id) self._agent_bridge = GuiAgentBridge( device=device, app_name=self.app_name, ) logger.info("[FastbotAgent] GuiAgent 初始化成功") except Exception as e: logger.error(f"[FastbotAgent] GuiAgent 初始化失败: {e}") import traceback logger.debug(traceback.format_exc()) def _send_back(self): """通过 WDA 发送 back/返回操作""" try: client = self._get_wda_client() # iOS 没有 back 键,使用从左向右滑动模拟返回 window_size = client.window_size() w, h = window_size # 从屏幕左边缘向右滑动 client.swipe(0, h // 2, w // 2, h // 2, duration=0.3) self.back_count += 1 logger.info(f"[FastbotAgent] 发送 back 手势 (总计 {self.back_count} 次)") time.sleep(1) except Exception as e: logger.warning(f"[FastbotAgent] 发送 back 失败: {e}") def _handle_stuck_with_agent(self, stuck_info: dict) -> bool: """ 停止 Fastbot → Agent 处理卡住页面 → 重启 Fastbot Returns: 是否成功处理 """ logger.info(f"[FastbotAgent] ===== 开始 Agent 干预 =====") logger.info(f"[FastbotAgent] 卡住信息: {stuck_info}") # 1. 停止 Fastbot logger.info("[FastbotAgent] 正在停止 Fastbot...") try: self.runner.stop_fastbot() time.sleep(2) logger.info("[FastbotAgent] Fastbot 已停止") except Exception as e: logger.warning(f"[FastbotAgent] 停止 Fastbot 异常: {e}") # 2. 确保应用在前台 try: client = self._get_wda_client() # 等待 WDA 就绪 if not client.wait_ready(timeout=15, noprint=True): logger.warning("[FastbotAgent] WDA 未就绪,跳过 Agent 干预") return False # 启动目标应用 client.app_launch(self.bundle_id) time.sleep(2) except Exception as e: logger.warning(f"[FastbotAgent] 启动应用失败: {e}") # 3. 调用 GuiAgent 处理 success = False try: self._init_agent() if self._agent_bridge and self._agent_bridge.is_enabled: result = self._agent_bridge.handle_with_guiagent( category="stuck_handler", context={"stuck_info": stuck_info} ) success = result[0] if isinstance(result, tuple) else bool(result) self.agent_count += 1 logger.info(f"[FastbotAgent] Agent 处理结果: " f"{'成功' if success else '未解决'} " f"(总计 {self.agent_count} 次)") else: logger.warning("[FastbotAgent] GuiAgent 不可用,跳过处理") except Exception as e: logger.error(f"[FastbotAgent] Agent 处理异常: {e}") import traceback logger.debug(traceback.format_exc()) logger.info(f"[FastbotAgent] ===== Agent 干预结束 =====") return success def run(self, duration: int = 600, throttle: int = 1000, timeout: int = None) -> dict: """ 主执行逻辑:Fastbot + 后台监控 + 分级响应 Args: duration: Fastbot 单次运行时长(秒) throttle: Fastbot 操作间隔(毫秒) timeout: 总超时时间(秒),None 则使用 duration Returns: 执行统计 dict """ if timeout is None: timeout = duration start_time = time.time() remaining_duration = duration logger.info(f"[FastbotAgent] 开始混合测试 " f"(duration={duration}s, throttle={throttle}ms, timeout={timeout}s)") # 启动检测器 self.detector.start() # 启动 Fastbot try: fastbot_proc = self.runner.start_fastbot( target_bundle_id=self.bundle_id, duration=remaining_duration, throttle=throttle, ) logger.info(f"[FastbotAgent] Fastbot 已启动 (PID: {fastbot_proc.pid})") except Exception as e: logger.error(f"[FastbotAgent] Fastbot 启动失败: {e}") self.detector.stop() return self._build_stats(start_time, success=False, error="fastbot_start_failed") # 主监控循环 try: while True: elapsed = time.time() - start_time # 超时检查 if elapsed >= timeout: logger.info(f"[FastbotAgent] 已达到超时 ({timeout}s),结束测试") break # Fastbot 进程检查 if fastbot_proc.poll() is not None: if elapsed < timeout: logger.warning(f"[FastbotAgent] Fastbot 进程提前退出 " f"(returncode={fastbot_proc.returncode}, " f"已运行: {elapsed:.1f}s, 目标: {timeout}s)") # 重启 Fastbot 补足时间 remaining_duration = max(60, int(timeout - elapsed)) try: fastbot_proc = self.runner.start_fastbot( target_bundle_id=self.bundle_id, duration=remaining_duration, throttle=throttle, ) self.fastbot_restarts += 1 logger.info(f"[FastbotAgent] Fastbot 异常退出已重启 " f"(PID: {fastbot_proc.pid}, " f"remaining={remaining_duration}s, " f"restarts={self.fastbot_restarts})") except Exception as e: logger.error(f"[FastbotAgent] Fastbot 异常退出后重启失败: {e}") break else: logger.info(f"[FastbotAgent] Fastbot 进程已结束 " f"(returncode={fastbot_proc.returncode})") break # 检测页面卡住状态 action = self.detector.get_action_needed() if action == 'agent': # 调用 Agent 处理 stuck_info = self.detector.get_stuck_info() self._handle_stuck_with_agent(stuck_info) # 重置检测器 self.detector.reset() # 重新计算剩余时间 elapsed_after_agent = time.time() - start_time remaining_duration = max(60, int(duration - elapsed_after_agent)) # 重启 Fastbot try: fastbot_proc = self.runner.start_fastbot( target_bundle_id=self.bundle_id, duration=remaining_duration, throttle=throttle, ) self.fastbot_restarts += 1 logger.info(f"[FastbotAgent] Fastbot 已重启 " f"(PID: {fastbot_proc.pid}, " f"remaining={remaining_duration}s, " f"restarts={self.fastbot_restarts})") except Exception as e: logger.error(f"[FastbotAgent] Fastbot 重启失败: {e}") break elif action == 'back': self._send_back() # 等待一个检测周期 time.sleep(self.detector.check_interval) except KeyboardInterrupt: logger.info("[FastbotAgent] 收到中断信号,停止测试") finally: # 清理 self.detector.stop() try: self.runner.stop_fastbot() except Exception: pass return self._build_stats(start_time, success=True) def _build_stats(self, start_time: float, success: bool, error: str = "") -> dict: """构建执行统计""" return { 'success': success, 'error': error, 'duration': time.time() - start_time, 'back_count': self.back_count, 'agent_count': self.agent_count, 'fastbot_restarts': self.fastbot_restarts, } # ============================================================ # 组件3: IOSFastbotTestRunner — 测试执行器 # ============================================================ class IOSFastbotTestRunner: """ 封装单次 Fastbot + GuiAgent 混合测试流程 流程: 1. 创建输出目录、配置日志、启动抓包 2. 阶段1: 权限处理 + 关键词检测 + GuiAgent 登录 3. 阶段2: FastbotAgentController.run() — Fastbot + 异步卡住检测 4. 返回 TaskResult """ def __init__(self, runner, wda_url: str, udid: str, output_base: str = None, health_monitor=None): """ Args: runner: GoIOSRunner 实例 wda_url: WDA 服务地址 udid: 设备 UDID output_base: 输出目录基础路径 health_monitor: WDAHealthMonitor 实例(统一管理 WDA 断联预内恢复) """ self.runner = runner self.wda_url = wda_url self.udid = udid self.output_base = output_base or os.path.join(ROOT, "output", "ios_test") self.health_monitor = health_monitor def run_test(self, bundle_id: str, app_id: str = "", app_name: str = "",raw_app_name: str = "", duration: int = 600, throttle: int = 1000, enable_login: bool = True, check_interval: int = 20, back_threshold: int = 10, agent_threshold: int = 30, agent_timeout: int = 300, test_mode: str = "droidbot", # droidbot 模式专用参数 policy: str = "memory_guided", event_count: int = 10000, cv_mode: bool = False, debug_mode: bool = False, guiagent_flag: bool = True, random_input: bool = False) -> TaskResult: """ 执行完整的混合测试 Args: bundle_id: 被测应用 Bundle ID app_name: 应用名称,来自iOS安装后显示名称(用于日志) raw_app_name: 应用原始名称,来自采集名单(用于日志) duration: 测试时长(秒) throttle: Fastbot 操作间隔(毫秒) enable_login: 是否启用登录处理 check_interval: 卡住检测间隔 back_threshold: back 阈值 agent_threshold: agent 阈值 agent_timeout: agent 超时 test_mode: 测试模式,'fastbot'(默认)或 'droidbot' policy: [droidbot] 探索策略(默认: memory_guided) event_count: [droidbot] 最大事件数(默认: 10000) cv_mode: [droidbot] 是否启用 CV 模式 debug_mode: [droidbot] 是否启用调试模式 guiagent_flag: [droidbot] 是否启用 GuiAgent random_input: [droidbot] 是否引入随机输入 Returns: TaskResult """ start_time = time.time() display_name = app_name or bundle_id # 创建输出目录 safe_bundle = bundle_id.replace('.', '_') timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = os.path.join( self.output_base, f"{safe_bundle}_iOS_{timestamp}" ) os.makedirs(output_dir, exist_ok=True) # 设置 agent log 目录环境变量 # GuiAgentDecisionMaker 读取 GUIAGENT_LOG_DIR,其他组件读取 AGENT_LOG_DIR,两个都要设置 agent_log_dir = os.path.join(ROOT, "output", "agent_logs") os.environ['AGENT_LOG_DIR'] = agent_log_dir os.environ['GUIAGENT_LOG_DIR'] = agent_log_dir os.makedirs(agent_log_dir, exist_ok=True) # 配置日志输出到文件 log_file = os.path.join(output_dir, "test_log.txt") tee = TeeOutput(log_file) tee.setup_logging(debug_mode=debug_mode) old_stdout, old_stderr = sys.stdout, sys.stderr sys.stdout = tee sys.stderr = tee pcap_process = None flows_jsonl_path = None try: print(f"\n{'='*60}") print(f" iOS Fastbot+GuiAgent 混合测试") print(f" 应用: {display_name} ({bundle_id})") print(f" 时长: {duration}s | 间隔: {throttle}ms") print(f" 输出: {output_dir}") print(f"{'='*60}\n") # ========== 启动抓包(默认启用) ========== print("[INFO] 启动抓包...") pcap_process = start_pcap( bundle_id=bundle_id, udid=self.udid, output_dir=output_dir, app_id=app_id, app_name=app_name, raw_app_name=raw_app_name, ) time.sleep(2) # 查找 flows.csv 文件路径 pcap_csv_path = self._find_pcap_csv(output_dir) if pcap_csv_path: print(f"[INFO] flows.csv 路径: {pcap_csv_path}") else: print("[WARN] 未找到 flows.csv 文件,流量检测将不可用") # ========== 阶段1: 权限处理 + 关键词检测 + GuiAgent 登录 ========== print(f"\n{'='*50}") print(f"[阶段1] 权限处理 + 登录检测") print(f"{'='*50}\n") if enable_login: import concurrent.futures try: from wda import Client as WDAClient, USBClient as WDAUSBClient # 根据 wda_url 格式选择连接方式 if self.wda_url and self.wda_url.startswith("http:"): wda_client = WDAClient(self.wda_url) else: wda_client = WDAUSBClient(udid=self.wda_url or "") # 1. 启动应用 print(f"[INFO] 启动应用: {bundle_id}") wda_client.session(bundle_id) time.sleep(3) # 2. 处理权限弹窗(最多尝试 5 轮) print("[INFO] 处理权限弹窗...") for _ in range(5): try: alert = wda_client.alert with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(lambda: alert.exists) try: has_alert = future.result(timeout=10) except concurrent.futures.TimeoutError: print("[WARN] 检查 Alert 超时,跳过") break if has_alert: alert.accept() print("[INFO] 已处理一个权限弹窗") time.sleep(1) else: break except Exception as e: logger.debug(f"处理权限弹窗异常: {e}") break # 3. 关键词检测:检查页面是否包含登录/注册相关文案 LOGIN_KEYWORDS = [ "登录", "登陆", "login", "sign in", "log in", "Apple 继续", "AppleID", ] REGISTER_KEYWORDS = [ "注册", "register", "sign up", "create account", ] detected_category = None detected_keyword = None def _check_keyword(kw): """检查单个关键词是否存在于页面(labelContains)""" try: return wda_client(labelContains=kw).exists except Exception: return False time.sleep(5) print("[INFO] 检测登录/注册关键词...") # 先检测登录关键词 for kw in LOGIN_KEYWORDS: try: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_check_keyword, kw) found = future.result(timeout=8) if found: detected_category = "login" detected_keyword = kw print(f"[INFO] 检测到登录关键词: '{kw}'") break except concurrent.futures.TimeoutError: logger.debug(f"检测关键词超时: {kw}") continue except Exception as e: logger.debug(f"检测关键词异常: {kw} - {e}") continue # 未检测到登录,再检测注册 if not detected_category: for kw in REGISTER_KEYWORDS: try: with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(_check_keyword, kw) found = future.result(timeout=8) if found: detected_category = "register" detected_keyword = kw print(f"[INFO] 检测到注册关键词: '{kw}'") break except concurrent.futures.TimeoutError: logger.debug(f"检测关键词超时: {kw}") continue except Exception as e: logger.debug(f"检测关键词异常: {kw} - {e}") continue # 4. 如果检测到登录/注册关键词,交给 GuiAgent 处理 if detected_category: print(f"[INFO] 启动 GuiAgent 处理 {detected_category}...") try: from DroidBot.guiagent_bridge import GuiAgentBridge from DroidBot.platforms.ios.ios_device import IOSDevice # 创建轻量级 IOSDevice 用于 Agent 操作 device = IOSDevice.__new__(IOSDevice) device.wda_url = self.wda_url device._wda_client = wda_client device._platform_name = "ios" device.get_platform_name = lambda: "ios" # GuiAgentBridge._init_agent 需要的属性 device.display_info = None # get_display_info 需要 device.output_dir = os.path.join(output_dir, "agent_login") os.makedirs(device.output_dir, exist_ok=True) device.logger = logging.getLogger("IOSDevice.lightweight") device._window_size = None device._consecutive_failures = 0 device.WDA_FAILURE_THRESHOLD = 3 device._wda_health_monitor = None # WDA 就绪检查:委托给 WDAHealthMonitor(如有),否则轻量实现 _health_mon = self.health_monitor # 闭包捕获 def _lightweight_wait_wda_ready(timeout=30): """等待 WDA 就绪(委托给 health_monitor 或直接检等)""" try: return wda_client.wait_ready(timeout=timeout, noprint=True) except Exception as e: logging.getLogger("IOSDevice.lightweight").warning( f"_wait_wda_ready 异常: {e}") return False def _lightweight_on_wda_failure(reason=""): """委托给 WDAHealthMonitor 统一处理""" _logger = logging.getLogger("IOSDevice.lightweight") _logger.warning(f"WDA 操作失败 ({reason}),触发统一恢复...") if _health_mon: recovered = _health_mon.trigger_recovery_and_wait(timeout=120) if recovered: _logger.info("WDA 已恢复") else: _logger.error("WDA 恢复失败") else: # 无 health_monitor 时保持原有轻量实现 _logger.warning("无 WDAHealthMonitor,无法自动恢复") device._wait_wda_ready = _lightweight_wait_wda_ready device._on_wda_failure = _lightweight_on_wda_failure # get_current_state / send_event 需要的属性 device._cached_state = None device.bundle_id = bundle_id device.pause_sending_event = False device.cv_mode = False agent_bridge = GuiAgentBridge( device=device, app_name=display_name, # 使用真实应用名 ) if agent_bridge.is_enabled: agent_result = agent_bridge.handle_with_guiagent( category=detected_category, max_steps=30, max_error_steps=20, ) agent_success = agent_result[0] if isinstance(agent_result, tuple) else bool(agent_result) if agent_success: print(f"[SUCCESS] GuiAgent {detected_category} 处理完成") else: print(f"[WARN] GuiAgent {detected_category} 处理未成功") else: print("[WARN] GuiAgent 不可用") except Exception as e: print(f"[WARN] GuiAgent 处理异常: {e}") import traceback logger.debug(traceback.format_exc()) else: print("[INFO] 未检测到登录/注册关键词,跳过登录处理") except Exception as e: print(f"[WARN] 阶段1异常: {e}") import traceback logger.debug(traceback.format_exc()) else: print("[INFO] 跳过登录处理") # 等待应用稳定 time.sleep(3) # ========== 阶段2: 核心测试(fastbot / droidbot 二选一) ========== print(f"\n{'='*50}") print(f"[阶段2] 核心测试模式: {test_mode}") print(f"{'='*50}\n") if test_mode == "droidbot": # ---------- DroidBot 模式(对齐 ios_start.py IOSTestRunner 实现)---------- droidbot_output_dir = os.path.join(output_dir, "droidbot") os.makedirs(droidbot_output_dir, exist_ok=True) # 重置 GuiAgent 步数计数器(每轮任务独立统计) GuiAgentDecisionMaker.total_steps = 0 droidbot_steps = 0 guiagent_steps = 0 _droidbot_ret_code = EXIT_ERROR_GENERAL _droidbot_status = "FAILED" _droidbot_error = "" _user_interrupted = False print(f"[INFO] 启动 DroidBot 测试 " f"(policy={policy}, timeout={duration}s, " f"event_interval={throttle/1000:.1f}s, event_count={event_count}) ...") try: droidbot = DroidBot( package_name=bundle_id, device_serial=self.udid, is_emulator=False, output_dir=droidbot_output_dir, policy_name=policy, random_input=random_input, event_interval=throttle / 1000, # 毫秒转秒 timeout=duration, event_count=event_count, cv_mode=cv_mode, debug_mode=debug_mode, keep_app=True, # 不重装应用 keep_env=False, profiling_method=None, grant_perm=False, enable_accessibility_hard=False, humanoid=None, ignore_ad=False, replay_output=None, enable_guiagent=guiagent_flag, app_name=app_name, platform="ios", wda_url=self.wda_url, ) # 注入 WDA 健康监控 if self.health_monitor and hasattr(droidbot, 'device'): droidbot.device.set_health_monitor(self.health_monitor) # 注入 pcap 流量目录,供 TrafficMonitor.get_traffic_domains() 读取 # pcap 工具写入 --output={output_dir},flows.csv 在 output_dir 下 if hasattr(droidbot, 'device'): droidbot.device.captured_traffic_dir = output_dir # 同步 output_dir(TrafficMonitor 会从 device.output_dir 推断备用路径) if not droidbot.device.output_dir: droidbot.device.output_dir = output_dir # 注入 pcap 进程引用,供 is_traffic_capture_running / restart_traffic_capture 使用 if pcap_process and hasattr(droidbot.device, 'set_pcap_process'): droidbot.device.set_pcap_process( pcap_process, restart_kwargs=dict( bundle_id=bundle_id, udid=self.udid, output_dir=output_dir, app_id=app_id, app_name=app_name, raw_app_name=raw_app_name, ) ) droidbot.start() _droidbot_ret_code = EXIT_SUCCESS _droidbot_status = "SUCCESS" # 精确统计步数 droidbot_steps = droidbot.input_manager.total_exploring_steps guiagent_steps = GuiAgentDecisionMaker.total_steps print(f"[SUCCESS] DroidBot 测试完成: {bundle_id}") except KeyboardInterrupt: print(f"[WARN] 用户手动中断 DroidBot 测试: {bundle_id}") _droidbot_ret_code = EXIT_ERROR_USER _droidbot_status = "INTERRUPTED" _droidbot_error = "用户手动中断" _user_interrupted = True except Exception as _e: import traceback as _tb # WDA 异常区分 if WDAStuckError and isinstance(_e, WDAStuckError): print(f"[ERROR] WDA 卡死且恢复失败: {_e}") _droidbot_ret_code = EXIT_WDA_STUCK _droidbot_error = f"WDA 卡死恢复失败: {_e}" elif WDA_EXCEPTIONS and isinstance(_e, WDA_EXCEPTIONS): print(f"[ERROR] WDA 连接异常: {_e}") _droidbot_ret_code = EXIT_WDA_ERROR _droidbot_error = f"WDA 连接异常: {_e}" else: print(f"[ERROR] DroidBot 执行异常: {_e}") _droidbot_error = f"执行异常: {_e}" _droidbot_status = "FAILED" logger.debug(_tb.format_exc()) finally: # 尽量获取步数(即使 droidbot.start() 中途抛出异常也要读取) try: droidbot_steps = droidbot.input_manager.total_exploring_steps except Exception: pass try: guiagent_steps = GuiAgentDecisionMaker.total_steps except Exception: pass elapsed = time.time() - start_time total_steps_db = droidbot_steps + guiagent_steps print(f"[INFO] DroidBot 步数: {droidbot_steps}") print(f"[INFO] GuiAgent 步数: {guiagent_steps}") print(f"[INFO] 总步数: {total_steps_db}") _result = TaskResult( status=_droidbot_status, exit_code=_droidbot_ret_code, error_reason=_droidbot_error, droidbot_steps=droidbot_steps, guiagent_steps=guiagent_steps, total_steps=total_steps_db, duration_seconds=elapsed, test_timestamp=timestamp, ) if _user_interrupted: raise KeyboardInterrupt(_result) return _result else: # ---------- Fastbot 模式(默认) ---------- controller = FastbotAgentController( runner=self.runner, wda_url=self.wda_url, bundle_id=bundle_id, app_name=app_name, # 传入真实应用名 check_interval=check_interval, back_threshold=back_threshold, agent_threshold=agent_threshold, agent_timeout=agent_timeout, pcap_csv_path=pcap_csv_path, health_monitor=self.health_monitor, # 传入统一健康监控 ) stats = controller.run( duration=duration, throttle=throttle, timeout=duration, ) # ========== 构建结果 ========== elapsed = time.time() - start_time print(f"\n{'='*50}") print(f" 测试完成") print(f" 耗时: {elapsed:.1f}s") print(f" Back 次数: {stats['back_count']}") print(f" Agent 介入: {stats['agent_count']} 次") print(f" Fastbot 重启: {stats['fastbot_restarts']} 次") print(f"{'='*50}\n") return TaskResult( status="SUCCESS", exit_code=EXIT_SUCCESS, error_reason="", droidbot_steps=0, # Fastbot 模式无 DroidBot 步数 guiagent_steps=stats['agent_count'], total_steps=stats['back_count'] + stats['agent_count'], duration_seconds=elapsed, test_timestamp=timestamp, ) except KeyboardInterrupt: elapsed = time.time() - start_time print(f"\n[INTERRUPTED] 用户中断测试 (耗时 {elapsed:.1f}s)") return TaskResult( status="INTERRUPTED", exit_code=EXIT_ERROR_USER, error_reason="用户中断", droidbot_steps=0, guiagent_steps=0, total_steps=0, duration_seconds=elapsed, test_timestamp=timestamp, ) except Exception as e: elapsed = time.time() - start_time import traceback error_msg = str(e) print(f"\n[ERROR] 测试异常: {error_msg}") print(traceback.format_exc()) return TaskResult( status="FAILED", exit_code=EXIT_ERROR_GENERAL, error_reason=error_msg, droidbot_steps=0, guiagent_steps=0, total_steps=0, duration_seconds=elapsed, test_timestamp=timestamp, ) finally: # 停止抓包 stop_pcap(pcap_process) # 恢复标准输出 sys.stdout = old_stdout sys.stderr = old_stderr tee.close() def _find_pcap_csv(self, output_dir: str) -> Optional[str]: """查找 pcap 生成的 .flows.csv 文件""" # pcap 工具生成的文件名格式: dump-{bundle_id}-{timestamp}.pcap.flows.csv # 递归查找 traffic 子目录 traffic_dir = os.path.join(output_dir, "traffic") search_dirs = [output_dir] if os.path.exists(traffic_dir): search_dirs.insert(0, traffic_dir) for _ in range(2): # 尝试两次,中间等待 for d in search_dirs: if not os.path.exists(d): continue try: for fname in os.listdir(d): if fname.endswith('.flows.csv'): return os.path.join(d, fname) except Exception: pass # 等待文件生成 time.sleep(5) return None # ============================================================ # 组件4: 命令行参数和主函数 # ============================================================ def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser( description="iOS Fastbot + GuiAgent 混合测试脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 示例: # 单个应用测试 python ios_test.py -bundle_id com.apple.AppStore -duration 120 # 批量模式 python ios_test.py -batch -app_list output/ios/app_magic_summary_apple_no_google.csv -batch_count 5 # 自定义检测参数 python ios_test.py -bundle_id com.example.app -duration 600 \\ -stuck_check_interval 15 -back_threshold 8 -agent_threshold 20 """ ) # 基本参数 parser.add_argument('-bundle_id', type=str, default='', help='被测应用的 Bundle ID') parser.add_argument('-wda_url', type=str, default='00008101-0016601022C0001E', help='WDA 服务地址或设备 UDID (默认: 00008101-0016601022C0001E 或 http://localhost:8100)') parser.add_argument('-udid', type=str, default='00008101-0016601022C0001E', help='设备 UDID') parser.add_argument('-duration', type=int, default=5400, help='测试时长/秒 (默认: 5400)') parser.add_argument('-throttle', type=int, default=500, help='Fastbot 操作间隔/毫秒 (默认: 500)') # 登录相关 parser.add_argument('-enable_login', action='store_true', help='启用 AppleID 登录处理') # 批量模式 parser.add_argument('-batch', action='store_true', help='启用批量测试模式') parser.add_argument('-app_list', type=str, default=str(ROOT / 'output' / 'ios' / 'app_magic_summary_apple_no_google.csv'), help='应用列表 CSV 文件路径') parser.add_argument('-batch_start', type=int, default=-1, help='批量测试起始 index(对应 CSV 中的 0-based 行号)。' '不指定(默认 -1)时启用智能调度:未测优先,SKIPPED/FAILED 放队尾') parser.add_argument('-batch_count', type=int, default=0, help='批量测试数量, 0=全部 (默认: 0)') parser.add_argument('-resume_from', type=str, default='', help='断点续传指定的 previous batch result CSV 路径') # 页面卡住检测参数 parser.add_argument('-stuck_check_interval', type=int, default=20, help='页面卡住检测间隔/秒 (默认: 20)') parser.add_argument('-back_threshold', type=int, default=10, help='发送 back 的检测次数阈值 (默认: 10)') parser.add_argument('-agent_threshold', type=int, default=30, help='调用 Agent 的检测次数阈值 (默认: 30)') parser.add_argument('-agent_timeout', type=int, default=300, help='Agent 单次处理超时/秒 (默认: 300)') # 测试模式 parser.add_argument('-test_mode', type=str, default='droidbot', choices=['fastbot', 'droidbot'], help='核心测试模式: fastbot(默认)或 droidbot') # DroidBot 模式专用参数 parser.add_argument('-policy', type=str, default='memory_guided', help='[droidbot] 探索策略 (默认: memory_guided)') parser.add_argument('-event_count', type=int, default=10000, help='[droidbot] 最大事件数 (默认: 10000)') parser.add_argument('-cv_mode', action='store_true', default=False, help='[droidbot] 启用 CV 模式') parser.add_argument('-debug_mode', action='store_true', default=False, help='[droidbot] 启用调试模式') parser.add_argument('-guiagent_flag', type=bool, default=True, help='[droidbot] 是否启用 GuiAgent') parser.add_argument('-random_input', action='store_true', default=False, help='[droidbot] 引入随机输入') # 输出 parser.add_argument('-output_dir', type=str, default=str(ROOT / 'output' / 'ios_test'), help='输出目录 (默认: output/ios_test)') return parser.parse_args() def _read_tested_history(output_dir: str) -> dict: """读取历史测试记录,返回 {index: (status, error_reason)} 字典 扫描 output_dir 目录下所有 ios_batch_result_*.csv 文件, 同一个 index 的最新记录(按文件名时间戳排序)为准。 """ import glob result_dir = str(Path(output_dir).parent) if not output_dir.endswith('output') else output_dir # 查找所有 batch result CSV(兼容 output/ 目录的父目录) patterns = [ os.path.join(result_dir, 'ios_batch_result_*.csv'), os.path.join(os.path.dirname(result_dir), 'ios_batch_result_*.csv'), os.path.join(str(ROOT / 'output'), 'ios_batch_result_*.csv'), ] files = [] for pat in patterns: files.extend(glob.glob(pat)) files = sorted(set(files)) # 去重,按文件名时间排序 history = {} # index -> (status, error_reason) for fpath in files: try: with open(fpath, 'r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: try: idx = int(row['index']) status = row.get('status', '').strip() error_reason = row.get('error_reason', '').strip() if status: history[idx] = (status, error_reason) except (ValueError, KeyError): pass except Exception: pass return history # 不应重试的永久性失败原因(应用本身问题,重试也无法成功) PERMANENT_FAIL_REASONS = ["应用不可用", "付费应用"] def _is_permanent_failure(status: str, error_reason: str) -> bool: """判断是否为永久性失败(不应重试)""" if status == 'SUCCESS': return True return any(reason in error_reason for reason in PERMANENT_FAIL_REASONS) def load_apps_smart_schedule(csv_path: str, batch_count: int = 0) -> list: """智能调度:未测应用优先,失败放队尾 Args: csv_path: 应用列表 CSV batch_count: 调度数量上限,0=全部 Returns: 排好序的 app_info 列表(每条含 index、app_id、name) 顺序:未测(按 CSV 顺序)→ SKIPPED/FAILED(按 CSV 顺序) """ output_dir = str(ROOT / 'output') history = _read_tested_history(output_dir) # 加载全量应用 all_apps = load_apps_from_csv(csv_path) total = len(all_apps) # 成功/永久性失败 不重测;未测 & 可重试失败 分开 untested = [] failed = [] permanent_fail_count = 0 for app in all_apps: idx = app['index'] record = history.get(idx) if record is None: untested.append(app) # 未测 else: status, error_reason = record if _is_permanent_failure(status, error_reason): if status != 'SUCCESS': permanent_fail_count += 1 continue # 已成功或永久性失败,跳过 else: failed.append(app) # SKIPPED / FAILED(可重试) scheduled = untested + failed tested_count = total - len(untested) - len(failed) - permanent_fail_count print(f"[INFO] 历史记录:共 {len(history)} 条 | 已成功: {tested_count} | 永久失败: {permanent_fail_count} | 未测: {len(untested)} | 待重试: {len(failed)}") if batch_count > 0: scheduled = scheduled[:batch_count] return scheduled def run_batch_mode(args, runner, udid: str): """批量测试模式""" # 初始化统计 stats = IOSBatchStatistics(output_dir=args.output_dir) # 修改详细数据存储位置 if args.output_dir.endswith('ios_test') or args.output_dir.endswith('ios_test/'): base_dir = os.path.dirname(args.output_dir.rstrip('/')) args.output_dir = os.path.join(base_dir, f"ios_test_{stats.start_time.strftime('%Y%m%d_%H%M%S')}") stats.output_dir = args.output_dir # ----- 决定应用列表 ----- # 若指定了 batch_start,则从指定位置顺序加载(精确控制) # 若未指定(默认 -1),则启用智能调度:未测优先,失败放队尾 use_smart_schedule = (args.batch_start < 0) if use_smart_schedule: print(f"\n{'='*60}") print(f" iOS Fastbot+GuiAgent 批量测试模式(智能调度)") print(f" CSV: {args.app_list}") print(f" 模式: 未测优先,SKIPPED/FAILED 放队尾") print(f"{'='*60}\n") apps = load_apps_smart_schedule(args.app_list, batch_count=args.batch_count) else: print(f"\n{'='*60}") print(f" iOS Fastbot+GuiAgent 批量测试模式") print(f" CSV: {args.app_list}") print(f" 起始 index: {args.batch_start} | 数量: {args.batch_count or '全部'}") print(f"{'='*60}\n") apps = load_apps_from_csv( csv_path=args.app_list, start_index=args.batch_start, count=args.batch_count, ) if not apps: print("[ERROR] 未找到可测试的应用") return print(f"[INFO] 加载 {len(apps)} 个应用") # 定期报告:每6小时发送一次采集情况 REPORT_INTERVAL = 6 * 3600 # 6小时(秒) last_report_time = time.time() batch_start_time = time.time() # 初始化 WDA 健康监控(用于卡死检测与异步恢复) from utils_ios.wda_health import WDAHealthMonitor wda_health_monitor = WDAHealthMonitor( wda_url=args.wda_url, go_ios_runner=runner, ) print("[INFO] WDA 健康监控已初始化") # 测试执行器 test_runner = IOSFastbotTestRunner( runner=runner, wda_url=args.wda_url, udid=udid, output_base=args.output_dir, health_monitor=wda_health_monitor, # 传入统一健康监控 ) # 遍历应用列表 for i, app_info in enumerate(apps): app_id = app_info['app_id'] app_name = app_info.get('name', '') index = app_info['index'] # CSV 中的 0-based 唯一行号 print(f"\n{'='*60}") print(f" [index={index}] ({i+1}/{len(apps)}) {app_name or app_id}") print(f"{'='*60}\n") wda_health_monitor.reset_attempts() # 安装前检查证书有效性(证书过期会导致 WDA/Fastbot 无法运行) if not ensure_certs_valid(udid, runner, threshold_hours=24): send_weCom_alert("证书更新异常,采集终止") break # 安装应用 bundle_id, reason = install_app_and_get_bundle_id( app_id=app_id, wda_url=args.wda_url, udid=udid, health_monitor=wda_health_monitor ) # 安装完成后先使用go-ios ps -apps获取运行应用信息(json),过滤Name为WebDriverAgentRunner-Runner的应用,其他应用关闭 try: import json import subprocess print("[INFO] 开始清理后台常驻应用...") go_ios_bin = str(ROOT / "utils_ios" / "go_ios" / "bin" / "ios-darwin-arm64") if not os.path.exists(go_ios_bin): go_ios_bin = "ios" ps_cmd = [go_ios_bin, "ps", "--apps", "--udid", udid] ps_result = subprocess.run(ps_cmd, capture_output=True, text=True, timeout=15) if ps_result.returncode == 0: apps_info = json.loads(ps_result.stdout) for app in apps_info: name = app.get("Name", "") pid = app.get("Pid") if name and name != "WebDriverAgentRunner-Runner" and pid: kill_cmd = [go_ios_bin, "kill", f"--pid={pid}", "--udid", udid] subprocess.run(kill_cmd, capture_output=True, timeout=5) print(f"[INFO] 已关闭后台应用: {name} (PID: {pid})") except Exception as e: print(f"[WARN] 清理后台应用异常: {e}") # 安装失败处理(WDA 断联已在底层安装过程中自动处理+等待恢复) # 到达此处的失败均为:付费应用、应用不可用、安装超时等真实业务失败 if not bundle_id: csv_app_id_fail = app_id if not app_id.isdigit() else f"id{app_id}" # 永久性失败(应用不可用、付费应用)记录为 FAILED,避免后续重试 if any(r in (reason or "") for r in PERMANENT_FAIL_REASONS): print(f"[FAIL] 应用永久性失败: {csv_app_id_fail} - {reason}") stats.add_fail_record(index, app_name, app_name, csv_app_id_fail, reason) else: print(f"[SKIP] 应用安装失败: {csv_app_id_fail} - {reason}") stats.add_skip_record(index, app_name, app_name, csv_app_id_fail, reason or "安装失败") stats.save_csv() continue # 从 appstore_mapping.csv 读取真实应用名(模型更容易识别) csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv") csv_app_id = app_id if not app_id.isdigit() else f"id{app_id}" csv_info = get_app_info_from_csv(csv_app_id, csv_file) real_app_name = csv_info.get('app_name', '') or app_name raw_app_name = app_name # 原始 app_name if real_app_name and real_app_name != app_name: print(f"[INFO] 使用真实应用名: {real_app_name} (原始: {app_name or bundle_id})") app_name = real_app_name # 执行测试 result = test_runner.run_test( bundle_id=bundle_id, app_id=csv_app_id, app_name=app_name, raw_app_name=raw_app_name, duration=args.duration, throttle=args.throttle, enable_login=args.enable_login, check_interval=args.stuck_check_interval, back_threshold=args.back_threshold, agent_threshold=args.agent_threshold, agent_timeout=args.agent_timeout, test_mode=args.test_mode, policy=args.policy, event_count=args.event_count, cv_mode=args.cv_mode, debug_mode=args.debug_mode, guiagent_flag=args.guiagent_flag, random_input=args.random_input, ) # 记录结果(手动中断时不写入 CSV,直接终止批量循环) if result.status == "INTERRUPTED": print(f"[WARN] 用户手动中断,跳过 CSV 写入,终止批量测试") break stats.add_record(index, app_name, raw_app_name, csv_app_id, bundle_id, result) stats.save_csv() # 测试成功后重置 WDA 恢复计数 if result.status == "SUCCESS": wda_health_monitor.reset_recover_count() print(f"[RESULT] {app_name}: {result.status} " f"(耗时 {result.duration_seconds:.1f}s)") # 测试完成后卸载应用(不论成功/失败) try: go_ios_bin = str(ROOT / "utils_ios" / "go_ios" / "bin" / "ios-darwin-arm64") uninstall_cmd = [go_ios_bin, "uninstall", bundle_id, "--udid", udid] subprocess.run(uninstall_cmd, timeout=30, capture_output=True) print(f"[INFO] 已卸载应用: {bundle_id}") except Exception as e: print(f"[WARN] 卸载应用失败: {bundle_id} - {e}") # 每6小时发送采集情况报告 if time.time() - last_report_time >= REPORT_INTERVAL: total = len(stats.records) success = sum(1 for r in stats.records if r["status"] == "SUCCESS") failed = sum(1 for r in stats.records if r["status"] == "FAILED") skipped = sum(1 for r in stats.records if r["status"] == "SKIPPED") progress = (i + 1) / len(apps) * 100 report_msg = ( f" iOS 采集情况报告\n" f"━━━━━━━━━━━━━━━━\n" f" 进度: {i + 1}/{len(apps)} ({progress:.1f}%)\n" f" 成功: {success}\n" f" 失败: {failed}\n" f" 跳过: {skipped}\n" f"━━━━━━━━━━━━━━━━" ) try: send_weCom_alert(report_msg) print(f"[INFO] 已发送6小时采集情况报告") except Exception as e: print(f"[WARN] 发送采集报告失败: {e}") last_report_time = time.time() # 打印摘要 stats.print_summary() def main(): """主入口""" # 加载环境变量 load_env_variables() # 解析参数 args = parse_args() # 启动基础设施 runner, udid = setup_infrastructure(args) if args.batch: # 批量模式 run_batch_mode(args, runner, udid) else: # 单个应用模式 if not args.bundle_id: print("[ERROR] 单个模式需要指定 -bundle_id") sys.exit(EXIT_ERROR_USER) # 安装应用(如果是 app_id 格式) bundle_id = args.bundle_id if bundle_id.isdigit() or bundle_id.startswith('id'): from utils_ios.wda_health import WDAHealthMonitor wda_health_monitor = WDAHealthMonitor( wda_url=args.wda_url, go_ios_runner=runner, ) installed_bundle_id, reason = install_app_and_get_bundle_id( app_id=bundle_id, wda_url=args.wda_url, udid=udid, health_monitor=wda_health_monitor ) if installed_bundle_id: bundle_id = installed_bundle_id else: print(f"[ERROR] 应用安装失败: {bundle_id} - {reason}") sys.exit(EXIT_FASTBOT_ERROR) # 执行测试 test_runner = IOSFastbotTestRunner( runner=runner, wda_url=args.wda_url, udid=udid, output_base=args.output_dir, ) result = test_runner.run_test( bundle_id=bundle_id, duration=args.duration, throttle=args.throttle, enable_login=args.enable_login, check_interval=args.stuck_check_interval, back_threshold=args.back_threshold, agent_threshold=args.agent_threshold, agent_timeout=args.agent_timeout, test_mode=args.test_mode, policy=args.policy, event_count=args.event_count, cv_mode=args.cv_mode, debug_mode=args.debug_mode, guiagent_flag=args.guiagent_flag, random_input=args.random_input, ) print(f"\n[FINAL] 状态: {result.status} | 退出码: {result.exit_code}") sys.exit(result.exit_code) if __name__ == "__main__": main()