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

1167 lines
48 KiB
Python
Raw Permalink 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.

# -*- 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()