开发任务笔记

云端 Mac 上诊断 XCUITest 启动失败:GUI 会话与 WindowServer 预检

云端 Mac 上诊断 XCUITest 启动失败:GUI 会话与 WindowServer 预检

同一台云端 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 测试前,可以按以下顺序做准入:

  1. 控制台用户与任务用户一致。
  2. gui/<uid> 可由当前任务查询。
  3. WindowServer 存在。
  4. xcode-select -p 指向预期工具目录。
  5. 目标模拟器 UDID 存在且可完成 bootstatus。
  6. 产物目录可写,并为本次任务使用独立路径。
  7. 执行一个只启动应用并检查首屏元素的轻量测试。

如果第七步失败,先归档现场,再决定是否重建该专用模拟器。不要把删除全部设备、清空所有缓存当作默认恢复动作;这会掩盖启动域问题,也可能影响同机的其他任务。

把图形会话预检放到流水线入口后,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,并按团队位置从五个物理节点中配置租用方案。

配置云端 Mac