Skip to content

Commit 345b7ee

Browse files
authored
fix: harden ios runner resilience and cancellation (#85)
1 parent 40283f5 commit 345b7ee

10 files changed

Lines changed: 490 additions & 72 deletions

File tree

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 179 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ final class RunnerTests: XCTestCase {
3333
private let maxSnapshotElements = 600
3434
private let fastSnapshotLimit = 300
3535
private let mainThreadExecutionTimeout: TimeInterval = 30
36+
private let appExistenceTimeout: TimeInterval = 30
3637
private let retryCooldown: TimeInterval = 0.2
3738
private let postSnapshotInteractionDelay: TimeInterval = 0.2
3839
private let firstInteractionAfterActivateDelay: TimeInterval = 0.25
@@ -261,7 +262,11 @@ final class RunnerTests: XCTestCase {
261262
if let exceptionMessage {
262263
currentApp = nil
263264
currentBundleId = nil
264-
if !hasRetried, shouldRetryCommand(command.command) {
265+
if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
266+
NSLog(
267+
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
268+
command.command.rawValue
269+
)
265270
hasRetried = true
266271
sleepFor(retryCooldown)
267272
continue
@@ -282,7 +287,11 @@ final class RunnerTests: XCTestCase {
282287
userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
283288
)
284289
}
285-
if !hasRetried, shouldRetryCommand(command.command), shouldRetryResponse(response) {
290+
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
291+
NSLog(
292+
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
293+
command.command.rawValue
294+
)
286295
hasRetried = true
287296
currentApp = nil
288297
currentBundleId = nil
@@ -319,10 +328,10 @@ final class RunnerTests: XCTestCase {
319328
activeApp = app
320329
}
321330

322-
if !activeApp.waitForExistence(timeout: 5) {
331+
if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
323332
if let bundleId = requestedBundleId {
324333
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
325-
guard activeApp.waitForExistence(timeout: 5) else {
334+
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
326335
return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
327336
}
328337
} else {
@@ -532,10 +541,35 @@ final class RunnerTests: XCTestCase {
532541
return target
533542
}
534543

535-
private func shouldRetryCommand(_ command: CommandType) -> Bool {
536-
switch command {
537-
case .tap, .longPress, .drag:
544+
private func shouldRetryCommand(_ command: Command) -> Bool {
545+
if isEnvTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
546+
return false
547+
}
548+
return isReadOnlyCommand(command)
549+
}
550+
551+
private func shouldRetryException(_ command: Command, message: String) -> Bool {
552+
guard shouldRetryCommand(command) else { return false }
553+
let normalized = message.lowercased()
554+
if normalized.contains("kaxerrorservernotfound") {
555+
return true
556+
}
557+
if normalized.contains("main thread execution timed out") {
558+
return true
559+
}
560+
if normalized.contains("timed out") && command.command == .snapshot {
561+
return true
562+
}
563+
return false
564+
}
565+
566+
private func isReadOnlyCommand(_ command: Command) -> Bool {
567+
switch command.command {
568+
case .findText, .listTappables, .snapshot:
538569
return true
570+
case .alert:
571+
let action = (command.action ?? "get").lowercased()
572+
return action == "get"
539573
default:
540574
return false
541575
}
@@ -977,50 +1011,95 @@ final class RunnerTests: XCTestCase {
9771011
}
9781012

9791013
let title = preferredSystemModalTitle(modal)
980-
981-
var nodes: [SnapshotNode] = [
982-
makeSnapshotNode(
983-
element: modal,
984-
index: 0,
985-
type: "Alert",
986-
labelOverride: title,
987-
identifierOverride: modal.identifier,
988-
depth: 0,
989-
hittableOverride: true
990-
)
991-
]
1014+
guard let modalNode = safeMakeSnapshotNode(
1015+
element: modal,
1016+
index: 0,
1017+
type: "Alert",
1018+
labelOverride: title,
1019+
identifierOverride: modal.identifier,
1020+
depth: 0,
1021+
hittableOverride: true
1022+
) else {
1023+
return nil
1024+
}
1025+
var nodes: [SnapshotNode] = [modalNode]
9921026

9931027
for action in actions {
994-
nodes.append(
995-
makeSnapshotNode(
996-
element: action,
997-
index: nodes.count,
998-
type: elementTypeName(action.elementType),
999-
depth: 1,
1000-
hittableOverride: true
1001-
)
1002-
)
1028+
guard let actionNode = safeMakeSnapshotNode(
1029+
element: action,
1030+
index: nodes.count,
1031+
type: elementTypeName(action.elementType),
1032+
depth: 1,
1033+
hittableOverride: true
1034+
) else {
1035+
continue
1036+
}
1037+
nodes.append(actionNode)
10031038
}
10041039

10051040
return DataPayload(nodes: nodes, truncated: false)
10061041
}
10071042

10081043
private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
1009-
for alert in springboard.alerts.allElementsBoundByIndex {
1010-
if isBlockingSystemModal(alert, in: springboard) {
1044+
let disableSafeProbe = isEnvTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
1045+
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
1046+
if disableSafeProbe {
1047+
return fetch()
1048+
}
1049+
return self.safeElementsQuery(fetch)
1050+
}
1051+
1052+
let alerts = queryElements {
1053+
springboard.alerts.allElementsBoundByIndex
1054+
}
1055+
for alert in alerts {
1056+
if safeIsBlockingSystemModal(alert, in: springboard) {
10111057
return alert
10121058
}
10131059
}
10141060

1015-
for sheet in springboard.sheets.allElementsBoundByIndex {
1016-
if isBlockingSystemModal(sheet, in: springboard) {
1061+
let sheets = queryElements {
1062+
springboard.sheets.allElementsBoundByIndex
1063+
}
1064+
for sheet in sheets {
1065+
if safeIsBlockingSystemModal(sheet, in: springboard) {
10171066
return sheet
10181067
}
10191068
}
10201069

10211070
return nil
10221071
}
10231072

1073+
private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
1074+
var elements: [XCUIElement] = []
1075+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1076+
elements = fetch()
1077+
})
1078+
if let exceptionMessage {
1079+
NSLog(
1080+
"AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@",
1081+
exceptionMessage
1082+
)
1083+
return []
1084+
}
1085+
return elements
1086+
}
1087+
1088+
private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
1089+
var isBlocking = false
1090+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1091+
isBlocking = isBlockingSystemModal(element, in: springboard)
1092+
})
1093+
if let exceptionMessage {
1094+
NSLog(
1095+
"AGENT_DEVICE_RUNNER_MODAL_CHECK_IGNORED_EXCEPTION=%@",
1096+
exceptionMessage
1097+
)
1098+
return false
1099+
}
1100+
return isBlocking
1101+
}
1102+
10241103
private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
10251104
guard element.exists else { return false }
10261105
let frame = element.frame
@@ -1038,18 +1117,36 @@ final class RunnerTests: XCTestCase {
10381117
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
10391118
var seen = Set<String>()
10401119
var actions: [XCUIElement] = []
1041-
let descendants = element.descendants(matching: .any).allElementsBoundByIndex
1120+
let descendants = safeElementsQuery {
1121+
element.descendants(matching: .any).allElementsBoundByIndex
1122+
}
10421123
for candidate in descendants {
1043-
if !candidate.exists || !candidate.isHittable { continue }
1044-
if !actionableTypes.contains(candidate.elementType) { continue }
1124+
if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
1125+
actions.append(candidate)
1126+
}
1127+
return actions
1128+
}
1129+
1130+
private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
1131+
var include = false
1132+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1133+
if !candidate.exists || !candidate.isHittable { return }
1134+
if !actionableTypes.contains(candidate.elementType) { return }
10451135
let frame = candidate.frame
1046-
if frame.isNull || frame.isEmpty { continue }
1136+
if frame.isNull || frame.isEmpty { return }
10471137
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
1048-
if seen.contains(key) { continue }
1138+
if seen.contains(key) { return }
10491139
seen.insert(key)
1050-
actions.append(candidate)
1140+
include = true
1141+
})
1142+
if let exceptionMessage {
1143+
NSLog(
1144+
"AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
1145+
exceptionMessage
1146+
)
1147+
return false
10511148
}
1052-
return actions
1149+
return include
10531150
}
10541151

10551152
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
@@ -1088,6 +1185,37 @@ final class RunnerTests: XCTestCase {
10881185
)
10891186
}
10901187

