Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions Sources/SwiftAgentKitSimulator/SimClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,42 @@ public struct SimDriverError: Error, LocalizedError, Sendable {
}
}

// MARK: - SimLifecycle

/// Launching and terminating an app, done natively on the host through
/// CoreSimulator (`simctl`), not through XCTest. `XCUIApplication.launch()`
/// is a test-harness call: on an app that isn't installed it recorded the
/// failure and then kept waiting (accessibility 120 s, a spindump, idle) —
/// 4 minutes inside one driver request, which also blocked the driver's
/// health checks so the host replaced it (2026-10-10). simctl answers in
/// about a second and says why it failed. The driver attaches to the
/// running app for UI work.
public struct SimLifecycle: Sendable {
public var isInstalled: @Sendable (String) async -> Bool
public var launch: @Sendable (String) async throws -> Void
public var terminate: @Sendable (String) async throws -> Void

public init(isInstalled: @escaping @Sendable (String) async -> Bool,
launch: @escaping @Sendable (String) async throws -> Void,
terminate: @escaping @Sendable (String) async throws -> Void) {
self.isInstalled = isInstalled; self.launch = launch; self.terminate = terminate
}

public static func simctl(udid: String) -> SimLifecycle {
SimLifecycle(isInstalled: { await Simctl.isInstalled(udid: udid, bundleId: $0) },
launch: { try await Simctl.launchApp(udid: udid, bundleId: $0) },
terminate: { try await Simctl.terminateApp(udid: udid, bundleId: $0) })
}
}

// MARK: - SimClient

