mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
462db525cc
commit
3780ca6ed5
+116
-42
@@ -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
|
||||
}
|
||||
|
||||
+36
-4
@@ -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?
|
||||
|
||||
+200
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user