1188+
private func safeMakeSnapshotNode(
1189+
element: XCUIElement,
1190+
index: Int,
1191+
type: String,
1192+
labelOverride: String? = nil,
1193+
identifierOverride: String? = nil,
1194+
depth: Int,
1195+
hittableOverride: Bool? = nil
1196+
) -> SnapshotNode? {
1197+
var node: SnapshotNode?
1198+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
1199+
node = makeSnapshotNode(
1200+
element: element,
1201+
index: index,
1202+
type: type,
1203+
labelOverride: labelOverride,
1204+
identifierOverride: identifierOverride,
1205+
depth: depth,
1206+
hittableOverride: hittableOverride
1207+
)
1208+
})
1209+
if let exceptionMessage {
1210+
NSLog(
1211+
"AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
1212+
exceptionMessage
1213+
)
1214+
return nil
1215+
}
1216+
return node
1217+
}
1218+
10911219
private func snapshotRect(from frame: CGRect) -> SnapshotRect {
10921220
return SnapshotRect(
10931221
x: Double(frame.origin.x),
@@ -1213,6 +1341,18 @@ private func resolveRunnerPort() -> UInt16 {
12131341
return 0
12141342
}
12151343

1344+
private func isEnvTruthy(_ name: String) -> Bool {
1345+
guard let raw = ProcessInfo.processInfo.environment[name] else {
1346+
return false
1347+
}
1348+
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
1349+
case "1", "true", "yes", "on":
1350+
return true
1351+
default:
1352+
return false
1353+
}
1354+
}
1355+
12161356
enum CommandType: String, Codable {
12171357
case tap
12181358
case tapSeries

0 commit comments

Comments
 (0)