fix(ios): never repeat a synthesized gesture that may have run
Merged
TH
9h ago+675+675+012345678901234567890123456789-96-96-01234567890123456789
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 aresynthesizedGestureOutcomeand thenativePlannedGesturerefusals. - A policy may still answer it with an XCTest fallback.
- When the refusal ends the command (long press,
scroll,swipeas a planned fling, other plannedgesture), one helper,refusalResponse, answersSYNTHESIZED_GESTURE_NOT_DISPATCHED→COMMAND_FAILED,dispatched: no. - A sequence step reads the same case for its
errorCode.
- The iOS synthesis produces
- 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=falsebefore the rethrow. - A sequence stops on that step and keeps its real gesture window.
- It throws, so no fallback repeats it, and answers
- 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 plaindragcommand (XCTest drag only). - Series dispatch:
runApplePressSeriesturns a failed step's dispatch intounknownonce 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
RunnerSynthesizedGesturedispatched:out-param the same way. - #4460 adds
PreparedTouch(notSent:)andTouchDispatch(notSent:). Each takes anativePlannedGesturerefusal throughguard case .unsupported … else preconditionFailure. Here, those refusals become.notDispatched. - Whichever PR merges second must let those two initializers accept
.notDispatched, for example by switching overrefusalText. 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 withsuccess: trueand nogestureFallback; 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
runApplePressSeriestest for the earlier-stepunknown, 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