Skip to content

Commit cd9f74f

Browse files
committed
fix: preserve gesture compatibility contracts
1 parent bd407c0 commit cd9f74f

12 files changed

Lines changed: 330 additions & 20 deletions

File tree

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ NS_ASSUME_NONNULL_BEGIN
44

55
@interface RunnerSynthesizedGesture : NSObject
66

7+
+ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
8+
x:(double)x
9+
y:(double)y
10+
x2:(double)x2
11+
y2:(double)y2
12+
durationMs:(double)durationMs;
13+
714
+ (NSString * _Nullable)synthesizeContinuousDragWithApplication:(id)application
815
x:(double)x
916
y:(double)y

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ typedef id (*RunnerDragPointerPathFactory)(
4040
static NSString * _Nullable RunnerRequireClass(Class cls, NSString *className);
4141
static NSString * _Nullable RunnerRequireSelector(Class cls, SEL selector, NSString *selectorName);
4242
static NSString * _Nullable RunnerRequireApplicationSelector(id application, SEL selector, NSString *selectorName);
43+
static id RunnerSwipePointerPath(
44+
const RunnerXCTestEventBridge *bridge,
45+
CGPoint start,
46+
CGPoint end,
47+
double durationMs
48+
);
4349
static id RunnerContinuousDragPointerPath(
4450
const RunnerXCTestEventBridge *bridge,
4551
CGPoint start,
@@ -54,13 +60,38 @@ static id RunnerContinuousDragPointerPath(
5460
NSString *recordName,
5561
RunnerDragPointerPathFactory pathFactory
5662
);
63+
// XCTest's proven swipe profile reaches the endpoint in 100 ms, then holds for the planned
64+
// fling duration. Fast movement is what lets UIKit distinguish a fling from a timed pan.
65+
static const NSTimeInterval RunnerSwipeMovementDurationSeconds = 0.1;
5766
static id RunnerTapPointerPath(
5867
const RunnerXCTestEventBridge *bridge,
5968
CGPoint point
6069
);
6170

6271
@implementation RunnerSynthesizedGesture
6372

73+
+ (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
74+
x:(double)x
75+
y:(double)y
76+
x2:(double)x2
77+
y2:(double)y2
78+
durationMs:(double)durationMs {
79+
@try {
80+
return RunnerTrySynthesizeDrag(
81+
application,
82+
CGPointMake(x, y),
83+
CGPointMake(x2, y2),
84+
durationMs,
85+
@"agent-device-swipe",
86+
RunnerSwipePointerPath
87+
);
88+
} @catch (NSException *exception) {
89+
NSString *name = exception.name ?: @"NSException";
90+
NSString *reason = exception.reason ?: @"private XCTest event synthesis failed";
91+
return [NSString stringWithFormat:@"%@: %@", name, reason];
92+
}
93+
}
94+
6495
+ (NSString * _Nullable)synthesizeContinuousDragWithApplication:(id)application
6596
x:(double)x
6697
y:(double)y
@@ -352,6 +383,33 @@ + (NSString * _Nullable)trySynthesizeTapWithApplication:(id)application
352383
}
353384

354385

386+
static id RunnerSwipePointerPath(
387+
const RunnerXCTestEventBridge *bridge,
388+
CGPoint start,
389+
CGPoint end,
390+
double durationMs
391+
) {
392+
id path =
393+
((RunnerMsgSendInitPath)objc_msgSend)([bridge->pathClass alloc], bridge->initPathSelector, start, 0.0);
394+
if (path == nil) {
395+
return nil;
396+
}
397+
398+
NSTimeInterval durationSeconds = durationMs / 1000.0;
399+
((RunnerMsgSendPathMove)objc_msgSend)(
400+
path,
401+
bridge->moveSelector,
402+
end,
403+
RunnerSwipeMovementDurationSeconds
404+
);
405+
((RunnerMsgSendPathOffset)objc_msgSend)(
406+
path,
407+
bridge->liftSelector,
408+
RunnerSwipeMovementDurationSeconds + durationSeconds
409+
);
410+
return path;
411+
}
412+
355413
static id RunnerContinuousDragPointerPath(
356414
const RunnerXCTestEventBridge *bridge,
357415
CGPoint start,

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ extension RunnerTests {
133133
return Response(ok: true, data: data)
134134
}
135135

136+
/// Gesture plans already return canonical centroid endpoints from the portable runtime.
137+
/// Keep runner timing/fallback diagnostics, but do not leak the coordinate-drag adapter's
138+
/// visualization frame into only the fast-fling response shape.
139+
private func canonicalPlannedGestureResponse(_ response: Response) -> Response {
140+
guard response.ok, let data = response.data else { return response }
141+
return Response(
142+
ok: true,
143+
data: DataPayload(
144+
message: data.message,
145+
gestureStartUptimeMs: data.gestureStartUptimeMs,
146+
gestureEndUptimeMs: data.gestureEndUptimeMs,
147+
gestureFallback: data.gestureFallback,
148+
gestureFallbackMessage: data.gestureFallbackMessage,
149+
gestureFallbackHint: data.gestureFallbackHint
150+
)
151+
)
152+
}
153+
136154
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
137155
func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
138156
let response = gestureResponse(
@@ -165,6 +183,42 @@ extension RunnerTests {
165183
XCTAssertEqual(response.data?.maestroNonHittableCoordinateFallbackUsed, true)
166184
}
167185

186+
func testCanonicalPlannedGestureResponseOmitsDragFrameAndPreservesDiagnostics() {
187+
let response = gestureResponse(
188+
message: "fling",
189+
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
190+
frame: .drag(
191+
DragVisualizationFrame(
192+
x: 160,
193+
y: 150,
194+
x2: 40,
195+
y2: 150,
196+
referenceWidth: 200,
197+
referenceHeight: 300
198+
)
199+
),
200+
fallback: GestureFallback(
201+
strategy: "xctest-coordinate-drag",
202+
message: "Private synthesis unavailable",
203+
hint: "Using XCTest coordinate fallback."
204+
)
205+
)
206+
207+
let canonical = canonicalPlannedGestureResponse(response)
208+
209+
XCTAssertEqual(canonical.data?.gestureStartUptimeMs, 1)
210+
XCTAssertEqual(canonical.data?.gestureEndUptimeMs, 2)
211+
XCTAssertEqual(canonical.data?.gestureFallback, "xctest-coordinate-drag")
212+
XCTAssertEqual(canonical.data?.gestureFallbackMessage, "Private synthesis unavailable")
213+
XCTAssertEqual(canonical.data?.gestureFallbackHint, "Using XCTest coordinate fallback.")
214+
XCTAssertNil(canonical.data?.x)
215+
XCTAssertNil(canonical.data?.y)
216+
XCTAssertNil(canonical.data?.x2)
217+
XCTAssertNil(canonical.data?.y2)
218+
XCTAssertNil(canonical.data?.referenceWidth)
219+
XCTAssertNil(canonical.data?.referenceHeight)
220+
}
221+
168222
func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
169223
let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
170224
let response = Response(ok: true, data: DataPayload(message: "tapped"))
@@ -1482,8 +1536,27 @@ extension RunnerTests {
14821536
error: ErrorPayload(code: "INVALID_ARGS", message: validationError)
14831537
)
14841538
}
1539+
if plannedGestureExecution(for: plan) == .fastSwipe {
1540+
// Validation above guarantees a non-empty, single-pointer path for this execution kind.
1541+
let first = plan.pointers[0].samples.first!.point
1542+
let last = plan.pointers[0].samples.last!.point
1543+
return canonicalPlannedGestureResponse(
1544+
executeDragGesture(
1545+
activeApp: activeApp,
1546+
x: first.x,
1547+
y: first.y,
1548+
x2: last.x,
1549+
y2: last.y,
1550+
durationMs: plan.durationMs,
1551+
synthesized: true,
1552+
message: plan.intent,
1553+
synthesizedPolicyKind: .synthesizedDrag,
1554+
synthesizedProfile: .fastSwipe
1555+
)
1556+
)
1557+
}
14851558
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
1486-
plannedGesture(app: activeApp, plan: plan)
1559+
sampledPlannedGesture(app: activeApp, plan: plan)
14871560
}
14881561
if let response = unsupportedResponse(for: outcome) {
14891562
return response
@@ -1513,7 +1586,8 @@ extension RunnerTests {
15131586
synthesized: Bool,
15141587
message: String,
15151588
synthesizedContext: SynthesizedCoordinateContext? = nil,
1516-
synthesizedPolicyKind: SynthesizedGesturePolicyKind
1589+
synthesizedPolicyKind: SynthesizedGesturePolicyKind,
1590+
synthesizedProfile: SynthesizedDragProfile = .continuous
15171591
) -> Response {
15181592
let commandName = dragCommandName(message: message)
15191593
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
@@ -1531,7 +1605,8 @@ extension RunnerTests {
15311605
durationMs: durationMs,
15321606
message: message,
15331607
context: synthesizedContext,
1534-
policyKind: synthesizedPolicyKind
1608+
policyKind: synthesizedPolicyKind,
1609+
profile: synthesizedProfile
15351610
) {
15361611
return synthesizedResponse
15371612
}
@@ -1555,6 +1630,7 @@ extension RunnerTests {
15551630
x2: dragPoints.x2,
15561631
y2: dragPoints.y2,
15571632
durationMs: durationMs,
1633+
profile: synthesizedProfile,
15581634
context: context
15591635
)
15601636
}
@@ -1596,7 +1672,8 @@ extension RunnerTests {
15961672
durationMs: Double?,
15971673
message: String,
15981674
context: SynthesizedCoordinateContext?,
1599-
policyKind: SynthesizedGesturePolicyKind
1675+
policyKind: SynthesizedGesturePolicyKind,
1676+
profile: SynthesizedDragProfile
16001677
) -> Response? {
16011678
#if os(iOS)
16021679
let policy = synthesizedGesturePolicy(policyKind)
@@ -1648,6 +1725,7 @@ extension RunnerTests {
16481725
x2: plan.points.x2,
16491726
y2: plan.points.y2,
16501727
durationMs: durationMs,
1728+
profile: profile,
16511729
context: plan.context
16521730
)
16531731
}

apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ private enum RunnerInterfaceOrientation {
1313
}
1414

1515
extension RunnerTests {
16+
enum PlannedGestureExecution: Equatable {
17+
case fastSwipe
18+
case sampled
19+
}
20+
21+
enum SynthesizedDragProfile {
22+
case continuous
23+
case fastSwipe
24+
}
25+
1626
struct TouchVisualizationFrame {
1727
let x: Double
1828
let y: Double
@@ -775,6 +785,7 @@ extension RunnerTests {
775785
x2: Double,
776786
y2: Double,
777787
durationMs: Double,
788+
profile: SynthesizedDragProfile = .continuous,
778789
context: SynthesizedCoordinateContext? = nil
779790
) -> RunnerInteractionOutcome {
780791
#if os(iOS)
@@ -794,14 +805,26 @@ extension RunnerTests {
794805
let frame = context.referenceFrame
795806
let start = nativeSynthesizedPoint(orientedX: x, orientedY: y, in: frame, interfaceOrientation: orientation)
796807
let end = nativeSynthesizedPoint(orientedX: x2, orientedY: y2, in: frame, interfaceOrientation: orientation)
797-
let message = RunnerSynthesizedGesture.synthesizeContinuousDrag(
798-
withApplication: app,
799-
x: Double(start.x),
800-
y: Double(start.y),
801-
x2: Double(end.x),
802-
y2: Double(end.y),
803-
durationMs: durationMs
804-
)
808+
let message = switch profile {
809+
case .continuous:
810+
RunnerSynthesizedGesture.synthesizeContinuousDrag(
811+
withApplication: app,
812+
x: Double(start.x),
813+
y: Double(start.y),
814+
x2: Double(end.x),
815+
y2: Double(end.y),
816+
durationMs: durationMs
817+
)
818+
case .fastSwipe:
819+
RunnerSynthesizedGesture.synthesizeSwipe(
820+
withApplication: app,
821+
x: Double(start.x),
822+
y: Double(start.y),
823+
x2: Double(end.x),
824+
y2: Double(end.y),
825+
durationMs: durationMs
826+
)
827+
}
805828
if let message {
806829
return .unsupported(
807830
message: message,
@@ -1221,7 +1244,11 @@ extension RunnerTests {
12211244
return nil
12221245
}
12231246

1224-
func plannedGesture(
1247+
func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
1248+
plan.topology == "single" && plan.intent == "fling" ? .fastSwipe : .sampled
1249+
}
1250+
1251+
func sampledPlannedGesture(
12251252
app: XCUIApplication,
12261253
plan: RunnerGesturePlan
12271254
) -> RunnerInteractionOutcome {
@@ -1448,6 +1475,28 @@ extension RunnerTests {
14481475
)
14491476
}
14501477

1478+
func testSinglePointerFlingUsesFastSwipeExecution() throws {
1479+
let plan = try JSONDecoder().decode(
1480+
RunnerGesturePlan.self,
1481+
from: Data(
1482+
#"{"topology":"single","intent":"fling","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}"#.utf8
1483+
)
1484+
)
1485+
1486+
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
1487+
}
1488+
1489+
func testSinglePointerTimedPanUsesSampledExecution() throws {
1490+
let plan = try JSONDecoder().decode(
1491+
RunnerGesturePlan.self,
1492+
from: Data(
1493+
#"{"topology":"single","intent":"pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":250,"point":{"x":100,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
1494+
)
1495+
)
1496+
1497+
XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
1498+
}
1499+
14511500
func testDesktopScrollWheelDeltasMapDirections() throws {
14521501
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "up", pixels: 120)).vertical, 120)
14531502
XCTAssertEqual(try XCTUnwrap(desktopScrollWheelDeltas(direction: "down", pixels: 120)).vertical, -120)

src/commands/interaction/metadata.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,10 @@ const gestureFields = {
204204
distance: integerField('Fling distance.', { min: 0 }),
205205
scale: numberField('Pinch or transform scale.'),
206206
degrees: numberField('Rotation in degrees.'),
207-
velocity: integerField('Deprecated: rotation pacing is derived from degrees.', { min: 0 }),
207+
velocity: numberField('Deprecated: rotation pacing is derived from degrees; must be non-zero.'),
208208
durationMs: integerField(
209209
'Pan/transform duration. Deprecated on swipe/fling; timed movement is a pan.',
210-
{ min: 0 },
210+
{ min: 16, max: 10_000 },
211211
),
212212
pointerCount: integerField('Pan touch pointer count (1 or 2).', { min: 1, max: 2 }),
213213
};

src/contracts/gesture-input.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,20 @@ test('structured gesture input rejects durations outside the planner range', ()
4242
);
4343
}
4444
});
45+
46+
test('deprecated rotate velocity accepts finite non-zero compatibility values', () => {
47+
for (const velocity of [-2.5, -1, 0.25, 3]) {
48+
assert.deepEqual(readGesturePayload({ kind: 'rotate', degrees: 45, velocity }), {
49+
kind: 'rotate',
50+
degrees: 45,
51+
origin: undefined,
52+
velocity,
53+
});
54+
}
55+
56+
for (const velocity of [0, Number.NaN, Number.POSITIVE_INFINITY]) {
57+
assert.throws(() => readGesturePayload({ kind: 'rotate', degrees: 45, velocity }), {
58+
code: 'INVALID_ARGS',
59+
});
60+
}
61+
});

0 commit comments

Comments
 (0)