Use Process with sandbox-aware temp paths for UI test app launch
The Process-spawned app inherits the test runner's sandbox. Previous attempts failed because diagnostics used hardcoded /tmp/ which the sandboxed app can't write to. Now using FileManager.temporaryDirectory for all temp paths (resolves to the sandbox container's tmp), and inheriting the full test runner environment so the child shares the same sandbox context. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79aae4fe83
commit
6233f1b2f0
1 changed files with 55 additions and 56 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
final class DisplayResolutionRegressionUITests: XCTestCase {
|
||||
private let defaultDisplayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
|
||||
|
|
@ -12,7 +11,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
private var displayDonePath = ""
|
||||
private var helperBinaryPath = ""
|
||||
private var helperLogPath = ""
|
||||
private var launchedRunningApp: NSRunningApplication?
|
||||
private var appProcess: Process?
|
||||
private var helperProcess: Process?
|
||||
|
||||
override func setUp() {
|
||||
|
|
@ -24,7 +23,9 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
.appendingPathComponent("cmux-ui-test-display-\(token)")
|
||||
.path
|
||||
launchTag = "ui-tests-display-resolution-\(token.prefix(8))"
|
||||
diagnosticsPath = "/tmp/cmux-ui-test-display-churn-\(token).json"
|
||||
diagnosticsPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-display-churn-\(token).json")
|
||||
.path
|
||||
displayReadyPath = "\(tempPrefix).ready"
|
||||
displayIDPath = "\(tempPrefix).id"
|
||||
displayStartPath = "\(tempPrefix).start"
|
||||
|
|
@ -36,7 +37,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
}
|
||||
|
||||
override func tearDown() {
|
||||
terminateLaunchedApp()
|
||||
terminateAppProcess()
|
||||
helperProcess?.terminate()
|
||||
helperProcess?.waitUntilExit()
|
||||
helperProcess = nil
|
||||
|
|
@ -240,71 +241,69 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
helperProcess = proc
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Launch the app binary directly via Process. XCUIApplication.launch() blocks
|
||||
// for 60s on headless CI trying to foreground-activate. NSWorkspace.openApplication
|
||||
// causes the app to die immediately on headless runners. Process works because
|
||||
// it doesn't need WindowServer activation.
|
||||
//
|
||||
// The child process inherits the test runner's sandbox, so all temp file paths
|
||||
// (diagnostics, etc.) must use FileManager.default.temporaryDirectory (which
|
||||
// resolves to the sandbox container's tmp) instead of hardcoded /tmp/.
|
||||
private func launchAppProcess(targetDisplayID: String) throws {
|
||||
let appBundlePath = try resolveAppBundlePath()
|
||||
let appURL = URL(fileURLWithPath: appBundlePath)
|
||||
let binaryPath = try resolveAppBinaryPath()
|
||||
|
||||
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)
|
||||
// Don't set proc.environment — inherit the test runner's full environment
|
||||
// so the child gets the same sandbox context and system state. Just add our
|
||||
// test-specific vars on top.
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
|
||||
env[key] = value
|
||||
}
|
||||
proc.environment = env
|
||||
|
||||
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 = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-app-\(launchTag).log").path
|
||||
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)"
|
||||
])
|
||||
}
|
||||
|
||||
launchedRunningApp = runningApp
|
||||
try proc.run()
|
||||
appProcess = proc
|
||||
|
||||
if !waitForAppLaunchDiagnostics(timeout: 15.0) {
|
||||
let isAlive = launchedRunningApp?.isTerminated == false
|
||||
let isAlive = proc.isRunning
|
||||
let appLog = (try? String(contentsOfFile: logPath, encoding: .utf8))
|
||||
.map { String($0.suffix(2000)) } ?? "<empty>"
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:]) pid=\(runningApp?.processIdentifier ?? -1)"
|
||||
NSLocalizedDescriptionKey: "App failed to write launch diagnostics. alive=\(isAlive) diagnostics=\(loadDiagnostics() ?? [:]) diagPath=\(diagnosticsPath) appLog=[\(appLog)]"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveAppBundlePath() throws -> String {
|
||||
private func resolveAppBinaryPath() throws -> String {
|
||||
// UI test bundle is at:
|
||||
// .../Build/Products/Debug/cmuxUITests-Runner.app/Contents/PlugIns/cmuxUITests.xctest
|
||||
// The app is at:
|
||||
// .../Build/Products/Debug/cmux DEV.app
|
||||
// The app binary is at:
|
||||
// .../Build/Products/Debug/cmux DEV.app/Contents/MacOS/cmux DEV
|
||||
let testBundle = Bundle(for: Self.self)
|
||||
let productsDir = testBundle.bundleURL
|
||||
.deletingLastPathComponent() // -> .../Contents/PlugIns
|
||||
.deletingLastPathComponent() // -> .../Contents
|
||||
.deletingLastPathComponent() // -> .../cmuxUITests-Runner.app
|
||||
.deletingLastPathComponent() // -> .../Debug
|
||||
let appPath = productsDir.appendingPathComponent("cmux DEV.app").path
|
||||
if FileManager.default.fileExists(atPath: appPath) {
|
||||
return appPath
|
||||
let binaryPath = productsDir
|
||||
.appendingPathComponent("cmux DEV.app")
|
||||
.appendingPathComponent("Contents/MacOS/cmux DEV")
|
||||
.path
|
||||
if FileManager.default.fileExists(atPath: binaryPath) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "App bundle not found at \(appPath). testBundle=\(testBundle.bundleURL.path)"
|
||||
NSLocalizedDescriptionKey: "App binary not found at \(binaryPath). testBundle=\(testBundle.bundleURL.path)"
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -318,24 +317,24 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
|
|||
]
|
||||
}
|
||||
|
||||
private func terminateLaunchedApp() {
|
||||
guard let app = launchedRunningApp else { return }
|
||||
defer { launchedRunningApp = nil }
|
||||
private func terminateAppProcess() {
|
||||
guard let proc = appProcess else { return }
|
||||
defer { appProcess = nil }
|
||||
|
||||
if app.isTerminated { return }
|
||||
app.terminate()
|
||||
if !proc.isRunning { return }
|
||||
proc.terminate()
|
||||
let deadline = Date().addingTimeInterval(5.0)
|
||||
while !app.isTerminated && Date() < deadline {
|
||||
while proc.isRunning && Date() < deadline {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||
}
|
||||
if !app.isTerminated {
|
||||
app.forceTerminate()
|
||||
if proc.isRunning {
|
||||
proc.interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
private func launchedAppDiagnostics() -> String {
|
||||
guard let app = launchedRunningApp else { return "not-launched" }
|
||||
return "pid=\(app.processIdentifier) terminated=\(app.isTerminated)"
|
||||
guard let proc = appProcess else { return "not-launched" }
|
||||
return "pid=\(proc.processIdentifier) running=\(proc.isRunning)"
|
||||
}
|
||||
|
||||
private func waitForAppLaunchDiagnostics(timeout: TimeInterval) -> Bool {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue