Use NSWorkspace to launch app in display resolution UI test

XCUIApplication.launch() hard-fails with a 60-second timeout when
it can't foreground the app on headless CI runners. This test never
uses XCUIApplication for interaction (no taps/keys) — it only reads
a diagnostics file.

Replace with NSWorkspace.openApplication which launches through
Launch Services, passes env vars via OpenConfiguration, and returns
immediately without blocking on activation failure.

Also add CI retry loop since runner environment is flaky.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
austinpower1258 2026-03-23 02:48:31 -07:00
parent fc858fcfa4
commit ae064802c6
2 changed files with 95 additions and 30 deletions

View file

@ -495,12 +495,23 @@ jobs:
{"helperBinaryPath":"$HELPER_PATH"}
EOF
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
test
for attempt in 1 2; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
test; then
exit 0
fi
if [ "$attempt" -eq 2 ]; then
echo "Display resolution UI regression failed after 2 attempts" >&2
exit 1
fi
echo "Attempt $attempt failed, retrying..."
pkill -x "cmux DEV" 2>/dev/null || true
sleep 3
done
- name: Run browser find focus UI regression
run: |

View file

@ -1,5 +1,6 @@
import XCTest
import Foundation
import AppKit
final class DisplayResolutionRegressionUITests: XCTestCase {
private let defaultDisplayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
@ -11,7 +12,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
private var displayDonePath = ""
private var helperBinaryPath = ""
private var helperLogPath = ""
private var launchedApp: XCUIApplication?
private var launchedRunningApp: NSRunningApplication?
private var helperProcess: Process?
override func setUp() {
@ -239,18 +240,70 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
helperProcess = proc
}
// Launch the app via NSWorkspace instead of XCUIApplication to avoid
// the 60-second foreground activation timeout that kills UI tests on
// headless CI runners. NSWorkspace.openApplication passes environment
// variables through OpenConfiguration and returns immediately.
private func launchAppProcess(targetDisplayID: String) throws {
let app = XCUIApplication()
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
app.launchEnvironment[key] = value
let appBundlePath = try resolveAppBundlePath()
let appURL = URL(fileURLWithPath: appBundlePath)
let config = NSWorkspace.OpenConfiguration()
config.environment = launchEnvironment(targetDisplayID: targetDisplayID)
config.activates = true
let semaphore = DispatchSemaphore(value: 0)
var launchError: Error?
var runningApp: NSRunningApplication?
NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in
runningApp = app
launchError = error
semaphore.signal()
}
app.launch()
guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
let waitResult = semaphore.wait(timeout: .now() + 30.0)
if waitResult == .timedOut {
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
NSLocalizedDescriptionKey: "NSWorkspace.openApplication timed out after 30s for \(appBundlePath)"
])
}
launchedApp = app
if let error = launchError {
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "NSWorkspace.openApplication failed: \(error.localizedDescription) path=\(appBundlePath)"
])
}
launchedRunningApp = runningApp
if !waitForAppLaunchDiagnostics(timeout: 15.0) {
let isAlive = launchedRunningApp?.isTerminated == false
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:])"
])
}
}
private func resolveAppBundlePath() throws -> String {
// UI test bundle is at:
// .../Build/Products/Debug/cmuxUITests-Runner.app/Contents/PlugIns/cmuxUITests.xctest
// The app is at:
// .../Build/Products/Debug/cmux DEV.app
let testBundle = Bundle(for: Self.self)
let productsDir = testBundle.bundleURL
.deletingLastPathComponent() // -> .../Contents/PlugIns
.deletingLastPathComponent() // -> .../Contents
.deletingLastPathComponent() // -> .../cmuxUITests-Runner.app
.deletingLastPathComponent() // -> .../Debug
let appPath = productsDir.appendingPathComponent("cmux DEV.app").path
if FileManager.default.fileExists(atPath: appPath) {
return appPath
}
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 4, userInfo: [
NSLocalizedDescriptionKey: "App bundle not found at \(appPath). testBundle=\(testBundle.bundleURL.path)"
])
}
private func launchEnvironment(targetDisplayID: String) -> [String: String] {
@ -264,31 +317,32 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
}
private func terminateLaunchedAppIfNeeded() {
guard let launchedApp else { return }
defer { self.launchedApp = nil }
guard let app = launchedRunningApp else { return }
defer { launchedRunningApp = nil }
if launchedApp.state == .notRunning {
return
if app.isTerminated { return }
app.terminate()
let deadline = Date().addingTimeInterval(5.0)
while !app.isTerminated && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
if !app.isTerminated {
app.forceTerminate()
}
launchedApp.terminate()
_ = launchedApp.wait(for: .notRunning, timeout: 5.0)
}
private func launchedAppDiagnostics() -> String {
guard let launchedApp else { return "not-launched" }
return "state=\(launchedApp.state.rawValue)"
guard let app = launchedRunningApp else { return "not-launched" }
return "pid=\(app.processIdentifier) terminated=\(app.isTerminated)"
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
private func waitForAppLaunchDiagnostics(timeout: TimeInterval) -> Bool {
waitForCondition(timeout: timeout) {
guard let diagnostics = self.loadDiagnostics() else { return false }
guard let pid = diagnostics["pid"], !pid.isEmpty else { return false }
guard let stage = diagnostics["stage"], !stage.isEmpty else { return false }
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool {