agent-device 0.20.6 → 0.20.8

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 (191) hide show
  1. package/README.md +3 -3
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.6.apk → agent-device-android-ime-helper-0.20.8.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.8.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.6.manifest.json → agent-device-android-ime-helper-0.20.8.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.6.apk → agent-device-android-snapshot-helper-0.20.8.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.8.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.6.manifest.json → agent-device-android-snapshot-helper-0.20.8.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +20 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +67 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +339 -4
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSelectorMatchPolicy.swift +78 -0
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +31 -2
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +34 -8
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CoordinateTextEntryTests.swift +4 -0
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +36 -28
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +7 -0
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+RecordingTests.swift +4 -0
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SelectorMatchPolicyTests.swift +4 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +2 -1
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +100 -12
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +4 -0
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +57 -0
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +1 -0
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +18 -3
  25. package/dist/src/agent-browser-tool.js +1 -1
  26. package/dist/src/agent-device-client.js +2 -2
  27. package/dist/src/android-adb.js +1 -1
  28. package/dist/src/android.js +1 -1
  29. package/dist/src/app-helpers.js +1 -1
  30. package/dist/src/app-lifecycle.js +7 -7
  31. package/dist/src/app-lifecycle2.js +2 -0
  32. package/dist/src/app-log-files.js +1 -0
  33. package/dist/src/app-log-reconnect.js +1 -0
  34. package/dist/src/app-log-runtime.js +4 -0
  35. package/dist/src/app-resolution.js +2 -1
  36. package/dist/src/apps.js +5 -5
  37. package/dist/src/audio-probe.js +1 -1
  38. package/dist/src/auth-session.js +1 -0
  39. package/dist/src/auth.js +2 -0
  40. package/dist/src/boot-diagnostics.js +2 -2
  41. package/dist/src/cli-config.js +1 -1
  42. package/dist/src/cli-grammar.js +1 -0
  43. package/dist/src/cli-help.js +199 -259
  44. package/dist/src/cli-output.js +1 -0
  45. package/dist/src/cli-runner.js +1 -2
  46. package/dist/src/cli.js +6 -16
  47. package/dist/src/client-metro.js +1 -1
  48. package/dist/src/cloud-artifacts.d.ts +2 -137
  49. package/dist/src/command-schema.js +3 -3
  50. package/dist/src/connection-runtime.js +1 -0
  51. package/dist/src/connection.js +5 -0
  52. package/dist/src/daemon-shutdown-report.js +1 -0
  53. package/dist/src/daemon.js +2 -0
  54. package/dist/src/debug-symbols.js +1 -1
  55. package/dist/src/device-claim-inspection.js +1 -0
  56. package/dist/src/device-input-state.js +2 -2
  57. package/dist/src/device-ready.js +1 -0
  58. package/dist/src/device.js +1 -1
  59. package/dist/src/device2.js +2 -0
  60. package/dist/src/dispatch.js +1 -0
  61. package/dist/src/find.js +1 -1
  62. package/dist/src/finders.js +1 -1
  63. package/dist/src/generic-settle.js +1 -1
  64. package/dist/src/generic.js +1 -1
  65. package/dist/src/handler-utils.js +1 -0
  66. package/dist/src/harmonyos.js +1 -0
  67. package/dist/src/hdc.js +1 -0
  68. package/dist/src/host-process.js +3 -2
  69. package/dist/src/ime-lifecycle.js +7 -7
  70. package/dist/src/index.d.ts +52 -21
  71. package/dist/src/input-actions.js +4 -4
  72. package/dist/src/input-actions2.js +1 -1
  73. package/dist/src/input-actions3.js +1 -0
  74. package/dist/src/install-artifact.js +1 -5
  75. package/dist/src/install-artifact2.js +1 -1
  76. package/dist/src/install-source2.js +1 -1
  77. package/dist/src/interaction-runtime.js +1 -1
  78. package/dist/src/interaction-snapshot.js +1 -1
  79. package/dist/src/interaction-touch-reference-frame.js +1 -1
  80. package/dist/src/interaction.js +1 -1
  81. package/dist/src/interactor.js +2 -2
  82. package/dist/src/interactor2.js +1 -1
  83. package/dist/src/internal/bin.js +2 -2
  84. package/dist/src/internal/daemon.js +7 -36
  85. package/dist/src/inventory.js +5 -0
  86. package/dist/src/inventory2.js +1 -0
  87. package/dist/src/inventory3.js +2 -0
  88. package/dist/src/inventory4.js +1 -0
  89. package/dist/src/inventory5.js +1 -0
  90. package/dist/src/inventory6.js +1 -0
  91. package/dist/src/lease-context.js +1 -1
  92. package/dist/src/lease.js +1 -1
  93. package/dist/src/limrun-runtime-dependencies.js +1 -0
  94. package/dist/src/limrun.d.ts +10 -125
  95. package/dist/src/limrun.js +1 -1
  96. package/dist/src/linux.js +1 -1
  97. package/dist/src/logcat.js +1 -1
  98. package/dist/src/manifest.js +1 -1
  99. package/dist/src/notifications.js +1 -1
  100. package/dist/src/output-path.js +1 -0
  101. package/dist/src/owner-identity.js +1 -0
  102. package/dist/src/physical-device-control.js +1 -1
  103. package/dist/src/platform-runtime-app-log-android-transport.js +1 -0
  104. package/dist/src/platform-runtime-app-log-process.js +1 -0
  105. package/dist/src/platform-runtime-host.js +1 -0
  106. package/dist/src/platform-runtime-network-web-transport.js +1 -0
  107. package/dist/src/platform-runtime-operation-host.js +5 -0
  108. package/dist/src/platform-runtime-screen-recording-android-host.js +1 -0
  109. package/dist/src/platform-runtime-screen-recording-apple-host.js +1 -0
  110. package/dist/src/platform-runtime-screen-recording-apple-runner-host.js +1 -0
  111. package/dist/src/platform-runtime-screen-recording-apple-runner-transport.js +1 -0
  112. package/dist/src/platform-runtime-screen-recording-apple-simulator-host.js +1 -0
  113. package/dist/src/platform-runtime-screen-recording-apple-transport.js +1 -0
  114. package/dist/src/platform-runtime-screen-recording-finalizer-host.js +1 -0
  115. package/dist/src/platform-runtime-screen-recording-harmony-host.js +1 -0
  116. package/dist/src/platform-runtime-screen-recording-output-host.js +1 -0
  117. package/dist/src/platform-runtime-screen-recording-web-host.js +1 -0
  118. package/dist/src/platform-runtime-toolchain-host.js +1 -0
  119. package/dist/src/platform-runtime.js +1 -0
  120. package/dist/src/prepare-kind.js +15 -0
  121. package/dist/src/process-entry.js +1 -0
  122. package/dist/src/process-lock.js +1 -1
  123. package/dist/src/provider-webdriver.js +1 -0
  124. package/dist/src/proxy.js +2 -0
  125. package/dist/src/react-native.js +1 -1
  126. package/dist/src/record-runtime.js +1 -0
  127. package/dist/src/record-trace.js +1 -1
  128. package/dist/src/recording-telemetry.js +1 -0
  129. package/dist/src/registry.js +24 -24
  130. package/dist/src/replay.js +3 -0
  131. package/dist/src/reporting.js +3 -3
  132. package/dist/src/runner-disposal.js +1 -1
  133. package/dist/src/runner-lease.js +1 -1
  134. package/dist/src/runtime.js +3 -3
  135. package/dist/src/runtime2.js +4 -0
  136. package/dist/src/runtime3.js +3 -0
  137. package/dist/src/runtime4.js +1 -0
  138. package/dist/src/runtime5.js +1 -0
  139. package/dist/src/runtime6.js +1 -0
  140. package/dist/src/runtime7.js +1 -0
  141. package/dist/src/screenshot-result.js +4 -4
  142. package/dist/src/screenshot.js +1 -0
  143. package/dist/src/sdk-android-adb.js +4 -4
  144. package/dist/src/sdk-batch-runner.js +3 -3
  145. package/dist/src/sdk-contracts.d.ts +31 -5
  146. package/dist/src/sdk-contracts.js +1 -1
  147. package/dist/src/sdk-device.js +1 -1
  148. package/dist/src/sdk-finders.js +1 -1
  149. package/dist/src/sdk-remote-config.js +1 -1
  150. package/dist/src/sdk-selectors.d.ts +285 -5
  151. package/dist/src/sdk-selectors.js +1 -1
  152. package/dist/src/selector-runtime.js +1 -1
  153. package/dist/src/selector-vocabulary.js +1 -1
  154. package/dist/src/selectors.js +1 -1
  155. package/dist/src/server.js +3 -3
  156. package/dist/src/session-target-evidence.js +1 -1
  157. package/dist/src/session.js +10 -9
  158. package/dist/src/shared.js +2 -0
  159. package/dist/src/shell-quote.js +1 -0
  160. package/dist/src/simulator.js +1 -1
  161. package/dist/src/snapshot-runtime.js +1 -0
  162. package/dist/src/snapshot-session.js +1 -0
  163. package/dist/src/snapshot2.js +1 -1
  164. package/dist/src/src.js +1 -1
  165. package/dist/src/src2.js +2 -0
  166. package/dist/src/src3.js +1 -0
  167. package/dist/src/text-surface.js +1 -1
  168. package/dist/src/timing-safe-equal.js +1 -0
  169. package/dist/src/tool-provider.js +2 -2
  170. package/dist/src/update-check.js +1 -1
  171. package/dist/src/version.js +1 -1
  172. package/dist/src/video.js +23 -0
  173. package/dist/src/web.js +1 -1
  174. package/package.json +34 -20
  175. package/server.json +2 -2
  176. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.6.apk.sha256 +0 -1
  177. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.6.apk.sha256 +0 -1
  178. package/dist/src/app-log-request-scope.js +0 -1
  179. package/dist/src/context.js +0 -1
  180. package/dist/src/devices.js +0 -1
  181. package/dist/src/devices2.js +0 -1
  182. package/dist/src/devices3.js +0 -1
  183. package/dist/src/devices4.js +0 -1
  184. package/dist/src/provider-limrun-runtime.js +0 -1
  185. package/dist/src/register-builtins.js +0 -1
  186. package/dist/src/runner-client.js +0 -1
  187. package/dist/src/runner-provider.js +0 -1
  188. package/dist/src/screenshot-geometry.js +0 -1
  189. package/dist/src/viewport-dimension.js +0 -15
  190. package/skills/agent-device/SKILL.md +0 -45
  191. package/skills/dogfood/SKILL.md +0 -27
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  Let your coding agent verify its changes in the running app.
17
17
 
