agent-device 0.20.5 → 0.20.6

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 (100) hide show
  1. package/README.md +2 -0
  2. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.5.apk → agent-device-android-ime-helper-0.20.6.apk} +0 -0
  3. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.6.apk.sha256 +1 -0
  4. package/android/ime-helper/dist/{agent-device-android-ime-helper-0.20.5.manifest.json → agent-device-android-ime-helper-0.20.6.manifest.json} +4 -4
  5. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.5.apk → agent-device-android-snapshot-helper-0.20.6.apk} +0 -0
  6. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.6.apk.sha256 +1 -0
  7. package/android/snapshot-helper/dist/{agent-device-android-snapshot-helper-0.20.5.manifest.json → agent-device-android-snapshot-helper-0.20.6.manifest.json} +6 -6
  8. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunner/AgentDeviceRunnerApp.m +28 -0
  9. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h +1 -0
  10. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.h +41 -1
  11. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAXSnapshotBridge.m +298 -92
  12. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +126 -187
  13. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.h +32 -0
  14. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedTextEntry.m +212 -0
  15. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +118 -8
  16. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +134 -27
  17. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Keyboard.swift +23 -5
  18. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +10 -0
  19. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift +0 -67
  20. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +11 -1
  21. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +2 -22
  22. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +48 -11
  23. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTraversalIdentityTests.swift +0 -20
  24. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +177 -0
  25. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +105 -448
  26. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryPolicyTests.swift +4 -0
  27. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +587 -0
  28. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +18 -0
  29. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.h +72 -0
  30. package/dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerXCTestEventBridge.m +89 -0
  31. package/dist/src/agent-device-client.js +2 -2
  32. package/dist/src/android.js +1 -1
  33. package/dist/src/app-log-request-scope.js +1 -1
  34. package/dist/src/app-resolution.js +1 -1
  35. package/dist/src/apps.js +5 -5
  36. package/dist/src/cli-config.js +1 -1
  37. package/dist/src/cli-help.js +34 -24
  38. package/dist/src/cli-runner.js +1 -1
  39. package/dist/src/cli.js +15 -15
  40. package/dist/src/command-metadata.js +1 -1
  41. package/dist/src/command-schema.js +3 -3
  42. package/dist/src/context.js +1 -1
  43. package/dist/src/debug-symbols.js +1 -1
  44. package/dist/src/find.js +1 -1
  45. package/dist/src/finders.js +1 -1
  46. package/dist/src/generic-settle.js +1 -0
  47. package/dist/src/generic.js +1 -1
  48. package/dist/src/ime-lifecycle.js +3 -3
  49. package/dist/src/index.d.ts +27 -7
  50. package/dist/src/input-actions.js +4 -4
  51. package/dist/src/interaction-runtime.js +1 -0
  52. package/dist/src/interaction-snapshot.js +1 -1
  53. package/dist/src/interaction-touch-reference-frame.js +1 -0
  54. package/dist/src/interaction.js +1 -1
  55. package/dist/src/interactor.js +2 -2
  56. package/dist/src/interactor2.js +1 -1
  57. package/dist/src/internal/bin.js +2 -2
  58. package/dist/src/internal/daemon.js +15 -15
  59. package/dist/src/lease-context.js +1 -1
  60. package/dist/src/lease.js +1 -1
  61. package/dist/src/limrun.d.ts +23 -2
  62. package/dist/src/linux.js +1 -1
  63. package/dist/src/process-exit.js +1 -0
  64. package/dist/src/react-native.js +1 -1
  65. package/dist/src/record-trace.js +1 -1
  66. package/dist/src/register-builtins.js +1 -1
  67. package/dist/src/registry.js +25 -24
  68. package/dist/src/remote-config.js +1 -1
  69. package/dist/src/remote-config2.js +1 -1
  70. package/dist/src/reporting.js +5 -5
  71. package/dist/src/runner-client.js +1 -1
  72. package/dist/src/runner-disposal.js +1 -1
  73. package/dist/src/runner-lease.js +1 -1
  74. package/dist/src/runtime.js +3 -3
  75. package/dist/src/screenshot-geometry.js +1 -1
  76. package/dist/src/screenshot-result.js +4 -4
  77. package/dist/src/sdk-android-adb.d.ts +20 -2
  78. package/dist/src/sdk-android-adb.js +1 -1
  79. package/dist/src/sdk-batch-runner.js +3 -3
  80. package/dist/src/sdk-contracts.d.ts +17 -2
  81. package/dist/src/sdk-finders.d.ts +4 -20
  82. package/dist/src/sdk-finders.js +1 -1
  83. package/dist/src/{interaction.d.ts → sdk-selectors.d.ts} +98 -3
  84. package/dist/src/sdk-selectors.js +1 -1
  85. package/dist/src/selector-runtime.js +1 -1
  86. package/dist/src/selector-vocabulary.js +1 -1
  87. package/dist/src/selectors.d.ts +1 -46
  88. package/dist/src/selectors.js +1 -1
  89. package/dist/src/server.js +3 -3
  90. package/dist/src/session-target-evidence.js +1 -1
  91. package/dist/src/session.js +9 -9
  92. package/dist/src/snapshot2.js +1 -1
  93. package/dist/src/text-surface.js +1 -0
  94. package/dist/src/viewport-dimension.js +13 -12
  95. package/dist/src/web.js +1 -1
  96. package/package.json +41 -35
  97. package/server.json +3 -3
  98. package/android/ime-helper/dist/agent-device-android-ime-helper-0.20.5.apk.sha256 +0 -1
  99. package/android/snapshot-helper/dist/agent-device-android-snapshot-helper-0.20.5.apk.sha256 +0 -1
  100. package/dist/apple/runner/AgentDeviceRunner/RecordingScripts/recording-resize.swift +0 -225
