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.
@@ -204,6 +204,8 @@
204
204
  isa = XCBuildConfiguration;
205
205
  buildSettings = {
206
206
  ALWAYS_SEARCH_USER_PATHS = NO;
207
+ AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner;
208
+ AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID).uitests";
207
209
  ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
208
210
  CLANG_ANALYZER_NONNULL = YES;
209
211
  CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -268,6 +270,8 @@
268
270
  isa = XCBuildConfiguration;
269
271
  buildSettings = {
270
272
  ALWAYS_SEARCH_USER_PATHS = NO;
273
+ AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID = com.callstack.agentdevice.runner;
274
+ AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID).uitests";
271
275
  ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
272
276
  CLANG_ANALYZER_NONNULL = YES;
273
277
  CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -342,7 +346,7 @@
342
346
  "@executable_path/Frameworks",
343
347
  );
344
348
  MARKETING_VERSION = 1.0;
345
- PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunner;
349
+ PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID)";
346
350
  PRODUCT_NAME = "$(TARGET_NAME)";
347
351
  STRING_CATALOG_GENERATE_SYMBOLS = YES;
348
352
  SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -377,7 +381,7 @@
377
381
  "@executable_path/Frameworks",
378
382
  );
379
383
  MARKETING_VERSION = 1.0;
380
- PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunner;
384
+ PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID)";
381
385
  PRODUCT_NAME = "$(TARGET_NAME)";
382
386
  STRING_CATALOG_GENERATE_SYMBOLS = YES;
383
387
  SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -400,7 +404,7 @@
400
404
  GENERATE_INFOPLIST_FILE = YES;
401
405
  IPHONEOS_DEPLOYMENT_TARGET = 15.6;
402
406
  MARKETING_VERSION = 1.0;
403
- PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunnerUITests;
407
+ PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID)";
404
408
  PRODUCT_NAME = "$(TARGET_NAME)";
405
409
  STRING_CATALOG_GENERATE_SYMBOLS = NO;
406
410
  SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -424,7 +428,7 @@
424
428
  GENERATE_INFOPLIST_FILE = YES;
425
429
  IPHONEOS_DEPLOYMENT_TARGET = 15.6;
426
430
  MARKETING_VERSION = 1.0;
427
- PRODUCT_BUNDLE_IDENTIFIER = com.myapp.AgentDeviceRunnerUITests;
431
+ PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID)";
428
432
  PRODUCT_NAME = "$(TARGET_NAME)";
429
433
  STRING_CATALOG_GENERATE_SYMBOLS = NO;
430
434
  SWIFT_APPROACHABLE_CONCURRENCY = YES;