public actor SimClient: SimDriving {
private let baseURL: URL
private let session: URLSession
/// Native app lifecycle (production: simctl on the client's device).
/// nil = the driver's /launch and /terminate routes (test stubs only).
private let lifecycle: SimLifecycle?
/// Relaunches the driver (production: `SimDriverManager.launch`, which
/// health-checks and replaces a dead session). nil = no recovery possible.
private let relaunch: (@Sendable () async throws -> Void)?
Expand All @@ -66,6 +97,7 @@ public actor SimClient: SimDriving {
self.baseURL = URL(string: "http://127.0.0.1:\(manager.port)")!
self.relaunch = { try await manager.launch(udid: udid, runtime: runtime) }
self.revealWindow = { await Simctl.ensureDeviceWindow(udid: udid) }
self.lifecycle = .simctl(udid: udid)
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 120 // /wait can legitimately take a while
self.session = URLSession(configuration: cfg)
Expand All @@ -77,10 +109,11 @@ public actor SimClient: SimDriving {
/// Test-only: point at a stub server, with an optional relaunch hook.
/// Uses a short-timeout session so a misbehaving stub fails fast instead of hanging.
init(baseURL: URL, relaunch: (@Sendable () async throws -> Void)? = nil,
revealWindow: (@Sendable () async -> Void)? = nil) {
revealWindow: (@Sendable () async -> Void)? = nil, lifecycle: SimLifecycle? = nil) {
self.baseURL = baseURL
self.relaunch = relaunch
self.revealWindow = revealWindow
self.lifecycle = lifecycle
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 10
self.session = URLSession(configuration: cfg)
Expand Down Expand Up @@ -139,15 +172,32 @@ public actor SimClient: SimDriving {
}

public func launch(bundleId: String, terminateFirst: Bool) async throws {
_ = try await post("/launch",
SimWire.LaunchRequest(bundleId: bundleId, terminateFirst: terminateFirst),
as: SimWire.OKResponse.self)
guard let lifecycle else {
_ = try await post("/launch",
SimWire.LaunchRequest(bundleId: bundleId, terminateFirst: terminateFirst),
as: SimWire.OKResponse.self)
return
}
guard await lifecycle.isInstalled(bundleId) else {
throw SimDriverError(code: "not_installed",
message: "\(bundleId) is not installed on this simulator (or the simulator is not booted). Build and install it with sim_build_install, then sim_launch.")
}
if terminateFirst { try? await lifecycle.terminate(bundleId) }
do {
try await lifecycle.launch(bundleId)
} catch {
throw SimDriverError(code: "launch_failed", message: "\(bundleId) did not launch: \(error.localizedDescription)")
}
}

public func terminate(bundleId: String) async throws {
_ = try await post("/terminate",
SimWire.LaunchRequest(bundleId: bundleId, terminateFirst: false),
as: SimWire.OKResponse.self)
guard let lifecycle else {
_ = try await post("/terminate",
SimWire.LaunchRequest(bundleId: bundleId, terminateFirst: false),
as: SimWire.OKResponse.self)
return
}
try await lifecycle.terminate(bundleId)
}

public func screenshot() async throws -> Data {
Expand Down Expand Up @@ -220,12 +270,25 @@ public actor SimClient: SimDriving {
try await relaunch?()
return try await send(req)
} catch {
guard consumeRelaunchBudget() else { throw error }
guard Self.mayResend(after: error, idempotent: idempotent), consumeRelaunchBudget() else { throw error }
try await relaunch?()
return try await send(req)
}
}

/// Whether a request that failed in transport may be sent again after the
/// driver is relaunched. One that never reached the driver may; one that
/// may already have run (a timeout, a dropped connection) only when it is
/// safe to repeat — a resent tap or text entry would happen twice.
nonisolated static func mayResend(after error: Error, idempotent: Bool) -> Bool {
if idempotent { return true }
guard let u = error as? URLError else { return false }
switch u.code {
case .cannotConnectToHost, .cannotFindHost, .notConnectedToInternet: return true
default: return false
}
}

private func send(_ req: URLRequest) async throws -> Data {
let (data, resp) = try await session.data(for: req)
let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
Expand Down
7 changes: 7 additions & 0 deletions Sources/SwiftAgentKitSimulator/Simctl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ public enum Simctl {
guard status == 0 else { throw SimctlError.commandFailed("simctl launch failed: \(out)") }
}

/// Whether the app is installed on the device (`simctl get_app_container`
/// exits non-zero for an unknown bundle id). Answers in about a second.
public static func isInstalled(udid: String, bundleId: String) async -> Bool {
guard let (status, _) = try? await run(["simctl", "get_app_container", udid, bundleId], timeout: 20) else { return false }
return status == 0
}

public static func terminateApp(udid: String, bundleId: String) async throws {
_ = try await run(["simctl", "terminate", udid, bundleId], timeout: 30) // non-zero if not running: fine
}
Expand Down
79 changes: 79 additions & 0 deletions Tests/SwiftAgentKitSimulatorTests/SimClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,4 +301,83 @@ final class SimClientTests: XCTestCase {
XCTAssertEqual(spy.count, SimClient.maxRelaunchesPerClient)
}
}

/// App lifecycle runs natively on the host (simctl), not through XCTest.
/// `XCUIApplication.launch()` on an app that isn't installed recorded the
/// failure and then waited 4 minutes (accessibility, spindump, idle) inside
/// one driver request; the host timed out at 120 s, replaced the driver and
/// sent the launch again (Naseem, 2026-10-10).
final class SimLifecycleTests: XCTestCase {
final class Calls: @unchecked Sendable {
var log: [String] = []
var installed = true
var launchError: String?
}

private func client(_ calls: Calls) -> SimClient {
// No driver server on purpose: lifecycle must never touch HTTP.
SimClient(baseURL: URL(string: "http://127.0.0.1:9")!, lifecycle: SimLifecycle(
isInstalled: { id in calls.log.append("installed?:\(id)"); return calls.installed },
launch: { id in
calls.log.append("launch:\(id)")
if let e = calls.launchError { throw SimctlError.commandFailed(e) }
},
terminate: { id in calls.log.append("terminate:\(id)") }))
}

func testAnAppThatIsNotInstalledFailsAtOnceWithTheReason() async throws {
let calls = Calls(); calls.installed = false
let started = Date()
do {
try await client(calls).launch(bundleId: "com.quakely.app", terminateFirst: false)
XCTFail("expected not_installed")
} catch let e as SimDriverError {
XCTAssertEqual(e.code, "not_installed")
XCTAssertTrue(e.message.contains("sim_build_install"))
}
XCTAssertLessThan(Date().timeIntervalSince(started), 2)
XCTAssertEqual(calls.log, ["installed?:com.quakely.app"])
}

func testLaunchTerminatesFirstWhenAsked() async throws {
let calls = Calls()
try await client(calls).launch(bundleId: "com.quakely.app", terminateFirst: true)
XCTAssertEqual(calls.log, ["installed?:com.quakely.app", "terminate:com.quakely.app", "launch:com.quakely.app"])
}

func testASimctlLaunchFailureCarriesItsMessage() async throws {
let calls = Calls(); calls.launchError = "An error was encountered processing the command (code=4)"
do {
try await client(calls).launch(bundleId: "com.quakely.app", terminateFirst: false)
XCTFail("expected launch_failed")
} catch let e as SimDriverError {
XCTAssertEqual(e.code, "launch_failed")
XCTAssertTrue(e.message.contains("code=4"))
}
}

func testTerminateIsNative() async throws {
let calls = Calls()
try await client(calls).terminate(bundleId: "com.quakely.app")
XCTAssertEqual(calls.log, ["terminate:com.quakely.app"])
}
}

/// After a transport error the client relaunches the driver and may resend.
/// A request that may already have run (a timeout) is resent only when it is
/// safe to repeat; one that never reached the driver always is.
final class SimResendPolicyTests: XCTestCase {
func testATimedOutTapIsNotResent() {
XCTAssertFalse(SimClient.mayResend(after: URLError(.timedOut), idempotent: false))
XCTAssertTrue(SimClient.mayResend(after: URLError(.timedOut), idempotent: true))
}
func testARequestThatNeverReachedTheDriverIsResent() {
XCTAssertTrue(SimClient.mayResend(after: URLError(.cannotConnectToHost), idempotent: false))
XCTAssertTrue(SimClient.mayResend(after: URLError(.cannotFindHost), idempotent: false))
}
func testAnUnknownFailureFollowsIdempotence() {
XCTAssertFalse(SimClient.mayResend(after: URLError(.networkConnectionLost), idempotent: false))
XCTAssertTrue(SimClient.mayResend(after: URLError(.networkConnectionLost), idempotent: true))
}
}
#endif
Loading