Use NSWorkspace with activates=false to launch app in UI test

Root cause: Process inherits the XCTest runner's sandbox, preventing
the app from writing diagnostics to /tmp/. NSWorkspace.openApplication
goes through LaunchServices, which launches the app in its own process
context outside the sandbox. Using activates=false avoids the 60s
foreground activation timeout that killed the previous NSWorkspace
attempt on headless CI runners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
austinpower1258 2026-03-23 03:34:56 -07:00
parent eee5862ca7
commit 79aae4fe83

View file

@ -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) ?? "<empty>"
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 {