#!/usr/bin/env python3 import os import argparse import sys import time import json import re import logging from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict # 找到项目根目录 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 from config_loader import load_config as load_runtime_config from utils_android.device_config import ensure_android_wireless_connected, get_android_device_serial # 导入 DroidBot from DroidBot.droidbot import DroidBot from DroidBot.exceptions import ADBException, AppCrashException, ExplorationStuckException, AppNeedUpdateException, AppLaunchErrorException from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker from result_codes import ResultCode, ErrorCategory, ErrorInfo, STUCK_TO_ERROR EXIT_ERROR_GENERAL = ResultCode.ERROR_GENERAL EXIT_SUCCESS = ResultCode.SUCCESS EXIT_ERROR_USER = ResultCode.ERROR_USER EXIT_ADB_ERROR = ResultCode.ADB_ERROR EXIT_NETWORK_ERROR = ResultCode.NETWORK_ERROR EXIT_SIMULATOR_ERROR = ResultCode.SIMULATOR_ERROR EXIT_EXPLORATION_STUCK = ResultCode.EXPLORATION_STUCK EXIT_APP_CRASH_ERROR = ResultCode.APP_CRASH EXIT_APP_NEED_UPDATE = ResultCode.APP_NEED_UPDATE EXIT_APP_LAUNCH_ERROR = ResultCode.APP_LAUNCH_ERROR STUCK_REASON_TO_ERROR_TYPE = {k: v[0].name for k, v in STUCK_TO_ERROR.items()} @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 # 执行耗时(秒) guiagent_message: str = "" # GuiAgent 登录/注册失败消息,用于worker.report汇报 login_count: int = 0 # 进入登录场景的次数 register_count: int = 0 # 进入注册场景的次数 stuck_reason_code: int = 0 # 卡住原因代码 (0=无/其他, 1=登录注册, 2=启动异常, 3=正常, 4-12=其他原因) num_nodes: int = 0 # UTG 节点数 num_reached_activities: int = 0 # UTG 已到达 Activity 数 app_num_total_activities: int = 0 # 应用总 Activity 数 def load_config(json_path: str = None): """从JSON文件加载配置""" try: return load_runtime_config(json_path) except Exception as e: target = json_path or "layered autool config" print(f"[ERROR] 无法从 {target} 加载配置: {e}") raise e 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): """设置根日志记录器的文件处理器,捕获所有logging模块输出""" import logging self.log_handler = logging.FileHandler(self.file_path, mode='a', encoding='utf-8') self.log_handler.setLevel(logging.DEBUG) # 捕获所有级别 formatter = logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s') self.log_handler.setFormatter(formatter) logging.getLogger().addHandler(self.log_handler) 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 TaskRunner: """任务执行器:负责构建路径、直接调用 DroidBot 模块并执行""" def __init__(self, config: dict): self.config = config def _build_droidbot_kwargs(self, package_name: str, app_name: str, output_dir: str, **extra_kwargs) -> dict: """构建 DroidBot 初始化所需的参数""" # 统一路径格式为正斜杠 output_dir = output_dir.replace('\\', '/') device_serial = get_android_device_serial(self.config) ensure_android_wireless_connected(self.config, device_serial) mumu_config = self.config.get("mumu", {}) kwargs = { 'package_name': package_name, 'app_name': app_name, 'device_serial': device_serial, 'is_emulator': self.config.get("IS_EMULATOR", False), 'output_dir': output_dir, 'policy_name': self.config.get("POLICY", "memory_guided"), 'random_input': False, 'event_interval': 0, 'timeout': self.config.get("TIMEOUT", 3600), 'event_count': 10000, 'cv_mode': self.config.get("CV_MODE", False), 'debug_mode': False, 'keep_app': self.config.get("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': self.config.get("ENABLE_GUIAGENT", False), 'platform': "android", 'pcap_callback': extra_kwargs.get('pcap_callback'), 'mumu_manager_path': mumu_config.get("manager_path", ""), 'mumu_vm_index': mumu_config.get("default_vm_index", 2), } return kwargs def run_task(self, package_name: str, app_name: str, output_dir: str, **kwargs) -> TaskResult: """执行单个测试任务,返回 TaskResult""" print(f"\n{'='*50}") print(f"[INFO] 准备测试应用包名: {package_name}") 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 # 追踪用户中断,用于向上传递 os.makedirs(output_dir, exist_ok=True) print(f"[INFO] 输出目录已创建: {output_dir}") # 将 **kwargs (如 pcap_callback) 合并到构建参数中 droidbot_kwargs = self._build_droidbot_kwargs(package_name, app_name, output_dir) enable_app_block = kwargs.pop('enable_app_block', False) droidbot_kwargs.update(kwargs) droidbot_kwargs['enable_app_block'] = enable_app_block if enable_app_block: droidbot_kwargs['timeout'] = self.config.get("BLOCK_TIMEOUT", 1000) print(f"[DEBUG] DroidBot 初始化参数: {droidbot_kwargs}") cmd_log_path = os.path.join(output_dir, "cmd_output.txt") 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() # 设置日志处理器,捕获所有logging模块输出 try: # 直接创建并运行DroidBot实例 droidbot = DroidBot(**droidbot_kwargs) 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] 测试任务完成: {app_name}") except KeyboardInterrupt: print(f"\n[WARN] 用户手动中断测试: {package_name}") ret_code = EXIT_ERROR_USER status = "INTERRUPTED" error_reason = "用户手动中断" user_interrupted = True # 标记需要向上传递中断 except ADBException as e: import traceback print(f"\n[ERROR] ADB 断联: {e}") print(traceback.format_exc()) ret_code = EXIT_ADB_ERROR status = "FAILED" error_reason = f"ADB 断联: {e}" except AppCrashException as e: import traceback print(f"\n[ERROR] 应用闪退: {e}") print(traceback.format_exc()) ret_code = EXIT_APP_CRASH_ERROR status = "FAILED" error_reason = f"应用闪退: {e}" except AppNeedUpdateException as e: import traceback print(f"\n[ERROR] 应用需更新: {e}") print(traceback.format_exc()) ret_code = EXIT_APP_NEED_UPDATE status = "FAILED" error_reason = f"应用需更新: {e}" except AppLaunchErrorException as e: import traceback print(f"\n[ERROR] 启动异常: {e}") print(traceback.format_exc()) ret_code = EXIT_APP_LAUNCH_ERROR status = "FAILED" error_reason = f"启动异常: {e}" except ExplorationStuckException as e: import traceback print(f"\n[ERROR] 探索停滞: {e}") print(traceback.format_exc()) ret_code = EXIT_EXPLORATION_STUCK status = "FAILED" error_reason = f"探索停滞: {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 # 读取 GuiAgent 登录/注册失败消息 guiagent_message = "" login_count = 0 register_count = 0 stuck_reason_code = 0 try: if droidbot and droidbot.input_manager and droidbot.input_manager.policy: guiagent_message = getattr(droidbot.input_manager.policy, 'guiagent_message', '') or '' login_count = getattr(droidbot.input_manager.policy, 'login_count', 0) or 0 register_count = getattr(droidbot.input_manager.policy, 'register_count', 0) or 0 stuck_reason_code = getattr(droidbot.input_manager.policy, 'stuck_reason_code', 0) or 0 except Exception: pass # 解析 UTG 文件 num_nodes = 0 num_reached_activities = 0 app_num_total_activities = 0 try: from utils_android.Manager.utg_parser import parse_utg_js utg_path = os.path.join(output_dir, 'utg.js') if os.path.exists(utg_path): utg_result = parse_utg_js(utg_path, package_name) num_nodes = utg_result['num_nodes'] num_reached_activities = utg_result['num_reached_activities'] app_num_total_activities = utg_result['app_num_total_activities'] print(f"[INFO] UTG 节点数: {num_nodes}, 已到达Activity: {num_reached_activities}/{app_num_total_activities}") except Exception as e: print(f"[WARN] 解析 UTG 文件失败: {e}") 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, guiagent_message=guiagent_message, login_count=login_count, register_count=register_count, stuck_reason_code=stuck_reason_code, num_nodes=num_nodes, num_reached_activities=num_reached_activities, app_num_total_activities=app_num_total_activities ) # 如果是用户中断,返回结果后重新抛出异常,让上层 batch_run 能够捕获并退出 if user_interrupted: raise KeyboardInterrupt(result) return result def main(): logger = get_logger(__name__) logger.info("开始测试任务") package = "com.google.android.youtube" app_name = "YouTube" config = load_config() task_runner = TaskRunner(config) output_dir = "test_output" result = task_runner.run_task(package, app_name, output_dir) logger.info(f"测试完成: status={result.status}, total_steps={result.total_steps}") return result if __name__ == "__main__": setup_logging(level=logging.INFO, enable_file_handler=False) # from pyinstrument import Profiler # profiler = Profiler() # profiler.start() main() # profiler.stop() # profiler.write_html("report.html")