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

1720 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
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: 格式用 ClientUDID/空用 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: 格式用 ClientUDID/空用 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_dirTrafficMonitor 会从 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()