@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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/bin/argent-android-devtools-0.1.0.apk +0 -0
  3. package/bin/darwin/ax-service +0 -0
  4. package/bin/darwin/tvos-ax-service +0 -0
  5. package/bin/darwin/tvos-hid-daemon +0 -0
  6. package/bin/tcp/ax-service +0 -0
  7. package/dist/cli-cmds.mjs +17 -2
  8. package/dist/installer.mjs +17 -2
  9. package/dist/ios-device-runner/ArgentRunner/ArgentRunner/RunnerHostApp.swift +37 -0
  10. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.pbxproj +389 -0
  11. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  12. package/dist/ios-device-runner/ArgentRunner/ArgentRunner.xcodeproj/xcshareddata/xcschemes/ArgentRunner.xcscheme +88 -0
  13. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.h +16 -0
  14. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentExceptionGuard.m +16 -0
  15. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Commands.swift +280 -0
  16. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift +149 -0
  17. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift +21 -0
  18. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Snapshot.swift +327 -0
  19. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+TextEntry.swift +157 -0
  20. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession.swift +410 -0
  21. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerUITests-Bridging-Header.h +1 -0
  22. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/CommandJournal.swift +134 -0
  23. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/MainThreadGate.swift +111 -0
  24. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerHTTPServer.swift +267 -0
  25. package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/RunnerProtocol.swift +331 -0
  26. package/dist/mcp-server.mjs +16 -1
  27. package/dist/tool-server.cjs +4224 -1388
  28. package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
  29. package/dylibs/libKeyboardPatch.dylib +0 -0
  30. package/dylibs/libNativeDevtoolsIos.dylib +0 -0
  31. package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
  32. package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
  33. package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
  34. package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
  35. package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
  36. package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
  37. package/package.json +1 -1
  38. package/rules/argent.md +8 -3
  39. package/skills/argent-create-flow/SKILL.md +1 -0
  40. package/skills/argent-device-interact/SKILL.md +2 -0
  41. package/skills/argent-ios-device-interact/SKILL.md +18 -0
  42. package/skills/argent-ios-device-setup/SKILL.md +20 -0
  43. package/skills/argent-qa-flows/SKILL.md +2 -0
  44. package/skills/argent-screenshot-diff/SKILL.md +2 -0
@@ -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
+ }
@@ -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
+ }