一条 iOS 流水线可能在编译阶段全部通过,却在模拟器安装时才发现包结构异常,或者启动后立即退出。把完整 UI 测试放在最前面,不但反馈慢,还会把基础故障包装成超时。更稳妥的做法,是在云端 Mac 上增加一条两三分钟内完成的安装与启动冒烟门禁:固定运行时,安装刚生成的 .app,确认目标进程存活,再保存启动日志。
门禁要回答哪几个问题
这条门禁不验证业务功能,只回答四个二元问题:模拟器能否正常启动、产物能否安装、指定 Bundle ID 能否拉起、进程是否在观察期内保持运行。任何一项失败,都不应继续占用后续 UI 测试资源。
| 检查点 | 成功条件 | 失败时保留 |
|---|---|---|
| 启动 | bootstatus 正常结束 |
设备 UDID、运行时版本 |
| 安装 | simctl install 返回 0 |
App 路径、命令输出 |
| 拉起 | simctl launch 返回 PID |
Bundle ID、退出码 |
| 存活 | 观察期后仍能查询进程 | 系统日志、崩溃记录 |
冒烟门禁的价值不在于覆盖更多,而在于把“产物存在”和“产物可运行”分成两个明确结论。
建议为任务显式传入模拟器 UDID,不要依赖 booted。共享云端 Mac 上可能残留其他任务启动的设备,使用模糊目标会让日志和安装结果互相污染。NowMini 节点上的具体可选配置,应在控制台确认;流水线本身只需要依赖已安装的 Xcode 与对应模拟器运行时。
先固定构建产物和目标设备
先将构建输出收敛到任务目录,避免从 DerivedData 中猜测路径。Scheme、配置、Bundle ID 和 UDID 都应由环境变量提供,并在任务开头检查。
set -euo pipefail
: "${SIM_UDID:?SIM_UDID is required}"
: "${APP_BUNDLE_ID:?APP_BUNDLE_ID is required}"
WORK_DIR="${RUNNER_TEMP:-$PWD/.smoke}"
APP_PATH="$WORK_DIR/build/Sample.app"
LOG_DIR="$WORK_DIR/logs"
rm -rf "$WORK_DIR"
mkdir -p "$LOG_DIR"
xcodebuild \
-workspace Sample.xcworkspace \
-scheme Sample \
-configuration Debug \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,id=$SIM_UDID" \
CONFIGURATION_BUILD_DIR="$WORK_DIR/build" \
build
不要用 find 取第一个 .app。工程可能同时生成宿主 App、测试宿主和扩展,顺序也不稳定。已知产品名时直接拼出路径,并在安装前验证 Info.plist:
test -d "$APP_PATH"
BUILT_ID=$(/usr/libexec/PlistBuddy -c \
"Print :CFBundleIdentifier" "$APP_PATH/Info.plist")
test "$BUILT_ID" = "$APP_BUNDLE_ID"
这一步能提前识别 Scheme 指错、构建配置替换错误,以及安装命令拿到测试宿主等问题。
启动、安装并验证进程
设备启动要使用 bootstatus -b 等待系统真正可用。单独执行 boot 后立即安装,容易在负载较高时遇到服务尚未就绪的间歇失败。
xcrun simctl boot "$SIM_UDID" 2>/dev/null || true
xcrun simctl bootstatus "$SIM_UDID" -b
xcrun simctl uninstall "$SIM_UDID" "$APP_BUNDLE_ID" \
2>/dev/null || true
xcrun simctl install "$SIM_UDID" "$APP_PATH"
LAUNCH_OUTPUT=$(xcrun simctl launch \
"$SIM_UDID" "$APP_BUNDLE_ID" \
-SmokeTestMode YES)
printf '%s
' "$LAUNCH_OUTPUT"
PID=$(printf '%s
' "$LAUNCH_OUTPUT" | awk -F': ' 'NF == 2 {print $2}')
test -n "$PID"
启动参数可以让 App 跳过首次引导、关闭动画或改用本地夹具,但必须由应用代码明确实现,不能假设任意参数都会生效。拉起后等待短暂观察期,再从模拟器内部查询进程。只检查 launch 的返回码,会漏掉启动后一秒内退出的情况。
sleep 8
xcrun simctl spawn "$SIM_UDID" launchctl print \
"gui/$(id -u)/$APP_BUNDLE_ID" >/dev/null
部分应用的进程标签与 Bundle ID 不完全一致,此时应改为查询已知进程名,或者在应用启动时写入一个仅供测试读取的就绪标记。不要靠固定增加等待时间掩盖不稳定状态。
为失败保存可读证据
清理动作很重要,但证据必须先保存。最小证据集包括构建输出、设备描述、安装与启动输出,以及目标进程附近的统一日志。日志范围应围绕当前任务,而不是导出整台主机的历史记录。
xcrun simctl list devices -j > "$LOG_DIR/devices.json"
xcrun simctl spawn "$SIM_UDID" log show \
--style compact \
--last 2m \
--predicate "process == 'Sample'" \
> "$LOG_DIR/sample-launch.log" 2>&1 || true
常见误区是只归档标准错误。启动即退时,真正原因可能出现在模拟器的系统日志中,例如动态库加载失败、资源路径错误或未处理异常。日志里如果含令牌、请求头或用户数据,应在归档前脱敏,并限制流水线产物的访问范围。
区分环境故障与应用故障
bootstatus 失败通常属于设备环境问题;install 失败优先检查包结构、运行时兼容性和磁盘空间;launch 成功但进程消失,则优先读取应用日志。分类后再决定是否重试。应用以同一方式连续失败时,不应自动重跑多次,否则只会延迟真实反馈。
清理状态并接入流水线
无论任务成功还是失败,都应终止应用并关闭本次使用的模拟器。可以用 shell 的 trap 保证收尾执行:
cleanup() {
xcrun simctl terminate "$SIM_UDID" "$APP_BUNDLE_ID" \
2>/dev/null || true
xcrun simctl uninstall "$SIM_UDID" "$APP_BUNDLE_ID" \
2>/dev/null || true
xcrun simctl shutdown "$SIM_UDID" \
2>/dev/null || true
}
trap cleanup EXIT
如果任务要求完全相同的初始状态,可在关闭后执行 simctl erase;但它会增加耗时,也会删除预置数据。更常见的折中是卸载 App、清理任务目录,并为每条流水线分配独立设备。涉及权限状态时,再对目标服务做显式重置。
最后,把这条门禁放在编译之后、单元测试或 UI 测试之前。脚本应返回明确的非零退出码,日志归档步骤设置为始终执行。这样,安装失败不会伪装成测试超时,启动即退也不会浪费整组执行器;每次失败都能留下足够证据,供开发者在同一运行时和同一产物上复现。
常见问题
为什么不能只检查 xcodebuild 是否成功?
编译成功只证明产物生成完成,不能证明 App 能被模拟器安装并正常拉起。嵌入内容、Bundle ID、最低系统版本或运行时资源错误仍可能在安装和启动阶段暴露。
冒烟门禁失败时至少应保留哪些证据?
至少保留 xcodebuild 输出、目标模拟器 UDID 与运行时版本、App 路径、simctl install 和 launch 的退出码,以及失败前后两分钟的目标进程日志。
在独享物理节点上运行下一项任务
使用 M4、16GB RAM 与 256GB SSD 的云端 Mac,按任务周期选择新加坡、东京、首尔或香港节点。