@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+Commands.swift
ADDED
|
@@ -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
|
+
}
|
package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Gestures.swift
ADDED
|
@@ -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
|
+
}
|
package/dist/ios-device-runner/ArgentRunner/ArgentRunnerUITests/ArgentRunnerSession+Screenshot.swift
ADDED
|
@@ -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
|
+
}
|