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.
@@ -0,0 +1,174 @@
1
+ import XCTest
2
+ import UIKit
3
+
4
+ extension RunnerTests {
5
+ // MARK: - Recording
6
+
7
+ func captureRunnerFrame() -> UIImage? {
8
+ var image: UIImage?
9
+ let capture = {
10
+ let screenshot = XCUIScreen.main.screenshot()
11
+ image = screenshot.image
12
+ }
13
+ if Thread.isMainThread {
14
+ capture()
15
+ } else {
16
+ DispatchQueue.main.sync(execute: capture)
17
+ }
18
+ return image
19
+ }
20
+
21
+ func stopRecordingIfNeeded() {
22
+ guard let recorder = activeRecording else { return }
23
+ do {
24
+ try recorder.stop()
25
+ } catch {
26
+ NSLog("AGENT_DEVICE_RUNNER_RECORD_STOP_FAILED=%@", String(describing: error))
27
+ }
28
+ activeRecording = nil
29
+ }
30
+
31
+ func resolveRecordingOutPath(_ requestedOutPath: String) -> String {
32
+ let fileName = URL(fileURLWithPath: requestedOutPath).lastPathComponent
33
+ let fallbackName = "agent-device-recording-\(Int(Date().timeIntervalSince1970 * 1000)).mp4"
34
+ let safeFileName = fileName.isEmpty ? fallbackName : fileName
35
+ return (NSTemporaryDirectory() as NSString).appendingPathComponent(safeFileName)
36
+ }
37
+
38
+ // MARK: - Target Activation
39
+
40
+ func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
41
+ switch target.state {
42
+ case .unknown, .notRunning, .runningBackground, .runningBackgroundSuspended:
43
+ return true
44
+ default:
45
+ return false
46
+ }
47
+ }
48
+
49
+ func activateTarget(bundleId: String, reason: String) -> XCUIApplication {
50
+ let target = XCUIApplication(bundleIdentifier: bundleId)
51
+ NSLog(
52
+ "AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@",
53
+ bundleId,
54
+ target.state.rawValue,
55
+ reason
56
+ )
57
+ // activate avoids terminating and relaunching the target app
58
+ target.activate()
59
+ currentApp = target
60
+ currentBundleId = bundleId
61
+ needsFirstInteractionDelay = true
62
+ return target
63
+ }
64
+
65
+ func withTemporaryScrollIdleTimeoutIfSupported(
66
+ _ target: XCUIApplication,
67
+ operation: () -> Void
68
+ ) {
69
+ let setter = NSSelectorFromString("setWaitForIdleTimeout:")
70
+ guard target.responds(to: setter) else {
71
+ operation()
72
+ return
73
+ }
74
+ let previous = target.value(forKey: "waitForIdleTimeout") as? NSNumber
75
+ target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
76
+ defer {
77
+ if let previous {
78
+ target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
79
+ }
80
+ }
81
+ operation()
82
+ }
83
+
84
+ private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
85
+ guard
86
+ let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_IOS_INTERACTION_IDLE_TIMEOUT"],
87
+ !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
88
+ else {
89
+ return scrollInteractionIdleTimeoutDefault
90
+ }
91
+ guard let parsed = Double(raw), parsed >= 0 else {
92
+ return scrollInteractionIdleTimeoutDefault
93
+ }
94
+ return min(parsed, 30)
95
+ }
96
+
97
+ func shouldRetryCommand(_ command: Command) -> Bool {
98
+ if RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
99
+ return false
100
+ }
101
+ return isReadOnlyCommand(command)
102
+ }
103
+
104
+ func shouldRetryException(_ command: Command, message: String) -> Bool {
105
+ guard shouldRetryCommand(command) else { return false }
106
+ let normalized = message.lowercased()
107
+ if normalized.contains("kaxerrorservernotfound") {
108
+ return true
109
+ }
110
+ if normalized.contains("main thread execution timed out") {
111
+ return true
112
+ }
113
+ if normalized.contains("timed out") && command.command == .snapshot {
114
+ return true
115
+ }
116
+ return false
117
+ }
118
+
119
+ // MARK: - Command Classification
120
+
121
+ func isReadOnlyCommand(_ command: Command) -> Bool {
122
+ switch command.command {
123
+ case .findText, .snapshot, .screenshot:
124
+ return true
125
+ case .alert:
126
+ let action = (command.action ?? "get").lowercased()
127
+ return action == "get"
128
+ default:
129
+ return false
130
+ }
131
+ }
132
+
133
+ func shouldRetryResponse(_ response: Response) -> Bool {
134
+ guard response.ok == false else { return false }
135
+ guard let message = response.error?.message.lowercased() else { return false }
136
+ return message.contains("is not available")
137
+ }
138
+
139
+ func isInteractionCommand(_ command: CommandType) -> Bool {
140
+ switch command {
141
+ case .tap, .longPress, .drag, .type, .swipe, .back, .appSwitcher, .pinch:
142
+ return true
143
+ default:
144
+ return false
145
+ }
146
+ }
147
+
148
+ func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
149
+ switch command {
150
+ case .shutdown, .recordStop, .screenshot:
151
+ return true
152
+ default:
153
+ return false
154
+ }
155
+ }
156
+
157
+ // MARK: - Interaction Stabilization
158
+
159
+ func applyInteractionStabilizationIfNeeded() {
160
+ if needsPostSnapshotInteractionDelay {
161
+ sleepFor(postSnapshotInteractionDelay)
162
+ needsPostSnapshotInteractionDelay = false
163
+ }
164
+ if needsFirstInteractionDelay {
165
+ sleepFor(firstInteractionAfterActivateDelay)
166
+ needsFirstInteractionDelay = false
167
+ }
168
+ }
169
+
170
+ func sleepFor(_ delay: TimeInterval) {
171
+ guard delay > 0 else { return }
172
+ usleep(useconds_t(delay * 1_000_000))
173
+ }
174
+ }
@@ -0,0 +1,121 @@
1
+ // MARK: - Wire Models
2
+
3
+ enum CommandType: String, Codable {
4
+ case tap
5
+ case tapSeries
6
+ case longPress
7
+ case drag
8
+ case dragSeries
9
+ case type
10
+ case swipe
11
+ case findText
12
+ case snapshot
13
+ case screenshot
14
+ case back
15
+ case home
16
+ case appSwitcher
17
+ case alert
18
+ case pinch
19
+ case recordStart
20
+ case recordStop
21
+ case shutdown
22
+ }
23
+
24
+ enum SwipeDirection: String, Codable {
25
+ case up
26
+ case down
27
+ case left
28
+ case right
29
+ }
30
+
31
+ struct Command: Codable {
32
+ let command: CommandType
33
+ let appBundleId: String?
34
+ let text: String?
35
+ let clearFirst: Bool?
36
+ let action: String?
37
+ let x: Double?
38
+ let y: Double?
39
+ let count: Double?
40
+ let intervalMs: Double?
41
+ let doubleTap: Bool?
42
+ let pauseMs: Double?
43
+ let pattern: String?
44
+ let x2: Double?
45
+ let y2: Double?
46
+ let durationMs: Double?
47
+ let direction: SwipeDirection?
48
+ let scale: Double?
49
+ let outPath: String?
50
+ let fps: Int?
51
+ let interactiveOnly: Bool?
52
+ let compact: Bool?
53
+ let depth: Int?
54
+ let scope: String?
55
+ let raw: Bool?
56
+ }
57
+
58
+ struct Response: Codable {
59
+ let ok: Bool
60
+ let data: DataPayload?
61
+ let error: ErrorPayload?
62
+
63
+ init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) {
64
+ self.ok = ok
65
+ self.data = data
66
+ self.error = error
67
+ }
68
+ }
69
+
70
+ struct DataPayload: Codable {
71
+ let message: String?
72
+ let found: Bool?
73
+ let items: [String]?
74
+ let nodes: [SnapshotNode]?
75
+ let truncated: Bool?
76
+
77
+ init(
78
+ message: String? = nil,
79
+ found: Bool? = nil,
80
+ items: [String]? = nil,
81
+ nodes: [SnapshotNode]? = nil,
82
+ truncated: Bool? = nil
83
+ ) {
84
+ self.message = message
85
+ self.found = found
86
+ self.items = items
87
+ self.nodes = nodes
88
+ self.truncated = truncated
89
+ }
90
+ }
91
+
92
+ struct ErrorPayload: Codable {
93
+ let message: String
94
+ }
95
+
96
+ struct SnapshotRect: Codable {
97
+ let x: Double
98
+ let y: Double
99
+ let width: Double
100
+ let height: Double
101
+ }
102
+
103
+ struct SnapshotNode: Codable {
104
+ let index: Int
105
+ let type: String
106
+ let label: String?
107
+ let identifier: String?
108
+ let value: String?
109
+ let rect: SnapshotRect
110
+ let enabled: Bool
111
+ let hittable: Bool
112
+ let depth: Int
113
+ }
114
+
115
+ struct SnapshotOptions {
116
+ let interactiveOnly: Bool
117
+ let compact: Bool
118
+ let depth: Int?
119
+ let scope: String?
120
+ let raw: Bool
121
+ }
@@ -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
+ }