agent-device 0.16.9 → 0.16.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/README.md +1 -0
  2. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.9.apk → agent-device-android-multitouch-helper-0.16.10.apk} +0 -0
  3. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.10.apk.sha256 +1 -0
  4. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.9.manifest.json → agent-device-android-multitouch-helper-0.16.10.manifest.json} +4 -4
  5. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.9.apk → agent-device-android-snapshot-helper-0.16.10.apk} +0 -0
  6. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.10.apk.sha256 +1 -0
  7. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.9.manifest.json → agent-device-android-snapshot-helper-0.16.10.manifest.json} +6 -6
  8. package/dist/src/2415.js +19 -19
  9. package/dist/src/apps.js +2 -2
  10. package/dist/src/input-actions.js +1 -1
  11. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +197 -232
  12. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +282 -0
  13. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +29 -0
  14. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +8 -34
  15. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +30 -0
  16. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -20
  17. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +10 -50
  18. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +3 -23
  19. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +64 -22
  20. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +7 -4
  21. package/package.json +1 -1
  22. package/server.json +2 -2
  23. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.9.apk.sha256 +0 -1
  24. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.9.apk.sha256 +0 -1
@@ -0,0 +1,282 @@
1
+ import Foundation
2
+ import XCTest
3
+
4
+ enum RunnerCommandLifecycleState: String {
5
+ case notAccepted
6
+ case accepted
7
+ case started
8
+ case completed
9
+ case failed
10
+ }
11
+
12
+ struct RunnerCommandJournalEntry {
13
+ let commandId: String
14
+ let command: String
15
+ var state: RunnerCommandLifecycleState
16
+ var responseOk: Bool?
17
+ var responseJson: String?
18
+ var error: ErrorPayload?
19
+ }
20
+
21
+ final class RunnerCommandJournal {
22
+ private let lock = NSLock()
23
+ private let maxEntries = 64
24
+ private let maxResponseJsonBytes = 16 * 1024
25
+ private var entries: [String: RunnerCommandJournalEntry] = [:]
26
+ private var order: [String] = []
27
+
28
+ func accept(command: Command) {
29
+ guard let commandId = normalizedCommandId(command.commandId) else { return }
30
+ lock.lock()
31
+ defer { lock.unlock() }
32
+ entries[commandId] = RunnerCommandJournalEntry(
33
+ commandId: commandId,
34
+ command: command.command.rawValue,
35
+ state: .accepted,
36
+ responseOk: nil,
37
+ responseJson: nil,
38
+ error: nil
39
+ )
40
+ order.removeAll { $0 == commandId }
41
+ order.append(commandId)
42
+ pruneIfNeeded()
43
+ }
44
+
45
+ func start(command: Command) {
46
+ update(command: command, state: .started, responseOk: nil, responseJson: nil, error: nil)
47
+ }
48
+
49
+ func finish(command: Command, response: Response) {
50
+ update(
51
+ command: command,
52
+ state: response.ok ? .completed : .failed,
53
+ responseOk: response.ok,
54
+ responseJson: encodeResponseJson(command: command, response: response),
55
+ error: response.error
56
+ )
57
+ }
58
+
59
+ func fail(command: Command, error: Error) {
60
+ update(
61
+ command: command,
62
+ state: .failed,
63
+ responseOk: nil,
64
+ responseJson: nil,
65
+ error: ErrorPayload(message: error.localizedDescription)
66
+ )
67
+ }
68
+
69
+ func status(commandId: String) -> DataPayload {
70
+ guard let normalized = normalizedCommandId(commandId) else {
71
+ return DataPayload(lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue)
72
+ }
73
+ lock.lock()
74
+ let entry = entries[normalized]
75
+ lock.unlock()
76
+ guard let entry else {
77
+ return DataPayload(
78
+ commandId: normalized,
79
+ lifecycleState: RunnerCommandLifecycleState.notAccepted.rawValue
80
+ )
81
+ }
82
+ return DataPayload(
83
+ commandId: entry.commandId,
84
+ lifecycleState: entry.state.rawValue,
85
+ lifecycleCommand: entry.command,
86
+ lifecycleResponseOk: entry.responseOk,
87
+ lifecycleResponseJson: entry.responseJson,
88
+ lifecycleErrorCode: entry.error?.code,
89
+ lifecycleErrorMessage: entry.error?.message,
90
+ lifecycleErrorHint: entry.error?.hint
91
+ )
92
+ }
93
+
94
+ private func update(
95
+ command: Command,
96
+ state: RunnerCommandLifecycleState,
97
+ responseOk: Bool?,
98
+ responseJson: String?,
99
+ error: ErrorPayload?
100
+ ) {
101
+ guard let commandId = normalizedCommandId(command.commandId) else { return }
102
+ lock.lock()
103
+ defer { lock.unlock() }
104
+ var entry = entries[commandId] ?? RunnerCommandJournalEntry(
105
+ commandId: commandId,
106
+ command: command.command.rawValue,
107
+ state: .accepted,
108
+ responseOk: nil,
109
+ responseJson: nil,
110
+ error: nil
111
+ )
112
+ entry.state = state
113
+ entry.responseOk = responseOk
114
+ entry.responseJson = responseJson
115
+ entry.error = error
116
+ entries[commandId] = entry
117
+ order.removeAll { $0 == commandId }
118
+ order.append(commandId)
119
+ pruneIfNeeded()
120
+ }
121
+
122
+ private func pruneIfNeeded() {
123
+ while order.count > maxEntries {
124
+ let removed = order.removeFirst()
125
+ entries.removeValue(forKey: removed)
126
+ }
127
+ }
128
+
129
+ private func normalizedCommandId(_ value: String?) -> String? {
130
+ guard let value else { return nil }
131
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
132
+ return trimmed.isEmpty ? nil : trimmed
133
+ }
134
+
135
+ private func encodeResponseJson(command: Command, response: Response) -> String? {
136
+ guard shouldRetainResponseJson(command: command) else { return nil }
137
+ guard let data = try? JSONEncoder().encode(response) else { return nil }
138
+ guard data.count <= maxResponseJsonBytes else { return nil }
139
+ return String(data: data, encoding: .utf8)
140
+ }
141
+
142
+ private func shouldRetainResponseJson(command: Command) -> Bool {
143
+ switch command.command {
144
+ case .snapshot, .screenshot:
145
+ return false
146
+ case .tap, .mouseClick, .tapSeries, .longPress, .interactionFrame, .drag, .dragSeries,
147
+ .remotePress, .type, .swipe, .findText, .querySelector, .readText, .back, .backInApp,
148
+ .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn, .alert,
149
+ .pinch, .rotateGesture, .transformGesture, .recordStart, .recordStop, .status, .uptime,
150
+ .shutdown:
151
+ return true
152
+ }
153
+ }
154
+ }
155
+
156
+ extension RunnerTests {
157
+ func testCommandJournalRetentionPolicy() throws {
158
+ let journal = RunnerCommandJournal()
159
+
160
+ let uptime = runnerJournalCommand("uptime", id: "small-scalar")
161
+ journal.accept(command: uptime)
162
+ journal.finish(
163
+ command: uptime,
164
+ response: Response(ok: true, data: DataPayload(currentUptimeMs: 12.5))
165
+ )
166
+
167
+ let scalarStatus = journal.status(commandId: "small-scalar")
168
+ XCTAssertEqual(scalarStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
169
+ XCTAssertEqual(scalarStatus.lifecycleResponseOk, true)
170
+ XCTAssertNotNil(scalarStatus.lifecycleResponseJson)
171
+ let scalarResponse = try decodeRunnerJournalResponse(scalarStatus.lifecycleResponseJson)
172
+ XCTAssertEqual(scalarResponse.data?.currentUptimeMs, 12.5)
173
+
174
+ let querySelector = runnerJournalCommand("querySelector", id: "small-object")
175
+ journal.accept(command: querySelector)
176
+ journal.finish(
177
+ command: querySelector,
178
+ response: Response(ok: true, data: DataPayload(found: true, nodes: [runnerJournalNode()]))
179
+ )
180
+
181
+ let objectStatus = journal.status(commandId: "small-object")
182
+ XCTAssertNotNil(objectStatus.lifecycleResponseJson)
183
+ let objectResponse = try decodeRunnerJournalResponse(objectStatus.lifecycleResponseJson)
184
+ XCTAssertEqual(objectResponse.data?.found, true)
185
+ XCTAssertEqual(objectResponse.data?.nodes?.count, 1)
186
+
187
+ let snapshot = runnerJournalCommand("snapshot", id: "snapshot-tree")
188
+ journal.accept(command: snapshot)
189
+ journal.finish(
190
+ command: snapshot,
191
+ response: Response(ok: true, data: DataPayload(nodes: [runnerJournalNode()], truncated: false))
192
+ )
193
+
194
+ let snapshotStatus = journal.status(commandId: "snapshot-tree")
195
+ XCTAssertEqual(snapshotStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
196
+ XCTAssertEqual(snapshotStatus.lifecycleResponseOk, true)
197
+ XCTAssertNil(snapshotStatus.lifecycleResponseJson)
198
+
199
+ let screenshot = runnerJournalCommand("screenshot", id: "screenshot-artifact")
200
+ journal.accept(command: screenshot)
201
+ journal.finish(
202
+ command: screenshot,
203
+ response: Response(ok: true, data: DataPayload(message: "tmp/screenshot-1.png"))
204
+ )
205
+
206
+ let screenshotStatus = journal.status(commandId: "screenshot-artifact")
207
+ XCTAssertEqual(screenshotStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
208
+ XCTAssertEqual(screenshotStatus.lifecycleResponseOk, true)
209
+ XCTAssertNil(screenshotStatus.lifecycleResponseJson)
210
+
211
+ let largeRead = runnerJournalCommand("readText", id: "large-read")
212
+ journal.accept(command: largeRead)
213
+ journal.finish(
214
+ command: largeRead,
215
+ response: Response(ok: true, data: DataPayload(text: String(repeating: "x", count: 17 * 1024)))
216
+ )
217
+
218
+ let largeReadStatus = journal.status(commandId: "large-read")
219
+ XCTAssertEqual(largeReadStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
220
+ XCTAssertEqual(largeReadStatus.lifecycleResponseOk, true)
221
+ XCTAssertNil(largeReadStatus.lifecycleResponseJson)
222
+ }
223
+
224
+ func testCommandJournalKeepsErrorMetadataWhenResponseJsonIsDropped() {
225
+ let journal = RunnerCommandJournal()
226
+ let snapshot = runnerJournalCommand("snapshot", id: "snapshot-error")
227
+ let hint = "Try a smaller read such as snapshot -s <visible label or id> -d 8."
228
+
229
+ journal.accept(command: snapshot)
230
+ journal.finish(
231
+ command: snapshot,
232
+ response: Response(
233
+ ok: false,
234
+ error: ErrorPayload(
235
+ code: "IOS_AX_SNAPSHOT_FAILED",
236
+ message: "iOS XCTest snapshot failed while serializing the accessibility tree.",
237
+ hint: hint
238
+ )
239
+ )
240
+ )
241
+
242
+ let status = journal.status(commandId: "snapshot-error")
243
+ XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.failed.rawValue)
244
+ XCTAssertEqual(status.lifecycleResponseOk, false)
245
+ XCTAssertNil(status.lifecycleResponseJson)
246
+ XCTAssertEqual(status.lifecycleErrorCode, "IOS_AX_SNAPSHOT_FAILED")
247
+ XCTAssertEqual(
248
+ status.lifecycleErrorMessage,
249
+ "iOS XCTest snapshot failed while serializing the accessibility tree."
250
+ )
251
+ XCTAssertEqual(status.lifecycleErrorHint, hint)
252
+ }
253
+
254
+ private func runnerJournalCommand(_ command: String, id: String) -> Command {
255
+ let json = #"{"command":"\#(command)","commandId":"\#(id)"}"#
256
+ return try! JSONDecoder().decode(Command.self, from: Data(json.utf8))
257
+ }
258
+
259
+ private func runnerJournalNode() -> SnapshotNode {
260
+ SnapshotNode(
261
+ index: 0,
262
+ type: "button",
263
+ label: "Continue",
264
+ identifier: "continue",
265
+ value: nil,
266
+ rect: SnapshotRect(x: 10, y: 20, width: 100, height: 44),
267
+ enabled: true,
268
+ focused: nil,
269
+ selected: nil,
270
+ hittable: true,
271
+ depth: 0,
272
+ parentIndex: nil,
273
+ hiddenContentAbove: nil,
274
+ hiddenContentBelow: nil
275
+ )
276
+ }
277
+
278
+ private func decodeRunnerJournalResponse(_ responseJson: String?) throws -> Response {
279
+ let responseJson = try XCTUnwrap(responseJson)
280
+ return try JSONDecoder().decode(Response.self, from: Data(responseJson.utf8))
281
+ }
282
+ }
@@ -0,0 +1,29 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ /// Runs `block` and returns its value. If it raises an Objective-C exception, logs the message
5
+ /// under `AGENT_DEVICE_RUNNER_<tag>_IGNORED_EXCEPTION` and returns `fallback`.
6
+ ///
7
+ /// Consolidates the catch-log-and-default band-aid the runner uses around exception-prone
8
+ /// XCUITest queries (flaky `allElementsBoundByIndex` snapshots, stale element reads), giving the
9
+ /// "silently logged and continued" path one searchable format and one place to add per-tag
10
+ /// exception telemetry later. `RunnerObjCExceptionCatcher.catchException` takes a non-escaping
11
+ /// block, so `block` may capture `inout` state.
12
+ func safely<T>(_ tag: String, _ fallback: T, _ block: () -> T) -> T {
13
+ var result = fallback
14
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
15
+ result = block()
16
+ })
17
+ if let exceptionMessage {
18
+ NSLog("AGENT_DEVICE_RUNNER_%@_IGNORED_EXCEPTION=%@", tag, exceptionMessage)
19
+ return fallback
20
+ }
21
+ return result
22
+ }
23
+
24
+ /// Optional-returning convenience: returns `nil` on exception (matching the common
25
+ /// `var x: T?` + catch-and-return-nil shape).
26
+ func safely<T>(_ tag: String, _ block: () -> T?) -> T? {
27
+ safely(tag, nil, block)
28
+ }
29
+ }
@@ -285,14 +285,13 @@ extension RunnerTests {
285
285
  }
