agent-device 0.10.3 → 0.11.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 +6 -0
- package/dist/src/306.js +3 -0
- package/dist/src/bin.js +78 -62
- package/dist/src/daemon.js +40 -39
- package/dist/src/index.d.ts +24 -0
- package/dist/src/index.js +2 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +84 -16
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +140 -50
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +13 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +22 -9
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +221 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +19 -7
- package/ios-runner/README.md +17 -2
- package/ios-runner/RUNNER_PROTOCOL.md +73 -0
- package/package.json +13 -9
- package/skills/agent-device/SKILL.md +3 -0
- package/skills/agent-device/references/exploration.md +29 -1
- package/skills/agent-device/references/verification.md +8 -1
- package/dist/src/376.js +0 -3
|
@@ -17,14 +17,9 @@ extension RunnerTests {
|
|
|
17
17
|
let referenceHeight: Double
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
struct GestureReferenceFrame {
|
|
21
|
-
let referenceWidth: Double
|
|
22
|
-
let referenceHeight: Double
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
// MARK: - Navigation Gestures
|
|
26
21
|
|
|
27
|
-
func
|
|
22
|
+
func tapInAppBackControl(app: XCUIApplication) -> Bool {
|
|
28
23
|
#if os(macOS)
|
|
29
24
|
if let back = macOSNavigationBackElement(app: app) {
|
|
30
25
|
tapElementCenter(app: app, element: back)
|
|
@@ -37,7 +32,7 @@ extension RunnerTests {
|
|
|
37
32
|
back.tap()
|
|
38
33
|
return true
|
|
39
34
|
}
|
|
40
|
-
return
|
|
35
|
+
return false
|
|
41
36
|
#endif
|
|
42
37
|
}
|
|
43
38
|
|
|
@@ -51,6 +46,18 @@ extension RunnerTests {
|
|
|
51
46
|
start.press(forDuration: 0.05, thenDragTo: end)
|
|
52
47
|
}
|
|
53
48
|
|
|
49
|
+
func performSystemBackAction(app: XCUIApplication) -> Bool {
|
|
50
|
+
#if os(macOS)
|
|
51
|
+
return false
|
|
52
|
+
#else
|
|
53
|
+
if pressTvRemoteMenuIfAvailable() {
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
performBackGesture(app: app)
|
|
57
|
+
return true
|
|
58
|
+
#endif
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
func performAppSwitcherGesture(app: XCUIApplication) {
|
|
55
62
|
if performTvRemoteAppSwitcherIfAvailable() {
|
|
56
63
|
return
|
|
@@ -161,19 +168,114 @@ extension RunnerTests {
|
|
|
161
168
|
element.typeText(deletes)
|
|
162
169
|
}
|
|
163
170
|
|
|
164
|
-
func
|
|
165
|
-
let
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.
|
|
169
|
-
|
|
171
|
+
func textInputAt(app: XCUIApplication, x: Double, y: Double) -> XCUIElement? {
|
|
172
|
+
let point = CGPoint(x: x, y: y)
|
|
173
|
+
var matched: XCUIElement?
|
|
174
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
175
|
+
// Prefer the smallest matching field so nested editable controls win over large containers.
|
|
176
|
+
let candidates = app.descendants(matching: .any).allElementsBoundByIndex
|
|
177
|
+
.filter { element in
|
|
178
|
+
guard element.exists else { return false }
|
|
179
|
+
switch element.elementType {
|
|
180
|
+
case .textField, .secureTextField, .searchField, .textView:
|
|
181
|
+
let frame = element.frame
|
|
182
|
+
return !frame.isEmpty && frame.contains(point)
|
|
183
|
+
default:
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
.sorted { left, right in
|
|
188
|
+
let leftArea = max(1, left.frame.width * left.frame.height)
|
|
189
|
+
let rightArea = max(1, right.frame.width * right.frame.height)
|
|
190
|
+
if leftArea != rightArea {
|
|
191
|
+
return leftArea < rightArea
|
|
192
|
+
}
|
|
193
|
+
if left.frame.minY != right.frame.minY {
|
|
194
|
+
return left.frame.minY < right.frame.minY
|
|
195
|
+
}
|
|
196
|
+
if left.frame.minX != right.frame.minX {
|
|
197
|
+
return left.frame.minX < right.frame.minX
|
|
198
|
+
}
|
|
199
|
+
return left.elementType.rawValue < right.elementType.rawValue
|
|
200
|
+
}
|
|
201
|
+
matched = candidates.first
|
|
202
|
+
})
|
|
203
|
+
if let exceptionMessage {
|
|
204
|
+
NSLog(
|
|
205
|
+
"AGENT_DEVICE_RUNNER_TEXT_INPUT_AT_POINT_IGNORED_EXCEPTION=%@",
|
|
206
|
+
exceptionMessage
|
|
207
|
+
)
|
|
208
|
+
return nil
|
|
209
|
+
}
|
|
210
|
+
return matched
|
|
211
|
+
}
|
|
170
212
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
213
|
+
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
|
|
214
|
+
var focused: XCUIElement?
|
|
215
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
216
|
+
let candidate = app
|
|
217
|
+
.descendants(matching: .any)
|
|
218
|
+
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
|
|
219
|
+
.firstMatch
|
|
220
|
+
guard candidate.exists else { return }
|
|
221
|
+
|
|
222
|
+
switch candidate.elementType {
|
|
223
|
+
case .textField, .secureTextField, .searchField, .textView:
|
|
224
|
+
focused = candidate
|
|
225
|
+
default:
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
if let exceptionMessage {
|
|
230
|
+
NSLog(
|
|
231
|
+
"AGENT_DEVICE_RUNNER_FOCUSED_INPUT_QUERY_IGNORED_EXCEPTION=%@",
|
|
232
|
+
exceptionMessage
|
|
233
|
+
)
|
|
175
234
|
return nil
|
|
176
235
|
}
|
|
236
|
+
return focused
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
|
240
|
+
let keyboard = app.keyboards.firstMatch
|
|
241
|
+
return keyboard.exists && !keyboard.frame.isEmpty
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
245
|
+
let wasVisible = isKeyboardVisible(app: app)
|
|
246
|
+
guard wasVisible else {
|
|
247
|
+
return (wasVisible: false, dismissed: false, visible: false)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let keyboard = app.keyboards.firstMatch
|
|
251
|
+
keyboard.swipeDown()
|
|
252
|
+
sleepFor(0.2)
|
|
253
|
+
if !isKeyboardVisible(app: app) {
|
|
254
|
+
return (wasVisible: true, dismissed: true, visible: false)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if tapKeyboardDismissControl(app: app) {
|
|
258
|
+
sleepFor(0.2)
|
|
259
|
+
let visible = isKeyboardVisible(app: app)
|
|
260
|
+
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
|
|
267
|
+
for label in ["Hide keyboard", "Dismiss keyboard"] {
|
|
268
|
+
let candidates = [
|
|
269
|
+
app.keyboards.buttons[label],
|
|
270
|
+
app.keyboards.keys[label],
|
|
271
|
+
app.toolbars.buttons[label],
|
|
272
|
+
]
|
|
273
|
+
if let hittable = candidates.first(where: { $0.exists && $0.isHittable }) {
|
|
274
|
+
hittable.tap()
|
|
275
|
+
return true
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return false
|
|
177
279
|
}
|
|
178
280
|
|
|
179
281
|
private func moveCaretToEnd(element: XCUIElement) {
|
|
@@ -322,7 +424,7 @@ extension RunnerTests {
|
|
|
322
424
|
)
|
|
323
425
|
}
|
|
324
426
|
|
|
325
|
-
|
|
427
|
+
func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
|
|
326
428
|
let window = app.windows.firstMatch
|
|
327
429
|
let windowFrame = window.frame
|
|
328
430
|
if window.exists && !windowFrame.isEmpty {
|
|
@@ -334,14 +436,6 @@ extension RunnerTests {
|
|
|
334
436
|
return CGRect(x: 0, y: 0, width: 0, height: 0)
|
|
335
437
|
}
|
|
336
438
|
|
|
337
|
-
func resolvedGestureReferenceFrame(app: XCUIApplication) -> GestureReferenceFrame {
|
|
338
|
-
let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
|
339
|
-
return GestureReferenceFrame(
|
|
340
|
-
referenceWidth: frame.width,
|
|
341
|
-
referenceHeight: frame.height
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
|
|
345
439
|
func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
|
|
346
440
|
let total = max(count, 1)
|
|
347
441
|
let pause = max(pauseMs, 0)
|
|
@@ -353,39 +447,36 @@ extension RunnerTests {
|
|
|
353
447
|
}
|
|
354
448
|
}
|
|
355
449
|
|
|
356
|
-
func swipe(app: XCUIApplication, direction:
|
|
450
|
+
func swipe(app: XCUIApplication, direction: String) -> DragVisualizationFrame? {
|
|
357
451
|
if performTvRemoteSwipeIfAvailable(direction: direction) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
case .down:
|
|
370
|
-
start.press(forDuration: 0.1, thenDragTo: end)
|
|
371
|
-
case .left:
|
|
372
|
-
right.press(forDuration: 0.1, thenDragTo: left)
|
|
373
|
-
case .right:
|
|
374
|
-
left.press(forDuration: 0.1, thenDragTo: right)
|
|
452
|
+
let frame = resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
|
|
453
|
+
let midX = frame.midX
|
|
454
|
+
let midY = frame.midY
|
|
455
|
+
return DragVisualizationFrame(
|
|
456
|
+
x: midX,
|
|
457
|
+
y: midY,
|
|
458
|
+
x2: midX,
|
|
459
|
+
y2: midY,
|
|
460
|
+
referenceWidth: frame.width,
|
|
461
|
+
referenceHeight: frame.height
|
|
462
|
+
)
|
|
375
463
|
}
|
|
464
|
+
return nil
|
|
376
465
|
}
|
|
377
466
|
|
|
378
|
-
private func performTvRemoteSwipeIfAvailable(direction:
|
|
467
|
+
private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
|
|
379
468
|
#if os(tvOS)
|
|
380
469
|
switch direction {
|
|
381
|
-
case
|
|
470
|
+
case "up":
|
|
382
471
|
XCUIRemote.shared.press(.up)
|
|
383
|
-
case
|
|
472
|
+
case "down":
|
|
384
473
|
XCUIRemote.shared.press(.down)
|
|
385
|
-
case
|
|
474
|
+
case "left":
|
|
386
475
|
XCUIRemote.shared.press(.left)
|
|
387
|
-
case
|
|
476
|
+
case "right":
|
|
388
477
|
XCUIRemote.shared.press(.right)
|
|
478
|
+
default:
|
|
479
|
+
return false
|
|
389
480
|
}
|
|
390
481
|
return true
|
|
391
482
|
#else
|
|
@@ -461,5 +552,4 @@ extension RunnerTests {
|
|
|
461
552
|
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
462
553
|
return element.exists ? element : nil
|
|
463
554
|
}
|
|
464
|
-
|
|
465
555
|
}
|
|
@@ -152,7 +152,7 @@ extension RunnerTests {
|
|
|
152
152
|
|
|
153
153
|
func isReadOnlyCommand(_ command: Command) -> Bool {
|
|
154
154
|
switch command.command {
|
|
155
|
-
case .findText, .readText, .snapshot, .screenshot:
|
|
155
|
+
case .interactionFrame, .findText, .readText, .snapshot, .screenshot:
|
|
156
156
|
return true
|
|
157
157
|
case .alert:
|
|
158
158
|
let action = (command.action ?? "get").lowercased()
|
|
@@ -170,7 +170,18 @@ extension RunnerTests {
|
|
|
170
170
|
|
|
171
171
|
func isInteractionCommand(_ command: CommandType) -> Bool {
|
|
172
172
|
switch command {
|
|
173
|
-
case
|
|
173
|
+
case
|
|
174
|
+
.tap,
|
|
175
|
+
.longPress,
|
|
176
|
+
.drag,
|
|
177
|
+
.type,
|
|
178
|
+
.swipe,
|
|
179
|
+
.back,
|
|
180
|
+
.backInApp,
|
|
181
|
+
.backSystem,
|
|
182
|
+
.appSwitcher,
|
|
183
|
+
.keyboardDismiss,
|
|
184
|
+
.pinch:
|
|
174
185
|
return true
|
|
175
186
|
default:
|
|
176
187
|
return false
|
|
@@ -5,6 +5,7 @@ enum CommandType: String, Codable {
|
|
|
5
5
|
case mouseClick
|
|
6
6
|
case tapSeries
|
|
7
7
|
case longPress
|
|
8
|
+
case interactionFrame
|
|
8
9
|
case drag
|
|
9
10
|
case dragSeries
|
|
10
11
|
case type
|
|
@@ -14,8 +15,11 @@ enum CommandType: String, Codable {
|
|
|
14
15
|
case snapshot
|
|
15
16
|
case screenshot
|
|
16
17
|
case back
|
|
18
|
+
case backInApp
|
|
19
|
+
case backSystem
|
|
17
20
|
case home
|
|
18
21
|
case appSwitcher
|
|
22
|
+
case keyboardDismiss
|
|
19
23
|
case alert
|
|
20
24
|
case pinch
|
|
21
25
|
case recordStart
|
|
@@ -24,17 +28,11 @@ enum CommandType: String, Codable {
|
|
|
24
28
|
case shutdown
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
enum SwipeDirection: String, Codable {
|
|
28
|
-
case up
|
|
29
|
-
case down
|
|
30
|
-
case left
|
|
31
|
-
case right
|
|
32
|
-
}
|
|
33
|
-
|
|
34
31
|
struct Command: Codable {
|
|
35
32
|
let command: CommandType
|
|
36
33
|
let appBundleId: String?
|
|
37
34
|
let text: String?
|
|
35
|
+
let delayMs: Int?
|
|
38
36
|
let clearFirst: Bool?
|
|
39
37
|
let action: String?
|
|
40
38
|
let x: Double?
|
|
@@ -48,7 +46,7 @@ struct Command: Codable {
|
|
|
48
46
|
let x2: Double?
|
|
49
47
|
let y2: Double?
|
|
50
48
|
let durationMs: Double?
|
|
51
|
-
let direction:
|
|
49
|
+
let direction: String?
|
|
52
50
|
let scale: Double?
|
|
53
51
|
let outPath: String?
|
|
54
52
|
let fps: Int?
|
|
@@ -87,6 +85,9 @@ struct DataPayload: Codable {
|
|
|
87
85
|
let referenceWidth: Double?
|
|
88
86
|
let referenceHeight: Double?
|
|
89
87
|
let currentUptimeMs: Double?
|
|
88
|
+
let visible: Bool?
|
|
89
|
+
let wasVisible: Bool?
|
|
90
|
+
let dismissed: Bool?
|
|
90
91
|
|
|
91
92
|
init(
|
|
92
93
|
message: String? = nil,
|
|
@@ -103,7 +104,10 @@ struct DataPayload: Codable {
|
|
|
103
104
|
y2: Double? = nil,
|
|
104
105
|
referenceWidth: Double? = nil,
|
|
105
106
|
referenceHeight: Double? = nil,
|
|
106
|
-
currentUptimeMs: Double? = nil
|
|
107
|
+
currentUptimeMs: Double? = nil,
|
|
108
|
+
visible: Bool? = nil,
|
|
109
|
+
wasVisible: Bool? = nil,
|
|
110
|
+
dismissed: Bool? = nil
|
|
107
111
|
) {
|
|
108
112
|
self.message = message
|
|
109
113
|
self.text = text
|
|
@@ -120,11 +124,20 @@ struct DataPayload: Codable {
|
|
|
120
124
|
self.referenceWidth = referenceWidth
|
|
121
125
|
self.referenceHeight = referenceHeight
|
|
122
126
|
self.currentUptimeMs = currentUptimeMs
|
|
127
|
+
self.visible = visible
|
|
128
|
+
self.wasVisible = wasVisible
|
|
129
|
+
self.dismissed = dismissed
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
struct ErrorPayload: Codable {
|
|
134
|
+
let code: String?
|
|
127
135
|
let message: String
|
|
136
|
+
|
|
137
|
+
init(code: String? = nil, message: String) {
|
|
138
|
+
self.code = code
|
|
139
|
+
self.message = message
|
|
140
|
+
}
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
struct SnapshotRect: Codable {
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import XCTest
|
|
2
2
|
|
|
3
3
|
extension RunnerTests {
|
|
4
|
+
private static let collapsedTabCandidateTypes: Set<XCUIElement.ElementType> = [
|
|
5
|
+
.button,
|
|
6
|
+
.link,
|
|
7
|
+
.menuItem,
|
|
8
|
+
.other,
|
|
9
|
+
.staticText
|
|
10
|
+
]
|
|
11
|
+
|
|
4
12
|
private struct SnapshotTraversalContext {
|
|
13
|
+
let queryRoot: XCUIElement
|
|
5
14
|
let rootSnapshot: XCUIElementSnapshot
|
|
6
15
|
let viewport: CGRect
|
|
7
16
|
let flatSnapshots: [XCUIElementSnapshot]
|
|
@@ -68,12 +77,34 @@ extension RunnerTests {
|
|
|
68
77
|
return DataPayload(nodes: [], truncated: false)
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
var cachedDescendantElements: [XCUIElement]?
|
|
81
|
+
func collapsedTabDescendants() -> [XCUIElement] {
|
|
82
|
+
if let cachedDescendantElements {
|
|
83
|
+
return cachedDescendantElements
|
|
84
|
+
}
|
|
85
|
+
let fetched = safeSnapshotElementsQuery {
|
|
86
|
+
context.queryRoot.descendants(matching: .any).allElementsBoundByIndex
|
|
87
|
+
}
|
|
88
|
+
cachedDescendantElements = fetched
|
|
89
|
+
return fetched
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
var nodes: [SnapshotNode] = []
|
|
72
93
|
var truncated = false
|
|
73
94
|
let rootEvaluation = evaluateSnapshot(context.rootSnapshot, in: context)
|
|
74
95
|
nodes.append(
|
|
75
96
|
makeSnapshotNode(snapshot: context.rootSnapshot, evaluation: rootEvaluation, depth: 0, index: 0)
|
|
76
97
|
)
|
|
98
|
+
if context.maxDepth > 0 {
|
|
99
|
+
let didTruncateFallback = appendCollapsedTabFallbackNodes(
|
|
100
|
+
to: &nodes,
|
|
101
|
+
containerSnapshot: context.rootSnapshot,
|
|
102
|
+
resolveElements: collapsedTabDescendants,
|
|
103
|
+
depth: 1,
|
|
104
|
+
nodeLimit: fastSnapshotLimit
|
|
105
|
+
)
|
|
106
|
+
truncated = truncated || didTruncateFallback
|
|
107
|
+
}
|
|
77
108
|
|
|
78
109
|
var seen = Set<String>()
|
|
79
110
|
var stack: [(XCUIElementSnapshot, Int, Int)] = context.rootSnapshot.children.map { ($0, 1, 1) }
|
|
@@ -119,6 +150,16 @@ extension RunnerTests {
|
|
|
119
150
|
index: nodes.count
|
|
120
151
|
)
|
|
121
152
|
)
|
|
153
|
+
if visibleDepth < context.maxDepth {
|
|
154
|
+
let didTruncateFallback = appendCollapsedTabFallbackNodes(
|
|
155
|
+
to: &nodes,
|
|
156
|
+
containerSnapshot: snapshot,
|
|
157
|
+
resolveElements: collapsedTabDescendants,
|
|
158
|
+
depth: visibleDepth + 1,
|
|
159
|
+
nodeLimit: fastSnapshotLimit
|
|
160
|
+
)
|
|
161
|
+
truncated = truncated || didTruncateFallback
|
|
162
|
+
}
|
|
122
163
|
|
|
123
164
|
}
|
|
124
165
|
|
|
@@ -247,6 +288,7 @@ extension RunnerTests {
|
|
|
247
288
|
|
|
248
289
|
let (flatSnapshots, snapshotRanges) = flattenedSnapshots(rootSnapshot)
|
|
249
290
|
return SnapshotTraversalContext(
|
|
291
|
+
queryRoot: queryRoot,
|
|
250
292
|
rootSnapshot: rootSnapshot,
|
|
251
293
|
viewport: viewport,
|
|
252
294
|
flatSnapshots: flatSnapshots,
|
|
@@ -376,4 +418,183 @@ extension RunnerTests {
|
|
|
376
418
|
if rect.isNull || rect.isEmpty { return false }
|
|
377
419
|
return rect.intersects(viewport)
|
|
378
420
|
}
|
|
421
|
+
|
|
422
|
+
private func appendCollapsedTabFallbackNodes(
|
|
423
|
+
to nodes: inout [SnapshotNode],
|
|
424
|
+
containerSnapshot: XCUIElementSnapshot,
|
|
425
|
+
resolveElements: () -> [XCUIElement],
|
|
426
|
+
depth: Int,
|
|
427
|
+
nodeLimit: Int
|
|
428
|
+
) -> Bool {
|
|
429
|
+
let fallbackNodes = collapsedTabFallbackNodes(
|
|
430
|
+
for: containerSnapshot,
|
|
431
|
+
resolveElements: resolveElements,
|
|
432
|
+
startingIndex: nodes.count,
|
|
433
|
+
depth: depth
|
|
434
|
+
)
|
|
435
|
+
if fallbackNodes.isEmpty { return false }
|
|
436
|
+
let remaining = max(0, nodeLimit - nodes.count)
|
|
437
|
+
if remaining == 0 { return true }
|
|
438
|
+
nodes.append(contentsOf: fallbackNodes.prefix(remaining))
|
|
439
|
+
return fallbackNodes.count > remaining
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private func collapsedTabFallbackNodes(
|
|
443
|
+
for containerSnapshot: XCUIElementSnapshot,
|
|
444
|
+
resolveElements: () -> [XCUIElement],
|
|
445
|
+
startingIndex: Int,
|
|
446
|
+
depth: Int
|
|
447
|
+
) -> [SnapshotNode] {
|
|
448
|
+
if !containerSnapshot.children.isEmpty { return [] }
|
|
449
|
+
guard shouldExpandCollapsedTabContainer(containerSnapshot) else { return [] }
|
|
450
|
+
let containerFrame = containerSnapshot.frame
|
|
451
|
+
if containerFrame.isNull || containerFrame.isEmpty { return [] }
|
|
452
|
+
|
|
453
|
+
// Collapsed tab containers should be rare, so a full descendant scan is acceptable once per
|
|
454
|
+
// snapshot as a fallback for XCTest omitting the tab children from the snapshot tree.
|
|
455
|
+
let elements = resolveElements()
|
|
456
|
+
let candidates = elements.compactMap { element in
|
|
457
|
+
collapsedTabCandidateNode(
|
|
458
|
+
element: element,
|
|
459
|
+
containerSnapshot: containerSnapshot,
|
|
460
|
+
containerFrame: containerFrame
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
.sorted { left, right in
|
|
464
|
+
if left.rect.x != right.rect.x {
|
|
465
|
+
return left.rect.x < right.rect.x
|
|
466
|
+
}
|
|
467
|
+
return left.rect.y < right.rect.y
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if candidates.count < 2 { return [] }
|
|
471
|
+
let rowMidpoints = candidates.map { $0.rect.y + ($0.rect.height / 2) }
|
|
472
|
+
let rowSpread = (rowMidpoints.max() ?? 0) - (rowMidpoints.min() ?? 0)
|
|
473
|
+
// Allow modest vertical jitter and short two-row wraps while still rejecting unrelated controls.
|
|
474
|
+
if rowSpread > max(24.0, Double(containerFrame.height) * 0.6) { return [] }
|
|
475
|
+
|
|
476
|
+
var seen = Set<String>()
|
|
477
|
+
let uniqueCandidates = candidates.filter { node in
|
|
478
|
+
let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
|
|
479
|
+
if seen.contains(key) { return false }
|
|
480
|
+
seen.insert(key)
|
|
481
|
+
return true
|
|
482
|
+
}
|
|
483
|
+
if uniqueCandidates.count < 2 { return [] }
|
|
484
|
+
|
|
485
|
+
return uniqueCandidates.enumerated().map { offset, node in
|
|
486
|
+
SnapshotNode(
|
|
487
|
+
index: startingIndex + offset,
|
|
488
|
+
type: node.type,
|
|
489
|
+
label: node.label,
|
|
490
|
+
identifier: node.identifier,
|
|
491
|
+
value: node.value,
|
|
492
|
+
rect: node.rect,
|
|
493
|
+
enabled: node.enabled,
|
|
494
|
+
hittable: node.hittable,
|
|
495
|
+
depth: depth
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private func collapsedTabCandidateNode(
|
|
501
|
+
element: XCUIElement,
|
|
502
|
+
containerSnapshot: XCUIElementSnapshot,
|
|
503
|
+
containerFrame: CGRect
|
|
504
|
+
) -> SnapshotNode? {
|
|
505
|
+
var node: SnapshotNode?
|
|
506
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
507
|
+
if !element.exists { return }
|
|
508
|
+
let elementType = element.elementType
|
|
509
|
+
if !Self.collapsedTabCandidateTypes.contains(elementType) { return }
|
|
510
|
+
let frame = element.frame
|
|
511
|
+
if frame.isNull || frame.isEmpty { return }
|
|
512
|
+
if frame.equalTo(containerFrame) { return }
|
|
513
|
+
let area = max(CGFloat(1), frame.width * frame.height)
|
|
514
|
+
let containerArea = max(CGFloat(1), containerFrame.width * containerFrame.height)
|
|
515
|
+
if area >= containerArea * 0.9 { return }
|
|
516
|
+
let center = CGPoint(x: frame.midX, y: frame.midY)
|
|
517
|
+
if !containerFrame.contains(center) { return }
|
|
518
|
+
|
|
519
|
+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
520
|
+
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
521
|
+
let valueText = snapshotValueText(element)
|
|
522
|
+
let hasContent = !label.isEmpty || !identifier.isEmpty || valueText != nil
|
|
523
|
+
if !hasContent { return }
|
|
524
|
+
if sameSemanticElement(
|
|
525
|
+
containerSnapshot: containerSnapshot,
|
|
526
|
+
elementType: elementType,
|
|
527
|
+
label: label,
|
|
528
|
+
identifier: identifier
|
|
529
|
+
) {
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
node = SnapshotNode(
|
|
534
|
+
index: 0,
|
|
535
|
+
type: elementTypeName(elementType),
|
|
536
|
+
label: label.isEmpty ? nil : label,
|
|
537
|
+
identifier: identifier.isEmpty ? nil : identifier,
|
|
538
|
+
value: valueText,
|
|
539
|
+
rect: snapshotRect(from: frame),
|
|
540
|
+
enabled: element.isEnabled,
|
|
541
|
+
hittable: element.isHittable,
|
|
542
|
+
depth: 0
|
|
543
|
+
)
|
|
544
|
+
})
|
|
545
|
+
if let exceptionMessage {
|
|
546
|
+
NSLog(
|
|
547
|
+
"AGENT_DEVICE_RUNNER_SNAPSHOT_TAB_FALLBACK_IGNORED_EXCEPTION=%@",
|
|
548
|
+
exceptionMessage
|
|
549
|
+
)
|
|
550
|
+
return nil
|
|
551
|
+
}
|
|
552
|
+
return node
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private func shouldExpandCollapsedTabContainer(_ snapshot: XCUIElementSnapshot) -> Bool {
|
|
556
|
+
let frame = snapshot.frame
|
|
557
|
+
if frame.isNull || frame.isEmpty { return false }
|
|
558
|
+
if frame.width < max(CGFloat(160), frame.height * 1.75) { return false }
|
|
559
|
+
switch snapshot.elementType {
|
|
560
|
+
case .tabBar, .segmentedControl, .slider:
|
|
561
|
+
return true
|
|
562
|
+
default:
|
|
563
|
+
return false
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private func snapshotValueText(_ element: XCUIElement) -> String? {
|
|
568
|
+
let text = String(describing: element.value ?? "")
|
|
569
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
570
|
+
return text.isEmpty ? nil : text
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private func sameSemanticElement(
|
|
574
|
+
containerSnapshot: XCUIElementSnapshot,
|
|
575
|
+
elementType: XCUIElement.ElementType,
|
|
576
|
+
label: String,
|
|
577
|
+
identifier: String
|
|
578
|
+
) -> Bool {
|
|
579
|
+
if containerSnapshot.elementType != elementType { return false }
|
|
580
|
+
let containerLabel = containerSnapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
581
|
+
let containerIdentifier = containerSnapshot.identifier
|
|
582
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
583
|
+
return containerLabel == label && containerIdentifier == identifier
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private func safeSnapshotElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
|
|
587
|
+
var elements: [XCUIElement] = []
|
|
588
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
589
|
+
elements = fetch()
|
|
590
|
+
})
|
|
591
|
+
if let exceptionMessage {
|
|
592
|
+
NSLog(
|
|
593
|
+
"AGENT_DEVICE_RUNNER_SNAPSHOT_QUERY_IGNORED_EXCEPTION=%@",
|
|
594
|
+
exceptionMessage
|
|
595
|
+
)
|
|
596
|
+
return []
|
|
597
|
+
}
|
|
598
|
+
return elements
|
|
599
|
+
}
|
|
379
600
|
}
|
|
@@ -21,27 +21,39 @@ extension RunnerTests {
|
|
|
21
21
|
status: 413,
|
|
22
22
|
response: Response(ok: false, error: ErrorPayload(message: "request too large"))
|
|
23
23
|
)
|
|
24
|
-
|
|
25
|
-
connection.cancel()
|
|
24
|
+
self.sendResponse(response, over: connection) { [weak self] in
|
|
26
25
|
self?.finish()
|
|
27
|
-
}
|
|
26
|
+
}
|
|
28
27
|
return
|
|
29
28
|
}
|
|
30
29
|
let combined = buffer + data
|
|
31
30
|
if let body = self.parseRequest(data: combined) {
|
|
32
31
|
let result = self.handleRequestBody(body)
|
|
33
|
-
|
|
34
|
-
connection.cancel()
|
|
32
|
+
self.sendResponse(result.data, over: connection) { [weak self] in
|
|
35
33
|
if result.shouldFinish {
|
|
36
|
-
self
|
|
34
|
+
self?.finish()
|
|
37
35
|
}
|
|
38
|
-
}
|
|
36
|
+
}
|
|
39
37
|
} else {
|
|
40
38
|
self.receiveRequest(connection: connection, buffer: combined)
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
41
|
}
|
|
44
42
|
|
|
43
|
+
private func sendResponse(
|
|
44
|
+
_ response: Data,
|
|
45
|
+
over connection: NWConnection,
|
|
46
|
+
afterSend: @escaping () -> Void = {}
|
|
47
|
+
) {
|
|
48
|
+
connection.send(content: response, isComplete: true, completion: .contentProcessed { error in
|
|
49
|
+
if let error {
|
|
50
|
+
NSLog("AGENT_DEVICE_RUNNER_SEND_FAILED=%@", String(describing: error))
|
|
51
|
+
}
|
|
52
|
+
connection.cancel()
|
|
53
|
+
afterSend()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
private func parseRequest(data: Data) -> Data? {
|
|
46
58
|
guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
|
|
47
59
|
return nil
|