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
2 changes: 1 addition & 1 deletion docs/adr/0005-ios-runner-interaction-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ startup commands still skip that preflight because the first successful command
proof for a newly launched runner. Readiness probe commands skip preflight to avoid recursion.

The daemon may additionally skip the ready-session `uptime` preflight for an explicit allowlist of
mutating interactions (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`) when the same
mutating interactions (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`) when the same
session produced a healthy mutating response — parsed ok and not carrying `runnerFatal` — for the
same `appBundleId` within 5 seconds. This recency lives only on the `RunnerSession` object as
`lastHealthyMutation`, so it dies with every invalidation/restart, and it is recorded only after the
Expand Down
2 changes: 1 addition & 1 deletion docs/ios-runner-protocol-optimizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Acceptance criteria (as shipped):
(conservative) commands still preflight; readiness probes and read-only startup commands keep
their existing skips.
- Recency is derived only from healthy (parsed ok, non-`runnerFatal`) responses of an explicit
mutating allowlist (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`) for the same
mutating allowlist (`tap`, `tapSeries`, `longPress`, `drag`, `dragSeries`, `swipe`, `scroll`) for the same
`appBundleId`, within a 5s freshness window, and lives only on the session object so it dies with
every invalidation/restart. Snapshots and read-only responses never refresh it.
- A transport failure after a skipped preflight clears the recency record and marks the error with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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")
)
}
if let response = unsupportedResponse(for: outcome) {
return response
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 gestureResponse(
message: "dragged",
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
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 {
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum CommandType: String, Codable {
case remotePress
case type
case swipe
case scroll
case findText
case querySelector
case readText
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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?
Expand Down
Loading
Loading