From ae064802c64fc07fb5a36206ff1bf0c5a0a549ac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Mar 2026 02:48:31 -0700 Subject: [PATCH] Use NSWorkspace to launch app in display resolution UI test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 23 ++-- .../DisplayResolutionRegressionUITests.swift | 102 +++++++++++++----- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf24332..ff5fdb4c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 1cc5aecf..592b6a30 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -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 {