# -*- encoding=utf8 -*- from __future__ import annotations from airtest.core.api import * from airtest.cli.parser import cli_setup from poco.drivers.android.uiautomation import AndroidUiautomationPoco import subprocess import os import re import csv import json import traceback import time import sys from dataclasses import dataclass from datetime import datetime # 导入统一的日志配置模块 from logging_config import setup_logging, get_logger from start_test import ( TaskRunner, TaskResult, load_config, EXIT_SUCCESS, EXIT_ERROR_GENERAL, EXIT_ERROR_USER, EXIT_ADB_ERROR, EXIT_NETWORK_ERROR, EXIT_SIMULATOR_ERROR, EXIT_EXPLORATION_STUCK, EXIT_APP_CRASH_ERROR, EXIT_APP_NEED_UPDATE, EXIT_APP_LAUNCH_ERROR, STUCK_REASON_TO_ERROR_TYPE, ) from result_codes import ErrorInfo, STUCK_TO_ERROR, ErrorCategory, InfraError, AppError, BusinessError, DownloadError from utils_android.Manager.mumu_manager import MuMuManager from utils_android.Manager.physical_device_manager import PhysicalDeviceManager from utils_android.Manager.pcapdroid_manager import PCAPManager from utils_android.Manager.data_manager import DataManager from utils_android.Manager.network_manager import NetworkWatchdog, NetworkException, check_network, wait_for_network from utils_android.device_config import ( build_airtest_android_uri, ensure_android_wireless_connected, get_android_device_serial, ) from utils_android.download_app.download_controller import ( download_app_chain, stop_services, reset_runtime_state as reset_download_runtime_state, ) from utils_android.download_app.country_codes import normalize_country_codes from DroidBot.exceptions import ADBException # ========================================== # 0. NullTaskWorker(单应用模式,不连 Redis) # ========================================== class NullTaskWorker: """不连接 Redis 的 Worker 桩,用于单应用本地测试模式。""" def init(self): return None def report(self, report_data): status = report_data.get("status", "?") task_key = report_data.get("task_key", "?") error_info = report_data.get("error", "") if error_info: print(f"[NullWorker] Report task={task_key} status={status} error={error_info}") else: print(f"[NullWorker] Report task={task_key} status={status}") return None def retry(self): return True def event(self, event_data): pass # ========================================== # 1. 日志重定向类 (捕获所有 stdout/stderr) # ========================================== class TeeOutput: """同时将输出写入文件和控制台""" def __init__(self, filepath, original_stream): self.file = open(filepath, 'a', encoding='utf-8') self.original_stream = original_stream def write(self, data): self.file.write(data) self.file.flush() self.original_stream.write(data) self.original_stream.flush() def flush(self): self.file.flush() self.original_stream.flush() def close(self): self.file.close() def isatty(self): return False # 配置日志目录和文件路径 log_dir = os.path.join(os.path.dirname(__file__), 'logs') os.makedirs(log_dir, exist_ok=True) log_filename = datetime.now().strftime("batch_run_%Y%m%d_%H%M%S.log") log_filepath = os.path.join(log_dir, log_filename) # 重定向 stdout 和 stderr 到日志文件 sys.stdout = TeeOutput(log_filepath, sys.stdout) sys.stderr = TeeOutput(log_filepath, sys.stderr) # 使用统一的日志配置 setup_logging( level='INFO', log_file=log_filepath, debug_mode=False ) logger = get_logger(__name__) logger.info(f"日志文件路径: {log_filepath}") # ========================================== # 2. 统计管理 (BatchStatistics) # ========================================== class BatchStatistics: """批量测试统计管理器""" def __init__(self): self.start_time = datetime.now() self.records = [] self.current_round = 0 def add_record(self, round_num: int, app_name: str, package_name: str, result: TaskResult, is_retry: bool = False): """添加一条测试记录""" self.records.append({ "round": round_num, "app_name": app_name, "package_name": package_name, "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), "is_retry": is_retry, "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }) def get_current_statistics(self, result: TaskResult, round_num: int, is_retry: bool = False, download_source: str = None) -> dict: """获取当前任务的统计数据,用于上报""" return { "round": round_num, "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), "is_retry": is_retry, "download_source": download_source, "num_nodes": result.num_nodes, "num_reached_activities": result.num_reached_activities, "app_num_total_activities": result.app_num_total_activities } def get_round_summary(self, round_num: int) -> dict: """获取某轮的统计摘要""" round_records = [r for r in self.records if r["round"] == round_num] success_count = sum(1 for r in round_records if r["status"] == "SUCCESS") failed_count = sum(1 for r in round_records if r["status"] == "FAILED") return { "total": len(round_records), "success": success_count, "failed": failed_count, "retry_count": sum(1 for r in round_records if r["is_retry"]) } @dataclass class TaskContext: task: dict app_name: str package_name: str task_key: str country_codes: list available_sources: list local_apk_dir: str = None local_apk_files: list = None download_source: str = None output_dir: str = None final_result: TaskResult = None status_to_report: str = "failed" error_info: object = None collect_retry_count: int = 0 keep_app_installed: bool = False enable_app_block: bool = False app_block_mode: str = "set" app_block_tag: str = None class FinalizeRecoveryFailed(RuntimeError): """finalize 阶段环境恢复失败后终止当前轮。""" # ========================================== # 3. 执行部分 (TestExecutor) # ========================================== class TestExecutor: """自动化测试的执行引擎""" BROWSER_PACKAGE_CANDIDATES = ( "com.android.chrome", "com.android.chromium", "com.android.browser", ) def __init__(self, stats: BatchStatistics = None, worker=None): self.config = load_config() self.data = DataManager(self.config) self.device = MuMuManager(self.config) if self.config.get('IS_EMULATOR', True) else PhysicalDeviceManager(self.config) self.mumu = self.device self.network_watchdog = NetworkWatchdog() self._adb_helper = None self._pcap = None self.task_runner = TaskRunner(self.config) self.stats = stats or BatchStatistics() self.current_round = 0 self._base_packages = None self.device_serial = get_android_device_serial(self.config) self.app_block_app_list = self.config.get('APP_BLOCK_APP_LIST') self.app_block_url_lib = self.config.get('APP_BLOCK_URL_LIB') self._worker = worker @property def adb_helper(self): """延迟初始化 ADB Helper""" if self._adb_helper is None: from utils_android.download_app.adb_helper import ADBHelper self._adb_helper = ADBHelper(self.device_serial) return self._adb_helper @property def pcap(self): """延迟初始化 PCAPManager(依赖 adb_helper)""" if self._pcap is None: self._pcap = PCAPManager(self.adb_helper, root_capture=self.device.device_type == 'emulator') return self._pcap @property def device_label(self) -> str: return "模拟器" if self.device.device_type == "emulator" else "真机" @property def is_emulator_device(self) -> bool: return self.device.device_type == "emulator" def _reset_runtime_state(self): """设备重启后丢弃所有持有旧设备连接的缓存对象。""" self._adb_helper = None self._pcap = None reset_download_runtime_state() def _reinitialize_after_restart(self): self._reset_runtime_state() print(f"[INFO] {self.device_label}已恢复,重新初始化 Airtest 连接...") try: return self.setup() except ADBException as e: logger.error(f"重启后重新初始化 Airtest 失败: {e}") return False except Exception as e: logger.error(f"重启后初始化运行时失败: {e}") return False def _restart_after_adb_fault(self): print(f"[CRITICAL] 检测到 ADB 断联,正在重启{self.device_label}...") logger.error(f"检测到 ADB 断联,触发{self.device_label}重启恢复流程") if not self.device.restart(): self._reset_runtime_state() return False return self._reinitialize_after_restart() def _load_base_packages(self): """加载纯净镜像的包名白名单(仅加载一次)""" if self._base_packages is not None: return self._base_packages if not self.is_emulator_device: logger.info("当前为真机模式,跳过纯净镜像包名白名单校验") self._base_packages = set() return self._base_packages packages_file = os.path.join(os.path.dirname(__file__), 'doc', 'android_packages.txt') if not os.path.exists(packages_file): logger.warning(f"纯净镜像包名文件不存在: {packages_file}") self._base_packages = set() return self._base_packages packages = set() with open(packages_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if line.startswith('package:'): packages.add(line.split('package:')[1].strip()) elif line: # 兼容没有 package: 前缀的格式 packages.add(line) logger.info(f"已加载纯净镜像包名白名单: {len(packages)} 个包") self._base_packages = packages return self._base_packages def _get_third_party_packages(self): """获取设备上已安装的第三方包列表(通过 adb_helper 封装调用)""" output = self.adb_helper.shell("pm list package") return [line.split("package:")[1].strip() for line in output.splitlines() if "package:" in line] def _stop_browser_apps(self): """停止所有已知浏览器候选包,兼容模拟器与真机。""" for package_name in self.BROWSER_PACKAGE_CANDIDATES: try: stop_app(package_name) except Exception as exc: logger.debug(f"停止浏览器 {package_name} 失败,忽略: {exc}") def _resolve_pocoservice_apk(self) -> str: """定位 Poco 随包提供的 Android 服务 APK。""" import poco poco_dir = os.path.dirname(os.path.abspath(poco.__file__)) return os.path.join(poco_dir, "drivers", "android", "lib", "pocoservice-debug.apk") def uninstall_extra_apps(self, current_package: str): """卸载白名单和当前任务包名之外的多余应用""" base_packages = self._load_base_packages() if not base_packages: logger.warning("白名单为空,跳过卸载多余应用") return # 获取当前设备上所有已安装的包 installed = self._get_third_party_packages() # 计算需要卸载的包:不在白名单中,且不是当前任务的包 to_uninstall = [ pkg for pkg in installed if pkg not in base_packages and pkg != current_package ] if not to_uninstall: logger.info("没有需要卸载的多余应用") return logger.info(f"检测到 {len(to_uninstall)} 个多余应用,开始卸载: {to_uninstall}") for pkg in to_uninstall: if self.adb_helper.uninstall(pkg): logger.info(f"已卸载: {pkg}") continue logger.warning(f"卸载 {pkg} 失败") def setup(self): if not cli_setup(): try: ensure_android_wireless_connected(self.config, self.device_serial) auto_setup( __file__, logdir=False, devices=[build_airtest_android_uri(self.config, serial=self.device_serial)], ) except Exception as e: print(f"设备连接失败: {e}") return False if "com.netease.open.pocoservice" not in self._get_third_party_packages(): install(self._resolve_pocoservice_apk()) sleep(5) start_app("com.netease.open.pocoservice") sleep(2) return True def _create_task_context(self, task: dict) -> TaskContext: country_codes = normalize_country_codes(task.get('country_codes') or task.get('country_code', '')) return TaskContext( task=task, app_name=task.get('app_name', 'Unknown App'), package_name=task.get('package_name', ''), task_key=task.get('task_key', ''), country_codes=country_codes, available_sources=task.get('available_sources', ['google_play', 'local']), local_apk_dir=task.get('local_apk_dir', '') or None, local_apk_files=task.get('local_apk_files', []) or None, error_info=ErrorInfo.success(), keep_app_installed=bool(task.get('keep_app_installed', False)), enable_app_block=bool(task.get('blocked', False)), app_block_mode=task.get('app_block_mode', 'set'), app_block_tag=task.get('app_block_tag') or None, ) def _error_type_str(self, error) -> str: if hasattr(error, 'category') and hasattr(error, 'code'): return f"{error.category.name}/{error.code}" if isinstance(error, dict) and error.get('category') is not None and error.get('code') is not None: return f"{error['category']}/{error['code']}" return "UNEXPECTED/0" def _report_terminal(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker, *, status: str, failed_stage: str, error, error_message: str = None, download_errors: dict = None, extra_error: dict = None, **extra): error_payload = error.to_report_dict() if hasattr(error, 'to_report_dict') else dict(error or {}) if extra_error: error_payload.update(extra_error) if error_message is None: error_message = error_payload.get('reason', '') mon.finish_task( status, failed_stage=failed_stage, error_type=self._error_type_str(error), error_message=error_message, download_errors=download_errors, ) return worker.report( mon.build_report_payload( status, error_payload, download_errors=download_errors, **extra, ) ) def _prepare_download(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker): mon.start_stage("download", "running_download") while True: device_ok, was_restarted = self.device.ensure_running() if not device_ok: print(f"[ERROR] 跳过 {ctx.app_name}: {self.device_label}不可用") mon.finish_stage("download", status="failed") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.infra( InfraError.EMULATOR_START_FAILED if self.is_emulator_device else InfraError.ADB_ERROR ), error_message=f"{self.device_label} unavailable", ) try: if was_restarted: mon.mark("device_restarted") setup_ok = self._reinitialize_after_restart() if not setup_ok: print(f"[ERROR] 跳过 {ctx.app_name}: Airtest 重新连接失败") mon.finish_stage("download", status="failed") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.infra(InfraError.AIRTEST_INIT_FAILED), error_message="airtest reinit failed", ) mon.mark("airtest_reinitialized") worker.retry() self.uninstall_extra_apps(ctx.package_name) return None except ADBException as e: logger.error(f"准备下载阶段检测到 ADB 异常: {e}") print(f"[ERROR] 清理多余应用时 ADB 断联 {ctx.app_name},尝试恢复...") if not self._restart_after_adb_fault(): mon.finish_stage("download", status="failed") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.infra( InfraError.EMULATOR_RECOVERY_FAILED if self.is_emulator_device else InfraError.ADB_ERROR, str(e), ), error_message=f"{self.device_label} recovery failed during cleanup", ) mon.mark("download_recovered") worker.retry() def _build_download_kwargs(self, ctx: TaskContext) -> dict: kwargs = { 'app_name': ctx.app_name, 'country': ctx.country_codes[0], 'country_codes': ctx.country_codes, 'local_path': self.config.get('LOCAL_APK_PATH'), 'apkpure_path': self.config.get('APKPURE_APK_PATH'), 'target_account': '', 'available_sources': ctx.available_sources, } if ctx.local_apk_dir: print(f"[Task] 收到本地APK路径: {ctx.local_apk_dir}") print(f"[Task] 本地APK文件: {ctx.local_apk_files}") kwargs['local_apk_dir'] = ctx.local_apk_dir else: print(f"[Task] 未收到本地APK路径 (available_sources={ctx.available_sources})") if ctx.local_apk_files: kwargs['local_apk_files'] = ctx.local_apk_files return kwargs def _handle_download_result(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker, dl_success: bool, dl_result: dict): if not dl_success: error_code = dl_result.get('error_code', DownloadError.OTHER) errors_json = dl_result.get('errors', {}) attempted_countries = dl_result.get('attempted_countries', []) mon.finish_stage("download", status="failed", download_source=dl_result.get('source', 'unknown')) print(f"[ERROR] 应用下载失败 {ctx.app_name}: {errors_json}") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.download(error_code), error_message=json.dumps(errors_json, ensure_ascii=False), download_errors=errors_json, attempted_countries=attempted_countries, ) if dl_result.get('run') == 'crash': details = dl_result.get('details', 'App crashed') crashed_source = dl_result.get('crashed_source', 'unknown') mon.finish_stage("download", status="failed", download_source=dl_result.get('source', 'unknown')) print(f"[ERROR] 应用安装成功但无法运行 {ctx.app_name}: {details}") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.app(AppError.CRASH, details), error_message=details, extra_error={"crashed_source": crashed_source}, ) print(f"[INFO] 应用准备就绪 (来源: {dl_result.get('source')}, 状态: {dl_result.get('state')})") ctx.download_source = dl_result.get('source', 'unknown') mon.finish_stage("download", status="success", download_source=ctx.download_source) mon.start_stage("collect", "running_collect") return None def _recover_download(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker, dl_kwargs: dict): recovery_success = self.device.recover_from_frozen() if self.device.is_player_frozen() else self.device.restart() if not recovery_success: self._reset_runtime_state() mon.finish_stage("download", status="failed") print(f"[ERROR] {self.device_label}恢复失败,无法继续测试: {ctx.app_name}") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.infra( InfraError.EMULATOR_RECOVERY_FAILED if self.is_emulator_device else InfraError.ADB_ERROR ), error_message=f"{self.device_label} recovery failed during download", ) mon.mark("download_recovered") logger.info(f"{self.device_label}恢复成功,重新初始化连接...") if not self._reinitialize_after_restart(): mon.finish_stage("download", status="failed") print(f"[ERROR] 跳过 {ctx.app_name}: Airtest 重新连接失败") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.infra(InfraError.AIRTEST_INIT_FAILED), error_message="airtest reinit failed after download recovery", ) worker.retry() logger.info("重新尝试下载应用...") try: dl_success, dl_result = download_app_chain(ctx.package_name, **dl_kwargs) return self._handle_download_result(ctx, mon, worker, dl_success, dl_result) except Exception as e: mon.finish_stage("download", status="failed") print(f"[ERROR] 重试下载时出现异常 {ctx.app_name}: {e}") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.download(DownloadError.OTHER, str(e)), error_message=str(e), ) def _execute_download(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker): print(f"[INFO] 正在启动智能下载链条: {ctx.app_name} ({ctx.package_name})") dl_kwargs = self._build_download_kwargs(ctx) try: dl_success, dl_result = download_app_chain(ctx.package_name, **dl_kwargs) return self._handle_download_result(ctx, mon, worker, dl_success, dl_result) except (ADBException, IndexError) as e: logger.error(f"下载过程中检测到 ADB 异常: {e}") print(f"[ERROR] 下载应用时 ADB 断联 {ctx.app_name},尝试恢复...") return self._recover_download(ctx, mon, worker, dl_kwargs) except Exception as e: traceback.print_exc() mon.finish_stage("download", status="failed") print(f"[ERROR] 下载过程出现异常 {ctx.app_name}: {e}") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="download", error=ErrorInfo.download(DownloadError.OTHER, str(e)), error_message=str(e), ) def _prepare_collect(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker): print("[INFO] 清理下载相关进程 (Google Play, Browser, APKPure, PocoService)...") stop_services() stop_app("com.android.vending") self._stop_browser_apps() stop_app("com.apkpure.aegon") stop_app("com.netease.open.pocoservice") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") ctx.output_dir = os.path.join(self.config['OUTPUT_BASE_DIR'], f"{ctx.package_name}_{timestamp}") # 根据当前任务上下文重新初始化 PCAPManager(应用阻塞时关闭 root 抓包) root_capture = self.device.device_type == 'emulator' and not ctx.enable_app_block app_blocker = None if ctx.enable_app_block and self.app_block_app_list and self.app_block_url_lib: from utils_android.Manager.pcapdroid_manager import AppBlocker app_blocker = AppBlocker( self.adb_helper, app_list_file=self.app_block_app_list, url_lib_file=self.app_block_url_lib, ) self._pcap = PCAPManager( self.adb_helper, root_capture=root_capture, app_blocker=app_blocker, ) # 导入应用阻塞规则 if ctx.enable_app_block: print(f"[INFO] 正在导入应用阻塞规则 (package={ctx.package_name}, tag={ctx.app_block_tag}, mode={ctx.app_block_mode})") block_ok = self._pcap.import_app_blocklist( ctx.package_name, mode=ctx.app_block_mode, tag=ctx.app_block_tag, ) if block_ok: print(f"[INFO] 应用阻塞规则导入成功") else: print(f"[WARNING] 应用阻塞规则导入失败或该应用无规则") if self.pcap.start_capture(ctx.package_name): mon.mark("pcap_started") return None mon.finish_stage("collect", status="failed") print(f"[ERROR] 跳过 {ctx.app_name}: PCAP 启动失败") return self._report_terminal( ctx, mon, worker, status="failed", failed_stage="collect", error=ErrorInfo.infra(InfraError.PCAP_START_FAILED), error_message="pcap start failed", ) def _pcap_sync_wrapper(self, pkg_name): print(f"[Callback] 检测到 PCAPDroid 停止,正在推送流量文件: {pkg_name}") self.data.sync_latest_traffic_file(pkg_name) def _run_collect_task(self, ctx: TaskContext, mon: TaskMonitor) -> TaskResult: try: result = self.task_runner.run_task( ctx.package_name, ctx.app_name, ctx.output_dir, pcap_callback=self._pcap_sync_wrapper, enable_app_block=ctx.enable_app_block, ) except KeyboardInterrupt as e: if e.args and isinstance(e.args[0], TaskResult): result = e.args[0] self.stats.add_record(self.current_round, ctx.app_name, ctx.package_name, result) raise return result def _retry_collect(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker, recover_fn, failure_error, retry_error_builder): if not recover_fn(): self._reset_runtime_state() ctx.status_to_report = 'failed' ctx.error_info = failure_error return mon.mark("collect_recovered") if not self._reinitialize_after_restart(): ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.infra(InfraError.AIRTEST_INIT_FAILED) return worker.retry() retry_result = self.task_runner.run_task( ctx.package_name, ctx.app_name, ctx.output_dir, pcap_callback=self._pcap_sync_wrapper, enable_app_block=ctx.enable_app_block, ) self.stats.add_record(self.current_round, ctx.app_name, ctx.package_name, retry_result, is_retry=True) ctx.final_result = retry_result ctx.status_to_report = 'success' if retry_result.status == 'SUCCESS' else 'failed' ctx.collect_retry_count = 1 if retry_result.status != "SUCCESS": ctx.error_info = retry_error_builder(retry_result) def _resolve_collect_outcome(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker): result = ctx.final_result ctx.status_to_report = 'success' if result.status == 'SUCCESS' else 'failed' ctx.error_info = ErrorInfo.success() ctx.collect_retry_count = 0 if result.exit_code == EXIT_SIMULATOR_ERROR: print(f"[CRITICAL] 检测到{self.device_label}异常 (code {result.exit_code}),触发紧急恢复流程...") self._retry_collect( ctx, mon, worker, self.device.recover if self.is_emulator_device else self.device.restart, ErrorInfo.infra( InfraError.EMULATOR_RECOVERY_FAILED if self.is_emulator_device else InfraError.ADB_ERROR ), lambda retry_result: ErrorInfo.infra( InfraError.EMULATOR_CRASH if self.is_emulator_device else InfraError.ADB_ERROR, retry_result.error_reason, ), ) elif result.exit_code == EXIT_ADB_ERROR: print(f"[CRITICAL] 检测到 ADB 断联 (code {result.exit_code}),正在重启{self.device_label}...") self._retry_collect( ctx, mon, worker, self.device.restart, ErrorInfo.infra( InfraError.EMULATOR_RECOVERY_FAILED if self.is_emulator_device else InfraError.ADB_ERROR ), lambda retry_result: ErrorInfo.infra(InfraError.ADB_ERROR, retry_result.error_reason), ) elif result.exit_code == EXIT_NETWORK_ERROR: print(f"[ERROR] 网络异常,跳过当前应用: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.infra(InfraError.NETWORK_ERROR, result.error_reason) elif result.exit_code == EXIT_APP_CRASH_ERROR: if ctx.enable_app_block: print(f"[WARN] Block任务应用闪退,视为正常结束: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'success' ctx.error_info = ErrorInfo.success() else: print(f"[ERROR] 应用闪退,跳过当前应用: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.app(AppError.CRASH, result.error_reason) elif result.exit_code == EXIT_APP_NEED_UPDATE: print(f"[ERROR] 应用需更新,跳过当前应用: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.app(AppError.NEED_UPDATE, result.error_reason) elif result.exit_code == EXIT_APP_LAUNCH_ERROR: print(f"[ERROR] 启动异常,跳过当前应用: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.app(AppError.LAUNCH_ERROR, result.error_reason) elif result.exit_code == EXIT_EXPLORATION_STUCK: print(f"[ERROR] 探索停滞,跳过当前应用: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo.from_stuck_reason(result.stuck_reason_code, ctx.final_result.guiagent_message or result.error_reason) elif result.status != "SUCCESS": print(f"[ERROR] 任务执行失败: {ctx.app_name} ({result.error_reason})") ctx.status_to_report = 'failed' ctx.error_info = ErrorInfo(ErrorCategory.BUSINESS_ERROR, BusinessError.OTHER, result.error_reason) if ctx.error_info.category != ErrorCategory.SUCCESS: return if ctx.final_result.guiagent_message: has_login_failed = "登录失败" in ctx.final_result.guiagent_message has_register_failed = "注册失败" in ctx.final_result.guiagent_message if has_login_failed and has_register_failed: ctx.error_info = ErrorInfo.business(BusinessError.LOGIN_FAILED) elif has_login_failed: ctx.error_info = ErrorInfo.business(BusinessError.LOGIN_FAILED) elif has_register_failed: ctx.error_info = ErrorInfo.business(BusinessError.REGISTER_FAILED) if ctx.final_result.login_count == 0 and ctx.final_result.register_count == 0: ctx.error_info = ErrorInfo.business(BusinessError.NO_SCENARIO) def _report_task_result(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker): mon.finish_task( ctx.status_to_report, failed_stage=None if ctx.status_to_report == 'success' else 'collect', error_type=None if ctx.status_to_report == 'success' else self._error_type_str(ctx.error_info), error_message=None if ctx.status_to_report == 'success' else (ctx.error_info.reason or ctx.final_result.error_reason), retry_count=ctx.collect_retry_count, ) report_data = mon.build_report_payload( ctx.status_to_report, ctx.error_info if ctx.status_to_report != 'success' else None, metrics={ "login_count": ctx.final_result.login_count, "register_count": ctx.final_result.register_count, "guiagent_message": ctx.final_result.guiagent_message, "scenario_triggered": ctx.final_result.login_count > 0 or ctx.final_result.register_count > 0, "stuck_reason_code": ctx.final_result.stuck_reason_code, }, statistics=self.stats.get_current_statistics( ctx.final_result, self.current_round, download_source=ctx.download_source, ), ) print(f"任务完成,状态: {ctx.status_to_report}, 错误: {ctx.error_info.category.name}/{ctx.error_info.code}") return worker.report(report_data) def _run_one_task(self, worker: TaskWorker, ctx: TaskContext): print(f"\n>>>> 开始处理任务: {ctx.app_name} <<<<") print(f" 包名: {ctx.package_name}") print(f" 任务键: {ctx.task_key}") print(f" 国家码: {ctx.country_codes}") print(f" 可用下载源: {ctx.available_sources}") print(f" 应用阻塞: {ctx.enable_app_block} (mode={ctx.app_block_mode}, tag={ctx.app_block_tag})") # 每个任务开始前:清除上一个任务遗留的 blocklist 并重置 PCAPManager if self._pcap is not None: try: self._pcap.clear_app_blocklist() except Exception as e: logger.warning(f"清除上一个任务的 blocklist 失败: {e}") self._pcap = None from utils_android.TaskWorker.task_monitor import TaskMonitor # 延迟导入,避免单应用模式依赖 Redis mon = TaskMonitor(worker, ctx.task_key) mon.start_task() self.network_watchdog.check_and_raise() next_task = self._prepare_download(ctx, mon, worker) if next_task is not None: return next_task, mon next_task = self._execute_download(ctx, mon, worker) if next_task is not None: return next_task, mon next_task = self._prepare_collect(ctx, mon, worker) if next_task is not None: return next_task, mon ctx.final_result = self._run_collect_task(ctx, mon) self.stats.add_record(self.current_round, ctx.app_name, ctx.package_name, ctx.final_result) self._resolve_collect_outcome(ctx, mon, worker) mon.finish_stage( "collect", status="success" if ctx.status_to_report == 'success' else "failed", duration_seconds=round(ctx.final_result.duration_seconds, 2), ) stop_app(ctx.package_name) start_app("com.netease.open.pocoservice") self.pcap.stop_capture() sleep(2) return self._report_task_result(ctx, mon, worker), mon def _stop_current_task(self, ctx: TaskContext, mon: TaskMonitor, worker: TaskWorker, error): from utils_android.TaskWorker.task_monitor import TaskMonitor # noqa: F811 failed_stage = None if mon and "collect" in mon.stage_started_at: failed_stage = "collect" mon.finish_stage("collect", status="failed") elif mon and "download" in mon.stage_started_at: failed_stage = "download" mon.finish_stage("download", status="failed") return self._report_terminal( ctx, mon or TaskMonitor(worker, ctx.task_key), worker, status="stop", failed_stage=failed_stage, error=error, error_message=error.get('reason') if isinstance(error, dict) else error.reason, ) def _finalize_task(self, ctx: TaskContext, worker: TaskWorker): traffic_synced = False log_synced = False if ctx.package_name: if ctx.keep_app_installed: print(f"[INFO] 保留应用: {ctx.package_name}") else: print(f"[INFO] 卸载应用: {ctx.package_name}") if self.adb_helper.uninstall(ctx.package_name): print(f"[INFO] 应用 {ctx.package_name} 卸载成功") else: print(f"[WARNING] 卸载应用 {ctx.package_name} 失败") traffic_synced = bool(self.data.sync_latest_traffic_file(ctx.package_name)) if log_filepath: log_synced = bool(self.data.sync_log_file(log_filepath)) # 清除应用阻塞规则 if ctx.enable_app_block: try: print("[INFO] 正在清除应用阻塞规则...") if self._pcap is not None: self._pcap.clear_app_blocklist() else: from utils_android.Manager.pcapdroid_manager import AppBlocker AppBlocker(self.adb_helper).clear_blocklist() except Exception as e: logger.warning(f"清除应用阻塞规则失败: {e}") if not ctx.output_dir: worker.event({ "event_type": "artifacts_synced", "task_key": ctx.task_key, "package_name": ctx.package_name, "traffic_synced": traffic_synced, "log_synced": log_synced, }) return worker.event({ "event_type": "artifacts_synced", "task_key": ctx.task_key, "package_name": ctx.package_name, "traffic_synced": traffic_synced, "log_synced": log_synced, }) def _finalize_with_recovery(self, ctx: TaskContext, worker: TaskWorker, next_task): try: self._finalize_task(ctx, worker) return next_task except ADBException as e: logger.error(f"finalize 阶段检测到 ADB 异常: {e}") print(f"[CRITICAL] finalize 卸载阶段 ADB 断联 {ctx.app_name},尝试重启{self.device_label}恢复...") if self._restart_after_adb_fault(): print("[INFO] finalize 阶段环境恢复成功,继续执行后续任务") return next_task if next_task: logger.warning("finalize 恢复失败,重新计时已领取的下一任务") worker.retry() raise FinalizeRecoveryFailed(f"finalize recovery failed: {e}") def run(self, initial_task=None): from utils_android.TaskWorker import TaskWorker # 延迟导入,避免单应用模式依赖 Redis 配置 worker = self._worker if self._worker is not None else TaskWorker() logger.info("启动后台网络监控...") self.network_watchdog.start() print("\n[1] 初始化Worker...") if initial_task is not None: task = initial_task print(f"[INFO] 使用注入的初始任务: {task.get('package_name', '?')}") else: task = worker.init() if not task: print("初始化失败,程序退出") self.network_watchdog.stop() return print("\n[2] 开始处理任务...") print("按 Ctrl+C 停止Worker\n") fatal_error = None interrupted = False while task: if task.get('package_name', '') == '-1': logger.error(f"任务包名异常, 任务键: {task.get('task_key', '')}") break ctx = self._create_task_context(task) if task.get('traffic_root'): self.config['TRAFFIC_DATA_SHARE'] = task['traffic_root'] mon = None next_task = None interrupted_this_task = False try: next_task, mon = self._run_one_task(worker, ctx) except NetworkException as e: logger.error(f"后台监控检测到网络异常: {e}") lan_info = f"{e.ip_address}局域网" if e.ip_address else "未知局域网" mac_info = f"{e.mac_address}mac" if e.mac_address else "未知mac" stop_error = ErrorInfo.infra(InfraError.NETWORK_ERROR, f"{lan_info},{mac_info} 网络错误") next_task = self._stop_current_task(ctx, mon, worker, stop_error) logger.info("已停止当前任务,等待网络恢复...") self.network_watchdog.stop() except KeyboardInterrupt: logger.info("\n收到中断信号,程序退出。") next_task = self._stop_current_task(ctx, mon, worker, {"category": "USER_INTERRUPT", "code": 0, "reason": "User interrupted"}) interrupted_this_task = True except Exception as e: print(f"处理任务时出错: {e}") traceback.print_exc() failed_stage = None if mon and "collect" in mon.stage_started_at: failed_stage = "collect" mon.finish_stage("collect", status="failed") elif mon and "download" in mon.stage_started_at: failed_stage = "download" mon.finish_stage("download", status="failed") next_task = self._report_terminal( ctx, mon or TaskMonitor(worker, ctx.task_key), worker, status="failed", failed_stage=failed_stage, error={"category": "UNEXPECTED", "code": 0, "reason": str(e)}, error_message=str(e), ) try: task = self._finalize_with_recovery(ctx, worker, next_task) except FinalizeRecoveryFailed as e: fatal_error = e break if interrupted_this_task: interrupted = True break logger.info("任务循环结束,停止后台网络监控...") self.network_watchdog.stop() if fatal_error: raise fatal_error if interrupted: raise KeyboardInterrupt print("\n所有任务已完成或无更多任务") if __name__ == '__main__': import argparse parser = argparse.ArgumentParser( description="autool 批量/单应用测试运行器", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""示例: # 标准批量模式(连接 Redis 中控端) python batch_run.py # 单应用本地测试模式 python batch_run.py --single --package com.google.android.youtube python batch_run.py --single --package com.tencent.mm --app-name "微信" --country CN --keep-app """, ) parser.add_argument( "--single", action="store_true", help="启用单应用本地测试模式(不连接 Redis 中控端)", ) parser.add_argument( "--package", type=str, default=None, help="应用包名(--single 模式下必需)", ) parser.add_argument( "--app-name", type=str, default=None, help="应用显示名称(可选,默认取包名最后一段)", ) parser.add_argument( "--country", type=str, default="US", help="国家码,默认 US", ) parser.add_argument( "--keep-app", action="store_true", help="测试完成后保留应用(默认卸载)", ) args = parser.parse_args() # 清理自定义参数,避免 airtest 的 cli_setup 解析时报 unrecognized arguments sys.argv = [sys.argv[0]] if args.single: if not args.package: parser.error("--single 模式必须指定 --package") app_name = args.app_name or args.package.split(".")[-1] task_key = f"single-{args.package}-{datetime.now().strftime('%Y%m%d%H%M%S')}" task = { "app_name": app_name, "package_name": args.package, "task_key": task_key, "country_codes": [args.country.upper()], "available_sources": ["google_play", "local"], "keep_app_installed": args.keep_app, } config = load_config() print(f"\n{'='*50}") print(f"[单应用模式]") print(f" 应用: {app_name}") print(f" 包名: {args.package}") print(f" 国家码: {args.country.upper()}") print(f" 保留应用: {args.keep_app}") print(f"{'='*50}\n") if not check_network(): logger.warning("初始网络检查失败,等待网络恢复...") wait_for_network(check_interval=5) executor = TestExecutor(worker=NullTaskWorker()) executor.setup() executor.run(initial_task=task) print("\n[单应用模式] 测试完成。") else: round_count = 0 stats = None config = load_config() while True: try: if not check_network(): logger.warning("初始网络检查失败,等待网络恢复...") wait_for_network(check_interval=5) if stats is None: stats = BatchStatistics() executor = TestExecutor(stats=stats) executor.setup() round_count += 1 executor.current_round = round_count logger.info(f"========== 开始第 {round_count} 轮测试 ==========") executor.run() summary = stats.get_round_summary(round_count) logger.info(f"========== 第 {round_count} 轮测试完成 ==========") logger.info(f"本轮统计: 总计 {summary['total']} 个应用, 成功 {summary['success']}, 失败 {summary['failed']}, 重试 {summary['retry_count']} 次") except KeyboardInterrupt: logger.info(f"\n收到中断信号,共完成 {round_count} 轮测试,程序退出。") raise except Exception as e: logger.error(f"测试过程中发生未预期错误: {e}") logger.error(traceback.format_exc()) msg = f"测试过程中发生未预期错误: {e}" if 'executor' in locals(): pass finally: if 'executor' in locals() and executor and hasattr(executor, 'network_watchdog'): logger.info("正在清理网络监控资源...") executor.network_watchdog.shutdown()