11import XCTest
22import Foundation
3+ import AppKit
34
45final 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