Skip to content
Open
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 @@ -176,6 +176,17 @@ extension RunnerTests {
)
}

private func plannedGestureResponse(
plan: RunnerGesturePlan,
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
outcome: RunnerInteractionOutcome
) -> Response {
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: plan.intent, timing: timing)
}

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
let response = gestureResponse(
Expand Down Expand Up @@ -2056,7 +2067,8 @@ extension RunnerTests {
error: ErrorPayload(code: "INVALID_ARGS", message: validationError)
)
}
if plannedGestureExecution(for: plan) == .fastSwipe {
switch plannedGestureExecution(for: plan) {
case .fastSwipe:
// Validation above guarantees a non-empty, single-pointer path for this execution kind.
let first = plan.pointers[0].samples.first!.point
let last = plan.pointers[0].samples.last!.point
Expand All @@ -2074,14 +2086,17 @@ extension RunnerTests {
synthesizedProfile: .fastSwipe
)
)
case .continuousPan:
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
continuousPlannedGesture(app: activeApp, plan: plan)
}
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
case .sampled:
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
sampledPlannedGesture(app: activeApp, plan: plan)
}
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
}
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
sampledPlannedGesture(app: activeApp, plan: plan)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: plan.intent, timing: timing)
case .gestureViewport:
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ private enum RunnerInterfaceOrientation {
extension RunnerTests {
enum PlannedGestureExecution: Equatable {
case fastSwipe
case continuousPan
case sampled
}

Expand Down Expand Up @@ -1095,9 +1096,82 @@ extension RunnerTests {
}

func plannedGestureExecution(for plan: RunnerGesturePlan) -> PlannedGestureExecution {
plan.topology == "single" && plan.executionProfile == "endpoint-hold"
? .fastSwipe
: .sampled
if plan.topology == "single" && plan.executionProfile == "endpoint-hold" {
return .fastSwipe
}
if plan.topology == "single" && plan.executionProfile == "timed-pan" {
return .continuousPan
}
return .sampled
}

func continuousPlannedGesture(
app: XCUIApplication,
plan: RunnerGesturePlan
) -> RunnerInteractionOutcome {
#if os(iOS)
// On current iOS simulators, a dense single-pointer path assembled through the planned
// dictionary bridge can report success without delivering the pan updates. The native
// continuous profile preserves the planned endpoints and duration while keeping the
// proven continuous XCTest path. Multi-touch plans still use the exact sampled bridge below.
guard let pointer = plan.pointers.first,
let first = pointer.samples.first,
let last = pointer.samples.last
else {
return .unsupported(
message: "planned timed pan has no usable pointer endpoints",
hint: "Retry with a valid single-pointer gesture plan."
)
}
let orientation = Int(RunnerSynthesizedGesture.interfaceOrientation(forApplication: app))
let frame = CGRect(
x: plan.viewport.x,
y: plan.viewport.y,
width: plan.viewport.width,
height: plan.viewport.height
)
let start = nativeSynthesizedPoint(
orientedX: first.point.x,
orientedY: first.point.y,
in: frame,
interfaceOrientation: orientation
)
let end = nativeSynthesizedPoint(
orientedX: last.point.x,
orientedY: last.point.y,
in: frame,
interfaceOrientation: orientation
)
if let message = RunnerSynthesizedGesture.synthesizeContinuousDrag(
withApplication: app,
x: Double(start.x),
y: Double(start.y),
x2: Double(end.x),
y2: Double(end.y),
durationMs: plan.durationMs
) {
return .unsupported(
message: message,
hint: "This gesture uses private XCTest event-synthesis APIs; rebuild the runner with a supported Xcode if this persists."
)
}
return .performed
#elseif os(tvOS)
return .unsupported(
message: "timed pan gestures are not supported on tvOS",
hint: "tvOS has no touch input; use remote-driven navigation."
)
#elseif os(visionOS)
return .unsupported(
message: "timed pan gestures are not supported on visionOS",
hint: "The current XCTest synthesizer supports iOS and iPadOS touch simulators only."
)
#else
return .unsupported(
message: "timed pan gestures are not supported on macOS",
hint: "Run the gesture on an iOS simulator, where XCTest touch synthesis is available."
)
#endif
}

func sampledPlannedGesture(
Expand Down Expand Up @@ -1396,15 +1470,15 @@ extension RunnerTests {
XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe)
}

func testSinglePointerTimedPanUsesSampledExecution() throws {
func testSinglePointerTimedPanUsesContinuousExecution() throws {
let plan = try JSONDecoder().decode(
RunnerGesturePlan.self,
from: Data(
#"{"topology":"single","intent":"pan","executionProfile":"timed-pan","durationMs":500,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":250,"point":{"x":100,"y":150}},{"offsetMs":500,"point":{"x":40,"y":150}}]}]}"#.utf8
)
)

XCTAssertEqual(plannedGestureExecution(for: plan), .sampled)
XCTAssertEqual(plannedGestureExecution(for: plan), .continuousPan)
}

func testSinglePointerEndpointHoldUsesFastSwipeExecution() throws {
Expand Down
14 changes: 8 additions & 6 deletions docs/adr/0013-unified-gesture-plans.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,14 @@ Platform adapters consume the canonical plan:
around every local gesture).
- iOS lowers one-contact endpoint-hold plans to the established fast-swipe synthesis profile. That
profile reaches the endpoint in 100 ms, then holds there for the planned duration before lifting,
matching Maestro's XCTest driver. Timed-pan and two-contact plans convert every point to native
orientation and feed the exact planned arrays to the private XCTest event bridge. Android and
WebDriver continue to execute the plan samples across the authored duration, matching their
native Maestro drivers. macOS lowers a one-contact plan to its drag executor and tvOS lowers it to remote
direction. Core admission and the Apple adapter both consume the same shared multi-touch support
policy; multi-touch remains capability-gated to iOS simulators.
matching Maestro's XCTest driver. Two-contact plans convert every point to native orientation
and feed the exact planned arrays to the private XCTest event bridge. One-contact timed pans
preserve the planned endpoints and duration but use the runner's native continuous-drag profile:
on current iOS simulators, the dense per-sample bridge can report success without delivering pan
updates. Android and WebDriver continue to execute the plan samples across the authored duration,
matching their native Maestro drivers. macOS lowers a one-contact plan to its drag executor and
tvOS lowers it to remote direction. Core admission and the Apple adapter both consume the same
shared multi-touch support policy; multi-touch remains capability-gated to iOS simulators.
- WebDriver lowers a supported plan to synchronized W3C pointer action sources. Multi-touch remains
capability-gated until a provider proves it.

Expand Down
Loading