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
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
ADDED
|
@@ -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
|
+
}
|