agent-device 0.14.8 → 0.15.0
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 +8 -6
- package/android-snapshot-helper/README.md +4 -2
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.apk → agent-device-android-snapshot-helper-0.15.0.apk} +0 -0
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.15.0.apk.sha256 +1 -0
- package/android-snapshot-helper/dist/{agent-device-android-snapshot-helper-0.14.8.manifest.json → agent-device-android-snapshot-helper-0.15.0.manifest.json} +6 -6
- package/dist/src/1769.js +7 -0
- package/dist/src/2151.js +429 -0
- package/dist/src/221.js +4 -4
- package/dist/src/2842.js +1 -0
- package/dist/src/3572.js +1 -0
- package/dist/src/4057.js +1 -1
- package/dist/src/840.js +2 -0
- package/dist/src/9542.js +2 -2
- package/dist/src/9639.js +2 -2
- package/dist/src/9818.js +1 -1
- package/dist/src/android-adb.d.ts +49 -11
- package/dist/src/android-adb.js +1 -1
- package/dist/src/android-snapshot-helper.d.ts +35 -2
- package/dist/src/cli.js +60 -57
- package/dist/src/contracts.d.ts +2 -0
- package/dist/src/finders.d.ts +2 -0
- package/dist/src/index.d.ts +25 -22
- package/dist/src/internal/companion-tunnel.js +1 -1
- package/dist/src/internal/daemon.js +51 -23
- package/dist/src/remote-config.d.ts +17 -14
- package/dist/src/selectors.d.ts +3 -0
- package/dist/src/server.js +2 -20
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/xcshareddata/xcschemes/AgentDeviceRunner.xcscheme +7 -1
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +210 -56
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +890 -99
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +94 -7
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +8 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +24 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +2 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TvRemote.swift +185 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +1 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests.xctestplan +26 -0
- package/package.json +25 -11
- package/server.json +3 -3
- package/skills/agent-device/SKILL.md +6 -1
- package/skills/dogfood/SKILL.md +3 -1
- package/android-snapshot-helper/dist/agent-device-android-snapshot-helper-0.14.8.apk.sha256 +0 -1
- package/dist/src/180.js +0 -1
- package/dist/src/6108.js +0 -26
- package/dist/src/6642.js +0 -1
- package/dist/src/7462.js +0 -1
- package/dist/src/8809.js +0 -8
- package/dist/src/command-schema.js +0 -381
- package/skills/react-devtools/SKILL.md +0 -48
|
@@ -17,6 +17,60 @@ extension RunnerTests {
|
|
|
17
17
|
let referenceHeight: Double
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
struct DragPoints {
|
|
21
|
+
let x: Double
|
|
22
|
+
let y: Double
|
|
23
|
+
let x2: Double
|
|
24
|
+
let y2: Double
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct SelectorElementMatch {
|
|
28
|
+
let element: XCUIElement?
|
|
29
|
+
let isAmbiguous: Bool
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
enum TextTypingRepairMode {
|
|
33
|
+
case none
|
|
34
|
+
case append
|
|
35
|
+
case replacement
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
enum TextEntryTiming {
|
|
39
|
+
static let focusTimeout: TimeInterval = 0.4
|
|
40
|
+
static let repairReadinessTimeout: TimeInterval = 1.0
|
|
41
|
+
static let readinessTimeout: TimeInterval = 2.0
|
|
42
|
+
static let hardwareKeyboardFallbackTimeout: TimeInterval = 0.35
|
|
43
|
+
static let pollInterval: TimeInterval = 0.02
|
|
44
|
+
static let warmupValueTimeout: TimeInterval = 0.4
|
|
45
|
+
static let verificationStabilityWindow: TimeInterval = 0.2
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
struct TextEntryResult {
|
|
49
|
+
let verified: Bool?
|
|
50
|
+
let repaired: Bool
|
|
51
|
+
let expectedText: String?
|
|
52
|
+
let observedText: String?
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
struct TextEntryTarget {
|
|
56
|
+
let element: XCUIElement?
|
|
57
|
+
let refreshPoint: CGPoint?
|
|
58
|
+
let prefersFocusedElement: Bool
|
|
59
|
+
|
|
60
|
+
func withElement(_ nextElement: XCUIElement?) -> TextEntryTarget {
|
|
61
|
+
guard let nextElement else {
|
|
62
|
+
return self
|
|
63
|
+
}
|
|
64
|
+
let frame = nextElement.frame
|
|
65
|
+
let point = frame.isEmpty ? refreshPoint : CGPoint(x: frame.midX, y: frame.midY)
|
|
66
|
+
return TextEntryTarget(
|
|
67
|
+
element: nextElement,
|
|
68
|
+
refreshPoint: point,
|
|
69
|
+
prefersFocusedElement: prefersFocusedElement
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
20
74
|
// MARK: - Navigation Gestures
|
|
21
75
|
|
|
22
76
|
func tapInAppBackControl(app: XCUIApplication) -> Bool {
|
|
@@ -26,6 +80,9 @@ extension RunnerTests {
|
|
|
26
80
|
return true
|
|
27
81
|
}
|
|
28
82
|
return false
|
|
83
|
+
#elseif os(tvOS)
|
|
84
|
+
_ = pressTvRemote(.menu)
|
|
85
|
+
return true
|
|
29
86
|
#else
|
|
30
87
|
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
|
|
31
88
|
if let back = buttons.first(where: { $0.isHittable }) {
|
|
@@ -37,20 +94,26 @@ extension RunnerTests {
|
|
|
37
94
|
}
|
|
38
95
|
|
|
39
96
|
func performBackGesture(app: XCUIApplication) {
|
|
40
|
-
if
|
|
97
|
+
if pressTvRemote(.menu) {
|
|
41
98
|
return
|
|
42
99
|
}
|
|
100
|
+
performCoordinateBackGesture(app: app)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private func performCoordinateBackGesture(app: XCUIApplication) {
|
|
104
|
+
#if !os(tvOS)
|
|
43
105
|
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
44
106
|
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
|
|
45
107
|
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
|
|
46
108
|
start.press(forDuration: 0.05, thenDragTo: end)
|
|
109
|
+
#endif
|
|
47
110
|
}
|
|
48
111
|
|
|
49
112
|
func performSystemBackAction(app: XCUIApplication) -> Bool {
|
|
50
113
|
#if os(macOS)
|
|
51
114
|
return false
|
|
52
115
|
#else
|
|
53
|
-
if
|
|
116
|
+
if pressTvRemote(.menu) {
|
|
54
117
|
return true
|
|
55
118
|
}
|
|
56
119
|
performBackGesture(app: app)
|
|
@@ -59,20 +122,28 @@ extension RunnerTests {
|
|
|
59
122
|
}
|
|
60
123
|
|
|
61
124
|
func performAppSwitcherGesture(app: XCUIApplication) {
|
|
62
|
-
if
|
|
125
|
+
if pressTvRemote(.home) {
|
|
126
|
+
sleepFor(resolveTvRemoteDoublePressDelay())
|
|
127
|
+
_ = pressTvRemote(.home)
|
|
63
128
|
return
|
|
64
129
|
}
|
|
130
|
+
performCoordinateAppSwitcherGesture(app: app)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func performCoordinateAppSwitcherGesture(app: XCUIApplication) {
|
|
134
|
+
#if !os(tvOS)
|
|
65
135
|
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
66
136
|
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
|
|
67
137
|
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
|
|
68
138
|
start.press(forDuration: 0.6, thenDragTo: end)
|
|
139
|
+
#endif
|
|
69
140
|
}
|
|
70
141
|
|
|
71
142
|
func pressHomeButton() {
|
|
72
143
|
#if os(macOS)
|
|
73
144
|
return
|
|
74
145
|
#else
|
|
75
|
-
if
|
|
146
|
+
if pressTvRemote(.home) {
|
|
76
147
|
return
|
|
77
148
|
}
|
|
78
149
|
XCUIDevice.shared.press(.home)
|
|
@@ -80,7 +151,7 @@ extension RunnerTests {
|
|
|
80
151
|
}
|
|
81
152
|
|
|
82
153
|
func rotateDevice(to orientationName: String) -> Bool {
|
|
83
|
-
#if os(macOS)
|
|
154
|
+
#if os(macOS) || os(tvOS)
|
|
84
155
|
return false
|
|
85
156
|
#else
|
|
86
157
|
switch orientationName {
|
|
@@ -100,52 +171,82 @@ extension RunnerTests {
|
|
|
100
171
|
#endif
|
|
101
172
|
}
|
|
102
173
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
#else
|
|
108
|
-
return false
|
|
109
|
-
#endif
|
|
174
|
+
func findElement(app: XCUIApplication, text: String) -> XCUIElement? {
|
|
175
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text)
|
|
176
|
+
let element = app.descendants(matching: .any).matching(predicate).firstMatch
|
|
177
|
+
return element.exists ? element : nil
|
|
110
178
|
}
|
|
111
179
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
180
|
+
func findElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> SelectorElementMatch {
|
|
181
|
+
let value = selectorValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
182
|
+
guard !value.isEmpty else {
|
|
183
|
+
return SelectorElementMatch(element: nil, isAmbiguous: false)
|
|
184
|
+
}
|
|
185
|
+
let predicate: NSPredicate
|
|
186
|
+
switch selectorKey {
|
|
187
|
+
case "id":
|
|
188
|
+
predicate = NSPredicate(format: "identifier ==[c] %@", value)
|
|
189
|
+
case "label":
|
|
190
|
+
predicate = NSPredicate(format: "label ==[c] %@", value)
|
|
191
|
+
case "value":
|
|
192
|
+
predicate = NSPredicate(format: "value ==[c] %@", value)
|
|
193
|
+
case "text":
|
|
194
|
+
predicate = NSPredicate(format: "label ==[c] %@ OR identifier ==[c] %@ OR value ==[c] %@", value, value, value)
|
|
195
|
+
default:
|
|
196
|
+
return SelectorElementMatch(element: nil, isAmbiguous: false)
|
|
197
|
+
}
|
|
120
198
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
199
|
+
var matchedElement: XCUIElement?
|
|
200
|
+
let matches = app.descendants(matching: .any).matching(predicate).allElementsBoundByIndex
|
|
201
|
+
for element in matches where element.exists {
|
|
202
|
+
guard element.isHittable else {
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
guard matchedElement == nil else {
|
|
206
|
+
return SelectorElementMatch(element: nil, isAmbiguous: true)
|
|
207
|
+
}
|
|
208
|
+
matchedElement = element
|
|
209
|
+
}
|
|
210
|
+
return SelectorElementMatch(element: matchedElement, isAmbiguous: false)
|
|
130
211
|
}
|
|
131
212
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
else {
|
|
137
|
-
return tvRemoteDoublePressDelayDefault
|
|
213
|
+
func queryElement(app: XCUIApplication, selectorKey: String, selectorValue: String) -> Response {
|
|
214
|
+
let match = findElement(app: app, selectorKey: selectorKey, selectorValue: selectorValue)
|
|
215
|
+
if match.isAmbiguous {
|
|
216
|
+
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
|
|
138
217
|
}
|
|
139
|
-
guard let
|
|
140
|
-
return
|
|
218
|
+
guard let element = match.element else {
|
|
219
|
+
return Response(ok: true, data: DataPayload(found: false, nodes: []))
|
|
141
220
|
}
|
|
142
|
-
return min(parsedMs, 1000) / 1000.0
|
|
143
|
-
}
|
|
144
221
|
|
|
145
|
-
|
|
146
|
-
let
|
|
147
|
-
let
|
|
148
|
-
|
|
222
|
+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
223
|
+
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
224
|
+
let valueText = String(describing: element.value ?? "")
|
|
225
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
226
|
+
let node = SnapshotNode(
|
|
227
|
+
index: 0,
|
|
228
|
+
type: elementTypeName(element.elementType),
|
|
229
|
+
label: label.isEmpty ? nil : label,
|
|
230
|
+
identifier: identifier.isEmpty ? nil : identifier,
|
|
231
|
+
value: valueText.isEmpty ? nil : valueText,
|
|
232
|
+
rect: snapshotRect(from: element.frame),
|
|
233
|
+
enabled: element.isEnabled,
|
|
234
|
+
focused: nil,
|
|
235
|
+
selected: element.isSelected ? true : nil,
|
|
236
|
+
hittable: element.isHittable,
|
|
237
|
+
depth: 0,
|
|
238
|
+
parentIndex: nil,
|
|
239
|
+
hiddenContentAbove: nil,
|
|
240
|
+
hiddenContentBelow: nil
|
|
241
|
+
)
|
|
242
|
+
return Response(
|
|
243
|
+
ok: true,
|
|
244
|
+
data: DataPayload(
|
|
245
|
+
text: readableText(for: element),
|
|
246
|
+
found: true,
|
|
247
|
+
nodes: [node]
|
|
248
|
+
)
|
|
249
|
+
)
|
|
149
250
|
}
|
|
150
251
|
|
|
151
252
|
func readTextAt(app: XCUIApplication, x: Double, y: Double) -> String? {
|
|
@@ -183,7 +284,9 @@ extension RunnerTests {
|
|
|
183
284
|
}
|
|
184
285
|
|
|
185
286
|
func clearTextInput(_ element: XCUIElement) {
|
|
287
|
+
#if !os(tvOS)
|
|
186
288
|
moveCaretToEnd(element: element)
|
|
289
|
+
#endif
|
|
187
290
|
let count = estimatedDeleteCount(for: element)
|
|
188
291
|
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
|
|
189
292
|
element.typeText(deletes)
|
|
@@ -257,9 +360,458 @@ extension RunnerTests {
|
|
|
257
360
|
return focused
|
|
258
361
|
}
|
|
259
362
|
|
|
363
|
+
func stabilizeTextInputBeforeTyping(app: XCUIApplication, target: XCUIElement?) -> XCUIElement? {
|
|
364
|
+
#if os(tvOS)
|
|
365
|
+
return target
|
|
366
|
+
#else
|
|
367
|
+
let latest = target
|
|
368
|
+
let deadline = Date().addingTimeInterval(TextEntryTiming.focusTimeout)
|
|
369
|
+
while Date() < deadline {
|
|
370
|
+
if let focused = focusedTextInput(app: app) {
|
|
371
|
+
return focused
|
|
372
|
+
}
|
|
373
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
374
|
+
}
|
|
375
|
+
return latest
|
|
376
|
+
#endif
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget {
|
|
380
|
+
guard let x, let y else {
|
|
381
|
+
let focused = waitForTextEntryReadiness(
|
|
382
|
+
app: app,
|
|
383
|
+
target: TextEntryTarget(
|
|
384
|
+
element: focusedTextInput(app: app),
|
|
385
|
+
refreshPoint: nil,
|
|
386
|
+
prefersFocusedElement: true
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
return TextEntryTarget(element: focused, refreshPoint: nil, prefersFocusedElement: true)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let target = textInputAt(app: app, x: x, y: y)
|
|
393
|
+
let requestedPoint = CGPoint(x: x, y: y)
|
|
394
|
+
if let target {
|
|
395
|
+
let frame = target.frame
|
|
396
|
+
if !frame.isEmpty {
|
|
397
|
+
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
|
|
398
|
+
} else {
|
|
399
|
+
_ = tapAt(app: app, x: x, y: y)
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
_ = tapAt(app: app, x: x, y: y)
|
|
403
|
+
}
|
|
404
|
+
let stabilized = stabilizeTextInputBeforeTyping(app: app, target: target)
|
|
405
|
+
let element = waitForTextEntryReadiness(
|
|
406
|
+
app: app,
|
|
407
|
+
target: TextEntryTarget(
|
|
408
|
+
element: stabilized ?? target,
|
|
409
|
+
refreshPoint: requestedPoint,
|
|
410
|
+
prefersFocusedElement: false
|
|
411
|
+
)
|
|
412
|
+
) ?? stabilized ?? target
|
|
413
|
+
return TextEntryTarget(
|
|
414
|
+
element: element,
|
|
415
|
+
refreshPoint: textEntryRefreshPoint(for: element) ?? requestedPoint,
|
|
416
|
+
prefersFocusedElement: false
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func resolveTextEntryMode(_ command: Command) -> TextTypingRepairMode {
|
|
421
|
+
switch command.textEntryMode {
|
|
422
|
+
case "append":
|
|
423
|
+
return .append
|
|
424
|
+
case "replace":
|
|
425
|
+
return .replacement
|
|
426
|
+
default:
|
|
427
|
+
return command.clearFirst == true ? .replacement : .none
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func typeTextReliably(
|
|
432
|
+
app: XCUIApplication,
|
|
433
|
+
target: TextEntryTarget,
|
|
434
|
+
text: String,
|
|
435
|
+
delaySeconds: Double,
|
|
436
|
+
repairMode: TextTypingRepairMode = .none
|
|
437
|
+
) -> TextEntryResult {
|
|
438
|
+
guard !text.isEmpty else {
|
|
439
|
+
return TextEntryResult(verified: true, repaired: false, expectedText: "", observedText: "")
|
|
440
|
+
}
|
|
441
|
+
var activeTarget = target
|
|
442
|
+
let initialTarget = resolveTextEntryElement(app: app, target: activeTarget)
|
|
443
|
+
activeTarget = activeTarget.withElement(initialTarget)
|
|
444
|
+
let currentText = editableTextValue(for: initialTarget, treatingPlaceholderAsEmpty: true)
|
|
445
|
+
let initialText = repairMode == .append ? currentText : nil
|
|
446
|
+
let expectedText = expectedTextEntryValue(typedText: text, mode: repairMode, initialText: initialText)
|
|
447
|
+
|
|
448
|
+
if repairMode == .replacement {
|
|
449
|
+
guard let replacementTarget = initialTarget else {
|
|
450
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
451
|
+
}
|
|
452
|
+
if currentText == nil || currentText?.isEmpty == false {
|
|
453
|
+
clearTextInput(replacementTarget)
|
|
454
|
+
activeTarget = activeTarget.withElement(replacementTarget)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
func typeIntoCurrentTarget(_ value: String) -> XCUIElement? {
|
|
459
|
+
if let currentTarget = resolveTextEntryElement(app: app, target: activeTarget) {
|
|
460
|
+
app.typeText(value)
|
|
461
|
+
return currentTarget
|
|
462
|
+
} else {
|
|
463
|
+
app.typeText(value)
|
|
464
|
+
return resolveTextEntryElement(app: app, target: activeTarget)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
func waitForWarmupValue(_ expectedValue: String?, target: TextEntryTarget) {
|
|
469
|
+
guard let expectedValue else {
|
|
470
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
let deadline = Date().addingTimeInterval(TextEntryTiming.warmupValueTimeout)
|
|
474
|
+
while Date() < deadline {
|
|
475
|
+
if editableTextValue(for: resolveTextEntryElement(app: app, target: target)) == expectedValue {
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let characters = Array(text)
|
|
483
|
+
if delaySeconds > 0 && characters.count > 1 {
|
|
484
|
+
var typedTarget: XCUIElement?
|
|
485
|
+
for (index, character) in characters.enumerated() {
|
|
486
|
+
typedTarget = typeIntoCurrentTarget(String(character)) ?? typedTarget
|
|
487
|
+
if index + 1 < characters.count {
|
|
488
|
+
sleepFor(delaySeconds)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if repairMode == .none {
|
|
492
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
|
|
493
|
+
}
|
|
494
|
+
let repairResult = repairTextEntryIfNeeded(
|
|
495
|
+
app: app,
|
|
496
|
+
target: activeTarget.withElement(typedTarget),
|
|
497
|
+
expectedText: expectedText,
|
|
498
|
+
repairMode: repairMode
|
|
499
|
+
)
|
|
500
|
+
return verifyTextEntry(
|
|
501
|
+
app: app,
|
|
502
|
+
target: activeTarget.withElement(typedTarget),
|
|
503
|
+
expectedText: expectedText,
|
|
504
|
+
repaired: repairResult.repaired
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let typedTarget: XCUIElement?
|
|
509
|
+
if repairMode != .none && characters.count > 1 {
|
|
510
|
+
let firstCharacter = String(characters[0])
|
|
511
|
+
var firstTypedTarget = typeIntoCurrentTarget(firstCharacter)
|
|
512
|
+
activeTarget = activeTarget.withElement(firstTypedTarget)
|
|
513
|
+
let warmupExpectedText = expectedTextEntryValue(
|
|
514
|
+
typedText: firstCharacter,
|
|
515
|
+
mode: repairMode,
|
|
516
|
+
initialText: initialText
|
|
517
|
+
)
|
|
518
|
+
waitForWarmupValue(warmupExpectedText, target: activeTarget)
|
|
519
|
+
let remainingText = String(characters.dropFirst())
|
|
520
|
+
firstTypedTarget = typeIntoCurrentTarget(remainingText) ?? firstTypedTarget
|
|
521
|
+
typedTarget = firstTypedTarget
|
|
522
|
+
} else {
|
|
523
|
+
typedTarget = typeIntoCurrentTarget(text)
|
|
524
|
+
}
|
|
525
|
+
if repairMode == .none {
|
|
526
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: nil)
|
|
527
|
+
}
|
|
528
|
+
let repairResult = repairTextEntryIfNeeded(
|
|
529
|
+
app: app,
|
|
530
|
+
target: activeTarget.withElement(typedTarget),
|
|
531
|
+
expectedText: expectedText,
|
|
532
|
+
repairMode: repairMode
|
|
533
|
+
)
|
|
534
|
+
return verifyTextEntry(
|
|
535
|
+
app: app,
|
|
536
|
+
target: activeTarget.withElement(typedTarget),
|
|
537
|
+
expectedText: expectedText,
|
|
538
|
+
repaired: repairResult.repaired
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private func repairTextEntryIfNeeded(
|
|
543
|
+
app: XCUIApplication,
|
|
544
|
+
target: TextEntryTarget,
|
|
545
|
+
expectedText: String?,
|
|
546
|
+
repairMode: TextTypingRepairMode
|
|
547
|
+
) -> TextEntryResult {
|
|
548
|
+
#if os(iOS)
|
|
549
|
+
guard let targetElement = resolveTextEntryElement(app: app, target: target) else {
|
|
550
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
551
|
+
}
|
|
552
|
+
guard let expectedText else {
|
|
553
|
+
let observedText = editableTextValue(for: targetElement)
|
|
554
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: nil, observedText: observedText)
|
|
555
|
+
}
|
|
556
|
+
guard shouldRepairTextEntry(
|
|
557
|
+
app: app,
|
|
558
|
+
target: target,
|
|
559
|
+
expectedText: expectedText,
|
|
560
|
+
repairMode: repairMode
|
|
561
|
+
) else {
|
|
562
|
+
return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: false)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
guard let repairTarget = resolveTextEntryElement(app: app, target: target) else {
|
|
566
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
567
|
+
}
|
|
568
|
+
let observedText = editableTextValue(for: repairTarget) ?? ""
|
|
569
|
+
NSLog(
|
|
570
|
+
"AGENT_DEVICE_RUNNER_REPAIR_TEXT_ENTRY expectedLength=%d observedLength=%d",
|
|
571
|
+
expectedText.count,
|
|
572
|
+
observedText.count
|
|
573
|
+
)
|
|
574
|
+
clearTextInput(repairTarget)
|
|
575
|
+
app.typeText(expectedText)
|
|
576
|
+
return verifyTextEntry(app: app, target: target, expectedText: expectedText, repaired: true)
|
|
577
|
+
#else
|
|
578
|
+
return TextEntryResult(verified: nil, repaired: false, expectedText: expectedText, observedText: nil)
|
|
579
|
+
#endif
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private func verifyTextEntry(
|
|
583
|
+
app: XCUIApplication,
|
|
584
|
+
target: TextEntryTarget,
|
|
585
|
+
expectedText: String?,
|
|
586
|
+
repaired: Bool
|
|
587
|
+
) -> TextEntryResult {
|
|
588
|
+
let targetElement = resolveTextEntryElement(app: app, target: target)
|
|
589
|
+
guard let expectedText else {
|
|
590
|
+
return TextEntryResult(
|
|
591
|
+
verified: nil,
|
|
592
|
+
repaired: repaired,
|
|
593
|
+
expectedText: nil,
|
|
594
|
+
observedText: editableTextValue(for: targetElement)
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
guard let observedText = editableTextValue(for: targetElement) else {
|
|
598
|
+
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
|
|
599
|
+
}
|
|
600
|
+
guard observedText == expectedText else {
|
|
601
|
+
return TextEntryResult(
|
|
602
|
+
verified: false,
|
|
603
|
+
repaired: repaired,
|
|
604
|
+
expectedText: expectedText,
|
|
605
|
+
observedText: observedText
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
let stableDeadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
|
|
609
|
+
var latestObservedText = observedText
|
|
610
|
+
while Date() < stableDeadline {
|
|
611
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
612
|
+
guard let nextObservedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
|
|
613
|
+
return TextEntryResult(verified: nil, repaired: repaired, expectedText: expectedText, observedText: nil)
|
|
614
|
+
}
|
|
615
|
+
latestObservedText = nextObservedText
|
|
616
|
+
guard nextObservedText == expectedText else {
|
|
617
|
+
return TextEntryResult(
|
|
618
|
+
verified: false,
|
|
619
|
+
repaired: repaired,
|
|
620
|
+
expectedText: expectedText,
|
|
621
|
+
observedText: nextObservedText
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return TextEntryResult(
|
|
626
|
+
verified: true,
|
|
627
|
+
repaired: repaired,
|
|
628
|
+
expectedText: expectedText,
|
|
629
|
+
observedText: latestObservedText
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private func expectedTextEntryValue(
|
|
634
|
+
typedText: String,
|
|
635
|
+
mode: TextTypingRepairMode,
|
|
636
|
+
initialText: String?
|
|
637
|
+
) -> String? {
|
|
638
|
+
switch mode {
|
|
639
|
+
case .none:
|
|
640
|
+
return nil
|
|
641
|
+
case .append:
|
|
642
|
+
guard let initialText else {
|
|
643
|
+
return nil
|
|
644
|
+
}
|
|
645
|
+
return initialText + typedText
|
|
646
|
+
case .replacement:
|
|
647
|
+
return typedText
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private func shouldRepairTextEntry(
|
|
652
|
+
app: XCUIApplication,
|
|
653
|
+
target: TextEntryTarget,
|
|
654
|
+
expectedText: String,
|
|
655
|
+
repairMode: TextTypingRepairMode
|
|
656
|
+
) -> Bool {
|
|
657
|
+
#if os(iOS)
|
|
658
|
+
var latestObservedText: String?
|
|
659
|
+
let deadline = Date().addingTimeInterval(TextEntryTiming.verificationStabilityWindow)
|
|
660
|
+
repeat {
|
|
661
|
+
guard let observedText = editableTextValue(for: resolveTextEntryElement(app: app, target: target)) else {
|
|
662
|
+
return false
|
|
663
|
+
}
|
|
664
|
+
if observedText == expectedText {
|
|
665
|
+
return false
|
|
666
|
+
}
|
|
667
|
+
latestObservedText = observedText
|
|
668
|
+
if !isRepairableTextEntryMismatch(
|
|
669
|
+
observedText: observedText,
|
|
670
|
+
expectedText: expectedText,
|
|
671
|
+
repairMode: repairMode
|
|
672
|
+
) {
|
|
673
|
+
return false
|
|
674
|
+
}
|
|
675
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
676
|
+
} while Date() < deadline
|
|
677
|
+
|
|
678
|
+
guard let latestObservedText else {
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
guard latestObservedText != expectedText else {
|
|
682
|
+
return false
|
|
683
|
+
}
|
|
684
|
+
return isRepairableTextEntryMismatch(
|
|
685
|
+
observedText: latestObservedText,
|
|
686
|
+
expectedText: expectedText,
|
|
687
|
+
repairMode: repairMode
|
|
688
|
+
)
|
|
689
|
+
#else
|
|
690
|
+
return false
|
|
691
|
+
#endif
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private func isRepairableTextEntryMismatch(
|
|
695
|
+
observedText: String,
|
|
696
|
+
expectedText: String,
|
|
697
|
+
repairMode: TextTypingRepairMode
|
|
698
|
+
) -> Bool {
|
|
699
|
+
guard observedText != expectedText else {
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
if repairMode == .replacement {
|
|
703
|
+
return true
|
|
704
|
+
}
|
|
705
|
+
return observedText.isEmpty || isLikelyDroppedCharacterTextEntryMismatch(
|
|
706
|
+
observedText: observedText,
|
|
707
|
+
expectedText: expectedText
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private func isLikelyDroppedCharacterTextEntryMismatch(observedText: String, expectedText: String) -> Bool {
|
|
712
|
+
guard observedText.count < expectedText.count else {
|
|
713
|
+
return false
|
|
714
|
+
}
|
|
715
|
+
let missingCharacterCount = expectedText.count - observedText.count
|
|
716
|
+
guard missingCharacterCount <= max(2, expectedText.count / 4) else {
|
|
717
|
+
return false
|
|
718
|
+
}
|
|
719
|
+
var expectedIndex = expectedText.startIndex
|
|
720
|
+
for character in observedText {
|
|
721
|
+
guard let matchIndex = expectedText[expectedIndex...].firstIndex(of: character) else {
|
|
722
|
+
return false
|
|
723
|
+
}
|
|
724
|
+
expectedIndex = expectedText.index(after: matchIndex)
|
|
725
|
+
}
|
|
726
|
+
return true
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private func resolveTextEntryElement(app: XCUIApplication, target: TextEntryTarget) -> XCUIElement? {
|
|
730
|
+
if target.prefersFocusedElement {
|
|
731
|
+
if let focused = focusedTextInput(app: app) {
|
|
732
|
+
return focused
|
|
733
|
+
}
|
|
734
|
+
if let element = target.element, element.exists {
|
|
735
|
+
return element
|
|
736
|
+
}
|
|
737
|
+
} else {
|
|
738
|
+
if let element = target.element, element.exists {
|
|
739
|
+
return element
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if let refreshPoint = target.refreshPoint,
|
|
743
|
+
let refreshed = textInputAt(app: app, x: refreshPoint.x, y: refreshPoint.y) {
|
|
744
|
+
return refreshed
|
|
745
|
+
}
|
|
746
|
+
if let focused = focusedTextInput(app: app) {
|
|
747
|
+
return focused
|
|
748
|
+
}
|
|
749
|
+
return nil
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private func waitForTextEntryReadiness(
|
|
753
|
+
app: XCUIApplication,
|
|
754
|
+
target: TextEntryTarget,
|
|
755
|
+
timeout: TimeInterval = TextEntryTiming.readinessTimeout
|
|
756
|
+
) -> XCUIElement? {
|
|
757
|
+
#if os(iOS)
|
|
758
|
+
var latest = resolveTextEntryElement(app: app, target: target)
|
|
759
|
+
let deadline = Date().addingTimeInterval(timeout)
|
|
760
|
+
let hardwareKeyboardFallback = Date().addingTimeInterval(
|
|
761
|
+
min(TextEntryTiming.hardwareKeyboardFallbackTimeout, timeout)
|
|
762
|
+
)
|
|
763
|
+
var sawSoftwareKeyboard = false
|
|
764
|
+
while Date() < deadline {
|
|
765
|
+
if let focused = focusedTextInput(app: app) {
|
|
766
|
+
latest = focused
|
|
767
|
+
if isKeyboardVisible(app: app) {
|
|
768
|
+
return focused
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
sawSoftwareKeyboard = sawSoftwareKeyboard || keyboardElementExists(app: app)
|
|
772
|
+
if !sawSoftwareKeyboard && Date() >= hardwareKeyboardFallback && latest != nil {
|
|
773
|
+
return latest
|
|
774
|
+
}
|
|
775
|
+
sleepFor(TextEntryTiming.pollInterval)
|
|
776
|
+
}
|
|
777
|
+
return focusedTextInput(app: app) ?? latest
|
|
778
|
+
#else
|
|
779
|
+
return resolveTextEntryElement(app: app, target: target)
|
|
780
|
+
#endif
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private func textEntryRefreshPoint(for element: XCUIElement?) -> CGPoint? {
|
|
784
|
+
guard let element else {
|
|
785
|
+
return nil
|
|
786
|
+
}
|
|
787
|
+
let frame = element.frame
|
|
788
|
+
guard !frame.isEmpty else {
|
|
789
|
+
return nil
|
|
790
|
+
}
|
|
791
|
+
return CGPoint(x: frame.midX, y: frame.midY)
|
|
792
|
+
}
|
|
793
|
+
|
|
260
794
|
func isKeyboardVisible(app: XCUIApplication) -> Bool {
|
|
261
|
-
|
|
262
|
-
|
|
795
|
+
return visibleKeyboardFrame(app: app) != nil
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private func keyboardElementExists(app: XCUIApplication) -> Bool {
|
|
799
|
+
#if os(iOS)
|
|
800
|
+
var exists = false
|
|
801
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
802
|
+
exists = app.keyboards.firstMatch.exists
|
|
803
|
+
})
|
|
804
|
+
if let exceptionMessage {
|
|
805
|
+
NSLog(
|
|
806
|
+
"AGENT_DEVICE_RUNNER_KEYBOARD_EXISTS_IGNORED_EXCEPTION=%@",
|
|
807
|
+
exceptionMessage
|
|
808
|
+
)
|
|
809
|
+
return false
|
|
810
|
+
}
|
|
811
|
+
return exists
|
|
812
|
+
#else
|
|
813
|
+
return false
|
|
814
|
+
#endif
|
|
263
815
|
}
|
|
264
816
|
|
|
265
817
|
func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
|
|
@@ -268,6 +820,12 @@ extension RunnerTests {
|
|
|
268
820
|
return (wasVisible: false, dismissed: false, visible: false)
|
|
269
821
|
}
|
|
270
822
|
|
|
823
|
+
#if os(tvOS)
|
|
824
|
+
_ = pressTvRemote(.menu)
|
|
825
|
+
sleepFor(0.2)
|
|
826
|
+
let visible = isKeyboardVisible(app: app)
|
|
827
|
+
return (wasVisible: true, dismissed: !visible, visible: visible)
|
|
828
|
+
#else
|
|
271
829
|
let keyboard = app.keyboards.firstMatch
|
|
272
830
|
keyboard.swipeDown()
|
|
273
831
|
sleepFor(0.2)
|
|
@@ -282,10 +840,16 @@ extension RunnerTests {
|
|
|
282
840
|
}
|
|
283
841
|
|
|
284
842
|
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
|
|
843
|
+
#endif
|
|
285
844
|
}
|
|
286
845
|
|
|
287
846
|
private func tapKeyboardDismissControl(app: XCUIApplication) -> Bool {
|
|
288
|
-
|
|
847
|
+
#if os(tvOS)
|
|
848
|
+
return false
|
|
849
|
+
#else
|
|
850
|
+
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
289
853
|
for label in ["Hide keyboard", "Dismiss keyboard", "Done"] {
|
|
290
854
|
let candidates = [
|
|
291
855
|
app.keyboards.buttons[label],
|
|
@@ -313,6 +877,7 @@ extension RunnerTests {
|
|
|
313
877
|
}
|
|
314
878
|
}
|
|
315
879
|
return false
|
|
880
|
+
#endif
|
|
316
881
|
}
|
|
317
882
|
|
|
318
883
|
private func isKeyboardAccessoryControl(_ element: XCUIElement, keyboardFrame: CGRect) -> Bool {
|
|
@@ -324,6 +889,9 @@ extension RunnerTests {
|
|
|
324
889
|
}
|
|
325
890
|
|
|
326
891
|
private func moveCaretToEnd(element: XCUIElement) {
|
|
892
|
+
#if os(tvOS)
|
|
893
|
+
return
|
|
894
|
+
#else
|
|
327
895
|
let frame = element.frame
|
|
328
896
|
guard !frame.isEmpty else {
|
|
329
897
|
element.tap()
|
|
@@ -334,15 +902,53 @@ extension RunnerTests {
|
|
|
334
902
|
CGVector(dx: max(2, frame.width - 4), dy: max(2, frame.height / 2))
|
|
335
903
|
)
|
|
336
904
|
target.tap()
|
|
905
|
+
#endif
|
|
337
906
|
}
|
|
338
907
|
|
|
339
908
|
private func estimatedDeleteCount(for element: XCUIElement) -> Int {
|
|
340
|
-
let valueText =
|
|
341
|
-
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
909
|
+
let valueText = normalizedElementText(element.value)
|
|
342
910
|
let base = valueText.isEmpty ? 24 : (valueText.count + 8)
|
|
343
911
|
return max(24, min(120, base))
|
|
344
912
|
}
|
|
345
913
|
|
|
914
|
+
private func normalizedElementText(_ value: Any?) -> String {
|
|
915
|
+
String(describing: value ?? "")
|
|
916
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private func editableTextValue(
|
|
920
|
+
for element: XCUIElement?,
|
|
921
|
+
treatingPlaceholderAsEmpty: Bool = false
|
|
922
|
+
) -> String? {
|
|
923
|
+
guard let element else {
|
|
924
|
+
return nil
|
|
925
|
+
}
|
|
926
|
+
switch element.elementType {
|
|
927
|
+
case .textField, .searchField, .textView:
|
|
928
|
+
let value = String(describing: element.value ?? "")
|
|
929
|
+
if treatingPlaceholderAsEmpty && isPlaceholderValue(value, for: element) {
|
|
930
|
+
return ""
|
|
931
|
+
}
|
|
932
|
+
return value
|
|
933
|
+
case .secureTextField:
|
|
934
|
+
return nil
|
|
935
|
+
default:
|
|
936
|
+
return nil
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private func isPlaceholderValue(_ value: String, for element: XCUIElement) -> Bool {
|
|
941
|
+
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
942
|
+
guard !normalizedValue.isEmpty else {
|
|
943
|
+
return false
|
|
944
|
+
}
|
|
945
|
+
guard let placeholder = element.placeholderValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
946
|
+
!placeholder.isEmpty else {
|
|
947
|
+
return false
|
|
948
|
+
}
|
|
949
|
+
return normalizedValue == placeholder
|
|
950
|
+
}
|
|
951
|
+
|
|
346
952
|
private func readableText(for element: XCUIElement) -> String? {
|
|
347
953
|
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
348
954
|
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
@@ -379,49 +985,64 @@ extension RunnerTests {
|
|
|
379
985
|
return element.exists ? element : nil
|
|
380
986
|
}
|
|
381
987
|
|
|
382
|
-
func tapAt(app: XCUIApplication, x: Double, y: Double) {
|
|
383
|
-
let
|
|
384
|
-
|
|
988
|
+
func tapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
989
|
+
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "tap") {
|
|
990
|
+
return outcome
|
|
991
|
+
}
|
|
992
|
+
return performCoordinateTap(app: app, x: x, y: y)
|
|
385
993
|
}
|
|
386
994
|
|
|
387
995
|
func mouseClickAt(app: XCUIApplication, x: Double, y: Double, button: String) throws {
|
|
996
|
+
#if os(macOS)
|
|
388
997
|
let coordinate = interactionCoordinate(app: app, x: x, y: y)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
case "middle":
|
|
396
|
-
throw NSError(
|
|
397
|
-
domain: "AgentDeviceRunner",
|
|
398
|
-
code: 1,
|
|
399
|
-
userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
|
|
400
|
-
)
|
|
401
|
-
default:
|
|
402
|
-
throw NSError(
|
|
403
|
-
domain: "AgentDeviceRunner",
|
|
404
|
-
code: 1,
|
|
405
|
-
userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
|
|
406
|
-
)
|
|
407
|
-
}
|
|
408
|
-
#else
|
|
998
|
+
switch button {
|
|
999
|
+
case "primary":
|
|
1000
|
+
coordinate.tap()
|
|
1001
|
+
case "secondary":
|
|
1002
|
+
coordinate.rightClick()
|
|
1003
|
+
case "middle":
|
|
409
1004
|
throw NSError(
|
|
410
1005
|
domain: "AgentDeviceRunner",
|
|
411
1006
|
code: 1,
|
|
412
|
-
userInfo: [NSLocalizedDescriptionKey: "
|
|
1007
|
+
userInfo: [NSLocalizedDescriptionKey: "middle mouse button is not supported"]
|
|
413
1008
|
)
|
|
414
|
-
|
|
1009
|
+
default:
|
|
1010
|
+
throw NSError(
|
|
1011
|
+
domain: "AgentDeviceRunner",
|
|
1012
|
+
code: 1,
|
|
1013
|
+
userInfo: [NSLocalizedDescriptionKey: "unsupported mouse button: \(button)"]
|
|
1014
|
+
)
|
|
1015
|
+
}
|
|
1016
|
+
#elseif os(tvOS)
|
|
1017
|
+
throw NSError(
|
|
1018
|
+
domain: "AgentDeviceRunner",
|
|
1019
|
+
code: 1,
|
|
1020
|
+
userInfo: [NSLocalizedDescriptionKey: "mouseClick is not supported on tvOS"]
|
|
1021
|
+
)
|
|
1022
|
+
#else
|
|
1023
|
+
throw NSError(
|
|
1024
|
+
domain: "AgentDeviceRunner",
|
|
1025
|
+
code: 1,
|
|
1026
|
+
userInfo: [NSLocalizedDescriptionKey: "mouseClick is only supported on macOS"]
|
|
1027
|
+
)
|
|
1028
|
+
#endif
|
|
415
1029
|
}
|
|
416
1030
|
|
|
417
|
-
func doubleTapAt(app: XCUIApplication, x: Double, y: Double) {
|
|
418
|
-
let
|
|
419
|
-
|
|
1031
|
+
func doubleTapAt(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
1032
|
+
if let outcome = selectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), action: "double tap") {
|
|
1033
|
+
guard case .performed = outcome else { return outcome }
|
|
1034
|
+
sleepFor(0.1)
|
|
1035
|
+
_ = pressTvRemote(.select)
|
|
1036
|
+
return .performed
|
|
1037
|
+
}
|
|
1038
|
+
return performCoordinateDoubleTap(app: app, x: x, y: y)
|
|
420
1039
|
}
|
|
421
1040
|
|
|
422
|
-
func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) {
|
|
423
|
-
let
|
|
424
|
-
|
|
1041
|
+
func longPressAt(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
|
|
1042
|
+
if let outcome = longSelectFocusedTvElement(app: app, point: CGPoint(x: x, y: y), duration: duration) {
|
|
1043
|
+
return outcome
|
|
1044
|
+
}
|
|
1045
|
+
return performCoordinateLongPress(app: app, x: x, y: y, duration: duration)
|
|
425
1046
|
}
|
|
426
1047
|
|
|
427
1048
|
func dragAt(
|
|
@@ -431,10 +1052,79 @@ extension RunnerTests {
|
|
|
431
1052
|
x2: Double,
|
|
432
1053
|
y2: Double,
|
|
433
1054
|
holdDuration: TimeInterval
|
|
434
|
-
) {
|
|
435
|
-
|
|
436
|
-
let
|
|
437
|
-
|
|
1055
|
+
) -> RunnerInteractionOutcome {
|
|
1056
|
+
// tvOS has no coordinate drag. Preserve the direction as a focus move.
|
|
1057
|
+
let dx = x2 - x
|
|
1058
|
+
let dy = y2 - y
|
|
1059
|
+
let button: TvRemoteButton = abs(dx) > abs(dy)
|
|
1060
|
+
? (dx > 0 ? .right : .left)
|
|
1061
|
+
: (dy > 0 ? .down : .up)
|
|
1062
|
+
if pressTvRemote(button) {
|
|
1063
|
+
return .performed
|
|
1064
|
+
}
|
|
1065
|
+
return performCoordinateDrag(app: app, x: x, y: y, x2: x2, y2: y2, holdDuration: holdDuration)
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
func keyboardAvoidingDragPoints(
|
|
1069
|
+
app: XCUIApplication,
|
|
1070
|
+
x: Double,
|
|
1071
|
+
y: Double,
|
|
1072
|
+
x2: Double,
|
|
1073
|
+
y2: Double
|
|
1074
|
+
) -> DragPoints {
|
|
1075
|
+
let original = DragPoints(x: x, y: y, x2: x2, y2: y2)
|
|
1076
|
+
#if os(iOS)
|
|
1077
|
+
guard let keyboardFrame = visibleKeyboardFrame(app: app) else {
|
|
1078
|
+
return original
|
|
1079
|
+
}
|
|
1080
|
+
let minX = min(x, x2)
|
|
1081
|
+
let minY = min(y, y2)
|
|
1082
|
+
let gestureBounds = CGRect(
|
|
1083
|
+
x: CGFloat(minX),
|
|
1084
|
+
y: CGFloat(minY),
|
|
1085
|
+
width: CGFloat(max(abs(x2 - x), 1)),
|
|
1086
|
+
height: CGFloat(max(abs(y2 - y), 1))
|
|
1087
|
+
)
|
|
1088
|
+
guard gestureBounds.intersects(keyboardFrame) else {
|
|
1089
|
+
return original
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let window = app.windows.firstMatch
|
|
1093
|
+
let appFrame = window.exists && !window.frame.isEmpty ? window.frame : app.frame
|
|
1094
|
+
guard !appFrame.isEmpty else {
|
|
1095
|
+
return original
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let padding: Double = 12
|
|
1099
|
+
let targetMaxY = Double(keyboardFrame.minY) - padding
|
|
1100
|
+
let currentMaxY = max(y, y2)
|
|
1101
|
+
let shift = currentMaxY - targetMaxY
|
|
1102
|
+
guard shift > 0 else {
|
|
1103
|
+
return original
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let adjustedY = y - shift
|
|
1107
|
+
let adjustedY2 = y2 - shift
|
|
1108
|
+
guard min(adjustedY, adjustedY2) >= Double(appFrame.minY) + padding else {
|
|
1109
|
+
return original
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
NSLog(
|
|
1113
|
+
"AGENT_DEVICE_RUNNER_KEYBOARD_AVOIDING_DRAG from=(%.1f,%.1f)->(%.1f,%.1f) adjusted=(%.1f,%.1f)->(%.1f,%.1f) keyboardMinY=%.1f",
|
|
1114
|
+
x,
|
|
1115
|
+
y,
|
|
1116
|
+
x2,
|
|
1117
|
+
y2,
|
|
1118
|
+
x,
|
|
1119
|
+
adjustedY,
|
|
1120
|
+
x2,
|
|
1121
|
+
adjustedY2,
|
|
1122
|
+
Double(keyboardFrame.minY)
|
|
1123
|
+
)
|
|
1124
|
+
return DragPoints(x: x, y: adjustedY, x2: x2, y2: adjustedY2)
|
|
1125
|
+
#else
|
|
1126
|
+
return original
|
|
1127
|
+
#endif
|
|
438
1128
|
}
|
|
439
1129
|
|
|
440
1130
|
func resolvedTouchVisualizationFrame(app: XCUIApplication, x: Double, y: Double) -> TouchVisualizationFrame {
|
|
@@ -471,23 +1161,71 @@ extension RunnerTests {
|
|
|
471
1161
|
|
|
472
1162
|
func resolvedTouchReferenceFrame(app: XCUIApplication, appFrame: CGRect) -> CGRect {
|
|
473
1163
|
let window = app.windows.firstMatch
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1164
|
+
if window.exists {
|
|
1165
|
+
let windowFrame = window.frame
|
|
1166
|
+
if !windowFrame.isEmpty {
|
|
1167
|
+
return frameAvoidingKeyboard(app: app, frame: windowFrame)
|
|
1168
|
+
}
|
|
477
1169
|
}
|
|
478
1170
|
if !appFrame.isEmpty {
|
|
479
|
-
return appFrame
|
|
1171
|
+
return frameAvoidingKeyboard(app: app, frame: appFrame)
|
|
480
1172
|
}
|
|
481
1173
|
return CGRect(x: 0, y: 0, width: 0, height: 0)
|
|
482
1174
|
}
|
|
483
1175
|
|
|
1176
|
+
private func frameAvoidingKeyboard(app: XCUIApplication, frame: CGRect) -> CGRect {
|
|
1177
|
+
#if os(iOS)
|
|
1178
|
+
guard let keyboardFrame = visibleKeyboardFrame(app: app), !frame.isEmpty else {
|
|
1179
|
+
return frame
|
|
1180
|
+
}
|
|
1181
|
+
let intersection = frame.intersection(keyboardFrame)
|
|
1182
|
+
guard !intersection.isNull && intersection.height > 0 else {
|
|
1183
|
+
return frame
|
|
1184
|
+
}
|
|
1185
|
+
let keyboardCoverage = intersection.width / max(frame.width, 1)
|
|
1186
|
+
guard keyboardCoverage >= 0.5 else {
|
|
1187
|
+
return frame
|
|
1188
|
+
}
|
|
1189
|
+
let safeHeight = keyboardFrame.minY - frame.minY
|
|
1190
|
+
guard safeHeight >= frame.height * 0.25 else {
|
|
1191
|
+
return frame
|
|
1192
|
+
}
|
|
1193
|
+
return CGRect(x: frame.minX, y: frame.minY, width: frame.width, height: safeHeight)
|
|
1194
|
+
#else
|
|
1195
|
+
return frame
|
|
1196
|
+
#endif
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private func visibleKeyboardFrame(app: XCUIApplication) -> CGRect? {
|
|
1200
|
+
#if os(iOS)
|
|
1201
|
+
var frame: CGRect?
|
|
1202
|
+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
|
|
1203
|
+
let keyboard = app.keyboards.firstMatch
|
|
1204
|
+
guard keyboard.exists else { return }
|
|
1205
|
+
let keyboardFrame = keyboard.frame
|
|
1206
|
+
guard !keyboardFrame.isEmpty else { return }
|
|
1207
|
+
frame = keyboardFrame
|
|
1208
|
+
})
|
|
1209
|
+
if let exceptionMessage {
|
|
1210
|
+
NSLog(
|
|
1211
|
+
"AGENT_DEVICE_RUNNER_KEYBOARD_FRAME_IGNORED_EXCEPTION=%@",
|
|
1212
|
+
exceptionMessage
|
|
1213
|
+
)
|
|
1214
|
+
return nil
|
|
1215
|
+
}
|
|
1216
|
+
return frame
|
|
1217
|
+
#else
|
|
1218
|
+
return nil
|
|
1219
|
+
#endif
|
|
1220
|
+
}
|
|
1221
|
+
|
|
484
1222
|
func runSeries(count: Int, pauseMs: Double, operation: (Int) -> Void) {
|
|
485
1223
|
let total = max(count, 1)
|
|
486
1224
|
let pause = max(pauseMs, 0)
|
|
487
1225
|
for idx in 0..<total {
|
|
488
1226
|
operation(idx)
|
|
489
1227
|
if idx < total - 1 && pause > 0 {
|
|
490
|
-
|
|
1228
|
+
sleepFor(pause / 1000.0)
|
|
491
1229
|
}
|
|
492
1230
|
}
|
|
493
1231
|
}
|
|
@@ -510,26 +1248,28 @@ extension RunnerTests {
|
|
|
510
1248
|
}
|
|
511
1249
|
|
|
512
1250
|
private func performTvRemoteSwipeIfAvailable(direction: String) -> Bool {
|
|
513
|
-
#if os(tvOS)
|
|
514
1251
|
switch direction {
|
|
515
1252
|
case "up":
|
|
516
|
-
|
|
1253
|
+
return pressTvRemote(.up)
|
|
517
1254
|
case "down":
|
|
518
|
-
|
|
1255
|
+
return pressTvRemote(.down)
|
|
519
1256
|
case "left":
|
|
520
|
-
|
|
1257
|
+
return pressTvRemote(.left)
|
|
521
1258
|
case "right":
|
|
522
|
-
|
|
1259
|
+
return pressTvRemote(.right)
|
|
523
1260
|
default:
|
|
524
1261
|
return false
|
|
525
1262
|
}
|
|
526
|
-
return true
|
|
527
|
-
#else
|
|
528
|
-
return false
|
|
529
|
-
#endif
|
|
530
1263
|
}
|
|
531
1264
|
|
|
532
|
-
func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
|
|
1265
|
+
func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
|
|
1266
|
+
return performCoordinatePinch(app: app, scale: scale, x: x, y: y)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
private func performCoordinatePinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) -> RunnerInteractionOutcome {
|
|
1270
|
+
#if os(tvOS)
|
|
1271
|
+
return .unsupported("pinch is not supported on tvOS")
|
|
1272
|
+
#else
|
|
533
1273
|
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
|
|
534
1274
|
|
|
535
1275
|
// Use double-tap + drag gesture for reliable map zoom
|
|
@@ -560,6 +1300,8 @@ extension RunnerTests {
|
|
|
560
1300
|
|
|
561
1301
|
// Immediately press and drag (second tap + drag)
|
|
562
1302
|
center.press(forDuration: 0.05, thenDragTo: endPoint)
|
|
1303
|
+
return .performed
|
|
1304
|
+
#endif
|
|
563
1305
|
}
|
|
564
1306
|
|
|
565
1307
|
private func interactionRoot(app: XCUIApplication) -> XCUIElement {
|
|
@@ -570,6 +1312,52 @@ extension RunnerTests {
|
|
|
570
1312
|
return app
|
|
571
1313
|
}
|
|
572
1314
|
|
|
1315
|
+
private func performCoordinateTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
1316
|
+
#if os(tvOS)
|
|
1317
|
+
return .unsupported("coordinate tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
|
|
1318
|
+
#else
|
|
1319
|
+
interactionCoordinate(app: app, x: x, y: y).tap()
|
|
1320
|
+
return .performed
|
|
1321
|
+
#endif
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
private func performCoordinateDoubleTap(app: XCUIApplication, x: Double, y: Double) -> RunnerInteractionOutcome {
|
|
1325
|
+
#if os(tvOS)
|
|
1326
|
+
return .unsupported("coordinate double tap is not supported on tvOS; move focus with swipe or scroll, then select the focused element")
|
|
1327
|
+
#else
|
|
1328
|
+
interactionCoordinate(app: app, x: x, y: y).doubleTap()
|
|
1329
|
+
return .performed
|
|
1330
|
+
#endif
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
private func performCoordinateLongPress(app: XCUIApplication, x: Double, y: Double, duration: TimeInterval) -> RunnerInteractionOutcome {
|
|
1334
|
+
#if os(tvOS)
|
|
1335
|
+
return .unsupported("coordinate long press is not supported on tvOS; move focus with swipe or scroll, then long-select the focused element")
|
|
1336
|
+
#else
|
|
1337
|
+
interactionCoordinate(app: app, x: x, y: y).press(forDuration: duration)
|
|
1338
|
+
return .performed
|
|
1339
|
+
#endif
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private func performCoordinateDrag(
|
|
1343
|
+
app: XCUIApplication,
|
|
1344
|
+
x: Double,
|
|
1345
|
+
y: Double,
|
|
1346
|
+
x2: Double,
|
|
1347
|
+
y2: Double,
|
|
1348
|
+
holdDuration: TimeInterval
|
|
1349
|
+
) -> RunnerInteractionOutcome {
|
|
1350
|
+
#if os(tvOS)
|
|
1351
|
+
return .unsupported("coordinate drag is not supported on tvOS")
|
|
1352
|
+
#else
|
|
1353
|
+
let start = interactionCoordinate(app: app, x: x, y: y)
|
|
1354
|
+
let end = interactionCoordinate(app: app, x: x2, y: y2)
|
|
1355
|
+
start.press(forDuration: holdDuration, thenDragTo: end)
|
|
1356
|
+
return .performed
|
|
1357
|
+
#endif
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
#if !os(tvOS)
|
|
573
1361
|
private func interactionCoordinate(app: XCUIApplication, x: Double, y: Double) -> XCUICoordinate {
|
|
574
1362
|
let root = interactionRoot(app: app)
|
|
575
1363
|
let origin = root.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0))
|
|
@@ -578,14 +1366,17 @@ extension RunnerTests {
|
|
|
578
1366
|
let offsetY = y - Double(rootFrame.origin.y)
|
|
579
1367
|
return origin.withOffset(CGVector(dx: offsetX, dy: offsetY))
|
|
580
1368
|
}
|
|
1369
|
+
#endif
|
|
581
1370
|
|
|
582
1371
|
private func tapElementCenter(app: XCUIApplication, element: XCUIElement) {
|
|
583
1372
|
let frame = element.frame
|
|
584
1373
|
if !frame.isEmpty {
|
|
585
|
-
tapAt(app: app, x: frame.midX, y: frame.midY)
|
|
1374
|
+
_ = tapAt(app: app, x: frame.midX, y: frame.midY)
|
|
586
1375
|
return
|
|
587
1376
|
}
|
|
1377
|
+
#if !os(tvOS)
|
|
588
1378
|
element.tap()
|
|
1379
|
+
#endif
|
|
589
1380
|
}
|
|
590
1381
|
|
|
591
1382
|
private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
|