agent-device 0.16.14 → 0.17.1

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 (52) hide show
  1. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.apk → agent-device-android-multitouch-helper-0.17.1.apk} +0 -0
  2. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.17.1.apk.sha256 +1 -0
  3. package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.16.14.manifest.json → agent-device-android-multitouch-helper-0.17.1.manifest.json} +4 -4
  4. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.apk → agent-device-android-snapshot-helper-0.17.1.apk} +0 -0
  5. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.17.1.apk.sha256 +1 -0
  6. package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.16.14.manifest.json → agent-device-android-snapshot-helper-0.17.1.manifest.json} +6 -6
  7. package/dist/src/1352.js +1 -1
  8. package/dist/src/221.js +4 -4
  9. package/dist/src/2415.js +29 -29
  10. package/dist/src/2805.js +1 -1
  11. package/dist/src/6232.js +1 -1
  12. package/dist/src/7599.js +4 -3
  13. package/dist/src/8020.js +1 -0
  14. package/dist/src/8699.js +1 -1
  15. package/dist/src/9238.js +3 -3
  16. package/dist/src/940.js +1 -1
  17. package/dist/src/9533.js +1 -1
  18. package/dist/src/9542.js +3 -3
  19. package/dist/src/android-snapshot-helper.d.ts +1 -0
  20. package/dist/src/apple.js +1 -1
  21. package/dist/src/apps.js +1 -1
  22. package/dist/src/args.js +15 -10
  23. package/dist/src/cli.js +9 -9
  24. package/dist/src/command-metadata.js +1 -1
  25. package/dist/src/contracts.d.ts +1 -0
  26. package/dist/src/find.js +1 -1
  27. package/dist/src/finders.d.ts +1 -0
  28. package/dist/src/generic.js +12 -10
  29. package/dist/src/index.d.ts +20 -1
  30. package/dist/src/interaction.js +1 -1
  31. package/dist/src/record-trace-recording.js +26 -0
  32. package/dist/src/record-trace.js +1 -26
  33. package/dist/src/selectors.d.ts +1 -0
  34. package/dist/src/session.js +11 -11
  35. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +4 -0
  36. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +71 -0
  37. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +41 -7
  38. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +160 -13
  39. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandJournal.swift +11 -0
  40. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Exceptions.swift +12 -4
  41. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +26 -0
  42. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +8 -0
  43. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +7 -1
  44. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +571 -56
  45. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +21 -0
  46. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +11 -0
  47. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +13 -2
  48. package/ios-runner/README.md +13 -0
  49. package/package.json +2 -2
  50. package/server.json +2 -2
  51. package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.16.14.apk.sha256 +0 -1
  52. package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.16.14.apk.sha256 +0 -1
@@ -21,6 +21,10 @@ NS_ASSUME_NONNULL_BEGIN
21
21
  y2:(double)y2
22
22
  durationMs:(double)durationMs;
23
23
 
24
+ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
25
+ x:(double)x
26
+ y:(double)y;
27
+
24
28
  @end
25
29
 
26
30
  NS_ASSUME_NONNULL_END
@@ -53,6 +53,10 @@ static id RunnerSwipePointerPath(
53
53
  CGPoint end,
54
54
  double durationMs
55
55
  );
56
+ static id RunnerTapPointerPath(
57
+ const RunnerXCTestEventBridge *bridge,
58
+ CGPoint point
59
+ );
56
60
  static CGPoint RunnerPointerPointAt(
57
61
  double x,
58
62
  double y,
@@ -115,6 +119,18 @@ static double RunnerSmoothStep(double t);
115
119
  }
116
120
  }
117
121
 
122
+ + (NSString * _Nullable)synthesizeTapWithApplication:(id)application
123
+ x:(double)x
124
+ y:(double)y {
125
+ @try {
126
+ return [self trySynthesizeTapWithApplication:application x:x y:y];
127
+ } @catch (NSException *exception) {
128
+ NSString *name = exception.name ?: @"NSException";
129
+ NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
130
+ return [NSString stringWithFormat:@"%@: %@", name, reason];
131
+ }
132
+ }
133
+
118
134
  + (NSString * _Nullable)trySynthesizeTransformWithApplication:(id)application
119
135
  x:(double)x
120
136
  y:(double)y
