perf(ios): fuse scroll frame resolution and drag into one runner command (#760)

* perf(ios): fuse scroll frame resolution and drag into one runner command

Non-tvOS scroll now sends a single mutating 'scroll' runner command. The
Swift runner resolves the interaction frame and executes the same
non-synthesized drag path, eliminating the separate read-only
interactionFrame request per scroll. The command is lifecycle-journaled
with retained response JSON so lost-response recovery returns the result
without replaying the gesture.

Closes #668

* perf(ios): make fused scroll eligible for readiness preflight skip

#763 landed the healthy-mutation preflight skip with a note that the
fused scroll command should join the allowlist once it exists. Add
'scroll' to PREFLIGHT_SKIP_ELIGIBLE_RUNNER_COMMANDS, drop the
now-resolved code note, extend the per-family skip tests, and update
the allowlist enumeration in ADR 0005 and the protocol-optimizations
doc.

https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo

* test: complete scroll plan parity vector mirror

Address review on the cross-language parity vectors:
- mirror the Swift pixels-plan vector (down, 120px @ 300x600) in the
  vitest suite so every vector exists in both languages
- add amount > 1 clamp and tiny-frame (2x2) vectors to both suites;
  the tiny frame engages every max(1, ...) floor and the .5 rounding
  cases where JS half-up and Swift half-away-from-zero must agree

https://claude.ai/code/session_01VokBZWESTDgcnbYwS4DkJo

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Michał Pierzchała
2026-06-11 12:03:53 +02:00
committed by GitHub
parent 462db525cc
commit 3780ca6ed5
15 changed files with 654 additions and 139 deletions
@@ -633,53 +633,62 @@ extension RunnerTests {
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
}
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
let dragFrame = resolvedDragVisualizationFrame(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2
return executeDragGesture(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: command.durationMs,
synthesized: command.synthesized == true,
message: "dragged"
)
var fallback: GestureFallback?
if command.synthesized == true {
let durationMs = min(max(command.durationMs ?? 250, 16), 10000)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedDragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
durationMs: durationMs
case .scroll:
// Fused frame-resolve + drag scroll for non-tvOS. Resolves the interaction frame exactly
// like .interactionFrame, computes drag endpoints with the Swift port of
// buildScrollGesturePlan, then runs the same non-synthesized drag path scroll's drag used.
guard let direction = command.direction,
direction == "up" || direction == "down" || direction == "left" || direction == "right"
else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "scroll requires direction up|down|left|right"
)
}
if case .performed = outcome {
return gestureResponse(message: "dragged", timing: timing, frame: .drag(dragFrame))
}
fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
}
let holdDuration = command.synthesized == true
? synthesizedSwipeFallbackHoldDuration(durationMs: command.durationMs ?? 250)
: coordinateDragHoldDuration()
let (timing, outcome) = performGesture(activeApp) {
dragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
holdDuration: holdDuration
)
}
if let response = unsupportedResponse(for: outcome) {
return response
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
guard frame.width > 0, frame.height > 0 else {
return Response(
ok: false,
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
)
}
return gestureResponse(
message: "dragged",
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
guard let plan = runnerScrollGesturePlan(
direction: direction,
amount: command.amount,
pixels: command.pixels,
referenceWidth: frame.width,
referenceHeight: frame.height
) else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "scroll could not compute a gesture plan"
)
)
}
return executeDragGesture(
activeApp: activeApp,
x: frame.minX + plan.x1,
y: frame.minY + plan.y1,
x2: frame.minX + plan.x2,
y2: frame.minY + plan.y2,
durationMs: nil,
synthesized: false,
message: "scrolled"
)
case .dragSeries:
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
@@ -1023,6 +1032,71 @@ extension RunnerTests {
}
}
/// Shared drag execution for `.drag` and the fused `.scroll`. Mirrors the original `.drag` body
/// exactly: keyboardAvoidingDragPoints -> resolvedDragVisualizationFrame -> synthesized branch
/// (16-10000ms clamp) or non-synthesized dragAt with coordinateDragHoldDuration ->
/// gestureResponse(.drag). `.scroll` always passes synthesized: false, pinning the same
/// non-synthesized drag path scroll's drag used today.
private func executeDragGesture(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double?,
synthesized: Bool,
message: String
) -> Response {
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
let dragFrame = resolvedDragVisualizationFrame(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2
)
var fallback: GestureFallback?
if synthesized {
let durationMs = min(max(durationMs ?? 250, 16), 10000)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedDragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
durationMs: durationMs
)
}
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 ?? 250)
: coordinateDragHoldDuration()
let (timing, outcome) = performGesture(activeApp) {
dragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
holdDuration: holdDuration
)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(
message: message,
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
)
}
private func currentXCTestFailureCount() -> Int {
return testRun?.failureCount ?? 0
}
@@ -144,10 +144,10 @@ final class RunnerCommandJournal {
case .snapshot, .screenshot:
return false
case .tap, .mouseClick, .tapSeries, .longPress, .interactionFrame, .drag, .dragSeries,
.remotePress, .type, .swipe, .findText, .querySelector, .readText, .back, .backInApp,
.backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn, .alert,
.pinch, .rotateGesture, .transformGesture, .recordStart, .recordStop, .status, .uptime,
.shutdown:
.remotePress, .type, .swipe, .scroll, .findText, .querySelector, .readText, .back,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
.alert, .pinch, .rotateGesture, .transformGesture, .recordStart, .recordStop, .status,
.uptime, .shutdown:
return true
}
}
@@ -219,6 +219,38 @@ extension RunnerTests {
XCTAssertEqual(screenshotStatus.lifecycleResponseOk, true)
XCTAssertNil(screenshotStatus.lifecycleResponseJson)
let scroll = runnerJournalCommand("scroll", id: "scroll-drag")
journal.accept(command: scroll)
journal.finish(
command: scroll,
response: Response(
ok: true,
data: DataPayload(
message: "scrolled",
gestureStartUptimeMs: 1,
gestureEndUptimeMs: 2,
x: 155,
y: 420,
x2: 155,
y2: 301,
referenceWidth: 300,
referenceHeight: 600
)
)
)
let scrollStatus = journal.status(commandId: "scroll-drag")
XCTAssertEqual(scrollStatus.lifecycleState, RunnerCommandLifecycleState.completed.rawValue)
XCTAssertEqual(scrollStatus.lifecycleResponseOk, true)
XCTAssertNotNil(scrollStatus.lifecycleResponseJson)
let scrollResponse = try decodeRunnerJournalResponse(scrollStatus.lifecycleResponseJson)
XCTAssertEqual(scrollResponse.data?.x, 155)
XCTAssertEqual(scrollResponse.data?.y, 420)
XCTAssertEqual(scrollResponse.data?.x2, 155)
XCTAssertEqual(scrollResponse.data?.y2, 301)
XCTAssertEqual(scrollResponse.data?.referenceWidth, 300)
XCTAssertEqual(scrollResponse.data?.referenceHeight, 600)
let largeRead = runnerJournalCommand("readText", id: "large-read")
journal.accept(command: largeRead)
journal.finish(
@@ -11,6 +11,7 @@ enum CommandType: String, Codable {
case remotePress
case type
case swipe
case scroll
case findText
case querySelector
case readText
@@ -71,7 +72,8 @@ extension CommandType {
// tapSeries/dragSeries are the series forms of tap/drag; keyboardReturn is the sibling
// of keyboardDismiss — all three were missing from the historical switch (drift the
// table now prevents) and are classified as interactions here.
case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe,
// .scroll is the fused frame-resolve + drag scroll; same classification as .drag.
case .tap, .tapSeries, .longPress, .drag, .dragSeries, .remotePress, .type, .swipe, .scroll,
.back, .backInApp, .backSystem, .rotate, .appSwitcher,
.keyboardDismiss, .keyboardReturn, .pinch, .rotateGesture, .transformGesture:
return CommandTraits(isInteraction: true, readOnly: .never, isLifecycle: false)
@@ -134,6 +136,8 @@ struct Command: Codable {
let dy: Double?
let durationMs: Double?
let direction: String?
let amount: Double?
let pixels: Double?
let orientation: String?
let scale: Double?
let degrees: Double?
@@ -0,0 +1,200 @@
import XCTest
// Swift port of buildScrollGesturePlan from src/core/scroll-gesture.ts.
//
// This is a deliberate two-place invariant: the daemon keeps the TS implementation (for Android,
// recording, and reported-pixels), and the runner places the gesture with this Swift copy. The
// parity test vectors at the bottom of this file mirror src/core/__tests__/scroll-gesture.test.ts —
// if you change the math in either language, update the other and both vector sets.
//
// All inputs here are positive (reference dims, travel, center), so Swift's `.rounded()`
// (half away from zero) matches JS `Math.round` (half up) on every value computed below.
struct RunnerScrollGesturePlan {
let x1: Double
let y1: Double
let x2: Double
let y2: Double
let travelPixels: Double
}
private let runnerDefaultScrollAmount = 0.6
private let runnerDefaultEdgePaddingFraction = 0.05
func runnerScrollGesturePlan(
direction: String,
amount: Double?,
pixels: Double?,
referenceWidth: Double,
referenceHeight: Double
) -> RunnerScrollGesturePlan? {
// Mirror the TS INVALID_ARGS contract: non-positive or non-finite amount/pixels are rejected
// rather than clamped into a journaled 1px scroll. The daemon validates before sending, so
// this only triggers for non-daemon wire clients.
if let amount, !(amount.isFinite && amount > 0) { return nil }
if let pixels, !(pixels.isFinite && pixels > 0) { return nil }
let axisLength = (direction == "up" || direction == "down") ? referenceHeight : referenceWidth
let requestedAmount = amount ?? runnerDefaultScrollAmount
let requestedPixels: Double =
pixels.map { max(1, $0.rounded()) } ?? (axisLength * requestedAmount).rounded()
let edgePadding = max(1, (axisLength * runnerDefaultEdgePaddingFraction).rounded())
let maxTravelPixels = max(1, axisLength - edgePadding * 2)
let travelPixels = max(1, min(requestedPixels, maxTravelPixels))
let halfTravel = (travelPixels / 2).rounded()
let centerX = (referenceWidth / 2).rounded()
let centerY = (referenceHeight / 2).rounded()
func plan(_ x1: Double, _ y1: Double, _ x2: Double, _ y2: Double) -> RunnerScrollGesturePlan {
RunnerScrollGesturePlan(x1: x1, y1: y1, x2: x2, y2: y2, travelPixels: travelPixels)
}
switch direction {
case "up":
return plan(centerX, centerY - halfTravel, centerX, centerY + halfTravel)
case "down":
return plan(centerX, centerY + halfTravel, centerX, centerY - halfTravel)
case "left":
return plan(centerX - halfTravel, centerY, centerX + halfTravel, centerY)
case "right":
return plan(centerX + halfTravel, centerY, centerX - halfTravel, centerY)
default:
return nil
}
}
extension RunnerTests {
// Cross-language parity vectors mirroring src/core/__tests__/scroll-gesture.test.ts. Keep these
// in sync with the vitest vectors so the two buildScrollGesturePlan implementations cannot drift.
func testRunnerScrollGesturePlanMapsRelativeAmount() throws {
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: 0.5,
pixels: nil,
referenceWidth: 400,
referenceHeight: 800
)
)
XCTAssertEqual(plan.x1, 200)
XCTAssertEqual(plan.y1, 600)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 200)
XCTAssertEqual(plan.travelPixels, 400)
}
func testRunnerScrollGesturePlanPixelsDown() throws {
// 300x600, down, pixels 120 -> (150,360)->(150,240), travel 120.
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: 120,
referenceWidth: 300,
referenceHeight: 600
)
)
XCTAssertEqual(plan.x1, 150)
XCTAssertEqual(plan.y1, 360)
XCTAssertEqual(plan.x2, 150)
XCTAssertEqual(plan.y2, 240)
XCTAssertEqual(plan.travelPixels, 120)
}
func testRunnerScrollGesturePlanClampsAmountAboveOne() throws {
// 400x800, down, amount 2 -> requested 1600 clamps to the safe band (720): (200,760)->(200,40).
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: 2,
pixels: nil,
referenceWidth: 400,
referenceHeight: 800
)
)
XCTAssertEqual(plan.x1, 200)
XCTAssertEqual(plan.y1, 760)
XCTAssertEqual(plan.x2, 200)
XCTAssertEqual(plan.y2, 40)
XCTAssertEqual(plan.travelPixels, 720)
}
func testRunnerScrollGesturePlanFloorsTinyFrames() throws {
// 2x2, down, pixels 10 engages every max(1, ...) floor and the .5 rounding cases the two
// ports must agree on (halfTravel 0.5 -> 1, center 1 from 2/2): (1,2)->(1,0), travel 1.
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: 10,
referenceWidth: 2,
referenceHeight: 2
)
)
XCTAssertEqual(plan.x1, 1)
XCTAssertEqual(plan.y1, 2)
XCTAssertEqual(plan.x2, 1)
XCTAssertEqual(plan.y2, 0)
XCTAssertEqual(plan.travelPixels, 1)
}
func testRunnerScrollGesturePlanClampsToSafeBand() throws {
// 300x600, right, pixels 500 clamps travel to the safe band (270).
let plan = try XCTUnwrap(
runnerScrollGesturePlan(
direction: "right",
amount: nil,
pixels: 500,
referenceWidth: 300,
referenceHeight: 600
)
)
XCTAssertEqual(plan.x1, 285)
XCTAssertEqual(plan.x2, 15)
XCTAssertEqual(plan.y1, 300)
XCTAssertEqual(plan.y2, 300)
XCTAssertEqual(plan.travelPixels, 270)
}
func testRunnerScrollGesturePlanRejectsUnknownDirection() {
XCTAssertNil(
runnerScrollGesturePlan(
direction: "sideways",
amount: nil,
pixels: 100,
referenceWidth: 300,
referenceHeight: 600
)
)
}
func testRunnerScrollGesturePlanRejectsInvalidAmountAndPixels() {
XCTAssertNil(
runnerScrollGesturePlan(
direction: "down",
amount: 0,
pixels: nil,
referenceWidth: 300,
referenceHeight: 600
)
)
XCTAssertNil(
runnerScrollGesturePlan(
direction: "down",
amount: nil,
pixels: -10,
referenceWidth: 300,
referenceHeight: 600
)
)
XCTAssertNil(
runnerScrollGesturePlan(
direction: "down",
amount: .infinity,
pixels: nil,
referenceWidth: 300,
referenceHeight: 600
)
)
}
}