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:
parent
eee5862ca7
commit
79aae4fe83
1 changed files with 54 additions and 51 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue