同一台云端 Mac 上,xcodebuild build 经 SSH 执行正常,切到 test 却可能卡在“启动测试运行器”之前:没有测试用例开始、模拟器窗口不出现,日志里只留下应用启动或会话连接失败。此时先别清理 DerivedData。编译链路已经通过,真正需要核对的是任务是否处于可用的 macOS 图形会话中。
先区分构建环境与图形会话
XCUITest 不只是编译一个测试包。它还要启动目标应用、测试宿主和模拟器服务,并通过当前用户的图形会话完成进程协作。SSH 登录成功,只能证明远程 Shell 可用;它不保证该 Shell 与桌面登录用户位于同一个 launchd GUI 域。
先记录四项事实,不要一上来重启全部进程:
| 检查项 | 可接受结果 | 异常线索 |
|---|---|---|
| 控制台用户 | 实际执行自动化的用户 | root 或 loginwindow |
| GUI 启动域 | gui/<uid> 可查询 |
域不存在或无权限 |
| WindowServer | 进程存在 | 登录会话尚未建立 |
| 目标模拟器 | 状态为 Booted |
一直停在 Booting 或 Shutdown |
WindowServer 存在只是必要条件之一。若测试任务由另一个用户或系统级守护进程启动,它仍可能无法访问目标用户的图形会话。
用预检脚本固定现场
下面的脚本不会修改系统状态,适合放在测试任务最前面。它把当前执行用户、控制台用户、GUI 域和可用设备写入日志,任何一项失败都直接终止任务。
#!/bin/zsh
set -euo pipefail
RUNNER_USER="$(id -un)"
RUNNER_UID="$(id -u)"
CONSOLE_USER="$(stat -f '%Su' /dev/console)"
echo "runner_user=${RUNNER_USER}"
echo "runner_uid=${RUNNER_UID}"
echo "console_user=${CONSOLE_USER}"
if [[ "${CONSOLE_USER}" == "root" || "${CONSOLE_USER}" == "loginwindow" ]]; then
echo "No interactive console user"
exit 21
fi
CONSOLE_UID="$(id -u "${CONSOLE_USER}")"
if [[ "${RUNNER_UID}" != "${CONSOLE_UID}" ]]; then
echo "Runner and console user differ"
exit 22
fi
if ! launchctl print "gui/${CONSOLE_UID}" >/dev/null 2>&1; then
echo "GUI launch domain is unavailable"
exit 23
fi
if ! pgrep -x WindowServer >/dev/null; then
echo "WindowServer is unavailable"
exit 24
fi
xcrun simctl list devices available
不要把用户不一致当成普通警告继续执行。后续失败通常只会产生更模糊的 XCTest 错误,反而丢失最有价值的根因。
固定模拟器而不是依赖模糊名称
GUI 会话正常后,再处理设备选择。只写 name=iPhone 容易在安装多个运行时时产生歧义。先创建或选定一个专用设备,保存其 UDID,然后等待启动完成:
DEVICE_UDID="${IOS_TEST_DEVICE_UDID:?missing device udid}"
xcrun simctl boot "${DEVICE_UDID}" 2>/dev/null || true
xcrun simctl bootstatus "${DEVICE_UDID}" -b
xcodebuild test \
-workspace App.xcworkspace \
-scheme AppUITests \
-destination "platform=iOS Simulator,id=${DEVICE_UDID}" \
-resultBundlePath "$PWD/artifacts/ui-tests.xcresult"
simctl boot 返回不代表系统已可运行测试,真正的同步点是 bootstatus -b。并行任务还应使用不同设备 UDID,避免两个任务争用同一个模拟器的数据目录。任务结束后可以关机,但不要在其他作业仍运行时执行全局设备清理。
保留三层证据
排查时至少保留三层材料:预检脚本输出、xcodebuild 原始日志和 .xcresult。如果测试宿主没有启动,记录以下进程也有帮助:
ps -axo user,pid,ppid,command | \
grep -E 'XCTest|Simulator|CoreSimulator' | \
grep -v grep > artifacts/ui-processes.txt
这些信息能回答“测试是否进入过模拟器”,而不只是告诉你命令最终失败。
让任务从用户 GUI 域执行
稳定做法不是让每个 SSH 会话直接启动 UI 测试,而是让已登录用户域中的 LaunchAgent 接收任务。SSH 只负责写入任务描述或触发队列,LaunchAgent 在固定用户、固定工作目录和固定环境变量下执行脚本。
LaunchAgent 应明确设置 WorkingDirectory,并把标准输出和错误输出写到任务目录。工具路径不要依赖交互式 Shell 的启动文件;在脚本中显式设置 PATH,并用 xcode-select -p 记录当前开发工具目录。这样可以把“SSH 能运行”与“GUI 自动化能运行”拆成两个独立契约。
不建议用系统级 LaunchDaemon 直接拉起 XCUITest,也不要把 nohup 当作进入 GUI 会话的方法。它们可以让进程继续存在,却不会自动改变进程所属的启动域。
建立接单前的最小验收
一台节点在接收 UI 测试前,可以按以下顺序做准入:
- 控制台用户与任务用户一致。
gui/<uid>可由当前任务查询。- WindowServer 存在。
xcode-select -p指向预期工具目录。- 目标模拟器 UDID 存在且可完成
bootstatus。 - 产物目录可写,并为本次任务使用独立路径。
- 执行一个只启动应用并检查首屏元素的轻量测试。
如果第七步失败,先归档现场,再决定是否重建该专用模拟器。不要把删除全部设备、清空所有缓存当作默认恢复动作;这会掩盖启动域问题,也可能影响同机的其他任务。
把图形会话预检放到流水线入口后,XCUITest 的“无法启动”就不再是一个笼统故障。任务会在控制台用户、GUI 域、WindowServer或模拟器阶段给出明确失败点,后续排查也能基于同一组证据进行。
常见问题
为什么 SSH 中可以编译,却无法启动 XCUITest?
编译主要依赖命令行工具,而 UI 测试还需要可用的登录用户、launchd GUI 域、WindowServer 和模拟器运行环境。SSH 进程若位于非 GUI 启动域,测试宿主可能无法正常附着。
只看到 WindowServer 进程就能确认 UI 测试环境正常吗?
不能。还应确认当前控制台用户不是 loginwindow、对应的 gui/UID 域可查询、测试进程由该用户运行,并且目标模拟器已完成启动。
自动化任务应该怎样进入正确的 GUI 会话?
让已登录用户域中的 LaunchAgent 接收任务并执行测试,不要从系统级守护进程或临时 SSH Shell 直接拉起 UI 测试。任务入口和产物目录应固定并记录运行用户。
为下一项开发任务配置云端 Mac
选择 Oak M4、Oak M4 Plus 或 Oak M4 Pro,并按团队位置从五个物理节点中配置租用方案。