@@ -0,0 +1,212 @@
1
+ #import "RunnerSynthesizedTextEntry.h"
2
+ #import "RunnerXCTestEventBridge.h"
3
+
4
+ #import <objc/message.h>
5
+
6
+ static NSString *const RunnerTextSynthesisSurface = @"text";
7
+
8
+ typedef id (*RunnerTextMsgSendInit)(id, SEL, NSString *);
9
+ typedef id (*RunnerTextMsgSendInitPath)(id, SEL);
10
+ typedef void (*RunnerTextMsgSendType)(id, SEL, NSString *, NSTimeInterval, NSUInteger, BOOL);
11
+ typedef void (*RunnerTextMsgSendTypeKey)(id, SEL, NSString *, NSUInteger, NSTimeInterval);
12
+
13
+ // Text-entry-specific extension of the shared bridge: the 1-arg `initWithName:`
14
+ // record initializer and the text-input path selectors, none of which gesture
15
+ // synthesis needs.
16
+ typedef struct {
17
+ RunnerXCTestEventBridge core;
18
+ SEL initRecordSelector;
19
+ SEL initPathSelector;
20
+ SEL typeTextSelector;
21
+ SEL typeKeySelector; // only required in `replace` mode
22
+ } RunnerTextEventBridge;
23
+
24
+ static NSString * _Nullable RunnerResolveTextEventBridge(
25
+ id application,
26
+ BOOL replace,
27
+ RunnerTextEventBridge *bridge
28
+ );
29
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryResult(
30
+ RunnerSynthesizedTextEntryStatus status,
31
+ NSString * _Nullable message
32
+ );
33
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryUnavailable(NSString *message);
34
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryFailed(NSString *message);
35
+ static RunnerSynthesizedTextEntryResult *RunnerSynthesizeTextWithMode(
36
+ id application,
37
+ NSString *text,
38
+ BOOL replace
39
+ );
40
+
41
+ @interface RunnerSynthesizedTextEntryResult ()
42
+
43
+ @property(nonatomic, readwrite) RunnerSynthesizedTextEntryStatus status;
44
+ @property(nonatomic, readwrite, nullable) NSString *message;
45
+
46
+ @end
47
+
48
+
49
+ @implementation RunnerSynthesizedTextEntryResult
50
+ @end
51
+
52
+ @implementation RunnerSynthesizedTextEntry
53
+
54
+ + (RunnerSynthesizedTextEntryResult *)synthesizeTextWithApplication:(id)application
55
+ text:(NSString *)text {
56
+ return RunnerSynthesizeTextWithMode(application, text, NO);
57
+ }
58
+
59
+ + (RunnerSynthesizedTextEntryResult *)replaceTextWithApplication:(id)application
60
+ text:(NSString *)text {
61
+ return RunnerSynthesizeTextWithMode(application, text, YES);
62
+ }
63
+
64
+ @end
65
+
66
+ static RunnerSynthesizedTextEntryResult *RunnerSynthesizeTextWithMode(
67
+ id application,
68
+ NSString *text,
69
+ BOOL replace
70
+ ) {
71
+ @try {
72
+ RunnerTextEventBridge bridge;
73
+ NSString *missing = RunnerResolveTextEventBridge(application, replace, &bridge);
74
+ if (missing != nil) return RunnerTextEntryUnavailable(missing);
75
+
76
+ NSInteger targetProcessID =
77
+ ((RunnerMsgSendInteger)objc_msgSend)(application, bridge.core.processIDSelector);
78
+ if (targetProcessID <= 0) {
79
+ return RunnerTextEntryUnavailable(
80
+ @"private XCTest text synthesis unavailable: could not resolve target process ID"
81
+ );
82
+ }
83
+
84
+ if (replace) {
85
+ id selectionRecord = ((RunnerTextMsgSendInit)objc_msgSend)(
86
+ [bridge.core.recordClass alloc], bridge.initRecordSelector, @"agent-device-fill-select-all"
87
+ );
88
+ id selectionPath =
89
+ ((RunnerTextMsgSendInitPath)objc_msgSend)([bridge.core.pathClass alloc], bridge.initPathSelector);
90
+ if (selectionRecord == nil || selectionPath == nil) {
91
+ return RunnerTextEntryFailed(
92
+ @"private XCTest text synthesis failed: could not create the selection event"
93
+ );
94
+ }
95
+ ((RunnerMsgSendSetInteger)objc_msgSend)(
96
+ selectionRecord, bridge.core.setTargetProcessIDSelector, targetProcessID
97
+ );
98
+ // XCUIKeyModifierCommand is public XCTest API (1 << 4). Keeping the value local
99
+ // avoids importing XCUIAutomation headers into the bridging helper. A text-input
100
+ // path only performs its first operation reliably, so selection and replacement
101
+ // use ordered records rather than one multi-operation path.
102
+ NSUInteger commandModifier = 1UL << 4;
103
+ ((RunnerTextMsgSendTypeKey)objc_msgSend)(
104
+ selectionPath, bridge.typeKeySelector, @"a", commandModifier, 0.0
105
+ );
106
+ ((RunnerMsgSendAddPath)objc_msgSend)(selectionRecord, bridge.core.addPathSelector, selectionPath);
107
+ NSError *selectionError = nil;
108
+ BOOL selected = ((RunnerMsgSendSynthesize)objc_msgSend)(
109
+ selectionRecord, bridge.core.synthesizeSelector, &selectionError
110
+ );
111
+ if (!selected) {
112
+ NSString *detail = selectionError.localizedDescription ?: @"synthesizeWithError returned false";
113
+ return RunnerTextEntryFailed(
114
+ [NSString stringWithFormat:@"private XCTest text selection failed: %@", detail]
115
+ );
116
+ }
117
+ }
118
+
119
+ id record = ((RunnerTextMsgSendInit)objc_msgSend)(
120
+ [bridge.core.recordClass alloc],
121
+ bridge.initRecordSelector,
122
+ replace ? @"agent-device-fill-text" : @"agent-device-type"
123
+ );
124
+ id path = ((RunnerTextMsgSendInitPath)objc_msgSend)([bridge.core.pathClass alloc], bridge.initPathSelector);
125
+ if (record == nil || path == nil) {
126
+ return RunnerTextEntryFailed(
127
+ @"private XCTest text synthesis failed: could not create the text event"
128
+ );
129
+ }
130
+ ((RunnerMsgSendSetInteger)objc_msgSend)(record, bridge.core.setTargetProcessIDSelector, targetProcessID);
131
+ ((RunnerTextMsgSendType)objc_msgSend)(path, bridge.typeTextSelector, text, 0.0, 60, YES);
132
+ ((RunnerMsgSendAddPath)objc_msgSend)(record, bridge.core.addPathSelector, path);
133
+
134
+ NSError *error = nil;
135
+ BOOL ok = ((RunnerMsgSendSynthesize)objc_msgSend)(record, bridge.core.synthesizeSelector, &error);
136
+ if (!ok) {
137
+ NSString *detail = error.localizedDescription ?: @"synthesizeWithError returned false";
138
+ return RunnerTextEntryFailed(
139
+ [NSString stringWithFormat:@"private XCTest text synthesis failed: %@", detail]
140
+ );
141
+ }
142
+ return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusSucceeded, nil);
143
+ } @catch (NSException *exception) {
144
+ return RunnerTextEntryFailed(
145
+ RunnerFormatXCTestException(exception, @"private XCTest text synthesis failed")
146
+ );
147
+ }
148
+ }
149
+
150
+ static NSString * _Nullable RunnerResolveTextEventBridge(
151
+ id application,
152
+ BOOL replace,
153
+ RunnerTextEventBridge *bridge
154
+ ) {
155
+ RunnerXCTestEventBridge core;
156
+ NSString *missing = RunnerResolveXCTestEventBridge(application, RunnerTextSynthesisSurface, &core);
157
+ if (missing != nil) return missing;
158
+
159
+ SEL initRecordSelector = NSSelectorFromString(@"initWithName:");
160
+ SEL initPathSelector = NSSelectorFromString(@"initForTextInput");
161
+ SEL typeTextSelector = NSSelectorFromString(@"typeText:atOffset:typingSpeed:shouldRedact:");
162
+ SEL typeKeySelector = NSSelectorFromString(@"typeKey:modifiers:atOffset:");
163
+
164
+ missing = RunnerRequireSelector(
165
+ core.recordClass, initRecordSelector, @"initWithName:", RunnerTextSynthesisSurface
166
+ );
167
+ if (missing != nil) return missing;
168
+ missing = RunnerRequireSelector(
169
+ core.pathClass, initPathSelector, @"initForTextInput", RunnerTextSynthesisSurface
170
+ );
171
+ if (missing != nil) return missing;
172
+ missing = RunnerRequireSelector(
173
+ core.pathClass,
174
+ typeTextSelector,
175
+ @"typeText:atOffset:typingSpeed:shouldRedact:",
176
+ RunnerTextSynthesisSurface
177
+ );
178
+ if (missing != nil) return missing;
179
+ if (replace) {
180
+ missing = RunnerRequireSelector(
181
+ core.pathClass, typeKeySelector, @"typeKey:modifiers:atOffset:", RunnerTextSynthesisSurface
182
+ );
183
+ if (missing != nil) return missing;
184
+ }
185
+
186
+ *bridge = (RunnerTextEventBridge){
187
+ .core = core,
188
+ .initRecordSelector = initRecordSelector,
189
+ .initPathSelector = initPathSelector,
190
+ .typeTextSelector = typeTextSelector,
191
+ .typeKeySelector = typeKeySelector,
192
+ };
193
+ return nil;
194
+ }
195
+
196
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryResult(
197
+ RunnerSynthesizedTextEntryStatus status,
198
+ NSString * _Nullable message
199
+ ) {
200
+ RunnerSynthesizedTextEntryResult *result = [RunnerSynthesizedTextEntryResult new];
201
+ result.status = status;
202
+ result.message = message;
203
+ return result;
204
+ }
205
+
206
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryUnavailable(NSString *message) {
207
+ return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusUnavailable, message);
208
+ }
209
+
210
+ static RunnerSynthesizedTextEntryResult *RunnerTextEntryFailed(NSString *message) {
211
+ return RunnerTextEntryResult(RunnerSynthesizedTextEntryStatusFailed, message);
212
+ }
@@ -2,6 +2,28 @@ import XCTest
2
2
 
