11import XCTest
22import Foundation
3+ import Darwin
34
45final class DisplayResolutionRegressionUITests : XCTestCase {
56 private let defaultDisplayHarnessManifestPath = " /tmp/cmux-ui-test-display-harness.json "
@@ -11,7 +12,8 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
1112 private var displayDonePath = " "
1213 private var helperBinaryPath = " "
1314 private var helperLogPath = " "
14- private var launchedApp : XCUIApplication ?
15+ private var appLogPath = " "
16+ private var launchedAppProcess : Process ?
1517 private var helperProcess : Process ?
1618
1719 override func setUp( ) {
@@ -30,15 +32,14 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
3032 displayDonePath = " \( tempPrefix) .done "
3133 helperBinaryPath = " \( tempPrefix) -helper "
3234 helperLogPath = " \( tempPrefix) -helper.log "
35+ appLogPath = " \( tempPrefix) -app.log "
3336
3437 removeTestArtifacts ( )
3538 }
3639
3740 override func tearDown( ) {
3841 terminateLaunchedAppIfNeeded ( )
39- helperProcess? . terminate ( )
40- helperProcess? . waitUntilExit ( )
41- helperProcess = nil
42+ terminateHelperProcessIfNeeded ( )
4243 removeTestArtifacts ( )
4344 super. tearDown ( )
4445 }
@@ -240,17 +241,93 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
240241 }
241242
242243 private func launchAppProcess( targetDisplayID: String ) throws {
243- let app = XCUIApplication ( )
244+ let proc = Process ( )
245+ proc. executableURL = URL ( fileURLWithPath: try resolveAppExecutablePath ( ) )
246+
247+ var environment = ProcessInfo . processInfo. environment
244248 for (key, value) in launchEnvironment ( targetDisplayID: targetDisplayID) {
245- app . launchEnvironment [ key] = value
249+ environment [ key] = value
246250 }
247- app. launch ( )
248- guard ensureForegroundAfterLaunch ( app, timeout: 12.0 ) else {
249- throw NSError ( domain: " DisplayResolutionRegressionUITests " , code: 2 , userInfo: [
250- NSLocalizedDescriptionKey: " XCUIApplication failed to reach foreground. state= \( app. state. rawValue) "
251- ] )
251+ proc. environment = environment
252+
253+ let logHandle = FileHandle ( forWritingAtPath: appLogPath) ?? {
254+ FileManager . default. createFile ( atPath: appLogPath, contents: nil )
255+ return FileHandle ( forWritingAtPath: appLogPath)
256+ } ( )
257+ proc. standardOutput = logHandle
258+ proc. standardError = logHandle
259+
260+ try proc. run ( )
261+ launchedAppProcess = proc
262+ }
263+
264+ private func resolveAppExecutablePath( ) throws -> String {
265+ let env = ProcessInfo . processInfo. environment
266+ let fileManager = FileManager . default
267+ var candidateProductsDirectories : [ String ] = [ ]
268+
269+ if let builtProductsDir = env [ " BUILT_PRODUCTS_DIR " ] , !builtProductsDir. isEmpty {
270+ candidateProductsDirectories. append ( builtProductsDir)
271+ }
272+
273+ if let testHost = env [ " TEST_HOST " ] , !testHost. isEmpty {
274+ let hostURL = URL ( fileURLWithPath: testHost)
275+ candidateProductsDirectories. append (
276+ hostURL
277+ . deletingLastPathComponent ( )
278+ . deletingLastPathComponent ( )
279+ . deletingLastPathComponent ( )
280+ . deletingLastPathComponent ( )
281+ . path
282+ )
252283 }
253- launchedApp = app
284+
285+ candidateProductsDirectories. append ( contentsOf: inferredBuildProductsDirectories ( ) )
286+
287+ for productsDir in uniquePaths ( candidateProductsDirectories) {
288+ let appExecutablePath = URL ( fileURLWithPath: productsDir)
289+ . appendingPathComponent ( " cmux DEV.app " )
290+ . appendingPathComponent ( " Contents/MacOS/cmux DEV " )
291+ . path
292+ if fileManager. isExecutableFile ( atPath: appExecutablePath) {
293+ return appExecutablePath
294+ }
295+ }
296+
297+ throw NSError ( domain: " DisplayResolutionRegressionUITests " , code: 4 , userInfo: [
298+ NSLocalizedDescriptionKey: " Unable to resolve cmux DEV executable from UI test environment "
299+ ] )
300+ }
301+
302+ private func inferredBuildProductsDirectories( ) -> [ String ] {
303+ let bundleURLs = [
304+ Bundle . main. bundleURL,
305+ Bundle ( for: Self . self) . bundleURL,
306+ ]
307+
308+ return bundleURLs. compactMap { bundleURL in
309+ let standardizedPath = bundleURL. standardizedFileURL. path
310+ let components = standardizedPath. split ( separator: " / " )
311+ guard let productsIndex = components. firstIndex ( of: " Products " ) ,
312+ productsIndex + 1 < components. count else {
313+ return nil
314+ }
315+ return " / " + components. prefix ( productsIndex + 2 ) . joined ( separator: " / " )
316+ }
317+ }
318+
319+ private func uniquePaths( _ paths: [ String ] ) -> [ String ] {
320+ var seen = Set < String > ( )
321+ var ordered : [ String ] = [ ]
322+ for rawPath in paths {
323+ let trimmed = rawPath. trimmingCharacters ( in: . whitespacesAndNewlines)
324+ guard !trimmed. isEmpty else { continue }
325+ let path = URL ( fileURLWithPath: trimmed) . resolvingSymlinksInPath ( ) . path
326+ if seen. insert ( path) . inserted {
327+ ordered. append ( path)
328+ }
329+ }
330+ return ordered
254331 }
255332
256333 private func launchEnvironment( targetDisplayID: String ) -> [ String : String ] {
@@ -264,31 +341,44 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
264341 }
265342
266343 private func terminateLaunchedAppIfNeeded( ) {
267- guard let launchedApp else { return }
268- defer { self . launchedApp = nil }
344+ terminateProcessIfNeeded ( & launchedAppProcess , timeout : 5.0 )
345+ }
269346
270- if launchedApp. state == . notRunning {
271- return
347+ private func terminateHelperProcessIfNeeded( ) {
348+ terminateProcessIfNeeded ( & helperProcess, timeout: 2.0 )
349+ }
350+
351+ private func terminateProcessIfNeeded( _ processRef: inout Process ? , timeout: TimeInterval ) {
352+ guard let process = processRef else { return }
353+ defer { processRef = nil }
354+
355+ guard process. isRunning else { return }
356+
357+ process. terminate ( )
358+ waitForProcessExit ( process, timeout: timeout)
359+
360+ if process. isRunning {
361+ process. interrupt ( )
362+ waitForProcessExit ( process, timeout: 1.0 )
272363 }
273364
274- launchedApp. terminate ( )
275- _ = launchedApp. wait ( for: . notRunning, timeout: 5.0 )
365+ if process. isRunning {
366+ _ = kill ( process. processIdentifier, SIGKILL)
367+ waitForProcessExit ( process, timeout: 1.0 )
368+ }
276369 }
277370
278- private func launchedAppDiagnostics( ) -> String {
279- guard let launchedApp else { return " not-launched " }
280- return " state= \( launchedApp. state. rawValue) "
371+ private func waitForProcessExit( _ process: Process , timeout: TimeInterval ) {
372+ let deadline = Date ( ) . addingTimeInterval ( timeout)
373+ while process. isRunning && Date ( ) < deadline {
374+ RunLoop . current. run ( until: Date ( ) . addingTimeInterval ( 0.1 ) )
375+ }
281376 }
282377
283- private func ensureForegroundAfterLaunch( _ app: XCUIApplication , timeout: TimeInterval ) -> Bool {
284- if app. wait ( for: . runningForeground, timeout: timeout) {
285- return true
286- }
287- if app. state == . runningBackground {
288- app. activate ( )
289- return app. wait ( for: . runningForeground, timeout: 6.0 )
290- }
291- return false
378+ private func launchedAppDiagnostics( ) -> String {
379+ guard let launchedAppProcess else { return " not-launched " }
380+ let log = readTrimmedFile ( atPath: appLogPath) ?? " <missing> "
381+ return " pid= \( launchedAppProcess. processIdentifier) running= \( launchedAppProcess. isRunning) status= \( launchedAppProcess. terminationStatus) log= \( log) "
292382 }
293383
294384 private func waitForTargetDisplayMove( targetDisplayID: String , timeout: TimeInterval ) -> Bool {
@@ -364,6 +454,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
364454 displayDonePath,
365455 helperBinaryPath,
366456 helperLogPath,
457+ appLogPath,
367458 ] {
368459 guard !path. isEmpty else { continue }
369460 try ? FileManager . default. removeItem ( atPath: path)
0 commit comments