Skip to content

fix(ios): synthesize long presses and the gesture viewport on deep trees - #3893

Open
thymikee wants to merge 5 commits into
mainfrom
fix/ios-deep-tree-gestures
Open

thymikee wants to merge 5 commits into
mainfrom
fix/ios-deep-tree-gestures

Conversation

@thymikee

@thymikee thymikee commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

On deep React Native trees (heavy-feed fixture #3884, Bluesky), these iOS gestures failed after #3870:

  • longpress used XCTest's coordinate press. It retried kAXErrorIllegalArgument for about 16 s, then threw point.x != INFINITY, which ended the runner test. On iOS, longPress and press-series hold steps are now synthesized like a tap. While the tree answers, XCTest's press is still the fallback.
  • swipe failed with "Active app interaction viewport is unavailable". gestureViewport now uses the synthesized context: the resolved window, or the sole screen on a one-screen iPhone.
  • The 8–30 s scroll came from the runner that longpress killed.

6 runner Swift files, no wire fields.

Validation

Head 9f5cbc9ebd (main 5f6dfb3e6f merged).

Open: on iPad deep trees, long press still falls back to XCTest's press. back on the heavy feed is fixed in #3894.

🤖 Generated with Claude Code

thymikee and others added 3 commits October 22, 2026 06:10
A long press used XCTest's coordinate press, which resolves the app frame
through the accessibility tree. On a tree XCTest cannot serialize (the
heavy-feed fixture, Bluesky's feed) that read retried for ~16 s, then the
press raised "point.x != INFINITY", which ended the runner test. The next
command found the runner gone: its readiness preflight failed and the
runner restarted, or its reply was lost while the runner exited.

On iOS the longPress command and press-series longPress steps now
synthesize one stationary contact held for the duration, through the
same coordinate context as a synthesized tap, including the sole-screen
route. The XCTest press stays as the fallback only while the tree
answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A swipe asks the runner for the gesture viewport before it plans its
path. The viewport read the app window or app.frame, and on a tree
XCTest cannot serialize both fail, so every swipe failed with "Active
app interaction viewport is unavailable" even though the planned
gesture itself is synthesized without the tree.

On iOS the viewport now follows the synthesized coordinate context: the
resolved window, or, when none resolves on an iPhone with one screen,
that screen in the app's orientation. iPad and multi-screen devices
still require a window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A press series read the app frame and the windows query for its first
step's touch frame before any step ran. On a tree XCTest cannot
serialize each of those reads costs about 2 s of XCTest retries, and
now that held presses run as synthesized sequence steps that cost came
before every hold. A sequence with a synthesized context reads the frame
from that context, as the single longPress command does.

The long-press test now also pins zero frame reads and the held
duration of each synthesized hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.53 MB 5.54 MB +5.0 kB
Package (unpacked) 5.53 MB 5.54 MB +5.0 kB
Package (download) 1.68 MB 1.68 MB +617 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 39.4 ms 41.1 ms +1.6 ms
CLI --help 82.1 ms 84.2 ms +2.1 ms

@thymikee

Copy link
Copy Markdown
Member Author

I reviewed 521e511 and I do not think this is ready to merge yet. Long presses on every iPhone and iPad screen now go through the synthesized route, and I could not confirm that route still triggers a long-press handler.

On iOS, every longPress command and every sequence longPress step now goes through synthesizedLongPressAt whenever a window or the sole screen resolves (RunnerTests+CommandExecution.swift#L226). That includes ordinary shallow trees, where XCTest press(forDuration:) ran before. The synthesized press sends two stationary pointer samples, at offset 0 and at durationMs. If that does not hold the contact long enough for UILongPressGestureRecognizer or a context menu, every iOS longpress will report "long pressed" with no effect, in all apps and not only on deep trees. The heavy-feed step timings from the #3885 suite measure success and duration, not whether a recognizer fired. Could you attach a live run on an iPhone simulator through the CLI, using agent-device longpress with both an @ref or selector and raw x/y? The run must reach the synthesized route, shown by an AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY line with kind=coordinateLongPress and fallbackAttempted=false in runner.log. Please do it (a) on a normal-tree fixture screen whose long press opens a context menu or changes visible state, with a snapshot after the press showing that outcome, and (b) on the heavy-feed screen, again with a snapshot showing the outcome. A sequence with longPress steps should show the same.

Not blocking, and fine to take or leave: gestureViewportFrame in RunnerTests+SynthesizedInteraction.swift (line 348) repeats the window, then sole-screen, then nothing ladder that synthesizedCoordinateContext (line 476) already owns, so it could derive the viewport from that; and for taps the daemon decides synthesis through the synthesized wire flag, while for longPress the runner decides from #if os(iOS) in RunnerTests+SynthesizedGesturePolicy.swift (line 120) and ignores the flag, so one layer could own the "synthesize on non-tv, non-vision iOS" rule.

The failing Lint, Typecheck & Package job reports packages/platform-apple/src/snapshot-route.test.ts at 1003 lines, over the 1000-line cap. This PR only touches Swift files under apple/runner, and that file is over the cap on main, which #3890 fixes, so the failure looks unrelated to this change. There are no conflicts. Before merge, the live long-press evidence above is needed, and the line cap on main has to be fixed.

I did not run the runner suite or a device, so the live medians and the 463-test pass come from the PR body. The new regression tests only discriminate on a single-screen iPhone simulator, because on iPad both base and head take the else branch.

thymikee and others added 2 commits October 22, 2026 10:08
…tures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The window, then sole screen, then nothing ladder lives in
synthesizedCoordinateContext; the viewport now reads its frame instead
of repeating the ladder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@thymikee

Copy link
Copy Markdown
Member Author

Thanks. Answers point by point at 9f5cbc9ebd: main merged (5f6dfb3e6f, which has the line-cap fix and the heavy feed), plus one refactor commit.

Blocking: does the synthesized hold fire long-press handlers?

Yes. I ran these live through the CLI on my own iPhone 17 Pro simulator (iOS 27.0). The runner was built from this branch. The test app was a Release build with main's JS repacked in. Every press logged AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=longPress. The policy line is logged once per kind and decision:

AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=coordinateLongPress axHealth=unknown frameSource=window keyboardPolicy=never fallbackPolicy=xctestCoordinateWhenAccessibilityAvailable fallbackAllowed=true fallbackAttempted=false

(a) Normal tree: the automation lab's Long press canary (RN Pressable, 500 ms long-press delay)

command snapshot after
longpress @e10 Last input: longpress, Long presses: 1
longpress 201 462 Long presses: 2
longpress 'id="automation-longpress"' Long presses: 3
press 201 462 --hold-ms 800 --count 2 --interval-ms 300 (a sequence with 2 longPress steps, 2 dispatch lines) Long presses: 5

The hold lasts the requested time, not just one dispatch. longpress 201 462 <ms> at 200 ms and 350 ms gave Last input: tap with the count unchanged. At 650 ms and 1000 ms it gave longpress, and the count went 1 → 2.

Native UIKit. In Safari, longpress @e16 on the Tabs button (800 ms) opened the UIKit context menu. The snapshot after it showed Private, 1 Tab, Close This Tab, Move to Tab Group, New Private Tab, New Tab. The policy line had axHealth=healthy … fallbackAttempted=false. On the same button, longpress 344 816 300 acted as a tap and opened Tab Overview. So a recognizer with a minimum press duration sees the contact held for durationMs.

(b) Heavy feed (agent-device-test-app:///heavy-feed), on the sole-screen route:

AGENT_DEVICE_RUNNER_AX_SNAPSHOT_ISSUE_SUPPRESSED count=1 description=Failed to resolve query: Error kAXErrorIllegalArgument …
AGENT_DEVICE_RUNNER_SYNTHESIZED_SOLE_SCREEN_ROUTE resolved={{0, 0}, {402, 874}}
AGENT_DEVICE_RUNNER_SYNTHESIZED_DISPATCH kind=longPress point=(226.0,535.0) reference=(0.0,0.0,402.0,874.0) orientation=1
AGENT_DEVICE_RUNNER_SYNTHESIZED_GESTURE_POLICY kind=coordinateLongPress axHealth=unavailable frameSource=window keyboardPolicy=never fallbackPolicy=xctestCoordinateWhenAccessibilityAvailable fallbackAllowed=false fallbackAttempted=false
command snapshot after
longpress @e63 Last action: held post 1
longpress 226 278 Last action: held post 0
press 226 644 --hold-ms 700 --count 2 --interval-ms 300 (2 sole-screen dispatches) Last action: held post 2

The synthesis triggered every recognizer I tried, so I did not change it. I did not add a recognizer-level test. The runner host app (AgentDeviceRunnerApp.m, 1,013 lines) would need a split first. The existing assertion that each hold takes ≥ 600 ms already catches a press that lifts early.

One side note: frameSource=window is printed on the sole-screen route too. That token is on main and comes from synthesizedGesturePolicyLine. I left it alone here.

Non-blocking

  1. Applied (9f5cbc9ebd). gestureViewportFrame now reads synthesizedCoordinateContext(.synthesizedDrag). It trims for the keyboard only on the window route. Rebuilt and checked live: swipe on the heavy feed (gestureViewport ok=1, drag reference 402x874, feed moved to post 5) and on the automation lab (window route).
  2. Kept, with a reason. The runner already owns the platform rule for scroll, drag, swipe, the gesture viewport and long press through #if os(iOS). That check is false on tvOS and visionOS, which is the same rule as runnerSynthesizesTap. Tap is the only gesture whose synthesis the daemon decides, through the synthesized wire flag. Moving long press onto the flag would add a second wire-decided kind. The single-owner fix is to remove the tap flag. That changes the runner wire contract (contract type, 3 TS producers, Swift models, tests), so it belongs in its own PR. I can open it.

Validation

  • Runner unit tests (built with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1): 44/44 on iPhone 17 Pro and 27/27 on iPad Pro 11 (M5), iOS 27.0. Agreed: the new regression tests only discriminate on a single-screen iPhone. On iPad they assert the XCTest fallback.
  • check:xctest-selection passed.
  • check:affected --run --head 9f5cbc9ebd passed with exactCommit=true.
  • This PR and fix(ios): verify deep-tree back on the sole screen #3894 still merge cleanly (git merge-tree). The combined runner builds, and 54/54 tests pass on both simulators (Navigation, SynthesizedInteraction, SynthesizedGesturePolicy, SequenceExecution).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant