@@ -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+
12161356enum CommandType : String , Codable {
12171357 case tap
12181358 case tapSeries
0 commit comments