diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 5131e82c..1df755df 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 appProcess: Process? + private var launchedRunningApp: NSRunningApplication? private var helperProcess: Process? override func setUp() { @@ -35,7 +36,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase { } override func tearDown() { - terminateAppProcess() + terminateLaunchedApp() helperProcess?.terminate() helperProcess?.waitUntilExit() helperProcess = nil @@ -239,69 +240,71 @@ final class DisplayResolutionRegressionUITests: XCTestCase { helperProcess = proc } - // Launch the app binary directly via Process, matching what smoke-test-ci.sh - // does. XCUIApplication.launch() and NSWorkspace.openApplication both require - // foreground activation which fails on headless WarpBuild CI runners. - // Running the binary directly works because it doesn't need WindowServer activation. + // Launch via NSWorkspace.openApplication which goes through LaunchServices, + // escaping the test runner's sandbox. XCUIApplication.launch() blocks for 60s + // on headless CI trying to foreground-activate. Process inherits the test + // runner's sandbox, preventing file writes. NSWorkspace with activates=false + // avoids both problems. private func launchAppProcess(targetDisplayID: String) throws { - let binaryPath = try resolveAppBinaryPath() - let appEnv = launchEnvironment(targetDisplayID: targetDisplayID) + let appBundlePath = try resolveAppBundlePath() + let appURL = URL(fileURLWithPath: appBundlePath) - // Build a clean environment with only system essentials + our test vars. - // Do NOT pass the test runner's full environment — it contains XCTest - // variables (DYLD_INSERT_LIBRARIES, XCInjectBundle, etc.) that cause the - // app to hang when launched via Process. - let runnerEnv = ProcessInfo.processInfo.environment - var cleanEnv = appEnv - for key in ["HOME", "PATH", "TMPDIR", "USER", "SHELL", "LANG", - "TERM", "LOGNAME", "DISPLAY", "__CF_USER_TEXT_ENCODING"] { - if let val = runnerEnv[key] { cleanEnv[key] = val } + let config = NSWorkspace.OpenConfiguration() + config.environment = launchEnvironment(targetDisplayID: targetDisplayID) + config.activates = false + config.addsToRecentItems = false + + 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() } - let proc = Process() - proc.executableURL = URL(fileURLWithPath: binaryPath) - proc.environment = cleanEnv + let waitResult = semaphore.wait(timeout: .now() + 30.0) + if waitResult == .timedOut { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "NSWorkspace.openApplication timed out after 30s for \(appBundlePath)" + ]) + } - let logPath = "/tmp/cmux-ui-test-app-\(launchTag).log" - FileManager.default.createFile(atPath: logPath, contents: nil) - let logHandle = FileHandle(forWritingAtPath: logPath) - proc.standardOutput = logHandle - proc.standardError = logHandle + if let error = launchError { + throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "NSWorkspace.openApplication failed: \(error.localizedDescription) path=\(appBundlePath)" + ]) + } - try proc.run() - appProcess = proc + launchedRunningApp = runningApp if !waitForAppLaunchDiagnostics(timeout: 15.0) { - let isAlive = proc.isRunning - let appLog = (try? String(contentsOfFile: logPath, encoding: .utf8))?.suffix(2000) ?? "" - let envDump = cleanEnv.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value.prefix(80))" }.joined(separator: " | ") + let isAlive = launchedRunningApp?.isTerminated == false throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:]) binary=\(binaryPath) env=[\(envDump)] appLog=[\(appLog)]" + NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:]) pid=\(runningApp?.processIdentifier ?? -1)" ]) } } - private func resolveAppBinaryPath() throws -> String { + private func resolveAppBundlePath() throws -> String { // UI test bundle is at: // .../Build/Products/Debug/cmuxUITests-Runner.app/Contents/PlugIns/cmuxUITests.xctest - // The app binary is at: - // .../Build/Products/Debug/cmux DEV.app/Contents/MacOS/cmux DEV + // 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 binaryPath = productsDir - .appendingPathComponent("cmux DEV.app") - .appendingPathComponent("Contents/MacOS/cmux DEV") - .path - if FileManager.default.fileExists(atPath: binaryPath) { - return binaryPath + 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 binary not found at \(binaryPath). testBundle=\(testBundle.bundleURL.path)" + NSLocalizedDescriptionKey: "App bundle not found at \(appPath). testBundle=\(testBundle.bundleURL.path)" ]) } @@ -315,24 +318,24 @@ final class DisplayResolutionRegressionUITests: XCTestCase { ] } - private func terminateAppProcess() { - guard let proc = appProcess else { return } - defer { appProcess = nil } + private func terminateLaunchedApp() { + guard let app = launchedRunningApp else { return } + defer { launchedRunningApp = nil } - if !proc.isRunning { return } - proc.terminate() + if app.isTerminated { return } + app.terminate() let deadline = Date().addingTimeInterval(5.0) - while proc.isRunning && Date() < deadline { + while !app.isTerminated && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.1)) } - if proc.isRunning { - proc.interrupt() + if !app.isTerminated { + app.forceTerminate() } } private func launchedAppDiagnostics() -> String { - guard let proc = appProcess else { return "not-launched" } - return "pid=\(proc.processIdentifier) running=\(proc.isRunning)" + guard let app = launchedRunningApp else { return "not-launched" } + return "pid=\(app.processIdentifier) terminated=\(app.isTerminated)" } private func waitForAppLaunchDiagnostics(timeout: TimeInterval) -> Bool {