@@ -0,0 +1,381 @@
1
+ import XCTest
2
+
3
+ extension RunnerTests {
4
+ // MARK: - Main Thread Dispatch
5
+
6
+ func execute(command: Command) throws -> Response {
7
+ if Thread.isMainThread {
8
+ return try executeOnMainSafely(command: command)
9
+ }
10
+ var result: Result<Response, Error>?
11
+ let semaphore = DispatchSemaphore(value: 0)
12
+ DispatchQueue.main.async {
13
+ do {
14
+ result = .success(try self.executeOnMainSafely(command: command))
15
+ } catch {
16
+ result = .failure(error)
17
+ }
18
+ semaphore.signal()
19
+ }
20
+ let waitResult = semaphore.wait(timeout: .now() + mainThreadExecutionTimeout)
21
+ if waitResult == .timedOut {
22
+ // The main queue work may still be running; we stop waiting and report timeout.
23
+ throw NSError(
24
+ domain: RunnerErrorDomain.general,
25
+ code: RunnerErrorCode.mainThreadExecutionTimedOut,
26
+ userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
27
+ )
28
+ }
29
+ switch result {
30
+ case .success(let response):
31
+ return response
32
+ case .failure(let error):
33
+ throw error
34
+ case .none:
35
+ throw NSError(
36
+ domain: RunnerErrorDomain.general,
37
+ code: RunnerErrorCode.noResponseFromMainThread,
38
+ userInfo: [NSLocalizedDescriptionKey: "no response from main thread"]
39
+ )
40
+ }
41
+ }
42
+
43
+ // MARK: - Command Handling
44
+
45
+ private func executeOnMainSafely(command: Command) throws -> Response {
46
+ var hasRetried = false
47
+ while true {
48
+ var response: Response?
49
+ var swiftError: Error?
50
+ let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
51
+ do {
52
+ response = try self.executeOnMain(command: command)
53
+ } catch {
54
+ swiftError = error
55
+ }
56
+ })
57
+
58
+ if let exceptionMessage {
59
+ currentApp = nil
60
+ currentBundleId = nil
61
+ if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
62
+ NSLog(
63
+ "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
64
+ command.command.rawValue
65
+ )
66
+ hasRetried = true
67
+ sleepFor(retryCooldown)
68
+ continue
69
+ }
70
+ throw NSError(
71
+ domain: RunnerErrorDomain.exception,
72
+ code: RunnerErrorCode.objcException,
73
+ userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
74
+ )
75
+ }
76
+ if let swiftError {
77
+ throw swiftError
78
+ }
79
+ guard let response else {
80
+ throw NSError(
81
+ domain: RunnerErrorDomain.general,
82
+ code: RunnerErrorCode.commandReturnedNoResponse,
83
+ userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
84
+ )
85
+ }
86
+ if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
87
+ NSLog(
88
+ "AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
89
+ command.command.rawValue
90
+ )
91
+ hasRetried = true
92
+ currentApp = nil
93
+ currentBundleId = nil
94
+ sleepFor(retryCooldown)
95
+ continue
96
+ }
97
+ return response
98
+ }
99
+ }
100
+
101
+ private func executeOnMain(command: Command) throws -> Response {
102
+ var activeApp = currentApp ?? app
103
+ if !isRunnerLifecycleCommand(command.command) {
104
+ let normalizedBundleId = command.appBundleId?
105
+ .trimmingCharacters(in: .whitespacesAndNewlines)
106
+ let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
107
+ if let bundleId = requestedBundleId {
108
+ if currentBundleId != bundleId || currentApp == nil {
109
+ _ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
110
+ }
111
+ } else {
112
+ // Do not reuse stale bundle targets when the caller does not explicitly request one.
113
+ currentApp = nil
114
+ currentBundleId = nil
115
+ }
116
+
117
+ activeApp = currentApp ?? app
118
+ if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
119
+ activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
120
+ } else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
121
+ app.activate()
122
+ activeApp = app
123
+ }
124
+
125
+ if !activeApp.waitForExistence(timeout: appExistenceTimeout) {
126
+ if let bundleId = requestedBundleId {
127
+ activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
128
+ guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
129
+ return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
130
+ }
131
+ } else {
132
+ return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
133
+ }
134
+ }
135
+
136
+ if isInteractionCommand(command.command) {
137
+ if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
138
+ activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
139
+ } else if requestedBundleId == nil, activeApp.state != .runningForeground {
140
+ app.activate()
141
+ activeApp = app
142
+ }
143
+ if !activeApp.waitForExistence(timeout: 2) {
144
+ if let bundleId = requestedBundleId {
145
+ return Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available"))
146
+ }
147
+ return Response(ok: false, error: ErrorPayload(message: "runner app is not available"))
148
+ }
149
+ applyInteractionStabilizationIfNeeded()
150
+ }
151
+ }
152
+
153
+ switch command.command {
154
+ case .shutdown:
155
+ stopRecordingIfNeeded()
156
+ return Response(ok: true, data: DataPayload(message: "shutdown"))
157
+ case .recordStart:
158
+ guard
159
+ let requestedOutPath = command.outPath?.trimmingCharacters(in: .whitespacesAndNewlines),
160
+ !requestedOutPath.isEmpty
161
+ else {
162
+ return Response(ok: false, error: ErrorPayload(message: "recordStart requires outPath"))
163
+ }
164
+ let hasAppBundleId = !(command.appBundleId?
165
+ .trimmingCharacters(in: .whitespacesAndNewlines)
166
+ .isEmpty ?? true)
167
+ guard hasAppBundleId else {
168
+ return Response(ok: false, error: ErrorPayload(message: "recordStart requires appBundleId"))
169
+ }
170
+ if activeRecording != nil {
171
+ return Response(ok: false, error: ErrorPayload(message: "recording already in progress"))
172
+ }
173
+ if let requestedFps = command.fps, (requestedFps < minRecordingFps || requestedFps > maxRecordingFps) {
174
+ return Response(ok: false, error: ErrorPayload(message: "recordStart fps must be between \(minRecordingFps) and \(maxRecordingFps)"))
175
+ }
176
+ do {
177
+ let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
178
+ let fpsLabel = command.fps.map(String.init) ?? "max"
179
+ NSLog(
180
+ "AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",
181
+ requestedOutPath,
182
+ resolvedOutPath,
183
+ fpsLabel
184
+ )
185
+ let recorder = ScreenRecorder(outputPath: resolvedOutPath, fps: command.fps.map { Int32($0) })
186
+ try recorder.start { [weak self] in
187
+ return self?.captureRunnerFrame()
188
+ }
189
+ activeRecording = recorder
190
+ return Response(ok: true, data: DataPayload(message: "recording started"))
191
+ } catch {
192
+ activeRecording = nil
193
+ return Response(ok: false, error: ErrorPayload(message: "failed to start recording: \(error.localizedDescription)"))
194
+ }
195
+ case .recordStop:
196
+ guard let recorder = activeRecording else {
197
+ return Response(ok: false, error: ErrorPayload(message: "no active recording"))
198
+ }
199
+ do {
200
+ try recorder.stop()
201
+ activeRecording = nil
202
+ return Response(ok: true, data: DataPayload(message: "recording stopped"))
203
+ } catch {
204
+ activeRecording = nil
205
+ return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
206
+ }
207
+ case .tap:
208
+ if let text = command.text {
209
+ if let element = findElement(app: activeApp, text: text) {
210
+ element.tap()
211
+ return Response(ok: true, data: DataPayload(message: "tapped"))
212
+ }
213
+ return Response(ok: false, error: ErrorPayload(message: "element not found"))
214
+ }
215
+ if let x = command.x, let y = command.y {
216
+ tapAt(app: activeApp, x: x, y: y)
217
+ return Response(ok: true, data: DataPayload(message: "tapped"))
218
+ }
219
+ return Response(ok: false, error: ErrorPayload(message: "tap requires text or x/y"))
220
+ case .tapSeries:
221
+ guard let x = command.x, let y = command.y else {
222
+ return Response(ok: false, error: ErrorPayload(message: "tapSeries requires x and y"))
223
+ }
224
+ let count = max(Int(command.count ?? 1), 1)
225
+ let intervalMs = max(command.intervalMs ?? 0, 0)
226
+ let doubleTap = command.doubleTap ?? false
227
+ if doubleTap {
228
+ runSeries(count: count, pauseMs: intervalMs) { _ in
229
+ doubleTapAt(app: activeApp, x: x, y: y)
230
+ }
231
+ return Response(ok: true, data: DataPayload(message: "tap series"))
232
+ }
233
+ runSeries(count: count, pauseMs: intervalMs) { _ in
234
+ tapAt(app: activeApp, x: x, y: y)
235
+ }
236
+ return Response(ok: true, data: DataPayload(message: "tap series"))
237
+ case .longPress:
238
+ guard let x = command.x, let y = command.y else {
239
+ return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
240
+ }
241
+ let duration = (command.durationMs ?? 800) / 1000.0
242
+ longPressAt(app: activeApp, x: x, y: y, duration: duration)
243
+ return Response(ok: true, data: DataPayload(message: "long pressed"))
244
+ case .drag:
245
+ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
246
+ return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
247
+ }
248
+ let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
249
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
250
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
251
+ }
252
+ return Response(ok: true, data: DataPayload(message: "dragged"))
253
+ case .dragSeries:
254
+ guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
255
+ return Response(ok: false, error: ErrorPayload(message: "dragSeries requires x, y, x2, and y2"))
256
+ }
257
+ let count = max(Int(command.count ?? 1), 1)
258
+ let pauseMs = max(command.pauseMs ?? 0, 0)
259
+ let pattern = command.pattern ?? "one-way"
260
+ if pattern != "one-way" && pattern != "ping-pong" {
261
+ return Response(ok: false, error: ErrorPayload(message: "dragSeries pattern must be one-way or ping-pong"))
262
+ }
263
+ let holdDuration = min(max((command.durationMs ?? 60) / 1000.0, 0.016), 10.0)
264
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
265
+ runSeries(count: count, pauseMs: pauseMs) { idx in
266
+ let reverse = pattern == "ping-pong" && (idx % 2 == 1)
267
+ if reverse {
268
+ dragAt(app: activeApp, x: x2, y: y2, x2: x, y2: y, holdDuration: holdDuration)
269
+ } else {
270
+ dragAt(app: activeApp, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
271
+ }
272
+ }
273
+ }
274
+ return Response(ok: true, data: DataPayload(message: "drag series"))
275
+ case .type:
276
+ guard let text = command.text else {
277
+ return Response(ok: false, error: ErrorPayload(message: "type requires text"))
278
+ }
279
+ if command.clearFirst == true {
280
+ guard let focused = focusedTextInput(app: activeApp) else {
281
+ return Response(ok: false, error: ErrorPayload(message: "no focused text input to clear"))
282
+ }
283
+ clearTextInput(focused)
284
+ focused.typeText(text)
285
+ return Response(ok: true, data: DataPayload(message: "typed"))
286
+ }
287
+ if let focused = focusedTextInput(app: activeApp) {
288
+ focused.typeText(text)
289
+ } else {
290
+ activeApp.typeText(text)
291
+ }
292
+ return Response(ok: true, data: DataPayload(message: "typed"))
293
+ case .swipe:
294
+ guard let direction = command.direction else {
295
+ return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
296
+ }
297
+ withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
298
+ swipe(app: activeApp, direction: direction)
299
+ }
300
+ return Response(ok: true, data: DataPayload(message: "swiped"))
301
+ case .findText:
302
+ guard let text = command.text else {
303
+ return Response(ok: false, error: ErrorPayload(message: "findText requires text"))
304
+ }
305
+ let found = findElement(app: activeApp, text: text) != nil
306
+ return Response(ok: true, data: DataPayload(found: found))
307
+ case .snapshot:
308
+ let options = SnapshotOptions(
309
+ interactiveOnly: command.interactiveOnly ?? false,
310
+ compact: command.compact ?? false,
311
+ depth: command.depth,
312
+ scope: command.scope,
313
+ raw: command.raw ?? false,
314
+ )
315
+ if options.raw {
316
+ needsPostSnapshotInteractionDelay = true
317
+ return Response(ok: true, data: snapshotRaw(app: activeApp, options: options))
318
+ }
319
+ needsPostSnapshotInteractionDelay = true
320
+ return Response(ok: true, data: snapshotFast(app: activeApp, options: options))
321
+ case .screenshot:
322
+ // If a target app bundle ID is provided, activate it first so the screenshot
323
+ // captures the target app rather than the AgentDeviceRunner itself.
324
+ if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
325
+ let targetApp = XCUIApplication(bundleIdentifier: bundleId)
326
+ targetApp.activate()
327
+ // Brief wait for the app transition animation to complete
328
+ Thread.sleep(forTimeInterval: 0.5)
329
+ }
330
+ let screenshot = XCUIScreen.main.screenshot()
331
+ guard let pngData = screenshot.image.pngData() else {
332
+ return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
333
+ }
334
+ let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
335
+ let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
336
+ do {
337
+ try pngData.write(to: URL(fileURLWithPath: filePath))
338
+ } catch {
339
+ return Response(ok: false, error: ErrorPayload(message: "Failed to write screenshot: \(error.localizedDescription)"))
340
+ }
341
+ // Return path relative to app container root (tmp/ maps to NSTemporaryDirectory)
342
+ return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)"))
343
+ case .back:
344
+ if tapNavigationBack(app: activeApp) {
345
+ return Response(ok: true, data: DataPayload(message: "back"))
346
+ }
347
+ performBackGesture(app: activeApp)
348
+ return Response(ok: true, data: DataPayload(message: "back"))
349
+ case .home:
350
+ pressHomeButton()
351
+ return Response(ok: true, data: DataPayload(message: "home"))
352
+ case .appSwitcher:
353
+ performAppSwitcherGesture(app: activeApp)
354
+ return Response(ok: true, data: DataPayload(message: "appSwitcher"))
355
+ case .alert:
356
+ let action = (command.action ?? "get").lowercased()
357
+ let alert = activeApp.alerts.firstMatch
358
+ if !alert.exists {
359
+ return Response(ok: false, error: ErrorPayload(message: "alert not found"))
360
+ }
361
+ if action == "accept" {
362
+ let button = alert.buttons.allElementsBoundByIndex.first
363
+ button?.tap()
364
+ return Response(ok: true, data: DataPayload(message: "accepted"))
365
+ }
366
+ if action == "dismiss" {
367
+ let button = alert.buttons.allElementsBoundByIndex.last
368
+ button?.tap()
369
+ return Response(ok: true, data: DataPayload(message: "dismissed"))
370
+ }
371
+ let buttonLabels = alert.buttons.allElementsBoundByIndex.map { $0.label }
372
+ return Response(ok: true, data: DataPayload(message: alert.label, items: buttonLabels))
373
+ case .pinch:
374
+ guard let scale = command.scale, scale > 0 else {
375
+ return Response(ok: false, error: ErrorPayload(message: "pinch requires scale > 0"))
376
+ }
377
+ pinch(app: activeApp, scale: scale, x: command.x, y: command.y)
378
+ return Response(ok: true, data: DataPayload(message: "pinched"))
379
+ }
380
+ }
381
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+
3
+ // MARK: - Environment
4
+
5
+ enum RunnerEnv {
6
+ static func resolvePort() -> UInt16 {
7
+ if let env = ProcessInfo.processInfo.environment["AGENT_DEVICE_RUNNER_PORT"], let port = UInt16(env) {
8
+ return port
9
+ }
10
+ for arg in CommandLine.arguments {
11
+ if arg.hasPrefix("AGENT_DEVICE_RUNNER_PORT=") {
12
+ let value = arg.replacingOccurrences(of: "AGENT_DEVICE_RUNNER_PORT=", with: "")
13
+ if let port = UInt16(value) { return port }
14
+ }
15
+ }
16
+ return 0
17
+ }
18
+
19
+ static func isTruthy(_ name: String) -> Bool {
20
+ guard let raw = ProcessInfo.processInfo.environment[name] else {
21
+ return false
22
+ }
23
+ switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
24
+ case "1", "true", "yes", "on":
25
+ return true
26
+ default:
27
+ return false
28
+ }
29
+ }
30
+ }