diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 36fa2ee80..198a56366 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -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( @@ -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 @@ -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 { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index e3b1a39bc..c39f28f5d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -15,6 +15,7 @@ private enum RunnerInterfaceOrientation { extension RunnerTests { enum PlannedGestureExecution: Equatable { case fastSwipe + case continuousPan case sampled } @@ -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( @@ -1396,7 +1470,7 @@ extension RunnerTests { XCTAssertEqual(plannedGestureExecution(for: plan), .fastSwipe) } - func testSinglePointerTimedPanUsesSampledExecution() throws { + func testSinglePointerTimedPanUsesContinuousExecution() throws { let plan = try JSONDecoder().decode( RunnerGesturePlan.self, from: Data( @@ -1404,7 +1478,7 @@ extension RunnerTests { ) ) - XCTAssertEqual(plannedGestureExecution(for: plan), .sampled) + XCTAssertEqual(plannedGestureExecution(for: plan), .continuousPan) } func testSinglePointerEndpointHoldUsesFastSwipeExecution() throws { diff --git a/docs/adr/0013-unified-gesture-plans.md b/docs/adr/0013-unified-gesture-plans.md index cce2d7086..e46d3cf53 100644 --- a/docs/adr/0013-unified-gesture-plans.md +++ b/docs/adr/0013-unified-gesture-plans.md @@ -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.