1412 lines
53 KiB
Python
1412 lines
53 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
iOS 自动化测试启动脚本
|
||
|
||
与 start.py 不同,此脚本直接通过 bundle_id 参数指定被测应用,
|
||
无需通过列表选择。适用于 iOS 自动化测试场景。
|
||
|
||
使用示例:
|
||
python start_ios.py -bundle_id com.example.app
|
||
python start_ios.py -bundle_id com.example.app -wda_url http://localhost:8100
|
||
python start_ios.py -bundle_id com.example.app -timeout 1800 -policy memory_guided
|
||
"""
|
||
|
||
import os
|
||
import argparse
|
||
import csv
|
||
import sys
|
||
import time
|
||
import subprocess
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from dataclasses import dataclass, asdict
|
||
from typing import Optional, List, Dict, Tuple
|
||
|
||
# 找到项目根目录
|
||
ROOT = Path(__file__).resolve().parent
|
||
# 确保根目录在Python路径中
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
# 导入统一的日志配置模块
|
||
from logging_config import setup_logging, get_logger
|
||
|
||
# 导入 DroidBot
|
||
from DroidBot.droidbot import DroidBot
|
||
from DroidBot.guiagent_core.decision_maker import GuiAgentDecisionMaker
|
||
|
||
# 导入异常类
|
||
try:
|
||
from DroidBot.exceptions import FATAL_EXCEPTIONS, WDA_RECOVERABLE_EXCEPTIONS
|
||
from DroidBot.platforms.ios.wda.exceptions import WDAStuckError
|
||
# WDA_EXCEPTIONS 用于 run_test 中单独捕获 WDA 连接异常
|
||
WDA_EXCEPTIONS = WDA_RECOVERABLE_EXCEPTIONS + (WDAStuckError,)
|
||
except ImportError:
|
||
FATAL_EXCEPTIONS = (KeyboardInterrupt, SystemExit)
|
||
WDA_EXCEPTIONS = ()
|
||
WDAStuckError = None
|
||
|
||
|
||
def load_env_variables():
|
||
"""从 .env 文件加载环境变量到系统环境中"""
|
||
env_file = ROOT / ".env"
|
||
if not env_file.exists():
|
||
print(f"[WARN] .env 文件不存在: {env_file}")
|
||
return
|
||
|
||
try:
|
||
with open(env_file, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
# 跳过空行和注释
|
||
if not line or line.startswith('#'):
|
||
continue
|
||
# 解析 KEY=VALUE 格式
|
||
if '=' in line:
|
||
key, value = line.split('=', 1)
|
||
key = key.strip()
|
||
value = value.strip()
|
||
# 设置到环境变量
|
||
os.environ[key] = value
|
||
print(f"[INFO] 已从 .env 加载环境变量")
|
||
except Exception as e:
|
||
print(f"[WARN] 加载 .env 文件失败: {e}")
|
||
|
||
|
||
def setup_iphone_proxy():
|
||
"""调用 fix_proxy.sh 脚本为 iPhone 设置代理网络"""
|
||
import subprocess
|
||
|
||
# 确保已加载环境变量
|
||
if 'SUDO' not in os.environ:
|
||
print("[WARN] SUDO 环境变量未设置,尝试从 .env 加载...")
|
||
load_env_variables()
|
||
|
||
# fix_proxy.sh 脚本路径
|
||
script_path = ROOT / "utils_ios" / "scripts" / "fix_proxy.sh"
|
||
|
||
if not script_path.exists():
|
||
print(f"[ERROR] fix_proxy.sh 脚本不存在: {script_path}")
|
||
return False
|
||
|
||
print(f"\n{'='*50}")
|
||
print("[INFO] 正在为 iPhone 设置代理网络...")
|
||
print(f"{'='*50}\n")
|
||
|
||
try:
|
||
# 执行脚本,传递环境变量
|
||
result = subprocess.run(
|
||
['bash', str(script_path)],
|
||
env=os.environ.copy(), # 传递包含 SUDO 的环境变量
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=30
|
||
)
|
||
|
||
# 打印输出
|
||
if result.stdout:
|
||
print(result.stdout)
|
||
if result.stderr:
|
||
print(result.stderr, file=sys.stderr)
|
||
|
||
if result.returncode == 0:
|
||
print("[SUCCESS] iPhone 代理设置完成\n")
|
||
return True
|
||
else:
|
||
print(f"[ERROR] 代理设置失败,退出码: {result.returncode}\n")
|
||
return False
|
||
except subprocess.TimeoutExpired:
|
||
print("[ERROR] 代理设置超时\n")
|
||
return False
|
||
except Exception as e:
|
||
print(f"[ERROR] 执行 fix_proxy.sh 失败: {e}\n")
|
||
return False
|
||
|
||
|
||
# 状态码定义 (符合 Linux 惯例)
|
||
EXIT_SUCCESS = 0
|
||
EXIT_ERROR_USER = 1
|
||
EXIT_WDA_ERROR = 2
|
||
EXIT_WDA_STUCK = 6 # WDA 卡死且多次恢复失败
|
||
EXIT_NETWORK_ERROR = 3
|
||
EXIT_ERROR_GENERAL = 5
|
||
|
||
|
||
@dataclass
|
||
class TaskResult:
|
||
"""单次测试任务的执行结果"""
|
||
status: str # "SUCCESS" | "FAILED" | "INTERRUPTED"
|
||
exit_code: int # 退出码
|
||
error_reason: str # 错误原因(成功时为空字符串)
|
||
droidbot_steps: int # DroidBot 执行步数
|
||
guiagent_steps: int # GuiAgent 执行步数
|
||
total_steps: int # 总步数
|
||
duration_seconds: float # 执行耗时(秒)
|
||
test_timestamp: str = "" # 测试目录名中的时间戳(%Y%m%d_%H%M%S),与输出目录名对应
|
||
|
||
|
||
class IOSBatchStatistics:
|
||
"""iOS 批量测试统计管理器"""
|
||
|
||
def __init__(self, output_dir: str):
|
||
self.output_dir = output_dir
|
||
self.start_time = datetime.now()
|
||
self.records = [] # 存储所有测试记录
|
||
self.csv_filepath = None
|
||
|
||
def add_record(self, index: int, app_name: str, raw_app_name: str, app_id: str,
|
||
bundle_id: str, result: TaskResult):
|
||
"""添加一条测试记录"""
|
||
# 优先使用目录名中的时间戳,保持与输出目录一一对应;格式转换 %Y%m%d_%H%M%S -> %Y-%m-%d %H:%M:%S
|
||
if result.test_timestamp:
|
||
timestamp_str = result.test_timestamp
|
||
else:
|
||
timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
self.records.append({
|
||
"index": index,
|
||
"app_name": app_name,
|
||
"raw_app_name": raw_app_name,
|
||
"app_id": app_id,
|
||
"bundle_id": bundle_id,
|
||
"status": result.status,
|
||
"exit_code": result.exit_code,
|
||
"error_reason": result.error_reason,
|
||
"droidbot_steps": result.droidbot_steps,
|
||
"guiagent_steps": result.guiagent_steps,
|
||
"total_steps": result.total_steps,
|
||
"duration_seconds": round(result.duration_seconds, 2),
|
||
"timestamp": timestamp_str
|
||
})
|
||
|
||
def add_skip_record(self, index: int, app_name: str, raw_app_name: str, app_id: str, reason: str):
|
||
"""添加一条跳过记录(安装失败等)"""
|
||
self.records.append({
|
||
"index": index,
|
||
"app_name": app_name,
|
||
"raw_app_name": raw_app_name,
|
||
"app_id": app_id,
|
||
"bundle_id": "",
|
||
"status": "SKIPPED",
|
||
"exit_code": -1,
|
||
"error_reason": reason,
|
||
"droidbot_steps": 0,
|
||
"guiagent_steps": 0,
|
||
"total_steps": 0,
|
||
"duration_seconds": 0,
|
||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
})
|
||
|
||
def add_fail_record(self, index: int, app_name: str, raw_app_name: str, app_id: str, reason: str):
|
||
"""添加一条失败记录(应用不可用、付费应用等永久性失败,不应重试)"""
|
||
self.records.append({
|
||
"index": index,
|
||
"app_name": app_name,
|
||
"raw_app_name": raw_app_name,
|
||
"app_id": app_id,
|
||
"bundle_id": "",
|
||
"status": "FAILED",
|
||
"exit_code": -1,
|
||
"error_reason": reason,
|
||
"droidbot_steps": 0,
|
||
"guiagent_steps": 0,
|
||
"total_steps": 0,
|
||
"duration_seconds": 0,
|
||
"timestamp": datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
})
|
||
|
||
def save_csv(self):
|
||
"""保存统计结果到 CSV 文件(每次调用覆盖写入)"""
|
||
os.makedirs(self.output_dir, exist_ok=True)
|
||
filename = f"ios_batch_result_{self.start_time.strftime('%Y%m%d_%H%M%S')}.csv"
|
||
filepath = os.path.join(str(Path(self.output_dir).parent), filename)
|
||
|
||
fieldnames = ["index", "app_name", "raw_app_name", "app_id", "bundle_id", "status",
|
||
"exit_code", "error_reason", "droidbot_steps",
|
||
"guiagent_steps", "total_steps", "duration_seconds",
|
||
"timestamp"]
|
||
|
||
with open(filepath, 'w', newline='', encoding='utf-8') as f:
|
||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||
writer.writeheader()
|
||
writer.writerows(self.records)
|
||
|
||
self.csv_filepath = filepath
|
||
print(f"[INFO] 批量统计结果已保存到: {filepath}")
|
||
return filepath
|
||
|
||
def print_summary(self):
|
||
"""打印批量测试摘要"""
|
||
total = len(self.records)
|
||
success = sum(1 for r in self.records if r["status"] == "SUCCESS")
|
||
failed = sum(1 for r in self.records if r["status"] == "FAILED")
|
||
skipped = sum(1 for r in self.records if r["status"] == "SKIPPED")
|
||
interrupted = sum(1 for r in self.records if r["status"] == "INTERRUPTED")
|
||
total_duration = sum(r["duration_seconds"] for r in self.records)
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f" iOS 批量测试摘要")
|
||
print(f"{'='*60}")
|
||
print(f" 总数: {total}")
|
||
print(f" 成功: {success} | 失败: {failed} | 跳过: {skipped} | 中断: {interrupted}")
|
||
print(f" 总耗时: {total_duration:.1f} 秒 ({total_duration/3600:.1f} 小时)")
|
||
if self.csv_filepath:
|
||
print(f" 结果文件: {self.csv_filepath}")
|
||
print(f"{'='*60}\n")
|
||
|
||
|
||
class TeeOutput:
|
||
"""将输出同时写入控制台和文件,包括标准输出、标准错误和所有logging输出"""
|
||
def __init__(self, file_path):
|
||
self.file_path = file_path
|
||
self.file = open(file_path, 'w', encoding='utf-8', buffering=1) # 行缓冲
|
||
self.stdout = sys.stdout
|
||
self.stderr = sys.stderr
|
||
self.log_handler = None
|
||
|
||
def write(self, message):
|
||
try:
|
||
self.stdout.write(message)
|
||
self.file.write(message)
|
||
except Exception:
|
||
pass # 忽略写入错误,防止程序崩溃
|
||
|
||
def flush(self):
|
||
try:
|
||
self.stdout.flush()
|
||
self.file.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
def fileno(self):
|
||
"""返回文件描述符,使某些库兼容"""
|
||
return self.stdout.fileno()
|
||
|
||
def isatty(self):
|
||
"""检查是否为终端"""
|
||
return self.stdout.isatty()
|
||
|
||
def setup_logging(self, debug_mode: bool = False):
|
||
"""设置根日志记录器,捕获所有logging模块输出到文件和终端"""
|
||
import logging
|
||
root_logger = logging.getLogger()
|
||
# debug_mode 时将根 logger 降到 DEBUG,否则保持 INFO
|
||
root_level = logging.DEBUG if debug_mode else logging.INFO
|
||
root_logger.setLevel(root_level)
|
||
|
||
# 文件处理器:始终捕获所有级别
|
||
self.log_handler = logging.FileHandler(self.file_path, mode='a', encoding='utf-8')
|
||
self.log_handler.setLevel(logging.DEBUG)
|
||
file_formatter = logging.Formatter('%(asctime)s %(name)-12s %(levelname)-8s %(message)s')
|
||
self.log_handler.setFormatter(file_formatter)
|
||
root_logger.addHandler(self.log_handler)
|
||
|
||
# 终端处理器:debug_mode 时输出 DEBUG,否则只输出 INFO 及以上(避免重复添加)
|
||
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler)
|
||
for h in root_logger.handlers):
|
||
stream_handler = logging.StreamHandler()
|
||
stream_handler.setLevel(logging.DEBUG if debug_mode else logging.INFO)
|
||
stream_formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s',
|
||
datefmt='%H:%M:%S')
|
||
stream_handler.setFormatter(stream_formatter)
|
||
root_logger.addHandler(stream_handler)
|
||
|
||
# 抑制第三方库的 DEBUG 日志(即使 debug_mode 开启也不输出)
|
||
from logging_config import THIRD_PARTY_LOGGERS
|
||
for logger_name in THIRD_PARTY_LOGGERS:
|
||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||
|
||
def close(self):
|
||
"""清理资源"""
|
||
import logging
|
||
if self.log_handler:
|
||
logging.getLogger().removeHandler(self.log_handler)
|
||
self.log_handler.close()
|
||
self.log_handler = None
|
||
if self.file and not self.file.closed:
|
||
self.file.close()
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||
self.close()
|
||
return False
|
||
|
||
|
||
class IOSTestConfig:
|
||
"""iOS 测试配置参数"""
|
||
|
||
# --- 默认配置 ---
|
||
DEFAULT_WDA_URL: str = "http://localhost:8100"
|
||
DEFAULT_POLICY: str = "memory_guided"
|
||
DEFAULT_TIMEOUT: int = 3600
|
||
DEFAULT_EVENT_INTERVAL: int = 1
|
||
DEFAULT_EVENT_COUNT: int = 10000
|
||
|
||
# --- 输出配置 ---
|
||
OUTPUT_BASE_DIR: str = "./output/ios_test"
|
||
|
||
# --- 日志配置 ---
|
||
LOG_LEVEL: str = "DEBUG"
|
||
|
||
|
||
class IOSTestRunner:
|
||
"""iOS 测试执行器"""
|
||
|
||
def __init__(self, config: IOSTestConfig):
|
||
self.config = config
|
||
|
||
def run_test(self, bundle_id: str, wda_url: str, policy: str,
|
||
timeout: int, event_interval: int, event_count: int,
|
||
cv_mode: bool, debug_mode: bool, enable_guiagent: bool,
|
||
keep_app: bool, random_input: bool,
|
||
output_dir: str = None, model_name: str = "iOS",
|
||
wda_health_monitor=None) -> TaskResult:
|
||
"""执行 iOS 测试任务,返回 TaskResult"""
|
||
print(f"\n{'='*50}")
|
||
print(f"[INFO] 准备测试 iOS 应用: {bundle_id}")
|
||
print(f"[INFO] WDA URL: {wda_url}")
|
||
print(f"[INFO] 测试策略: {policy}")
|
||
print(f"[INFO] 超时时间: {timeout} 秒")
|
||
|
||
start_time = time.time()
|
||
ret_code = EXIT_ERROR_GENERAL
|
||
status = "FAILED"
|
||
error_reason = ""
|
||
|
||
# 重置全局步数计数器,确保每轮任务独立统计
|
||
GuiAgentDecisionMaker.total_steps = 0
|
||
|
||
# 初始化步数统计,确保 finally 块中能访问到
|
||
droidbot_steps = 0
|
||
guiagent_steps = 0
|
||
total_steps = 0
|
||
user_interrupted = False # 追踪用户中断,用于向上传递
|
||
|
||
# 生成带时间戳的输出目录
|
||
if output_dir is None:
|
||
# 格式: ./output/BundleId_ModelName_YYYYMMDD_HHMMSS
|
||
# 将 bundle_id 中的点替换为下划线
|
||
sanitized_bundle_id = bundle_id.replace('.', '_').replace(' ', '_')
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
folder_name = f"{sanitized_bundle_id}_{model_name}_{timestamp}"
|
||
output_dir = os.path.join(self.config.OUTPUT_BASE_DIR, folder_name)
|
||
|
||
# 将日志目录写入 .env 文件(使用全局独立的 agent_logs 目录)
|
||
agent_log_dir = os.path.join(ROOT, "output", "agent_logs")
|
||
env_path = os.path.join(ROOT, "GuiAgent", ".env")
|
||
if os.path.exists(env_path):
|
||
with open(env_path, "r", encoding="utf-8") as f:
|
||
lines = f.readlines()
|
||
|
||
with open(env_path, "w", encoding="utf-8") as f:
|
||
found = False
|
||
for line in lines:
|
||
if line.startswith("AGENT_LOG_DIR="):
|
||
f.write(f"AGENT_LOG_DIR={agent_log_dir}\n")
|
||
found = True
|
||
else:
|
||
f.write(line)
|
||
if not found:
|
||
f.write(f"AGENT_LOG_DIR={agent_log_dir}\n")
|
||
else:
|
||
os.makedirs(os.path.dirname(env_path), exist_ok=True)
|
||
with open(env_path, "w", encoding="utf-8") as f:
|
||
f.write(f"AGENT_LOG_DIR={agent_log_dir}\n")
|
||
|
||
# 确保目录存在
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
print(f"[INFO] 输出目录已创建: {output_dir}")
|
||
|
||
cmd_log_path = os.path.join(output_dir, "cmd_output.txt")
|
||
|
||
# ====== 配置 logging ======
|
||
# 使用统一的日志配置模块
|
||
import logging
|
||
|
||
setup_logging(
|
||
level=logging.DEBUG if debug_mode else logging.INFO,
|
||
log_file=None, # 日志文件通过TeeOutput处理
|
||
debug_mode=debug_mode
|
||
)
|
||
|
||
# 确保所有相关模块都使用正确的日志等级
|
||
logging_level = logging.DEBUG if debug_mode else logging.INFO
|
||
for logger_name in ['DroidBot', 'IOSDevice', 'InputEventManager', 'MemoryGuidedPolicy']:
|
||
logging.getLogger(logger_name).setLevel(logging_level)
|
||
|
||
if debug_mode:
|
||
print(f"[INFO] Debug 模式已启用,DroidBot 相关日志等级设置为 DEBUG,第三方库设置为 WARNING")
|
||
# ====== logging 配置完成 ======
|
||
|
||
# 直接调用 DroidBot(支持断点调试)
|
||
print(f"[INFO] 启动 DroidBot(函数调用模式,支持断点调试)")
|
||
print(f"{'='*50}\n")
|
||
|
||
# 设置输出重定向
|
||
tee = TeeOutput(cmd_log_path)
|
||
old_stdout = sys.stdout
|
||
old_stderr = sys.stderr
|
||
sys.stdout = tee
|
||
sys.stderr = tee
|
||
tee.setup_logging(debug_mode=debug_mode) # 设置日志处理器,捕获所有logging模块输出
|
||
|
||
try:
|
||
droidbot = DroidBot(
|
||
package_name=bundle_id,
|
||
device_serial=None, # iOS 不需要
|
||
is_emulator=False,
|
||
output_dir=output_dir,
|
||
policy_name=policy,
|
||
random_input=random_input,
|
||
event_interval=event_interval,
|
||
timeout=timeout,
|
||
event_count=event_count,
|
||
cv_mode=cv_mode,
|
||
debug_mode=debug_mode,
|
||
keep_app=keep_app,
|
||
keep_env=False,
|
||
profiling_method=None,
|
||
grant_perm=False,
|
||
enable_accessibility_hard=False,
|
||
humanoid=None,
|
||
ignore_ad=False,
|
||
replay_output=None,
|
||
enable_guiagent=enable_guiagent,
|
||
platform="ios",
|
||
wda_url=wda_url,
|
||
)
|
||
|
||
# 注入 WDA 健康监控(如果提供)
|
||
if wda_health_monitor and hasattr(droidbot, 'device'):
|
||
droidbot.device.set_health_monitor(wda_health_monitor)
|
||
|
||
droidbot.start()
|
||
ret_code = EXIT_SUCCESS
|
||
status = "SUCCESS"
|
||
error_reason = ""
|
||
|
||
# 统计步数
|
||
droidbot_steps = droidbot.input_manager.total_exploring_steps
|
||
guiagent_steps = GuiAgentDecisionMaker.total_steps
|
||
total_steps = droidbot_steps + guiagent_steps
|
||
|
||
print(f"\n[SUCCESS] iOS 测试任务完成: {bundle_id}")
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"\n[WARN] 用户手动中断测试: {bundle_id}")
|
||
ret_code = EXIT_ERROR_USER
|
||
status = "INTERRUPTED"
|
||
error_reason = "用户手动中断"
|
||
user_interrupted = True # 标记需要向上传递中断
|
||
except WDA_EXCEPTIONS as e:
|
||
import traceback
|
||
# 区分 WDAStuckError(多次恢复失败)和普通 WDA 异常
|
||
if WDAStuckError and isinstance(e, WDAStuckError):
|
||
print(f"\n[ERROR] WDA 卡死且恢复失败: {e}")
|
||
print(traceback.format_exc())
|
||
ret_code = EXIT_WDA_STUCK
|
||
status = "FAILED"
|
||
error_reason = f"WDA 卡死恢复失败: {e}"
|
||
else:
|
||
print(f"\n[ERROR] WDA 连接异常: {e}")
|
||
print(traceback.format_exc())
|
||
ret_code = EXIT_WDA_ERROR
|
||
status = "FAILED"
|
||
error_reason = f"WDA 连接异常: {e}"
|
||
except Exception as e:
|
||
import traceback
|
||
print(f"\n[ERROR] DroidBot 执行异常: {e}")
|
||
print(traceback.format_exc())
|
||
ret_code = EXIT_ERROR_GENERAL
|
||
status = "FAILED"
|
||
error_reason = f"执行异常: {e}"
|
||
finally:
|
||
# 尝试获取步数(即使异常也尽量获取)
|
||
try:
|
||
guiagent_steps = GuiAgentDecisionMaker.total_steps
|
||
except Exception:
|
||
pass
|
||
total_steps = droidbot_steps + guiagent_steps
|
||
|
||
print(f"[INFO] DroidBot 步数: {droidbot_steps}")
|
||
print(f"[INFO] GUI Agent 步数: {guiagent_steps}")
|
||
print(f"[INFO] 总步数: {total_steps}")
|
||
|
||
# 恢复原来的stdout/stderr
|
||
sys.stdout = old_stdout
|
||
sys.stderr = old_stderr
|
||
tee.close()
|
||
|
||
end_time = time.time()
|
||
duration = end_time - start_time
|
||
hours, rem = divmod(duration, 3600)
|
||
minutes, seconds = divmod(rem, 60)
|
||
time_str = f"{int(hours)}小时 {int(minutes)}分 {int(seconds)}秒"
|
||
print(f"[INFO] 任务耗时: {time_str}\n")
|
||
|
||
if os.path.exists(output_dir):
|
||
try:
|
||
time_file_path = os.path.join(output_dir, "time_cost.txt")
|
||
with open(time_file_path, "w", encoding="utf-8") as f:
|
||
f.write(f"Test Duration: {time_str}\n")
|
||
f.write(f"Start Time: {datetime.fromtimestamp(start_time).strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||
f.write(f"End Time: {datetime.fromtimestamp(end_time).strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||
except Exception as e:
|
||
print(f"[ERROR] 无法记录耗时文件: {e}")
|
||
|
||
result = TaskResult(
|
||
status=status,
|
||
exit_code=ret_code,
|
||
error_reason=error_reason,
|
||
droidbot_steps=droidbot_steps,
|
||
guiagent_steps=guiagent_steps,
|
||
total_steps=total_steps,
|
||
duration_seconds=duration
|
||
)
|
||
|
||
# 如果是用户中断,返回结果后重新抛出异常,让上层能够捕获并退出
|
||
if user_interrupted:
|
||
raise KeyboardInterrupt(result)
|
||
|
||
return result
|
||
|
||
|
||
def parse_args():
|
||
"""解析命令行参数"""
|
||
parser = argparse.ArgumentParser(
|
||
description="iOS 自动化测试启动脚本",
|
||
formatter_class=argparse.RawTextHelpFormatter
|
||
)
|
||
|
||
# 必需参数
|
||
parser.add_argument("-bundle_id", action="store", dest="bundle_id",
|
||
default="com.apple.AppStore",
|
||
help="被测 iOS 应用的 Bundle ID (默认: com.apple.AppStore)")
|
||
|
||
# WDA 配置
|
||
parser.add_argument("-wda_url", action="store", dest="wda_url",
|
||
default=IOSTestConfig.DEFAULT_WDA_URL,
|
||
help=f"WDA Server URL (默认: {IOSTestConfig.DEFAULT_WDA_URL})")
|
||
|
||
# 测试配置
|
||
parser.add_argument("-policy", action="store", dest="policy",
|
||
default=IOSTestConfig.DEFAULT_POLICY,
|
||
help=f"测试策略 (默认: {IOSTestConfig.DEFAULT_POLICY})\n"
|
||
"支持的策略:\n"
|
||
" \"none\" -- 不发送事件,用户手动操作\n"
|
||
" \"memory_guided\" -- 内存引导的探索策略\n"
|
||
" \"manual\" -- 手动控制设备")
|
||
|
||
parser.add_argument("-timeout", action="store", dest="timeout", type=int,
|
||
default=IOSTestConfig.DEFAULT_TIMEOUT,
|
||
help=f"超时时间(秒)(默认: {IOSTestConfig.DEFAULT_TIMEOUT})")
|
||
|
||
parser.add_argument("-interval", action="store", dest="interval", type=int,
|
||
default=IOSTestConfig.DEFAULT_EVENT_INTERVAL,
|
||
help=f"事件间隔时间(秒)(默认: {IOSTestConfig.DEFAULT_EVENT_INTERVAL})")
|
||
|
||
parser.add_argument("-count", action="store", dest="count", type=int,
|
||
default=IOSTestConfig.DEFAULT_EVENT_COUNT,
|
||
help=f"事件总数 (默认: {IOSTestConfig.DEFAULT_EVENT_COUNT})")
|
||
|
||
# 输出配置
|
||
parser.add_argument("-o", action="store", dest="output_dir",
|
||
help="输出目录 (默认: 自动生成带时间戳的目录)")
|
||
|
||
# 模式开关
|
||
parser.add_argument("-cv", action="store_true", dest="cv_mode",
|
||
help="启用 CV 模式(使用 OmniParser 进行 UI 识别)")
|
||
|
||
parser.add_argument("-debug", action="store_true", dest="debug_mode",
|
||
help="启用调试模式")
|
||
|
||
parser.add_argument("-enable_guiagent", action="store_true", dest="enable_guiagent",
|
||
help="启用 GuiAgent 进行智能 UI 交互")
|
||
|
||
parser.add_argument("-keep_app", action="store_true", dest="keep_app",
|
||
help="测试后保留应用状态")
|
||
|
||
parser.add_argument("-random", action="store_true", dest="random_input",
|
||
help="在输入事件中添加随机性")
|
||
|
||
# 额外配置
|
||
parser.add_argument("-model", action="store", dest="model_name", default="iOS",
|
||
help="模型名称,用于输出目录命名 (默认: iOS)")
|
||
|
||
# Fastbot 模式
|
||
parser.add_argument("-fastbot", action="store_true", dest="fastbot_mode",
|
||
help="启用 Fastbot 测试模式(不使用 DroidBot)")
|
||
|
||
parser.add_argument("-duration", action="store", dest="duration", type=int,
|
||
default=None,
|
||
help="Fastbot 测试持续时间(秒)(默认: 使用 timeout 值)")
|
||
|
||
parser.add_argument("-throttle", action="store", dest="throttle", type=int,
|
||
default=1000,
|
||
help="Fastbot 事件间隔(毫秒)(默认: 1000)")
|
||
|
||
parser.add_argument("-udid", action="store", dest="udid",
|
||
help="设备 UDID(可选,默认使用第一个设备)")
|
||
|
||
parser.add_argument("-fastbot_port", action="store", dest="fastbot_port", type=int,
|
||
default=9197,
|
||
help="Fastbot 状态监控端口 (默认: 9197)")
|
||
|
||
# 应用安装和登录
|
||
parser.add_argument("-install_app", action="store", dest="install_app_id",
|
||
help="从 App Store 安装应用 (提供 app_id)")
|
||
|
||
parser.add_argument("-enable_login", action="store_true", dest="enable_login",
|
||
help="启用 Apple ID 登录和权限处理")
|
||
|
||
parser.add_argument("-skip_onboarding", action="store_true", dest="skip_onboarding",
|
||
default=True,
|
||
help="跳过应用引导页 (默认开启)")
|
||
|
||
# 批量测试参数
|
||
parser.add_argument("-batch", action="store_true", dest="batch_mode",
|
||
help="启用批量测试模式,从CSV文件加载应用列表")
|
||
|
||
parser.add_argument("-app_list", action="store", dest="app_list",
|
||
default=str(ROOT / "output" / "ios" / "app_magic_summary_apple_no_google.csv"),
|
||
help="应用列表CSV文件路径 (默认: output/ios/app_magic_summary_apple_no_google.csv)")
|
||
|
||
parser.add_argument("-start_index", action="store", dest="start_index", type=int,
|
||
default=0,
|
||
help="批量测试起始索引 (默认: 0),用于断点续测")
|
||
|
||
parser.add_argument("-batch_count", action="store", dest="batch_count", type=int,
|
||
default=0,
|
||
help="批量测试数量 (默认: 0=全部)")
|
||
|
||
parser.add_argument("-enable_pcap", action="store_true", dest="enable_pcap",
|
||
help="启用 pcap 抓包")
|
||
|
||
|
||
return parser.parse_args()
|
||
|
||
|
||
def get_app_info_from_csv(app_id: str, csv_file: str = "appstore_mapping.csv") -> dict:
|
||
"""从CSV映射文件中读取应用信息(bundle_id 和 app_name)
|
||
|
||
从后往前读取,以最新的记录为准。
|
||
|
||
Returns:
|
||
dict: {'bundle_id': str, 'app_name': str},未找到时返回空 dict
|
||
"""
|
||
import csv
|
||
if not os.path.exists(csv_file):
|
||
return {}
|
||
|
||
try:
|
||
with open(csv_file, 'r', encoding='utf-8') as f:
|
||
reader = csv.DictReader(f)
|
||
rows = list(reader)
|
||
|
||
# 从后往前遍历,以最新记录为准
|
||
for row in reversed(rows):
|
||
if row.get('app_id', '').strip() == app_id:
|
||
return {
|
||
'bundle_id': row.get('bundle_id', '').strip(),
|
||
'app_name': row.get('app_name', '').strip(),
|
||
}
|
||
except Exception as e:
|
||
print(f"[WARN] 读取CSV映射失败: {e}")
|
||
|
||
return {}
|
||
|
||
|
||
def get_bundle_id_from_csv(app_id: str, csv_file: str = "appstore_mapping.csv") -> Optional[str]:
|
||
"""从CSV映射文件中读取bundle_id(兼容旧接口)"""
|
||
info = get_app_info_from_csv(app_id, csv_file)
|
||
return info.get('bundle_id') or None
|
||
|
||
|
||
def load_apps_from_csv(csv_path: str, start_index: int = 0,
|
||
count: int = 0,
|
||
index_set: set = None) -> List[Dict[str, str]]:
|
||
"""从 iOS 应用列表 CSV 中加载 app_id 和 app_name
|
||
|
||
每条记录包含 'index' 字段(CSV 中去掉表头后的 0-based 行号),
|
||
该值是每个应用的唯一编号,与 batch_result CSV 中的 index 列一一对应。
|
||
|
||
Args:
|
||
csv_path: CSV 文件路径
|
||
start_index: 起始索引(0-based),当 index_set 为 None 时生效
|
||
count: 加载数量,0 表示全部,当 index_set 为 None 时生效
|
||
index_set: 若指定,则只加载行号在此集合中的应用(忽略 start_index/count)
|
||
|
||
Returns:
|
||
包含 index、app_id、name 的字典列表,顺序与 CSV 一致
|
||
"""
|
||
apps = []
|
||
try:
|
||
with open(csv_path, 'r', encoding='utf-8-sig') as f:
|
||
reader = csv.DictReader(f)
|
||
for i, row in enumerate(reader):
|
||
if index_set is not None:
|
||
# 精确加载指定行
|
||
if i not in index_set:
|
||
continue
|
||
else:
|
||
if i < start_index:
|
||
continue
|
||
if count > 0 and len(apps) >= count:
|
||
break
|
||
app_id = row.get('App_ID', '').strip()
|
||
name = row.get('Name', '').strip()
|
||
if app_id:
|
||
apps.append({'index': i, 'app_id': app_id, 'name': name})
|
||
except FileNotFoundError:
|
||
print(f"[ERROR] 应用列表文件不存在: {csv_path}")
|
||
except Exception as e:
|
||
print(f"[ERROR] 读取应用列表失败: {e}")
|
||
|
||
return apps
|
||
|
||
|
||
def start_pcap(bundle_id: str, udid: str, output_dir: str,
|
||
ios_executable: str = None, app_id: str = None, app_name: str = None, raw_app_name: str = None) -> Optional[subprocess.Popen]:
|
||
"""启动 pcap 抓包
|
||
|
||
Args:
|
||
bundle_id: 应用的 Bundle ID
|
||
udid: 设备 UDID
|
||
output_dir: 输出目录,pcap 文件保存路径
|
||
ios_executable: go-ios 可执行文件路径
|
||
|
||
Returns:
|
||
抓包进程对象,失败返回 None
|
||
"""
|
||
if ios_executable is None:
|
||
sys.path.insert(0, str(ROOT / "utils_ios" / "go_ios"))
|
||
from go_ios_runner import get_ios_executable
|
||
ios_executable = get_ios_executable()
|
||
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
pcap_file = os.path.join(output_dir, "capture.pcap")
|
||
|
||
try:
|
||
cmd = [
|
||
ios_executable, "pcap",
|
||
f"--bundle={bundle_id}",
|
||
f"--udid={udid}",
|
||
f"--output={output_dir}",
|
||
"--allmark",
|
||
f"--appid={app_id}",
|
||
f"--appname={app_name}",
|
||
f"--rawappname={raw_app_name}",
|
||
]
|
||
print(f"[INFO] 启动抓包: {' '.join(cmd)}")
|
||
process = subprocess.Popen(
|
||
cmd,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL
|
||
)
|
||
print(f"[SUCCESS] 抓包已启动 (PID: {process.pid})")
|
||
return process
|
||
except Exception as e:
|
||
print(f"[WARN] 启动抓包失败: {e}")
|
||
return None
|
||
|
||
|
||
def stop_pcap(process: Optional[subprocess.Popen]):
|
||
"""停止 pcap 抓包进程"""
|
||
if process and process.poll() is None:
|
||
try:
|
||
process.terminate()
|
||
process.wait(timeout=5)
|
||
print("[INFO] 抓包已停止")
|
||
except Exception:
|
||
process.kill()
|
||
print("[WARN] 抓包进程被强制终止")
|
||
|
||
|
||
def check_app_installed(bundle_id: str, udid: Optional[str] = None,
|
||
ssh_host: Optional[str] = None,
|
||
go_ios: str = 'ios') -> bool:
|
||
"""
|
||
检查应用是否已安装
|
||
|
||
Args:
|
||
bundle_id: 应用的 Bundle ID
|
||
udid: 设备 UDID
|
||
ssh_host: SSH主机地址
|
||
go_ios: go-ios 可执行文件路径
|
||
|
||
Returns:
|
||
应用是否已安装
|
||
"""
|
||
import subprocess
|
||
|
||
try:
|
||
base_cmd = [go_ios, "apps", "--list"]
|
||
if udid:
|
||
base_cmd.extend(["--udid", udid])
|
||
|
||
if ssh_host:
|
||
cmd = ["ssh", ssh_host, " ".join(base_cmd)]
|
||
else:
|
||
cmd = base_cmd
|
||
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
if result.returncode != 0:
|
||
return False
|
||
|
||
# 检查 bundle_id 是否在输出中
|
||
for line in result.stdout.strip().split('\n'):
|
||
if line.strip() and line.split()[0] == bundle_id:
|
||
return True
|
||
|
||
return False
|
||
except Exception as e:
|
||
print(f"[WARN] 检查应用安装状态失败: {e}")
|
||
return False
|
||
|
||
|
||
def handle_app_installation(wda_url: str, app_id: str, udid: Optional[str] = None, health_monitor=None) -> Tuple[Optional[str], str]:
|
||
"""
|
||
处理应用安装
|
||
|
||
Returns:
|
||
Tuple[Optional[str], str]: (成功安装后的 bundle_id, 失败原因说明)
|
||
"""
|
||
print(f"\n{'='*50}")
|
||
print(f"[INFO] 开始安装应用: {app_id}")
|
||
print(f"{'='*50}\n")
|
||
|
||
# 导入安装器
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from package_init.ios_appstore_installer import install_from_appstore
|
||
|
||
# 执行安装
|
||
success, reason = install_from_appstore(
|
||
server_url=wda_url,
|
||
app_id=app_id,
|
||
timeout=600,
|
||
udid=udid,
|
||
ssh_host=None,
|
||
go_ios='ios',
|
||
alert_strategy="allow",
|
||
health_monitor=health_monitor
|
||
)
|
||
|
||
if not success:
|
||
print(f"[ERROR] 应用安装失败: {reason}")
|
||
return None, reason
|
||
|
||
print("[SUCCESS] 应用安装成功")
|
||
|
||
# 从CSV读取bundle_id(使用与安装器相同的路径)
|
||
# 注意:安装器内部会将纯数字 app_id 格式化为 id{app_id} 保存到 CSV
|
||
csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv")
|
||
bundle_id = get_bundle_id_from_csv(app_id, csv_file)
|
||
if not bundle_id and app_id.isdigit():
|
||
# 尝试带 id 前缀的格式(安装器保存时使用的格式)
|
||
bundle_id = get_bundle_id_from_csv(f"id{app_id}", csv_file)
|
||
if bundle_id:
|
||
print(f"[INFO] 从CSV获取到 Bundle ID: {bundle_id}")
|
||
return bundle_id, ""
|
||
else:
|
||
print("[WARN] 无法从CSV获取Bundle ID,请手动指定")
|
||
return None, "无法从CSV获取Bundle ID"
|
||
|
||
|
||
def handle_apple_id_login(wda_url: str, bundle_id: str, skip_onboarding: bool = True) -> bool:
|
||
"""
|
||
处理Apple ID登录和权限
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
print(f"\n{'='*50}")
|
||
print(f"[INFO] 开始处理Apple ID登录和权限")
|
||
print(f"[INFO] Bundle ID: {bundle_id}")
|
||
print(f"{'='*50}\n")
|
||
|
||
# 导入登录器
|
||
from utils_ios.package_init.ios_appleid_signer import sign_with_appleid
|
||
|
||
# 执行登录
|
||
success = sign_with_appleid(
|
||
server_url=wda_url,
|
||
bundle_id=bundle_id,
|
||
alert_strategy="allow",
|
||
skip_onboarding=skip_onboarding,
|
||
timeout=120,
|
||
try_login=True
|
||
)
|
||
|
||
if success:
|
||
print("[SUCCESS] Apple ID登录和权限处理完成")
|
||
else:
|
||
print("[WARN] Apple ID登录和权限处理未完全成功")
|
||
|
||
return success
|
||
|
||
|
||
def setup_infrastructure(args):
|
||
"""启动基础设施:tunnel、端口转发、WDA、代理网络、设备解锁
|
||
|
||
Returns:
|
||
(runner, udid) 元组,失败时 sys.exit(1)
|
||
"""
|
||
sys.path.insert(0, str(ROOT / "utils_ios"))
|
||
from go_ios import go_ios_runner
|
||
GoIOSRunner = go_ios_runner.GoIOSRunner
|
||
|
||
udid = args.udid or "00008101-0016601022C0001E"
|
||
runner = GoIOSRunner(udid=udid, wda_url=args.wda_url)
|
||
|
||
print("[INFO] 正在启动 WDA 基础设施...")
|
||
if not runner.start_infrastructure(wda_port=8100, use_tunnel=True, tunnel_sudo_password=os.getenv("SUDO")):
|
||
print("[ERROR] WDA 基础设施启动失败")
|
||
sys.exit(1)
|
||
|
||
print("[INFO] WDA 基础设施已启动,等待稳定...")
|
||
time.sleep(3)
|
||
|
||
# 设置 iPhone 代理网络
|
||
setup_iphone_proxy()
|
||
|
||
# 启动 WDA
|
||
print("[INFO] 正在启动 WDA...")
|
||
wda_process = runner.start_wda(port=8100)
|
||
print(f"[SUCCESS] WDA 已启动 (PID: {wda_process.pid})")
|
||
time.sleep(3)
|
||
# 等待 WDA 就绪后再操作设备
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
# 根据 wda_url 格式选择连接方式
|
||
if args.wda_url and args.wda_url.startswith("http:"):
|
||
c = WDAClient(args.wda_url)
|
||
else:
|
||
c = WDAUSBClient(udid=args.wda_url or udid or "")
|
||
print("[INFO] 等待 WDA 就绪...")
|
||
if not c.wait_ready(timeout=30):
|
||
print("[WARN] WDA 未就绪,继续尝试...")
|
||
|
||
# 解锁设备
|
||
try:
|
||
if c.locked():
|
||
print("[INFO] 设备已锁定,正在解锁...")
|
||
c.unlock()
|
||
time.sleep(1)
|
||
else:
|
||
print("[INFO] 设备未锁定")
|
||
except Exception as e:
|
||
print(f"[WARN] 设备解锁失败: {e}")
|
||
|
||
return runner, udid
|
||
|
||
|
||
def install_app_and_get_bundle_id(app_id: str, wda_url: str,
|
||
udid: str, health_monitor=None) -> Tuple[Optional[str], str]:
|
||
"""安装应用并获取 bundle_id
|
||
|
||
流程:CSV缓存查找 → 检查已安装 → 执行安装
|
||
|
||
Returns:
|
||
Tuple[Optional[str], str]: (bundle_id, 失败原因) 失败时 bundle_id 为 None
|
||
"""
|
||
csv_file = os.path.join(ROOT, "output", "ios", "appstore_mapping.csv")
|
||
|
||
# 1. 先从 CSV 查找是否有记录(兼容 id 前缀格式)
|
||
cached_bundle_id = get_bundle_id_from_csv(app_id, csv_file)
|
||
if not cached_bundle_id and app_id.isdigit():
|
||
cached_bundle_id = get_bundle_id_from_csv(f"id{app_id}", csv_file)
|
||
|
||
if cached_bundle_id:
|
||
print(f"[INFO] 从CSV找到记录: {app_id} -> {cached_bundle_id}")
|
||
|
||
# 2. 检查应用是否已安装
|
||
if check_app_installed(cached_bundle_id, udid=udid):
|
||
print(f"[SUCCESS] 应用已安装,跳过安装步骤: {cached_bundle_id}")
|
||
return cached_bundle_id, ""
|
||
else:
|
||
print(f"[INFO] 应用未安装,开始安装: {app_id}")
|
||
else:
|
||
print(f"[INFO] CSV中无记录,开始安装: {app_id}")
|
||
|
||
# 3. 执行安装
|
||
installed_bundle_id, reason = handle_app_installation(
|
||
wda_url=wda_url,
|
||
app_id=app_id,
|
||
udid=udid,
|
||
health_monitor=health_monitor
|
||
)
|
||
|
||
if installed_bundle_id:
|
||
print(f"[INFO] 安装成功,Bundle ID: {installed_bundle_id}")
|
||
else:
|
||
print(f"[ERROR] 安装失败: {app_id} - {reason}")
|
||
|
||
return installed_bundle_id, reason
|
||
|
||
|
||
def run_batch_mode(args):
|
||
"""批量测试模式主逻辑"""
|
||
print(f"\n{'='*60}")
|
||
print(f" iOS 批量测试模式")
|
||
print(f"{'='*60}")
|
||
print(f"[INFO] 应用列表: {args.app_list}")
|
||
print(f"[INFO] 起始索引: {args.start_index}")
|
||
print(f"[INFO] 批量数量: {args.batch_count or '全部'}")
|
||
|
||
# 1. 加载应用列表
|
||
apps = load_apps_from_csv(
|
||
csv_path=args.app_list,
|
||
start_index=args.start_index,
|
||
count=args.batch_count
|
||
)
|
||
|
||
if not apps:
|
||
print("[ERROR] 应用列表为空,退出")
|
||
sys.exit(1)
|
||
|
||
print(f"[INFO] 共加载 {len(apps)} 个应用")
|
||
|
||
# 2. 初始化批量统计
|
||
batch_output_dir = os.path.join(ROOT, "output")
|
||
stats = IOSBatchStatistics(batch_output_dir)
|
||
|
||
# 3. 启动基础设施(tunnel、端口转发、WDA、代理)
|
||
runner, udid = setup_infrastructure(args)
|
||
|
||
# 4. 初始化测试配置和运行器
|
||
config = IOSTestConfig()
|
||
test_runner = IOSTestRunner(config)
|
||
|
||
# 4b. 初始化 WDA 健康监控(用于卡死检测与异步恢复)
|
||
from utils_ios.wda_health import WDAHealthMonitor
|
||
wda_health_monitor = WDAHealthMonitor(
|
||
wda_url=args.wda_url,
|
||
go_ios_runner=runner
|
||
)
|
||
print("[INFO] WDA 健康监控已初始化")
|
||
|
||
# 5. 获取 go-ios 可执行文件路径(用于抓包)
|
||
ios_executable = runner.ios_executable
|
||
|
||
# 6. 遍历应用列表,逐个测试
|
||
try:
|
||
for idx, app_info in enumerate(apps):
|
||
app_id = app_info['app_id']
|
||
app_name = app_info['name']
|
||
real_index = app_info['index'] # CSV 中的 0-based 唯一行号
|
||
|
||
print(f"\n{'#'*60}")
|
||
print(f" [index={real_index}] ({idx+1}/{len(apps)}) {app_name}")
|
||
print(f" App ID: {app_id}")
|
||
print(f"{'#'*60}")
|
||
|
||
# 6a. 安装应用并获取 bundle_id
|
||
bundle_id = install_app_and_get_bundle_id(
|
||
app_id=app_id,
|
||
wda_url=args.wda_url,
|
||
udid=udid
|
||
)
|
||
|
||
if not bundle_id:
|
||
print(f"[SKIP] 跳过 {app_name}: 安装失败")
|
||
stats.add_skip_record(real_index, app_name, app_name, app_id, "安装失败")
|
||
stats.save_csv()
|
||
continue
|
||
|
||
# 6b. 处理Apple ID登录(如果启用)
|
||
if args.enable_login:
|
||
handle_apple_id_login(
|
||
wda_url=args.wda_url,
|
||
bundle_id=bundle_id,
|
||
skip_onboarding=args.skip_onboarding
|
||
)
|
||
|
||
# 6c. 启动抓包(如果启用)
|
||
pcap_process = None
|
||
if args.enable_pcap:
|
||
pcap_output_dir = os.path.join(
|
||
config.OUTPUT_BASE_DIR,
|
||
f"{bundle_id.replace('.', '_')}_iOS_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||
)
|
||
pcap_process = start_pcap(
|
||
bundle_id=bundle_id,
|
||
udid=udid,
|
||
output_dir=pcap_output_dir,
|
||
ios_executable=ios_executable
|
||
)
|
||
|
||
# 6d. 执行测试
|
||
try:
|
||
result = test_runner.run_test(
|
||
bundle_id=bundle_id,
|
||
wda_url=args.wda_url,
|
||
policy=args.policy,
|
||
timeout=args.timeout,
|
||
event_interval=args.interval,
|
||
event_count=args.count,
|
||
cv_mode=args.cv_mode,
|
||
debug_mode=args.debug_mode,
|
||
enable_guiagent=args.enable_guiagent,
|
||
keep_app=False, # 批量模式不保留应用
|
||
random_input=args.random_input,
|
||
model_name=args.model_name,
|
||
wda_health_monitor=wda_health_monitor
|
||
)
|
||
stats.add_record(real_index, app_name, app_id, bundle_id, result)
|
||
print(f"[RESULT] {app_name}: {result.status} "
|
||
f"(步数: {result.total_steps}, 耗时: {result.duration_seconds:.1f}s)")
|
||
|
||
except KeyboardInterrupt as e:
|
||
# run_test 中用户中断会抛出 KeyboardInterrupt(TaskResult)
|
||
if e.args and isinstance(e.args[0], TaskResult):
|
||
result = e.args[0]
|
||
stats.add_record(real_index, app_name, app_id, bundle_id, result)
|
||
else:
|
||
stats.add_skip_record(real_index, app_name, app_name, app_id, "用户中断")
|
||
# 用户中断时保存结果并退出整个批量循环
|
||
print(f"\n[WARN] 用户中断,停止批量测试")
|
||
stats.save_csv()
|
||
stats.print_summary()
|
||
raise # 重新抛出,让外层处理
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] 测试 {app_name} 异常: {e}")
|
||
stats.add_skip_record(real_index, app_name, app_name, app_id, f"异常: {e}")
|
||
|
||
finally:
|
||
# 停止抓包
|
||
stop_pcap(pcap_process)
|
||
|
||
# 6e. 实时保存 CSV
|
||
stats.save_csv()
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n[WARN] 批量测试被用户中断")
|
||
finally:
|
||
# 7. 打印最终摘要
|
||
stats.save_csv()
|
||
stats.print_summary()
|
||
|
||
# 8. 清理基础设施
|
||
print("[INFO] 正在清理资源...")
|
||
runner.stop_all()
|
||
print("[INFO] 清理完成")
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
args = parse_args()
|
||
|
||
# 加载环境变量(包括 SUDO)
|
||
load_env_variables()
|
||
|
||
print("--- iOS 自动化测试脚本启动 ---")
|
||
|
||
# ====== 批量测试模式 ======
|
||
if args.batch_mode:
|
||
run_batch_mode(args)
|
||
return
|
||
|
||
print(f"[INFO] Bundle ID: {args.bundle_id}")
|
||
|
||
# 判断运行模式
|
||
if args.fastbot_mode:
|
||
# Fastbot 模式
|
||
print(f"[INFO] 运行模式: Fastbot")
|
||
|
||
# 如果未指定 duration,使用 timeout
|
||
test_duration = args.duration if args.duration else args.timeout
|
||
print(f"[INFO] Duration: {test_duration}s, Throttle: {args.throttle}ms, Timeout: {args.timeout}s")
|
||
print(f"[INFO] UDID: {args.udid or 'auto'}")
|
||
|
||
# Import Fastbot runner
|
||
import sys
|
||
sys.path.insert(0, str(ROOT / "DroidBot" / "platforms" / "ios"))
|
||
from utils_ios.go_ios.go_ios_runner import GoIOSRunner
|
||
import time
|
||
import requests
|
||
|
||
udid = args.udid or "00008101-0016601022C0001E" # Default UDID
|
||
runner = GoIOSRunner(udid=udid, wda_url="http://localhost:8100")
|
||
|
||
try:
|
||
# 启动基础设施
|
||
print("[INFO] 正在启动基础设施...")
|
||
if not runner.start_infrastructure(wda_port=8100, fastbot_port=args.fastbot_port):
|
||
print("[ERROR] 基础设施启动失败")
|
||
sys.exit(1)
|
||
|
||
print("[INFO] 基础设施已启动,等待稳定...")
|
||
time.sleep(3)
|
||
|
||
# 设置 iPhone 代理网络
|
||
setup_iphone_proxy()
|
||
|
||
# 等待 WDA 就绪后再操作设备
|
||
from wda import Client as WDAClient, USBClient as WDAUSBClient
|
||
# Fastbot 模式中使用 UDID 连接
|
||
c = WDAUSBClient(udid=udid)
|
||
print("[INFO] 等待 WDA 就绪...")
|
||
if not c.wait_ready(timeout=30):
|
||
print("[WARN] WDA 未就绪,继续尝试...")
|
||
|
||
# 解锁设备
|
||
try:
|
||
if c.locked():
|
||
print("[INFO] 设备已锁定,正在解锁...")
|
||
c.unlock()
|
||
time.sleep(1)
|
||
else:
|
||
print("[INFO] 设备未锁定")
|
||
except Exception as e:
|
||
print(f"[WARN] 设备解锁失败: {e}")
|
||
|
||
# 处理应用安装(如果指定)
|
||
if args.install_app_id:
|
||
bundle_id = install_app_and_get_bundle_id(
|
||
app_id=args.install_app_id,
|
||
wda_url="http://localhost:8100",
|
||
udid=udid
|
||
)
|
||
if bundle_id:
|
||
args.bundle_id = bundle_id
|
||
elif not args.bundle_id or args.bundle_id == "com.apple.AppStore":
|
||
print("[ERROR] 安装失败且未指定Bundle ID,无法继续")
|
||
sys.exit(1)
|
||
|
||
|
||
# 处理Apple ID登录(如果启用)
|
||
if args.enable_login:
|
||
handle_apple_id_login(
|
||
wda_url="http://localhost:8100",
|
||
bundle_id=args.bundle_id,
|
||
skip_onboarding=args.skip_onboarding
|
||
)
|
||
|
||
# 启动 Fastbot
|
||
print(f"[INFO] 正在启动 Fastbot 测试...")
|
||
fastbot_process = runner.start_fastbot(
|
||
target_bundle_id=args.bundle_id,
|
||
duration=test_duration,
|
||
throttle=args.throttle
|
||
)
|
||
print(f"[SUCCESS] Fastbot 已启动 (PID: {fastbot_process.pid})")
|
||
|
||
# 监控状态(使用 timeout 作为总时长限制)
|
||
print(f"[INFO] 开始监控 Fastbot 状态 (端口 {args.fastbot_port})...")
|
||
status_url = f"http://127.0.0.1:{args.fastbot_port}/status"
|
||
last_status = None
|
||
start_time = time.time()
|
||
check_interval = 5 # 每5秒检查一次
|
||
|
||
while True:
|
||
# 检查是否超时
|
||
elapsed = time.time() - start_time
|
||
if elapsed >= args.timeout:
|
||
print(f"[WARN] 已达到超时时间 ({args.timeout}s),停止监控")
|
||
fastbot_process.terminate()
|
||
break
|
||
|
||
# 检查进程是否结束
|
||
if fastbot_process.poll() is not None:
|
||
print(f"[INFO] Fastbot 进程已结束 (退出码: {fastbot_process.returncode}, 耗时: {elapsed:.1f}s)")
|
||
break
|
||
|
||
try:
|
||
response = requests.get(status_url, timeout=2)
|
||
if response.status_code == 200:
|
||
data = response.json()
|
||
running = data.get("runingStatus", False)
|
||
|
||
if running != last_status:
|
||
if running:
|
||
print(f"[STATUS] Fastbot 正在运行 ✓ (已运行 {elapsed:.1f}s / {args.timeout}s)")
|
||
else:
|
||
print(f"[STATUS] Fastbot 已停止 (耗时: {elapsed:.1f}s)")
|
||
break
|
||
last_status = running
|
||
elif running:
|
||
# 每30秒打印一次进度
|
||
if int(elapsed) % 30 == 0 and int(elapsed) > 0:
|
||
remaining = args.timeout - elapsed
|
||
print(f"[STATUS] Fastbot 运行中... (已运行 {elapsed:.1f}s, 剩余 {remaining:.1f}s)")
|
||
except requests.RequestException:
|
||
pass # Silently ignore connection errors
|
||
except KeyboardInterrupt:
|
||
print(f"\n[WARN] 用户中断测试 (已运行 {elapsed:.1f}s)")
|
||
fastbot_process.terminate()
|
||
break
|
||
|
||
time.sleep(check_interval)
|
||
|
||
# 等待进程完全结束
|
||
fastbot_process.wait()
|
||
total_elapsed = time.time() - start_time
|
||
print(f"[INFO] Fastbot 测试完成 (总耗时: {total_elapsed:.1f}s)")
|
||
|
||
except KeyboardInterrupt:
|
||
print("\n[WARN] 用户中断测试")
|
||
finally:
|
||
print("[INFO] 正在清理资源...")
|
||
runner.stop_all()
|
||
print("[INFO] 清理完成")
|
||
|
||
else:
|
||
# DroidBot 模式(单个应用)
|
||
print(f"[INFO] 运行模式: DroidBot")
|
||
print(f"[INFO] WDA URL: {args.wda_url}")
|
||
|
||
# 初始化WDA(如果需要)
|
||
if args.wda_url.startswith("http://localhost") or args.wda_url.startswith("http://127.0.0.1"):
|
||
runner, udid = setup_infrastructure(args)
|
||
else:
|
||
udid = args.udid
|
||
|
||
# 处理应用安装(如果指定)
|
||
if args.install_app_id:
|
||
bundle_id = install_app_and_get_bundle_id(
|
||
app_id=args.install_app_id,
|
||
wda_url=args.wda_url,
|
||
udid=udid or "00008101-0016601022C0001E"
|
||
)
|
||
if bundle_id:
|
||
args.bundle_id = bundle_id
|
||
elif not args.bundle_id or args.bundle_id == "com.apple.AppStore":
|
||
print("[ERROR] 安装失败且未指定Bundle ID,无法继续")
|
||
sys.exit(1)
|
||
|
||
|
||
# 处理Apple ID登录(如果启用)
|
||
if args.enable_login:
|
||
handle_apple_id_login(
|
||
wda_url=args.wda_url,
|
||
bundle_id=args.bundle_id,
|
||
skip_onboarding=args.skip_onboarding
|
||
)
|
||
|
||
# 初始化配置与运行器
|
||
config = IOSTestConfig()
|
||
test_runner = IOSTestRunner(config)
|
||
|
||
# 执行测试
|
||
test_runner.run_test(
|
||
bundle_id=args.bundle_id,
|
||
wda_url=args.wda_url,
|
||
policy=args.policy,
|
||
timeout=args.timeout,
|
||
event_interval=args.interval,
|
||
event_count=args.count,
|
||
cv_mode=args.cv_mode,
|
||
debug_mode=args.debug_mode,
|
||
enable_guiagent=args.enable_guiagent,
|
||
keep_app=args.keep_app,
|
||
random_input=args.random_input,
|
||
output_dir=args.output_dir,
|
||
model_name=args.model_name
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|