fix(ios): never repeat a synthesized gesture that may have run

Merged
TH
8h ago
+675-96

Summary

A synthesized long press could fail after its events reached XCTest, then fall back to an XCTest long press and press a second time. The shared synthesis now reports whether its event record was handed to XCTest, and the runner carries that fact as a typed outcome.

  • Before hand-off: nothing was sent.
    • The iOS synthesis produces RunnerInteractionOutcome.notDispatched. Its producers are synthesizedGestureOutcome and the nativePlannedGesture refusals.
    • A policy may still answer it with an XCTest fallback.
    • When the refusal ends the command (long press, scroll, swipe as a planned fling, other planned gesture), one helper, refusalResponse, answers SYNTHESIZED_GESTURE_NOT_DISPATCHED → COMMAND_FAILED, dispatched: no.
    • A sequence step reads the same case for its errorCode.
  • After hand-off: the gesture may have run.
    • It throws, so no fallback repeats it, and answers SYNTHESIZED_GESTURE_OUTCOME_UNKNOWN → COMMAND_FAILED, dispatched: unknown.
    • The policy marker is logged with fallbackAttempted=false before the rethrow.
    • A sequence stops on that step and keeps its real gesture window.
  • Every other refusal keeps UNSUPPORTED_OPERATION. That includes the tvOS, visionOS and macOS platform arms, taps (element, coordinate and alert taps never produce .notDispatched), and the plain drag command (XCTest drag only).
  • Series dispatch: runApplePressSeries turns a failed step's dispatch into unknown once an earlier step ran.

One channel: typed runner codes in RUNNER_ERROR_CODE_DISPATCH, no new envelope field. Split out of #4460, which keeps back's own BACK_SWIPE_* codes.

Merge note with #4460:

  • Both PRs change the RunnerSynthesizedGesture dispatched: out-param the same way.
  • #4460 adds PreparedTouch(notSent:) and TouchDispatch(notSent:). Each takes a nativePlannedGesture refusal through guard case .unsupported … else preconditionFailure. Here, those refusals become .notDispatched.
  • Whichever PR merges second must let those two initializers accept .notDispatched, for example by switching over refusalText. Otherwise an edge-touch or device back refusal would trap at runtime.

Validation

  • Live iOS simulator runs through the daemon CLI: longpress, scroll down, swipe x1 y1 x2 y2, gesture pan, gesture pinch, and a press series with long-press steps. Each exited 0 with success: true and no gestureFallback; exit status and JSON are in https://github.com/callstack/agent-device/pull/4474#issuecomment-6829378643.
  • Runner unit-test lane on an iOS 27 simulator: all of SynthesizedInteractionTests, ScrollDragExecutionTests, SynthesizedGesturePolicyTests and SequenceExecutionTests ran, 70 tests, 0 failures, 1 environment skip.
  • New tests:
    • A sampled plan per mapping: a stubbed iOS pre-hand-off refusal → NOT_DISPATCHED; a platform refusal → UNSUPPORTED_OPERATION.
    • A scroll drag refused before hand-off → NOT_DISPATCHED.
    • Sequence long-press refused before hand-off, in the first step and after a tap.
    • A sequence that stops at an outcome-unknown step and never runs the next step.
    • A long press failing after hand-off → unknown, with 0 XCTest presses.
    • TS classification for both codes; a runApplePressSeries test for the earlier-step unknown, which sends the refused step once.
  • Builds: iOS simulator, iOS device, tvOS, visionOS and macOS compile.
  • CI owns the full suites.

🤖 Generated with Claude Code