Skip to content

Commit ae06480

Browse files
austinywangclaude
andcommitted
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>
1 parent fc858fc commit ae06480

2 files changed

Lines changed: 96 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -495,12 +495,23 @@ jobs:
495495
{"helperBinaryPath":"$HELPER_PATH"}
496496
EOF
497497
498-
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
499-
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
500-
-disableAutomaticPackageResolution \
501-
-destination "platform=macOS" \
502-
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
503-
test
498+
for attempt in 1 2; do
499+
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
500+
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
501+
-disableAutomaticPackageResolution \
502+
-destination "platform=macOS" \
503+
-only-testing:cmuxUITests/DisplayResolutionRegressionUITests \
504+
test; then
505+
exit 0
506+
fi
507+
if [ "$attempt" -eq 2 ]; then
508+
echo "Display resolution UI regression failed after 2 attempts" >&2
509+
exit 1
510+
fi
511+
echo "Attempt $attempt failed, retrying..."
512+
pkill -x "cmux DEV" 2>/dev/null || true
513+
sleep 3
514+
done
504515
505516
- name: Run browser find focus UI regression
506517
run: |

cmuxUITests/DisplayResolutionRegressionUITests.swift

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import XCTest
22
import Foundation
3+
import AppKit
34

45
final class DisplayResolutionRegressionUITests: XCTestCase {
56
private let defaultDisplayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
@@ -11,7 +12,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
1112
private var displayDonePath = ""
1213
private var helperBinaryPath = ""
1314
private var helperLogPath = ""
14-
private var launchedApp: XCUIApplication?
15+
private var launchedRunningApp: NSRunningApplication?
1516
private var helperProcess: Process?
1617

1718
override func setUp() {
@@ -239,18 +240,70 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
239240
helperProcess = proc
240241
}
241242

243+
// Launch the app via NSWorkspace instead of XCUIApplication to avoid
244+
// the 60-second foreground activation timeout that kills UI tests on
245+
// headless CI runners. NSWorkspace.openApplication passes environment
246+
// variables through OpenConfiguration and returns immediately.
242247
private func launchAppProcess(targetDisplayID: String) throws {
243-
let app = XCUIApplication()
244-
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
245-
app.launchEnvironment[key] = value
248+
let appBundlePath = try resolveAppBundlePath()
249+
let appURL = URL(fileURLWithPath: appBundlePath)
250+
251+
let config = NSWorkspace.OpenConfiguration()
252+
config.environment = launchEnvironment(targetDisplayID: targetDisplayID)
253+
config.activates = true
254+
255+
let semaphore = DispatchSemaphore(value: 0)
256+
var launchError: Error?
257+
var runningApp: NSRunningApplication?
258+
259+
NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in
260+
runningApp = app
261+
launchError = error
262+
semaphore.signal()
263+
}
264+
265+
let waitResult = semaphore.wait(timeout: .now() + 30.0)
266+
if waitResult == .timedOut {
267+
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
268+
NSLocalizedDescriptionKey: "NSWorkspace.openApplication timed out after 30s for \(appBundlePath)"
269+
])
270+
}
271+
272+
if let error = launchError {
273+
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
274+
NSLocalizedDescriptionKey: "NSWorkspace.openApplication failed: \(error.localizedDescription) path=\(appBundlePath)"
275+
])
246276
}
247-
app.launch()
248-
guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
277+
278+
launchedRunningApp = runningApp
279+
280+
if !waitForAppLaunchDiagnostics(timeout: 15.0) {
281+
let isAlive = launchedRunningApp?.isTerminated == false
249282
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
250-
NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
283+
NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:])"
251284
])
252285
}
253-
launchedApp = app
286+
}
287+
288+
private func resolveAppBundlePath() throws -> String {
289+
// UI test bundle is at:
290+
// .../Build/Products/Debug/cmuxUITests-Runner.app/Contents/PlugIns/cmuxUITests.xctest
291+
// The app is at:
292+
// .../Build/Products/Debug/cmux DEV.app
293+
let testBundle = Bundle(for: Self.self)
294+
let productsDir = testBundle.bundleURL
295+
.deletingLastPathComponent() // -> .../Contents/PlugIns
296+
.deletingLastPathComponent() // -> .../Contents
297+
.deletingLastPathComponent() // -> .../cmuxUITests-Runner.app
298+
.deletingLastPathComponent() // -> .../Debug
299+
let appPath = productsDir.appendingPathComponent("cmux DEV.app").path
300+
if FileManager.default.fileExists(atPath: appPath) {
301+
return appPath
302+
}
303+
304+
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 4, userInfo: [
305+
NSLocalizedDescriptionKey: "App bundle not found at \(appPath). testBundle=\(testBundle.bundleURL.path)"
306+
])
254307
}
255308

256309
private func launchEnvironment(targetDisplayID: String) -> [String: String] {
@@ -264,31 +317,32 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
264317
}
265318

266319
private func terminateLaunchedAppIfNeeded() {
267-
guard let launchedApp else { return }
268-
defer { self.launchedApp = nil }
269-
270-
if launchedApp.state == .notRunning {
271-
return
320+
guard let app = launchedRunningApp else { return }
321+
defer { launchedRunningApp = nil }
322+
323+
if app.isTerminated { return }
324+
app.terminate()
325+
let deadline = Date().addingTimeInterval(5.0)
326+
while !app.isTerminated && Date() < deadline {
327+
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
328+
}
329+
if !app.isTerminated {
330+
app.forceTerminate()
272331
}
273-
274-
launchedApp.terminate()
275-
_ = launchedApp.wait(for: .notRunning, timeout: 5.0)
276332
}
277333

278334
private func launchedAppDiagnostics() -> String {
279-
guard let launchedApp else { return "not-launched" }
280-
return "state=\(launchedApp.state.rawValue)"
335+
guard let app = launchedRunningApp else { return "not-launched" }
336+
return "pid=\(app.processIdentifier) terminated=\(app.isTerminated)"
281337
}
282338

283-
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
284-
if app.wait(for: .runningForeground, timeout: timeout) {
339+
private func waitForAppLaunchDiagnostics(timeout: TimeInterval) -> Bool {
340+
waitForCondition(timeout: timeout) {
341+
guard let diagnostics = self.loadDiagnostics() else { return false }
342+
guard let pid = diagnostics["pid"], !pid.isEmpty else { return false }
343+
guard let stage = diagnostics["stage"], !stage.isEmpty else { return false }
285344
return true
286345
}
287-
if app.state == .runningBackground {
288-
app.activate()
289-
return app.wait(for: .runningForeground, timeout: 6.0)
290-
}
291-
return false
292346
}
293347

294348
private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool {

0 commit comments

Comments
 (0)