@@ -224,6 +240,48 @@ static double RunnerSmoothStep(double t);
224
240
  return nil;
225
241
  }
226
242
 
243
+ + (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
244
+ x:(double)x
245
+ y:(double)y {
246
+ RunnerXCTestEventBridge bridge;
247
+ NSString *missing = RunnerResolveXCTestEventBridge(application, &bridge);
248
+ if (missing != nil) {
249
+ return missing;
250
+ }
251
+
252
+ NSInteger interfaceOrientation =
253
+ ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.interfaceOrientationSelector);
254
+ NSInteger targetProcessID = ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.processIDSelector);
255
+ if (targetProcessID <= 0) {
256
+ return @"private XCTest event synthesis unavailable: could not resolve target process ID";
257
+ }
258
+
259
+ id record = ((RunnerMsgSendInitRecord)objc_msgSend)(
260
+ [bridge.recordClass alloc],
261
+ bridge.initRecordSelector,
262
+ @"agent-device-tap",
263
+ interfaceOrientation
264
+ );
265
+ if (record == nil) {
266
+ return @"private XCTest event synthesis failed: could not create event record";
267
+ }
268
+ ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.setTargetProcessIDSelector, targetProcessID);
269
+
270
+ id path = RunnerTapPointerPath(&bridge, CGPointMake(x, y));
271
+ if (path == nil) {
272
+ return @"private XCTest event synthesis failed: could not create pointer path";
273
+ }
274
+ ((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.addPathSelector, path);
275
+
276
+ NSError *error = nil;
277
+ BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.synthesizeSelector, &error);
278
+ if (!ok) {
279
+ NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
280
+ return [NSString stringWithFormat:@"private XCTest event synthesis failed: %@", detail];
281
+ }
282
+ return nil;
283
+ }
284
+
227
285
  static NSString * _Nullable RunnerResolveXCTestEventBridge(
228
286
  id application,
229
287
  RunnerXCTestEventBridge *bridge
@@ -368,6 +426,19 @@ static id RunnerSwipePointerPath(
368
426
  return path;
369
427
  }
370
428
 
429
+ static id RunnerTapPointerPath(
430
+ const RunnerXCTestEventBridge *bridge,
431
+ CGPoint point
432
+ ) {
433
+ id path =
434
+ ((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, point, 0.0);
435
+ if (path == nil) {
436
+ return nil;
437
+ }
438
+ ((RunnerMsgSendPathOffset)objc_msgSend)(path, bridge->liftSelector, 0.05);
439
+ return path;
440
+ }
441
+
371
442
  static CGPoint RunnerPointerPointAt(
372
443
  double x,
373
444
  double y,
@@ -8,20 +8,18 @@ extension RunnerTests {
8
8
  }
9
9
 
10
10
  func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11
+ #if !os(macOS)
12
+ if let systemModal = firstBlockingSystemModal(in: springboard) {
13
+ return runnerAlert(root: systemModal, ownerApp: springboard)
14
+ }
15
+ #endif
11
16
  if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
12
17
  return runnerAlert(root: alert, ownerApp: activeApp)
13
18
  }
14
19
  if let popup = firstDismissPopupWindow(in: activeApp) {
15
20
  return runnerAlert(root: popup, ownerApp: activeApp)
16
21
  }
17
- #if os(macOS)
18
- return nil
19
- #else
20
- if let systemModal = firstBlockingSystemModal(in: springboard) {
21
- return runnerAlert(root: systemModal, ownerApp: springboard)
22
- }
23
22
  return nil
24
- #endif
25
23
  }
26
24
 
27
25
  func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
@@ -33,6 +31,27 @@ extension RunnerTests {
33
31
  if let response = unsupportedResponse(for: outcome) {
34
32
  return response
35
33
  }
34
+ sleepFor(0.2)
35
+ if alertStillVisible(alert, actionButtonLabel: button.label) {
36
+ let frame = button.frame
37
+ if !frame.isNull && !frame.isEmpty {
38
+ let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
39
+ if let response = unsupportedResponse(for: coordinateOutcome) {
40
+ return response
41
+ }
42
+ sleepFor(0.2)
43
+ }
44
+ }
45
+ if alertStillVisible(alert, actionButtonLabel: button.label) {
46
+ return Response(
47
+ ok: false,
48
+ error: ErrorPayload(
49
+ code: "INTERACTION_FAILED",
50
+ message: "alert \(action) did not dismiss the visible alert",
51
+ hint: "The alert button was found but the system still reports the alert after tapping it."
52
+ )
53
+ )
54
+ }
36
55
  return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
37
56
  }
38
57
 
@@ -53,6 +72,21 @@ extension RunnerTests {
53
72
  return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
54
73
  }
55
74
 
75
+ private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
76
+ guard let current = resolveAlert(app: alert.ownerApp) else {
77
+ return false
78
+ }
79
+ let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
80
+ let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
81
+ if previousTitle == currentTitle {
82
+ return true
83
+ }
84
+ let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
85
+ return current.buttons.contains { button in
86
+ button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
87
+ }
88
+ }
89
+
56
90
  private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