18
- `agent-device` lets coding agents inspect, control, and verify apps on iOS, Android, tvOS, Android TV, Amazon Vega OS TV through the Vega Virtual Device (VVD), web, macOS, and Linux. Agents can read token-efficient accessibility snapshots where supported, find elements by ref or selector, run device actions, and save evidence for review. Initial Vega OS support is VVD-only and covers discovery, app lifecycle, and complete TV-remote control; physical Fire TV, capture, and selector backends remain unsupported.
18
+ `agent-device` lets coding agents inspect, control, and verify apps on iOS, Android, HarmonyOS, tvOS, Android TV, Amazon Vega OS TV through the Vega Virtual Device (VVD), web, macOS, and Linux. Agents can read token-efficient accessibility snapshots where supported, find elements by ref or selector, run device actions, and save evidence for review. HarmonyOS support uses HDC and ArkUI `uitest` on connected devices and DevEco emulators; use `capabilities --platform harmonyos` to inspect its evidence-backed command subset. Initial Vega OS support is VVD-only and covers discovery, app lifecycle, and complete TV-remote control; physical Fire TV, capture, and selector backends remain unsupported.
19
19
 
20
20
  Your coding agent or QA tool reads each result and chooses the next command. `agent-device` runs the command and saves evidence when asked.
