@swmansion/argent 0.23.0 → 0.23.1-next.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 +1 -1
- package/bin/argent-android-devtools-0.1.0.apk +0 -0
- package/bin/darwin/ax-service +0 -0
- package/bin/darwin/tvos-ax-service +0 -0
- package/bin/darwin/tvos-hid-daemon +0 -0
- package/bin/tcp/ax-service +0 -0
- package/dist/cli-cmds.mjs +17 -2
- package/dist/installer.mjs +17 -2
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner/RunnerHostApp.swift +37 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.pbxproj +389 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/xcshareddata/xcschemes/ArgentRunner.xcscheme +88 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.h +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.m +16 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Commands.swift +280 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift +149 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift +21 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift +327 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift +157 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession.swift +410 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerUITests-Bridging-Header.h +1 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/CommandJournal.swift +134 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/MainThreadGate.swift +111 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerHTTPServer.swift +267 -0
- package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerProtocol.swift +331 -0
- package/dist/mcp-server.mjs +16 -1
- package/dist/tool-server.cjs +4224 -1388
- package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/libKeyboardPatch.dylib +0 -0
- package/dylibs/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
- package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
- package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
- package/package.json +1 -1
- package/rules/argent.md +8 -3
- package/skills/argent-create-flow/SKILL.md +1 -0
- package/skills/argent-device-interact/SKILL.md +2 -0
- package/skills/argent-ios-device-interact/SKILL.md +18 -0
- package/skills/argent-ios-device-setup/SKILL.md +20 -0
- package/skills/argent-qa-flows/SKILL.md +2 -0
- package/skills/argent-screenshot-diff/SKILL.md +2 -0
package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension ArgentRunnerSession {
|
|
4
|
+
/// Element types an agent can act on directly. These are always included.
|
|
5
|
+
static let interactiveTypes: Set<XCUIElement.ElementType> = [
|
|
6
|
+
.button, .cell, .checkBox, .collectionView, .datePicker, .link,
|
|
7
|
+
.menuItem,
|
|
8
|
+
.picker, .pickerWheel, .searchField, .segmentedControl, .slider,
|
|
9
|
+
.stepper,
|
|
10
|
+
.switch, .tabBar, .textField, .secureTextField, .textView, .toggle,
|
|
11
|
+
.webView,
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
/// Containers whose content scrolls. Included even when unlabeled so the
|
|
15
|
+
/// tree shows where scrolling is possible.
|
|
16
|
+
static let scrollContainerTypes: Set<XCUIElement.ElementType> = [
|
|
17
|
+
.scrollView, .table, .collectionView, .webView,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/// Hard ceiling on emitted nodes. Bounds the reply size for very deep trees.
|
|
21
|
+
static let snapshotNodeBudget = 1500
|
|
22
|
+
|
|
23
|
+
/// Guard against cyclic or extremely deep raw trees.
|
|
24
|
+
private static let rawDepthLimit = 100
|
|
25
|
+
|
|
26
|
+
/// Ceiling on emitted node depth.
|
|
27
|
+
private static let emittedDepthLimit = 60
|
|
28
|
+
|
|
29
|
+
/// Captures the app's accessibility tree and flattens it into a snapshot
|
|
30
|
+
/// payload. The capture is retried once after a short pause: this is the
|
|
31
|
+
/// one runner-side retry, kept here because the capture can read its own
|
|
32
|
+
/// failure, a thrown error or an accessibility exception from a tree
|
|
33
|
+
/// that moved mid-capture, and a moment is usually enough for the tree
|
|
34
|
+
/// to settle. performOnMain only guards; the host retries read-only sends.
|
|
35
|
+
func captureSnapshot(of app: XCUIApplication) -> Envelope {
|
|
36
|
+
var root: XCUIElementSnapshot?
|
|
37
|
+
var lastError = ""
|
|
38
|
+
|
|
39
|
+
for attempt in 0..<2 {
|
|
40
|
+
if attempt > 0 {
|
|
41
|
+
NSLog("ARGENT_RUNNER_RETRY command=snapshot reason=%@", lastError)
|
|
42
|
+
Thread.sleep(forTimeInterval: 0.4)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// One XPC round trip captures the whole tree. Flattening it in-process
|
|
46
|
+
// avoids per-element AX queries and their stalls. The capture can throw
|
|
47
|
+
// a Swift error or raise an accessibility NSException; the guard turns
|
|
48
|
+
// the exception into a description instead of a crash.
|
|
49
|
+
let exceptionDescription = ArgentExceptionGuard.runCatching {
|
|
50
|
+
do {
|
|
51
|
+
root = try app.snapshot()
|
|
52
|
+
} catch {
|
|
53
|
+
lastError = String(describing: error)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if let exceptionDescription {
|
|
58
|
+
lastError = exceptionDescription
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if root != nil { break }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
guard let root else {
|
|
65
|
+
return .failure(
|
|
66
|
+
.snapshotFailed,
|
|
67
|
+
"XCTest could not capture the accessibility tree: \(lastError)",
|
|
68
|
+
hint:
|
|
69
|
+
"Retry after the UI settles, or use screenshot as visual truth."
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let nodes = Self.flatten(root)
|
|
74
|
+
let capped = nodes.count >= Self.snapshotNodeBudget
|
|
75
|
+
|
|
76
|
+
return .success(
|
|
77
|
+
SnapshotPayload(
|
|
78
|
+
nodes: nodes,
|
|
79
|
+
quality: SnapshotQualityPayload(
|
|
80
|
+
state: capped ? "degraded" : "healthy",
|
|
81
|
+
backend: "xctest",
|
|
82
|
+
reason: capped
|
|
83
|
+
? "node budget reached; deeper content was dropped"
|
|
84
|
+
: nil,
|
|
85
|
+
reasonCode: capped ? "node_cap" : nil
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Flattens the snapshot tree into an ordered node list. Keeps elements an
|
|
92
|
+
/// agent can name or act on, assigns indices and parent links in emission
|
|
93
|
+
/// order, and dedupes mirror elements.
|
|
94
|
+
static func flatten(_ root: XCUIElementSnapshot) -> [SnapshotNodePayload] {
|
|
95
|
+
struct WorkItem {
|
|
96
|
+
let snapshot: XCUIElementSnapshot
|
|
97
|
+
let rawDepth: Int
|
|
98
|
+
let emittedDepth: Int
|
|
99
|
+
let parentIndex: Int
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let viewport = root.frame
|
|
103
|
+
var nodes = [makeNode(root, index: 0, depth: 0, parentIndex: nil)]
|
|
104
|
+
var seen: Set<String> = [identity(root)]
|
|
105
|
+
|
|
106
|
+
var stack = root.children.reversed().map {
|
|
107
|
+
WorkItem(snapshot: $0, rawDepth: 1, emittedDepth: 1, parentIndex: 0)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
while let item = stack.popLast() {
|
|
111
|
+
if nodes.count >= snapshotNodeBudget { break }
|
|
112
|
+
if item.rawDepth > rawDepthLimit { continue }
|
|
113
|
+
|
|
114
|
+
let snapshot = item.snapshot
|
|
115
|
+
let frame = snapshot.frame
|
|
116
|
+
let visible =
|
|
117
|
+
viewport.isEmpty
|
|
118
|
+
|| (!frame.isEmpty && viewport.intersects(frame))
|
|
119
|
+
let include =
|
|
120
|
+
visible && item.emittedDepth <= emittedDepthLimit
|
|
121
|
+
&& shouldInclude(snapshot)
|
|
122
|
+
let key = identity(snapshot)
|
|
123
|
+
let duplicate = seen.contains(key)
|
|
124
|
+
|
|
125
|
+
var childParentIndex = item.parentIndex
|
|
126
|
+
var childEmittedDepth = item.emittedDepth
|
|
127
|
+
|
|
128
|
+
if include && !duplicate {
|
|
129
|
+
seen.insert(key)
|
|
130
|
+
|
|
131
|
+
let index = nodes.count
|
|
132
|
+
|
|
133
|
+
nodes.append(
|
|
134
|
+
makeNode(
|
|
135
|
+
snapshot,
|
|
136
|
+
index: index,
|
|
137
|
+
depth: item.emittedDepth,
|
|
138
|
+
parentIndex: item.parentIndex
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
childParentIndex = index
|
|
143
|
+
childEmittedDepth += 1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
for child in snapshot.children.reversed() {
|
|
147
|
+
stack.append(
|
|
148
|
+
WorkItem(
|
|
149
|
+
snapshot: child,
|
|
150
|
+
rawDepth: item.rawDepth + 1,
|
|
151
|
+
emittedDepth: childEmittedDepth,
|
|
152
|
+
parentIndex: childParentIndex
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return nodes
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Whether an element earns a node: interactive, a scroll container, or
|
|
162
|
+
/// carrying any text.
|
|
163
|
+
private static func shouldInclude(_ snapshot: XCUIElementSnapshot) -> Bool {
|
|
164
|
+
if interactiveTypes.contains(snapshot.elementType) { return true }
|
|
165
|
+
if scrollContainerTypes.contains(snapshot.elementType) { return true }
|
|
166
|
+
|
|
167
|
+
return !snapshot.label.isEmpty || !snapshot.identifier.isEmpty
|
|
168
|
+
|| valueText(snapshot.value) != nil
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Converts one element snapshot into a wire node payload.
|
|
172
|
+
private static func makeNode(
|
|
173
|
+
_ snapshot: XCUIElementSnapshot,
|
|
174
|
+
index: Int,
|
|
175
|
+
depth: Int,
|
|
176
|
+
parentIndex: Int?
|
|
177
|
+
) -> SnapshotNodePayload {
|
|
178
|
+
let frame = snapshot.frame
|
|
179
|
+
|
|
180
|
+
return SnapshotNodePayload(
|
|
181
|
+
index: index,
|
|
182
|
+
type: elementTypeName(snapshot.elementType),
|
|
183
|
+
label: snapshot.label.isEmpty ? nil : snapshot.label,
|
|
184
|
+
identifier: snapshot.identifier.isEmpty ? nil : snapshot.identifier,
|
|
185
|
+
value: valueText(snapshot.value),
|
|
186
|
+
// A geometry-less element reports CGRect.null with an infinite origin,
|
|
187
|
+
// and JSONEncoder rejects non-finite doubles. Coordinates are forced
|
|
188
|
+
// finite so one such node cannot fail the whole encode.
|
|
189
|
+
rect: SnapshotRect(
|
|
190
|
+
x: finite(frame.minX),
|
|
191
|
+
y: finite(frame.minY),
|
|
192
|
+
width: finite(frame.width),
|
|
193
|
+
height: finite(frame.height)
|
|
194
|
+
),
|
|
195
|
+
enabled: snapshot.isEnabled,
|
|
196
|
+
focused: snapshot.hasFocus ? true : nil,
|
|
197
|
+
selected: snapshot.isSelected ? true : nil,
|
|
198
|
+
depth: depth,
|
|
199
|
+
parentIndex: parentIndex
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// Dedup key for one node. Mirror elements surfaced twice by the AX tree
|
|
204
|
+
/// share type, texts, and geometry and collapse to the same key.
|
|
205
|
+
private static func identity(_ snapshot: XCUIElementSnapshot) -> String {
|
|
206
|
+
let frame = snapshot.frame
|
|
207
|
+
|
|
208
|
+
return
|
|
209
|
+
"\(snapshot.elementType.rawValue)|\(snapshot.label)|\(snapshot.identifier)|"
|
|
210
|
+
+ "\(keyCoordinate(frame.minX)),\(keyCoordinate(frame.minY)),"
|
|
211
|
+
+ "\(keyCoordinate(frame.width)),\(keyCoordinate(frame.height))"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Stable dedup-key text for one frame coordinate. The key needs to be
|
|
215
|
+
/// stable, not exact.
|
|
216
|
+
private static func keyCoordinate(_ v: CGFloat) -> String {
|
|
217
|
+
// Int(_: Double) traps on non-finite or out-of-range input, and a trap
|
|
218
|
+
// kills the runner process. Geometry-less elements do reach here with
|
|
219
|
+
// CGRect.null frames, so every conversion must be total.
|
|
220
|
+
guard v.isFinite else { return String(describing: v) }
|
|
221
|
+
|
|
222
|
+
return String(Int(min(max(v.rounded(), -1e15), 1e15)))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// Collapses a non-finite coordinate to 0 for the wire payload.
|
|
226
|
+
private static func finite(_ v: CGFloat) -> Double {
|
|
227
|
+
v.isFinite ? Double(v) : 0
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// The element's value as wire text, nil when absent or empty.
|
|
231
|
+
private static func valueText(_ value: Any?) -> String? {
|
|
232
|
+
guard let value, !(value is NSNull) else { return nil }
|
|
233
|
+
let text = String(describing: value)
|
|
234
|
+
return text.isEmpty ? nil : text
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// Stable type names for the wire payload. The TypeScript describe adapter
|
|
238
|
+
/// maps them onto accessibility roles.
|
|
239
|
+
static func elementTypeName(_ type: XCUIElement.ElementType) -> String {
|
|
240
|
+
switch type {
|
|
241
|
+
case .any: return "Any"
|
|
242
|
+
case .other: return "Other"
|
|
243
|
+
case .application: return "Application"
|
|
244
|
+
case .group: return "Group"
|
|
245
|
+
case .window: return "Window"
|
|
246
|
+
case .sheet: return "Sheet"
|
|
247
|
+
case .drawer: return "Drawer"
|
|
248
|
+
case .alert: return "Alert"
|
|
249
|
+
case .dialog: return "Dialog"
|
|
250
|
+
case .button: return "Button"
|
|
251
|
+
case .radioButton: return "RadioButton"
|
|
252
|
+
case .radioGroup: return "RadioGroup"
|
|
253
|
+
case .checkBox: return "CheckBox"
|
|
254
|
+
case .disclosureTriangle: return "DisclosureTriangle"
|
|
255
|
+
case .popUpButton: return "PopUpButton"
|
|
256
|
+
case .comboBox: return "ComboBox"
|
|
257
|
+
case .menuButton: return "MenuButton"
|
|
258
|
+
case .toolbarButton: return "ToolbarButton"
|
|
259
|
+
case .popover: return "Popover"
|
|
260
|
+
case .keyboard: return "Keyboard"
|
|
261
|
+
case .key: return "Key"
|
|
262
|
+
case .navigationBar: return "NavigationBar"
|
|
263
|
+
case .tabBar: return "TabBar"
|
|
264
|
+
case .tabGroup: return "TabGroup"
|
|
265
|
+
case .toolbar: return "Toolbar"
|
|
266
|
+
case .statusBar: return "StatusBar"
|
|
267
|
+
case .table: return "Table"
|
|
268
|
+
case .tableRow: return "TableRow"
|
|
269
|
+
case .tableColumn: return "TableColumn"
|
|
270
|
+
case .outline: return "Outline"
|
|
271
|
+
case .outlineRow: return "OutlineRow"
|
|
272
|
+
case .browser: return "Browser"
|
|
273
|
+
case .collectionView: return "CollectionView"
|
|
274
|
+
case .slider: return "Slider"
|
|
275
|
+
case .pageIndicator: return "PageIndicator"
|
|
276
|
+
case .progressIndicator: return "ProgressIndicator"
|
|
277
|
+
case .activityIndicator: return "ActivityIndicator"
|
|
278
|
+
case .segmentedControl: return "SegmentedControl"
|
|
279
|
+
case .picker: return "Picker"
|
|
280
|
+
case .pickerWheel: return "PickerWheel"
|
|
281
|
+
case .switch: return "Switch"
|
|
282
|
+
case .toggle: return "Toggle"
|
|
283
|
+
case .link: return "Link"
|
|
284
|
+
case .image: return "Image"
|
|
285
|
+
case .icon: return "Icon"
|
|
286
|
+
case .searchField: return "SearchField"
|
|
287
|
+
case .scrollView: return "ScrollView"
|
|
288
|
+
case .scrollBar: return "ScrollBar"
|
|
289
|
+
case .staticText: return "StaticText"
|
|
290
|
+
case .textField: return "TextField"
|
|
291
|
+
case .secureTextField: return "SecureTextField"
|
|
292
|
+
case .datePicker: return "DatePicker"
|
|
293
|
+
case .textView: return "TextView"
|
|
294
|
+
case .menu: return "Menu"
|
|
295
|
+
case .menuItem: return "MenuItem"
|
|
296
|
+
case .menuBar: return "MenuBar"
|
|
297
|
+
case .menuBarItem: return "MenuBarItem"
|
|
298
|
+
case .map: return "Map"
|
|
299
|
+
case .webView: return "WebView"
|
|
300
|
+
case .incrementArrow: return "IncrementArrow"
|
|
301
|
+
case .decrementArrow: return "DecrementArrow"
|
|
302
|
+
case .timeline: return "Timeline"
|
|
303
|
+
case .ratingIndicator: return "RatingIndicator"
|
|
304
|
+
case .valueIndicator: return "ValueIndicator"
|
|
305
|
+
case .splitGroup: return "SplitGroup"
|
|
306
|
+
case .splitter: return "Splitter"
|
|
307
|
+
case .relevanceIndicator: return "RelevanceIndicator"
|
|
308
|
+
case .colorWell: return "ColorWell"
|
|
309
|
+
case .helpTag: return "HelpTag"
|
|
310
|
+
case .matte: return "Matte"
|
|
311
|
+
case .dockItem: return "DockItem"
|
|
312
|
+
case .ruler: return "Ruler"
|
|
313
|
+
case .rulerMarker: return "RulerMarker"
|
|
314
|
+
case .grid: return "Grid"
|
|
315
|
+
case .levelIndicator: return "LevelIndicator"
|
|
316
|
+
case .cell: return "Cell"
|
|
317
|
+
case .layoutArea: return "LayoutArea"
|
|
318
|
+
case .layoutItem: return "LayoutItem"
|
|
319
|
+
case .handle: return "Handle"
|
|
320
|
+
case .stepper: return "Stepper"
|
|
321
|
+
case .tab: return "Tab"
|
|
322
|
+
case .touchBar: return "TouchBar"
|
|
323
|
+
case .statusItem: return "StatusItem"
|
|
324
|
+
@unknown default: return "Other"
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
|
|
3
|
+
extension ArgentRunnerSession {
|
|
4
|
+
/// The dedicated answer for typing with no first responder.
|
|
5
|
+
private static func textInputNotFocusedEnvelope() -> Envelope {
|
|
6
|
+
.failure(
|
|
7
|
+
.textInputNotFocused,
|
|
8
|
+
"no text input has keyboard focus",
|
|
9
|
+
hint: "Tap the target input first, then retry."
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Whether any element in the target app currently has keyboard focus.
|
|
14
|
+
/// One scoped element query answers it. nil when the probe itself failed
|
|
15
|
+
/// (a degraded tree must not block typing; the recorded-failure audit in
|
|
16
|
+
/// performOnMain still covers the attempt).
|
|
17
|
+
private func hasKeyboardFocus(in app: XCUIApplication) -> Bool? {
|
|
18
|
+
var focused: Bool?
|
|
19
|
+
let exceptionDescription = ArgentExceptionGuard.runCatching {
|
|
20
|
+
focused =
|
|
21
|
+
app.descendants(matching: .any)
|
|
22
|
+
.matching(NSPredicate(format: "hasKeyboardFocus == true"))
|
|
23
|
+
.firstMatch.exists
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return exceptionDescription == nil ? focused : nil
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// Types text into whatever element currently has keyboard focus. The tool
|
|
30
|
+
/// layer focuses an input with a tap before sending a type command.
|
|
31
|
+
func performType(_ request: CommandRequest, on app: XCUIApplication)
|
|
32
|
+
-> Envelope
|
|
33
|
+
{
|
|
34
|
+
guard let text = request.text, !text.isEmpty else {
|
|
35
|
+
return .failure(.invalidRequest, "type requires non-empty text")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Wait for the keyboard's presentation animation to finish.
|
|
39
|
+
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
40
|
+
|
|
41
|
+
return typeGuarded(
|
|
42
|
+
text,
|
|
43
|
+
in: app,
|
|
44
|
+
success: "typed \(text.count) characters",
|
|
45
|
+
failure: "unable to type"
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Matches the keyboard's return key by label across the UIReturnKeyType
|
|
50
|
+
/// variants an app can configure. Case-insensitive, so the lowercase "go"
|
|
51
|
+
/// and "search" some keyboards show need no entry of their own. An OR of
|
|
52
|
+
/// `==[c]` terms rather than `IN[c]`: Foundation ignores the [c] option
|
|
53
|
+
/// on IN (checked on macOS, where "Return" fails `label IN[c] {"return"}`).
|
|
54
|
+
private static let submitKeyPredicate = NSCompoundPredicate(
|
|
55
|
+
orPredicateWithSubpredicates: [
|
|
56
|
+
"return", "enter", "go", "search", "next", "done", "send", "join",
|
|
57
|
+
"continue",
|
|
58
|
+
].map { NSPredicate(format: "label ==[c] %@", $0) }
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
/// Presses the keyboard's return or submit key.
|
|
62
|
+
func performKeyboardReturn(on app: XCUIApplication) -> Envelope {
|
|
63
|
+
// Prefer tapping the visible submit key. Its label carries the action the
|
|
64
|
+
// app configured, such as Search or Go, and tapping works even when
|
|
65
|
+
// typeText rejects the focus state.
|
|
66
|
+
let keyboard = app.keyboards.firstMatch
|
|
67
|
+
|
|
68
|
+
// Query only when a keyboard is visibly up, and then with one predicate
|
|
69
|
+
// query per key collection. A label-by-label scan cost an exists and an
|
|
70
|
+
// isHittable probe per candidate, each a live AX query, and on a heavy
|
|
71
|
+
// screen the scan alone approached the 30s watchdog.
|
|
72
|
+
if keyboard.exists && !keyboard.frame.isEmpty {
|
|
73
|
+
let submitKey = Self.submitKeyPredicate
|
|
74
|
+
|
|
75
|
+
for candidate in [
|
|
76
|
+
app.keyboards.buttons.matching(submitKey).firstMatch,
|
|
77
|
+
app.keyboards.keys.matching(submitKey).firstMatch,
|
|
78
|
+
] where candidate.exists && candidate.isHittable {
|
|
79
|
+
let label = candidate.label
|
|
80
|
+
candidate.tap()
|
|
81
|
+
|
|
82
|
+
return .success(
|
|
83
|
+
MessagePayload(message: "pressed keyboard \(label)")
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// No visible keyboard key matched, so the return character goes
|
|
89
|
+
// through typeText, behind the same focus probe as `type`.
|
|
90
|
+
return typeGuarded(
|
|
91
|
+
XCUIKeyboardKey.return.rawValue,
|
|
92
|
+
in: app,
|
|
93
|
+
success: "typed return",
|
|
94
|
+
failure: "unable to press the keyboard return key"
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Presses the keyboard's delete key once. Unlike return there is no
|
|
99
|
+
/// labeled key to prefer, so the delete character always goes through
|
|
100
|
+
/// typeText.
|
|
101
|
+
func performKeyboardDelete(on app: XCUIApplication) -> Envelope {
|
|
102
|
+
// Wait for the keyboard's presentation animation to finish.
|
|
103
|
+
_ = app.keyboards.firstMatch.waitForExistence(timeout: 3)
|
|
104
|
+
|
|
105
|
+
return typeGuarded(
|
|
106
|
+
XCUIKeyboardKey.delete.rawValue,
|
|
107
|
+
in: app,
|
|
108
|
+
success: "typed delete",
|
|
109
|
+
failure: "unable to press the keyboard delete key"
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Types `text` into the first responder behind the focus probe and the
|
|
114
|
+
/// exception guard, and maps the outcome onto the wire. On hardware,
|
|
115
|
+
/// typeText with no first responder RECORDS an XCTest failure instead of
|
|
116
|
+
/// throwing, so the exception branch alone never fires and the reply
|
|
117
|
+
/// would demote to the generic XCTEST_RECORDED_FAILURE (audited on an
|
|
118
|
+
/// iPhone 15). The probe answers the common case with the dedicated code;
|
|
119
|
+
/// only a probe that positively finds no focus refuses to type. typeText
|
|
120
|
+
/// itself targets the first responder directly, with no element
|
|
121
|
+
/// resolution, so typing works on screens whose accessibility trees
|
|
122
|
+
/// degrade.
|
|
123
|
+
private func typeGuarded(
|
|
124
|
+
_ text: String,
|
|
125
|
+
in app: XCUIApplication,
|
|
126
|
+
success message: String,
|
|
127
|
+
failure context: String
|
|
128
|
+
) -> Envelope {
|
|
129
|
+
if hasKeyboardFocus(in: app) == false {
|
|
130
|
+
return Self.textInputNotFocusedEnvelope()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let exceptionDescription = ArgentExceptionGuard.runCatching {
|
|
134
|
+
app.typeText(text)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if let exceptionDescription {
|
|
138
|
+
// Backstop for the runtimes where a missing first responder does
|
|
139
|
+
// throw instead of recording.
|
|
140
|
+
if exceptionDescription.contains("keyboard focus") {
|
|
141
|
+
return Self.textInputNotFocusedEnvelope()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// XCTest throwing mid-command is a failed command, not hardware
|
|
145
|
+
// the device lacks, so it answers COMMAND_FAILED like every other
|
|
146
|
+
// command's exception does.
|
|
147
|
+
return .failure(
|
|
148
|
+
.commandFailed,
|
|
149
|
+
"\(context): \(exceptionDescription)",
|
|
150
|
+
hint:
|
|
151
|
+
"Re-observe the screen, tap the target input if it lost focus, then retry."
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return .success(MessagePayload(message: message))
|
|
156
|
+
}
|
|
157
|
+
}
|