On the same cloud Mac, xcodebuild build may work over SSH while test hangs before the test runner launches: no test cases start, no Simulator window appears, and the logs show only an application launch or session connection failure. Do not clear DerivedData yet. The build pipeline has already succeeded; what you need to verify is whether the job is running in a usable macOS graphical session.
Separate the build environment from the graphical session
XCUITest does more than compile a test bundle. It must also launch the target application, test host, and Simulator services, then coordinate those processes through the current user's graphical session. A successful SSH login proves only that the remote shell is available. It does not guarantee that the shell belongs to the same launchd GUI domain as the user logged in to the desktop.
Start by recording four facts instead of immediately restarting every process:
| Check | Acceptable result | Sign of a problem |
|---|---|---|
| Console user | The user that actually runs the automation | root or loginwindow |
| GUI launch domain | gui/<uid> can be queried |
Domain is missing or inaccessible |
| WindowServer | Process is running | Login session has not been established |
| Target simulator | State is Booted |
Remains in Booting or Shutdown |
The presence of WindowServer is only one prerequisite. If the test job is launched by another user or a system-level daemon, it may still be unable to access the target user's graphical session.
Capture the environment with a preflight script
The following script does not modify system state, so it is suitable for the beginning of a test job. It writes the current execution user, console user, GUI domain, and available devices to the log, and terminates the job immediately if any check fails.
#!/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
Do not treat a user mismatch as an ordinary warning and continue. Subsequent failures usually produce less specific XCTest errors, obscuring the most valuable clue to the root cause.
Pin a simulator instead of relying on an ambiguous name
After confirming that the GUI session is available, address device selection. Specifying only name=iPhone can be ambiguous when multiple runtimes are installed. Create or select a dedicated device first, save its UDID, and then wait for it to finish booting:
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"
A successful return from simctl boot does not mean the system is ready to run tests. The actual synchronization point is bootstatus -b. Parallel jobs should also use different device UDIDs so that two jobs do not contend for the same simulator data directory. You can shut down the device when the job finishes, but do not perform a global device cleanup while other jobs are still running.
Preserve three layers of evidence
Keep at least three layers of evidence for troubleshooting: the preflight script output, the raw xcodebuild log, and the .xcresult bundle. If the test host does not launch, recording the following processes is also useful:
ps -axo user,pid,ppid,command | \
grep -E 'XCTest|Simulator|CoreSimulator' | \
grep -v grep > artifacts/ui-processes.txt
This information can show whether the test ever reached the simulator, rather than merely indicating that the command ultimately failed.
Run the job from the user's GUI domain
The reliable approach is not to launch UI tests directly from every SSH session. Instead, have a LaunchAgent in the logged-in user's domain receive the job. SSH should only write the job description or trigger a queue; the LaunchAgent runs the script as a fixed user, from a fixed working directory, and with a fixed set of environment variables.
The LaunchAgent should explicitly set WorkingDirectory and write standard output and standard error to the job directory. Tool paths should not depend on startup files for an interactive shell. Set PATH explicitly in the script, and use xcode-select -p to record the current developer tools directory. This separates “SSH can run commands” and “GUI automation can run” into two independent contracts.
Do not use a system-level LaunchDaemon to launch XCUITest directly, and do not treat nohup as a way to enter a GUI session. They can keep a process alive, but they do not automatically change the launch domain to which the process belongs.
Define minimum admission checks for jobs
Before a node accepts UI test jobs, apply the following admission checks in order:
- The console user and job user match.
- The current job can query
gui/<uid>. - WindowServer is running.
xcode-select -ppoints to the expected tools directory.- The target simulator UDID exists and can complete
bootstatus. - The artifact directory is writable and uses a dedicated path for this job.
- Run a lightweight test that only launches the application and checks an element on the initial screen.
If the seventh step fails, archive the current evidence before deciding whether to recreate the dedicated simulator. Do not make deleting every device and clearing every cache the default recovery action. Doing so can hide launch-domain problems and may disrupt other jobs on the same machine.
Once graphical-session preflight checks are placed at the pipeline entry point, an XCUITest “launch failure” is no longer an undifferentiated problem. The job will identify a specific failure at the console-user, GUI-domain, WindowServer, or simulator stage, and subsequent troubleshooting can proceed from the same body of evidence.
Frequently asked questions
Why can an SSH build succeed while XCUITest cannot launch?
Compilation mainly needs command-line tools. UI testing also depends on a logged-in console user, a valid launchd GUI domain, WindowServer, and a ready simulator runtime.
Is finding a WindowServer process enough to approve the runner?
No. Verify that the console owner is not loginwindow, gui/UID is available, the test runs as that user, and the selected simulator has completed booting.
How should automation enter the correct GUI session?
Have a LaunchAgent in the logged-in user domain receive and execute queued jobs. Do not launch UI tests directly from a system daemon or an incidental SSH shell.
Configure a Cloud Mac for Your Next Development Task
Choose Oak M4, Oak M4 Plus, or Oak M4 Pro, then configure a rental plan across five physical nodes based on your team's location.