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 {