diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 1f8477482b..6be914e7cc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -1576,10 +1576,10 @@ extension RunnerTests { if let bundleId = requestedBundleId { activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait") guard activeApp.waitForExistence(timeout: appExistenceTimeout) else { - return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))) + return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId))) } } else { - return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available"))) + return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil))) } } @@ -1595,10 +1595,9 @@ extension RunnerTests { requestedBundleId: requestedBundleId ) if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { - if let bundleId = requestedBundleId { - return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))) - } - return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available"))) + return .response( + Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId)) + ) } applyInteractionStabilizationIfNeeded() } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index 57bde60d26..57e4467cd3 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -431,17 +431,8 @@ extension RunnerTests { func shouldRetryException(_ command: Command, message: String) -> Bool { guard shouldRetryCommand(command) else { return false } - let normalized = message.lowercased() - if normalized.contains("kaxerrorservernotfound") { - return true - } - if normalized.contains("main thread execution timed out") { - return true - } - if normalized.contains("timed out") && command.command == .snapshot { - return true - } - return false + // XCTest raises this AX error as an ObjC exception whose reason is the only handle on it. + return message.lowercased().contains("kaxerrorservernotfound") } // MARK: - Command Classification @@ -460,8 +451,7 @@ extension RunnerTests { func shouldRetryResponse(_ response: Response) -> Bool { guard response.ok == false else { return false } - guard let message = response.error?.message.lowercased() else { return false } - return message.contains("is not available") + return response.error?.retryableFailure != nil } func isInteractionCommand(_ command: CommandType) -> Bool { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 4ddd306b16..2a4b84de27 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -385,8 +385,30 @@ struct SnapshotQualityPayload: Codable { } } +/// A runner failure the read-only retry may recover from by re-resolving the target. +enum RetryableResponseFailure: Equatable { + case targetAppUnavailable +} + struct ErrorPayload: Codable { var code: String? let message: String var hint: String? + /// Runner-internal: read by `shouldRetryResponse` and never encoded, so the host's decoding of + /// the error is unchanged. + var retryableFailure: RetryableResponseFailure? = nil + + private enum CodingKeys: String, CodingKey { + case code + case message + case hint + } + + static func targetAppUnavailable(bundleId: String?) -> ErrorPayload { + let subject = bundleId.map { "app '\($0)'" } ?? "runner app" + return ErrorPayload( + message: "\(subject) is not available", + retryableFailure: .targetAppUnavailable + ) + } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index 04e83a38f3..83686731cf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -764,7 +764,7 @@ extension RunnerTests { Command.self, from: Data(#"{"command":"snapshot","commandId":"recovery-guard"}"#.utf8) ) - let recovered = Response(ok: false, error: ErrorPayload(message: "target is not available")) + let recovered = Response(ok: false, error: .targetAppUnavailable(bundleId: nil)) setAbandonedMainThreadWork(1) defer { setAbandonedMainThreadWork(0) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift index c9c14b436d..cba532f03b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -28,6 +28,37 @@ extension RunnerTests { } } + func testTargetAppUnavailableResponseIsRetried() { + for bundleId in ["com.example.app", nil] as [String?] { + let response = Response(ok: false, error: .targetAppUnavailable(bundleId: bundleId)) + XCTAssertTrue(shouldRetryResponse(response), String(describing: bundleId)) + } + let reworded = ErrorPayload(message: "target vanished", retryableFailure: .targetAppUnavailable) + XCTAssertTrue(shouldRetryResponse(Response(ok: false, error: reworded))) + } + + func testUntypedUnavailableMessageIsNotRetried() { + for message in ["app 'com.example.app' is not available", "runner app is not available"] { + let response = Response(ok: false, error: ErrorPayload(message: message)) + XCTAssertFalse(shouldRetryResponse(response), message) + } + let succeeded = Response(ok: true, error: .targetAppUnavailable(bundleId: nil)) + XCTAssertFalse(shouldRetryResponse(succeeded)) + } + + func testExceptionRetryIsLimitedToTheAxServerNotFoundReadOnlyCase() throws { + let readText = try runnerCommandFixture(#"{"command":"readText"}"#) + let snapshot = try runnerCommandFixture(#"{"command":"snapshot"}"#) + let tap = try runnerCommandFixture(#"{"command":"tap"}"#) + let axServerNotFound = "NSException: Error kAXErrorServerNotFound" + XCTAssertTrue(shouldRetryException(readText, message: axServerNotFound)) + XCTAssertFalse(shouldRetryException(tap, message: axServerNotFound)) + XCTAssertFalse( + shouldRetryException(readText, message: "NSException: main thread execution timed out") + ) + XCTAssertFalse(shouldRetryException(snapshot, message: "NSException: query timed out")) + } + func testInlineScreenshotResponseKeepsDisplayFactsBesideTheImage() throws { let pngData = Data([0x89, 0x50, 0x4E, 0x47]) let response = screenshotResponse( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift new file mode 100644 index 0000000000..d0079bf728 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -0,0 +1,32 @@ +import Foundation +import XCTest + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testErrorPayloadEncodesEveryFieldButTheRunnerInternalRetryableFailure() throws { + let payload = ErrorPayload( + code: "CODE", + message: "message", + hint: "hint", + retryableFailure: .targetAppUnavailable + ) + let encoded = try JSONEncoder().encode(payload) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let storedFields = Set(Mirror(reflecting: payload).children.compactMap(\.label)) + XCTAssertEqual(Set(object.keys), storedFields.subtracting(["retryableFailure"])) + } + + func testTargetAppUnavailableErrorKeepsItsWireShape() throws { + let encoded = try JSONEncoder().encode( + ErrorPayload.targetAppUnavailable(bundleId: "com.example.app") + ) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + XCTAssertEqual(Array(object.keys), ["message"]) + XCTAssertEqual(object["message"] as? String, "app 'com.example.app' is not available") + XCTAssertEqual( + ErrorPayload.targetAppUnavailable(bundleId: nil).message, + "runner app is not available" + ) + } +} +#endif