1720 lines
72 KiB
Python
1720 lines
72 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
iOS Fastbot + GuiAgent 混合测试脚本
|
||
|
||
测试流程:
|
||
1. 复用 ios_start.py 基础设施(任务加载/管理、日志、安装)
|
||
2. 阶段1: AppleID + GuiAgent 处理权限/登录
|
||
3. 阶段2: Fastbot 快速自动化采集 + 后台页面卡住检测
|
||
4. 分级响应: 10次无变化→back, 30次无变化→暂停Fastbot→Agent处理→重启Fastbot
|
||
|
||
使用示例:
|
||
python ios_test.py -bundle_id com.apple.AppStore -duration 120
|
||
python ios_test.py -batch -app_list utils_ios/ios_only_apps.csv -batch_count 5
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
import csv
|
||
import hashlib
|
||
import json
|
||
import threading
|
||
import argparse
|
||
import subprocess
|
||
import logging
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from typing import Optional, Dict, Tuple
|
||
|
||
# 找到项目根目录
|
||
ROOT = Path(__file__).resolve().parent
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from utils_ios.cert_manager import ensure_certs_valid, send_weCom_alert
|
||
|
||
# ============================================================
|
||
# 从 ios_start.py 复用模块
|
||
# ============================================================
|
||
from ios_start import (
|
||
TaskResult,
|
||
IOSBatchStatistics,
|
||
TeeOutput,
|
||
load_env_variables,
|
||
setup_iphone_proxy,
|
||
setup_infrastructure,
|
||
install_app_and_get_bundle_id,
|
||
get_app_info_from_csv,
|
||
load_apps_from_csv,
|
||
start_pcap,
|
||
stop_pcap,
|
||
EXIT_SUCCESS,
|
||
EXIT_ERROR_USER,
|
||
EXIT_WDA_ERROR,
|
||
EXIT_WDA_STUCK,
|
||
EXIT_NETWORK_ERROR,
|
||
EXIT_ERROR_GENERAL,
|
||
)
|
||
|
||
# DroidBot 及 GuiAgent 步数统计(droidbot 模式需要)
|
||
from DroidBot.droidbot import DroidBot
|
||
from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker
|
||
|
||
# WDA 异常类(droidbot 模式异常处理)
|
||
try:
|
||
from DroidBot.exceptions import WDA_RECOVERABLE_EXCEPTIONS
|
||
from DroidBot.platforms.ios.wda.exceptions import WDAStuckError
|
||
WDA_EXCEPTIONS = WDA_RECOVERABLE_EXCEPTIONS + (WDAStuckError,)
|
||
except ImportError:
|
||
WDA_EXCEPTIONS = ()
|
||
WDAStuckError = None
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ============================================================
|
||
# 退出码扩展
|
||
# ============================================================
|
||
EXIT_FASTBOT_ERROR = 7 # Fastbot 启动/运行失败
|
||
|
||
|
||
# ============================================================
|
||
# 组件1: PageStuckDetector — 页面卡住检测器
|
||
# ============================================================
|
||
class PageStuckDetector:
|
||
"""
|
||
后台异步检测应用是否卡在某个页面
|
||
|
||
检测机制(双重):
|
||
1. WDA 交互元素指纹:获取 Button/TextField 等元素的 label+value,
|
||
计算特征哈希,连续 N 次相同则判定"疑似卡住"
|
||
2. pcap 流量检测:监控 .flows.jsonl 文件行数增量,
|
||
无新增行说明无网络活动,辅助判定
|
||
|
||
分级响应:
|
||
- back_threshold 次无新特征 → 需要发送 back
|
||
- agent_threshold 次无新特征 → 需要调用 Agent
|
||
"""
|
||
|
||
# 用于提取指纹的交互元素类型
|
||
INTERACTIVE_TYPES = ['Button', 'TextField', 'SecureTextField', 'SearchField']
|
||
|
||
def __init__(self, wda_url: str, check_interval: int = 20,
|
||
back_threshold: int = 10, agent_threshold: int = 30,
|
||
pcap_csv_path: str = None, health_monitor=None):
|
||
"""
|
||
Args:
|
||
wda_url: WDA 服务地址
|
||
check_interval: 检测间隔(秒)
|
||
back_threshold: 发送 back 的阈值
|
||
agent_threshold: 调用 agent 的阈值
|
||
pcap_csv_path: pcap .flows.csv 文件路径
|
||
"""
|
||
self.wda_url = wda_url
|
||
self.check_interval = check_interval
|
||
self.back_threshold = back_threshold
|
||
self.agent_threshold = agent_threshold
|
||
self.pcap_csv_path = pcap_csv_path
|
||
self.health_monitor = health_monitor
|
||
|
||
# 内部状态
|
||
self._stuck_count = 0
|
||
self._last_fingerprint = None
|
||
self._stuck_count = 0
|
||
self._last_fingerprint = None
|
||
self._seen_domains = set()
|
||
self._stuck_info = {}
|
||
self._running = False
|
||
self._lock = threading.Lock()
|
||
self._thread = None
|
||
self._wda_client = None
|
||
|
||
def _get_wda_client(self):
|
||
"""获取或创建 WDA 客户端(http: 格式用 Client,UDID/空用 USBClient)"""
|
||
if self._wda_client is None:
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
if self.wda_url and self.wda_url.startswith("http:"):
|
||
self._wda_client = WDAClient(self.wda_url)
|
||
else:
|
||
self._wda_client = WDAUSBClient(udid=self.wda_url or "")
|
||
return self._wda_client
|
||
|
||
def _compute_page_fingerprint(self) -> Optional[str]:
|
||
"""
|
||
通过 WDA source(format='json') 提取交互元素的 label+value 特征字符串,
|
||
计算 hash 作为页面指纹。
|
||
|
||
使用 source(format='json') 一次性获取所有元素信息,
|
||
比逐个 find_elements 效率更高。
|
||
"""
|
||
try:
|
||
client = self._get_wda_client()
|
||
# 使用 JSON 格式获取页面结构,超时 10 秒
|
||
page_source = client.source(format='json', timeout=10)
|
||
|
||
# 递归提取交互元素的 label 和 value
|
||
features = []
|
||
self._extract_interactive_features(page_source, features)
|
||
|
||
if not features:
|
||
return None
|
||
|
||
# 排序以保证一致性,计算 hash
|
||
features.sort()
|
||
fingerprint = hashlib.md5("||".join(features).encode()).hexdigest()
|
||
return fingerprint
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[StuckDetector] 获取页面指纹失败(可能 WDA 断联): {e}")
|
||
if self.health_monitor:
|
||
logger.info("[StuckDetector] 触发 WDA 恢复并等待...")
|
||
recovered = self.health_monitor.trigger_recovery_and_wait(timeout=120)
|
||
if recovered:
|
||
logger.info("[StuckDetector] WDA 已恢复,重置客户端")
|
||
self._wda_client = None # 重置以便下次循环重建连接
|
||
else:
|
||
logger.error("[StuckDetector] WDA 恢复失败")
|
||
return None
|
||
|
||
def _extract_interactive_features(self, node: dict, features: list):
|
||
"""递归遍历 JSON 页面结构,提取交互元素的 label + value"""
|
||
if not isinstance(node, dict):
|
||
return
|
||
|
||
# 检查当前节点是否为交互元素
|
||
node_type = node.get('type', '')
|
||
# JSON source 中 type 可能带 XCUIElementType 前缀
|
||
short_type = node_type.replace('XCUIElementType', '')
|
||
|
||
if short_type in self.INTERACTIVE_TYPES:
|
||
label = str(node.get('label', ''))
|
||
value = str(node.get('value', ''))
|
||
features.append(f"{short_type}:{label}|{value}")
|
||
|
||
# 递归遍历子节点
|
||
children = node.get('children', [])
|
||
if isinstance(children, list):
|
||
for child in children:
|
||
self._extract_interactive_features(child, features)
|
||
|
||
def _check_flow_activity(self) -> bool:
|
||
"""检测 pcap CSV 是否有新增域名(网络活动)"""
|
||
if not self.pcap_csv_path or not os.path.exists(self.pcap_csv_path):
|
||
return True # 无法检测时默认有活动
|
||
|
||
try:
|
||
new_activity = False
|
||
# csv 可能会被持续写入,使用 errors='ignore' 避免编码问题
|
||
with open(self.pcap_csv_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
domain = row.get('TargetDomain', '').strip()
|
||
if domain and domain not in self._seen_domains:
|
||
self._seen_domains.add(domain)
|
||
new_activity = True
|
||
|
||
return new_activity
|
||
|
||
except Exception as e:
|
||
logger.debug(f"[StuckDetector] 读取 flows.csv 失败: {e}")
|
||
return True # 读取失败时默认有活动
|
||
|
||
def _detection_loop(self):
|
||
"""后台检测循环"""
|
||
logger.info(f"[StuckDetector] 检测线程已启动 (间隔={self.check_interval}s, "
|
||
f"back阈值={self.back_threshold}, agent阈值={self.agent_threshold})")
|
||
|
||
while self._running:
|
||
try:
|
||
current_fingerprint = self._compute_page_fingerprint()
|
||
has_flow_activity = self._check_flow_activity()
|
||
|
||
with self._lock:
|
||
# 页面指纹相同 且 无网络活动 → 卡住计数+1
|
||
if (current_fingerprint is not None and
|
||
current_fingerprint == self._last_fingerprint and
|
||
not has_flow_activity):
|
||
self._stuck_count += 1
|
||
logger.debug(f"[StuckDetector] 页面无变化 "
|
||
f"(count={self._stuck_count}, "
|
||
f"fingerprint={current_fingerprint[:8]}...)")
|
||
# 仅页面指纹相同(有网络活动)→ 也计数但较慢
|
||
elif (current_fingerprint is not None and
|
||
current_fingerprint == self._last_fingerprint):
|
||
self._stuck_count += 0.5
|
||
logger.debug(f"[StuckDetector] 页面无变化但有网络活动 "
|
||
f"(count={self._stuck_count})")
|
||
else:
|
||
# 页面变化了,重置计数
|
||
if self._stuck_count > 0:
|
||
logger.debug(f"[StuckDetector] 页面已变化,重置计数 "
|
||
f"(was {self._stuck_count})")
|
||
self._stuck_count = 0
|
||
|
||
self._last_fingerprint = current_fingerprint
|
||
|
||
# 更新卡住详情
|
||
if self._stuck_count >= self.back_threshold:
|
||
self._stuck_info = {
|
||
'stuck_count': self._stuck_count,
|
||
'fingerprint': current_fingerprint,
|
||
'has_flow_activity': has_flow_activity,
|
||
'timestamp': datetime.now().isoformat(),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[StuckDetector] 检测循环异常: {e}")
|
||
|
||
# 等待下次检测
|
||
for _ in range(self.check_interval):
|
||
if not self._running:
|
||
break
|
||
time.sleep(1)
|
||
|
||
logger.info("[StuckDetector] 检测线程已退出")
|
||
|
||
def start(self):
|
||
"""启动后台检测线程"""
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
self._stuck_count = 0
|
||
self._last_fingerprint = None
|
||
self._stuck_count = 0
|
||
self._last_fingerprint = None
|
||
self._seen_domains = set()
|
||
self._thread = threading.Thread(target=self._detection_loop, daemon=True)
|
||
self._thread.start()
|
||
|
||
def stop(self):
|
||
"""停止后台检测线程"""
|
||
self._running = False
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=self.check_interval + 5)
|
||
self._thread = None
|
||
|
||
def get_action_needed(self) -> Optional[str]:
|
||
"""
|
||
查询当前需要执行的动作
|
||
|
||
Returns:
|
||
'agent' — 需要调用 Agent 处理
|
||
'back' — 需要发送 back 键
|
||
None — 无需操作
|
||
"""
|
||
with self._lock:
|
||
if self._stuck_count >= self.agent_threshold:
|
||
return 'agent'
|
||
elif self._stuck_count >= self.back_threshold:
|
||
return 'back'
|
||
return None
|
||
|
||
def get_stuck_info(self) -> dict:
|
||
"""获取卡住详情"""
|
||
with self._lock:
|
||
return self._stuck_info.copy()
|
||
|
||
def reset(self):
|
||
"""重置计数(Agent 处理完后调用)"""
|
||
with self._lock:
|
||
self._stuck_count = 0
|
||
self._last_fingerprint = None
|
||
self._stuck_info = {}
|
||
logger.info("[StuckDetector] 计数已重置")
|
||
|
||
|
||
# ============================================================
|
||
# 组件2: FastbotAgentController — Fastbot + Agent 混合控制器
|
||
# ============================================================
|
||
class FastbotAgentController:
|
||
"""
|
||
协调 Fastbot 和 GuiAgent 的交替执行
|
||
|
||
核心流程:
|
||
1. 启动 Fastbot
|
||
2. 后台监控循环,检测页面卡住
|
||
3. 10次无新特征 → 通过 WDA 发送 back 键
|
||
4. 30次无新特征 → 停止 Fastbot → Agent 处理 → 重启 Fastbot
|
||
5. Fastbot 正常结束或超时 → 退出
|
||
"""
|
||
|
||
def __init__(self, runner, wda_url: str, bundle_id: str,
|
||
app_name: str = "",
|
||
check_interval: int = 20,
|
||
back_threshold: int = 10,
|
||
agent_threshold: int = 30,
|
||
agent_timeout: int = 120,
|
||
pcap_csv_path: str = None,
|
||
health_monitor=None):
|
||
"""
|
||
Args:
|
||
runner: GoIOSRunner 实例
|
||
wda_url: WDA 服务地址
|
||
bundle_id: 被测应用 Bundle ID
|
||
app_name: 应用真实名称(用于 Agent 提示词,如 'Snapchat')
|
||
check_interval: 检测间隔(秒)
|
||
back_threshold: 发送 back 的阈值
|
||
agent_threshold: 调用 agent 的阈值
|
||
agent_timeout: Agent 单次处理超时(秒)
|
||
pcap_csv_path: pcap .flows.csv 文件路径
|
||
"""
|
||
self.runner = runner
|
||
self.wda_url = wda_url
|
||
self.bundle_id = bundle_id
|
||
self.app_name = app_name or bundle_id # 回退到 bundle_id
|
||
self.agent_timeout = agent_timeout
|
||
self.health_monitor = health_monitor
|
||
|
||
# 页面卡住检测器
|
||
self.detector = PageStuckDetector(
|
||
wda_url=wda_url,
|
||
check_interval=check_interval,
|
||
back_threshold=back_threshold,
|
||
agent_threshold=agent_threshold,
|
||
pcap_csv_path=pcap_csv_path,
|
||
health_monitor=health_monitor, # 传入统一健康监控
|
||
)
|
||
|
||
# WDA 客户端(懒加载)
|
||
self._wda_client = None
|
||
|
||
# GuiAgent(懒加载)
|
||
self._agent_bridge = None
|
||
|
||
# 统计
|
||
self.back_count = 0 # 执行 back 的次数
|
||
self.agent_count = 0 # 调用 Agent 的次数
|
||
self.fastbot_restarts = 0 # Fastbot 重启次数
|
||
|
||
def _get_wda_client(self):
|
||
"""获取 WDA 客户端(http: 格式用 Client,UDID/空用 USBClient)"""
|
||
if self._wda_client is None:
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
if self.wda_url and self.wda_url.startswith("http:"):
|
||
self._wda_client = WDAClient(self.wda_url)
|
||
else:
|
||
self._wda_client = WDAUSBClient(udid=self.wda_url or "")
|
||
return self._wda_client
|
||
|
||
def _init_agent(self):
|
||
"""初始化 GuiAgent for stuck_handler 场景"""
|
||
if self._agent_bridge is not None:
|
||
return
|
||
|
||
try:
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
from DroidBot.platforms.ios.ios_device import IOSDevice
|
||
from DroidBot.guiagent_bridge import GuiAgentBridge
|
||
|
||
# 创建轻量级 IOSDevice 用于 Agent 操作
|
||
# IOSDevice 需要 wda_url,用于截图和操作
|
||
device = IOSDevice.__new__(IOSDevice)
|
||
device.wda_url = self.wda_url
|
||
# 根据 wda_url 格式选择连接方式
|
||
if self.wda_url and self.wda_url.startswith("http:"):
|
||
_wda_client = WDAClient(self.wda_url)
|
||
else:
|
||
_wda_client = WDAUSBClient(udid=self.wda_url or "")
|
||
device._wda_client = _wda_client
|
||
device._platform_name = "ios"
|
||
device.get_platform_name = lambda: "ios"
|
||
# GuiAgentBridge._init_agent 需要的属性
|
||
device.display_info = None
|
||
device.output_dir = os.path.join(
|
||
self.output_dir, "agent_stuck"
|
||
) if hasattr(self, 'output_dir') and self.output_dir else "/tmp/agent_stuck"
|
||
os.makedirs(device.output_dir, exist_ok=True)
|
||
device.logger = logging.getLogger("IOSDevice.lightweight.fastbot")
|
||
device._window_size = None
|
||
device._consecutive_failures = 0
|
||
device.WDA_FAILURE_THRESHOLD = 3
|
||
device._wda_health_monitor = None
|
||
device._cached_state = None
|
||
device.bundle_id = self.bundle_id
|
||
device.pause_sending_event = False
|
||
device.cv_mode = False
|
||
|
||
# WDA 健康检查与恢复
|
||
_runner = self.runner if hasattr(self, 'runner') else None
|
||
_wda_url = self.wda_url
|
||
|
||
def _fastbot_wait_wda_ready(timeout=30):
|
||
try:
|
||
return _wda_client.wait_ready(timeout=timeout, noprint=True)
|
||
except Exception as e:
|
||
logging.getLogger("IOSDevice.lightweight.fastbot").warning(
|
||
f"_wait_wda_ready 异常: {e}")
|
||
return False
|
||
|
||
def _fastbot_on_wda_failure(reason=""):
|
||
"""WDA 失败处理:委托给 WDAHealthMonitor 统一处理"""
|
||
_logger = logging.getLogger("IOSDevice.lightweight.fastbot")
|
||
_logger.warning(f"WDA 操作失败 ({reason}),触发统一恢复...")
|
||
_health_mon = self.health_monitor
|
||
if _health_mon:
|
||
recovered = _health_mon.trigger_recovery_and_wait(timeout=120)
|
||
if recovered:
|
||
_logger.info("WDA 已恢复")
|
||
else:
|
||
_logger.error("WDA 恢复失败")
|
||
else:
|
||
_logger.warning("无 WDAHealthMonitor,无法自动恢复")
|
||
|
||
device._wait_wda_ready = _fastbot_wait_wda_ready
|
||
device._on_wda_failure = _fastbot_on_wda_failure
|
||
|
||
# 创建 GuiAgentBridge(使用真实应用名而非 bundle_id)
|
||
self._agent_bridge = GuiAgentBridge(
|
||
device=device,
|
||
app_name=self.app_name,
|
||
)
|
||
logger.info("[FastbotAgent] GuiAgent 初始化成功")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[FastbotAgent] GuiAgent 初始化失败: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
|
||
def _send_back(self):
|
||
"""通过 WDA 发送 back/返回操作"""
|
||
try:
|
||
client = self._get_wda_client()
|
||
# iOS 没有 back 键,使用从左向右滑动模拟返回
|
||
window_size = client.window_size()
|
||
w, h = window_size
|
||
# 从屏幕左边缘向右滑动
|
||
client.swipe(0, h // 2, w // 2, h // 2, duration=0.3)
|
||
self.back_count += 1
|
||
logger.info(f"[FastbotAgent] 发送 back 手势 (总计 {self.back_count} 次)")
|
||
time.sleep(1)
|
||
except Exception as e:
|
||
logger.warning(f"[FastbotAgent] 发送 back 失败: {e}")
|
||
|
||
def _handle_stuck_with_agent(self, stuck_info: dict) -> bool:
|
||
"""
|
||
停止 Fastbot → Agent 处理卡住页面 → 重启 Fastbot
|
||
|
||
Returns:
|
||
是否成功处理
|
||
"""
|
||
logger.info(f"[FastbotAgent] ===== 开始 Agent 干预 =====")
|
||
logger.info(f"[FastbotAgent] 卡住信息: {stuck_info}")
|
||
|
||
# 1. 停止 Fastbot
|
||
logger.info("[FastbotAgent] 正在停止 Fastbot...")
|
||
try:
|
||
self.runner.stop_fastbot()
|
||
time.sleep(2)
|
||
logger.info("[FastbotAgent] Fastbot 已停止")
|
||
except Exception as e:
|
||
logger.warning(f"[FastbotAgent] 停止 Fastbot 异常: {e}")
|
||
|
||
# 2. 确保应用在前台
|
||
try:
|
||
client = self._get_wda_client()
|
||
# 等待 WDA 就绪
|
||
if not client.wait_ready(timeout=15, noprint=True):
|
||
logger.warning("[FastbotAgent] WDA 未就绪,跳过 Agent 干预")
|
||
return False
|
||
|
||
# 启动目标应用
|
||
client.app_launch(self.bundle_id)
|
||
time.sleep(2)
|
||
except Exception as e:
|
||
logger.warning(f"[FastbotAgent] 启动应用失败: {e}")
|
||
|
||
# 3. 调用 GuiAgent 处理
|
||
success = False
|
||
try:
|
||
self._init_agent()
|
||
if self._agent_bridge and self._agent_bridge.is_enabled:
|
||
result = self._agent_bridge.handle_with_guiagent(
|
||
category="stuck_handler",
|
||
context={"stuck_info": stuck_info}
|
||
)
|
||
success = result[0] if isinstance(result, tuple) else bool(result)
|
||
self.agent_count += 1
|
||
logger.info(f"[FastbotAgent] Agent 处理结果: "
|
||
f"{'成功' if success else '未解决'} "
|
||
f"(总计 {self.agent_count} 次)")
|
||
else:
|
||
logger.warning("[FastbotAgent] GuiAgent 不可用,跳过处理")
|
||
except Exception as e:
|
||
logger.error(f"[FastbotAgent] Agent 处理异常: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
|
||
logger.info(f"[FastbotAgent] ===== Agent 干预结束 =====")
|
||
return success
|
||
|
||
def run(self, duration: int = 600, throttle: int = 1000,
|
||
timeout: int = None) -> dict:
|
||
"""
|
||
主执行逻辑:Fastbot + 后台监控 + 分级响应
|
||
|
||
Args:
|
||
duration: Fastbot 单次运行时长(秒)
|
||
throttle: Fastbot 操作间隔(毫秒)
|
||
timeout: 总超时时间(秒),None 则使用 duration
|
||
|
||
Returns:
|
||
执行统计 dict
|
||
"""
|
||
if timeout is None:
|
||
timeout = duration
|
||
|
||
start_time = time.time()
|
||
remaining_duration = duration
|
||
|
||
logger.info(f"[FastbotAgent] 开始混合测试 "
|
||
f"(duration={duration}s, throttle={throttle}ms, timeout={timeout}s)")
|
||
|
||
# 启动检测器
|
||
self.detector.start()
|
||
|
||
# 启动 Fastbot
|
||
try:
|
||
fastbot_proc = self.runner.start_fastbot(
|
||
target_bundle_id=self.bundle_id,
|
||
duration=remaining_duration,
|
||
throttle=throttle,
|
||
)
|
||
logger.info(f"[FastbotAgent] Fastbot 已启动 (PID: {fastbot_proc.pid})")
|
||
except Exception as e:
|
||
logger.error(f"[FastbotAgent] Fastbot 启动失败: {e}")
|
||
self.detector.stop()
|
||
return self._build_stats(start_time, success=False, error="fastbot_start_failed")
|
||
|
||
# 主监控循环
|
||
try:
|
||
while True:
|
||
elapsed = time.time() - start_time
|
||
|
||
# 超时检查
|
||
if elapsed >= timeout:
|
||
logger.info(f"[FastbotAgent] 已达到超时 ({timeout}s),结束测试")
|
||
break
|
||
|
||
# Fastbot 进程检查
|
||
if fastbot_proc.poll() is not None:
|
||
if elapsed < timeout:
|
||
logger.warning(f"[FastbotAgent] Fastbot 进程提前退出 "
|
||
f"(returncode={fastbot_proc.returncode}, "
|
||
f"已运行: {elapsed:.1f}s, 目标: {timeout}s)")
|
||
# 重启 Fastbot 补足时间
|
||
remaining_duration = max(60, int(timeout - elapsed))
|
||
try:
|
||
fastbot_proc = self.runner.start_fastbot(
|
||
target_bundle_id=self.bundle_id,
|
||
duration=remaining_duration,
|
||
throttle=throttle,
|
||
)
|
||
self.fastbot_restarts += 1
|
||
logger.info(f"[FastbotAgent] Fastbot 异常退出已重启 "
|
||
f"(PID: {fastbot_proc.pid}, "
|
||
f"remaining={remaining_duration}s, "
|
||
f"restarts={self.fastbot_restarts})")
|
||
except Exception as e:
|
||
logger.error(f"[FastbotAgent] Fastbot 异常退出后重启失败: {e}")
|
||
break
|
||
else:
|
||
logger.info(f"[FastbotAgent] Fastbot 进程已结束 "
|
||
f"(returncode={fastbot_proc.returncode})")
|
||
break
|
||
|
||
# 检测页面卡住状态
|
||
action = self.detector.get_action_needed()
|
||
|
||
if action == 'agent':
|
||
# 调用 Agent 处理
|
||
stuck_info = self.detector.get_stuck_info()
|
||
self._handle_stuck_with_agent(stuck_info)
|
||
|
||
# 重置检测器
|
||
self.detector.reset()
|
||
|
||
# 重新计算剩余时间
|
||
elapsed_after_agent = time.time() - start_time
|
||
remaining_duration = max(60, int(duration - elapsed_after_agent))
|
||
|
||
# 重启 Fastbot
|
||
try:
|
||
fastbot_proc = self.runner.start_fastbot(
|
||
target_bundle_id=self.bundle_id,
|
||
duration=remaining_duration,
|
||
throttle=throttle,
|
||
)
|
||
self.fastbot_restarts += 1
|
||
logger.info(f"[FastbotAgent] Fastbot 已重启 "
|
||
f"(PID: {fastbot_proc.pid}, "
|
||
f"remaining={remaining_duration}s, "
|
||
f"restarts={self.fastbot_restarts})")
|
||
except Exception as e:
|
||
logger.error(f"[FastbotAgent] Fastbot 重启失败: {e}")
|
||
break
|
||
|
||
elif action == 'back':
|
||
self._send_back()
|
||
|
||
# 等待一个检测周期
|
||
time.sleep(self.detector.check_interval)
|
||
|
||
except KeyboardInterrupt:
|
||
logger.info("[FastbotAgent] 收到中断信号,停止测试")
|
||
finally:
|
||
# 清理
|
||
self.detector.stop()
|
||
try:
|
||
self.runner.stop_fastbot()
|
||
except Exception:
|
||
pass
|
||
|
||
return self._build_stats(start_time, success=True)
|
||
|
||
def _build_stats(self, start_time: float, success: bool,
|
||
error: str = "") -> dict:
|
||
"""构建执行统计"""
|
||
return {
|
||
'success': success,
|
||
'error': error,
|
||
'duration': time.time() - start_time,
|
||
'back_count': self.back_count,
|
||
'agent_count': self.agent_count,
|
||
'fastbot_restarts': self.fastbot_restarts,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 组件3: IOSFastbotTestRunner — 测试执行器
|
||
# ============================================================
|
||
class IOSFastbotTestRunner:
|
||
"""
|
||
封装单次 Fastbot + GuiAgent 混合测试流程
|
||
|
||
流程:
|
||
1. 创建输出目录、配置日志、启动抓包
|
||
2. 阶段1: 权限处理 + 关键词检测 + GuiAgent 登录
|
||
3. 阶段2: FastbotAgentController.run() — Fastbot + 异步卡住检测
|
||
4. 返回 TaskResult
|
||
"""
|
||
|
||
def __init__(self, runner, wda_url: str, udid: str,
|
||
output_base: str = None, health_monitor=None):
|
||
"""
|
||
Args:
|
||
runner: GoIOSRunner 实例
|
||
wda_url: WDA 服务地址
|
||
udid: 设备 UDID
|
||
output_base: 输出目录基础路径
|
||
health_monitor: WDAHealthMonitor 实例(统一管理 WDA 断联预内恢复)
|
||
"""
|
||
self.runner = runner
|
||
self.wda_url = wda_url
|
||
self.udid = udid
|
||
self.output_base = output_base or os.path.join(ROOT, "output", "ios_test")
|
||
self.health_monitor = health_monitor
|
||
|
||
def run_test(self, bundle_id: str, app_id: str = "", app_name: str = "",raw_app_name: str = "",
|
||
duration: int = 600, throttle: int = 1000,
|
||
enable_login: bool = True,
|
||
check_interval: int = 20,
|
||
back_threshold: int = 10,
|
||
agent_threshold: int = 30,
|
||
agent_timeout: int = 300,
|
||
test_mode: str = "droidbot",
|
||
# droidbot 模式专用参数
|
||
policy: str = "memory_guided",
|
||
event_count: int = 10000,
|
||
cv_mode: bool = False,
|
||
debug_mode: bool = False,
|
||
guiagent_flag: bool = True,
|
||
random_input: bool = False) -> TaskResult:
|
||
"""
|
||
执行完整的混合测试
|
||
|
||
Args:
|
||
bundle_id: 被测应用 Bundle ID
|
||
app_name: 应用名称,来自iOS安装后显示名称(用于日志)
|
||
raw_app_name: 应用原始名称,来自采集名单(用于日志)
|
||
duration: 测试时长(秒)
|
||
throttle: Fastbot 操作间隔(毫秒)
|
||
enable_login: 是否启用登录处理
|
||
check_interval: 卡住检测间隔
|
||
back_threshold: back 阈值
|
||
agent_threshold: agent 阈值
|
||
agent_timeout: agent 超时
|
||
test_mode: 测试模式,'fastbot'(默认)或 'droidbot'
|
||
policy: [droidbot] 探索策略(默认: memory_guided)
|
||
event_count: [droidbot] 最大事件数(默认: 10000)
|
||
cv_mode: [droidbot] 是否启用 CV 模式
|
||
debug_mode: [droidbot] 是否启用调试模式
|
||
guiagent_flag: [droidbot] 是否启用 GuiAgent
|
||
random_input: [droidbot] 是否引入随机输入
|
||
|
||
Returns:
|
||
TaskResult
|
||
"""
|
||
start_time = time.time()
|
||
display_name = app_name or bundle_id
|
||
|
||
# 创建输出目录
|
||
safe_bundle = bundle_id.replace('.', '_')
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
output_dir = os.path.join(
|
||
self.output_base,
|
||
f"{safe_bundle}_iOS_{timestamp}"
|
||
)
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
|
||
# 设置 agent log 目录环境变量
|
||
# GuiAgentDecisionMaker 读取 GUIAGENT_LOG_DIR,其他组件读取 AGENT_LOG_DIR,两个都要设置
|
||
agent_log_dir = os.path.join(ROOT, "output", "agent_logs")
|
||
os.environ['AGENT_LOG_DIR'] = agent_log_dir
|
||
os.environ['GUIAGENT_LOG_DIR'] = agent_log_dir
|
||
os.makedirs(agent_log_dir, exist_ok=True)
|
||
|
||
# 配置日志输出到文件
|
||
log_file = os.path.join(output_dir, "test_log.txt")
|
||
tee = TeeOutput(log_file)
|
||
tee.setup_logging(debug_mode=debug_mode)
|
||
old_stdout, old_stderr = sys.stdout, sys.stderr
|
||
sys.stdout = tee
|
||
sys.stderr = tee
|
||
|
||
pcap_process = None
|
||
flows_jsonl_path = None
|
||
|
||
try:
|
||
print(f"\n{'='*60}")
|
||
print(f" iOS Fastbot+GuiAgent 混合测试")
|
||
print(f" 应用: {display_name} ({bundle_id})")
|
||
print(f" 时长: {duration}s | 间隔: {throttle}ms")
|
||
print(f" 输出: {output_dir}")
|
||
print(f"{'='*60}\n")
|
||
|
||
# ========== 启动抓包(默认启用) ==========
|
||
print("[INFO] 启动抓包...")
|
||
pcap_process = start_pcap(
|
||
bundle_id=bundle_id,
|
||
udid=self.udid,
|
||
output_dir=output_dir,
|
||
app_id=app_id,
|
||
app_name=app_name,
|
||
raw_app_name=raw_app_name,
|
||
)
|
||
time.sleep(2)
|
||
|
||
# 查找 flows.csv 文件路径
|
||
pcap_csv_path = self._find_pcap_csv(output_dir)
|
||
if pcap_csv_path:
|
||
print(f"[INFO] flows.csv 路径: {pcap_csv_path}")
|
||
else:
|
||
print("[WARN] 未找到 flows.csv 文件,流量检测将不可用")
|
||
|
||
# ========== 阶段1: 权限处理 + 关键词检测 + GuiAgent 登录 ==========
|
||
print(f"\n{'='*50}")
|
||
print(f"[阶段1] 权限处理 + 登录检测")
|
||
print(f"{'='*50}\n")
|
||
|
||
if enable_login:
|
||
import concurrent.futures
|
||
try:
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
# 根据 wda_url 格式选择连接方式
|
||
if self.wda_url and self.wda_url.startswith("http:"):
|
||
wda_client = WDAClient(self.wda_url)
|
||
else:
|
||
wda_client = WDAUSBClient(udid=self.wda_url or "")
|
||
|
||
# 1. 启动应用
|
||
print(f"[INFO] 启动应用: {bundle_id}")
|
||
wda_client.session(bundle_id)
|
||
time.sleep(3)
|
||
|
||
# 2. 处理权限弹窗(最多尝试 5 轮)
|
||
print("[INFO] 处理权限弹窗...")
|
||
for _ in range(5):
|
||
try:
|
||
alert = wda_client.alert
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(lambda: alert.exists)
|
||
try:
|
||
has_alert = future.result(timeout=10)
|
||
except concurrent.futures.TimeoutError:
|
||
print("[WARN] 检查 Alert 超时,跳过")
|
||
break
|
||
if has_alert:
|
||
alert.accept()
|
||
print("[INFO] 已处理一个权限弹窗")
|
||
time.sleep(1)
|
||
else:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"处理权限弹窗异常: {e}")
|
||
break
|
||
|
||
# 3. 关键词检测:检查页面是否包含登录/注册相关文案
|
||
LOGIN_KEYWORDS = [
|
||
"登录", "登陆", "login", "sign in", "log in",
|
||
"Apple 继续", "AppleID",
|
||
]
|
||
REGISTER_KEYWORDS = [
|
||
"注册", "register", "sign up", "create account",
|
||
]
|
||
|
||
detected_category = None
|
||
detected_keyword = None
|
||
|
||
def _check_keyword(kw):
|
||
"""检查单个关键词是否存在于页面(labelContains)"""
|
||
try:
|
||
return wda_client(labelContains=kw).exists
|
||
except Exception:
|
||
return False
|
||
time.sleep(5)
|
||
print("[INFO] 检测登录/注册关键词...")
|
||
# 先检测登录关键词
|
||
for kw in LOGIN_KEYWORDS:
|
||
try:
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(_check_keyword, kw)
|
||
found = future.result(timeout=8)
|
||
if found:
|
||
detected_category = "login"
|
||
detected_keyword = kw
|
||
print(f"[INFO] 检测到登录关键词: '{kw}'")
|
||
break
|
||
except concurrent.futures.TimeoutError:
|
||
logger.debug(f"检测关键词超时: {kw}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"检测关键词异常: {kw} - {e}")
|
||
continue
|
||
|
||
# 未检测到登录,再检测注册
|
||
if not detected_category:
|
||
for kw in REGISTER_KEYWORDS:
|
||
try:
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||
future = executor.submit(_check_keyword, kw)
|
||
found = future.result(timeout=8)
|
||
if found:
|
||
detected_category = "register"
|
||
detected_keyword = kw
|
||
print(f"[INFO] 检测到注册关键词: '{kw}'")
|
||
break
|
||
except concurrent.futures.TimeoutError:
|
||
logger.debug(f"检测关键词超时: {kw}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"检测关键词异常: {kw} - {e}")
|
||
continue
|
||
|
||
# 4. 如果检测到登录/注册关键词,交给 GuiAgent 处理
|
||
if detected_category:
|
||
print(f"[INFO] 启动 GuiAgent 处理 {detected_category}...")
|
||
try:
|
||
from DroidBot.guiagent_bridge import GuiAgentBridge
|
||
from DroidBot.platforms.ios.ios_device import IOSDevice
|
||
|
||
# 创建轻量级 IOSDevice 用于 Agent 操作
|
||
device = IOSDevice.__new__(IOSDevice)
|
||
device.wda_url = self.wda_url
|
||
device._wda_client = wda_client
|
||
device._platform_name = "ios"
|
||
device.get_platform_name = lambda: "ios"
|
||
# GuiAgentBridge._init_agent 需要的属性
|
||
device.display_info = None # get_display_info 需要
|
||
device.output_dir = os.path.join(output_dir, "agent_login")
|
||
os.makedirs(device.output_dir, exist_ok=True)
|
||
device.logger = logging.getLogger("IOSDevice.lightweight")
|
||
device._window_size = None
|
||
device._consecutive_failures = 0
|
||
device.WDA_FAILURE_THRESHOLD = 3
|
||
device._wda_health_monitor = None
|
||
|
||
# WDA 就绪检查:委托给 WDAHealthMonitor(如有),否则轻量实现
|
||
_health_mon = self.health_monitor # 闭包捕获
|
||
|
||
def _lightweight_wait_wda_ready(timeout=30):
|
||
"""等待 WDA 就绪(委托给 health_monitor 或直接检等)"""
|
||
try:
|
||
return wda_client.wait_ready(timeout=timeout, noprint=True)
|
||
except Exception as e:
|
||
logging.getLogger("IOSDevice.lightweight").warning(
|
||
f"_wait_wda_ready 异常: {e}")
|
||
return False
|
||
|
||
def _lightweight_on_wda_failure(reason=""):
|
||
"""委托给 WDAHealthMonitor 统一处理"""
|
||
_logger = logging.getLogger("IOSDevice.lightweight")
|
||
_logger.warning(f"WDA 操作失败 ({reason}),触发统一恢复...")
|
||
if _health_mon:
|
||
recovered = _health_mon.trigger_recovery_and_wait(timeout=120)
|
||
if recovered:
|
||
_logger.info("WDA 已恢复")
|
||
else:
|
||
_logger.error("WDA 恢复失败")
|
||
else:
|
||
# 无 health_monitor 时保持原有轻量实现
|
||
_logger.warning("无 WDAHealthMonitor,无法自动恢复")
|
||
|
||
device._wait_wda_ready = _lightweight_wait_wda_ready
|
||
device._on_wda_failure = _lightweight_on_wda_failure
|
||
|
||
# get_current_state / send_event 需要的属性
|
||
device._cached_state = None
|
||
device.bundle_id = bundle_id
|
||
device.pause_sending_event = False
|
||
device.cv_mode = False
|
||
agent_bridge = GuiAgentBridge(
|
||
device=device,
|
||
app_name=display_name, # 使用真实应用名
|
||
)
|
||
|
||
if agent_bridge.is_enabled:
|
||
agent_result = agent_bridge.handle_with_guiagent(
|
||
category=detected_category,
|
||
max_steps=30,
|
||
max_error_steps=20,
|
||
)
|
||
agent_success = agent_result[0] if isinstance(agent_result, tuple) else bool(agent_result)
|
||
if agent_success:
|
||
print(f"[SUCCESS] GuiAgent {detected_category} 处理完成")
|
||
else:
|
||
print(f"[WARN] GuiAgent {detected_category} 处理未成功")
|
||
else:
|
||
print("[WARN] GuiAgent 不可用")
|
||
except Exception as e:
|
||
print(f"[WARN] GuiAgent 处理异常: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
else:
|
||
print("[INFO] 未检测到登录/注册关键词,跳过登录处理")
|
||
|
||
except Exception as e:
|
||
print(f"[WARN] 阶段1异常: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
else:
|
||
print("[INFO] 跳过登录处理")
|
||
|
||
# 等待应用稳定
|
||
time.sleep(3)
|
||
|
||
# ========== 阶段2: 核心测试(fastbot / droidbot 二选一) ==========
|
||
print(f"\n{'='*50}")
|
||
print(f"[阶段2] 核心测试模式: {test_mode}")
|
||
print(f"{'='*50}\n")
|
||
|
||
if test_mode == "droidbot":
|
||
# ---------- DroidBot 模式(对齐 ios_start.py IOSTestRunner 实现)----------
|
||
droidbot_output_dir = os.path.join(output_dir, "droidbot")
|
||
os.makedirs(droidbot_output_dir, exist_ok=True)
|
||
|
||
# 重置 GuiAgent 步数计数器(每轮任务独立统计)
|
||
GuiAgentDecisionMaker.total_steps = 0
|
||
|
||
droidbot_steps = 0
|
||
guiagent_steps = 0
|
||
_droidbot_ret_code = EXIT_ERROR_GENERAL
|
||
_droidbot_status = "FAILED"
|
||
_droidbot_error = ""
|
||
_user_interrupted = False
|
||
|
||
print(f"[INFO] 启动 DroidBot 测试 "
|
||
f"(policy={policy}, timeout={duration}s, "
|
||
f"event_interval={throttle/1000:.1f}s, event_count={event_count}) ...")
|
||
|
||
try:
|
||
droidbot = DroidBot(
|
||
package_name=bundle_id,
|
||
device_serial=self.udid,
|
||
is_emulator=False,
|
||
output_dir=droidbot_output_dir,
|
||
policy_name=policy,
|
||
random_input=random_input,
|
||
event_interval=throttle / 1000, # 毫秒转秒
|
||
timeout=duration,
|
||
event_count=event_count,
|
||
cv_mode=cv_mode,
|
||
debug_mode=debug_mode,
|
||
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=guiagent_flag,
|
||
app_name=app_name,
|
||
platform="ios",
|
||
wda_url=self.wda_url,
|
||
)
|
||
|
||
# 注入 WDA 健康监控
|
||
if self.health_monitor and hasattr(droidbot, 'device'):
|
||
droidbot.device.set_health_monitor(self.health_monitor)
|
||
|
||
# 注入 pcap 流量目录,供 TrafficMonitor.get_traffic_domains() 读取
|
||
# pcap 工具写入 --output={output_dir},flows.csv 在 output_dir 下
|
||
if hasattr(droidbot, 'device'):
|
||
droidbot.device.captured_traffic_dir = output_dir
|
||
# 同步 output_dir(TrafficMonitor 会从 device.output_dir 推断备用路径)
|
||
if not droidbot.device.output_dir:
|
||
droidbot.device.output_dir = output_dir
|
||
# 注入 pcap 进程引用,供 is_traffic_capture_running / restart_traffic_capture 使用
|
||
if pcap_process and hasattr(droidbot.device, 'set_pcap_process'):
|
||
droidbot.device.set_pcap_process(
|
||
pcap_process,
|
||
restart_kwargs=dict(
|
||
bundle_id=bundle_id,
|
||
udid=self.udid,
|
||
output_dir=output_dir,
|
||
app_id=app_id,
|
||
app_name=app_name,
|
||
raw_app_name=raw_app_name,
|
||
)
|
||
)
|
||
|
||
droidbot.start()
|
||
_droidbot_ret_code = EXIT_SUCCESS
|
||
_droidbot_status = "SUCCESS"
|
||
|
||
# 精确统计步数
|
||
droidbot_steps = droidbot.input_manager.total_exploring_steps
|
||
guiagent_steps = GuiAgentDecisionMaker.total_steps
|
||
|
||
print(f"[SUCCESS] DroidBot 测试完成: {bundle_id}")
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"[WARN] 用户手动中断 DroidBot 测试: {bundle_id}")
|
||
_droidbot_ret_code = EXIT_ERROR_USER
|
||
_droidbot_status = "INTERRUPTED"
|
||
_droidbot_error = "用户手动中断"
|
||
_user_interrupted = True
|
||
except Exception as _e:
|
||
import traceback as _tb
|
||
# WDA 异常区分
|
||
if WDAStuckError and isinstance(_e, WDAStuckError):
|
||
print(f"[ERROR] WDA 卡死且恢复失败: {_e}")
|
||
_droidbot_ret_code = EXIT_WDA_STUCK
|
||
_droidbot_error = f"WDA 卡死恢复失败: {_e}"
|
||
elif WDA_EXCEPTIONS and isinstance(_e, WDA_EXCEPTIONS):
|
||
print(f"[ERROR] WDA 连接异常: {_e}")
|
||
_droidbot_ret_code = EXIT_WDA_ERROR
|
||
_droidbot_error = f"WDA 连接异常: {_e}"
|
||
else:
|
||
print(f"[ERROR] DroidBot 执行异常: {_e}")
|
||
_droidbot_error = f"执行异常: {_e}"
|
||
_droidbot_status = "FAILED"
|
||
logger.debug(_tb.format_exc())
|
||
finally:
|
||
# 尽量获取步数(即使 droidbot.start() 中途抛出异常也要读取)
|
||
try:
|
||
droidbot_steps = droidbot.input_manager.total_exploring_steps
|
||
except Exception:
|
||
pass
|
||
try:
|
||
guiagent_steps = GuiAgentDecisionMaker.total_steps
|
||
except Exception:
|
||
pass
|
||
|
||
elapsed = time.time() - start_time
|
||
total_steps_db = droidbot_steps + guiagent_steps
|
||
print(f"[INFO] DroidBot 步数: {droidbot_steps}")
|
||
print(f"[INFO] GuiAgent 步数: {guiagent_steps}")
|
||
print(f"[INFO] 总步数: {total_steps_db}")
|
||
|
||
_result = TaskResult(
|
||
status=_droidbot_status,
|
||
exit_code=_droidbot_ret_code,
|
||
error_reason=_droidbot_error,
|
||
droidbot_steps=droidbot_steps,
|
||
guiagent_steps=guiagent_steps,
|
||
total_steps=total_steps_db,
|
||
duration_seconds=elapsed,
|
||
test_timestamp=timestamp,
|
||
)
|
||
if _user_interrupted:
|
||
raise KeyboardInterrupt(_result)
|
||
return _result
|
||
|
||
else:
|
||
# ---------- Fastbot 模式(默认) ----------
|
||
controller = FastbotAgentController(
|
||
runner=self.runner,
|
||
wda_url=self.wda_url,
|
||
bundle_id=bundle_id,
|
||
app_name=app_name, # 传入真实应用名
|
||
check_interval=check_interval,
|
||
back_threshold=back_threshold,
|
||
agent_threshold=agent_threshold,
|
||
agent_timeout=agent_timeout,
|
||
pcap_csv_path=pcap_csv_path,
|
||
health_monitor=self.health_monitor, # 传入统一健康监控
|
||
)
|
||
|
||
stats = controller.run(
|
||
duration=duration,
|
||
throttle=throttle,
|
||
timeout=duration,
|
||
)
|
||
|
||
# ========== 构建结果 ==========
|
||
elapsed = time.time() - start_time
|
||
|
||
print(f"\n{'='*50}")
|
||
print(f" 测试完成")
|
||
print(f" 耗时: {elapsed:.1f}s")
|
||
print(f" Back 次数: {stats['back_count']}")
|
||
print(f" Agent 介入: {stats['agent_count']} 次")
|
||
print(f" Fastbot 重启: {stats['fastbot_restarts']} 次")
|
||
print(f"{'='*50}\n")
|
||
|
||
return TaskResult(
|
||
status="SUCCESS",
|
||
exit_code=EXIT_SUCCESS,
|
||
error_reason="",
|
||
droidbot_steps=0, # Fastbot 模式无 DroidBot 步数
|
||
guiagent_steps=stats['agent_count'],
|
||
total_steps=stats['back_count'] + stats['agent_count'],
|
||
duration_seconds=elapsed,
|
||
test_timestamp=timestamp,
|
||
)
|
||
|
||
except KeyboardInterrupt:
|
||
elapsed = time.time() - start_time
|
||
print(f"\n[INTERRUPTED] 用户中断测试 (耗时 {elapsed:.1f}s)")
|
||
return TaskResult(
|
||
status="INTERRUPTED",
|
||
exit_code=EXIT_ERROR_USER,
|
||
error_reason="用户中断",
|
||
droidbot_steps=0,
|
||
guiagent_steps=0,
|
||
total_steps=0,
|
||
duration_seconds=elapsed,
|
||
test_timestamp=timestamp,
|
||
)
|
||
|
||
except Exception as e:
|
||
elapsed = time.time() - start_time
|
||
import traceback
|
||
error_msg = str(e)
|
||
print(f"\n[ERROR] 测试异常: {error_msg}")
|
||
print(traceback.format_exc())
|
||
return TaskResult(
|
||
status="FAILED",
|
||
exit_code=EXIT_ERROR_GENERAL,
|
||
error_reason=error_msg,
|
||
droidbot_steps=0,
|
||
guiagent_steps=0,
|
||
total_steps=0,
|
||
duration_seconds=elapsed,
|
||
test_timestamp=timestamp,
|
||
)
|
||
|
||
finally:
|
||
# 停止抓包
|
||
stop_pcap(pcap_process)
|
||
|
||
# 恢复标准输出
|
||
sys.stdout = old_stdout
|
||
sys.stderr = old_stderr
|
||
tee.close()
|
||
|
||
def _find_pcap_csv(self, output_dir: str) -> Optional[str]:
|
||
"""查找 pcap 生成的 .flows.csv 文件"""
|
||
# pcap 工具生成的文件名格式: dump-{bundle_id}-{timestamp}.pcap.flows.csv
|
||
# 递归查找 traffic 子目录
|
||
traffic_dir = os.path.join(output_dir, "traffic")
|
||
search_dirs = [output_dir]
|
||
if os.path.exists(traffic_dir):
|
||
search_dirs.insert(0, traffic_dir)
|
||
|
||
for _ in range(2): # 尝试两次,中间等待
|
||
for d in search_dirs:
|
||
if not os.path.exists(d):
|
||
continue
|
||
try:
|
||
for fname in os.listdir(d):
|
||
if fname.endswith('.flows.csv'):
|
||
return os.path.join(d, fname)
|
||
except Exception:
|
||
pass
|
||
|
||
# 等待文件生成
|
||
time.sleep(5)
|
||
|
||
return None
|
||
|
||
|
||
# ============================================================
|
||
# 组件4: 命令行参数和主函数
|
||
# ============================================================
|
||
def parse_args():
|
||
"""解析命令行参数"""
|
||
parser = argparse.ArgumentParser(
|
||
description="iOS Fastbot + GuiAgent 混合测试脚本",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
示例:
|
||
# 单个应用测试
|
||
python ios_test.py -bundle_id com.apple.AppStore -duration 120
|
||
|
||
# 批量模式
|
||
python ios_test.py -batch -app_list output/ios/app_magic_summary_apple_no_google.csv -batch_count 5
|
||
|
||
# 自定义检测参数
|
||
python ios_test.py -bundle_id com.example.app -duration 600 \\
|
||
-stuck_check_interval 15 -back_threshold 8 -agent_threshold 20
|
||
"""
|
||
)
|
||
|
||
# 基本参数
|
||
parser.add_argument('-bundle_id', type=str, default='',
|
||
help='被测应用的 Bundle ID')
|
||
parser.add_argument('-wda_url', type=str,
|
||
default='00008101-0016601022C0001E',
|
||
help='WDA 服务地址或设备 UDID (默认: 00008101-0016601022C0001E 或 http://localhost:8100)')
|
||
parser.add_argument('-udid', type=str, default='00008101-0016601022C0001E',
|
||
help='设备 UDID')
|
||
parser.add_argument('-duration', type=int, default=5400,
|
||
help='测试时长/秒 (默认: 5400)')
|
||
parser.add_argument('-throttle', type=int, default=500,
|
||
help='Fastbot 操作间隔/毫秒 (默认: 500)')
|
||
|
||
# 登录相关
|
||
parser.add_argument('-enable_login', action='store_true',
|
||
help='启用 AppleID 登录处理')
|
||
|
||
# 批量模式
|
||
parser.add_argument('-batch', action='store_true',
|
||
help='启用批量测试模式')
|
||
parser.add_argument('-app_list', type=str,
|
||
default=str(ROOT / 'output' / 'ios' / 'app_magic_summary_apple_no_google.csv'),
|
||
help='应用列表 CSV 文件路径')
|
||
parser.add_argument('-batch_start', type=int, default=-1,
|
||
help='批量测试起始 index(对应 CSV 中的 0-based 行号)。'
|
||
'不指定(默认 -1)时启用智能调度:未测优先,SKIPPED/FAILED 放队尾')
|
||
parser.add_argument('-batch_count', type=int, default=0,
|
||
help='批量测试数量, 0=全部 (默认: 0)')
|
||
parser.add_argument('-resume_from', type=str, default='',
|
||
help='断点续传指定的 previous batch result CSV 路径')
|
||
|
||
# 页面卡住检测参数
|
||
parser.add_argument('-stuck_check_interval', type=int, default=20,
|
||
help='页面卡住检测间隔/秒 (默认: 20)')
|
||
parser.add_argument('-back_threshold', type=int, default=10,
|
||
help='发送 back 的检测次数阈值 (默认: 10)')
|
||
parser.add_argument('-agent_threshold', type=int, default=30,
|
||
help='调用 Agent 的检测次数阈值 (默认: 30)')
|
||
parser.add_argument('-agent_timeout', type=int, default=300,
|
||
help='Agent 单次处理超时/秒 (默认: 300)')
|
||
|
||
# 测试模式
|
||
parser.add_argument('-test_mode', type=str, default='droidbot',
|
||
choices=['fastbot', 'droidbot'],
|
||
help='核心测试模式: fastbot(默认)或 droidbot')
|
||
|
||
# DroidBot 模式专用参数
|
||
parser.add_argument('-policy', type=str, default='memory_guided',
|
||
help='[droidbot] 探索策略 (默认: memory_guided)')
|
||
parser.add_argument('-event_count', type=int, default=10000,
|
||
help='[droidbot] 最大事件数 (默认: 10000)')
|
||
parser.add_argument('-cv_mode', action='store_true', default=False,
|
||
help='[droidbot] 启用 CV 模式')
|
||
parser.add_argument('-debug_mode', action='store_true', default=False,
|
||
help='[droidbot] 启用调试模式')
|
||
parser.add_argument('-guiagent_flag', type=bool, default=True,
|
||
help='[droidbot] 是否启用 GuiAgent')
|
||
parser.add_argument('-random_input', action='store_true', default=False,
|
||
help='[droidbot] 引入随机输入')
|
||
|
||
# 输出
|
||
parser.add_argument('-output_dir', type=str,
|
||
default=str(ROOT / 'output' / 'ios_test'),
|
||
help='输出目录 (默认: output/ios_test)')
|
||
|
||
return parser.parse_args()
|
||
|
||
|
||
def _read_tested_history(output_dir: str) -> dict:
|
||
"""读取历史测试记录,返回 {index: (status, error_reason)} 字典
|
||
|
||
扫描 output_dir 目录下所有 ios_batch_result_*.csv 文件,
|
||
同一个 index 的最新记录(按文件名时间戳排序)为准。
|
||
"""
|
||
import glob
|
||
result_dir = str(Path(output_dir).parent) if not output_dir.endswith('output') else output_dir
|
||
# 查找所有 batch result CSV(兼容 output/ 目录的父目录)
|
||
patterns = [
|
||
os.path.join(result_dir, 'ios_batch_result_*.csv'),
|
||
os.path.join(os.path.dirname(result_dir), 'ios_batch_result_*.csv'),
|
||
os.path.join(str(ROOT / 'output'), 'ios_batch_result_*.csv'),
|
||
]
|
||
files = []
|
||
for pat in patterns:
|
||
files.extend(glob.glob(pat))
|
||
files = sorted(set(files)) # 去重,按文件名时间排序
|
||
|
||
history = {} # index -> (status, error_reason)
|
||
for fpath in files:
|
||
try:
|
||
with open(fpath, 'r', encoding='utf-8') as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
try:
|
||
idx = int(row['index'])
|
||
status = row.get('status', '').strip()
|
||
error_reason = row.get('error_reason', '').strip()
|
||
if status:
|
||
history[idx] = (status, error_reason)
|
||
except (ValueError, KeyError):
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return history
|
||
|
||
|
||
# 不应重试的永久性失败原因(应用本身问题,重试也无法成功)
|
||
PERMANENT_FAIL_REASONS = ["应用不可用", "付费应用"]
|
||
|
||
|
||
def _is_permanent_failure(status: str, error_reason: str) -> bool:
|
||
"""判断是否为永久性失败(不应重试)"""
|
||
if status == 'SUCCESS':
|
||
return True
|
||
return any(reason in error_reason for reason in PERMANENT_FAIL_REASONS)
|
||
|
||
|
||
def load_apps_smart_schedule(csv_path: str, batch_count: int = 0) -> list:
|
||
"""智能调度:未测应用优先,失败放队尾
|
||
|
||
Args:
|
||
csv_path: 应用列表 CSV
|
||
batch_count: 调度数量上限,0=全部
|
||
|
||
Returns:
|
||
排好序的 app_info 列表(每条含 index、app_id、name)
|
||
顺序:未测(按 CSV 顺序)→ SKIPPED/FAILED(按 CSV 顺序)
|
||
"""
|
||
output_dir = str(ROOT / 'output')
|
||
history = _read_tested_history(output_dir)
|
||
|
||
# 加载全量应用
|
||
all_apps = load_apps_from_csv(csv_path)
|
||
total = len(all_apps)
|
||
|
||
# 成功/永久性失败 不重测;未测 & 可重试失败 分开
|
||
untested = []
|
||
failed = []
|
||
permanent_fail_count = 0
|
||
for app in all_apps:
|
||
idx = app['index']
|
||
record = history.get(idx)
|
||
if record is None:
|
||
untested.append(app) # 未测
|
||
else:
|
||
status, error_reason = record
|
||
if _is_permanent_failure(status, error_reason):
|
||
if status != 'SUCCESS':
|
||
permanent_fail_count += 1
|
||
continue # 已成功或永久性失败,跳过
|
||
else:
|
||
failed.append(app) # SKIPPED / FAILED(可重试)
|
||
|
||
scheduled = untested + failed
|
||
tested_count = total - len(untested) - len(failed) - permanent_fail_count
|
||
print(f"[INFO] 历史记录:共 {len(history)} 条 | 已成功: {tested_count} | 永久失败: {permanent_fail_count} | 未测: {len(untested)} | 待重试: {len(failed)}")
|
||
|
||
if batch_count > 0:
|
||
scheduled = scheduled[:batch_count]
|
||
return scheduled
|
||
|
||
|
||
def run_batch_mode(args, runner, udid: str):
|
||
"""批量测试模式"""
|
||
# 初始化统计
|
||
stats = IOSBatchStatistics(output_dir=args.output_dir)
|
||
|
||
# 修改详细数据存储位置
|
||
if args.output_dir.endswith('ios_test') or args.output_dir.endswith('ios_test/'):
|
||
base_dir = os.path.dirname(args.output_dir.rstrip('/'))
|
||
args.output_dir = os.path.join(base_dir, f"ios_test_{stats.start_time.strftime('%Y%m%d_%H%M%S')}")
|
||
stats.output_dir = args.output_dir
|
||
|
||
# ----- 决定应用列表 -----
|
||
# 若指定了 batch_start,则从指定位置顺序加载(精确控制)
|
||
# 若未指定(默认 -1),则启用智能调度:未测优先,失败放队尾
|
||
use_smart_schedule = (args.batch_start < 0)
|
||
|
||
if use_smart_schedule:
|
||
print(f"\n{'='*60}")
|
||
print(f" iOS Fastbot+GuiAgent 批量测试模式(智能调度)")
|
||
print(f" CSV: {args.app_list}")
|
||
print(f" 模式: 未测优先,SKIPPED/FAILED 放队尾")
|
||
print(f"{'='*60}\n")
|
||
apps = load_apps_smart_schedule(args.app_list, batch_count=args.batch_count)
|
||
else:
|
||
print(f"\n{'='*60}")
|
||
print(f" iOS Fastbot+GuiAgent 批量测试模式")
|
||
print(f" CSV: {args.app_list}")
|
||
print(f" 起始 index: {args.batch_start} | 数量: {args.batch_count or '全部'}")
|
||
print(f"{'='*60}\n")
|
||
apps = load_apps_from_csv(
|
||
csv_path=args.app_list,
|
||
start_index=args.batch_start,
|
||
count=args.batch_count,
|
||
)
|
||
|
||
if not apps:
|
||
print("[ERROR] 未找到可测试的应用")
|
||
return
|
||
|
||
print(f"[INFO] 加载 {len(apps)} 个应用")
|
||
|
||
# 定期报告:每6小时发送一次采集情况
|
||
REPORT_INTERVAL = 6 * 3600 # 6小时(秒)
|
||
last_report_time = time.time()
|
||
batch_start_time = time.time()
|
||
|
||
# 初始化 WDA 健康监控(用于卡死检测与异步恢复)
|
||
from utils_ios.wda_health import WDAHealthMonitor
|
||
wda_health_monitor = WDAHealthMonitor(
|
||
wda_url=args.wda_url,
|
||
go_ios_runner=runner,
|
||
)
|
||
print("[INFO] WDA 健康监控已初始化")
|
||
|
||
# 测试执行器
|
||
test_runner = IOSFastbotTestRunner(
|
||
runner=runner,
|
||
wda_url=args.wda_url,
|
||
udid=udid,
|
||
output_base=args.output_dir,
|
||
health_monitor=wda_health_monitor, # 传入统一健康监控
|
||
)
|
||
|
||
# 遍历应用列表
|
||
for i, app_info in enumerate(apps):
|
||
app_id = app_info['app_id']
|
||
app_name = app_info.get('name', '')
|
||
index = app_info['index'] # CSV 中的 0-based 唯一行号
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" [index={index}] ({i+1}/{len(apps)}) {app_name or app_id}")
|
||
print(f"{'='*60}\n")
|
||
|
||
wda_health_monitor.reset_attempts()
|
||
|
||
# 安装前检查证书有效性(证书过期会导致 WDA/Fastbot 无法运行)
|
||
if not ensure_certs_valid(udid, runner, threshold_hours=24):
|
||
send_weCom_alert("证书更新异常,采集终止")
|
||
break
|
||
|
||
# 安装应用
|
||
bundle_id, reason = install_app_and_get_bundle_id(
|
||
app_id=app_id,
|
||
wda_url=args.wda_url,
|
||
udid=udid,
|
||
health_monitor=wda_health_monitor
|
||
)
|
||
|
||
# 安装完成后先使用go-ios ps -apps获取运行应用信息(json),过滤Name为WebDriverAgentRunner-Runner的应用,其他应用关闭
|
||
try:
|
||
import json
|
||
import subprocess
|
||
print("[INFO] 开始清理后台常驻应用...")
|
||
go_ios_bin = str(ROOT / "utils_ios" / "go_ios" / "bin" / "ios-darwin-arm64")
|
||
if not os.path.exists(go_ios_bin):
|
||
go_ios_bin = "ios"
|
||
|
||
ps_cmd = [go_ios_bin, "ps", "--apps", "--udid", udid]
|
||
ps_result = subprocess.run(ps_cmd, capture_output=True, text=True, timeout=15)
|
||
if ps_result.returncode == 0:
|
||
apps_info = json.loads(ps_result.stdout)
|
||
for app in apps_info:
|
||
name = app.get("Name", "")
|
||
pid = app.get("Pid")
|
||
if name and name != "WebDriverAgentRunner-Runner" and pid:
|
||
kill_cmd = [go_ios_bin, "kill", f"--pid={pid}", "--udid", udid]
|
||
subprocess.run(kill_cmd, capture_output=True, timeout=5)
|
||
print(f"[INFO] 已关闭后台应用: {name} (PID: {pid})")
|
||
except Exception as e:
|
||
print(f"[WARN] 清理后台应用异常: {e}")
|
||
|
||
# 安装失败处理(WDA 断联已在底层安装过程中自动处理+等待恢复)
|
||
# 到达此处的失败均为:付费应用、应用不可用、安装超时等真实业务失败
|
||
if not bundle_id:
|
||
csv_app_id_fail = app_id if not app_id.isdigit() else f"id{app_id}"
|
||
# 永久性失败(应用不可用、付费应用)记录为 FAILED,避免后续重试
|
||
if any(r in (reason or "") for r in PERMANENT_FAIL_REASONS):
|
||
print(f"[FAIL] 应用永久性失败: {csv_app_id_fail} - {reason}")
|
||
stats.add_fail_record(index, app_name, app_name, csv_app_id_fail, reason)
|
||
else:
|
||
print(f"[SKIP] 应用安装失败: {csv_app_id_fail} - {reason}")
|
||
stats.add_skip_record(index, app_name, app_name, csv_app_id_fail, reason or "安装失败")
|
||
stats.save_csv()
|
||
continue
|
||
|
||
# 从 appstore_mapping.csv 读取真实应用名(模型更容易识别)
|
||
csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv")
|
||
csv_app_id = app_id if not app_id.isdigit() else f"id{app_id}"
|
||
csv_info = get_app_info_from_csv(csv_app_id, csv_file)
|
||
real_app_name = csv_info.get('app_name', '') or app_name
|
||
raw_app_name = app_name # 原始 app_name
|
||
if real_app_name and real_app_name != app_name:
|
||
print(f"[INFO] 使用真实应用名: {real_app_name} (原始: {app_name or bundle_id})")
|
||
app_name = real_app_name
|
||
|
||
# 执行测试
|
||
result = test_runner.run_test(
|
||
bundle_id=bundle_id,
|
||
app_id=csv_app_id,
|
||
app_name=app_name,
|
||
raw_app_name=raw_app_name,
|
||
duration=args.duration,
|
||
throttle=args.throttle,
|
||
enable_login=args.enable_login,
|
||
check_interval=args.stuck_check_interval,
|
||
back_threshold=args.back_threshold,
|
||
agent_threshold=args.agent_threshold,
|
||
agent_timeout=args.agent_timeout,
|
||
test_mode=args.test_mode,
|
||
policy=args.policy,
|
||
event_count=args.event_count,
|
||
cv_mode=args.cv_mode,
|
||
debug_mode=args.debug_mode,
|
||
guiagent_flag=args.guiagent_flag,
|
||
random_input=args.random_input,
|
||
)
|
||
|
||
# 记录结果(手动中断时不写入 CSV,直接终止批量循环)
|
||
if result.status == "INTERRUPTED":
|
||
print(f"[WARN] 用户手动中断,跳过 CSV 写入,终止批量测试")
|
||
break
|
||
|
||
stats.add_record(index, app_name, raw_app_name, csv_app_id, bundle_id, result)
|
||
stats.save_csv()
|
||
|
||
# 测试成功后重置 WDA 恢复计数
|
||
if result.status == "SUCCESS":
|
||
wda_health_monitor.reset_recover_count()
|
||
|
||
print(f"[RESULT] {app_name}: {result.status} "
|
||
f"(耗时 {result.duration_seconds:.1f}s)")
|
||
|
||
# 测试完成后卸载应用(不论成功/失败)
|
||
try:
|
||
go_ios_bin = str(ROOT / "utils_ios" / "go_ios" / "bin" / "ios-darwin-arm64")
|
||
uninstall_cmd = [go_ios_bin, "uninstall", bundle_id, "--udid", udid]
|
||
subprocess.run(uninstall_cmd, timeout=30, capture_output=True)
|
||
print(f"[INFO] 已卸载应用: {bundle_id}")
|
||
except Exception as e:
|
||
print(f"[WARN] 卸载应用失败: {bundle_id} - {e}")
|
||
|
||
# 每6小时发送采集情况报告
|
||
if time.time() - last_report_time >= REPORT_INTERVAL:
|
||
total = len(stats.records)
|
||
success = sum(1 for r in stats.records if r["status"] == "SUCCESS")
|
||
failed = sum(1 for r in stats.records if r["status"] == "FAILED")
|
||
skipped = sum(1 for r in stats.records if r["status"] == "SKIPPED")
|
||
progress = (i + 1) / len(apps) * 100
|
||
report_msg = (
|
||
f" iOS 采集情况报告\n"
|
||
f"━━━━━━━━━━━━━━━━\n"
|
||
f" 进度: {i + 1}/{len(apps)} ({progress:.1f}%)\n"
|
||
f" 成功: {success}\n"
|
||
f" 失败: {failed}\n"
|
||
f" 跳过: {skipped}\n"
|
||
f"━━━━━━━━━━━━━━━━"
|
||
)
|
||
try:
|
||
send_weCom_alert(report_msg)
|
||
print(f"[INFO] 已发送6小时采集情况报告")
|
||
except Exception as e:
|
||
print(f"[WARN] 发送采集报告失败: {e}")
|
||
last_report_time = time.time()
|
||
|
||
# 打印摘要
|
||
stats.print_summary()
|
||
|
||
|
||
def main():
|
||
"""主入口"""
|
||
# 加载环境变量
|
||
load_env_variables()
|
||
|
||
# 解析参数
|
||
args = parse_args()
|
||
|
||
# 启动基础设施
|
||
runner, udid = setup_infrastructure(args)
|
||
|
||
if args.batch:
|
||
# 批量模式
|
||
run_batch_mode(args, runner, udid)
|
||
else:
|
||
# 单个应用模式
|
||
if not args.bundle_id:
|
||
print("[ERROR] 单个模式需要指定 -bundle_id")
|
||
sys.exit(EXIT_ERROR_USER)
|
||
|
||
# 安装应用(如果是 app_id 格式)
|
||
bundle_id = args.bundle_id
|
||
if bundle_id.isdigit() or bundle_id.startswith('id'):
|
||
from utils_ios.wda_health import WDAHealthMonitor
|
||
wda_health_monitor = WDAHealthMonitor(
|
||
wda_url=args.wda_url,
|
||
go_ios_runner=runner,
|
||
)
|
||
installed_bundle_id, reason = install_app_and_get_bundle_id(
|
||
app_id=bundle_id,
|
||
wda_url=args.wda_url,
|
||
udid=udid,
|
||
health_monitor=wda_health_monitor
|
||
)
|
||
if installed_bundle_id:
|
||
bundle_id = installed_bundle_id
|
||
else:
|
||
print(f"[ERROR] 应用安装失败: {bundle_id} - {reason}")
|
||
sys.exit(EXIT_FASTBOT_ERROR)
|
||
|
||
# 执行测试
|
||
test_runner = IOSFastbotTestRunner(
|
||
runner=runner,
|
||
wda_url=args.wda_url,
|
||
udid=udid,
|
||
output_base=args.output_dir,
|
||
)
|
||
|
||
result = test_runner.run_test(
|
||
bundle_id=bundle_id,
|
||
duration=args.duration,
|
||
throttle=args.throttle,
|
||
enable_login=args.enable_login,
|
||
check_interval=args.stuck_check_interval,
|
||
back_threshold=args.back_threshold,
|
||
agent_threshold=args.agent_threshold,
|
||
agent_timeout=args.agent_timeout,
|
||
test_mode=args.test_mode,
|
||
policy=args.policy,
|
||
event_count=args.event_count,
|
||
cv_mode=args.cv_mode,
|
||
debug_mode=args.debug_mode,
|
||
guiagent_flag=args.guiagent_flag,
|
||
random_input=args.random_input,
|
||
)
|
||
|
||
print(f"\n[FINAL] 状态: {result.status} | 退出码: {result.exit_code}")
|
||
sys.exit(result.exit_code)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|