286
286
 
287
287
  private func textInputCandidatesAt(app: XCUIApplication, point: CGPoint) -> [XCUIElement] {
288
- var candidates: [XCUIElement] = []
289
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
288
+ safely("TEXT_INPUT_AT_POINT", []) {
290
289
  // Query the text-input element types directly instead of enumerating the entire tree
291
290
  // (app.descendants(.any).allElementsBoundByIndex snapshots every element and is ~10x
292
291
  // slower — it dominated fill latency because resolveTextEntryElement re-runs this on
293
292
  // each verify/repair poll once the focused field reference goes stale).
294
293
  // Prefer the smallest matching field so nested editable controls win over large containers.
295
- candidates = [
294
+ [
296
295
  app.textFields,
297
296
  app.secureTextFields,
298
297
  app.searchFields,
@@ -318,15 +317,7 @@ extension RunnerTests {
318
317
  }
319
318
  return left.elementType.rawValue < right.elementType.rawValue
320
319
  }
321
- })
322
- if let exceptionMessage {
323
- NSLog(
324
- "AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
325
- exceptionMessage
326
- )
327
- return []
328
320
  }
329
- return candidates
330
321
  }
331
322
 
332
323
  private func frameContainsPoint(_ frame: CGRect, _ point: CGPoint, tolerance: CGFloat) -> Bool {
@@ -431,9 +422,8 @@ extension RunnerTests {
431
422
 
432
423
  private func singleTextEntryElement(app: XCUIApplication) -> XCUIElement? {
433
424
  #if os(iOS)
434
- var matches: [XCUIElement] = []
435
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
436
- matches = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
425
+ let matches = safely("KEYBOARD_RETURN_TEXT_ENTRY_QUERY", []) {
426
+ app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
437
427
  guard element.exists else { return false }
438
428
  switch element.elementType {
439
429
  case .textField, .secureTextField, .searchField, .textView:
@@ -442,13 +432,6 @@ extension RunnerTests {
442
432
  return false
443
433
  }
444
434
  }
445
- })
446
- if let exceptionMessage {
447
- NSLog(
448
- "AGENT_DEVICE_RUNNER_KEYBOARD_RETURN_TEXT_ENTRY_QUERY_IGNORED_EXCEPTION=%@",
449
- exceptionMessage
450
- )
451
- return nil
452
435
  }
453
436
  return matches.count == 1 ? matches[0] : nil
454
437
  #else
@@ -784,22 +767,13 @@ extension RunnerTests {
784
767
 
785
768
  private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
786
769
  #if os(iOS)
787
- var frame: CGRect?
788
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
770
+ return safely("KEYBOARD_FRAME") {
789
771
  let keyboard = app.keyboards.firstMatch
790
- guard keyboard.exists else { return }
772
+ guard keyboard.exists else { return nil }
791
773
  let keyboardFrame = keyboard.frame
792
- guard !keyboardFrame.isEmpty else { return }
793
- frame = keyboardFrame
794
- })
795
- if let exceptionMessage {
796
- NSLog(
797
- "AGENT_DEVICE_RUNNER_KEYBOARD_FRAME_IGNORED_EXCEPTION=%@",
798
- exceptionMessage
799
- )
800
- return nil
774
+ guard !keyboardFrame.isEmpty else { return nil }
775
+ return keyboardFrame
801
776
  }
802
- return frame
803
777
  #else
804
778
  return nil
805
779
  #endif
@@ -30,6 +30,7 @@ enum CommandType: String, Codable {
30
30
  case transformGesture
31
31
  case recordStart
32
32
  case recordStop
33
+ case status
33
34
  case uptime
34
35
  case shutdown
35
36
  }
@@ -91,6 +92,9 @@ extension CommandType {
91
92
  case .recordStop, .uptime, .shutdown:
92
93
  return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)
93
94
 
95
+ case .status:
96
+ return CommandTraits(isInteraction: false, readOnly: .always, isLifecycle: true)
97
+
94
98
  // Normal preflight, not retried.
95
99
  // NOTE: mouseClick stays non-interaction for now — it is macOS-only and the foreground
96
100
  // guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
@@ -104,6 +108,8 @@ extension CommandType {
104
108
 
105
109
  struct Command: Codable {
106
110
  let command: CommandType
111
+ let commandId: String?
112
+ let statusCommandId: String?
107
113
  let appBundleId: String?
108
114
  let text: String?
109
115
  let selectorKey: String?
@@ -171,6 +177,14 @@ struct DataPayload: Codable {
171
177
  let referenceWidth: Double?
172
178
  let referenceHeight: Double?
173
179
  let currentUptimeMs: Double?
180
+ let commandId: String?
181
+ let lifecycleState: String?
182
+ let lifecycleCommand: String?
183
+ let lifecycleResponseOk: Bool?
184
+ let lifecycleResponseJson: String?
185
+ let lifecycleErrorCode: String?
186
+ let lifecycleErrorMessage: String?
187
+ let lifecycleErrorHint: String?
174
188
  let visible: Bool?
175
189
  let wasVisible: Bool?
176
190
  let dismissed: Bool?
@@ -192,6 +206,14 @@ struct DataPayload: Codable {
192
206
  referenceWidth: Double? = nil,
193
207
  referenceHeight: Double? = nil,
194
208
  currentUptimeMs: Double? = nil,
209
+ commandId: String? = nil,
210
+ lifecycleState: String? = nil,
211
+ lifecycleCommand: String? = nil,
212
+ lifecycleResponseOk: Bool? = nil,
213
+ lifecycleResponseJson: String? = nil,
214
+ lifecycleErrorCode: String? = nil,
215
+ lifecycleErrorMessage: String? = nil,
216
+ lifecycleErrorHint: String? = nil,
195
217
  visible: Bool? = nil,
196
218
  wasVisible: Bool? = nil,
197
219
  dismissed: Bool? = nil,
@@ -212,6 +234,14 @@ struct DataPayload: Codable {
212
234
  self.referenceWidth = referenceWidth
213
235
  self.referenceHeight = referenceHeight
214
236
  self.currentUptimeMs = currentUptimeMs
237
+ self.commandId = commandId
238
+ self.lifecycleState = lifecycleState
239
+ self.lifecycleCommand = lifecycleCommand
240
+ self.lifecycleResponseOk = lifecycleResponseOk
241
+ self.lifecycleResponseJson = lifecycleResponseJson
242
+ self.lifecycleErrorCode = lifecycleErrorCode
243
+ self.lifecycleErrorMessage = lifecycleErrorMessage
244
+ self.lifecycleErrorHint = lifecycleErrorHint
215
245
  self.visible = visible
216
246
  self.wasVisible = wasVisible
217
247
  self.dismissed = dismissed
@@ -354,14 +354,7 @@ extension RunnerTests {
354
354
  }
355
355
 
356
356
  private func safeSnapshotViewport(app: XCUIApplication) -> CGRect {
357
- var viewport = CGRect.infinite
358
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
359
- viewport = snapshotViewport(app: app)
360
- })
361
- if let exceptionMessage {
362
- NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_VIEWPORT_IGNORED_EXCEPTION=%@", exceptionMessage)
363
- }
364
- return viewport
357
+ safely("SNAPSHOT_VIEWPORT", CGRect.infinite) { snapshotViewport(app: app) }
365
358
  }
366
359
 
367
360
  private func describeSnapshotError(_ error: Error) -> String {
@@ -718,18 +711,7 @@ extension RunnerTests {
718
711
  }
719
712
 
720
713
  private func safeSnapshotElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
721
- var elements: [XCUIElement] = []
722
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
723
- elements = fetch()
724
- })
725
- if let exceptionMessage {
726
- NSLog(
727
- "AGENT_DEVICE_RUNNER_SNAPSHOT_QUERY_IGNORED_EXCEPTION=%@",
728
- exceptionMessage
729
- )
730
- return []
731
- }
732
- return elements
714
+ safely("SNAPSHOT_QUERY", [], fetch)
733
715
  }
734
716
 
735
717
  private func isScrollableContainer(_ snapshot: XCUIElementSnapshot, visible: Bool) -> Bool {
@@ -77,33 +77,11 @@ extension RunnerTests {
77
77
  }
78
78
 
79
79
  func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
80
- var elements: [XCUIElement] = []
81
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
82
- elements = fetch()
83
- })
84
- if let exceptionMessage {
85
- NSLog(
86
- "AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@",
87
- exceptionMessage
88
- )
89
- return []
90
- }
91
- return elements
80
+ safely("MODAL_QUERY", [], fetch)
92
81
  }
93
82
 
94
83
  private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
95
- var isBlocking = false
96
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
97
- isBlocking = isBlockingSystemModal(element, in: springboard)
98
- })
99
- if let exceptionMessage {
100
- NSLog(
101
- "AGENT_DEVICE_RUNNER_MODAL_CHECK_IGNORED_EXCEPTION=%@",
102
- exceptionMessage
103
- )
104
- return false
105
- }
106
- return isBlocking
84
+ safely("MODAL_CHECK", false) { isBlockingSystemModal(element, in: springboard) }
107
85
  }