21
21
 
@@ -109,7 +109,7 @@ See [Commands](https://oss.callstack.com/agent-device/docs/commands) for the com
109
109
 
110
110
  ## How it works
111
111
 
112
- `agent-device` keeps device state in sessions. It sends commands to XCTest on iOS and tvOS, ADB and the snapshot helper on Android, Vega CLI/VDA on the Vega Virtual Device, a local helper on macOS, and AT-SPI on Linux.
112
+ `agent-device` keeps device state in sessions. It sends commands to XCTest on iOS and tvOS, ADB and the snapshot helper on Android, HDC and ArkUI `uitest` on HarmonyOS, Vega CLI/VDA on the Vega Virtual Device, a local helper on macOS, and AT-SPI on Linux.
113
113
 
114
114
  Node.js apps can use the typed client or public subpaths. `agent-device/android-adb` provides the Android ADB provider interface, helpers for logcat, the clipboard, the keyboard, and apps, and port reverse management.
115
115
 
@@ -117,7 +117,7 @@ Node.js apps can use the typed client or public subpaths. `agent-device/android-
117
117
 
118
118
  ### What is agent-device?
119
119
 
120
- `agent-device` is a command-line tool that lets coding agents inspect, control, and verify apps and save evidence for review. It supports iOS, Android, TV, web, macOS, and Linux.
120
+ `agent-device` is a command-line tool that lets coding agents inspect, control, and verify apps and save evidence for review. It supports iOS, Android, HarmonyOS, TV, web, macOS, and Linux.
121
121
 
122
122
  ### Does it work with React Native, Expo, Flutter, and native apps?
123
123
 
@@ -0,0 +1 @@
1
+ 2877cc7e16930bcbc35e5550ae12d036af70f3b53ba4a977a2b9af1ed87cde0b agent-device-android-ime-helper-0.20.8.apk
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "android-ime-helper",
3
- "version": "0.20.6",
4
- "assetName": "agent-device-android-ime-helper-0.20.6.apk",
5
- "sha256": "7f719c5e1698e827948a9de22bf31aab065d7f2cb7d17731793b586fd3f88f4f",
3
+ "version": "0.20.8",
4
+ "assetName": "agent-device-android-ime-helper-0.20.8.apk",
5
+ "sha256": "2877cc7e16930bcbc35e5550ae12d036af70f3b53ba4a977a2b9af1ed87cde0b",
6
6
  "packageName": "com.callstack.agentdevice.imehelper",
7
- "versionCode": 20006,
7
+ "versionCode": 20008,
8
8
  "serviceComponent": "com.callstack.agentdevice.imehelper/.TestInputMethodService",
9
9
  "broadcastProtocol": "android-ime-helper-v1"
10
10
  }
@@ -0,0 +1 @@
1
+ 8cf893c902a0a9f2d24c654b45f88f183fc8930d31f8991a109906b57c39b5b5 agent-device-android-snapshot-helper-0.20.8.apk
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "android-snapshot-helper",
3
- "version": "0.20.6",
4
- "releaseTag": "v0.20.6",
5
- "assetName": "agent-device-android-snapshot-helper-0.20.6.apk",
3
+ "version": "0.20.8",
4
+ "releaseTag": "v0.20.8",
5
+ "assetName": "agent-device-android-snapshot-helper-0.20.8.apk",
6
6
  "apkUrl": null,
7
- "sha256": "696f8e518bbc2ad66225cb80f2e9e70d2b19b75d382918edd8fce3009200e434",
8
- "checksumName": "agent-device-android-snapshot-helper-0.20.6.apk.sha256",
7
+ "sha256": "8cf893c902a0a9f2d24c654b45f88f183fc8930d31f8991a109906b57c39b5b5",
8
+ "checksumName": "agent-device-android-snapshot-helper-0.20.8.apk.sha256",
9
9
  "packageName": "com.callstack.agentdevice.snapshothelper",
10
- "versionCode": 20006,
10
+ "versionCode": 20008,
11
11
  "instrumentationRunner": "com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation",
12
12
  "minSdk": 23,
13
13
  "targetSdk": 36,
@@ -105,6 +105,26 @@ int main(int argc, const char *argv[]) {
105
105
  [textField.heightAnchor constraintEqualToConstant:44],
106
106
  ]];
107
107
  }
108
+
109
+ if ([NSProcessInfo.processInfo.arguments containsObject:@"--agent-device-selector-read-regression"]) {
110
+ NSString *const duplicateIdentifier = @"agent-device-selector-read-duplicate";
111
+
112
+ UIButton *visibleButton = [UIButton buttonWithType:UIButtonTypeSystem];
113
+ visibleButton.accessibilityIdentifier = duplicateIdentifier;
114
+ [visibleButton setTitle:@"Readable target" forState:UIControlStateNormal];
115
+ visibleButton.translatesAutoresizingMaskIntoConstraints = NO;
116
+ [self.view addSubview:visibleButton];
117
+
118
+ UILabel *offscreenLabel = [[UILabel alloc] initWithFrame:CGRectMake(-200, -200, 100, 40)];
119
+ offscreenLabel.accessibilityIdentifier = duplicateIdentifier;
120
+ offscreenLabel.text = @"Decorative duplicate";
121
+ [self.view addSubview:offscreenLabel];
122
+
123
+ [NSLayoutConstraint activateConstraints:@[
124
+ [visibleButton.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
125
+ [visibleButton.topAnchor constraintEqualToAnchor:label.bottomAnchor constant:24],
126
+ ]];
127
+ }
108
128
  #endif
109
129
  }
110
130
 
@@ -1,4 +1,5 @@
1
1
  #import <Foundation/Foundation.h>
2
+ #import <CoreGraphics/CoreGraphics.h>
2
3
  #import <XCTest/XCTest.h>
3
4
 
4
5
  NS_ASSUME_NONNULL_BEGIN
@@ -11,6 +12,15 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey;
11
12
  FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionPendingKey;
12
13
  FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionMissedKey;
13
14
 
15
+ /// Keys of the `customActions` dictionary in the snapshot response: how many
16
+ /// merged elements were eligible for an action read, and how many the bounded
17
+ /// pass actually reached. Present only when the capture asked for actions.
18
+ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsKey;
19
+ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsReadKey;
20
+ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsCandidatesKey;
21
+ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsTruncatedKey;
22
+ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey;
23
+
14
24
  /// A depth-capped childless node awaiting an element-rooted follow-up request.
15
25
  /// Public so the runner unit bundle can drive the extension's miss paths with
16
26
  /// fabricated snapshots — the executed contract that missed frontiers are
@@ -29,12 +39,68 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionMissedKey;
29
39
  /// not). The response gains a `deepExtension` {calls, nodesAdded,
30
40
  /// pendingFrontiers, missedFrontiers} dictionary when any frontier existed and
31
41
  /// the call limit allowed extension. Pass 0 to disable extension entirely.
42
+ ///
43
+ /// `customActionLimit` bounds the per-element custom-action reads described on
44
+ /// `customActionNamesForElement:axClient:`; pass 0 to skip them entirely.
32
45
  + (NSDictionary<NSString *, id> *)snapshotTreeForApplication:(XCUIApplication *)application
33
46
  maxDepth:(NSInteger)maxDepth
34
47
  maxNodes:(NSInteger)maxNodes
35
48
  deepExtensionCallLimit:(NSInteger)deepExtensionCallLimit
49
+ customActionLimit:(NSInteger)customActionLimit
36
50
  deadline:(nullable NSDate *)deadline;
37
51
 
52
+ /// Names of the element's UIAccessibilityCustomActions, or nil when it has
53
+ /// none.
54
+ ///
55
+ /// One element per call on purpose. The bulk snapshot request cannot carry this
56
+ /// attribute: adding it makes testmanagerd's reply decoder reject the nested
57
+ /// arrays a custom action serializes into ("value for key 'NS.objects' was of
58
+ /// unexpected class 'NSArray'"), drop the reply, and time the request out after
59
+ /// ~65s instead of the usual ~110ms. Per-element reads use a different XPC
60
+ /// message and answer normally.
61
+ ///
62
+ /// Reading is all we can do: the actions are not invocable from the runner.
63
+ /// XCUIElement exposes no custom-action API; XCAXClient_iOS's
64
+ /// performAction:onElement:value: answers kAXErrorCannotComplete for every AX
65
+ /// action number; setAttribute: on the custom-actions attribute reports success
66
+ /// and does nothing; parameterizedAttribute: answers kAXErrorNoValue; and the
67
+ /// raw AXUIElement C API cannot be aimed at app elements because
68
+ /// XCAccessibilityElement is token-based and answers nil for AXUIElement.
69
+ + (nullable NSArray<NSString *> *)customActionNamesForElement:(id)element
70
+ axClient:(id)axClient
71
+ completed:(BOOL *)completed;
72
+
73
+ /// Bounds one element's contribution to the response (max actions, max name
74
+ /// length). Reports whether anything was clipped so truncation is disclosed
75
+ /// rather than silently presented as a complete list.
76
+ + (NSArray<NSString *> *)cappedActionNames:(NSArray<NSString *> *)names
77
+ truncated:(BOOL *)truncated;
78
+
79
+ /// The bounded read pass over merged leaves, exposed for the runner unit
80
+ /// bundle: the containment contract (a hung read blocks the pass and is
81
+ /// disclosed, and adds no further dispatches) is only executable from here.
82
+ /// Returns {read, candidates, truncated, blocked}.
83
+ + (NSDictionary<NSString *, NSNumber *> *)
84
+ annotateCustomActionsOnMergedLeaves:(nullable NSArray<RunnerAXSnapshotFrontier *> *)leaves
85
+ axClient:(id)axClient
86
+ limit:(NSInteger)limit
87
+ rootFrame:(CGRect)rootFrame
88
+ deadline:(nullable NSDate *)deadline;
89
+
90
+ /// Reads dispatched but not yet returned. The AX call cannot be cancelled, so a
91
+ /// read that blew its deadline stays counted here until it finally returns —
92
+ /// which is precisely the state in which further reads must be refused rather
93
+ /// than queued behind it. Consumed by the read pass to disclose the skip.
94
+ + (NSInteger)customActionReadsInFlight;
95
+
96
+ /// Total reads ever dispatched. The containment invariant is "a blocked capture
97
+ /// adds no work", which is only observable as this counter standing still.
98
+ + (NSInteger)customActionReadDispatchCount;
99
+
100
+ /// The shared AX client (`XCUIDevice.accessibilityInterface`), or nil when the
101
+ /// private interface is unavailable.
102
+ + (nullable id)accessibilityClient;
103
+
38
104
  + (NSInteger)processIdentifierForApplication:(XCUIApplication *)application;
39
105
 
40
106
  /// The frontier-extension loop, exposed for the runner unit bundle: a frontier
@@ -49,6 +115,7 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionMissedKey;
49
115
  nodeCount:(NSInteger *)nodeCount
50
116
  truncated:(BOOL *)truncated
51
117
  callsAllowed:(NSInteger)callsAllowed
118
+ mergedLeaves:(nullable NSMutableArray<RunnerAXSnapshotFrontier *> *)mergedLeaves
52
119
  deadline:(nullable NSDate *)deadline;
53
120
 
54
121
  @end
@@ -2,6 +2,7 @@
2
2
 
3
3
  #import <CoreGraphics/CoreGraphics.h>
4
4
  #import <objc/message.h>
5
+ #import <stdatomic.h>
5
6
 
6
7
  static NSString *const RunnerAXSnapshotOkKey = @"ok";
7
8
  static NSString *const RunnerAXSnapshotErrorKey = @"error";
@@ -14,6 +15,60 @@ NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey = @"nodesAdded";
14
15
  NSString *const RunnerAXSnapshotDeepExtensionPendingKey = @"pendingFrontiers";
15
16
  NSString *const RunnerAXSnapshotDeepExtensionMissedKey = @"missedFrontiers";
16
17
 
18
+ NSString *const RunnerAXSnapshotCustomActionsKey = @"customActions";
19
+ NSString *const RunnerAXSnapshotCustomActionsReadKey = @"read";
20
+ NSString *const RunnerAXSnapshotCustomActionsCandidatesKey = @"candidates";
21
+ NSString *const RunnerAXSnapshotCustomActionsTruncatedKey = @"truncated";
22
+ NSString *const RunnerAXSnapshotCustomActionsBlockedKey = @"blocked";
23
+
24
+ /// The AX server's name for UIAccessibilityCustomActions (React Native's
25
+ /// `accessibilityActions` prop lands here too).
26
+ static NSString *const RunnerAXCustomActionsAttribute = @"XC_kAXXCAttributeCustomActions";
27
+
28
+ /// A single read costs ~100ms against a responsive app. This bound exists for
29
+ /// the unresponsive case: without it one wedged element would swallow the whole
30
+ /// capture's budget and starve every remaining candidate, turning a bounded
31
+ /// enrichment into a capture-length stall.
32
+ static const NSTimeInterval RunnerAXCustomActionReadTimeout = 1.0;
33
+
34
+ /// The AX call is a synchronous XPC round trip that cannot be cancelled once
35
+ /// issued, so the deadline above only frees the CALLER — the call itself keeps
36
+ /// running. Containment therefore has two parts, and both are needed:
37
+ ///
38
+ /// 1. every read runs on this one serial queue, so a wedged call can never be
39
+ /// joined by a second concurrent user of the shared XCAXClient;
40
+ /// 2. a single-flight guard (below) refuses to dispatch while an abandoned
41
+ /// read is still outstanding, so repeated captures cannot pile up work
42
+ /// behind it.
43
+ ///
44
+ /// Reads resume by themselves once the hung call returns (or the runner
45
+ /// restarts); nothing has to be reset by hand.
46
+ static dispatch_queue_t RunnerAXCustomActionReadQueue(void)
47
+ {
48
+ static dispatch_queue_t queue;
49
+ static dispatch_once_t onceToken;
50
+ dispatch_once(&onceToken, ^{
51
+ queue = dispatch_queue_create("com.callstack.agentdevice.runner.ax-custom-actions",
52
+ DISPATCH_QUEUE_SERIAL);
53
+ });
54
+ return queue;
55
+ }
56
+
57
+ /// Reads dispatched but not yet returned. Non-zero after a read blew its
58
+ /// deadline, which is exactly the condition the single-flight guard refuses on.
59
+ static atomic_int RunnerAXCustomActionReadsInFlight = 0;
60
+
61
+ /// Total dispatches. The invariant the containment fix exists to protect is
62
+ /// "a blocked capture adds no work", which is only observable as this counter
63
+ /// staying put, so it is recorded rather than inferred.
64
+ static atomic_long RunnerAXCustomActionReadDispatches = 0;
65
+
66
+ /// Output caps. The element budget bounds how many elements we read; these
67
+ /// bound what any ONE element can put in the response, so a pathological app
68
+ /// cannot spend the whole snapshot on one node's action list.
69
+ static const NSUInteger RunnerAXCustomActionsMaxPerElement = 8;
70
+ static const NSUInteger RunnerAXCustomActionNameMaxLength = 80;
71
+
17
72
  typedef id (*RunnerAXObjectMsgSend)(id, SEL);
18
73
  typedef NSInteger (*RunnerAXIntegerMsgSend)(id, SEL);
19
74
  typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
@@ -33,10 +88,11 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
33
88
  maxDepth:(NSInteger)maxDepth
34
89
  maxNodes:(NSInteger)maxNodes
35
90
  deepExtensionCallLimit:(NSInteger)deepExtensionCallLimit
91
+ customActionLimit:(NSInteger)customActionLimit
36
92
  deadline:(nullable NSDate *)deadline
37
93
  {
38
94
  @try {
39
- id axClient = [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
95
+ id axClient = [self accessibilityClient];
40
96
  if (nil == axClient) {
41
97
  return [self failure:@"XCUIDevice accessibilityInterface is unavailable"];
42
98
  }
@@ -64,13 +120,16 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
64
120
  // zero extension bookkeeping.
65
121
  NSMutableArray<RunnerAXSnapshotFrontier *> *candidates =
66
122
  deepExtensionCallLimit > 0 ? [NSMutableArray array] : nil;
123
+ NSMutableArray<RunnerAXSnapshotFrontier *> *merged =
124
+ customActionLimit > 0 ? [NSMutableArray array] : nil;
67
125
  NSMutableDictionary *rootNode = [self dictionaryForSnapshot:root
68
126
  depth:0
69
127
  maxDepth:maxDepth
70
128
  maxNodes:maxNodes
71
129
  nodeCount:&nodeCount
72
130
  truncated:&truncated
73
- frontiers:candidates];
131
+ frontiers:candidates
132
+ mergedLeaves:merged];
74
133
  if (nil == rootNode) {
75
134
  return [self failure:@"AX snapshot root could not be serialized"];
76
135
  }
@@ -85,7 +144,20 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
85
144
  nodeCount:&nodeCount
86
145
  truncated:&truncated
87
146
  callsAllowed:deepExtensionCallLimit
147
+ mergedLeaves:merged
88
148
  deadline:deadline];
149
+ CGRect rootFrame = CGRectZero;
150
+ NSDictionary *rootFrameValue = rootNode[@"frame"];
151
+ if ([rootFrameValue isKindOfClass:NSDictionary.class]) {
152
+ rootFrame = CGRectMake(
153
+ [rootFrameValue[@"x"] doubleValue], [rootFrameValue[@"y"] doubleValue],
154
+ [rootFrameValue[@"width"] doubleValue], [rootFrameValue[@"height"] doubleValue]);
155
+ }
156
+ NSDictionary *customActions = [self annotateCustomActionsOnMergedLeaves:merged
157
+ axClient:axClient
158
+ limit:customActionLimit
159
+ rootFrame:rootFrame
160
+ deadline:deadline];
89
161
 
90
162
  NSMutableDictionary *response = [NSMutableDictionary dictionaryWithDictionary:@{
91
163
  RunnerAXSnapshotOkKey: @YES,
@@ -95,6 +167,9 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
95
167
  if (nil != deepExtension) {
96
168
  response[RunnerAXSnapshotDeepExtensionKey] = deepExtension;
97
169
  }
170
+ if (customActions.count > 0) {
171
+ response[RunnerAXSnapshotCustomActionsKey] = customActions;
172
+ }
98
173
  return response.copy;
99
174
  } @catch (NSException *exception) {
100
175
  return [self failure:exception.reason ?: exception.name ?: @"AX snapshot bridge exception"];
@@ -115,6 +190,7 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
115
190
  nodeCount:(NSInteger *)nodeCount
116
191
  truncated:(BOOL *)truncated
117
192
  callsAllowed:(NSInteger)callsAllowed
193
+ mergedLeaves:(nullable NSMutableArray<RunnerAXSnapshotFrontier *> *)mergedLeaves
118
194
  deadline:(nullable NSDate *)deadline
119
195
  {
120
196
  if (callsAllowed <= 0 || frontiers.count == 0) {
@@ -167,7 +243,8 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
167
243
  maxNodes:maxNodes
168
244
  nodeCount:nodeCount
169
245
  truncated:truncated
170
- frontiers:subCandidates];
246
+ frontiers:subCandidates
247
+ mergedLeaves:mergedLeaves];
171
248
  if (nil != childNode) {
172
249
  [children addObject:childNode];
173
250
  }
@@ -306,6 +383,250 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
306
383
  return attributes;
307
384
  }
308
385
 
386
+ /// Reads custom actions for the merged leaves in traversal order until the
387
+ /// limit or the capture deadline is spent. Each read is its own AX round trip
388
+ /// (~100ms on an idle simulator), so this is strictly opt-in and strictly
389
+ /// bounded.
390
+ ///
391
+ /// Returns {read, candidates} so the response can DISCLOSE an incomplete pass.
392
+ /// An unread element is byte-identical to one that genuinely has no actions, so
393
+ /// silence here would teach a reader that later cards have no affordances —
394
+ /// exactly the mis-inference this capture exists to prevent.
395
+ ///
396
+ /// `candidates` counts only elements still eligible at read time: a leaf that
397
+ /// deep extension filled in since serialization is not a merged element, so
398
+ /// counting it would inflate the denominator with nodes that never needed a
399
+ /// read. A leaf whose live element vanished stays counted and unread, because
400
+ /// that one really is an element we failed to answer for.
401
+ ///
402
+ /// On-screen candidates are read first. The budget is smaller than a long
403
+ /// feed's card count, so the order decides which elements an agent can act on
404
+ /// at all — and it also makes the disclosure's remedy true: scrolling changes
405
+ /// the on-screen set, so a re-run reads elements the previous pass could not.
406
+ /// Note that `--scope` deliberately is NOT the ordering key: scope is applied
407
+ /// when the Swift walk builds nodes, long after this pass, so it cannot narrow
408
+ /// the read budget without duplicating the scope predicate here.
409
+ + (NSDictionary<NSString *, NSNumber *> *)
410
+ annotateCustomActionsOnMergedLeaves:(nullable NSArray<RunnerAXSnapshotFrontier *> *)leaves
411
+ axClient:(id)axClient
412
+ limit:(NSInteger)limit
413
+ rootFrame:(CGRect)rootFrame
414
+ deadline:(nullable NSDate *)deadline
415
+ {
416
+ if (limit <= 0 || leaves.count == 0) {
417
+ return @{};
418
+ }
419
+ NSMutableArray<RunnerAXSnapshotFrontier *> *onScreen = [NSMutableArray array];
420
+ NSMutableArray<RunnerAXSnapshotFrontier *> *offScreen = [NSMutableArray array];
421
+ for (RunnerAXSnapshotFrontier *leaf in leaves) {
422
+ if ([leaf.node[@"children"] isKindOfClass:NSArray.class]
423
+ && [(NSArray *)leaf.node[@"children"] count] > 0) {
424
+ continue;
425
+ }
426
+ [([self isFrameOnScreen:leaf.node[@"frame"] rootFrame:rootFrame] ? onScreen : offScreen)
427
+ addObject:leaf];
428
+ }
429
+ NSInteger candidates = onScreen.count + offScreen.count;
430
+ NSInteger attempts = 0;
431
+ NSInteger reads = 0;
432
+ NSInteger annotated = 0;
433
+ NSInteger truncatedElements = 0;
434
+ BOOL blocked = NO;
435
+ for (RunnerAXSnapshotFrontier *leaf in [onScreen arrayByAddingObjectsFromArray:offScreen]) {
436
+ if (attempts >= limit || (nil != deadline && deadline.timeIntervalSinceNow <= 0)) {
437
+ break;
438
+ }
439
+ // An abandoned read from an earlier capture is still outstanding. Stop the
440
+ // pass here rather than spending a deadline per element on reads that will
441
+ // all be refused, and record why so the response does not present the
442
+ // unread elements as "has no actions".
443
+ if ([self customActionReadsInFlight] > 0) {
444
+ blocked = YES;
445
+ break;
446
+ }
447
+ id element = [self accessibilityElementForSnapshot:leaf.snapshot];
448
+ if (nil == element) {
449
+ continue;
450
+ }
451
+ // The attempt is what the budget buys; only a completed read counts as
452
+ // read, so a timed-out element stays in the undisclosed remainder instead
453
+ // of being reported as "read, and it has no actions".
454
+ attempts += 1;
455
+ BOOL completed = NO;
456
+ NSArray<NSString *> *names = [self customActionNamesForElement:element
457
+ axClient:axClient
458
+ completed:&completed];
459
+ if (!completed) {
460
+ continue;
461
+ }
462
+ reads += 1;
463
+ BOOL truncated = NO;
464
+ NSArray<NSString *> *capped = [self cappedActionNames:names ?: @[] truncated:&truncated];
465
+ if (truncated) {
466
+ truncatedElements += 1;
467
+ }
468
+ if (capped.count > 0) {
469
+ leaf.node[@"actions"] = capped;
470
+ annotated += 1;
471
+ }
472
+ }
473
+ NSLog(
474
+ @"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS reads=%ld attempts=%ld annotated=%ld "
475
+ "candidates=%ld onScreen=%ld truncated=%ld blocked=%d dispatches=%ld",
476
+ (long)reads, (long)attempts, (long)annotated, (long)candidates, (long)onScreen.count,
477
+ (long)truncatedElements, (int)blocked, (long)[self customActionReadDispatchCount]);
478
+ return @{
479
+ RunnerAXSnapshotCustomActionsReadKey: @(reads),
480
+ RunnerAXSnapshotCustomActionsCandidatesKey: @(candidates),
481
+ RunnerAXSnapshotCustomActionsTruncatedKey: @(truncatedElements),
482
+ RunnerAXSnapshotCustomActionsBlockedKey: @(blocked),
483
+ };
484
+ }
485
+
486
+ /// An empty root frame means the capture has no viewport to judge against, so
487
+ /// every candidate counts as on-screen and the order stays document order.
488
+ + (BOOL)isFrameOnScreen:(id)frameValue rootFrame:(CGRect)rootFrame
489
+ {
490
+ if (CGRectIsEmpty(rootFrame)) {
491
+ return YES;
492
+ }
493
+ if (![frameValue isKindOfClass:NSDictionary.class]) {
494
+ return NO;
495
+ }
496
+ NSDictionary *frame = (NSDictionary *)frameValue;
497
+ CGRect rect = CGRectMake(
498
+ [frame[@"x"] doubleValue], [frame[@"y"] doubleValue],
499
+ [frame[@"width"] doubleValue], [frame[@"height"] doubleValue]);
500
+ return !CGRectIsEmpty(rect) && CGRectIntersectsRect(rect, rootFrame);
501
+ }
502
+
503
+ + (nullable NSArray<NSString *> *)customActionNamesForElement:(id)element
504
+ axClient:(id)axClient
505
+ completed:(BOOL *)completed
506
+ {
507
+ if (NULL != completed) {
508
+ *completed = NO;
509
+ }
510
+ if (nil == element || nil == axClient) {
511
+ return nil;
512
+ }
513
+ SEL selector = NSSelectorFromString(@"attributesForElement:attributes:error:");
514
+ if (![axClient respondsToSelector:selector]) {
515
+ return nil;
516
+ }
517
+ // Single flight. An abandoned read still owns the serial queue, so
518
+ // dispatching here would only queue work behind it — and repeating the
519
+ // capture would keep queueing more. Refuse without dispatching instead.
520
+ if (atomic_load(&RunnerAXCustomActionReadsInFlight) > 0) {
521
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS_READ_BLOCKED");
522
+ return nil;
523
+ }
524
+ // The AX call is a synchronous XPC round trip with no timeout of its own, so
525
+ // it runs off-thread behind a bounded wait. On timeout the result box is
526
+ // never read, so a late-returning wedged call cannot race this thread.
527
+ atomic_fetch_add(&RunnerAXCustomActionReadsInFlight, 1);
528
+ atomic_fetch_add(&RunnerAXCustomActionReadDispatches, 1);
529
+ NSMutableArray *box = [NSMutableArray array];
530
+ dispatch_semaphore_t finished = dispatch_semaphore_create(0);
531
+ dispatch_async(RunnerAXCustomActionReadQueue(), ^{
532
+ typedef id (*RunnerAXAttributesMsgSend)(id, SEL, id, id, NSError **);
533
+ RunnerAXAttributesMsgSend send = (RunnerAXAttributesMsgSend)objc_msgSend;
534
+ NSError *error = nil;
535
+ id result = nil;
536
+ @try {
537
+ result = send(axClient, selector, element, @[ RunnerAXCustomActionsAttribute ], &error);
538
+ } @catch (NSException *exception) {
539
+ result = nil;
540
+ }
541
+ if (nil != result) {
542
+ [box addObject:result];
543
+ }
544
+ // Clear in-flight BEFORE signalling, so a waiter that wakes on this signal
545
+ // never observes its own completed read as still outstanding.
546
+ atomic_fetch_sub(&RunnerAXCustomActionReadsInFlight, 1);
547
+ dispatch_semaphore_signal(finished);
548
+ });
549
+ long waited = dispatch_semaphore_wait(
550
+ finished,
551
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(RunnerAXCustomActionReadTimeout * NSEC_PER_SEC)));
552
+ if (waited != 0) {
553
+ NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_CUSTOM_ACTIONS_READ_TIMEOUT");
554
+ return nil;
555
+ }
556
+ if (NULL != completed) {
557
+ *completed = YES;
558
+ }
559
+ id values = box.firstObject;
560
+ if (![values isKindOfClass:NSDictionary.class]) {
561
+ return nil;
562
+ }
563
+ id actions = ((NSDictionary *)values)[RunnerAXCustomActionsAttribute];
564
+ if (![actions isKindOfClass:NSArray.class]) {
565
+ return nil;
566
+ }
567
+ NSMutableArray<NSString *> *names = [NSMutableArray array];
568
+ for (id action in (NSArray *)actions) {
569
+ if (![action isKindOfClass:NSDictionary.class]) {
570
+ continue;
571
+ }
572
+ id name = ((NSDictionary *)action)[@"CustomActionName"];
573
+ if ([name isKindOfClass:NSString.class] && [(NSString *)name length] > 0) {
574
+ [names addObject:name];
575
+ }
576
+ }
577
+ return names.count > 0 ? names.copy : nil;
578
+ }
579
+
580
+ /// Bounds what one element can contribute to the response. The names come from
581
+ /// the app, so their count and length are the app's choice, not ours — a list
582
+ /// long enough to dominate a snapshot is a plausible accident (generated rows)
583
+ /// as well as a hostile case. Truncation is reported, never silent: a clipped
584
+ /// list is exactly as misleading as an unread element if it looks complete.
585
+ + (NSArray<NSString *> *)cappedActionNames:(NSArray<NSString *> *)names
586
+ truncated:(BOOL *)truncated
587
+ {
588
+ if (NULL != truncated) {
589
+ *truncated = NO;
590
+ }
591
+ NSMutableArray<NSString *> *capped = [NSMutableArray array];
592
+ for (NSString *name in names) {
593
+ if (capped.count >= RunnerAXCustomActionsMaxPerElement) {
594
+ if (NULL != truncated) {
595
+ *truncated = YES;
596
+ }
597
+ break;
598
+ }
599
+ if (name.length > RunnerAXCustomActionNameMaxLength) {
600
+ // Clip on composed character boundaries so a truncated name stays a
601
+ // valid string rather than a split grapheme.
602
+ NSRange clip = [name rangeOfComposedCharacterSequencesForRange:
603
+ NSMakeRange(0, RunnerAXCustomActionNameMaxLength)];
604
+ [capped addObject:[[name substringWithRange:clip] stringByAppendingString:@"…"]];
605
+ if (NULL != truncated) {
606
+ *truncated = YES;
607
+ }
608
+ continue;
609
+ }
610
+ [capped addObject:name];
611
+ }
612
+ return capped.copy;
613
+ }
614
+
615
+ + (NSInteger)customActionReadsInFlight
616
+ {
617
+ return atomic_load(&RunnerAXCustomActionReadsInFlight);
618
+ }
619
+
620
+ + (NSInteger)customActionReadDispatchCount
621
+ {
622
+ return (NSInteger)atomic_load(&RunnerAXCustomActionReadDispatches);
623
+ }
624
+
625
+ + (nullable id)accessibilityClient
626
+ {
627
+ return [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
628
+ }
629
+
309
630
  + (nullable id)accessibilityElementForSnapshot:(id)snapshot
310
631
  {
311
632
  id element = nil;
@@ -384,6 +705,7 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
384
705
  nodeCount:(NSInteger *)nodeCount
385
706
  truncated:(BOOL *)truncated
386
707
  frontiers:(NSMutableArray<RunnerAXSnapshotFrontier *> *)frontiers
708
+ mergedLeaves:(NSMutableArray<RunnerAXSnapshotFrontier *> *)mergedLeaves
387
709
  {
388
710
  if (nil == snapshot || *nodeCount >= maxNodes) {
389
711
  *truncated = YES;
@@ -410,7 +732,8 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
410
732
  maxNodes:maxNodes
411
733
  nodeCount:nodeCount
412
734
  truncated:truncated
413
- frontiers:frontiers];
735
+ frontiers:frontiers
736
+ mergedLeaves:mergedLeaves];
414
737
  if (nil != childNode) {
415
738
  [children addObject:childNode];
416
739
  }
@@ -420,6 +743,18 @@ typedef id (*RunnerAXSnapshotMsgSend)(id, SEL, id, id, id, NSError **);
420
743
  }
421
744
  }
422
745
  }
746
+ if (children.count == 0 && nil != mergedLeaves
747
+ && [result[@"label"] isKindOfClass:NSString.class]
748
+ && [(NSString *)result[@"label"] length] > 0) {
749
+ // A labelled childless node is the shape a merged card takes: the container
750
+ // is marked accessible, so its real controls never appear as children. Only
751
+ // these are worth an action read — unlabelled leaves are decoration.
752
+ RunnerAXSnapshotFrontier *candidate = [[RunnerAXSnapshotFrontier alloc] init];
753
+ candidate.snapshot = snapshot;
754
+ candidate.node = result;
755
+ candidate.depth = depth;
756
+ [mergedLeaves addObject:candidate];
757
+ }
423
758
  if (children.count == 0 && nil != frontiers && depth >= maxDepth - 1) {
424
759
  // Childless node at the cap boundary: either a real leaf or a branch whose
425
760
  // children the AX server withheld. The two are indistinguishable here, so