Skip to content

Commit 642d6cb

Browse files
committed
Launch display regression app directly in UI test
1 parent 37b5e54 commit 642d6cb

1 file changed

Lines changed: 121 additions & 30 deletions

File tree

cmuxUITests/DisplayResolutionRegressionUITests.swift

Lines changed: 121 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import XCTest
22
import Foundation
3+
import Darwin
34

45
final class DisplayResolutionRegressionUITests: XCTestCase {
56
private let defaultDisplayHarnessManifestPath = "/tmp/cmux-ui-test-display-harness.json"
@@ -11,7 +12,8 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
1112
private var displayDonePath = ""
1213
private var helperBinaryPath = ""
1314
private var helperLogPath = ""
14-
private var launchedApp: XCUIApplication?
15+
private var appLogPath = ""
16+
private var launchedAppProcess: Process?
1517
private var helperProcess: Process?
1618

1719
override func setUp() {
@@ -30,15 +32,14 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
3032
displayDonePath = "\(tempPrefix).done"
3133
helperBinaryPath = "\(tempPrefix)-helper"
3234
helperLogPath = "\(tempPrefix)-helper.log"
35+
appLogPath = "\(tempPrefix)-app.log"
3336

3437
removeTestArtifacts()
3538
}
3639

3740
override func tearDown() {
3841
terminateLaunchedAppIfNeeded()
39-
helperProcess?.terminate()
40-
helperProcess?.waitUntilExit()
41-
helperProcess = nil
42+
terminateHelperProcessIfNeeded()
4243
removeTestArtifacts()
4344
super.tearDown()
4445
}
@@ -240,17 +241,93 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
240241
}
241242

242243
private func launchAppProcess(targetDisplayID: String) throws {
243-
let app = XCUIApplication()
244+
let proc = Process()
245+
proc.executableURL = URL(fileURLWithPath: try resolveAppExecutablePath())
246+
247+
var environment = ProcessInfo.processInfo.environment
244248
for (key, value) in launchEnvironment(targetDisplayID: targetDisplayID) {
245-
app.launchEnvironment[key] = value
249+
environment[key] = value
246250
}
247-
app.launch()
248-
guard ensureForegroundAfterLaunch(app, timeout: 12.0) else {
249-
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 2, userInfo: [
250-
NSLocalizedDescriptionKey: "XCUIApplication failed to reach foreground. state=\(app.state.rawValue)"
251-
])
251+
proc.environment = environment
252+
253+
let logHandle = FileHandle(forWritingAtPath: appLogPath) ?? {
254+
FileManager.default.createFile(atPath: appLogPath, contents: nil)
255+
return FileHandle(forWritingAtPath: appLogPath)
256+
}()
257+
proc.standardOutput = logHandle
258+
proc.standardError = logHandle
259+
260+
try proc.run()
261+
launchedAppProcess = proc
262+
}
263+
264+
private func resolveAppExecutablePath() throws -> String {
265+
let env = ProcessInfo.processInfo.environment
266+
let fileManager = FileManager.default
267+
var candidateProductsDirectories: [String] = []
268+
269+
if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty {
270+
candidateProductsDirectories.append(builtProductsDir)
271+
}
272+
273+
if let testHost = env["TEST_HOST"], !testHost.isEmpty {
274+
let hostURL = URL(fileURLWithPath: testHost)
275+
candidateProductsDirectories.append(
276+
hostURL
277+
.deletingLastPathComponent()
278+
.deletingLastPathComponent()
279+
.deletingLastPathComponent()
280+
.deletingLastPathComponent()
281+
.path
282+
)
252283
}
253-
launchedApp = app
284+
285+
candidateProductsDirectories.append(contentsOf: inferredBuildProductsDirectories())
286+
287+
for productsDir in uniquePaths(candidateProductsDirectories) {
288+
let appExecutablePath = URL(fileURLWithPath: productsDir)
289+
.appendingPathComponent("cmux DEV.app")
290+
.appendingPathComponent("Contents/MacOS/cmux DEV")
291+
.path
292+
if fileManager.isExecutableFile(atPath: appExecutablePath) {
293+
return appExecutablePath
294+
}
295+
}
296+
297+
throw NSError(domain: "DisplayResolutionRegressionUITests", code: 4, userInfo: [
298+
NSLocalizedDescriptionKey: "Unable to resolve cmux DEV executable from UI test environment"
299+
])
300+
}
301+
302+
private func inferredBuildProductsDirectories() -> [String] {
303+
let bundleURLs = [
304+
Bundle.main.bundleURL,
305+
Bundle(for: Self.self).bundleURL,
306+
]
307+
308+
return bundleURLs.compactMap { bundleURL in
309+
let standardizedPath = bundleURL.standardizedFileURL.path
310+
let components = standardizedPath.split(separator: "/")
311+
guard let productsIndex = components.firstIndex(of: "Products"),
312+
productsIndex + 1 < components.count else {
313+
return nil
314+
}
315+
return "/" + components.prefix(productsIndex + 2).joined(separator: "/")
316+
}
317+
}
318+
319+
private func uniquePaths(_ paths: [String]) -> [String] {
320+
var seen = Set<String>()
321+
var ordered: [String] = []
322+
for rawPath in paths {
323+
let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
324+
guard !trimmed.isEmpty else { continue }
325+
let path = URL(fileURLWithPath: trimmed).resolvingSymlinksInPath().path
326+
if seen.insert(path).inserted {
327+
ordered.append(path)
328+
}
329+
}
330+
return ordered
254331
}
255332

