autool/start_test.py
2026-06-17 19:44:18 +08:00

396 lines
15 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.

#!/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")