@swmansion/argent 0.31.1-next.11 → 0.31.1-next.13

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/dist/cli-cmds.mjs CHANGED
@@ -35740,7 +35740,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
35740
35740
  var SESSION_ID2 = randomUUID5();
35741
35741
  function readCliVersion() {
35742
35742
  if (true) {
35743
- return "0.31.1-next.11";
35743
+ return "0.31.1-next.13";
35744
35744
  }
35745
35745
  return "0.0.0";
35746
35746
  }
@@ -16915,7 +16915,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
16915
16915
  var SESSION_ID = randomUUID4();
16916
16916
  function readCliVersion() {
16917
16917
  if (true) {
16918
- return "0.31.1-next.11";
16918
+ return "0.31.1-next.13";
16919
16919
  }
16920
16920
  return "0.0.0";
16921
16921
  }
@@ -159,6 +159,8 @@ extension ArgentRunnerSession {
159
159
  return performLongPress(request, on: app)
160
160
  case .drag:
161
161
  return performDrag(request, on: app)
162
+ case .touchPaths:
163
+ return performTouchPaths(request, on: app)
162
164
  case .type:
163
165
  return performType(request, on: app)
164
166
  case .keyboardReturn:
@@ -122,6 +122,102 @@ extension ArgentRunnerSession {
122
122
  return .success(MessagePayload(message: "dragged"))
123
123
  }
124
124
 
125
+ /// Most fingers one `touchPaths` command plays. A hand has five.
126
+ private static let maxTouchFingers = 5
127
+ /// Longest `touchPaths` gesture, comfortably inside the command budget.
128
+ private static let maxTouchPathsMs = 30_000.0
129
+
130
+ /// Plays one path per finger, all at once: pinches, rotations, and any
131
+ /// single path with waypoints. XCUICoordinate only draws one straight drag,
132
+ /// so this goes through XCTest's event synthesizer (ArgentTouchSynthesizer).
133
+ ///
134
+ /// Wire points are absolute screen points, the same space `tap` and `drag`
135
+ /// take, and times are measured from the start of the gesture. A finger
136
+ /// whose first waypoint is later than 0 touches down late, which is how a
137
+ /// second finger joins a gesture already under way.
138
+ func performTouchPaths(_ request: CommandRequest, on app: XCUIApplication)
139
+ -> Envelope
140
+ {
141
+ guard let fingers = request.fingers,
142
+ (1...Self.maxTouchFingers).contains(fingers.count)
143
+ else {
144
+ return .failure(
145
+ .invalidRequest,
146
+ "touchPaths requires between 1 and \(Self.maxTouchFingers) fingers"
147
+ )
148
+ }
149
+
150
+ for (index, finger) in fingers.enumerated() {
151
+ guard !finger.points.isEmpty else {
152
+ return .failure(
153
+ .invalidRequest,
154
+ "touchPaths finger \(index) has no waypoints"
155
+ )
156
+ }
157
+
158
+ var previousMs = 0.0
159
+
160
+ for point in finger.points {
161
+ guard point.x.isFinite, point.y.isFinite, point.tMs.isFinite,
162
+ point.tMs >= previousMs, point.tMs <= Self.maxTouchPathsMs
163
+ else {
164
+ return .failure(
165
+ .invalidRequest,
166
+ "touchPaths finger \(index) needs finite waypoints whose times do not decrease and stay within \(Int(Self.maxTouchPathsMs)) ms"
167
+ )
168
+ }
169
+
170
+ previousMs = point.tMs
171
+ }
172
+ }
173
+
174
+ if let reason = ArgentTouchSynthesizer.unavailabilityReason() {
175
+ return .failure(
176
+ .unsupportedOperation,
177
+ "multi-finger touch is unavailable here: \(reason)",
178
+ hint:
179
+ "Use tap, swipe or drag for a single finger, or drive the app's own zoom controls."
180
+ )
181
+ }
182
+
183
+ // The orientation the app draws in, not the way the phone is held: a
184
+ // portrait-only app on a phone turned sideways is still portrait. Wire
185
+ // points are in the app's space, which is the panel's own only in
186
+ // portrait. Other orientations have not been checked on hardware, and
187
+ // a touch that lands somewhere else without an error is worse than a
188
+ // refusal.
189
+ switch ArgentTouchSynthesizer.interfaceOrientation(ofApplication: app) {
190
+ case .portrait:
191
+ break
192
+ case .unknown:
193
+ return .failure(
194
+ .unsupportedOperation,
195
+ "touchPaths could not read the app's interface orientation",
196
+ hint:
197
+ "Use tap, swipe or drag for a single finger, or drive the app's own zoom controls."
198
+ )
199
+ default:
200
+ return .failure(
201
+ .unsupportedOperation,
202
+ "touchPaths has only been verified with the app drawn in portrait",
203
+ hint: "Turn the phone upright so the app draws in portrait, then retry."
204
+ )
205
+ }
206
+
207
+ let waypoints = fingers.map { finger in
208
+ finger.points.map { [$0.x, $0.y, $0.tMs / 1000] as [NSNumber] }
209
+ }
210
+
211
+ if let failure = ArgentTouchSynthesizer.synthesizeFingers(
212
+ waypoints,
213
+ interfaceOrientation: .portrait
214
+ ) {
215
+ return .failure(.commandFailed, failure)
216
+ }
217
+
218
+ return .success(MessagePayload(message: "touched"))
219
+ }
220
+
125
221
  /// Reports the interaction viewport, the rectangle that normalized 0-1
126
222
  /// coordinates map into.
127
223
  func appViewport(_ app: XCUIApplication) -> Envelope {
@@ -1 +1,2 @@
1
1
  #import "ArgentExceptionGuard.h"
2
+ #import "ArgentTouchSynthesizer.h"
@@ -0,0 +1,40 @@
1
+ #import <UIKit/UIKit.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /// Multi-finger touch synthesis through XCTest's own event classes.
6
+ ///
7
+ /// The public XCUICoordinate API can drive one finger along one straight
8
+ /// press-then-drag. Pinches, rotations and any path with waypoints need several
9
+ /// fingers moving at once, which XCTest builds internally from
10
+ /// XCPointerEventPath and XCSynthesizedEventRecord. Those classes are not in
11
+ /// any public header, so they are looked up by name at runtime: nothing links
12
+ /// against a private symbol, and an Xcode that renames or drops one makes
13
+ /// `unavailabilityReason` answer instead of the runner failing to load.
14
+ @interface ArgentTouchSynthesizer : NSObject
15
+
16
+ /// nil when every class and selector this needs is present, otherwise a
17
+ /// sentence naming what is missing.
18
+ + (NSString *_Nullable)unavailabilityReason;
19
+
20
+ /// The orientation the app's interface is drawn in, which can differ from the
21
+ /// way the phone is held (a portrait-only app on a phone turned sideways).
22
+ /// UIInterfaceOrientationUnknown when this XCTest cannot say.
23
+ + (UIInterfaceOrientation)interfaceOrientationOfApplication:(id)application;
24
+
25
+ /// Plays every finger at once as one event record, and returns once the last
26
+ /// finger has lifted. Each finger is an array of `[x, y, seconds]` waypoints:
27
+ /// points in the panel's own portrait space, and the time since the gesture
28
+ /// started. The finger touches down at its first waypoint, moves through the
29
+ /// rest in order, and lifts a hair after its last. Waypoint times must not
30
+ /// decrease.
31
+ ///
32
+ /// Returns nil on success, otherwise "Name: reason" for the failure or the
33
+ /// NSException XCTest threw (Swift cannot catch those).
34
+ + (NSString *_Nullable)synthesizeFingers:
35
+ (NSArray<NSArray<NSArray<NSNumber *> *> *> *)fingers
36
+ interfaceOrientation:(UIInterfaceOrientation)orientation;
37
+
38
+ @end
39
+
40
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,149 @@
1
+ #import "ArgentTouchSynthesizer.h"
2
+
3
+ #import <XCTest/XCTest.h>
4
+ #import <objc/message.h>
5
+
6
+ /// The selectors used on XCTest's private classes, declared as protocols so the
7
+ /// compiler can check the calls without a class symbol to link against.
8
+ @protocol ArgentPointerEventPath <NSObject>
9
+ - (instancetype)initForTouchAtPoint:(CGPoint)point offset:(NSTimeInterval)offset;
10
+ - (void)moveToPoint:(CGPoint)point atOffset:(NSTimeInterval)offset;
11
+ - (void)liftUpAtOffset:(NSTimeInterval)offset;
12
+ @end
13
+
14
+ @protocol ArgentSynthesizedEventRecord <NSObject>
15
+ - (instancetype)initWithName:(NSString *)name
16
+ displayID:(NSUInteger)displayID
17
+ interfaceOrientation:(UIInterfaceOrientation)orientation;
18
+ - (instancetype)initWithName:(NSString *)name
19
+ interfaceOrientation:(UIInterfaceOrientation)orientation;
20
+ - (void)addPointerEventPath:(id<ArgentPointerEventPath>)path;
21
+ - (BOOL)synthesizeWithError:(NSError *_Nullable *_Nullable)error;
22
+ @end
23
+
24
+ /// How long after its last waypoint a finger lifts, so the move and the lift
25
+ /// are two distinct events in the stream.
26
+ static const NSTimeInterval kLiftDelay = 0.01;
27
+
28
+ @implementation ArgentTouchSynthesizer
29
+
30
+ + (NSString *_Nullable)unavailabilityReason {
31
+ Class pathClass = NSClassFromString(@"XCPointerEventPath");
32
+ Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
33
+
34
+ if (pathClass == Nil || recordClass == Nil) {
35
+ return @"this Xcode's XCTest has no XCPointerEventPath/XCSynthesizedEventRecord";
36
+ }
37
+
38
+ SEL pathSelectors[] = {
39
+ @selector(initForTouchAtPoint:offset:),
40
+ @selector(moveToPoint:atOffset:),
41
+ @selector(liftUpAtOffset:),
42
+ };
43
+ for (size_t i = 0; i < sizeof(pathSelectors) / sizeof(SEL); i++) {
44
+ if (![pathClass instancesRespondToSelector:pathSelectors[i]]) {
45
+ return [NSString stringWithFormat:@"XCPointerEventPath no longer responds to %@",
46
+ NSStringFromSelector(pathSelectors[i])];
47
+ }
48
+ }
49
+
50
+ // Either record initializer will do; synthesizeFingers picks the newer one.
51
+ if (![recordClass instancesRespondToSelector:@selector(initWithName:displayID:interfaceOrientation:)] &&
52
+ ![recordClass instancesRespondToSelector:@selector(initWithName:interfaceOrientation:)]) {
53
+ return @"XCSynthesizedEventRecord has neither initWithName:displayID:interfaceOrientation: "
54
+ @"nor initWithName:interfaceOrientation:";
55
+ }
56
+
57
+ SEL recordSelectors[] = {
58
+ @selector(addPointerEventPath:),
59
+ @selector(synthesizeWithError:),
60
+ };
61
+ for (size_t i = 0; i < sizeof(recordSelectors) / sizeof(SEL); i++) {
62
+ if (![recordClass instancesRespondToSelector:recordSelectors[i]]) {
63
+ return [NSString stringWithFormat:@"XCSynthesizedEventRecord no longer responds to %@",
64
+ NSStringFromSelector(recordSelectors[i])];
65
+ }
66
+ }
67
+
68
+ return nil;
69
+ }
70
+
71
+ + (UIInterfaceOrientation)interfaceOrientationOfApplication:(id)application {
72
+ SEL selector = NSSelectorFromString(@"interfaceOrientation");
73
+ if (![application respondsToSelector:selector]) {
74
+ return UIInterfaceOrientationUnknown;
75
+ }
76
+ return ((UIInterfaceOrientation(*)(id, SEL))objc_msgSend)(application, selector);
77
+ }
78
+
79
+ /// The main screen's display id, which the display-aware record initializer
80
+ /// routes touches by. nil when XCUIScreen does not expose one.
81
+ + (NSNumber *_Nullable)mainDisplayID {
82
+ XCUIScreen *screen = XCUIScreen.mainScreen;
83
+ if (![screen respondsToSelector:NSSelectorFromString(@"displayID")]) {
84
+ return nil;
85
+ }
86
+ id value = [screen valueForKey:@"displayID"];
87
+ return [value isKindOfClass:NSNumber.class] ? value : nil;
88
+ }
89
+
90
+ /// A new event record. Prefers the display-aware initializer, which newer
91
+ /// XCTest uses for its own touches, and falls back to the older one when this
92
+ /// XCTest or the screen cannot supply a display id.
93
+ + (id<ArgentSynthesizedEventRecord>)recordWithInterfaceOrientation:
94
+ (UIInterfaceOrientation)orientation {
95
+ Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
96
+ NSString *name = @"argent-touch-paths";
97
+ NSNumber *displayID = [self mainDisplayID];
98
+
99
+ if (displayID != nil &&
100
+ [recordClass instancesRespondToSelector:@selector(initWithName:displayID:interfaceOrientation:)]) {
101
+ return [[recordClass alloc] initWithName:name
102
+ displayID:displayID.unsignedIntegerValue
103
+ interfaceOrientation:orientation];
104
+ }
105
+
106
+ return [[recordClass alloc] initWithName:name interfaceOrientation:orientation];
107
+ }
108
+
109
+ + (NSString *_Nullable)synthesizeFingers:
110
+ (NSArray<NSArray<NSArray<NSNumber *> *> *> *)fingers
111
+ interfaceOrientation:(UIInterfaceOrientation)orientation {
112
+ NSString *unavailable = [self unavailabilityReason];
113
+ if (unavailable != nil) {
114
+ return unavailable;
115
+ }
116
+
117
+ @try {
118
+ id<ArgentSynthesizedEventRecord> record = [self recordWithInterfaceOrientation:orientation];
119
+
120
+ for (NSArray<NSArray<NSNumber *> *> *waypoints in fingers) {
121
+ NSArray<NSNumber *> *first = waypoints.firstObject;
122
+ id<ArgentPointerEventPath> path = [[NSClassFromString(@"XCPointerEventPath") alloc]
123
+ initForTouchAtPoint:CGPointMake(first[0].doubleValue, first[1].doubleValue)
124
+ offset:first[2].doubleValue];
125
+
126
+ for (NSUInteger i = 1; i < waypoints.count; i++) {
127
+ NSArray<NSNumber *> *waypoint = waypoints[i];
128
+ [path moveToPoint:CGPointMake(waypoint[0].doubleValue, waypoint[1].doubleValue)
129
+ atOffset:waypoint[2].doubleValue];
130
+ }
131
+
132
+ [path liftUpAtOffset:waypoints.lastObject[2].doubleValue + kLiftDelay];
133
+ [record addPointerEventPath:path];
134
+ }
135
+
136
+ NSError *error = nil;
137
+ if (![record synthesizeWithError:&error]) {
138
+ return [NSString stringWithFormat:@"XCTest refused the touch events: %@",
139
+ error.localizedDescription ?: @"no error given"];
140
+ }
141
+
142
+ return nil;
143
+ } @catch (NSException *exception) {
144
+ return [NSString stringWithFormat:@"%@: %@", exception.name ?: @"NSException",
145
+ exception.reason ?: @"unhandled Objective-C exception"];
146
+ }
147
+ }
148
+
149
+ @end
@@ -13,6 +13,7 @@ enum CommandKind: String, Codable {
13
13
  case tap
14
14
  case longPress
15
15
  case drag
16
+ case touchPaths
16
17
  case type
17
18
  case keyboardReturn
18
19
  case keyboardDelete
@@ -30,8 +31,8 @@ extension CommandKind {
30
31
  switch self {
31
32
  case .status, .viewport, .snapshot, .screenshot:
32
33
  return true
33
- case .tap, .longPress, .drag, .type, .keyboardReturn, .keyboardDelete,
34
- .button, .shutdown:
34
+ case .tap, .longPress, .drag, .touchPaths, .type, .keyboardReturn,
35
+ .keyboardDelete, .button, .shutdown:
35
36
  return false
36
37
  }
37
38
  }
@@ -41,8 +42,8 @@ extension CommandKind {
41
42
  /// runner's own host app.
42
43
  var requiresAppBundleId: Bool {
43
44
  switch self {
44
- case .viewport, .tap, .longPress, .drag, .type, .keyboardReturn,
45
- .keyboardDelete, .snapshot:
45
+ case .viewport, .tap, .longPress, .drag, .touchPaths, .type,
46
+ .keyboardReturn, .keyboardDelete, .snapshot:
46
47
  return true
47
48
  case .status, .button, .screenshot, .shutdown:
48
49
  return false
@@ -73,7 +74,7 @@ extension CommandKind {
73
74
  // never reports quiescent stalls the wait until XCTest gives up and
74
75
  // synthesizes the event anyway, and a shorter budget would abandon a
75
76
  // command that still completes.
76
- case .tap, .longPress, .drag: return 75
77
+ case .tap, .longPress, .drag, .touchPaths: return 75
77
78
  default: return 30
78
79
  }
79
80
  }
@@ -90,6 +91,20 @@ enum DeviceButton: String, Codable {
90
91
  case actionButton
91
92
  }
92
93
 
94
+ /// One point on a finger's path: an absolute point in the app's coordinate
95
+ /// space, and the time since the gesture started.
96
+ struct TouchWaypoint: Codable {
97
+ let x: Double
98
+ let y: Double
99
+ let tMs: Double
100
+ }
101
+
102
+ /// One finger of a `touchPaths` command: it touches down at the first
103
+ /// waypoint, moves through the rest in order, and lifts after the last.
104
+ struct TouchFinger: Codable {
105
+ let points: [TouchWaypoint]
106
+ }
107
+
93
108
  struct CommandRequest: Codable {
94
109
  let command: CommandKind
95
110
  let commandId: String?
@@ -119,6 +134,8 @@ struct CommandRequest: Codable {
119
134
  /// velocity is near zero and the scroll view does not fling. Mirrors the
120
135
  /// simulator's ease-out `settle` swipe.
121
136
  let settle: Bool?
137
+ /// `touchPaths`: one path per finger, all played at once.
138
+ let fingers: [TouchFinger]?
122
139
  /// `type`: the text delivered to the focused input.
123
140
  let text: String?
124
141
  /// `button`: which hardware button to press.
@@ -101024,7 +101024,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
101024
101024
  var SESSION_ID = (0, import_node_crypto3.randomUUID)();
101025
101025
  function readCliVersion() {
101026
101026
  if (true) {
101027
- return "0.31.1-next.11";
101027
+ return "0.31.1-next.13";
101028
101028
  }
101029
101029
  return "0.0.0";
101030
101030
  }
@@ -141439,6 +141439,13 @@ async function dragBetween(api, bundleId, from2, to, opts = {}) {
141439
141439
  );
141440
141440
  return { reactivated: repliedReactivated(data) };
141441
141441
  }
141442
+ async function touchPaths(api, bundleId, fingers) {
141443
+ const data = await api.run(
141444
+ { command: "touchPaths", appBundleId: bundleId, fingers },
141445
+ { timeoutMs: GESTURE_TIMEOUT_MS }
141446
+ );
141447
+ return { reactivated: repliedReactivated(data) };
141448
+ }
141442
141449
  async function pressButton(api, button) {
141443
141450
  await api.run({
141444
141451
  command: "button",
@@ -142564,13 +142571,33 @@ Pass momentum:false for a momentum-free drag that decelerates into the release,
142564
142571
 
142565
142572
  // ../tool-server/src/tools/gesture-custom/index.ts
142566
142573
  init_zod();
142567
- init_device_info();
142568
- init_capability();
142569
142574
 
142570
142575
  // ../tool-server/src/utils/gesture-utils.ts
142571
142576
  function lerp(a, b, t) {
142572
142577
  return a + (b - a) * t;
142573
142578
  }
142579
+ function pinchContacts(params, t) {
142580
+ const angleRad = (params.angle ?? 0) * Math.PI / 180;
142581
+ const cosA = Math.cos(angleRad);
142582
+ const sinA = Math.sin(angleRad);
142583
+ const halfDist = (params.startDistance + (params.endDistance - params.startDistance) * t) / 2;
142584
+ const cx = params.centerX + ((params.endCenterX ?? params.centerX) - params.centerX) * t;
142585
+ const cy = params.centerY + ((params.endCenterY ?? params.centerY) - params.centerY) * t;
142586
+ return {
142587
+ first: { x: cx - halfDist * cosA, y: cy - halfDist * sinA },
142588
+ second: { x: cx + halfDist * cosA, y: cy + halfDist * sinA }
142589
+ };
142590
+ }
142591
+ function rotateContacts(params, t) {
142592
+ const angleDeg = params.startAngle + (params.endAngle - params.startAngle) * t;
142593
+ const angleRad = angleDeg * Math.PI / 180;
142594
+ const dx = params.radiusX * Math.cos(angleRad);
142595
+ const dy = params.radiusY * Math.sin(angleRad);
142596
+ return {
142597
+ first: { x: params.centerX + dx, y: params.centerY + dy },
142598
+ second: { x: params.centerX - dx, y: params.centerY - dy }
142599
+ };
142600
+ }
142574
142601
  function interpolateEvents(events, steps) {
142575
142602
  if (events.length < 2 || steps < 1) return events;
142576
142603
  const result = [];
@@ -142614,7 +142641,59 @@ function sendTouchEvent(api, type, x, y, x2, y2) {
142614
142641
  });
142615
142642
  }
142616
142643
 
142644
+ // ../tool-server/src/utils/ios-device/touch-paths.ts
142645
+ var FRAME_MS = 16;
142646
+ function frameCount(durationMs) {
142647
+ return Math.max(1, Math.round(durationMs / FRAME_MS));
142648
+ }
142649
+ function twoFingerPaths(viewport, durationMs, contactsAt) {
142650
+ const steps = frameCount(durationMs);
142651
+ const first = { points: [] };
142652
+ const second = { points: [] };
142653
+ for (let i = 0; i <= steps; i++) {
142654
+ const contacts = contactsAt(i / steps);
142655
+ const tMs = i * durationMs / steps;
142656
+ first.points.push({ ...toPoints(viewport, contacts.first.x, contacts.first.y), tMs });
142657
+ second.points.push({ ...toPoints(viewport, contacts.second.x, contacts.second.y), tMs });
142658
+ }
142659
+ return [first, second];
142660
+ }
142661
+ function pinchPaths(params, viewport, durationMs) {
142662
+ return twoFingerPaths(viewport, durationMs, (t) => pinchContacts(params, t));
142663
+ }
142664
+ function rotatePaths(params, viewport, durationMs) {
142665
+ return twoFingerPaths(viewport, durationMs, (t) => rotateContacts(params, t));
142666
+ }
142667
+ function trainPaths(events, viewport) {
142668
+ const down = events[0];
142669
+ const up = events[events.length - 1];
142670
+ if (down?.type !== "Down" || up?.type !== "Up" || events.length < 2) {
142671
+ throw new Error("A touch train starts with a Down and ends with an Up.");
142672
+ }
142673
+ if (events.slice(1, -1).some((event2) => event2.type !== "Move")) {
142674
+ throw new Error("A touch train has only Move events between its Down and its Up.");
142675
+ }
142676
+ const first = { points: [] };
142677
+ const second = { points: [] };
142678
+ let tMs = 0;
142679
+ events.forEach((event2, index) => {
142680
+ if (event2.x2 === void 0 !== (event2.y2 === void 0)) {
142681
+ throw new Error(`Event ${index} has only one of x2 and y2; give both or neither.`);
142682
+ }
142683
+ if (index > 0) {
142684
+ tMs += event2.delayMs ?? FRAME_MS;
142685
+ }
142686
+ first.points.push({ ...toPoints(viewport, event2.x, event2.y), tMs });
142687
+ if (event2.x2 !== void 0 && event2.y2 !== void 0) {
142688
+ second.points.push({ ...toPoints(viewport, event2.x2, event2.y2), tMs });
142689
+ }
142690
+ });
142691
+ return second.points.length > 0 ? [first, second] : [first];
142692
+ }
142693
+
142617
142694
  // ../tool-server/src/tools/gesture-custom/index.ts
142695
+ init_device_info();
142696
+ init_capability();
142618
142697
  var sleep6 = (ms) => new Promise((r) => setTimeout(r, ms));
142619
142698
  var eventSchema = external_exports.object({
142620
142699
  type: external_exports.enum(["Down", "Move", "Up"]).describe("Touch event type"),
@@ -142643,32 +142722,42 @@ function samePoint(a, b) {
142643
142722
  return Math.abs(a.x - b.x) <= SAME_POINT_EPSILON && Math.abs(a.y - b.y) <= SAME_POINT_EPSILON;
142644
142723
  }
142645
142724
  function planIosDeviceGesture(events) {
142646
- if (events.some((e) => e.x2 !== void 0 || e.y2 !== void 0)) {
142647
- return {
142648
- kind: "unsupported",
142649
- reason: "gesture-custom on a physical iOS device supports single-finger gestures only; two-finger event trains (pinch/rotate) have no XCTest coordinate API."
142650
- };
142651
- }
142652
142725
  const down = events[0];
142653
142726
  const up = events[events.length - 1];
142654
- const hold = events.length === 3 ? events[1] : void 0;
142655
- const pickup = down !== void 0 && hold !== void 0 && hold.type === "Move" && samePoint(down, hold);
142656
- if (down === void 0 || up === void 0 || down.type !== "Down" || up.type !== "Up" || !(events.length === 2 || events.length === 3 && pickup)) {
142727
+ if (down === void 0 || up === void 0 || events.length < 2 || down.type !== "Down" || up.type !== "Up" || events.slice(1, -1).some((event2) => event2.type !== "Move")) {
142657
142728
  return {
142658
142729
  kind: "unsupported",
142659
- reason: "gesture-custom on a physical iOS device supports a Down followed by an Up (same point = press-hold; different points = drag, resting at the Down point for the Up's delayMs before moving), optionally with one Move at the Down point in between (its delayMs is the hold, the Up's delayMs the movement time: a long-press pickup). For scrolls use gesture-swipe; other Move waypoints cannot be replayed through XCTest."
142730
+ reason: "gesture-custom on a physical iOS device takes a Down, any number of Moves, then an Up (a second finger rides along as x2/y2 on those events)."
142660
142731
  };
142661
142732
  }
142733
+ const twoFingers = events.some((e) => e.x2 !== void 0 || e.y2 !== void 0);
142734
+ const hold = events.length === 3 ? events[1] : void 0;
142735
+ const pickup = hold !== void 0 && hold.type === "Move" && samePoint(down, hold);
142736
+ if (twoFingers || !(events.length === 2 || events.length === 3 && pickup)) {
142737
+ return { kind: "paths" };
142738
+ }
142662
142739
  return { kind: "gesture", down, up, ...pickup && hold ? { hold } : {} };
142663
142740
  }
142664
- async function runOnIosDevice(runner, udid, events) {
142741
+ async function runOnIosDevice(runner, udid, events, interpolate) {
142665
142742
  const plan = planIosDeviceGesture(events);
142666
142743
  if (plan.kind === "unsupported") {
142667
142744
  throw new InvalidToolInputError(plan.reason);
142668
142745
  }
142669
- const { down, hold, up } = plan;
142670
142746
  const bundleId = requireCurrentIosDeviceApp(udid);
142671
142747
  const viewport = await getViewport(runner, bundleId);
142748
+ if (plan.kind === "paths") {
142749
+ const played = interpolate && interpolate > 0 ? interpolateEvents(events, interpolate) : events;
142750
+ let fingers;
142751
+ try {
142752
+ fingers = trainPaths(played, viewport);
142753
+ } catch (error63) {
142754
+ throw new InvalidToolInputError(error63.message);
142755
+ }
142756
+ const reply = await touchPaths(runner, bundleId, fingers);
142757
+ const reactivated2 = viewport.reactivated === true || reply.reactivated;
142758
+ return { events: events.length, ...reactivated2 ? { reactivated: true } : {} };
142759
+ }
142760
+ const { down, hold, up } = plan;
142672
142761
  const holdMs = hold ? hold.delayMs ?? 16 : up.delayMs ?? 16;
142673
142762
  const moveMs = hold ? up.delayMs ?? 16 : void 0;
142674
142763
  const gesture = samePoint(down, up) ? await longPressAt(
@@ -142699,7 +142788,7 @@ For simple taps use the gesture-tap tool. For straight-line scrolling use the ge
142699
142788
  For pinch gestures use gesture-pinch. For rotation gestures use gesture-rotate.
142700
142789
  All x/y values are normalized 0.0\u20131.0 (screen fractions, not pixels). delayMs controls the delay before each event (default 16ms \u2248 60fps).
142701
142790
  Set interpolate to auto-generate smooth intermediate Move events between your keyframes.
142702
- Physical iOS: Down then Up only. Same point = press-hold. Other point = drag (Up delayMs = rest before the move). One Move at the Down point between them = long-press pickup (Move delayMs = hold, Up delayMs = move time). No second finger, no other waypoints; scroll with gesture-swipe.
142791
+ Physical iOS: a Down, any Moves, then an Up, with an optional second finger as x2/y2. Paths need the app drawn in portrait. A plain Down then Up keeps its hold semantics: same point = press-hold, other point = drag (Up delayMs = rest before the move), and one Move at the Down point between them = long-press pickup (Move delayMs = hold, Up delayMs = move time). Anything else (more Moves, a second finger) plays as timed paths, each event waiting its delayMs after the one before. The second finger touches down at the first event with x2/y2 and lifts after the last.
142703
142792
  Returns { events: number } with the total count of events dispatched. On physical iOS, reactivated: true = app was re-fronted; re-describe. Fails if the target device is not booted or an event type is invalid.
142704
142793
 
142705
142794
  Example long-press at center:
@@ -142719,11 +142808,11 @@ Example pinch-to-zoom (with interpolate:10 for smoothness):
142719
142808
  interpolate: 10`,
142720
142809
  zodSchema: zodSchema21,
142721
142810
  capability: capability13,
142722
- // Declare the runner only for a train execute can replay. A rejected train must not pay a runner cold start.
142811
+ // Declare the runner only for a train execute can play. A rejected train must not pay a runner cold start.
142723
142812
  services: (params) => {
142724
142813
  const device = resolveDevice(params.udid);
142725
142814
  if (isIosPhysicalDevice(device)) {
142726
- return planIosDeviceGesture(params.events).kind === "gesture" ? { iosDeviceRunner: iosDeviceRunnerRef(device) } : {};
142815
+ return planIosDeviceGesture(params.events).kind !== "unsupported" ? { iosDeviceRunner: iosDeviceRunnerRef(device) } : {};
142727
142816
  }
142728
142817
  return { simulatorServer: simulatorServerRef(device) };
142729
142818
  },
@@ -142733,7 +142822,8 @@ Example pinch-to-zoom (with interpolate:10 for smoothness):
142733
142822
  return runOnIosDevice(
142734
142823
  services.iosDeviceRunner,
142735
142824
  device.id,
142736
- params.events
142825
+ params.events,
142826
+ params.interpolate
142737
142827
  );
142738
142828
  }
142739
142829
  const api = services.simulatorServer;
@@ -142786,23 +142876,11 @@ var zodSchema22 = external_exports.object({
142786
142876
  )
142787
142877
  });
142788
142878
  var capability14 = {
142789
- apple: { simulator: true },
142879
+ apple: { simulator: true, device: true },
142790
142880
  appleRemote: { simulator: true },
142791
142881
  android: { emulator: true, device: true, unknown: true },
142792
142882
  harmony: { device: true }
142793
142883
  };
142794
- function contactsAt(params, t) {
142795
- const angleRad = (params.angle ?? 0) * Math.PI / 180;
142796
- const cosA = Math.cos(angleRad);
142797
- const sinA = Math.sin(angleRad);
142798
- const halfDist = (params.startDistance + (params.endDistance - params.startDistance) * t) / 2;
142799
- const cx = params.centerX + ((params.endCenterX ?? params.centerX) - params.centerX) * t;
142800
- const cy = params.centerY + ((params.endCenterY ?? params.centerY) - params.centerY) * t;
142801
- return {
142802
- first: { x: cx - halfDist * cosA, y: cy - halfDist * sinA },
142803
- second: { x: cx + halfDist * cosA, y: cy + halfDist * sinA }
142804
- };
142805
- }
142806
142884
  var gesturePinchTool = {
142807
142885
  id: "gesture-pinch",
142808
142886
  interaction: {
@@ -142814,12 +142892,13 @@ var gesturePinchTool = {
142814
142892
  startDistance > endDistance = pinch in (zoom out). startDistance < endDistance = pinch out (zoom in).
142815
142893
  Typical values: startDistance 0.2, endDistance 0.6 for a zoom-in pinch at screen center.
142816
142894
  Auto-generates interpolated frames at ~60fps<flag:harmonyos> on iOS and Android; HarmonyOS takes both fingers' endpoints in one call and interpolates them on-device</flag:harmonyos>. The angle parameter controls the axis (0 = horizontal, 90 = vertical). Optional endCenterX/endCenterY drift the centroid linearly over the gesture (omitted = fixed center).
142817
- Use when you need to zoom in or out on a map, image, or zoomable view. Returns { pinched: true, timestampMs }. Fails if the simulator-server / emulator backend<flag:harmonyos>, or \`hdc\` on HarmonyOS,</flag:harmonyos> is not reachable for the given device.`,
142895
+ Use when you need to zoom in or out on a map, image, or zoomable view. Physical iPhones play both fingers through the runner as one event stream. Returns { pinched: true, timestampMs }. On physical iOS, reactivated: true = app was re-fronted; re-describe. Fails if the simulator-server / emulator backend<flag:harmonyos>, or \`hdc\` on HarmonyOS,</flag:harmonyos> is not reachable for the given device, or on a physical iPhone when the app is not drawn in portrait.`,
142818
142896
  zodSchema: zodSchema22,
142819
142897
  capability: capability14,
142820
142898
  services: (params) => {
142821
142899
  const device = resolveDevice(params.udid);
142822
142900
  if (device.platform === "harmony") return {};
142901
+ if (isIosPhysicalDevice(device)) return { iosDeviceRunner: iosDeviceRunnerRef(device) };
142823
142902
  return { simulatorServer: simulatorServerRef(device) };
142824
142903
  },
142825
142904
  async execute(services, params, ctx) {
@@ -142844,8 +142923,8 @@ Use when you need to zoom in or out on a map, image, or zoomable view. Returns {
142844
142923
  )
142845
142924
  );
142846
142925
  assertHarmonyDisplayReady(live, "pinch");
142847
- const start2 = contactsAt(params, 0);
142848
- const end = contactsAt(params, 1);
142926
+ const start2 = pinchContacts(params, 0);
142927
+ const end = pinchContacts(params, 1);
142849
142928
  timestampMs2 = Date.now();
142850
142929
  await ui.multiTouchMove(
142851
142930
  [
@@ -142865,13 +142944,26 @@ Use when you need to zoom in or out on a map, image, or zoomable view. Returns {
142865
142944
  );
142866
142945
  return { pinched: true, timestampMs: timestampMs2 };
142867
142946
  }
142947
+ if (isIosPhysicalDevice(device)) {
142948
+ const runner = services.iosDeviceRunner;
142949
+ const bundleId = requireCurrentIosDeviceApp(device.id);
142950
+ const viewport = await getViewport(runner, bundleId);
142951
+ const timestampMs2 = Date.now();
142952
+ const { reactivated } = await touchPaths(
142953
+ runner,
142954
+ bundleId,
142955
+ pinchPaths(params, viewport, duration3)
142956
+ );
142957
+ const fronted = viewport.reactivated === true || reactivated;
142958
+ return { pinched: true, timestampMs: timestampMs2, ...fronted ? { reactivated: true } : {} };
142959
+ }
142868
142960
  const api = services.simulatorServer;
142869
142961
  const steps = Math.max(1, Math.round(duration3 / 16));
142870
142962
  let timestampMs = 0;
142871
142963
  let warning;
142872
142964
  for (let i = 0; i <= steps; i++) {
142873
142965
  const t = i / steps;
142874
- const { first, second } = contactsAt(params, t);
142966
+ const { first, second } = pinchContacts(params, t);
142875
142967
  const type = i === 0 ? "Down" : i === steps ? "Up" : "Move";
142876
142968
  if (i === 0) timestampMs = Date.now();
142877
142969
  const sent = await sendTouchEvent(api, type, first.x, first.y, second.x, second.y);
@@ -142911,7 +143003,7 @@ var zodSchema23 = external_exports.object({
142911
143003
  message: "Pass radius, or both radiusX and radiusY."
142912
143004
  });
142913
143005
  var capability15 = {
142914
- apple: { simulator: true },
143006
+ apple: { simulator: true, device: true },
142915
143007
  appleRemote: { simulator: true },
142916
143008
  android: { emulator: true, device: true, unknown: true }
142917
143009
  };
@@ -142934,19 +143026,35 @@ var gestureRotateTool = {
142934
143026
  endAngle > startAngle = clockwise rotation. Typical values: radius 0.15, startAngle 0, endAngle 90 for a 90\xB0 clockwise turn. A single radius applies to both axes, so on a non-square screen it traces a physical ellipse (finger separation varies through the turn); pass radiusX+radiusY (fractions of width/height with radiusX\xB7width = radiusY\xB7height) for a physically circular orbit instead.
142935
143027
  Auto-generates interpolated frames at ~60fps.
142936
143028
  Unlike gesture-pinch which moves fingers linearly to zoom, this orbits fingers in an arc to change orientation.
142937
- Use when you need to rotate a map, image picker, or any rotateable UI element. Returns { rotated: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.
143029
+ Use when you need to rotate a map, image picker, or any rotateable UI element. Returns { rotated: true, timestampMs }. On physical iOS, reactivated: true = app was re-fronted; re-describe. Fails if the simulator-server / emulator backend is not reachable for the given device, or on a physical iPhone when the app is not drawn in portrait.
142938
143030
  Size the orbit with radius, or with radiusX and radiusY together (the pair overrides radius); one half of the pair alone, or none of the three, is rejected.`,
142939
143031
  zodSchema: zodSchema23,
142940
143032
  capability: capability15,
142941
- services: (params) => ({
142942
- simulatorServer: simulatorServerRef(resolveDevice(params.udid))
142943
- }),
143033
+ services: (params) => {
143034
+ const device = resolveDevice(params.udid);
143035
+ return isIosPhysicalDevice(device) ? { iosDeviceRunner: iosDeviceRunnerRef(device) } : { simulatorServer: simulatorServerRef(device) };
143036
+ },
142944
143037
  async execute(services, params, ctx) {
142945
- const api = services.simulatorServer;
142946
143038
  const duration3 = params.durationMs ?? 300;
142947
143039
  const steps = Math.max(1, Math.round(duration3 / 16));
142948
143040
  const radiusX = params.radiusX ?? params.radius;
142949
143041
  const radiusY = params.radiusY ?? params.radius;
143042
+ const geometry = { ...params, radiusX, radiusY };
143043
+ const device = resolveDevice(params.udid);
143044
+ if (isIosPhysicalDevice(device)) {
143045
+ const runner = services.iosDeviceRunner;
143046
+ const bundleId = requireCurrentIosDeviceApp(device.id);
143047
+ const viewport = await getViewport(runner, bundleId);
143048
+ const timestampMs2 = Date.now();
143049
+ const { reactivated } = await touchPaths(
143050
+ runner,
143051
+ bundleId,
143052
+ rotatePaths(geometry, viewport, duration3)
143053
+ );
143054
+ const fronted = viewport.reactivated === true || reactivated;
143055
+ return { rotated: true, timestampMs: timestampMs2, ...fronted ? { reactivated: true } : {} };
143056
+ }
143057
+ const api = services.simulatorServer;
142950
143058
  let timestampMs = 0;
142951
143059
  let warning;
142952
143060
  let lastX1 = 0;
@@ -142968,13 +143076,10 @@ Size the orbit with radius, or with radiusX and radiusY together (the pair overr
142968
143076
  }
142969
143077
  throw err;
142970
143078
  }
142971
- const t = i / steps;
142972
- const angleDeg = params.startAngle + (params.endAngle - params.startAngle) * t;
142973
- const angleRad = angleDeg * Math.PI / 180;
142974
- const x1 = params.centerX + radiusX * Math.cos(angleRad);
142975
- const y1 = params.centerY + radiusY * Math.sin(angleRad);
142976
- const x2 = params.centerX - radiusX * Math.cos(angleRad);
142977
- const y2 = params.centerY - radiusY * Math.sin(angleRad);
143079
+ const {
143080
+ first: { x: x1, y: y1 },
143081
+ second: { x: x2, y: y2 }
143082
+ } = rotateContacts(geometry, i / steps);
142978
143083
  const type = i === 0 ? "Down" : i === steps ? "Up" : "Move";
142979
143084
  if (i === 0) timestampMs = Date.now();
142980
143085
  const sent = await sendTouchEvent(api, type, x1, y1, x2, y2);
@@ -151291,7 +151396,7 @@ function createRunSequenceTool(registry2) {
151291
151396
  failedMsg: ({ failureSignal: failureSignal2 }) => `Failed to run interaction sequence: ${failureSignal2.error_code}`
151292
151397
  },
151293
151398
  description: `Execute multiple device interaction steps in a single call (iOS simulator or physical device, Android emulator, Apple TV / Android TV, <flag:harmonyos>HarmonyOS device, </flag:harmonyos>or Chromium app).
151294
- On a physical iOS device only gesture-tap, gesture-swipe, gesture-custom, button, keyboard, and await-ui-element steps run; others fail at their own gate.
151399
+ On a physical iOS device only gesture-tap, gesture-swipe, gesture-custom, gesture-pinch, gesture-rotate, button, keyboard, and await-ui-element steps run; others fail at their own gate.
151295
151400
  Use when you need sequential actions and do NOT need to observe the screen between them
151296
151401
  (e.g. scrolling multiple times, typing then pressing enter, rotating back and forth).
151297
151402
  Returns { completed, total, steps } with per-step results. Fails if an unrecognised tool name is used in a step (error returned at that step, execution stops).
@@ -153865,7 +153970,7 @@ function formatDescribeTree(root, opts) {
153865
153970
  );
153866
153971
  } else {
153867
153972
  header.push(
153868
- opts.source === "xcuitest-runner" ? "Pass them straight to gesture-tap / gesture-swipe, which expect this same space. No two-finger gestures on physical iOS." : "Pass them straight to gesture-tap / gesture-swipe / gesture-pinch, which expect this same space."
153973
+ "Pass them straight to gesture-tap / gesture-swipe / gesture-pinch, which expect this same space."
153869
153974
  );
153870
153975
  header.push(
153871
153976
  "To tap an element, use its centre: tap_x = frame.x + frame.width / 2, tap_y = frame.y + frame.height / 2."
@@ -169569,12 +169674,6 @@ async function runDirective(env, step) {
169569
169674
  reason: step.kind === "long-press" ? "long-press is unsupported on HarmonyOS \u2014 `uitest uiInput` has a `longClick` verb, but it takes no hold duration and argent drives holds through `gesture-custom`, which has no HarmonyOS backend; use `tap`" : "rotate is unsupported on HarmonyOS \u2014 `uinput -T -m` is the platform's only multi-contact injection and it moves each finger along ONE straight line per call, with no way to chain segments into an arc; drive the app's own rotate control with `tap`, or `pinch`, which is a straight-line gesture and does run here"
169570
169675
  };
169571
169676
  }
169572
- if ((step.kind === "pinch" || step.kind === "rotate") && isIosPhysicalDevice(env.device)) {
169573
- return {
169574
- ok: false,
169575
- reason: step.kind === "pinch" ? "pinch is unsupported on a physical iOS device: XCTest exposes no two-finger coordinate API on hardware; run this e2e test on a simulator or drive the app's zoom UI with tap steps instead" : "rotate is unsupported on a physical iOS device: XCTest exposes no two-finger coordinate API on hardware; run this e2e test on a simulator or drive the app's rotate controls with tap steps instead"
169576
- };
169577
- }
169578
169677
  if (step.kind === "idle" && !supportsFlowTree(env.device.platform)) {
169579
169678
  return {
169580
169679
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.31.1-next.11",
3
+ "version": "0.31.1-next.13",
4
4
  "mcpName": "io.github.software-mansion/argent",
5
5
  "description": "MCP server for iOS Simulator and Android Emulator control",
6
6
  "license": "Apache-2.0",
package/rules/argent.md CHANGED
@@ -116,7 +116,7 @@ When: Beginning a task that involves the Android emulator, no emulator running y
116
116
 
117
117
  PHYSICAL iPHONE (USB)
118
118
  Skills: `argent-ios-device-setup` (cable, trust, signing), then `argent-ios-device-interact` (the app-scoped interaction contract)
119
- When: The user names a physical iPhone, a real device, or hardware, or the target `list-devices` iOS entry has kind `"device"`. Never for a simulator, and never because a cabled phone is listed first. On hardware every interaction starts with `launch-app`; `paste`, `settings-permissions`, two-finger gestures, `rotate`, `shake`, screen recording, `debugger-*`, `react-profiler-*`, `native-profiler-*` and `native-*` do not exist there.
119
+ When: The user names a physical iPhone, a real device, or hardware, or the target `list-devices` iOS entry has kind `"device"`. Never for a simulator, and never because a cabled phone is listed first. On hardware every interaction starts with `launch-app`; `paste`, `settings-permissions`, `rotate`, `shake`, screen recording, `debugger-*`, `react-profiler-*`, `native-profiler-*` and `native-*` do not exist there.
120
120
  Prompt keywords: physical iPhone, real device, on my phone, USB, hardware
121
121
 
122
122
  TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING
@@ -13,7 +13,7 @@ For a saved QA test case, ticket, or acceptance criterion, load `argent-qa-e2e`
13
13
 
14
14
  - Before creating or changing an e2e test, read [Live authoring](references/live-authoring.md) completely.
15
15
  - When polishing, composing, or manually reviewing YAML, read [E2E YAML](references/e2e-yaml.md). For Vega, read its platform limits before recording remote or keyboard tools.
16
- - E2E tests run on physical iPhones (an iOS `list-devices` entry with kind `"device"`), but replay never auto-binds one, even when no simulator is booted: pass the phone's udid as `device` (CLI `--device`), and only a `connected` phone can run. `pinch`/`rotate` steps fail there like the live tools. On hardware the runner tree is the `describe` tree: same ids and roles, no UIView hierarchy. See `argent-ios-device-interact` for the hardware contract.
16
+ - E2E tests run on physical iPhones (an iOS `list-devices` entry with kind `"device"`), but replay never auto-binds one, even when no simulator is booted: pass the phone's udid as `device` (CLI `--device`), and only a `connected` phone can run. `pinch`/`rotate` steps run there as two-finger gestures, in apps drawn in portrait. On hardware the runner tree is the `describe` tree: same ids and roles, no UIView hierarchy. See `argent-ios-device-interact` for the hardware contract.
17
17
  - On capture warnings, raw coordinates, unavailable trees, mistimed transitions, overlays, or replay failures, read [Reliability and recovery](references/reliability-and-recovery.md).
18
18
 
19
19
  ## Non-negotiable rules
@@ -11,10 +11,10 @@ Read this only for a physical iPhone. `argent-device-interact` still applies for
11
11
 
12
12
  - Observation never changes the screen; mutation may. `describe` and `await-ui-element` fail on a backgrounded target instead of re-fronting it; `await-screen-idle` returns `settled: false` with no reason. The first `describe` within a second of `button home` can still return the old tree; describe again. Gestures and `keyboard` re-front it and return `reactivated: true`; re-describe before the next step.
13
13
  - `launch-app`, `open-url` and `restart-app` set the app under automation; `reinstall-app` clears it; a tool-server restart forgets it: launch again.
14
- - Each tool's description states its own hardware limits (named keys, `gesture-custom` shapes, buttons, edge swipes, tap counts). A gated tool fails with a bare `not supported on ios device`: the fix is in the list below, not in the error.
14
+ - Each tool's description states its own hardware limits (named keys, buttons, edge swipes, tap counts). A gated tool fails with a bare `not supported on ios device`: the fix is in the list below, not in the error.
15
15
 
16
16
  ## Tools on hardware
17
17
 
18
- - Only these exist: `list-devices`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `describe`, `screenshot`, `screenshot-diff`, `gesture-tap`, `gesture-swipe`, `gesture-custom`, `button` (`home`, `volumeUp`, `volumeDown`, `actionButton` on models that have one), `keyboard`, `await-ui-element`, `await-screen-idle`, `run-sequence`, the e2e tools, `stop-simulator-server` and `stop-all-simulator-servers` (session end). Every other device tool fails with `not supported on ios device`.
19
- - No two-finger gestures, `rotate`, `shake`, `paste`, `settings-permissions`, screen recording, `debugger-*`, `react-profiler-*`, `native-profiler-*`, `native-*`, `boot-device`: drive the app's own zoom and rotate UI with taps and drags, move the phone by hand, type with `keyboard`, change permissions in the phone's Settings, and debug, profile or record on a simulator.
18
+ - Only these exist: `list-devices`, `launch-app`, `restart-app`, `reinstall-app`, `open-url`, `describe`, `screenshot`, `screenshot-diff`, `gesture-tap`, `gesture-swipe`, `gesture-custom`, `gesture-pinch`, `gesture-rotate`, `button` (`home`, `volumeUp`, `volumeDown`, `actionButton` on models that have one), `keyboard`, `await-ui-element`, `await-screen-idle`, `run-sequence`, the e2e tools, `stop-simulator-server` and `stop-all-simulator-servers` (session end). Every other device tool fails with `not supported on ios device`.
19
+ - No `rotate` (the orientation tool), `shake`, `paste`, `settings-permissions`, screen recording, `debugger-*`, `react-profiler-*`, `native-profiler-*`, `native-*`, `boot-device`: move the phone by hand, type with `keyboard`, change permissions in the phone's Settings, and debug, profile or record on a simulator.
20
20
  - `gesture-swipe`: `durationMs` sets drag speed, not time; `momentum:false` only rests 300 ms at the end, no damping. `open-url` https lands in Safari, never in the app that owns the link: pass `bundleId`.
@@ -11,7 +11,7 @@ Load `argent-create-e2e` as the authoring engine. Follow its required references
11
11
 
12
12
  **Apple TV and Android TV are out of scope.** The runner does not reject touch directives there, so they fail at the gesture layer instead of with authoring guidance. Use `argent-tv-interact` and report the limitation.
13
13
 
14
- **Physical iPhones** run QA e2e tests, with three hardware limits: replay never auto-binds a phone, so pass its udid as `device` (CLI `--device`) and keep it `connected`; `pinch`/`rotate` steps fail there like the live tools, so drive the app's own zoom UI instead; the runner tree is the `describe` tree (same ids and roles), so a selector authored on a simulator can miss there. Read `argent-ios-device-interact` for the app-scoped contract before recording.
14
+ **Physical iPhones** run QA e2e tests, with two hardware limits: replay never auto-binds a phone, so pass its udid as `device` (CLI `--device`) and keep it `connected`; the runner tree is the `describe` tree (same ids and roles), so a selector authored on a simulator can miss there. `pinch`/`rotate` steps run as two-finger gestures, in apps drawn in portrait. Read `argent-ios-device-interact` for the app-scoped contract before recording.
15
15
 
16
16
  ## Definition of done
17
17