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.
- package/dist/src/daemon.js +19 -19
- 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/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
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension RunnerTests {
|
|
4
|
+
// MARK: - Navigation Gestures
|
|
5
|
+
|
|
6
|
+
func tapNavigationBack(app: XCUIApplication) -> Bool {
|
|
7
|
+
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
|
|
8
|
+
if let back = buttons.first(where: { $0.isHittable }) {
|
|
9
|
+
back.tap()
|
|
10
|
+
return true
|
|
11
|
+
}
|
|
12
|
+
return pressTvRemoteMenuIfAvailable()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func performBackGesture(app: XCUIApplication) {
|
|
16
|
+
if pressTvRemoteMenuIfAvailable() {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
20
|
+
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
|
|
21
|
+
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
22
|
+
start.press(forDuration: 0.05, thenDragTo: end)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func performAppSwitcherGesture(app: XCUIApplication) {
|
|
26
|
+
if performTvRemoteAppSwitcherIfAvailable() {
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
30
|
+
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
|
|
31
|
+
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
|
|
32
|
+
start.press(forDuration: 0.6, thenDragTo: end)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func pressHomeButton() {
|
|
36
|
+
if pressTvRemoteHomeIfAvailable() {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
XCUIDevice.shared.press(.home)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private func pressTvRemoteMenuIfAvailable() -> Bool {
|
|
43
|
+
#if os(tvOS)
|
|
44
|
+
XCUIRemote.shared.press(.menu)
|
|
45
|
+
return true
|
|
46
|
+
#else
|
|
47
|
+
return false
|
|
48
|
+
#endif
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private func pressTvRemoteHomeIfAvailable() -> Bool {
|
|
52
|
+
#if os(tvOS)
|
|
53
|
+
XCUIRemote.shared.press(.home)
|
|
54
|
+
return true
|
|
55
|
+
#else
|
|
56
|
+
return false
|
|
57
|
+
#endif
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private func performTvRemoteAppSwitcherIfAvailable() -> Bool {
|
|
61
|
+
#if os(tvOS)
|
|
62
|
+
XCUIRemote.shared.press(.home)
|
|
63
|
+
sleepFor(resolveTvRemoteDoublePressDelay())
|
|
64
|
+
XCUIRemote.shared.press(.home)
|
|
65
|
+
return true
|
|
66
|
+
#else
|
|
67
|
+
return false
|
|
68
|
+
#endif
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private func resolveTvRemoteDoublePressDelay() -> TimeInterval {
|
|
72
|
+
guard
|
|
73
|
+
let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_TV_REMOTE_DOUBLE_PRESS_DELAY_MS"],
|
|
74
|
+
!raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
75
|
+
else {
|
|
76
|
+
return tvRemoteDoublePressDelayDefault
|
|
77
|
+
}
|
|
78
|
+
guard let parsedMs = Double(raw), parsedMs >= 0 else {
|
|
79
|
+
return tvRemoteDoublePressDelayDefault
|
|
80
|
+
}
|
|
81
|
+
return min(parsedMs, 1000) / 1000.0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
|
|
85
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
|
|
86
|
+
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
87
|
+
return element.exists ? element : nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func clearTextInput(_ element: XCUIElement) {
|
|
91
|
+
moveCaretToEnd(element: element)
|
|
92
|
+
let count = estimatedDeleteCount(for: element)
|
|
93
|
+
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
|
94
|
+
element.typeText(deletes)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func focusedTextInput(app: XCUIApplication) -> XCUIElement? {
|
|
98
|
+
let focused = app
|
|
99
|
+
.descendants(matching: .any)
|
|
100
|
+
.matching(NSPredicate(format: "hasKeyboardFocus == 1"))
|
|
101
|
+
.firstMatch
|
|
102
|
+
guard focused.exists else { return nil }
|
|
103
|
+
|
|
104
|
+
switch focused.elementType {
|
|
105
|
+
case .textField, .secureTextField, .searchField, .textView:
|
|
106
|
+
return focused
|
|
107
|
+
default:
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func moveCaretToEnd(element: XCUIElement) {
|
|
113
|
+
let frame = element.frame
|
|
114
|
+
guard !frame.isEmpty else {
|
|
115
|
+
element.tap()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
let origin = element.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
119
|
+
let target = origin.withOffset(
|
|
120
|
+
CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
|
|
121
|
+
)
|
|
122
|
+
target.tap()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private func estimatedDeleteCount(for element: XCUIElement) -> Int {
|
|
126
|
+
let valueText = String(describing: element.value ?? "")
|
|
127
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
128
|
+
let base = valueText.isEmpty ? 24 : (valueText.count + 8)
|
|
129
|
+
return max(24, min(120, base))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func findScopeElement(app: XCUIApplication, scope: String) -> XCUIElement? {
|
|
133
|
+
let predicate = NSPredicate(
|
|
134
|
+
format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@",
|
|
135
|
+
scope,
|
|
136
|
+
scope
|
|
137
|
+
)
|
|
138
|
+
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
139
|
+
return element.exists ? element : nil
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func tapAt(app: XCUIApplication, x: Double, y: Double) {
|
|
143
|
+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
144
|
+
let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
|
|
145
|
+
coordinate.tap()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func doubleTapAt(app: XCUIApplication, x: Double, y: Double) {
|
|
149
|
+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
150
|
+
let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
|
|
151
|
+
coordinate.doubleTap()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) {
|
|
155
|
+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
156
|
+
let coordinate = origin.withOffset(CGVector(dx: x, dy: y))
|
|
157
|
+
coordinate.press(forDuration: duration)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func dragAt(
|
|
161
|
+
app: XCUIApplication,
|
|
162
|
+
x: Double,
|
|
163
|
+
y: Double,
|
|
164
|
+
x2: Double,
|
|
165
|
+
y2: Double,
|
|
166
|
+
holdDuration: TimeInterval
|
|
167
|
+
) {
|
|
168
|
+
let origin = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
169
|
+
let start = origin.withOffset(CGVector(dx: x, dy: y))
|
|
170
|
+
let end = origin.withOffset(CGVector(dx: x2, dy: y2))
|
|
171
|
+
start.press(forDuration: holdDuration, thenDragTo: end)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
|
|
175
|
+
let total = max(count, 1)
|
|
176
|
+
let pause = max(pauseMs, 0)
|
|
177
|
+
for idx in 0..<total {
|
|
178
|
+
operation(idx)
|
|
179
|
+
if idx < total - 1 && pause > 0 {
|
|
180
|
+
Thread.sleep(forTimeInterval: pause / 1000.0)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func swipe(app: XCUIApplication, direction: SwipeDirection) {
|
|
186
|
+
if performTvRemoteSwipeIfAvailable(direction: direction) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
190
|
+
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
|
|
191
|
+
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
|
|
192
|
+
let left = target.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
|
193
|
+
let right = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
194
|
+
|
|
195
|
+
switch direction {
|
|
196
|
+
case .up:
|
|
197
|
+
end.press(forDuration: 0.1, thenDragTo: start)
|
|
198
|
+
case .down:
|
|
199
|
+
start.press(forDuration: 0.1, thenDragTo: end)
|
|
200
|
+
case .left:
|
|
201
|
+
right.press(forDuration: 0.1, thenDragTo: left)
|
|
202
|
+
case .right:
|
|
203
|
+
left.press(forDuration: 0.1, thenDragTo: right)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private func performTvRemoteSwipeIfAvailable(direction: SwipeDirection) -> Bool {
|
|
208
|
+
#if os(tvOS)
|
|
209
|
+
switch direction {
|
|
210
|
+
case .up:
|
|
211
|
+
XCUIRemote.shared.press(.up)
|
|
212
|
+
case .down:
|
|
213
|
+
XCUIRemote.shared.press(.down)
|
|
214
|
+
case .left:
|
|
215
|
+
XCUIRemote.shared.press(.left)
|
|
216
|
+
case .right:
|
|
217
|
+
XCUIRemote.shared.press(.right)
|
|
218
|
+
}
|
|
219
|
+
return true
|
|
220
|
+
#else
|
|
221
|
+
return false
|
|
222
|
+
#endif
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
|
|
226
|
+
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
227
|
+
|
|
228
|
+
// Use double-tap + drag gesture for reliable map zoom
|
|
229
|
+
// Zoom in (scale > 1): tap then drag UP
|
|
230
|
+
// Zoom out (scale < 1): tap then drag DOWN
|
|
231
|
+
|
|
232
|
+
// Determine center point (use provided x/y or screen center)
|
|
233
|
+
let centerX = x.map { $0 / target.frame.width } ?? 0.5
|
|
234
|
+
let centerY = y.map { $0 / target.frame.height } ?? 0.5
|
|
235
|
+
let center = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: centerY))
|
|
236
|
+
|
|
237
|
+
// Calculate drag distance based on scale (clamped to reasonable range)
|
|
238
|
+
// Larger scale = more drag distance
|
|
239
|
+
let dragAmount: CGFloat
|
|
240
|
+
if scale > 1.0 {
|
|
241
|
+
// Zoom in: drag up (negative Y direction in normalized coords)
|
|
242
|
+
dragAmount = min(0.4, CGFloat(scale - 1.0) * 0.2)
|
|
243
|
+
} else {
|
|
244
|
+
// Zoom out: drag down (positive Y direction)
|
|
245
|
+
dragAmount = min(0.4, CGFloat(1.0 - scale) * 0.4)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let endY = scale > 1.0 ? (centerY - Double(dragAmount)) : (centerY + Double(dragAmount))
|
|
249
|
+
let endPoint = target.coordinate(withNormalizedOffset: CGVector(dx: centerX, dy: max(0.1, min(0.9, endY))))
|
|
250
|
+
|
|
251
|
+
// Tap first (first tap of double-tap)
|
|
252
|
+
center.tap()
|
|
253
|
+
|
|
254
|
+
// Immediately press and drag (second tap + drag)
|
|
255
|
+
center.press(forDuration: 0.05, thenDragTo: endPoint)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
}
|