@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,280 @@
1
+ import UIKit
2
+ import XCTest
3
+
4
+ extension DeviceButton {
5
+ /// The XCUIDevice member for this wire name.
6
+ var hardwareButton: XCUIDevice.Button {
7
+ switch self {
8
+ case .home: return .home
9
+ case .volumeUp: return .volumeUp
10
+ case .volumeDown: return .volumeDown
11
+ case .actionButton: return .action
12
+ }
13
+ }
14
+ }
15
+
16
+ extension ArgentRunnerSession {
17
+ enum TargetResolution {
18
+ /// `reactivated` is true when the target was alive but backgrounded and
19
+ /// the runner had to re-front it for this command.
20
+ case ready(XCUIApplication, reactivated: Bool)
21
+ case unavailable(Envelope)
22
+ }
23
+
24
+ /// Executes one command on the main thread. Runs XCTest work inside the
25
+ /// exception guard and audits mutating commands against XCTest's failure
26
+ /// and suppressed-issue counters. It never retries: the one runner-side
27
+ /// retry is the snapshot capture's, in captureSnapshot, where the failure
28
+ /// is known; the host retries read-only sends on top of that.
29
+ func performOnMain(_ request: CommandRequest) -> Envelope {
30
+ let failuresBefore = recordedFailureCount()
31
+ let suppressedBefore = currentSuppressedIssueCount()
32
+ var envelope: Envelope?
33
+ var reactivated = false
34
+ // Stale accessibility elements throw NSExceptions. The guard turns
35
+ // them into a failure envelope instead of a process crash.
36
+ let exceptionDescription = ArgentExceptionGuard.runCatching {
37
+ envelope = self.performCommand(request, reactivated: &reactivated)
38
+ }
39
+
40
+ // A re-front happens before the command runs, so every reply shape
41
+ // reports it, failures included: the foreground screen changed whether
42
+ // or not the command then succeeded.
43
+ func stamped(_ reply: Envelope) -> Envelope {
44
+ reactivated ? reply.withReactivated() : reply
45
+ }
46
+
47
+ if let exceptionDescription {
48
+ return stamped(
49
+ .failure(
50
+ .commandFailed,
51
+ exceptionDescription,
52
+ hint:
53
+ "The target UI likely changed mid-command; re-observe the screen and retry."
54
+ )
55
+ )
56
+ }
57
+
58
+ guard var result = envelope else {
59
+ return stamped(
60
+ .failure(
61
+ .commandFailed,
62
+ "\(request.command.rawValue) produced no response"
63
+ )
64
+ )
65
+ }
66
+
67
+ // An XCTest failure recorded during a mutating command means the
68
+ // action may not have been performed, so an ok result is demoted.
69
+ if !request.command.isReadOnly, result.ok,
70
+ recordedFailureCount() > failuresBefore
71
+ {
72
+ result = .failure(
73
+ .xctestRecordedFailure,
74
+ "XCTest recorded a failure while executing \(request.command.rawValue); "
75
+ + "the action may not have been performed.",
76
+ hint:
77
+ "Re-observe the screen to confirm the effect before retrying."
78
+ )
79
+ }
80
+
81
+ // Checked after the demotion above, so a reply that flipped to failure
82
+ // never also warns. A grown suppressed-issue count stays advisory on an
83
+ // ok mutation. See the suppression comment in ArgentRunnerSession.swift.
84
+ if !request.command.isReadOnly, result.ok,
85
+ currentSuppressedIssueCount() > suppressedBefore
86
+ {
87
+ result = result.withWarning(
88
+ "accessibility noise was suppressed during this gesture; "
89
+ + "re-observe the screen to confirm the effect."
90
+ )
91
+ }
92
+
93
+ return stamped(result)
94
+ }
95
+
96
+ /// XCTest's cumulative recorded-failure count for this session.
97
+ private func recordedFailureCount() -> Int {
98
+ testRun?.totalFailureCount ?? 0
99
+ }
100
+
101
+ /// Runs one command: resolves and fronts the target app for app-scoped
102
+ /// commands, and handles device-scoped commands directly. `reactivated`
103
+ /// is set as soon as the target has been re-fronted, before the command
104
+ /// runs, so the caller can stamp the reply whatever shape it ends up in.
105
+ private func performCommand(
106
+ _ request: CommandRequest,
107
+ reactivated: inout Bool
108
+ ) -> Envelope {
109
+ if request.command.requiresAppBundleId {
110
+ guard let bundleId = request.normalizedAppBundleId else {
111
+ return .failure(
112
+ .appBundleIdRequired,
113
+ "\(request.command.rawValue) requires appBundleId",
114
+ hint: "Launch or target an app first with launch-app."
115
+ )
116
+ }
117
+
118
+ // snapshot observes the screen and must not change it, so it never
119
+ // re-fronts a backgrounded target; every other app command may.
120
+ switch foregroundTarget(
121
+ bundleId: bundleId,
122
+ reactivateBackgrounded: request.command != .snapshot
123
+ ) {
124
+ case .unavailable(let envelope):
125
+ return envelope
126
+ case .ready(let app, let didReactivate):
127
+ reactivated = didReactivate
128
+ return performAppCommand(request, on: app)
129
+ }
130
+ }
131
+
132
+ switch request.command {
133
+ case .button:
134
+ return pressDeviceButton(request)
135
+ case .screenshot:
136
+ return captureScreenshot()
137
+ case .shutdown:
138
+ return .success(MessagePayload(message: "shutting down"))
139
+ default:
140
+ return .failure(
141
+ .invalidRequest,
142
+ "\(request.command.rawValue) is not a device-scoped command"
143
+ )
144
+ }
145
+ }
146
+
147
+ /// Routes an app-scoped command to its gesture, text, viewport, or
148
+ /// snapshot handler.
149
+ private func performAppCommand(
150
+ _ request: CommandRequest,
151
+ on app: XCUIApplication
152
+ ) -> Envelope {
153
+ switch request.command {
154
+ case .viewport:
155
+ return appViewport(app)
156
+ case .tap:
157
+ return performTap(request, on: app)
158
+ case .longPress:
159
+ return performLongPress(request, on: app)
160
+ case .drag:
161
+ return performDrag(request, on: app)
162
+ case .type:
163
+ return performType(request, on: app)
164
+ case .keyboardReturn:
165
+ return performKeyboardReturn(on: app)
166
+ case .keyboardDelete:
167
+ return performKeyboardDelete(on: app)
168
+ case .snapshot:
169
+ return captureSnapshot(of: app)
170
+ default:
171
+ return .failure(
172
+ .invalidRequest,
173
+ "\(request.command.rawValue) is not an app-scoped command"
174
+ )
175
+ }
176
+ }
177
+
178
+ /// Presses one hardware button, refusing buttons this device does not have.
179
+ private func pressDeviceButton(_ request: CommandRequest) -> Envelope {
180
+ guard let button = request.button else {
181
+ return .failure(
182
+ .invalidRequest,
183
+ "button requires a button name",
184
+ hint: "Send one of: home, volumeUp, volumeDown, actionButton."
185
+ )
186
+ }
187
+
188
+ let device = XCUIDevice.shared
189
+
190
+ // press on an absent button is a silent no-op that would read as a
191
+ // successful press, so it is refused up front.
192
+ guard device.hasHardwareButton(button.hardwareButton) else {
193
+ return .failure(
194
+ .unsupportedOperation,
195
+ "this \(UIDevice.current.model) has no \(button.rawValue) button",
196
+ hint:
197
+ "Press a button this hardware has, or drive the equivalent from on-screen UI."
198
+ )
199
+ }
200
+
201
+ device.press(button.hardwareButton)
202
+
203
+ return .success(MessagePayload(message: "pressed \(button.rawValue)"))
204
+ }
205
+
206
+ /// Resolves the target app for an app-scoped command. A live but
207
+ /// backgrounded target is re-fronted only when `reactivateBackgrounded` is
208
+ /// true (mutating commands and the viewport read that precedes a gesture),
209
+ /// and the reply is then stamped `reactivated: true`. The observation-only
210
+ /// snapshot passes false and is refused with APP_BACKGROUNDED instead, so
211
+ /// reading the screen never changes what is on it. A target that is not
212
+ /// running, or whose state is unreadable, is refused with APP_NOT_AVAILABLE
213
+ /// either way: activating it would be a full launch, and launching is
214
+ /// launch-app's job, never a command side effect.
215
+ private func foregroundTarget(
216
+ bundleId: String,
217
+ reactivateBackgrounded: Bool
218
+ ) -> TargetResolution {
219
+ // A fresh proxy per command avoids stale-target bugs after the app is
220
+ // relaunched behind the runner's back.
221
+ let app = XCUIApplication(bundleIdentifier: bundleId)
222
+
223
+ switch app.state {
224
+ case .runningForeground:
225
+ return .ready(app, reactivated: false)
226
+ case .runningBackground, .runningBackgroundSuspended:
227
+ guard reactivateBackgrounded else {
228
+ return .unavailable(
229
+ .failure(
230
+ .appBackgrounded,
231
+ "app '\(bundleId)' is running in the background; "
232
+ + "the foreground screen is something else",
233
+ hint:
234
+ "Use screenshot for the current screen, or launch-app to bring the app back."
235
+ )
236
+ )
237
+ }
238
+
239
+ app.activate()
240
+
241
+ guard app.wait(for: .runningForeground, timeout: 15) else {
242
+ return .unavailable(
243
+ .failure(
244
+ .appNotAvailable,
245
+ "app '\(bundleId)' did not reach the foreground",
246
+ hint:
247
+ "Check the device screen and retry, or relaunch it with launch-app."
248
+ )
249
+ )
250
+ }
251
+
252
+ // Give the first frame of a fresh activation a moment to render before
253
+ // interacting.
254
+ Thread.sleep(forTimeInterval: 0.25)
255
+
256
+ return .ready(app, reactivated: true)
257
+ case .notRunning:
258
+ // activate() on an app that is not running performs a full launch,
259
+ // so this is refused rather than launching implicitly.
260
+ return .unavailable(
261
+ .failure(
262
+ .appNotAvailable,
263
+ "app '\(bundleId)' is not running",
264
+ hint: "Launch it first with launch-app."
265
+ )
266
+ )
267
+ default:
268
+ // Covers .unknown and any state a future SDK adds. On hardware a
269
+ // swipe-killed app this session never launched reports .unknown, so
270
+ // activating it could be a hidden full launch. It is refused as well.
271
+ return .unavailable(
272
+ .failure(
273
+ .appNotAvailable,
274
+ "app '\(bundleId)' is not reachable: its state is unreadable",
275
+ hint: "Launch it first with launch-app."
276
+ )
277
+ )
278
+ }
279
+ }
280
+ }
@@ -0,0 +1,149 @@
1
+ import XCTest
2
+
3
+ extension ArgentRunnerSession {
4
+ /// Builds an XCUICoordinate for absolute wire coordinates. Wire `x`/`y` are
5
+ /// screen points in the same space as `XCUIElement.frame` and snapshot rects.
6
+ /// `origin` is `app.frame.origin`, which the caller reads once per command:
7
+ /// app.frame is a live element query, and a drag needs two points.
8
+ private func point(
9
+ _ app: XCUIApplication,
10
+ _ x: Double,
11
+ _ y: Double,
12
+ relativeTo origin: CGPoint
13
+ ) -> XCUICoordinate {
14
+ // withOffset is relative to the app element's origin. Subtract it so a
15
+ // non-zero app.frame.origin is not applied twice. XCUICoordinate handles
16
+ // interface orientation itself.
17
+ return app.coordinate(withNormalizedOffset: .zero).withOffset(
18
+ CGVector(dx: x - origin.x, dy: y - origin.y)
19
+ )
20
+ }
21
+
22
+ /// Taps at the wire coordinates: one tap, the native double-tap when
23
+ /// `numberOfTaps` is 2, or a loop of single taps above that. XCUICoordinate
24
+ /// has no N-tap API, and the loop is not one gesture on hardware: each tap
25
+ /// is its own synthesized event behind XCTest's idle wait and an XPC round
26
+ /// trip, hundreds of milliseconds apart, so the taps land separately and do
27
+ /// not trigger a multi-tap recognizer. That is documented, not refused.
28
+ func performTap(_ request: CommandRequest, on app: XCUIApplication)
29
+ -> Envelope
30
+ {
31
+ guard let x = request.x, let y = request.y else {
32
+ return .failure(.invalidRequest, "tap requires x and y")
33
+ }
34
+
35
+ let taps = request.numberOfTaps ?? 1
36
+ guard taps >= 1 else {
37
+ return .failure(.invalidRequest, "tap requires numberOfTaps >= 1")
38
+ }
39
+
40
+ let coordinate = point(app, x, y, relativeTo: app.frame.origin)
41
+
42
+ switch taps {
43
+ case 1:
44
+ coordinate.tap()
45
+ case 2:
46
+ coordinate.doubleTap()
47
+ default:
48
+ // Separate taps, not one gesture (see the doc comment above).
49
+ for _ in 0..<taps {
50
+ coordinate.tap()
51
+ }
52
+ }
53
+
54
+ return .success(MessagePayload(message: "tapped"))
55
+ }
56
+
57
+ /// Presses at the wire coordinates for the requested duration.
58
+ func performLongPress(_ request: CommandRequest, on app: XCUIApplication)
59
+ -> Envelope
60
+ {
61
+ guard let x = request.x, let y = request.y else {
62
+ return .failure(.invalidRequest, "longPress requires x and y")
63
+ }
64
+
65
+ let seconds = max(0.05, (request.durationMs ?? 800) / 1000)
66
+ point(app, x, y, relativeTo: app.frame.origin).press(forDuration: seconds)
67
+
68
+ return .success(MessagePayload(message: "long-pressed"))
69
+ }
70
+
71
+ /// Drags between the wire coordinates, honoring the requested hold before
72
+ /// the movement, the movement duration, and the `settle` release behavior.
73
+ func performDrag(_ request: CommandRequest, on app: XCUIApplication)
74
+ -> Envelope
75
+ {
76
+ guard let fromX = request.fromX, let fromY = request.fromY,
77
+ let toX = request.toX, let toY = request.toY
78
+ else {
79
+ return .failure(
80
+ .invalidRequest,
81
+ "drag requires fromX, fromY, toX and toY"
82
+ )
83
+ }
84
+
85
+ let origin = app.frame.origin
86
+ let start = point(app, fromX, fromY, relativeTo: origin)
87
+ let end = point(app, toX, toY, relativeTo: origin)
88
+
89
+ // The press before the movement. 50 ms is a plain drag; a long-press
90
+ // pickup needs the caller's holdMs, since a short press never lifts a
91
+ // draggable item no matter how slowly the finger then moves.
92
+ let startHold = max((request.holdMs ?? 50) / 1000, 0.05)
93
+ // A `settle` drag rests at the destination before lifting, so the scroll
94
+ // view reads near-zero release velocity and does not fling.
95
+ let endHold = request.settle == true ? 0.3 : 0.05
96
+ let velocity: XCUIGestureVelocity
97
+
98
+ if let durationMs = request.durationMs, durationMs > 0 {
99
+ // Honor the requested duration through drag velocity (points/second),
100
+ // clamped to a range XCTest executes faithfully.
101
+ let distance =
102
+ ((toX - fromX) * (toX - fromX) + (toY - fromY) * (toY - fromY))
103
+ .squareRoot()
104
+
105
+ let pointsPerSecond = min(
106
+ max(distance / (durationMs / 1000), 60),
107
+ 5000
108
+ )
109
+
110
+ velocity = XCUIGestureVelocity(rawValue: CGFloat(pointsPerSecond))
111
+ } else {
112
+ velocity = .default
113
+ }
114
+
115
+ start.press(
116
+ forDuration: startHold,
117
+ thenDragTo: end,
118
+ withVelocity: velocity,
119
+ thenHoldForDuration: endHold
120
+ )
121
+
122
+ return .success(MessagePayload(message: "dragged"))
123
+ }
124
+
125
+ /// Reports the interaction viewport, the rectangle that normalized 0-1
126
+ /// coordinates map into.
127
+ func appViewport(_ app: XCUIApplication) -> Envelope {
128
+ // Describe normalizes frames against the snapshot's Application root
129
+ // frame, so the viewport must be that same rect, keyboard band included.
130
+ let frame = app.frame
131
+
132
+ guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
133
+ return .failure(
134
+ .appNotAvailable,
135
+ "the app's interaction viewport is unavailable",
136
+ hint: "Bring the app to the foreground, then retry."
137
+ )
138
+ }
139
+
140
+ return .success(
141
+ ViewportPayload(
142
+ x: frame.minX,
143
+ y: frame.minY,
144
+ width: frame.width,
145
+ height: frame.height
146
+ )
147
+ )
148
+ }
149
+ }
@@ -0,0 +1,21 @@
1
+ import XCTest
2
+
3
+ extension ArgentRunnerSession {
4
+ /// Captures a full-screen PNG and returns it inline as base64.
5
+ func captureScreenshot() -> Envelope {
6
+ // Screenshots are the fallback observation channel when accessibility
7
+ // snapshots degrade. This path deliberately touches no accessibility APIs.
8
+ let png = XCUIScreen.main.screenshot().pngRepresentation
9
+
10
+ guard !png.isEmpty else {
11
+ return .failure(
12
+ .commandFailed,
13
+ "screenshot capture produced no data"
14
+ )
15
+ }
16
+
17
+ return .success(
18
+ ScreenshotPayload(imageBase64: png.base64EncodedString())
19
+ )
20
+ }
21
+ }