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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|