工程实践

云端 Mac 用 simctl push 自动验收 iOS 推送通知

云端 Mac 用 simctl push 自动验收 iOS 推送通知

远程流水线已经产出可安装的 iOS 应用,但团队手边没有持续连接的测试设备。此时最容易漏掉的不是编译错误,而是通知权限、前台展示、深链参数和通知服务扩展之间的组合问题。simctl push 可以把 APNs 格式负载直接注入 iOS Simulator,适合在云端 Mac 上做快速、可重复的提交前验收。

它不能替代真实 APNs 投递测试,却能把大量客户端问题提前拦在构建阶段。关键是不要只看命令退出码,而要同时保存应用状态、截图和统一日志。

先定义这项测试能证明什么

simctl push 验证的是已构建应用如何处理通知负载。它可以覆盖通知内容解析、前后台代理回调、分类按钮、深链路由以及 Notification Service Extension 的处理结果。

它无法验证服务端鉴权、设备令牌有效性、外部网络投递、APNs 限流或真实设备的省电策略。模拟器收到负载,也不代表生产推送链路已经打通。

把命令成功视为“已完成本地注入”,不要视为“用户已经收到通知”。验收结论必须来自界面、应用日志和路由结果。

开始前确认应用已经声明通知能力,并在测试路径中完成授权。simctl push 不会替应用点击系统权限弹窗。首次授权可以由 UI 测试处理,也可以在专用测试模拟器中预先完成,但不能依赖某台长期使用的模拟器状态。

固定模拟器与应用输入

并行任务中不要直接使用 booted。如果同时启动多台模拟器,它可能把负载送到另一项任务。为每个作业记录明确的 UDID,并将应用路径、Bundle ID 和产物目录作为输入。

set -euo pipefail

: "${SIMULATOR_UDID:?Set SIMULATOR_UDID}"
: "${APP_PATH:?Set APP_PATH}"
: "${BUNDLE_ID:?Set BUNDLE_ID}"

ARTIFACTS="${ARTIFACTS:-$PWD/.ci/push-check}"
mkdir -p "$ARTIFACTS"

xcrun simctl bootstatus "$SIMULATOR_UDID" -b
xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH"
xcrun simctl launch "$SIMULATOR_UDID" "$BUNDLE_ID"
xcrun simctl get_app_container "$SIMULATOR_UDID" "$BUNDLE_ID"

bootstatus -b 会等待系统真正完成启动,比启动后固定睡眠若干秒稳定。get_app_container 则是低成本安装检查:如果 Bundle ID 错误或应用未安装,任务会在发送通知前失败。

需要复测首次授权时,应为该任务新建或抹除专用模拟器,而不是在共享设备上随意重置全部隐私设置。这样可以避免并行测试互相改变相机、照片或通知权限。

生成并校验 APNs 负载

负载文件使用 JSON。建议加入可检索的测试标识,便于把截图、应用日志和流水线任务对应起来。

PAYLOAD="$ARTIFACTS/foreground.apns"
TRACE_ID="push-check-${CI_RUN_ID:-local}"

cat > "$PAYLOAD" <<JSON
{
  "Simulator Target Bundle": "$BUNDLE_ID",
  "aps": {
    "alert": {
      "title": "Build completed",
      "body": "Open the result for details"
    },
    "sound": "default",
    "badge": 1,
    "category": "BUILD_RESULT"
  },
  "route": "build/result",
  "trace_id": "$TRACE_ID"
}
JSON

plutil -lint "$PAYLOAD"
PAYLOAD_BYTES="$(wc -c < "$PAYLOAD" | tr -d ' ')"
test "$PAYLOAD_BYTES" -le 4096
xcrun simctl push "$SIMULATOR_UDID" "$BUNDLE_ID" "$PAYLOAD"

4KB 检查用于尽早发现异常负载,但模拟器不会完整复刻服务端的所有校验。还应在应用侧对 route、标识符和自定义字段做类型检查,不能因为测试负载可信就强制转换。

如果使用通知服务扩展,增加 "mutable-content": 1,并让扩展写入统一的结构化日志。扩展的处理时间和系统终止行为仍应按失败路径设计,不能依赖它一定完成下载或改写。

按应用状态建立验收矩阵

同一负载至少要覆盖前台、后台和未运行三种状态。前台通知通常由代理方法决定是否显示横幅;因此“没有横幅”可能是产品逻辑,也可能是回调遗漏,必须为每种状态写明预期。

场景 操作 必查结果
前台 启动应用后注入 代理回调、页面内提示、重复事件
后台 返回主屏幕后注入 横幅内容、角标、点击路由
未运行 终止应用后注入 冷启动入口、参数解析、目标页面
内容扩展 注入带 mutable-content 的负载 扩展日志、改写内容、超时回退
异常字段 缺少路由或字段类型错误 安全降级、不崩溃、可诊断日志

发送后保存截图,避免流水线只留下“命令成功”的空结论。

xcrun simctl io "$SIMULATOR_UDID" screenshot \
  "$ARTIFACTS/notification.png"

xcrun simctl spawn "$SIMULATOR_UDID" log show \
  --last 2m \
  --style compact \
  --predicate "process == '$BUNDLE_ID'" \
  > "$ARTIFACTS/application.log"

进程名不一定等于 Bundle ID。更稳妥的做法是在应用和扩展中使用固定日志 subsystem,再按 subsystem 查询。日志只记录测试标识、状态与错误类型,不要写入完整通知正文、令牌或用户数据。

把失败条件写进流水线

一条可靠的通知验收任务不应只执行 shell 命令,还要判断产物是否存在、应用是否崩溃、预期路由是否被记录。可以让应用在测试构建中把已处理的 trace_id 和结果写入专用文件,再通过 simctl get_app_container 定位容器并读取。测试接口只在内部构建配置启用,发布配置必须关闭。

常见误判包括:复用已经授权的模拟器而漏测首次弹窗;使用 booted 导致并行串线;前台不显示横幅却被当成注入失败;只检查截图而没有验证点击后的路由;日志查询范围过大,读到了上一次任务的记录。

提交前按以下顺序收口:

  1. 固定 Xcode、运行时、设备型号和模拟器 UDID。
  2. 安装本次构建产物,不复用旧应用容器。
  3. 校验 JSON 语法、字节数和必需字段。
  4. 分别执行前台、后台、未运行与异常负载用例。
  5. 归档负载、截图、应用日志和唯一测试标识。
  6. 将模拟器验收与真实 APNs 端到端测试分开报告。

完成这些步骤后,推送客户端逻辑就从依赖人工观察的功能,变成了能够重复执行、留存证据并准确定位失败阶段的工程检查。

常见问题

simctl push 成功是否代表真实 APNs 投递一定成功?

不是。命令成功只表示负载已注入模拟器,不能验证服务端鉴权、设备令牌、网络投递或 APNs 限流;这些环节仍需在受控的真实设备流程中验收。

为什么命令返回成功,模拟器却没有显示通知横幅?

先确认应用已安装且通知权限已授予,再检查前台代理回调、当前专注状态和负载中的 aps 字段。前台应用不会自动显示横幅,必须由应用代码明确处理。

CI 中应该使用 booted 还是固定的模拟器 UDID?

应使用本次任务创建或分配的明确 UDID。booted 在并行任务中可能指向错误设备,导致负载、截图和日志被写入另一台模拟器。

独享物理 Mac mini

为下一条开发流水线选择云端 Mac

比较三档固定配置与四个亚洲节点,按实际工作负载选择租用周期。每笔租用对应一台独享物理机,不是虚拟机。

选择配置并租用