#!/usr/bin/env python3 """ Web 端 DroidBot 自动化批量测试脚本 从CSV文件加载URL列表,逐个执行DroidBot探索测试。 使用示例: # 使用全部默认参数运行 python web_test.py # 指定URL列表 python web_test.py -url_list doc/web_urls.csv # 覆盖部分默认值 python web_test.py -url_list urls.csv -duration 300 -headless """ import os import sys import csv import time import json import logging import argparse from pathlib import Path from datetime import datetime from dataclasses import dataclass, asdict # 找到项目根目录 ROOT = Path(__file__).resolve().parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from logging_config import setup_logging, get_logger from DroidBot.droidbot import DroidBot from DroidBot.exceptions import ( AppCrashException, ExplorationStuckException, AppNeedUpdateException, AppLaunchErrorException ) from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker # ==================== 结果码 ==================== EXIT_SUCCESS = 0 EXIT_ERROR_GENERAL = 1 EXIT_ERROR_USER = 2 EXIT_EXPLORATION_STUCK = 10 # ==================== 数据类 ==================== @dataclass class WebTaskResult: """单次Web测试任务的执行结果""" url: str app_name: str status: str # "SUCCESS" | "FAILED" | "INTERRUPTED" exit_code: int error_reason: str droidbot_steps: int total_steps: int duration_seconds: float num_nodes: int = 0 num_reached_pages: int = 0 # ==================== 统计类 ==================== class WebBatchStatistics: """Web批量测试统计""" def __init__(self, output_dir: str): self.output_dir = output_dir self.results: list = [] self.start_time = time.time() self._summary_path = os.path.join(output_dir, "batch_summary.csv") self._history_path = os.path.join(output_dir, "test_history.json") def add_result(self, result: WebTaskResult): self.results.append(result) self._save_summary() def _save_summary(self): """保存CSV汇总""" try: with open(self._summary_path, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=[ 'url', 'app_name', 'status', 'error_reason', 'droidbot_steps', 'total_steps', 'duration_seconds', 'num_nodes', 'num_reached_pages' ]) writer.writeheader() for r in self.results: writer.writerow({ 'url': r.url, 'app_name': r.app_name, 'status': r.status, 'error_reason': r.error_reason, 'droidbot_steps': r.droidbot_steps, 'total_steps': r.total_steps, 'duration_seconds': round(r.duration_seconds, 1), 'num_nodes': r.num_nodes, 'num_reached_pages': r.num_reached_pages, }) except Exception as e: print(f"[WARN] 保存汇总CSV失败: {e}") def load_history(self) -> set: """加载已成功测试的URL集合(智能调度用)""" succeeded = set() if os.path.exists(self._history_path): try: with open(self._history_path, 'r') as f: history = json.load(f) for entry in history: if entry.get('status') == 'SUCCESS': succeeded.add(entry.get('url', '')) except Exception: pass return succeeded def save_history(self): """保存测试历史""" try: with open(self._history_path, 'w') as f: json.dump([asdict(r) for r in self.results], f, indent=2, ensure_ascii=False) except Exception as e: print(f"[WARN] 保存测试历史失败: {e}") def print_summary(self): """打印最终统计""" total = len(self.results) success = sum(1 for r in self.results if r.status == 'SUCCESS') failed = sum(1 for r in self.results if r.status == 'FAILED') elapsed = time.time() - self.start_time hours, rem = divmod(elapsed, 3600) minutes, seconds = divmod(rem, 60) print(f"\n{'='*60}") print(f" Web 批量测试完成") print(f"{'='*60}") print(f" 总计: {total} 成功: {success} 失败: {failed}") print(f" 总耗时: {int(hours)}h {int(minutes)}m {int(seconds)}s") print(f" 汇总文件: {self._summary_path}") print(f"{'='*60}\n") # ==================== 测试执行器 ==================== class WebTestRunner: """Web测试执行器:直接调用DroidBot模块""" def __init__(self, duration: int, headless: bool, output_base: str, engine: str = 'playwright', browser: str = 'chrome'): self.duration = duration self.headless = headless self.output_base = output_base self.engine = engine self.browser = browser self.logger = get_logger('WebTestRunner') def run_test(self, url: str, app_name: str) -> WebTaskResult: """执行单个URL的DroidBot测试""" # 构建输出目录(基于域名 + 时间戳) from urllib.parse import urlparse domain = urlparse(url).netloc or "unknown" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_dir = os.path.join(self.output_base, f"{domain}_{timestamp}") os.makedirs(output_dir, exist_ok=True) self.logger.info(f"\n{'='*50}") self.logger.info(f"开始测试: {app_name} ({url})") self.logger.info(f"输出目录: {output_dir}") self.logger.info(f"{'='*50}") start_time = time.time() status = "FAILED" exit_code = EXIT_ERROR_GENERAL error_reason = "" droidbot_steps = 0 total_steps = 0 num_nodes = 0 num_reached_pages = 0 # 重置全局步数计数器 GuiAgentDecisionMaker.total_steps = 0 droidbot = None try: droidbot = DroidBot( package_name=url, app_name=app_name, device_serial=None, is_emulator=False, output_dir=output_dir, policy_name="memory_guided", random_input=False, event_interval=0.3, timeout=self.duration, event_count=10000, cv_mode=False, debug_mode=False, 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=False, platform="web", browser=self.browser, engine=self.engine, headless=self.headless, ) droidbot.start() exit_code = EXIT_SUCCESS status = "SUCCESS" except KeyboardInterrupt: status = "INTERRUPTED" exit_code = EXIT_ERROR_USER error_reason = "用户手动中断" raise # 向上传播 except ExplorationStuckException as e: status = "FAILED" exit_code = EXIT_EXPLORATION_STUCK error_reason = f"探索停滞: {e}" except Exception as e: import traceback self.logger.error(f"DroidBot执行异常: {e}") self.logger.error(traceback.format_exc()) status = "FAILED" error_reason = f"执行异常: {e}" finally: # 尝试获取步数 try: if droidbot and droidbot.input_manager: droidbot_steps = droidbot.input_manager.total_exploring_steps except Exception: pass total_steps = droidbot_steps + GuiAgentDecisionMaker.total_steps # 尝试获取UTG节点数 try: if droidbot and droidbot.input_manager and droidbot.input_manager.policy: utg = getattr(droidbot.input_manager.policy, 'utg', None) if utg: num_nodes = len(utg.G.nodes()) num_reached_pages = len(utg.reached_activities) except Exception: pass duration = time.time() - start_time self.logger.info(f"测试完成: {app_name} | 状态={status} | 步数={droidbot_steps} | 节点={num_nodes} | 耗时={duration:.0f}s") return WebTaskResult( url=url, app_name=app_name, status=status, exit_code=exit_code, error_reason=error_reason, droidbot_steps=droidbot_steps, total_steps=total_steps, duration_seconds=duration, num_nodes=num_nodes, num_reached_pages=num_reached_pages, ) # ==================== URL加载 ==================== def load_urls(csv_path: str) -> list: """ 从CSV加载URL列表 支持格式: - AppName,URL(标准格式) - 每行一个URL(无表头) """ urls = [] try: with open(csv_path, 'r', encoding='utf-8') as f: reader = csv.reader(f) header = next(reader, None) # 判断是否有表头 if header and any(h.lower().startswith('http') for h in header): # 无表头,第一行就是数据 if len(header) >= 2: urls.append((header[0].strip(), header[1].strip())) else: urls.append(("Unknown", header[0].strip())) for row in reader: if not row or not row[0].strip(): continue if len(row) >= 2: app_name = row[0].strip() url = row[1].strip() else: app_name = row[0].strip() url = row[0].strip() # 确保是有效URL if url.startswith('http'): urls.append((app_name, url)) except FileNotFoundError: print(f"[ERROR] URL列表文件不存在: {csv_path}") sys.exit(1) except Exception as e: print(f"[ERROR] 读取URL列表失败: {e}") sys.exit(1) return urls # ==================== 主入口 ==================== def parse_args(): parser = argparse.ArgumentParser( description="Web端DroidBot自动化批量测试", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 使用示例: python web_test.py # 全部默认参数 python web_test.py -url_list urls.csv # 指定URL列表 python web_test.py -duration 300 -headless # 无头模式,每URL 5分钟 """ ) parser.add_argument('-url_list', type=str, default='output/web_urls.csv', help='URL列表CSV文件 (默认: doc/web_urls.csv)') parser.add_argument('-duration', type=int, default=3600, help='每个URL测试时长,秒 (默认: 3600)') parser.add_argument('-headless', action='store_true', default=False, help='使用无头模式 (默认: False)') parser.add_argument('-output_dir', type=str, default='output/web_test', help='输出目录 (默认: output/web_test)') parser.add_argument('-engine', type=str, choices=['playwright', 'selenium'], default='playwright', help='Web自动化引擎 (默认: playwright)') parser.add_argument('-browser', type=str, choices=['chrome', 'lightpanda'], default='chrome', help='使用的浏览器 (默认: chrome)') parser.add_argument('-batch_count', type=int, default=0, help='测试URL数量上限,0=全部 (默认: 0)') return parser.parse_args() def main(): args = parse_args() setup_logging(level=logging.INFO, enable_file_handler=False) logger = get_logger(__name__) # 加载URL列表 csv_path = os.path.join(ROOT, args.url_list) if not os.path.isabs(args.url_list) else args.url_list all_urls = load_urls(csv_path) logger.info(f"从 {csv_path} 加载了 {len(all_urls)} 个URL") if not all_urls: logger.error("URL列表为空,退出") sys.exit(1) # 初始化输出目录和统计 output_dir = os.path.join(ROOT, args.output_dir) if not os.path.isabs(args.output_dir) else args.output_dir os.makedirs(output_dir, exist_ok=True) stats = WebBatchStatistics(output_dir) # 智能调度:跳过已成功的URL succeeded_urls = stats.load_history() pending_urls = [(name, url) for name, url in all_urls if url not in succeeded_urls] if len(pending_urls) < len(all_urls): logger.info(f"智能调度: 跳过 {len(all_urls) - len(pending_urls)} 个已成功的URL") # 限制数量 if args.batch_count > 0: pending_urls = pending_urls[:args.batch_count] logger.info(f"待测试URL: {len(pending_urls)} 个") # 执行测试 runner = WebTestRunner( duration=args.duration, headless=args.headless, output_base=output_dir, engine=args.engine, browser=args.browser, ) try: for idx, (app_name, url) in enumerate(pending_urls, 1): logger.info(f"\n[{idx}/{len(pending_urls)}] 开始测试: {app_name}") try: result = runner.run_test(url, app_name) stats.add_result(result) except KeyboardInterrupt: logger.warning("用户中断,保存当前进度...") break except Exception as e: logger.error(f"测试 {app_name} 发生未捕获异常: {e}") stats.add_result(WebTaskResult( url=url, app_name=app_name, status="FAILED", exit_code=EXIT_ERROR_GENERAL, error_reason=str(e), droidbot_steps=0, total_steps=0, duration_seconds=0, )) finally: stats.save_history() stats.print_summary() if __name__ == "__main__": main()