agent-device 0.19.0 → 0.19.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.
- package/README.md +5 -1
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.apk → agent-device-android-multitouch-helper-0.19.1.apk} +0 -0
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.1.apk.sha256 +1 -0
- package/android-multitouch-helper/dist/{agent-device-android-multitouch-helper-0.19.0.manifest.json → agent-device-android-multitouch-helper-0.19.1.manifest.json} +4 -4
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.apk → agent-device-android-snapshot-helper-0.19.1.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.1.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.19.0.manifest.json → agent-device-android-snapshot-helper-0.19.1.manifest.json} +6 -6
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+AXSnapshotFallback.swift +24 -7
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +506 -119
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +153 -48
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +172 -0
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +76 -5
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +60 -52
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +159 -56
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +142 -0
- package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +16 -14
- package/dist/src/android-adb.d.ts +1 -2
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android-input-ownership.js +6 -0
- package/dist/src/android.js +1 -1
- package/dist/src/app-lifecycle.js +3 -3
- package/dist/src/apple-runner-options.js +1 -1
- package/dist/src/apps.js +5 -5
- package/dist/src/cli-help.js +131 -34
- package/dist/src/cli-runner.js +1 -1
- package/dist/src/cli.js +11 -11
- package/dist/src/client.js +2 -2
- package/dist/src/command-metadata.js +1 -1
- package/dist/src/command-schema.js +3 -3
- package/dist/src/device-input-state.js +2 -2
- package/dist/src/devices2.js +1 -1
- package/dist/src/find.js +1 -1
- package/dist/src/generic.js +1 -1
- package/dist/src/index.d.ts +199 -20
- package/dist/src/input-actions.js +3 -3
- package/dist/src/interaction-snapshot.js +1 -1
- package/dist/src/interaction.js +1 -1
- package/dist/src/interactor.js +2 -2
- package/dist/src/internal/bin.js +1 -1
- package/dist/src/internal/daemon.js +17 -15
- package/dist/src/lease.js +1 -1
- package/dist/src/linux.js +1 -1
- package/dist/src/metro.d.ts +0 -1
- package/dist/src/notifications.js +1 -1
- package/dist/src/points.js +1 -1
- package/dist/src/project-runtime.js +1 -1
- package/dist/src/providers.js +1 -1
- package/dist/src/react-native.js +1 -1
- package/dist/src/record-trace-recording.js +2 -2
- package/dist/src/record-trace.js +1 -1
- package/dist/src/registry.js +24 -23
- package/dist/src/reporting.js +3 -3
- package/dist/src/runner-client.js +1 -1
- package/dist/src/runtime.js +2 -2
- package/dist/src/sdk-android-adb.d.ts +2 -2
- package/dist/src/sdk-batch-runner.d.ts +0 -1
- package/dist/src/sdk-batch.js +2 -2
- package/dist/src/sdk-finders.d.ts +1 -2
- package/dist/src/sdk-finders.js +1 -1
- package/dist/src/sdk-io.d.ts +0 -1
- package/dist/src/sdk-remote-config.d.ts +1 -2
- package/dist/src/sdk-remote-config.js +1 -1
- package/dist/src/sdk-selectors.js +1 -1
- package/dist/src/selector-runtime.js +1 -1
- package/dist/src/selectors.d.ts +0 -1
- package/dist/src/selectors.js +1 -1
- package/dist/src/selectors2.js +1 -1
- package/dist/src/server.js +3 -3
- package/dist/src/session.js +10 -10
- package/dist/src/simctl.js +1 -1
- package/dist/src/simulator.js +1 -1
- package/dist/src/snapshot-diagnostics.js +2 -2
- package/dist/src/snapshot-helper.js +4 -4
- package/dist/src/snapshot-quality.js +1 -1
- package/dist/src/snapshot2.js +2 -2
- package/dist/src/tv-remote.js +14 -0
- package/dist/src/web.js +1 -1
- package/package.json +5 -4
- package/server.json +2 -2
- package/skills/agent-device/SKILL.md +8 -7
- package/android-multitouch-helper/dist/agent-device-android-multitouch-helper-0.19.0.apk.sha256 +0 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.19.0.apk.sha256 +0 -1
- package/dist/src/device-rotation.js +0 -14
- package/dist/src/logcat.js +0 -6
package/dist/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift
CHANGED
|
@@ -99,7 +99,7 @@ extension RunnerTests {
|
|
|
99
99
|
.table
|
|
100
100
|
]
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
static let flatInteractiveFallbackBudget: TimeInterval = 1.0
|
|
103
103
|
|
|
104
104
|
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
|
|
105
105
|
if let blocking = blockingSystemAlertSnapshot() {
|
|
@@ -387,6 +387,7 @@ extension RunnerTests {
|
|
|
387
387
|
|
|
388
388
|
func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload {
|
|
389
389
|
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message)
|
|
390
|
+
runnerAccessibilityHealth = .unavailable
|
|
390
391
|
invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason)
|
|
391
392
|
// This is a planned terminal result, so it carries the structured verdict like every other
|
|
392
393
|
// planned snapshot — downstream sparse handling keys off the verdict, not node shapes.
|
|
@@ -526,12 +527,22 @@ extension RunnerTests {
|
|
|
526
527
|
func makeSnapshotTraversalContext(
|
|
527
528
|
app: XCUIApplication,
|
|
528
529
|
options: SnapshotOptions,
|
|
529
|
-
captureDeadline: Date = .distantFuture
|
|
530
|
+
captureDeadline: Date = .distantFuture,
|
|
531
|
+
treeCaptureSliceBudgetOverride: TimeInterval? = nil
|
|
530
532
|
) throws -> SnapshotTraversalContext? {
|
|
531
|
-
let viewport =
|
|
532
|
-
|
|
533
|
+
let (viewport, queryRoot) = try runMainThreadWork(
|
|
534
|
+
command: nil,
|
|
535
|
+
timeout: min(1.0, max(0.1, captureDeadline.timeIntervalSinceNow)),
|
|
536
|
+
timeoutError: snapshotMainThreadTimeoutError("preparing tree snapshot")
|
|
537
|
+
) {
|
|
538
|
+
(
|
|
539
|
+
self.safeSnapshotViewport(app: app),
|
|
540
|
+
options.scope.flatMap { self.findScopeElement(app: app, scope: $0) } ?? app
|
|
541
|
+
)
|
|
542
|
+
}
|
|
533
543
|
|
|
534
|
-
let
|
|
544
|
+
let treeSliceBudget = treeCaptureSliceBudgetOverride ?? treeCaptureSliceBudget
|
|
545
|
+
let slice = min(treeSliceBudget, max(0.5, captureDeadline.timeIntervalSinceNow))
|
|
535
546
|
guard let rootSnapshot = try captureSnapshotRootBounded(queryRoot, sliceSeconds: slice) else {
|
|
536
547
|
return nil
|
|
537
548
|
}
|
|
@@ -547,7 +558,7 @@ extension RunnerTests {
|
|
|
547
558
|
)
|
|
548
559
|
}
|
|
549
560
|
|
|
550
|
-
static let
|
|
561
|
+
static let xCTestSnapshotTimeoutCode = "IOS_TREE_CAPTURE_TIMEOUT"
|
|
551
562
|
|
|
552
563
|
func hasAbandonedTreeCapture() -> Bool {
|
|
553
564
|
treeCaptureLock.lock()
|
|
@@ -555,63 +566,60 @@ extension RunnerTests {
|
|
|
555
566
|
return abandonedTreeCaptureCount > 0
|
|
556
567
|
}
|
|
557
568
|
|
|
558
|
-
/// Runs the blocking tree-snapshot XPC on
|
|
559
|
-
/// timeout the XPC keeps running on
|
|
560
|
-
///
|
|
561
|
-
///
|
|
569
|
+
/// Runs the blocking tree-snapshot XPC on the main thread bounded by `sliceSeconds`. On
|
|
570
|
+
/// timeout the XPC keeps running on main (it cannot be cancelled); the capture is marked
|
|
571
|
+
/// abandoned so plans avoid XCTest-backed tiers until it drains, the tree backend is penalized
|
|
572
|
+
/// for this bundle, and the plan moves to the platform's independent recovery tier when one
|
|
573
|
+
/// exists (#1105/#1122).
|
|
562
574
|
private func captureSnapshotRootBounded(
|
|
563
575
|
_ element: XCUIElement,
|
|
564
576
|
sliceSeconds: TimeInterval
|
|
565
577
|
) throws -> XCUIElementSnapshot? {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
var outcome: Result<XCUIElementSnapshot?, Error>?
|
|
578
|
+
if Thread.isMainThread {
|
|
579
|
+
return try captureSnapshotRoot(element)
|
|
569
580
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
+
return try runMainThreadWork(
|
|
582
|
+
command: nil,
|
|
583
|
+
timeout: sliceSeconds,
|
|
584
|
+
timeoutError: treeCaptureTimeoutError(sliceSeconds: sliceSeconds),
|
|
585
|
+
onAbandoned: {
|
|
586
|
+
self.treeCaptureLock.lock()
|
|
587
|
+
self.abandonedTreeCaptureCount += 1
|
|
588
|
+
self.treeCaptureLock.unlock()
|
|
589
|
+
NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds)
|
|
590
|
+
self.penalizeSnapshotXCTestChannel(
|
|
591
|
+
bundleId: self.currentBundleId,
|
|
592
|
+
reason: "tree_capture_slice_timeout"
|
|
593
|
+
)
|
|
594
|
+
},
|
|
595
|
+
onDrained: {
|
|
596
|
+
self.treeCaptureLock.lock()
|
|
581
597
|
self.abandonedTreeCaptureCount -= 1
|
|
582
598
|
self.treeCaptureLock.unlock()
|
|
583
599
|
NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_DRAINED")
|
|
584
|
-
} else {
|
|
585
|
-
box.outcome = result
|
|
586
|
-
self.treeCaptureLock.unlock()
|
|
587
600
|
}
|
|
588
|
-
|
|
601
|
+
) {
|
|
602
|
+
try self.captureSnapshotRoot(element)
|
|
589
603
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
NSLog("AGENT_DEVICE_RUNNER_TREE_CAPTURE_SLICE_TIMEOUT slice=%.1f", sliceSeconds)
|
|
600
|
-
penalizeSnapshotTreeBackend(bundleId: currentBundleId, reason: "tree_capture_slice_timeout")
|
|
601
|
-
throw SnapshotCaptureFailure(
|
|
602
|
-
code: Self.treeCaptureTimeoutCode,
|
|
603
|
-
message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice",
|
|
604
|
-
hint: "The capture plan recovers through the private AX backend on this screen."
|
|
605
|
-
)
|
|
606
|
-
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> () -> Error {
|
|
607
|
+
{
|
|
608
|
+
SnapshotCaptureFailure(
|
|
609
|
+
code: Self.xCTestSnapshotTimeoutCode,
|
|
610
|
+
message: "the XCTest tree capture exceeded its \(Int(sliceSeconds))s time slice",
|
|
611
|
+
hint: "The capture plan will avoid or tightly bound XCTest-backed snapshot tiers on this screen."
|
|
612
|
+
)
|
|
607
613
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
func snapshotMainThreadTimeoutError(_ operation: String) -> () -> Error {
|
|
617
|
+
{
|
|
618
|
+
SnapshotCaptureFailure(
|
|
619
|
+
code: Self.xCTestSnapshotTimeoutCode,
|
|
620
|
+
message: "timed out while \(operation) on the XCTest main thread",
|
|
621
|
+
hint: "The capture plan will skip XCTest-backed snapshot tiers while the previous main-thread work drains."
|
|
622
|
+
)
|
|
615
623
|
}
|
|
616
624
|
}
|
|
617
625
|
|
|
@@ -28,6 +28,40 @@ enum SnapshotBackendKind: String, CaseIterable {
|
|
|
28
28
|
case recursiveTree = "tree"
|
|
29
29
|
case querySweep = "queries"
|
|
30
30
|
case privateAX = "private-ax"
|
|
31
|
+
|
|
32
|
+
var usesXCTestAccessibilityChannel: Bool {
|
|
33
|
+
switch self {
|
|
34
|
+
case .recursiveTree, .querySweep:
|
|
35
|
+
return true
|
|
36
|
+
case .privateAX:
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var isAvailableOnCurrentPlatform: Bool {
|
|
42
|
+
switch self {
|
|
43
|
+
case .recursiveTree, .querySweep:
|
|
44
|
+
return true
|
|
45
|
+
case .privateAX:
|
|
46
|
+
#if os(iOS) && targetEnvironment(simulator)
|
|
47
|
+
return true
|
|
48
|
+
#else
|
|
49
|
+
return false
|
|
50
|
+
#endif
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
enum SnapshotXCTestChannelPlanState: Equatable {
|
|
56
|
+
case normal
|
|
57
|
+
case deferredToIndependentBackend
|
|
58
|
+
case boundedXCTestProbe
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
struct EffectiveSnapshotCapturePlan {
|
|
62
|
+
let plan: [SnapshotBackendKind]
|
|
63
|
+
let xCTestChannelState: SnapshotXCTestChannelPlanState
|
|
64
|
+
let treeCaptureSliceBudgetOverride: TimeInterval?
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
/// What the plan runner does when every backend failed or stayed sparse.
|
|
@@ -52,6 +86,7 @@ extension RunnerTests {
|
|
|
52
86
|
/// but chained recovery tiers must never stack past the 30s main-thread watchdog: when the
|
|
53
87
|
/// budget is spent, remaining tiers are skipped and the best payload so far is returned.
|
|
54
88
|
static let snapshotPlanBudget: TimeInterval = 20
|
|
89
|
+
static let penalizedXCTestProbeTreeSliceBudget: TimeInterval = 1
|
|
55
90
|
static let collapsedLeafMinimumSegments = 10
|
|
56
91
|
|
|
57
92
|
static func payloadNodeCount(_ payload: DataPayload?) -> Int {
|
|
@@ -63,44 +98,68 @@ extension RunnerTests {
|
|
|
63
98
|
static let regularVisiblePlan: [SnapshotBackendKind] = [.recursiveTree, .querySweep, .privateAX]
|
|
64
99
|
static let rawDiagnosticPlan: [SnapshotBackendKind] = [.recursiveTree, .privateAX]
|
|
65
100
|
|
|
66
|
-
// MARK:
|
|
101
|
+
// MARK: XCTest accessibility channel penalty (cross-attempt memory, #1105/#1156)
|
|
67
102
|
//
|
|
68
103
|
// On some deep/dynamic screens the XCTest bulk snapshot no longer fails fast with
|
|
69
104
|
// kAXErrorIllegalArgument (the #758 signature) — it grinds for many seconds first. One slow
|
|
70
105
|
// grind is tolerable; re-grinding on every subsequent capture of the same screen buries the
|
|
71
|
-
// main thread past the execution watchdog. After a slow or abandoned
|
|
72
|
-
// plans for the same bundle
|
|
73
|
-
|
|
74
|
-
func
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
// main thread past the execution watchdog. After a slow, timed-out, or abandoned XCTest-backed
|
|
107
|
+
// capture, later plans for the same bundle use non-XCTest recovery tiers until the penalty expires.
|
|
108
|
+
|
|
109
|
+
func penalizeSnapshotXCTestChannel(bundleId: String?, reason: String) {
|
|
110
|
+
snapshotXCTestChannelPenaltyLock.lock()
|
|
111
|
+
snapshotXCTestChannelPenaltyBundleId = bundleId
|
|
112
|
+
snapshotXCTestChannelPenaltyUntil = Date().addingTimeInterval(snapshotXCTestChannelPenaltyDuration)
|
|
113
|
+
snapshotXCTestChannelPenaltyLock.unlock()
|
|
79
114
|
NSLog(
|
|
80
|
-
"
|
|
115
|
+
"AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PENALIZED bundle=%@ reason=%@",
|
|
81
116
|
bundleId ?? "",
|
|
82
117
|
reason
|
|
83
118
|
)
|
|
84
119
|
}
|
|
85
120
|
|
|
86
|
-
func
|
|
87
|
-
|
|
88
|
-
defer {
|
|
89
|
-
guard Date() <
|
|
121
|
+
func isSnapshotXCTestChannelPenalized(bundleId: String?) -> Bool {
|
|
122
|
+
snapshotXCTestChannelPenaltyLock.lock()
|
|
123
|
+
defer { snapshotXCTestChannelPenaltyLock.unlock() }
|
|
124
|
+
guard Date() < snapshotXCTestChannelPenaltyUntil else { return false }
|
|
90
125
|
// A penalty recorded without a bundle id applies to whatever target is current.
|
|
91
|
-
guard let penalized =
|
|
126
|
+
guard let penalized = snapshotXCTestChannelPenaltyBundleId else { return true }
|
|
92
127
|
return penalized == bundleId
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
/// Pure plan-reorder rule: a penalized
|
|
96
|
-
///
|
|
97
|
-
/// left untouched.
|
|
130
|
+
/// Pure plan-reorder rule: a penalized XCTest accessibility channel uses independent backends
|
|
131
|
+
/// when the platform has one, otherwise it keeps XCTest work on a short probe. The raw
|
|
132
|
+
/// diagnostic plan keeps tree-first errors, and unknown plans are left untouched.
|
|
98
133
|
static func effectiveSnapshotCapturePlan(
|
|
99
134
|
_ plan: [SnapshotBackendKind],
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
135
|
+
xCTestChannelPenalized: Bool,
|
|
136
|
+
availableBackends: Set<SnapshotBackendKind> = Set(SnapshotBackendKind.allCases)
|
|
137
|
+
) -> EffectiveSnapshotCapturePlan {
|
|
138
|
+
guard xCTestChannelPenalized, plan == Self.regularVisiblePlan else {
|
|
139
|
+
return EffectiveSnapshotCapturePlan(
|
|
140
|
+
plan: plan,
|
|
141
|
+
xCTestChannelState: .normal,
|
|
142
|
+
treeCaptureSliceBudgetOverride: nil
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
let availablePlan = plan.filter { availableBackends.contains($0) }
|
|
146
|
+
let recoveryPlan = availablePlan.filter { !$0.usesXCTestAccessibilityChannel }
|
|
147
|
+
if !recoveryPlan.isEmpty {
|
|
148
|
+
return EffectiveSnapshotCapturePlan(
|
|
149
|
+
plan: recoveryPlan,
|
|
150
|
+
xCTestChannelState: .deferredToIndependentBackend,
|
|
151
|
+
treeCaptureSliceBudgetOverride: nil
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
return EffectiveSnapshotCapturePlan(
|
|
155
|
+
plan: availablePlan.filter(\.usesXCTestAccessibilityChannel),
|
|
156
|
+
xCTestChannelState: .boundedXCTestProbe,
|
|
157
|
+
treeCaptureSliceBudgetOverride: Self.penalizedXCTestProbeTreeSliceBudget
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func shouldSkipSnapshotBackendForAbandonedTreeCapture(_ kind: SnapshotBackendKind) -> Bool {
|
|
162
|
+
kind.usesXCTestAccessibilityChannel && hasAbandonedTreeCapture()
|
|
104
163
|
}
|
|
105
164
|
|
|
106
165
|
// MARK: Plan runner
|
|
@@ -116,22 +175,34 @@ extension RunnerTests {
|
|
|
116
175
|
var axFailure: SnapshotCaptureFailure?
|
|
117
176
|
let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
|
|
118
177
|
|
|
119
|
-
// Reorder is
|
|
120
|
-
// the
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
178
|
+
// Reorder is iOS-only because hostile screens can make XCTest tree/query work grind while
|
|
179
|
+
// the app remains visually responsive. Simulators can avoid that channel through private AX;
|
|
180
|
+
// physical devices have no independent semantic backend yet, so they use a bounded probe.
|
|
181
|
+
var xCTestChannelPenalized = false
|
|
182
|
+
#if os(iOS)
|
|
183
|
+
xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)
|
|
124
184
|
#endif
|
|
125
|
-
let
|
|
185
|
+
let effective = Self.effectiveSnapshotCapturePlan(
|
|
126
186
|
plan,
|
|
127
|
-
|
|
187
|
+
xCTestChannelPenalized: xCTestChannelPenalized,
|
|
188
|
+
availableBackends: Set(SnapshotBackendKind.allCases.filter(\.isAvailableOnCurrentPlatform))
|
|
128
189
|
)
|
|
129
|
-
|
|
190
|
+
let effectivePlan = effective.plan
|
|
191
|
+
switch effective.xCTestChannelState {
|
|
192
|
+
case .normal:
|
|
193
|
+
break
|
|
194
|
+
case .deferredToIndependentBackend:
|
|
195
|
+
firstFailure = (
|
|
196
|
+
"XCTest-backed snapshot tiers were deferred after recent slow accessibility work on this screen",
|
|
197
|
+
"budget"
|
|
198
|
+
)
|
|
199
|
+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_DEFERRED bundle=%@", currentBundleId ?? "")
|
|
200
|
+
case .boundedXCTestProbe:
|
|
130
201
|
firstFailure = (
|
|
131
|
-
"
|
|
202
|
+
"XCTest-backed snapshot tiers are running with a short recovery probe after recent slow accessibility work on this screen",
|
|
132
203
|
"budget"
|
|
133
204
|
)
|
|
134
|
-
NSLog("
|
|
205
|
+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_XCTEST_CHANNEL_PROBE_BOUNDED bundle=%@", currentBundleId ?? "")
|
|
135
206
|
}
|
|
136
207
|
|
|
137
208
|
for kind in effectivePlan {
|
|
@@ -143,8 +214,8 @@ extension RunnerTests {
|
|
|
143
214
|
break
|
|
144
215
|
}
|
|
145
216
|
// While an abandoned tree capture is still grinding inside testmanagerd, XCTest-backed
|
|
146
|
-
// tiers would block behind it; only
|
|
147
|
-
if kind
|
|
217
|
+
// tiers would block behind it; only independent backends stay responsive (#1105).
|
|
218
|
+
if shouldSkipSnapshotBackendForAbandonedTreeCapture(kind) {
|
|
148
219
|
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_TIER_SKIPPED_XCTEST_OCCUPIED tier=%@", kind.rawValue)
|
|
149
220
|
if firstFailure == nil {
|
|
150
221
|
firstFailure = (
|
|
@@ -157,15 +228,23 @@ extension RunnerTests {
|
|
|
157
228
|
let capture: SnapshotBackendCapture
|
|
158
229
|
let backendStartedAt = Date()
|
|
159
230
|
do {
|
|
160
|
-
guard
|
|
231
|
+
guard
|
|
232
|
+
let result = try captureWithBackend(
|
|
233
|
+
kind,
|
|
234
|
+
app: app,
|
|
235
|
+
options: options,
|
|
236
|
+
deadline: deadline,
|
|
237
|
+
treeCaptureSliceBudgetOverride: effective.treeCaptureSliceBudgetOverride
|
|
238
|
+
)
|
|
161
239
|
else {
|
|
162
|
-
|
|
240
|
+
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
|
|
163
241
|
continue
|
|
164
242
|
}
|
|
165
243
|
capture = result
|
|
166
|
-
|
|
244
|
+
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
|
|
167
245
|
} catch let failure as SnapshotCaptureFailure {
|
|
168
|
-
|
|
246
|
+
recordXCTestSnapshotBackendFailureIfNeeded(kind, failure: failure)
|
|
247
|
+
recordSlowXCTestSnapshotBackendIfNeeded(kind, startedAt: backendStartedAt)
|
|
169
248
|
if Self.isAxSnapshotFailure(failure) { axFailure = failure }
|
|
170
249
|
if firstFailure == nil {
|
|
171
250
|
firstFailure = (failure.message, Self.isAxSnapshotFailure(failure) ? "ax-rejected" : "capture-failed")
|
|
@@ -186,7 +265,7 @@ extension RunnerTests {
|
|
|
186
265
|
continue
|
|
187
266
|
}
|
|
188
267
|
|
|
189
|
-
let recovered = kind != effectivePlan.first ||
|
|
268
|
+
let recovered = kind != effectivePlan.first || effective.xCTestChannelState != .normal
|
|
190
269
|
if recovered {
|
|
191
270
|
NSLog(
|
|
192
271
|
"AGENT_DEVICE_RUNNER_SNAPSHOT_RECOVERED backend=%@ reason=%@",
|
|
@@ -225,22 +304,33 @@ extension RunnerTests {
|
|
|
225
304
|
best.map { stampedSnapshotPayload($0.capture, backend: $0.kind, state: "sparse", reason: firstFailure) }
|
|
226
305
|
?? stampedSnapshotPayload(
|
|
227
306
|
SnapshotBackendCapture(payload: sparseTruncatedSnapshotPayload(), effectiveDepth: nil),
|
|
228
|
-
backend: plan.last ?? .recursiveTree,
|
|
307
|
+
backend: effectivePlan.last ?? plan.last ?? .recursiveTree,
|
|
229
308
|
state: "sparse",
|
|
230
309
|
reason: firstFailure
|
|
231
310
|
)
|
|
232
311
|
return fallbackPayload
|
|
233
312
|
}
|
|
234
313
|
|
|
235
|
-
/// Marks
|
|
314
|
+
/// Marks XCTest-backed snapshot tiers as penalized when one attempt ground past the slow-capture
|
|
236
315
|
/// threshold — even a successful one: the next capture of this screen must not re-grind.
|
|
237
|
-
private func
|
|
238
|
-
guard kind
|
|
316
|
+
private func recordSlowXCTestSnapshotBackendIfNeeded(_ kind: SnapshotBackendKind, startedAt: Date) {
|
|
317
|
+
guard kind.usesXCTestAccessibilityChannel else { return }
|
|
239
318
|
let elapsed = Date().timeIntervalSince(startedAt)
|
|
240
|
-
guard elapsed >
|
|
241
|
-
|
|
319
|
+
guard elapsed > snapshotXCTestSlowCaptureThreshold else { return }
|
|
320
|
+
penalizeSnapshotXCTestChannel(
|
|
242
321
|
bundleId: currentBundleId,
|
|
243
|
-
reason: "
|
|
322
|
+
reason: "slow_\(kind.rawValue)_capture_\(Int(elapsed * 1000))ms"
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private func recordXCTestSnapshotBackendFailureIfNeeded(
|
|
327
|
+
_ kind: SnapshotBackendKind,
|
|
328
|
+
failure: SnapshotCaptureFailure
|
|
329
|
+
) {
|
|
330
|
+
guard kind.usesXCTestAccessibilityChannel, failure.code == Self.xCTestSnapshotTimeoutCode else { return }
|
|
331
|
+
penalizeSnapshotXCTestChannel(
|
|
332
|
+
bundleId: currentBundleId,
|
|
333
|
+
reason: "\(kind.rawValue)_backend_timeout"
|
|
244
334
|
)
|
|
245
335
|
}
|
|
246
336
|
|
|
@@ -248,7 +338,8 @@ extension RunnerTests {
|
|
|
248
338
|
_ kind: SnapshotBackendKind,
|
|
249
339
|
app: XCUIApplication,
|
|
250
340
|
options: SnapshotOptions,
|
|
251
|
-
deadline: Date
|
|
341
|
+
deadline: Date,
|
|
342
|
+
treeCaptureSliceBudgetOverride: TimeInterval?
|
|
252
343
|
) throws -> SnapshotBackendCapture? {
|
|
253
344
|
switch kind {
|
|
254
345
|
case .recursiveTree:
|
|
@@ -256,20 +347,31 @@ extension RunnerTests {
|
|
|
256
347
|
let context = try makeSnapshotTraversalContext(
|
|
257
348
|
app: app,
|
|
258
349
|
options: options,
|
|
259
|
-
captureDeadline: deadline
|
|
350
|
+
captureDeadline: deadline,
|
|
351
|
+
treeCaptureSliceBudgetOverride: treeCaptureSliceBudgetOverride
|
|
260
352
|
)
|
|
261
353
|
else {
|
|
262
354
|
return nil
|
|
263
355
|
}
|
|
264
|
-
let payload =
|
|
265
|
-
|
|
266
|
-
:
|
|
356
|
+
let payload = try runMainThreadWork(
|
|
357
|
+
command: nil,
|
|
358
|
+
timeout: min(treeCaptureSliceBudget, max(0.5, deadline.timeIntervalSinceNow)),
|
|
359
|
+
timeoutError: snapshotMainThreadTimeoutError("processing tree snapshot")
|
|
360
|
+
) {
|
|
361
|
+
options.raw
|
|
362
|
+
? try self.rawTreeSnapshotPayload(context: context, options: options)
|
|
363
|
+
: self.recursiveTreeSnapshotPayload(context: context, options: options)
|
|
364
|
+
}
|
|
267
365
|
return SnapshotBackendCapture(payload: payload, effectiveDepth: nil)
|
|
268
366
|
case .querySweep:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
367
|
+
let payload = try runMainThreadWork(
|
|
368
|
+
command: nil,
|
|
369
|
+
timeout: min(Self.flatInteractiveFallbackBudget, max(0.1, deadline.timeIntervalSinceNow)),
|
|
370
|
+
timeoutError: snapshotMainThreadTimeoutError("running query-sweep snapshot")
|
|
371
|
+
) {
|
|
372
|
+
self.snapshotFlatInteractive(app: app, options: options, planDeadline: deadline)
|
|
373
|
+
}
|
|
374
|
+
return SnapshotBackendCapture(payload: payload, effectiveDepth: nil)
|
|
273
375
|
case .privateAX:
|
|
274
376
|
return privateAXSnapshotCapture(app: app, options: options, deadline: deadline)
|
|
275
377
|
}
|
|
@@ -349,6 +451,7 @@ extension RunnerTests {
|
|
|
349
451
|
state: String,
|
|
350
452
|
reason: (reason: String, code: String)?
|
|
351
453
|
) -> DataPayload {
|
|
454
|
+
runnerAccessibilityHealth = reason?.code == "ax-rejected" ? .unavailable : .healthy
|
|
352
455
|
let payload = capture.payload
|
|
353
456
|
let quality = SnapshotQuality(
|
|
354
457
|
state: state,
|
|
@@ -377,7 +480,7 @@ extension RunnerTests {
|
|
|
377
480
|
? " The primary capture ran out of its time budget (busy app or simulator); the recovered tree is authoritative for this screen."
|
|
378
481
|
: " This usually means the app publishes an unhealthy accessibility tree — fixing the app's accessibility is the real cure. Treat screenshot as visual truth when this warning appears."
|
|
379
482
|
parts.append(
|
|
380
|
-
"
|
|
483
|
+
"Detected an overly complex or slow accessibility tree. Fell back to the \(quality.backend) snapshot backend"
|
|
381
484
|
+ (quality.reason.map { " after: \($0)." } ?? ".")
|
|
382
485
|
+ meaning
|
|
383
486
|
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
// Runner-local policy for AX-free/private synthesized iOS gestures.
|
|
4
|
+
//
|
|
5
|
+
// This policy is intentionally separate from the TS interaction guarantee matrix:
|
|
6
|
+
// ADR 0011 models element-targeting guarantees, while this module models command
|
|
7
|
+
// paths that must keep scroll/drag/sequence usable when XCTest AX is unhealthy.
|
|
8
|
+
|
|
9
|
+
enum RunnerAccessibilityHealth: String, Equatable {
|
|
10
|
+
case unknown
|
|
11
|
+
case healthy
|
|
12
|
+
case unavailable
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
enum SynthesizedKeyboardPolicy: String, Equatable, Hashable {
|
|
16
|
+
case never
|
|
17
|
+
case whenAccessibilityHealthy
|
|
18
|
+
case requiredWhenAvailable
|
|
19
|
+
|
|
20
|
+
func allowsProbe(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
|
|
21
|
+
switch self {
|
|
22
|
+
case .never:
|
|
23
|
+
return false
|
|
24
|
+
case .whenAccessibilityHealthy:
|
|
25
|
+
return accessibilityHealth == .healthy
|
|
26
|
+
case .requiredWhenAvailable:
|
|
27
|
+
return accessibilityHealth != .unavailable
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
enum SynthesizedFallbackPolicy: String, Equatable, Hashable {
|
|
33
|
+
case privateSynthesisRequired
|
|
34
|
+
case xctestCoordinateWhenAccessibilityAvailable
|
|
35
|
+
case xctestCoordinateAllowed
|
|
36
|
+
|
|
37
|
+
func allowsXCTestCoordinateFallback(accessibilityHealth: RunnerAccessibilityHealth) -> Bool {
|
|
38
|
+
switch self {
|
|
39
|
+
case .privateSynthesisRequired:
|
|
40
|
+
return false
|
|
41
|
+
case .xctestCoordinateWhenAccessibilityAvailable:
|
|
42
|
+
return accessibilityHealth != .unavailable
|
|
43
|
+
case .xctestCoordinateAllowed:
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
enum SynthesizedGesturePolicyKind: String, Equatable, Hashable {
|
|
50
|
+
case coordinateTap
|
|
51
|
+
case scroll
|
|
52
|
+
case synthesizedDrag
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
struct SynthesizedGesturePolicy: Equatable, Hashable {
|
|
56
|
+
let keyboardPolicy: SynthesizedKeyboardPolicy
|
|
57
|
+
let fallbackPolicy: SynthesizedFallbackPolicy
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
struct SynthesizedCoordinateContext {
|
|
61
|
+
let referenceFrame: CGRect
|
|
62
|
+
let keyboardPolicy: SynthesizedKeyboardPolicy
|
|
63
|
+
let fallbackPolicy: SynthesizedFallbackPolicy
|
|
64
|
+
let accessibilityHealth: RunnerAccessibilityHealth
|
|
65
|
+
|
|
66
|
+
func withReferenceFrame(_ frame: CGRect) -> SynthesizedCoordinateContext {
|
|
67
|
+
SynthesizedCoordinateContext(
|
|
68
|
+
referenceFrame: frame,
|
|
69
|
+
keyboardPolicy: keyboardPolicy,
|
|
70
|
+
fallbackPolicy: fallbackPolicy,
|
|
71
|
+
accessibilityHealth: accessibilityHealth
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
var allowsXCTestCoordinateFallback: Bool {
|
|
76
|
+
fallbackPolicy.allowsXCTestCoordinateFallback(accessibilityHealth: accessibilityHealth)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var allowsKeyboardProbe: Bool {
|
|
80
|
+
keyboardPolicy.allowsProbe(accessibilityHealth: accessibilityHealth)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func synthesizedGesturePolicy(_ kind: SynthesizedGesturePolicyKind) -> SynthesizedGesturePolicy {
|
|
85
|
+
switch kind {
|
|
86
|
+
case .coordinateTap:
|
|
87
|
+
return SynthesizedGesturePolicy(
|
|
88
|
+
keyboardPolicy: .never,
|
|
89
|
+
fallbackPolicy: .xctestCoordinateAllowed
|
|
90
|
+
)
|
|
91
|
+
case .scroll:
|
|
92
|
+
return SynthesizedGesturePolicy(
|
|
93
|
+
keyboardPolicy: .whenAccessibilityHealthy,
|
|
94
|
+
fallbackPolicy: .privateSynthesisRequired
|
|
95
|
+
)
|
|
96
|
+
case .synthesizedDrag:
|
|
97
|
+
return SynthesizedGesturePolicy(
|
|
98
|
+
keyboardPolicy: .requiredWhenAvailable,
|
|
99
|
+
fallbackPolicy: .xctestCoordinateWhenAccessibilityAvailable
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func sequenceHasSynthesizedCoordinateStep(_ steps: [SequenceStep]) -> Bool {
|
|
105
|
+
steps.contains { step in
|
|
106
|
+
step.synthesized == true && (step.kind == "tap" || step.kind == "drag")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
extension RunnerTests {
|
|
111
|
+
func synthesizedSequenceCoordinateContext(steps: [SequenceStep]) -> SynthesizedCoordinateContext? {
|
|
112
|
+
guard sequenceHasSynthesizedCoordinateStep(steps) else { return nil }
|
|
113
|
+
return synthesizedCoordinateContext(policy: synthesizedGesturePolicy(.synthesizedDrag))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func logSynthesizedGesturePolicyDecision(
|
|
117
|
+
kind: SynthesizedGesturePolicyKind,
|
|
118
|
+
context: SynthesizedCoordinateContext?,
|
|
119
|
+
fallbackAttempted: Bool
|
|
120
|
+
) {
|
|
121
|
+
#if os(iOS)
|
|
122
|
+
guard let context else {
|
|
123
|
+
NSLog(
|
|
124
|
+
"AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ context=unavailable fallbackAttempted=%@",
|
|
125
|
+
kind.rawValue,
|
|
126
|
+
fallbackAttempted.description
|
|
127
|
+
)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
NSLog(
|
|
131
|
+
"AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=%@ axHealth=%@ frameSource=screenshot keyboardPolicy=%@ fallbackPolicy=%@ fallbackAllowed=%@ fallbackAttempted=%@",
|
|
132
|
+
kind.rawValue,
|
|
133
|
+
context.accessibilityHealth.rawValue,
|
|
134
|
+
context.keyboardPolicy.rawValue,
|
|
135
|
+
context.fallbackPolicy.rawValue,
|
|
136
|
+
context.allowsXCTestCoordinateFallback.description,
|
|
137
|
+
fallbackAttempted.description
|
|
138
|
+
)
|
|
139
|
+
#endif
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|