agent-device 0.7.3 → 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.
@@ -0,0 +1,263 @@
1
+ import AVFoundation
2
+ import CoreVideo
3
+ import UIKit
4
+
5
+ extension RunnerTests {
6
+ // MARK: - Screen Recorder
7
+
8
+ final class ScreenRecorder {
9
+ private let outputPath: String
10
+ private let fps: Int32?
11
+ private let uncappedFrameInterval: TimeInterval = 0.001
12
+ private var uncappedTimestampTimescale: Int32 {
13
+ Int32(max(1, Int((1.0 / uncappedFrameInterval).rounded())))
14
+ }
15
+ private var frameInterval: TimeInterval {
16
+ guard let fps else { return uncappedFrameInterval }
17
+ return 1.0 / Double(fps)
18
+ }
19
+ private let queue = DispatchQueue(label: "agent-device.runner.recorder")
20
+ private let lock = NSLock()
21
+ private var assetWriter: AVAssetWriter?
22
+ private var writerInput: AVAssetWriterInput?
23
+ private var pixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?
24
+ private var timer: DispatchSourceTimer?
25
+ private var recordingStartUptime: TimeInterval?
26
+ private var lastTimestampValue: Int64 = -1
27
+ private var isStopping = false
28
+ private var startedSession = false
29
+ private var startError: Error?
30
+
31
+ init(outputPath: String, fps: Int32?) {
32
+ self.outputPath = outputPath
33
+ self.fps = fps
34
+ }
35
+
36
+ func start(captureFrame: @escaping () -> UIImage?) throws {
37
+ let url = URL(fileURLWithPath: outputPath)
38
+ let directory = url.deletingLastPathComponent()
39
+ try FileManager.default.createDirectory(
40
+ at: directory,
41
+ withIntermediateDirectories: true,
42
+ attributes: nil
43
+ )
44
+ if FileManager.default.fileExists(atPath: outputPath) {
45
+ try FileManager.default.removeItem(atPath: outputPath)
46
+ }
47
+
48
+ var dimensions: CGSize = .zero
49
+ var bootstrapImage: UIImage?
50
+ let bootstrapDeadline = Date().addingTimeInterval(2.0)
51
+ while Date() < bootstrapDeadline {
52
+ if let image = captureFrame(), let cgImage = image.cgImage {
53
+ bootstrapImage = image
54
+ dimensions = CGSize(width: cgImage.width, height: cgImage.height)
55
+ break
56
+ }
57
+ Thread.sleep(forTimeInterval: 0.05)
58
+ }
59
+ guard dimensions.width > 0, dimensions.height > 0 else {
60
+ throw NSError(
61
+ domain: "AgentDeviceRunner.Record",
62
+ code: 1,
63
+ userInfo: [NSLocalizedDescriptionKey: "failed to capture initial frame"]
64
+ )
65
+ }
66
+
67
+ let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
68
+ let outputSettings: [String: Any] = [
69
+ AVVideoCodecKey: AVVideoCodecType.h264,
70
+ AVVideoWidthKey: Int(dimensions.width),
71
+ AVVideoHeightKey: Int(dimensions.height),
72
+ ]
73
+ let input = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings)
74
+ input.expectsMediaDataInRealTime = true
75
+ let attributes: [String: Any] = [
76
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
77
+ kCVPixelBufferWidthKey as String: Int(dimensions.width),
78
+ kCVPixelBufferHeightKey as String: Int(dimensions.height),
79
+ ]
80
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(
81
+ assetWriterInput: input,
82
+ sourcePixelBufferAttributes: attributes
83
+ )
84
+ guard writer.canAdd(input) else {
85
+ throw NSError(
86
+ domain: "AgentDeviceRunner.Record",
87
+ code: 2,
88
+ userInfo: [NSLocalizedDescriptionKey: "failed to add video input"]
89
+ )
90
+ }
91
+ writer.add(input)
92
+ guard writer.startWriting() else {
93
+ throw writer.error ?? NSError(
94
+ domain: "AgentDeviceRunner.Record",
95
+ code: 3,
96
+ userInfo: [NSLocalizedDescriptionKey: "failed to start writing"]
97
+ )
98
+ }
99
+
100
+ lock.lock()
101
+ assetWriter = writer
102
+ writerInput = input
103
+ pixelBufferAdaptor = adaptor
104
+ recordingStartUptime = nil
105
+ lastTimestampValue = -1
106
+ isStopping = false
107
+ startedSession = false
108
+ startError = nil
109
+ lock.unlock()
110
+
111
+ if let firstImage = bootstrapImage {
112
+ append(image: firstImage)
113
+ }
114
+
115
+ let timer = DispatchSource.makeTimerSource(queue: queue)
116
+ timer.schedule(deadline: .now() + frameInterval, repeating: frameInterval)
117
+ timer.setEventHandler { [weak self] in
118
+ guard let self else { return }
119
+ if self.shouldStop() { return }
120
+ guard let image = captureFrame() else { return }
121
+ self.append(image: image)
122
+ }
123
+ self.timer = timer
124
+ timer.resume()
125
+ }
126
+
127
+ func stop() throws {
128
+ var writer: AVAssetWriter?
129
+ var input: AVAssetWriterInput?
130
+ var appendError: Error?
131
+ lock.lock()
132
+ if isStopping {
133
+ lock.unlock()
134
+ return
135
+ }
136
+ isStopping = true
137
+ let activeTimer = timer
138
+ timer = nil
139
+ writer = assetWriter
140
+ input = writerInput
141
+ appendError = startError
142
+ lock.unlock()
143
+
144
+ activeTimer?.cancel()
145
+ input?.markAsFinished()
146
+ guard let writer else { return }
147
+
148
+ let semaphore = DispatchSemaphore(value: 0)
149
+ writer.finishWriting {
150
+ semaphore.signal()
151
+ }
152
+ var stopFailure: Error?
153
+ let waitResult = semaphore.wait(timeout: .now() + 10)
154
+ if waitResult == .timedOut {
155
+ writer.cancelWriting()
156
+ stopFailure = NSError(
157
+ domain: "AgentDeviceRunner.Record",
158
+ code: 6,
159
+ userInfo: [NSLocalizedDescriptionKey: "recording finalization timed out"]
160
+ )
161
+ } else if let appendError {
162
+ stopFailure = appendError
163
+ } else if writer.status == .failed {
164
+ stopFailure = writer.error ?? NSError(
165
+ domain: "AgentDeviceRunner.Record",
166
+ code: 4,
167
+ userInfo: [NSLocalizedDescriptionKey: "failed to finalize recording"]
168
+ )
169
+ }
170
+
171
+ lock.lock()
172
+ assetWriter = nil
173
+ writerInput = nil
174
+ pixelBufferAdaptor = nil
175
+ recordingStartUptime = nil
176
+ lastTimestampValue = -1
177
+ startedSession = false
178
+ startError = nil
179
+ lock.unlock()
180
+
181
+ if let stopFailure {
182
+ throw stopFailure
183
+ }
184
+ }
185
+
186
+ private func append(image: UIImage) {
187
+ guard let cgImage = image.cgImage else { return }
188
+ lock.lock()
189
+ defer { lock.unlock() }
190
+ if isStopping { return }
191
+ if startError != nil { return }
192
+ guard
193
+ let writer = assetWriter,
194
+ let input = writerInput,
195
+ let adaptor = pixelBufferAdaptor
196
+ else {
197
+ return
198
+ }
199
+ if !startedSession {
200
+ writer.startSession(atSourceTime: .zero)
201
+ startedSession = true
202
+ }
203
+ guard input.isReadyForMoreMediaData else { return }
204
+ guard let pixelBuffer = makePixelBuffer(from: cgImage) else { return }
205
+ let nowUptime = ProcessInfo.processInfo.systemUptime
206
+ if recordingStartUptime == nil {
207
+ recordingStartUptime = nowUptime
208
+ }
209
+ let elapsed = max(0, nowUptime - (recordingStartUptime ?? nowUptime))
210
+ let timescale = fps ?? uncappedTimestampTimescale
211
+ var timestampValue = Int64((elapsed * Double(timescale)).rounded(.down))
212
+ if timestampValue <= lastTimestampValue {
213
+ timestampValue = lastTimestampValue + 1
214
+ }
215
+ let timestamp = CMTime(value: timestampValue, timescale: timescale)
216
+ if !adaptor.append(pixelBuffer, withPresentationTime: timestamp) {
217
+ startError = writer.error ?? NSError(
218
+ domain: "AgentDeviceRunner.Record",
219
+ code: 5,
220
+ userInfo: [NSLocalizedDescriptionKey: "failed to append frame"]
221
+ )
222
+ return
223
+ }
224
+ lastTimestampValue = timestampValue
225
+ }
226
+
227
+ private func shouldStop() -> Bool {
228
+ lock.lock()
229
+ defer { lock.unlock() }
230
+ return isStopping
231
+ }
232
+
233
+ private func makePixelBuffer(from image: CGImage) -> CVPixelBuffer? {
234
+ guard let adaptor = pixelBufferAdaptor else { return nil }
235
+ var pixelBuffer: CVPixelBuffer?
236
+ guard let pool = adaptor.pixelBufferPool else { return nil }
237
+ let status = CVPixelBufferPoolCreatePixelBuffer(
238
+ nil,
239
+ pool,
240
+ &pixelBuffer
241
+ )
242
+ guard status == kCVReturnSuccess, let pixelBuffer else { return nil }
243
+
244
+ CVPixelBufferLockBaseAddress(pixelBuffer, [])
245
+ defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
246
+ guard
247
+ let context = CGContext(
248
+ data: CVPixelBufferGetBaseAddress(pixelBuffer),
249
+ width: image.width,
250
+ height: image.height,
251
+ bitsPerComponent: 8,
252
+ bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
253
+ space: CGColorSpaceCreateDeviceRGB(),
254
+ bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
255
+ )
256
+ else {
257
+ return nil
258
+ }
259
+ context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
260
+ return pixelBuffer
261
+ }
262
+ }
263
+ }
@@ -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
+ }