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
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}

Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading