diff --git a/Sources/SwiftAgentKitSimulator/SimClient.swift b/Sources/SwiftAgentKitSimulator/SimClient.swift index 309de7c..ea83af7 100644 --- a/Sources/SwiftAgentKitSimulator/SimClient.swift +++ b/Sources/SwiftAgentKitSimulator/SimClient.swift @@ -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)? @@ -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) @@ -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) @@ -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 { @@ -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 diff --git a/Sources/SwiftAgentKitSimulator/Simctl.swift b/Sources/SwiftAgentKitSimulator/Simctl.swift index c0069b3..49e597e 100644 --- a/Sources/SwiftAgentKitSimulator/Simctl.swift +++ b/Sources/SwiftAgentKitSimulator/Simctl.swift @@ -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 } diff --git a/Tests/SwiftAgentKitSimulatorTests/SimClientTests.swift b/Tests/SwiftAgentKitSimulatorTests/SimClientTests.swift index a521994..c5485c2 100644 --- a/Tests/SwiftAgentKitSimulatorTests/SimClientTests.swift +++ b/Tests/SwiftAgentKitSimulatorTests/SimClientTests.swift @@ -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