From 6233f1b2f06d2866fb88085e7ffe2b7c62ba4df1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Mar 2026 03:43:26 -0700 Subject: [PATCH] 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 --- .../DisplayResolutionRegressionUITests.swift | 111 +++++++++--------- 1 file changed, 55 insertions(+), 56 deletions(-) diff --git a/cmuxUITests/DisplayResolutionRegressionUITests.swift b/cmuxUITests/DisplayResolutionRegressionUITests.swift index 1df755df..d417f0ab 100644 --- a/cmuxUITests/DisplayResolutionRegressionUITests.swift +++ b/cmuxUITests/DisplayResolutionRegressionUITests.swift @@ -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)) } ?? "" 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 {