108
86
 
109
87
  private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
@@ -134,25 +112,16 @@ extension RunnerTests {
134
112
  }
135
113
 
136
114
  private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
137
- var include = false
138
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
139
- if !candidate.exists || !candidate.isHittable { return }
140
- if !actionableTypes.contains(candidate.elementType) { return }
115
+ safely("MODAL_ACTION", false) {
116
+ if !candidate.exists || !candidate.isHittable { return false }
117
+ if !actionableTypes.contains(candidate.elementType) { return false }
141
118
  let frame = candidate.frame
142
- if frame.isNull || frame.isEmpty { return }
119
+ if frame.isNull || frame.isEmpty { return false }
143
120
  let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
144
- if seen.contains(key) { return }
121
+ if seen.contains(key) { return false }
145
122
  seen.insert(key)
146
- include = true
147
- })
148
- if let exceptionMessage {
149
- NSLog(
150
- "AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
151
- exceptionMessage
152
- )
153
- return false
123
+ return true
154
124
  }
155
- return include
156
125
  }
157
126
 
158
127
  private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
@@ -205,9 +174,8 @@ extension RunnerTests {
205
174
  depth: Int,
206
175
  hittableOverride: Bool? = nil
207
176
  ) -> SnapshotNode? {
208
- var node: SnapshotNode?
209
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
210
- node = makeSnapshotNode(
177
+ safely("MODAL_NODE") {
178
+ makeSnapshotNode(
211
179
  element: element,
212
180
  index: index,
213
181
  type: type,
@@ -216,14 +184,6 @@ extension RunnerTests {
216
184
  depth: depth,
217
185
  hittableOverride: hittableOverride
218
186
  )
219
- })
220
- if let exceptionMessage {
221
- NSLog(
222
- "AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
223
- exceptionMessage
224
- )
225
- return nil
226
187
  }
227
- return node
228
188
  }
229
189
  }