57
91
  elements.first { isVisibleElement($0) }
58
92
  }
@@ -17,6 +17,10 @@ extension RunnerTests {
17
17
  min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
18
18
  }
19
19
 
20
+ private func coordinateDragHoldDuration() -> TimeInterval {
21
+ 0.050
22
+ }
23
+
20
24
  func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
21
25
  switch outcome {
22
26
  case .performed:
@@ -147,10 +151,64 @@ extension RunnerTests {
147
151
  return Response(ok: true, data: data)
148
152
  }
149
153
 
154
+ func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
155
+ let response = gestureResponse(
156
+ message: "tapped",
157
+ timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
158
+ fallback: GestureFallback(
159
+ strategy: "xctest-coordinate-tap",
160
+ message: "Runner synthesized coordinate tap is unavailable",
161
+ hint: "Using XCTest coordinate tap fallback."
162
+ )
163
+ )
164
+
165
+ XCTAssertEqual(response.ok, true)
166
+ XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
167
+ XCTAssertEqual(
168
+ response.data?.gestureFallbackMessage,
169
+ "Runner synthesized coordinate tap is unavailable"
170
+ )
171
+ XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
172
+ }
173
+
174
+ func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
175
+ let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
176
+ let response = Response(ok: true, data: DataPayload(message: "tapped"))
177
+
178
+ let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
179
+
180
+ XCTAssertEqual(failureResponse?.ok, false)
181
+ XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
182
+ XCTAssertEqual(
183
+ failureResponse?.error?.message,
184
+ "XCTest recorded a failure while executing tap; the action may not have been performed."
185
+ )
186
+ }
187
+
188
+ func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
189
+ let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
190
+ let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
191
+ let runnerFatalResponse = Response(
192
+ ok: true,
193
+ data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
194
+ )
195
+
196
+ XCTAssertNil(
197
+ xctestRecordedFailureResponse(
198
+ command: snapshotCommand,
199
+ response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
200
+ )
201
+ )
202
+ XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
203
+ }
204
+
150
205
  func execute(command: Command) throws -> Response {
151
206
  if command.command == .status {
152
207
  return executeStatus(command: command)
153
208
  }
209
+ if command.command == .uptime {
210
+ return executeUptime()
211
+ }
154
212
  commandJournal.accept(command: command)
155
213
  return try executeAccepted(command: command)
156
214
  }
@@ -185,6 +243,13 @@ extension RunnerTests {
185
243
  return Response(ok: true, data: commandJournal.status(commandId: statusCommandId))
186
244
  }
187
245
 
