Files
sanderling/companion/Sources/AppLifecycle.swift
T
pj 73736ab26f fix(ios): the runner must not answer ok for a launch that failed
XCTest records a refused launch as a test issue that never throws, so the
companion returned ok for an app that never started. Check the state the
app actually reached and report the refusal instead.
2026-08-15 20:49:31 +05:30

102 lines
3.6 KiB
Swift

import XCTest
// Launches and terminates apps through the automation session. Lifecycle
// performed outside the session leaves its cached app proxies bound to dead
// processes, after which snapshots hang and typing asserts.
enum AppLifecycle {
enum LifecycleError: Error {
case failed(String)
}
static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws {
var reached = XCUIApplication.State.unknown
try onMainCatching {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
if foregroundIfRunning {
// activate starts the app when needed and foregrounds it when
// already running, without a forced relaunch.
application.activate()
} else {
application.launch()
}
reached = application.state
}
// A refused launch is recorded as a test issue that never throws, so
// without this the runner answers ok for an app that is not running
// and the host learns nothing until its own bound expires.
switch reached {
case .runningForeground, .runningBackground, .runningBackgroundSuspended:
return
default:
throw LifecycleError.failed(
"\(bundleIdentifier) is \(name(of: reached)) after launch")
}
}
static func terminate(bundleIdentifier: String) throws {
try onMainCatching {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
if application.state == .notRunning {
return
}
application.terminate()
}
}
// state reports the app's run state in the vocabulary the Go transport maps
// to a process state: foreground, background, or notRunning. On the
// simulator the hybrid never calls it; on device it backs ForegroundApp.
static func state(bundleIdentifier: String) -> String {
var result = "notRunning"
let collect = {
switch XCUIApplication(bundleIdentifier: bundleIdentifier).state {
case .runningForeground:
result = "foreground"
case .runningBackground, .runningBackgroundSuspended:
result = "background"
default:
result = "notRunning"
}
}
if Thread.isMainThread {
collect()
} else {
DispatchQueue.main.sync(execute: collect)
}
return result
}
private static func name(of state: XCUIApplication.State) -> String {
switch state {
case .runningForeground:
return "foreground"
case .runningBackground:
return "background"
case .runningBackgroundSuspended:
return "suspended"
case .notRunning:
return "not running"
default:
return "unknown"
}
}
// onMainCatching runs automation work on the main thread and converts a
// framework assertion into a thrown error so the server survives it.
private static func onMainCatching(_ work: @escaping () -> Void) throws {
var caughtError: NSError?
var completed = false
let run = {
completed = CompanionRunCatching(work, &caughtError)
}
if Thread.isMainThread {
run()
} else {
DispatchQueue.main.sync(execute: run)
}
if !completed {
throw LifecycleError.failed(caughtError?.localizedDescription ?? "unknown")
}
}
}