From c96cdc7ef3275905fae8260b9bc5bb66e0a18602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:53:19 +0200 Subject: [PATCH 1/2] chore(ios-runner): delete the unreachable synthesized continuous drag profile The only daemon producer of the runner `drag` command is macOS-only and never sets `synthesized`, so the smoothstep `.continuous` profile could not run. Remove the ObjC continuous path and frame-count export, the Swift enum case, the `synthesized:` parameter and second synthesized block in `executeDragGesture`, and its frame-count test. `.fastSwipe` and `.controlledScroll` are untouched; ADR 0013 now describes the scroll schedule that remains. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../RunnerSynthesizedGesture.h | 9 --- .../RunnerSynthesizedGesture.m | 56 ------------------- .../RunnerTests+CommandExecution.swift | 46 ++------------- .../RunnerTests+Interaction.swift | 1 - .../RunnerTests+ScrollGesture.swift | 5 -- .../RunnerTests+SynthesizedInteraction.swift | 12 +--- docs/adr/0013-unified-gesture-plans.md | 17 +++--- 7 files changed, 16 insertions(+), 130 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h index e606041e8d..27ee022545 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.h @@ -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); @@ -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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m index 5767bb0c7d..bb6a2039aa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerSynthesizedGesture.m @@ -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, @@ -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, @@ -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 @@ -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, @@ -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, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 223103f893..b58da03e10 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -1867,7 +1867,6 @@ extension RunnerTests { x2: x2, y2: y2, durationMs: defaults.durationMs, - synthesized: command.synthesized == true, message: "dragged", synthesizedPolicyKind: .synthesizedDrag ) @@ -2240,7 +2239,6 @@ extension RunnerTests { x2: last.x, y2: last.y, durationMs: plan.durationMs, - synthesized: true, message: plan.intent, synthesizedPolicyKind: .synthesizedDrag, synthesizedProfile: .fastSwipe @@ -2307,7 +2305,6 @@ extension RunnerTests { x2: x2, y2: y2, durationMs: durationMs, - synthesized: true, message: message, synthesizedContext: context, synthesizedPolicyKind: .scroll, @@ -2321,7 +2318,6 @@ extension RunnerTests { x2: x2, y2: y2, durationMs: durationMs, - synthesized: false, message: message, synthesizedPolicyKind: .scroll ) @@ -2337,11 +2333,10 @@ extension RunnerTests { x2: Double, y2: Double, durationMs: Double?, - synthesized: Bool, message: String, synthesizedContext: SynthesizedCoordinateContext? = nil, synthesizedPolicyKind: SynthesizedGesturePolicyKind, - synthesizedProfile: SynthesizedDragProfile = .continuous + synthesizedProfile: SynthesizedDragProfile? = nil ) -> Response { let durationMs = durationMs ?? runnerDefaultDragDurationMs let commandName = dragCommandName(message: message) @@ -2351,7 +2346,7 @@ extension RunnerTests { error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates") ) } - if synthesized, let synthesizedResponse = executeSynthesizedDragGesture( + if let synthesizedProfile, let synthesizedResponse = executeSynthesizedDragGesture( activeApp: activeApp, x: x, y: y, @@ -2373,33 +2368,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 = synthesizedProfile == nil + ? coordinateDragHoldDuration() + : synthesizedSwipeFallbackHoldDuration(durationMs: durationMs) let (timing, outcome) = performGesture(activeApp) { dragAt( app: activeApp, @@ -2413,12 +2384,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( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index ba896c9164..8f0a101429 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -18,7 +18,6 @@ extension RunnerTests { } enum SynthesizedDragProfile: Equatable { - case continuous case controlledScroll case fastSwipe } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift index 6b3423777c..f6ebb6866b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollGesture.swift @@ -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")) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index b1e8db0915..1d307ddd91 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -9,7 +9,7 @@ extension RunnerTests { x2: Double, y2: Double, durationMs: Double, - profile: SynthesizedDragProfile = .continuous, + profile: SynthesizedDragProfile, context: SynthesizedCoordinateContext? = nil ) -> RunnerInteractionOutcome { #if os(iOS) @@ -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, diff --git a/docs/adr/0013-unified-gesture-plans.md b/docs/adr/0013-unified-gesture-plans.md index 7b93bc34c3..f85d489c23 100644 --- a/docs/adr/0013-unified-gesture-plans.md +++ b/docs/adr/0013-unified-gesture-plans.md @@ -113,14 +113,15 @@ 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. + runner's fused `scroll` reuses `endpoint-hold` for inertial release; controlled release instead + expands the movement to at most 30 samples, roughly 16 ms apart, 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 in [issue #1586](https://github.com/callstack/agent-device/issues/1586) confirmed that distinction: the schedules crossed the same fling-recognizer thresholds in the From 3b3fd2766007ef20ac5f2ff1a150d1571452e6f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:57:21 +0200 Subject: [PATCH 2/2] refactor(ios-runner): carry the drag synthesis profile and policy as one optional Plain coordinate drags no longer name a synthesized policy they never apply; the activation-preflight drag fixture stops sending the ignored flag. ADR 0011 and 0013 describe the synthesized lanes that remain. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../RunnerTests+CommandExecution.swift | 29 ++++++++----------- .../0011-interaction-guarantee-contract.md | 6 ++-- docs/adr/0013-unified-gesture-plans.md | 26 ++++++++--------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index b58da03e10..b4e50a3ab5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -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}"# @@ -1867,8 +1867,7 @@ extension RunnerTests { x2: x2, y2: y2, durationMs: defaults.durationMs, - message: "dragged", - synthesizedPolicyKind: .synthesizedDrag + message: "dragged" ) case .scroll: // Fused frame-resolve + drag scroll for non-tvOS. On iOS this intentionally stays on the @@ -2240,8 +2239,7 @@ extension RunnerTests { y2: last.y, durationMs: plan.durationMs, message: plan.intent, - synthesizedPolicyKind: .synthesizedDrag, - synthesizedProfile: .fastSwipe + synthesized: (profile: .fastSwipe, policyKind: .synthesizedDrag) ) ) case .sampled: @@ -2307,8 +2305,7 @@ extension RunnerTests { durationMs: durationMs, message: message, synthesizedContext: context, - synthesizedPolicyKind: .scroll, - synthesizedProfile: scrollDragProfile(releaseBehavior: releaseBehavior) + synthesized: (profile: scrollDragProfile(releaseBehavior: releaseBehavior), policyKind: .scroll) ) #else return executeDragGesture( @@ -2318,14 +2315,13 @@ extension RunnerTests { x2: x2, y2: y2, durationMs: durationMs, - 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, @@ -2335,8 +2331,7 @@ extension RunnerTests { durationMs: Double?, message: String, synthesizedContext: SynthesizedCoordinateContext? = nil, - synthesizedPolicyKind: SynthesizedGesturePolicyKind, - synthesizedProfile: SynthesizedDragProfile? = nil + synthesized: (profile: SynthesizedDragProfile, policyKind: SynthesizedGesturePolicyKind)? = nil ) -> Response { let durationMs = durationMs ?? runnerDefaultDragDurationMs let commandName = dragCommandName(message: message) @@ -2346,7 +2341,7 @@ extension RunnerTests { error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates") ) } - if let synthesizedProfile, let synthesizedResponse = executeSynthesizedDragGesture( + if let synthesized, let synthesizedResponse = executeSynthesizedDragGesture( activeApp: activeApp, x: x, y: y, @@ -2355,8 +2350,8 @@ extension RunnerTests { durationMs: durationMs, message: message, context: synthesizedContext, - policyKind: synthesizedPolicyKind, - profile: synthesizedProfile + policyKind: synthesized.policyKind, + profile: synthesized.profile ) { return synthesizedResponse } @@ -2368,7 +2363,7 @@ extension RunnerTests { x2: dragPoints.x2, y2: dragPoints.y2 ) - let holdDuration = synthesizedProfile == nil + let holdDuration = synthesized == nil ? coordinateDragHoldDuration() : synthesizedSwipeFallbackHoldDuration(durationMs: durationMs) let (timing, outcome) = performGesture(activeApp) { diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index ada94c5ff6..51665d3613 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -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 diff --git a/docs/adr/0013-unified-gesture-plans.md b/docs/adr/0013-unified-gesture-plans.md index f85d489c23..7d9f0a5f39 100644 --- a/docs/adr/0013-unified-gesture-plans.md +++ b/docs/adr/0013-unified-gesture-plans.md @@ -114,19 +114,19 @@ Platform adapters consume the canonical plan: 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 fused `scroll` reuses `endpoint-hold` for inertial release; controlled release instead - expands the movement to at most 30 samples, roughly 16 ms apart, 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 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 + 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