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.
- package/README.md +3 -1
- package/dist/src/daemon.js +25 -25
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +8 -4
- 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
- package/skills/agent-device/references/permissions.md +5 -1
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift
ADDED
|
@@ -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
|
+
}
|