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

1412 lines
53 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 自动化测试启动脚本
与 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()