246
+ func executeUptime() -> Response {
247
+ Response(
248
+ ok: true,
249
+ data: DataPayload(currentUptimeMs: currentUptimeMs())
250
+ )
251
+ }
252
+
188
253
  private func executeDispatched(command: Command) throws -> Response {
189
254
  if Thread.isMainThread {
190
255
  return try executeOnMainSafely(command: command)
@@ -229,6 +294,7 @@ extension RunnerTests {
229
294
  while true {
230
295
  var response: Response?
231
296
  var swiftError: Error?
297
+ let failureCountBefore = currentXCTestFailureCount()
232
298
  let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
233
299
  do {
234
300
  response = try self.executeOnMain(command: command)
@@ -238,8 +304,7 @@ extension RunnerTests {
238
304
  })
239
305
 
240
306
  if let exceptionMessage {
241
- currentApp = nil
242
- currentBundleId = nil
307
+ invalidateCachedTarget(reason: "objc_exception")
243
308
  if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
244
309
  NSLog(
245
310
  "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
@@ -265,14 +330,19 @@ extension RunnerTests {
265
330
  userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
266
331
  )
267
332
  }
333
+ if didRecordXCTestFailure(since: failureCountBefore),
334
+ let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
335
+ {
336
+ invalidateCachedTarget(reason: "xctest_recorded_failure")
337
+ return failureResponse
338
+ }
268
339
  if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
269
340
  NSLog(
270
341
  "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
271
342
  command.command.rawValue
272
343
  )
273
344
  hasRetried = true
274
- currentApp = nil
275
- currentBundleId = nil
345
+ invalidateCachedTarget(reason: "response_unavailable")
276
346
  sleepFor(retryCooldown)
277
347
  continue
278
348
  }
@@ -282,7 +352,9 @@ extension RunnerTests {
282
352
 
283
353
  private func executeOnMain(command: Command) throws -> Response {
284
354
  var activeApp = currentApp ?? app
285
- if !isRunnerLifecycleCommand(command.command) {
355
+ if shouldSkipAppActivationPreflight(command) {
356
+ activeApp = resolveAppWithoutActivation(command: command)
357
+ } else if !isRunnerLifecycleCommand(command.command) {
286
358
  let normalizedBundleId = command.appBundleId?
287
359
  .trimmingCharacters(in: .whitespacesAndNewlines)
288
360
  let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
@@ -408,10 +480,7 @@ extension RunnerTests {
408
480
  return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
409
481
  }
410
482
  case .uptime:
411
- return Response(
412
- ok: true,
413
- data: DataPayload(currentUptimeMs: currentUptimeMs())
414
- )
483
+ return executeUptime()
415
484
  case .tap:
416
485
  if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
417
486
  let match = findElement(
@@ -425,6 +494,7 @@ extension RunnerTests {
425
494
  }
426
495
  if let element = match.element {
427
496
  let frame = element.frame
497
+ let isTextEntry = isTextEntryElement(element)
428
498
  let touchFrame = frame.isEmpty
429
499
  ? nil
430
500
  : resolvedTouchVisualizationFrame(app: activeApp, x: frame.midX, y: frame.midY)
@@ -440,7 +510,9 @@ extension RunnerTests {
440
510
  if let response = unsupportedResponse(for: outcome) {
441
511
  return response
442
512
  }
443
- waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
513
+ if isTextEntry {
514
+ waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
515
+ }
444
516
  return gestureResponse(
445
517
  message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
446
518
  timing: timing,
@@ -462,12 +534,27 @@ extension RunnerTests {
462
534
  return Response(ok: false, error: ErrorPayload(message: "element not found"))
463
535
  }
464
536
  if let x = command.x, let y = command.y {
537
+ var fallback: GestureFallback?
538
+ if command.synthesized == true {
539
+ let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
540
+ synthesizedTapAt(app: activeApp, x: x, y: y)
541
+ }
542
+ if case .performed = outcome {
543
+ return gestureResponse(message: "tapped", timing: timing)
544
+ }
545
+ fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
546
+ }
465
547
  let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
466
548
  let (timing, outcome) = performGesture(activeApp) { tapAt(app: activeApp, x: x, y: y) }
467
549
  if let response = unsupportedResponse(for: outcome) {
468
550
  return response
469
551
  }
470
- return gestureResponse(message: "tapped", timing: timing, frame: .touch(touchFrame))
552
+ return gestureResponse(
553
+ message: "tapped",
554
+ timing: timing,
555
+ frame: .touch(touchFrame),
556
+ fallback: fallback
557
+ )
471
558
  }
472
559
  return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
473
560
  case .mouseClick:
@@ -574,7 +661,7 @@ extension RunnerTests {
574
661
  }
575
662
  let holdDuration = command.synthesized == true
576
663
  ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
577
- : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
664
+ : coordinateDragHoldDuration()
578
665
  let (timing, outcome) = performGesture(activeApp) {
579
666
  dragAt(
580
667
  app: activeApp,
@@ -632,7 +719,7 @@ extension RunnerTests {
632
719
  }
633
720
  let holdDuration = command.synthesized == true
634
721
  ? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
635
- : min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
722
+ : coordinateDragHoldDuration()
636
723
  let (timing, outcome) = performGesture(activeApp) {
637
724
  performDragSeries(
638
725
  count: count,
@@ -736,6 +823,7 @@ extension RunnerTests {
736
823
  needsPostSnapshotInteractionDelay = true
737
824
  return Response(ok: true, data: payload)
738
825
  } catch let failure as SnapshotCaptureFailure {
826
+ invalidateCachedTarget(reason: "ax_snapshot_failure")
739
827
  // Other thrown errors fall through to executeOnMainSafely's generic error response.
740
828
  return Response(
741
829
  ok: false,
@@ -935,6 +1023,65 @@ extension RunnerTests {
935
1023
  }
936
1024
  }
937
1025
 
1026
+ private func currentXCTestFailureCount() -> Int {
1027
+ return testRun?.failureCount ?? 0
1028
+ }
1029
+
1030
+ private func didRecordXCTestFailure(since failureCountBefore: Int) -> Bool {
1031
+ return currentXCTestFailureCount() > failureCountBefore
1032
+ }
1033
+
1034
+ private func xctestRecordedFailureResponse(command: Command, response: Response) -> Response? {
1035
+ guard response.ok else { return nil }
1036
+ if response.data?.runnerFatal == true {
1037
+ return nil
1038
+ }
1039
+ guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else {
1040
+ return nil
1041
+ }
1042
+ return Response(
1043
+ ok: false,
1044
+ error: ErrorPayload(
1045
+ code: "XCTEST_RECORDED_FAILURE",
1046
+ message: "XCTest recorded a failure while executing \(command.command.rawValue); the action may not have been performed.",
1047
+ hint: "The iOS runner session will be restarted. Retry after a fresh snapshot, or use screenshot plus coordinate commands when the accessibility tree is unavailable."
1048
+ )
1049
+ )
1050
+ }
1051
+
1052
+ private func runnerCommandFixture(_ json: String) throws -> Command {
1053
+ try JSONDecoder().decode(Command.self, from: Data(json.utf8))
1054
+ }
1055
+
1056
+ private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
1057
+ #if os(iOS)
1058
+ // Coordinate-only synthesized taps can run after an AX-fatal screen because they do not need
1059
+ // app activation, window lookup, keyboard lookup, or element resolution. Selector/text taps
1060
+ // intentionally stay on the normal AX path because they need an element query.
1061
+ return command.command == .tap
1062
+ && command.synthesized == true
1063
+ && command.x != nil
1064
+ && command.y != nil
1065
+ && command.text == nil
1066
+ && command.selectorKey == nil
1067
+ #else
1068
+ return false
1069
+ #endif
1070
+ }
1071
+
1072
+ private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
1073
+ guard let bundleId = command.appBundleId?
1074
+ .trimmingCharacters(in: .whitespacesAndNewlines),
1075
+ !bundleId.isEmpty
1076
+ else {
1077
+ return currentApp ?? app
1078
+ }
1079
+ if currentBundleId == bundleId, let currentApp {
1080
+ return currentApp
1081
+ }
1082
+ return XCUIApplication(bundleIdentifier: bundleId)
1083
+ }
1084
+
938
1085
  private func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
939
1086
  guard let text = command.text else {
940
1087
  return Response(ok: false, error: ErrorPayload(message: "type requires text"))
@@ -154,6 +154,17 @@ final class RunnerCommandJournal {
154
154
  }
155
155
 
156
156
  extension RunnerTests {
157
+ func testUptimeBypassesCommandJournal() throws {
158
+ let command = runnerJournalCommand("uptime", id: "uptime-probe")
159
+
160
+ let response = try execute(command: command)
161
+ let status = commandJournal.status(commandId: "uptime-probe")
162
+
163
+ XCTAssertEqual(response.ok, true)
164
+ XCTAssertNotNil(response.data?.currentUptimeMs)
165
+ XCTAssertEqual(status.lifecycleState, RunnerCommandLifecycleState.notAccepted.rawValue)
166
+ }
167
+
157
168
  func testCommandJournalRetentionPolicy() throws {
158
169
  let journal = RunnerCommandJournal()
159
170
 
@@ -10,10 +10,7 @@ extension RunnerTests {
10
10
  /// exception telemetry later. `RunnerObjCExceptionCatcher.catchException` takes a non-escaping
11
11
  /// block, so `block` may capture `inout` state.
12
12
  func safely<T>(_ tag: String, _ fallback: T, _ block: () -> T) -> T {
13
- var result = fallback
14
- let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
15
- result = block()
16
- })
13
+ let (result, exceptionMessage) = catchingObjCException(fallback: fallback, block)
17
14
  if let exceptionMessage {
18
15
  NSLog("AGENT_DEVICE_RUNNER_%@_IGNORED_EXCEPTION=%@", tag, exceptionMessage)
19
16
  return fallback
@@ -21,6 +18,17 @@ extension RunnerTests {
21
18
  return result
22
19
  }
23
20
 
21
+ func catchingObjCException<T>(
22
+ fallback: T,
23
+ _ block: () -> T
24
+ ) -> (result: T, exceptionMessage: String?) {
25
+ var result = fallback
26
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
27
+ result = block()
28
+ })
29
+ return (result, exceptionMessage)
30
+ }
31
+
24
32
  /// Optional-returning convenience: returns `nil` on exception (matching the common
25
33
  /// `var x: T?` + catch-and-return-nil shape).
26
34
  func safely<T>(_ tag: String, _ block: () -> T?) -> T? {
@@ -670,6 +670,32 @@ extension RunnerTests {
670
670
  #endif
671
671
  }
672
672
 
673
+ func synthesizedTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
674
+ #if os(iOS)
675
+ if let message = RunnerSynthesizedGesture.synthesizeTap(
676
+ withApplication: app,
677
+ x: x,
678
+ y: y
679
+ ) {
680
+ return .unsupported(
681
+ message: message,
682
+ hint: "Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
683
+ )
684
+ }
685
+ return .performed
686
+ #elseif os(tvOS)
687
+ return .unsupported(
688
+ message: "coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element",
689
+ hint: "tvOS has no coordinate input; move focus with swipe/scroll to the target, then select it."
690
+ )
691
+ #else
692
+ return .unsupported(
693
+ message: "synthesized coordinate tap is not supported on macOS",
694
+ hint: "macOS automation has no touchscreen; use mouse-driven interactions instead."
695
+ )
696
+ #endif
697
+ }
698
+
673
699
  func keyboardAvoidingDragPoints(
674
700
  app: XCUIApplication,
675
701
  x: Double,
@@ -87,6 +87,14 @@ extension RunnerTests {
87
87
  currentBundleId = nil
88
88
  }
89
89
 
90
+ func invalidateCachedTarget(reason: String) {
91
+ if currentApp != nil || currentBundleId != nil {
92
+ NSLog("AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE reason=%@", reason)
93
+ }
94
+ currentApp = nil
95
+ currentBundleId = nil
96
+ }
97
+
90
98
  func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
91
99
  let state = target.state
92
100
  #if os(macOS)
@@ -193,6 +193,8 @@ struct DataPayload: Codable {
193
193
  let gestureFallback: String?
194
194
  let gestureFallbackMessage: String?
195
195
  let gestureFallbackHint: String?
196
+ let runnerFatal: Bool?
197
+ let runnerFatalReason: String?
196
198
 
197
199
  init(
198
200
  message: String? = nil,
@@ -224,7 +226,9 @@ struct DataPayload: Codable {
224
226
  orientation: String? = nil,
225
227
  gestureFallback: String? = nil,
226
228
  gestureFallbackMessage: String? = nil,
227
- gestureFallbackHint: String? = nil
229
+ gestureFallbackHint: String? = nil,
230
+ runnerFatal: Bool? = nil,
231
+ runnerFatalReason: String? = nil
228
232
  ) {
229
233
  self.message = message
230
234
  self.text = text
@@ -256,6 +260,8 @@ struct DataPayload: Codable {
256
260
  self.gestureFallback = gestureFallback
257
261
  self.gestureFallbackMessage = gestureFallbackMessage
258
262
  self.gestureFallbackHint = gestureFallbackHint
263
+ self.runnerFatal = runnerFatal
264
+ self.runnerFatalReason = runnerFatalReason
259
265
  }
260
266
  }
261
267