@@ -73,8 +73,7 @@ extension RunnerTests {
73
73
  // under XCUITest, so text entry readiness is driven by tap/keyboard state.
74
74
  return nil
75
75
  #else
76
- var focused: XCUIElement?
77
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
76
+ return safely("FOCUSED_INPUT_QUERY") {
78
77
  let candidates = app
79
78
  .descendants(matching: .any)
80
79
  .matching(NSPredicate(format: "hasKeyboardFocus == 1"))
@@ -82,21 +81,13 @@ extension RunnerTests {
82
81
  for candidate in candidates where candidate.exists {
83
82
  switch candidate.elementType {
84
83
  case .textField, .secureTextField, .searchField, .textView:
85
- focused = candidate
86
- return
84
+ return candidate
87
85
  default:
88
86
  continue
89
87
  }
90
88
  }
91
- })
92
- if let exceptionMessage {
93
- NSLog(
94
- "AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
95
- exceptionMessage
96
- )
97
89
  return nil
98
90
  }
99
- return focused
100
91
  #endif
101
92
  }
102
93
 
@@ -650,18 +641,7 @@ extension RunnerTests {
650
641
 
651
642
  private func keyboardElementExists(app: XCUIApplication) -> Bool {
652
643
  #if os(iOS)
653
- var exists = false
654
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
655
- exists = app.keyboards.firstMatch.exists
656
- })
657
- if let exceptionMessage {
658
- NSLog(
659
- "AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
660
- exceptionMessage
661
- )
662
- return false
663
- }
664
- return exists
644
+ return safely("KEYBOARD_EXISTS", false) { app.keyboards.firstMatch.exists }
665
645
  #else
666
646
  return false
667
647
  #endif