3
3
  extension RunnerTests {
4
4
  private static let privateAXSnapshotMaxNodes = 5_000
5
+
6
+ /// Upper bound on element-rooted follow-up snapshot requests per capture. A
7
+ /// depth-capped Bluesky-class tree resolves in 1-3 chained requests (~100-300ms
8
+ /// each); the bound exists so a pathological tree cannot stack requests past
9
+ /// the capture-plan deadline, which is also enforced per call.
10
+ private static let privateAXDeepExtensionCallLimit = 8
11
+
12
+ /// A capture is depth-limited unless its frontier extension resolved every
13
+ /// capped node: frontiers left pending (budget/deadline) or missed (element
14
+ /// vanished, re-rooted request failed) mean subtrees are still absent, and
15
+ /// presenting such a capture as complete would hide exactly the content the
16
+ /// extension exists to recover. nil extension counts = extension never ran.
17
+ static func privateAXDepthLimited(
18
+ effectiveDepth: Int,
19
+ requestedDepth: Int,
20
+ pendingFrontiers: Int?,
21
+ missedFrontiers: Int?
22
+ ) -> Bool {
23
+ guard effectiveDepth < requestedDepth else { return false }
24
+ guard let pendingFrontiers, let missedFrontiers else { return true }
25
+ return pendingFrontiers > 0 || missedFrontiers > 0
26
+ }
5
27
  /// Deep React Native trees make the AX server reject bulk snapshot requests outright with
6
28
  /// kAXErrorIllegalArgument once the requested depth crosses a tree-size-dependent limit
7
29
  /// (observed between depth 56 and 64 on the Bluesky Home feed; the limit moves with live
@@ -10,6 +32,53 @@ extension RunnerTests {
10
32
  /// apps where the AX surface is genuinely unavailable.
11
33
  static let privateAXSnapshotDepthLadder = [56, 40, 24, 12]
12
34
 
35
+ /// Ladder rungs for one capture. A remembered accepted depth (recorded when a prior capture's
36
+ /// deep request was rejected) drops the rungs above it, so the known-rejected deep request is
37
+ /// not re-paid on every capture of the same screen class.
38
+ static func privateAXAttemptDepths(requestedDepth: Int, rememberedDepth: Int?) -> [Int] {
39
+ var depths = [requestedDepth]
40
+ depths.append(contentsOf: privateAXSnapshotDepthLadder.filter { $0 < requestedDepth })
41
+ guard let remembered = rememberedDepth, remembered < requestedDepth else { return depths }
42
+ return depths.filter { $0 <= remembered }
43
+ }
44
+
45
+ func rememberPrivateAXAcceptedDepth(bundleId: String?, processIdentifier: Int?, depth: Int) {
46
+ // No PID means no way to notice a relaunch later; record nothing rather than risk serving
47
+ // a stale shallow rung to a fresh process.
48
+ guard let processIdentifier else { return }
49
+ privateAXAcceptedDepthLock.lock()
50
+ privateAXAcceptedDepthBundleId = bundleId
51
+ privateAXAcceptedDepthProcessIdentifier = processIdentifier
52
+ privateAXAcceptedDepth = depth
53
+ privateAXAcceptedDepthUntil = Date().addingTimeInterval(snapshotXCTestChannelPenaltyDuration)
54
+ privateAXAcceptedDepthLock.unlock()
55
+ NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_DEPTH_REMEMBERED depth=%ld bundle=%@", depth, bundleId ?? "")
56
+ }
57
+
58
+ func rememberedPrivateAXAcceptedDepth(bundleId: String?, processIdentifier: Int?) -> Int? {
59
+ privateAXAcceptedDepthLock.lock()
60
+ defer { privateAXAcceptedDepthLock.unlock() }
61
+ guard Date() < privateAXAcceptedDepthUntil else { return nil }
62
+ guard privateAXAcceptedDepthBundleId == bundleId else { return nil }
63
+ guard let processIdentifier, privateAXAcceptedDepthProcessIdentifier == processIdentifier else {
64
+ return nil
65
+ }
66
+ return privateAXAcceptedDepth
67
+ }
68
+
69
+ func clearPrivateAXAcceptedDepth(reason: String) {
70
+ privateAXAcceptedDepthLock.lock()
71
+ let hadMemory = privateAXAcceptedDepth != nil && Date() < privateAXAcceptedDepthUntil
72
+ privateAXAcceptedDepthBundleId = nil
73
+ privateAXAcceptedDepthProcessIdentifier = nil
74
+ privateAXAcceptedDepth = nil
75
+ privateAXAcceptedDepthUntil = .distantPast
76
+ privateAXAcceptedDepthLock.unlock()
77
+ if hadMemory {
78
+ NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_DEPTH_MEMORY_CLEARED reason=%@", reason)
79
+ }
80
+ }
81
+
13
82
  func privateAXSnapshotCapture(
14
83
  app: XCUIApplication,
15
84
  options: SnapshotOptions,
@@ -17,9 +86,19 @@ extension RunnerTests {
17
86
  ) -> SnapshotBackendCapture? {
18
87
  #if os(iOS) && targetEnvironment(simulator)
19
88
  let requestedDepth = options.depth ?? 64
20
- var attemptDepths = [requestedDepth]
21
- attemptDepths.append(
22
- contentsOf: Self.privateAXSnapshotDepthLadder.filter { $0 < requestedDepth }
89
+ // An explicit --depth request is honored as asked: no accepted-depth
90
+ // memory, no frontier extension past it.
91
+ let exactDepthRequested = options.depth != nil
92
+ let rememberedDepth =
93
+ exactDepthRequested
94
+ ? nil
95
+ : rememberedPrivateAXAcceptedDepth(
96
+ bundleId: currentBundleId,
97
+ processIdentifier: currentAppProcessIdentifier
98
+ )
99
+ let attemptDepths = Self.privateAXAttemptDepths(
100
+ requestedDepth: requestedDepth,
101
+ rememberedDepth: rememberedDepth
23
102
  )
24
103
  var response: [String: Any] = [:]
25
104
  var effectiveDepth = requestedDepth
@@ -35,7 +114,9 @@ extension RunnerTests {
35
114
  response = RunnerAXSnapshotBridge.snapshotTree(
36
115
  for: app,
37
116
  maxDepth: depth,
38
- maxNodes: Self.privateAXSnapshotMaxNodes
117
+ maxNodes: Self.privateAXSnapshotMaxNodes,
118
+ deepExtensionCallLimit: exactDepthRequested ? 0 : Self.privateAXDeepExtensionCallLimit,
119
+ deadline: deadline
39
120
  )
40
121
  if response["ok"] as? Bool == true {
41
122
  effectiveDepth = depth
@@ -52,6 +133,16 @@ extension RunnerTests {
52
133
  NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_FAILED=%@", lastError)
53
134
  return nil
54
135
  }
136
+ // Only a capture that actually descended records memory: a first-rung success on a
137
+ // remembered depth deliberately does NOT refresh the TTL, so expiry re-probes the full
138
+ // requested depth once per window instead of capping this screen class forever.
139
+ if !exactDepthRequested, effectiveDepth != attemptDepths.first {
140
+ rememberPrivateAXAcceptedDepth(
141
+ bundleId: currentBundleId,
142
+ processIdentifier: currentAppProcessIdentifier,
143
+ depth: effectiveDepth
144
+ )
145
+ }
55
146
  guard let root = response["root"] as? [String: Any] else {
56
147
  NSLog("AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_FAILED=missing root")
57
148
  return nil
@@ -74,11 +165,22 @@ extension RunnerTests {
74
165
  return nil
75
166
  }
76
167
 
77
- let depthLimited = effectiveDepth < requestedDepth
168
+ // A capture whose frontier extension resolved every capped node is
169
+ // complete despite the per-request depth cap — reporting it as
170
+ // depth-limited would send agents chasing deeper content that is not
171
+ // there. Pending or missed frontiers keep the depth-limited verdict.
172
+ let deepExtension = response[RunnerAXSnapshotDeepExtensionKey] as? [String: Any]
173
+ let depthLimited = Self.privateAXDepthLimited(
174
+ effectiveDepth: effectiveDepth,
175
+ requestedDepth: requestedDepth,
176
+ pendingFrontiers: deepExtension?[RunnerAXSnapshotDeepExtensionPendingKey] as? Int,
177
+ missedFrontiers: deepExtension?[RunnerAXSnapshotDeepExtensionMissedKey] as? Int
178
+ )
78
179
  NSLog(
79
- "AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_USED nodes=%ld depth=%ld",
180
+ "AGENT_DEVICE_RUNNER_PRIVATE_AX_SNAPSHOT_USED nodes=%ld depth=%ld extended=%ld",
80
181
  nodes.count,
81
- effectiveDepth
182
+ effectiveDepth,
183
+ deepExtension?[RunnerAXSnapshotDeepExtensionNodesAddedKey] as? Int ?? 0
82
184
  )
83
185
  return SnapshotBackendCapture(
84
186
  payload: DataPayload(nodes: nodes, truncated: (response["truncated"] as? Bool) == true),
@@ -89,9 +191,17 @@ extension RunnerTests {
89
191
  #endif
90
192
  }
91
193
 
194
+ /// The viewport read is XCTest main-thread work — the exact channel the penalty marks as
195
+ /// grinding on this screen class. Under penalty it reliably burns its full timeout and
196
+ /// falls back anyway (~1s added to every private AX capture on the Bluesky bench feed),
197
+ /// so honor the penalty here the same way capture plans do.
198
+ func shouldReadPrivateAXViewportViaXCTest() -> Bool {
199
+ !hasAbandonedTreeCapture() && !isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)
200
+ }
201
+
92
202
  private func privateAXSnapshotViewport(app: XCUIApplication, rootFrame: CGRect) -> CGRect {
93
203
  let fallback = rootFrame.isEmpty ? CGRect.infinite : rootFrame
94
- guard !hasAbandonedTreeCapture() else {
204
+ guard shouldReadPrivateAXViewportViaXCTest() else {
95
205
  return fallback
96
206
  }
97
207
  do {