agent-device 0.7.4 → 0.7.5
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/dist/src/daemon.js +19 -19
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +381 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +30 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +258 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +174 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +121 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +263 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +359 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +220 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +124 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +30 -1855
- package/ios-runner/README.md +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension RunnerTests {
|
|
4
|
+
// MARK: - Snapshot Entry
|
|
5
|
+
|
|
6
|
+
func elementTypeName(_ type: XCUIElement.ElementType) -> String {
|
|
7
|
+
switch type {
|
|
8
|
+
case .application: return "Application"
|
|
9
|
+
case .window: return "Window"
|
|
10
|
+
case .button: return "Button"
|
|
11
|
+
case .cell: return "Cell"
|
|
12
|
+
case .staticText: return "StaticText"
|
|
13
|
+
case .textField: return "TextField"
|
|
14
|
+
case .textView: return "TextView"
|
|
15
|
+
case .secureTextField: return "SecureTextField"
|
|
16
|
+
case .switch: return "Switch"
|
|
17
|
+
case .slider: return "Slider"
|
|
18
|
+
case .link: return "Link"
|
|
19
|
+
case .image: return "Image"
|
|
20
|
+
case .navigationBar: return "NavigationBar"
|
|
21
|
+
case .tabBar: return "TabBar"
|
|
22
|
+
case .collectionView: return "CollectionView"
|
|
23
|
+
case .table: return "Table"
|
|
24
|
+
case .scrollView: return "ScrollView"
|
|
25
|
+
case .searchField: return "SearchField"
|
|
26
|
+
case .segmentedControl: return "SegmentedControl"
|
|
27
|
+
case .stepper: return "Stepper"
|
|
28
|
+
case .picker: return "Picker"
|
|
29
|
+
case .checkBox: return "CheckBox"
|
|
30
|
+
case .menuItem: return "MenuItem"
|
|
31
|
+
case .other: return "Other"
|
|
32
|
+
default:
|
|
33
|
+
switch type.rawValue {
|
|
34
|
+
case 19:
|
|
35
|
+
return "Keyboard"
|
|
36
|
+
case 20:
|
|
37
|
+
return "Key"
|
|
38
|
+
case 24:
|
|
39
|
+
return "SearchField"
|
|
40
|
+
default:
|
|
41
|
+
return "Element(\(type.rawValue))"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
|
|
47
|
+
if let blocking = blockingSystemAlertSnapshot() {
|
|
48
|
+
return blocking
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var nodes: [SnapshotNode] = []
|
|
52
|
+
var truncated = false
|
|
53
|
+
let maxDepth = options.depth ?? Int.max
|
|
54
|
+
let viewport = app.frame
|
|
55
|
+
let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
|
|
56
|
+
|
|
57
|
+
let rootSnapshot: XCUIElementSnapshot
|
|
58
|
+
do {
|
|
59
|
+
rootSnapshot = try queryRoot.snapshot()
|
|
60
|
+
} catch {
|
|
61
|
+
return DataPayload(nodes: nodes, truncated: truncated)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let (flatSnapshots, snapshotRanges) = flattenedSnapshots(rootSnapshot)
|
|
65
|
+
let rootLaterNodes = laterSnapshots(
|
|
66
|
+
for: rootSnapshot,
|
|
67
|
+
in: flatSnapshots,
|
|
68
|
+
ranges: snapshotRanges
|
|
69
|
+
)
|
|
70
|
+
let rootLabel = aggregatedLabel(for: rootSnapshot) ?? rootSnapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
71
|
+
let rootIdentifier = rootSnapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
72
|
+
let rootValue = snapshotValueText(rootSnapshot)
|
|
73
|
+
let rootHittable = computedSnapshotHittable(rootSnapshot, viewport: viewport, laterNodes: rootLaterNodes)
|
|
74
|
+
nodes.append(
|
|
75
|
+
SnapshotNode(
|
|
76
|
+
index: 0,
|
|
77
|
+
type: elementTypeName(rootSnapshot.elementType),
|
|
78
|
+
label: rootLabel.isEmpty ? nil : rootLabel,
|
|
79
|
+
identifier: rootIdentifier.isEmpty ? nil : rootIdentifier,
|
|
80
|
+
value: rootValue,
|
|
81
|
+
rect: SnapshotRect(
|
|
82
|
+
x: Double(rootSnapshot.frame.origin.x),
|
|
83
|
+
y: Double(rootSnapshot.frame.origin.y),
|
|
84
|
+
width: Double(rootSnapshot.frame.size.width),
|
|
85
|
+
height: Double(rootSnapshot.frame.size.height),
|
|
86
|
+
),
|
|
87
|
+
enabled: rootSnapshot.isEnabled,
|
|
88
|
+
hittable: rootHittable,
|
|
89
|
+
depth: 0,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
var seen = Set<String>()
|
|
94
|
+
var stack: [(XCUIElementSnapshot, Int, Int)] = rootSnapshot.children.map { ($0, 1, 1) }
|
|
95
|
+
|
|
96
|
+
while let (snapshot, depth, visibleDepth) = stack.popLast() {
|
|
97
|
+
if nodes.count >= fastSnapshotLimit {
|
|
98
|
+
truncated = true
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
if let limit = options.depth, depth > limit { continue }
|
|
102
|
+
|
|
103
|
+
let label = aggregatedLabel(for: snapshot) ?? snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
104
|
+
let identifier = snapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
105
|
+
let valueText = snapshotValueText(snapshot)
|
|
106
|
+
let laterNodes = laterSnapshots(
|
|
107
|
+
for: snapshot,
|
|
108
|
+
in: flatSnapshots,
|
|
109
|
+
ranges: snapshotRanges
|
|
110
|
+
)
|
|
111
|
+
let hittable = computedSnapshotHittable(snapshot, viewport: viewport, laterNodes: laterNodes)
|
|
112
|
+
let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil)
|
|
113
|
+
if !isVisibleInViewport(snapshot.frame, viewport) && !hasContent {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let include = shouldInclude(
|
|
118
|
+
snapshot: snapshot,
|
|
119
|
+
label: label,
|
|
120
|
+
identifier: identifier,
|
|
121
|
+
valueText: valueText,
|
|
122
|
+
options: options,
|
|
123
|
+
hittable: hittable
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
let key = "\(snapshot.elementType)-\(label)-\(identifier)-\(snapshot.frame.origin.x)-\(snapshot.frame.origin.y)"
|
|
127
|
+
let isDuplicate = seen.contains(key)
|
|
128
|
+
if !isDuplicate {
|
|
129
|
+
seen.insert(key)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if depth < maxDepth {
|
|
133
|
+
let nextVisibleDepth = include && !isDuplicate ? visibleDepth + 1 : visibleDepth
|
|
134
|
+
for child in snapshot.children.reversed() {
|
|
135
|
+
stack.append((child, depth + 1, nextVisibleDepth))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if !include || isDuplicate { continue }
|
|
140
|
+
|
|
141
|
+
nodes.append(
|
|
142
|
+
SnapshotNode(
|
|
143
|
+
index: nodes.count,
|
|
144
|
+
type: elementTypeName(snapshot.elementType),
|
|
145
|
+
label: label.isEmpty ? nil : label,
|
|
146
|
+
identifier: identifier.isEmpty ? nil : identifier,
|
|
147
|
+
value: valueText,
|
|
148
|
+
rect: SnapshotRect(
|
|
149
|
+
x: Double(snapshot.frame.origin.x),
|
|
150
|
+
y: Double(snapshot.frame.origin.y),
|
|
151
|
+
width: Double(snapshot.frame.size.width),
|
|
152
|
+
height: Double(snapshot.frame.size.height),
|
|
153
|
+
),
|
|
154
|
+
enabled: snapshot.isEnabled,
|
|
155
|
+
hittable: hittable,
|
|
156
|
+
depth: min(maxDepth, visibleDepth),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return DataPayload(nodes: nodes, truncated: truncated)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
|
|
166
|
+
if let blocking = blockingSystemAlertSnapshot() {
|
|
167
|
+
return blocking
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let queryRoot = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
|
|
171
|
+
var nodes: [SnapshotNode] = []
|
|
172
|
+
var truncated = false
|
|
173
|
+
let viewport = app.frame
|
|
174
|
+
|
|
175
|
+
let rootSnapshot: XCUIElementSnapshot
|
|
176
|
+
do {
|
|
177
|
+
rootSnapshot = try queryRoot.snapshot()
|
|
178
|
+
} catch {
|
|
179
|
+
return DataPayload(nodes: nodes, truncated: truncated)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let (flatSnapshots, snapshotRanges) = flattenedSnapshots(rootSnapshot)
|
|
183
|
+
|
|
184
|
+
func walk(_ snapshot: XCUIElementSnapshot, depth: Int) {
|
|
185
|
+
if nodes.count >= maxSnapshotElements {
|
|
186
|
+
truncated = true
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if let limit = options.depth, depth > limit { return }
|
|
190
|
+
if !isVisibleInViewport(snapshot.frame, viewport) { return }
|
|
191
|
+
|
|
192
|
+
let label = aggregatedLabel(for: snapshot) ?? snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
193
|
+
let identifier = snapshot.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
194
|
+
let valueText = snapshotValueText(snapshot)
|
|
195
|
+
let laterNodes = laterSnapshots(
|
|
196
|
+
for: snapshot,
|
|
197
|
+
in: flatSnapshots,
|
|
198
|
+
ranges: snapshotRanges
|
|
199
|
+
)
|
|
200
|
+
let hittable = computedSnapshotHittable(snapshot, viewport: viewport, laterNodes: laterNodes)
|
|
201
|
+
if shouldInclude(
|
|
202
|
+
snapshot: snapshot,
|
|
203
|
+
label: label,
|
|
204
|
+
identifier: identifier,
|
|
205
|
+
valueText: valueText,
|
|
206
|
+
options: options,
|
|
207
|
+
hittable: hittable
|
|
208
|
+
) {
|
|
209
|
+
nodes.append(
|
|
210
|
+
SnapshotNode(
|
|
211
|
+
index: nodes.count,
|
|
212
|
+
type: elementTypeName(snapshot.elementType),
|
|
213
|
+
label: label.isEmpty ? nil : label,
|
|
214
|
+
identifier: identifier.isEmpty ? nil : identifier,
|
|
215
|
+
value: valueText,
|
|
216
|
+
rect: snapshotRect(from: snapshot.frame),
|
|
217
|
+
enabled: snapshot.isEnabled,
|
|
218
|
+
hittable: hittable,
|
|
219
|
+
depth: depth,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let children = snapshot.children
|
|
225
|
+
for child in children {
|
|
226
|
+
walk(child, depth: depth + 1)
|
|
227
|
+
if truncated { return }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
walk(rootSnapshot, depth: 0)
|
|
232
|
+
return DataPayload(nodes: nodes, truncated: truncated)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func snapshotRect(from frame: CGRect) -> SnapshotRect {
|
|
236
|
+
return SnapshotRect(
|
|
237
|
+
x: Double(frame.origin.x),
|
|
238
|
+
y: Double(frame.origin.y),
|
|
239
|
+
width: Double(frame.size.width),
|
|
240
|
+
height: Double(frame.size.height)
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// MARK: - Snapshot Filtering
|
|
245
|
+
|
|
246
|
+
private func shouldInclude(
|
|
247
|
+
snapshot: XCUIElementSnapshot,
|
|
248
|
+
label: String,
|
|
249
|
+
identifier: String,
|
|
250
|
+
valueText: String?,
|
|
251
|
+
options: SnapshotOptions,
|
|
252
|
+
hittable: Bool
|
|
253
|
+
) -> Bool {
|
|
254
|
+
let type = snapshot.elementType
|
|
255
|
+
let hasContent = !label.isEmpty || !identifier.isEmpty || (valueText != nil)
|
|
256
|
+
if options.compact && type == .other && !hasContent && !hittable {
|
|
257
|
+
if snapshot.children.count <= 1 { return false }
|
|
258
|
+
}
|
|
259
|
+
if options.interactiveOnly {
|
|
260
|
+
if interactiveTypes.contains(type) { return true }
|
|
261
|
+
if hittable && type != .other { return true }
|
|
262
|
+
if hasContent { return true }
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
if options.compact {
|
|
266
|
+
return hasContent || hittable
|
|
267
|
+
}
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func computedSnapshotHittable(
|
|
272
|
+
_ snapshot: XCUIElementSnapshot,
|
|
273
|
+
viewport: CGRect,
|
|
274
|
+
laterNodes: ArraySlice<XCUIElementSnapshot>
|
|
275
|
+
) -> Bool {
|
|
276
|
+
guard snapshot.isEnabled else { return false }
|
|
277
|
+
let frame = snapshot.frame
|
|
278
|
+
if frame.isNull || frame.isEmpty { return false }
|
|
279
|
+
let center = CGPoint(x: frame.midX, y: frame.midY)
|
|
280
|
+
if !viewport.contains(center) { return false }
|
|
281
|
+
for node in laterNodes {
|
|
282
|
+
if !isOccludingType(node.elementType) { continue }
|
|
283
|
+
let nodeFrame = node.frame
|
|
284
|
+
if nodeFrame.isNull || nodeFrame.isEmpty { continue }
|
|
285
|
+
if nodeFrame.contains(center) { return false }
|
|
286
|
+
}
|
|
287
|
+
return true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private func isOccludingType(_ type: XCUIElement.ElementType) -> Bool {
|
|
291
|
+
switch type {
|
|
292
|
+
case .application, .window:
|
|
293
|
+
return false
|
|
294
|
+
default:
|
|
295
|
+
return true
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private func flattenedSnapshots(
|
|
300
|
+
_ root: XCUIElementSnapshot
|
|
301
|
+
) -> ([XCUIElementSnapshot], [ObjectIdentifier: (Int, Int)]) {
|
|
302
|
+
var ordered: [XCUIElementSnapshot] = []
|
|
303
|
+
var ranges: [ObjectIdentifier: (Int, Int)] = [:]
|
|
304
|
+
|
|
305
|
+
@discardableResult
|
|
306
|
+
func visit(_ snapshot: XCUIElementSnapshot) -> Int {
|
|
307
|
+
let start = ordered.count
|
|
308
|
+
ordered.append(snapshot)
|
|
309
|
+
var end = start
|
|
310
|
+
for child in snapshot.children {
|
|
311
|
+
end = max(end, visit(child))
|
|
312
|
+
}
|
|
313
|
+
ranges[ObjectIdentifier(snapshot)] = (start, end)
|
|
314
|
+
return end
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_ = visit(root)
|
|
318
|
+
return (ordered, ranges)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private func laterSnapshots(
|
|
322
|
+
for snapshot: XCUIElementSnapshot,
|
|
323
|
+
in ordered: [XCUIElementSnapshot],
|
|
324
|
+
ranges: [ObjectIdentifier: (Int, Int)]
|
|
325
|
+
) -> ArraySlice<XCUIElementSnapshot> {
|
|
326
|
+
guard let (_, subtreeEnd) = ranges[ObjectIdentifier(snapshot)] else {
|
|
327
|
+
return ordered.suffix(from: ordered.count)
|
|
328
|
+
}
|
|
329
|
+
let nextIndex = subtreeEnd + 1
|
|
330
|
+
if nextIndex >= ordered.count {
|
|
331
|
+
return ordered.suffix(from: ordered.count)
|
|
332
|
+
}
|
|
333
|
+
return ordered.suffix(from: nextIndex)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private func snapshotValueText(_ snapshot: XCUIElementSnapshot) -> String? {
|
|
337
|
+
guard let value = snapshot.value else { return nil }
|
|
338
|
+
let text = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
339
|
+
return text.isEmpty ? nil : text
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func aggregatedLabel(for snapshot: XCUIElementSnapshot, depth: Int = 0) -> String? {
|
|
343
|
+
if depth > 4 { return nil }
|
|
344
|
+
let text = snapshot.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
345
|
+
if !text.isEmpty { return text }
|
|
346
|
+
if let valueText = snapshotValueText(snapshot) { return valueText }
|
|
347
|
+
for child in snapshot.children {
|
|
348
|
+
if let childLabel = aggregatedLabel(for: child, depth: depth + 1) {
|
|
349
|
+
return childLabel
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return nil
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func isVisibleInViewport(_ rect: CGRect, _ viewport: CGRect) -> Bool {
|
|
356
|
+
if rect.isNull || rect.isEmpty { return false }
|
|
357
|
+
return rect.intersects(viewport)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension RunnerTests {
|
|
4
|
+
// MARK: - Blocking System Modal Snapshot
|
|
5
|
+
|
|
6
|
+
func blockingSystemAlertSnapshot() -> DataPayload? {
|
|
7
|
+
guard let modal = firstBlockingSystemModal(in: springboard) else {
|
|
8
|
+
return nil
|
|
9
|
+
}
|
|
10
|
+
let actions = actionableElements(in: modal)
|
|
11
|
+
guard !actions.isEmpty else {
|
|
12
|
+
return nil
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let title = preferredSystemModalTitle(modal)
|
|
16
|
+
guard let modalNode = safeMakeSnapshotNode(
|
|
17
|
+
element: modal,
|
|
18
|
+
index: 0,
|
|
19
|
+
type: "Alert",
|
|
20
|
+
labelOverride: title,
|
|
21
|
+
identifierOverride: modal.identifier,
|
|
22
|
+
depth: 0,
|
|
23
|
+
hittableOverride: true
|
|
24
|
+
) else {
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
var nodes: [SnapshotNode] = [modalNode]
|
|
28
|
+
|
|
29
|
+
for action in actions {
|
|
30
|
+
guard let actionNode = safeMakeSnapshotNode(
|
|
31
|
+
element: action,
|
|
32
|
+
index: nodes.count,
|
|
33
|
+
type: elementTypeName(action.elementType),
|
|
34
|
+
depth: 1,
|
|
35
|
+
hittableOverride: true
|
|
36
|
+
) else {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
nodes.append(actionNode)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return DataPayload(nodes: nodes, truncated: false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
|
|
46
|
+
let disableSafeProbe = RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_SAFE_MODAL_PROBE")
|
|
47
|
+
let queryElements: (() -> [XCUIElement]) -> [XCUIElement] = { fetch in
|
|
48
|
+
if disableSafeProbe {
|
|
49
|
+
return fetch()
|
|
50
|
+
}
|
|
51
|
+
return self.safeElementsQuery(fetch)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let alerts = queryElements {
|
|
55
|
+
springboard.alerts.allElementsBoundByIndex
|
|
56
|
+
}
|
|
57
|
+
for alert in alerts {
|
|
58
|
+
if safeIsBlockingSystemModal(alert, in: springboard) {
|
|
59
|
+
return alert
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let sheets = queryElements {
|
|
64
|
+
springboard.sheets.allElementsBoundByIndex
|
|
65
|
+
}
|
|
66
|
+
for sheet in sheets {
|
|
67
|
+
if safeIsBlockingSystemModal(sheet, in: springboard) {
|
|
68
|
+
return sheet
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return nil
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func safeElementsQuery(_ fetch: () -> [XCUIElement]) -> [XCUIElement] {
|
|
76
|
+
var elements: [XCUIElement] = []
|
|
77
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
78
|
+
elements = fetch()
|
|
79
|
+
})
|
|
80
|
+
if let exceptionMessage {
|
|
81
|
+
NSLog(
|
|
82
|
+
"AGENT_DEVICE_RUNNER_MODAL_QUERY_IGNORED_EXCEPTION=%@",
|
|
83
|
+
exceptionMessage
|
|
84
|
+
)
|
|
85
|
+
return []
|
|
86
|
+
}
|
|
87
|
+
return elements
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private func safeIsBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
|
|
91
|
+
var isBlocking = false
|
|
92
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
93
|
+
isBlocking = isBlockingSystemModal(element, in: springboard)
|
|
94
|
+
})
|
|
95
|
+
if let exceptionMessage {
|
|
96
|
+
NSLog(
|
|
97
|
+
"AGENT_DEVICE_RUNNER_MODAL_CHECK_IGNORED_EXCEPTION=%@",
|
|
98
|
+
exceptionMessage
|
|
99
|
+
)
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
return isBlocking
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
|
|
106
|
+
guard element.exists else { return false }
|
|
107
|
+
let frame = element.frame
|
|
108
|
+
if frame.isNull || frame.isEmpty { return false }
|
|
109
|
+
|
|
110
|
+
let viewport = springboard.frame
|
|
111
|
+
if viewport.isNull || viewport.isEmpty { return false }
|
|
112
|
+
|
|
113
|
+
let center = CGPoint(x: frame.midX, y: frame.midY)
|
|
114
|
+
if !viewport.contains(center) { return false }
|
|
115
|
+
|
|
116
|
+
return true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
|
|
120
|
+
var seen = Set<String>()
|
|
121
|
+
var actions: [XCUIElement] = []
|
|
122
|
+
let descendants = safeElementsQuery {
|
|
123
|
+
element.descendants(matching: .any).allElementsBoundByIndex
|
|
124
|
+
}
|
|
125
|
+
for candidate in descendants {
|
|
126
|
+
if !safeIsActionableCandidate(candidate, seen: &seen) { continue }
|
|
127
|
+
actions.append(candidate)
|
|
128
|
+
}
|
|
129
|
+
return actions
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private func safeIsActionableCandidate(_ candidate: XCUIElement, seen: inout Set<String>) -> Bool {
|
|
133
|
+
var include = false
|
|
134
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
135
|
+
if !candidate.exists || !candidate.isHittable { return }
|
|
136
|
+
if !actionableTypes.contains(candidate.elementType) { return }
|
|
137
|
+
let frame = candidate.frame
|
|
138
|
+
if frame.isNull || frame.isEmpty { return }
|
|
139
|
+
let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
|
|
140
|
+
if seen.contains(key) { return }
|
|
141
|
+
seen.insert(key)
|
|
142
|
+
include = true
|
|
143
|
+
})
|
|
144
|
+
if let exceptionMessage {
|
|
145
|
+
NSLog(
|
|
146
|
+
"AGENT_DEVICE_RUNNER_MODAL_ACTION_IGNORED_EXCEPTION=%@",
|
|
147
|
+
exceptionMessage
|
|
148
|
+
)
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
return include
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
|
|
155
|
+
let label = element.label
|
|
156
|
+
if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
157
|
+
return label
|
|
158
|
+
}
|
|
159
|
+
let identifier = element.identifier
|
|
160
|
+
if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
161
|
+
return identifier
|
|
162
|
+
}
|
|
163
|
+
return "System Alert"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func makeSnapshotNode(
|
|
167
|
+
element: XCUIElement,
|
|
168
|
+
index: Int,
|
|
169
|
+
type: String,
|
|
170
|
+
labelOverride: String? = nil,
|
|
171
|
+
identifierOverride: String? = nil,
|
|
172
|
+
depth: Int,
|
|
173
|
+
hittableOverride: Bool? = nil
|
|
174
|
+
) -> SnapshotNode {
|
|
175
|
+
let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
176
|
+
let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
177
|
+
return SnapshotNode(
|
|
178
|
+
index: index,
|
|
179
|
+
type: type,
|
|
180
|
+
label: label.isEmpty ? nil : label,
|
|
181
|
+
identifier: identifier.isEmpty ? nil : identifier,
|
|
182
|
+
value: nil,
|
|
183
|
+
rect: snapshotRect(from: element.frame),
|
|
184
|
+
enabled: element.isEnabled,
|
|
185
|
+
hittable: hittableOverride ?? element.isHittable,
|
|
186
|
+
depth: depth
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private func safeMakeSnapshotNode(
|
|
191
|
+
element: XCUIElement,
|
|
192
|
+
index: Int,
|
|
193
|
+
type: String,
|
|
194
|
+
labelOverride: String? = nil,
|
|
195
|
+
identifierOverride: String? = nil,
|
|
196
|
+
depth: Int,
|
|
197
|
+
hittableOverride: Bool? = nil
|
|
198
|
+
) -> SnapshotNode? {
|
|
199
|
+
var node: SnapshotNode?
|
|
200
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
201
|
+
node = makeSnapshotNode(
|
|
202
|
+
element: element,
|
|
203
|
+
index: index,
|
|
204
|
+
type: type,
|
|
205
|
+
labelOverride: labelOverride,
|
|
206
|
+
identifierOverride: identifierOverride,
|
|
207
|
+
depth: depth,
|
|
208
|
+
hittableOverride: hittableOverride
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
if let exceptionMessage {
|
|
212
|
+
NSLog(
|
|
213
|
+
"AGENT_DEVICE_RUNNER_MODAL_NODE_IGNORED_EXCEPTION=%@",
|
|
214
|
+
exceptionMessage
|
|
215
|
+
)
|
|
216
|
+
return nil
|
|
217
|
+
}
|
|
218
|
+
return node
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import Network
|
|
3
|
+
|
|
4
|
+
extension RunnerTests {
|
|
5
|
+
// MARK: - Connection Lifecycle
|
|
6
|
+
|
|
7
|
+
func handle(connection: NWConnection) {
|
|
8
|
+
receiveRequest(connection: connection, buffer: Data())
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// MARK: - Request Parsing
|
|
12
|
+
|
|
13
|
+
private func receiveRequest(connection: NWConnection, buffer: Data) {
|
|
14
|
+
connection.receive(minimumIncompleteLength: 1, maximumLength: 1024 * 1024) { [weak self] data, _, _, _ in
|
|
15
|
+
guard let self = self, let data = data else {
|
|
16
|
+
connection.cancel()
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if buffer.count + data.count > self.maxRequestBytes {
|
|
20
|
+
let response = self.jsonResponse(
|
|
21
|
+
status: 413,
|
|
22
|
+
response: Response(ok: false, error: ErrorPayload(message: "request too large")),
|
|
23
|
+
)
|
|
24
|
+
connection.send(content: response, completion: .contentProcessed { [weak self] _ in
|
|
25
|
+
connection.cancel()
|
|
26
|
+
self?.finish()
|
|
27
|
+
})
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
let combined = buffer + data
|
|
31
|
+
if let body = self.parseRequest(data: combined) {
|
|
32
|
+
let result = self.handleRequestBody(body)
|
|
33
|
+
connection.send(content: result.data, completion: .contentProcessed { _ in
|
|
34
|
+
connection.cancel()
|
|
35
|
+
if result.shouldFinish {
|
|
36
|
+
self.finish()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
} else {
|
|
40
|
+
self.receiveRequest(connection: connection, buffer: combined)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func parseRequest(data: Data) -> Data? {
|
|
46
|
+
guard let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) else {
|
|
47
|
+
return nil
|
|
48
|
+
}
|
|
49
|
+
let headerData = data.subdata(in: 0..<headerEnd.lowerBound)
|
|
50
|
+
let bodyStart = headerEnd.upperBound
|
|
51
|
+
let headers = String(decoding: headerData, as: UTF8.self)
|
|
52
|
+
let contentLength = extractContentLength(headers: headers)
|
|
53
|
+
guard let contentLength = contentLength else {
|
|
54
|
+
return nil
|
|
55
|
+
}
|
|
56
|
+
if data.count < bodyStart + contentLength {
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
let body = data.subdata(in: bodyStart..<(bodyStart + contentLength))
|
|
60
|
+
return body
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private func extractContentLength(headers: String) -> Int? {
|
|
64
|
+
for line in headers.split(separator: "\r\n") {
|
|
65
|
+
let parts = line.split(separator: ":", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }
|
|
66
|
+
if parts.count == 2 && parts[0].lowercased() == "content-length" {
|
|
67
|
+
return Int(parts[1])
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func handleRequestBody(_ body: Data) -> (data: Data, shouldFinish: Bool) {
|
|
74
|
+
guard let json = String(data: body, encoding: .utf8) else {
|
|
75
|
+
return (
|
|
76
|
+
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
|
|
77
|
+
false
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
guard let data = json.data(using: .utf8) else {
|
|
81
|
+
return (
|
|
82
|
+
jsonResponse(status: 400, response: Response(ok: false, error: ErrorPayload(message: "invalid json"))),
|
|
83
|
+
false
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
do {
|
|
88
|
+
let command = try JSONDecoder().decode(Command.self, from: data)
|
|
89
|
+
let response = try execute(command: command)
|
|
90
|
+
return (jsonResponse(status: 200, response: response), command.command == .shutdown)
|
|
91
|
+
} catch {
|
|
92
|
+
return (
|
|
93
|
+
jsonResponse(status: 500, response: Response(ok: false, error: ErrorPayload(message: "\(error)"))),
|
|
94
|
+
false
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: - Response Encoding
|
|
100
|
+
|
|
101
|
+
private func jsonResponse(status: Int, response: Response) -> Data {
|
|
102
|
+
let encoder = JSONEncoder()
|
|
103
|
+
let body = (try? encoder.encode(response)).flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
|
|
104
|
+
return httpResponse(status: status, body: body)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func httpResponse(status: Int, body: String) -> Data {
|
|
108
|
+
let headers = [
|
|
109
|
+
"HTTP/1.1 \(status) OK",
|
|
110
|
+
"Content-Type: application/json",
|
|
111
|
+
"Content-Length: \(body.utf8.count)",
|
|
112
|
+
"Connection: close",
|
|
113
|
+
"",
|
|
114
|
+
body,
|
|
115
|
+
].joined(separator: "\r\n")
|
|
116
|
+
return Data(headers.utf8)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private func finish() {
|
|
120
|
+
listener?.cancel()
|
|
121
|
+
listener = nil
|
|
122
|
+
doneExpectation?.fulfill()
|
|
123
|
+
}
|
|
124
|
+
}
|