Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

NS_ASSUME_NONNULL_BEGIN

FOUNDATION_EXPORT NSInteger RunnerContinuousDragFrameCount(double durationMs);
FOUNDATION_EXPORT NSInteger RunnerControlledScrollFrameCount(double durationMs);
FOUNDATION_EXPORT double RunnerControlledScrollProgress(double t);

Expand All @@ -19,14 +18,6 @@ FOUNDATION_EXPORT double RunnerControlledScrollProgress(double t);
y2:(double)y2
durationMs:(double)durationMs;

+ (NSString * _Nullable)synthesizeContinuousDragWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs;

+ (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,6 @@ static id RunnerSwipePointerPath(
CGPoint end,
double durationMs
);
static id RunnerContinuousDragPointerPath(
const RunnerGestureEventBridge *bridge,
CGPoint start,
CGPoint end,
double durationMs
);
static id RunnerControlledScrollPointerPath(
const RunnerGestureEventBridge *bridge,
CGPoint start,
Expand All @@ -72,7 +66,6 @@ static id RunnerSampledDragPointerPath(
RunnerDragProgressFunction progress,
NSInteger frameCount
);
static double RunnerSmoothstepProgress(double t);
static NSString * _Nullable RunnerTrySynthesizeDrag(
id application,
id _Nullable resolvedWindow,
Expand Down Expand Up @@ -117,28 +110,6 @@ + (NSString * _Nullable)synthesizeSwipeWithApplication:(id)application
}
}

+ (NSString * _Nullable)synthesizeContinuousDragWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
y:(double)y
x2:(double)x2
y2:(double)y2
durationMs:(double)durationMs {
@try {
return RunnerTrySynthesizeDrag(
application,
resolvedWindow,
CGPointMake(x, y),
CGPointMake(x2, y2),
durationMs,
@"agent-device-continuous-drag",
RunnerContinuousDragPointerPath
);
} @catch (NSException *exception) {
return RunnerFormatXCTestException(exception, @"private XCTest event synthesis failed");
}
}

+ (NSString * _Nullable)synthesizeControlledScrollWithApplication:(id)application
resolvedWindow:(id _Nullable)resolvedWindow
x:(double)x
Expand Down Expand Up @@ -411,25 +382,6 @@ static id RunnerSwipePointerPath(
return path;
}

static id RunnerContinuousDragPointerPath(
const RunnerGestureEventBridge *bridge,
CGPoint start,
CGPoint end,
double durationMs
) {
// This is velocity shaping, not just interpolation density: smoothstep's endpoint slope is zero,
// while a planned linear segment reaches lift with nonzero velocity unless a destination hold
// follows it. UIKit uses finger-up velocity for scroll deceleration. See ADR 0013 and issue #1586.
return RunnerSampledDragPointerPath(
bridge,
start,
end,
durationMs,
RunnerSmoothstepProgress,
RunnerContinuousDragFrameCount(durationMs)
);
}

static id RunnerControlledScrollPointerPath(
const RunnerGestureEventBridge *bridge,
CGPoint start,
Expand Down Expand Up @@ -478,14 +430,6 @@ static id RunnerSampledDragPointerPath(
return path;
}

static double RunnerSmoothstepProgress(double t) {
return t * t * (3.0 - 2.0 * t);
}

NSInteger RunnerContinuousDragFrameCount(double durationMs) {
return MAX(3, (NSInteger)(durationMs / RunnerDragSampleIntervalMs));
}

NSInteger RunnerControlledScrollFrameCount(double durationMs) {
return MIN(
RunnerControlledScrollMaxFrameCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ extension RunnerTests {
app.terminate()
}
let drag = try runnerCommandFixture(
#"{"command":"drag","commandId":"drag-1","x":10,"y":20,"x2":30,"y2":40,"synthesized":true}"#
#"{"command":"drag","commandId":"drag-1","x":10,"y":20,"x2":30,"y2":40}"#
)
let scroll = try runnerCommandFixture(
#"{"command":"scroll","commandId":"scroll-1","direction":"down","pixels":400}"#
Expand Down Expand Up @@ -1867,9 +1867,7 @@ extension RunnerTests {
x2: x2,
y2: y2,
durationMs: defaults.durationMs,
synthesized: command.synthesized == true,
message: "dragged",
synthesizedPolicyKind: .synthesizedDrag
message: "dragged"
)
case .scroll:
// Fused frame-resolve + drag scroll for non-tvOS. On iOS this intentionally stays on the
Expand Down Expand Up @@ -2240,10 +2238,8 @@ extension RunnerTests {
x2: last.x,
y2: last.y,
durationMs: plan.durationMs,
synthesized: true,
message: plan.intent,
synthesizedPolicyKind: .synthesizedDrag,
synthesizedProfile: .fastSwipe
synthesized: (profile: .fastSwipe, policyKind: .synthesizedDrag)
)
)
case .sampled:
Expand Down Expand Up @@ -2307,11 +2303,9 @@ extension RunnerTests {
x2: x2,
y2: y2,
durationMs: durationMs,
synthesized: true,
message: message,
synthesizedContext: context,
synthesizedPolicyKind: .scroll,
synthesizedProfile: scrollDragProfile(releaseBehavior: releaseBehavior)
synthesized: (profile: scrollDragProfile(releaseBehavior: releaseBehavior), policyKind: .scroll)
)
#else
return executeDragGesture(
Expand All @@ -2321,27 +2315,23 @@ extension RunnerTests {
x2: x2,
y2: y2,
durationMs: durationMs,
synthesized: false,
message: message,
synthesizedPolicyKind: .scroll
message: message
)
#endif
}

/// Shared drag execution for explicit drag commands. The iOS synthesized lane keeps its
/// fallback policy explicit; viewport scrolling owns a separate single drag specification.
/// Shared coordinate drag execution. Callers that pass `synthesized` take the iOS synthesized
/// lane with that profile and fallback policy; the rest perform an XCTest coordinate drag.
private func executeDragGesture(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double?,
synthesized: Bool,
message: String,
synthesizedContext: SynthesizedCoordinateContext? = nil,
synthesizedPolicyKind: SynthesizedGesturePolicyKind,
synthesizedProfile: SynthesizedDragProfile = .continuous
synthesized: (profile: SynthesizedDragProfile, policyKind: SynthesizedGesturePolicyKind)? = nil
) -> Response {
let durationMs = durationMs ?? runnerDefaultDragDurationMs
let commandName = dragCommandName(message: message)
Expand All @@ -2351,7 +2341,7 @@ extension RunnerTests {
error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates")
)
}
if synthesized, let synthesizedResponse = executeSynthesizedDragGesture(
if let synthesized, let synthesizedResponse = executeSynthesizedDragGesture(
activeApp: activeApp,
x: x,
y: y,
Expand All @@ -2360,8 +2350,8 @@ extension RunnerTests {
durationMs: durationMs,
message: message,
context: synthesizedContext,
policyKind: synthesizedPolicyKind,
profile: synthesizedProfile
policyKind: synthesized.policyKind,
profile: synthesized.profile
) {
return synthesizedResponse
}
Expand All @@ -2373,33 +2363,9 @@ extension RunnerTests {
x2: dragPoints.x2,
y2: dragPoints.y2
)
var fallback: GestureFallback?
if synthesized {
let durationMs = min(max(durationMs, 16), 10000)
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(synthesizedPolicyKind)
)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedDragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
durationMs: durationMs,
profile: synthesizedProfile,
context: context
)
}
if case .performed = outcome {
return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
}
fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
}
let holdDuration = synthesized
? synthesizedSwipeFallbackHoldDuration(durationMs: durationMs)
: coordinateDragHoldDuration()
let holdDuration = synthesized == nil
? coordinateDragHoldDuration()
: synthesizedSwipeFallbackHoldDuration(durationMs: durationMs)
let (timing, outcome) = performGesture(activeApp) {
dragAt(
app: activeApp,
Expand All @@ -2413,12 +2379,7 @@ extension RunnerTests {
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(
message: message,
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
)
return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
}

private func executeSynthesizedDragGesture(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ extension RunnerTests {
}

enum SynthesizedDragProfile: Equatable {
case continuous
case controlledScroll
case fastSwipe
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,6 @@ extension RunnerTests {
XCTAssertLessThan(finalSampleTravel, 0.1)
}

func testContinuousDragRetainsSixteenMillisecondSamplingAtLongDurations() {
XCTAssertEqual(RunnerContinuousDragFrameCount(1_000), 62)
XCTAssertEqual(RunnerContinuousDragFrameCount(10_000), 625)
}

func testRunnerScrollGesturePlanRejectsUnknownDirection() {
XCTAssertNil(RunnerScrollDirection(rawValue: "sideways"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ extension RunnerTests {
x2: Double,
y2: Double,
durationMs: Double,
profile: SynthesizedDragProfile = .continuous,
profile: SynthesizedDragProfile,
context: SynthesizedCoordinateContext? = nil
) -> RunnerInteractionOutcome {
#if os(iOS)
Expand Down Expand Up @@ -48,16 +48,6 @@ extension RunnerTests {
orientation: orientation
)
let message = switch profile {
case .continuous:
RunnerSynthesizedGesture.synthesizeContinuousDrag(
withApplication: app,
resolvedWindow: context.resolvedWindow,
x: Double(start.x),
y: Double(start.y),
x2: Double(end.x),
y2: Double(end.y),
durationMs: durationMs
)
case .controlledScroll:
RunnerSynthesizedGesture.synthesizeControlledScroll(
withApplication: app,
Expand Down
6 changes: 3 additions & 3 deletions docs/adr/0011-interaction-guarantee-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,9 +305,9 @@ truth because the current table has only three behaviors:
fallback;
- default iOS scroll probes keyboards only after AX is known healthy and must
not fall back to `XCUICoordinate`;
- explicit synthesized drag, including synthesized sequence tap/drag steps, may
still use the coordinate fallback before AX health is known, but stops using
it once a snapshot stamps AX unavailable.
- synthesized one-contact `gesture` plans and synthesized sequence tap steps may
still use the coordinate fallback before AX health is known, but stop using it
once a snapshot stamps AX unavailable.

The non-obvious parts are covered by gated XCTest policy tests instead of a
cross-language mirror. A future sibling registry should only be introduced once
Expand Down
27 changes: 14 additions & 13 deletions docs/adr/0013-unified-gesture-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,20 @@ Platform adapters consume the canonical plan:
The three schedules shape that velocity differently. `endpoint-hold` moves quickly for 100 ms and
becomes stationary before lift. `timed-pan` and target-authored drag submit the authored samples
unchanged, preserving piecewise-linear movement plus explicit source and destination holds. The
runner's coordinate `drag` and fused `scroll` compatibility path instead expands the movement to
roughly 16 ms samples using smoothstep `s(t) = 3t² - 2t³`. A linear segment has constant movement
velocity through its endpoint unless a destination hold follows it; smoothstep has zero slope at
both endpoints and a peak velocity 1.5 times its average. Identical endpoints and total durations
can consequently produce different recognizer and deceleration outcomes. Neither a destination
hold nor an analytically zero endpoint slope proves a controlled release by itself: XCTest event
sampling and app recognizer thresholds can still leave observable post-lift motion, so live
evidence must measure the resulting content offset after pointer-up.

Live iOS characterization in [issue #1586](https://github.com/callstack/agent-device/issues/1586)
confirmed that distinction: the schedules crossed the same fling-recognizer thresholds in the
tested range but produced materially different post-release ScrollView positions and
long-duration recognition behavior. The distinction is intentional policy at the Apple adapter
runner's fused `scroll` reuses `endpoint-hold` for inertial release; controlled release instead
expands the movement to evenly spaced samples, one per roughly 16 ms capped at 30, using cubic
ease-out `s(t) = 1 - (1 - t)³`. A linear segment has constant movement velocity through its
endpoint unless a destination hold follows it; cubic ease-out decelerates from the first movement
and has zero slope at lift, with a starting velocity three times its average. Identical endpoints
and total durations can consequently produce different recognizer and deceleration outcomes.
Neither a destination hold nor an analytically zero endpoint slope proves a controlled release by
itself: XCTest event sampling and app recognizer thresholds can still leave observable post-lift
motion, so live evidence must measure the resulting content offset after pointer-up.

Live iOS characterization of an eased sampled schedule against the linear ones in
[issue #1586](https://github.com/callstack/agent-device/issues/1586) confirmed that distinction:
the schedules crossed the same fling-recognizer thresholds in the tested range but produced
materially different post-release ScrollView positions and long-duration recognition behavior. The distinction is intentional policy at the Apple adapter
boundary, not a second interpretation of a `GesturePlan`; changes require live evidence for both
recognizer activation and post-release content movement.
- WebDriver lowers a supported plan to synchronized W3C pointer action sources. A one-contact
Expand Down
Loading