256333
private func launchEnvironment(targetDisplayID: String) -> [String: String] {
@@ -264,31 +341,44 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
264341
}
265342

266343
private func terminateLaunchedAppIfNeeded() {
267-
guard let launchedApp else { return }
268-
defer { self.launchedApp = nil }
344+
terminateProcessIfNeeded(&launchedAppProcess, timeout: 5.0)
345+
}
269346

270-
if launchedApp.state == .notRunning {
271-
return
347+
private func terminateHelperProcessIfNeeded() {
348+
terminateProcessIfNeeded(&helperProcess, timeout: 2.0)
349+
}
350+
351+
private func terminateProcessIfNeeded(_ processRef: inout Process?, timeout: TimeInterval) {
352+
guard let process = processRef else { return }
353+
defer { processRef = nil }
354+
355+
guard process.isRunning else { return }
356+
357+
process.terminate()
358+
waitForProcessExit(process, timeout: timeout)
359+
360+
if process.isRunning {
361+
process.interrupt()
362+
waitForProcessExit(process, timeout: 1.0)
272363
}
273364

274-
launchedApp.terminate()
275-
_ = launchedApp.wait(for: .notRunning, timeout: 5.0)
365+
if process.isRunning {
366+
_ = kill(process.processIdentifier, SIGKILL)
367+
waitForProcessExit(process, timeout: 1.0)
368+
}
276369
}
277370

278-
private func launchedAppDiagnostics() -> String {
279-
guard let launchedApp else { return "not-launched" }
280-
return "state=\(launchedApp.state.rawValue)"
371+
private func waitForProcessExit(_ process: Process, timeout: TimeInterval) {
372+
let deadline = Date().addingTimeInterval(timeout)
373+
while process.isRunning && Date() < deadline {
374+
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
375+
}
281376
}
282377

283-
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
284-
if app.wait(for: .runningForeground, timeout: timeout) {
285-
return true
286-
}
287-
if app.state == .runningBackground {
288-
app.activate()
289-
return app.wait(for: .runningForeground, timeout: 6.0)
290-
}
291-
return false
378+
private func launchedAppDiagnostics() -> String {
379+
guard let launchedAppProcess else { return "not-launched" }
380+
let log = readTrimmedFile(atPath: appLogPath) ?? "<missing>"
381+
return "pid=\(launchedAppProcess.processIdentifier) running=\(launchedAppProcess.isRunning) status=\(launchedAppProcess.terminationStatus) log=\(log)"
292382
}
293383

294384
private func waitForTargetDisplayMove(targetDisplayID: String, timeout: TimeInterval) -> Bool {
@@ -364,6 +454,7 @@ final class DisplayResolutionRegressionUITests: XCTestCase {
364454
displayDonePath,
365455
helperBinaryPath,
366456
helperLogPath,
457+
appLogPath,
367458
] {
368459
guard !path.isEmpty else { continue }
369460
try? FileManager.default.removeItem(atPath: path)

0 commit comments

Comments
 (0)