Skip to content

fix: add explicit native taps for physical iOS - #1528

Closed
GijungKim wants to merge 2 commits into
software-mansion:mainfrom
GijungKim:fix/ios-native-selector-taps
Closed

GijungKim wants to merge 2 commits into
software-mansion:mainfrom
GijungKim:fix/ios-native-selector-taps

Conversation

@GijungKim

@GijungKim GijungKim commented Oct 21, 2026 •

Copy link
Copy Markdown
  • Add opt-in physical-iOS tap-element for one live XCTest element tap, without using bulk snapshot coordinates.
  • Match exact identifier/label constraints and an optional supported AX role. Reject missing, ambiguous, and non-interactable targets without a coordinate fallback; retain mutation journaling and warnings.
  • Keep existing flows, coordinate gestures, snapshot frames, and other platforms unchanged. Include focused safety tests, MCP screen capture integration, and brief tool/protocol documentation.
argent run tap-element --args '{"udid":"<device-id>","selector":{"label":"Search","role":"AXTextField"}}'
  • Prior isolated XCTest evidence on an iPhone 15 Pro with iOS 27.0.1 shows Search at snapshot y=571/587 versus live y=752. Native tapping opens the keyboard in both picker layouts.
  • The images are from that earlier helper run, not a hardware rerun of this final minimal patch: the USB device is now disconnected. Picker content, status information, and keyboard suggestions are masked. The installed Argent remains untouched.
Before: snapshot-center tap, no keyboard After: native element tap, keyboard appears
Masked picker after a snapshot-center tap Masked picker after the production native tap

@GijungKim GijungKim changed the title fix: use native element taps on physical iOS fix: add explicit native taps for physical iOS Oct 21, 2026
hubgan added a commit that referenced this pull request Oct 30, 2026
…iPhone (#1664)

## Summary

- Add a `touchPaths` command to the iOS device runner: one timed
waypoint path per finger (1 to 5), played at once as a single event
record through XCTest's event synthesizer.
- `gesture-pinch`, `gesture-rotate` and `gesture-custom` now run on a
physical iPhone through it. e2e `pinch`/`rotate` steps no longer fail
upfront there.
- A plain Down/Up `gesture-custom` (press-hold, drag, long-press pickup)
stays on `XCUICoordinate`, whose hold semantics a pickup needs. Only a
second finger or `Move` waypoints take the new path.
- Update the docs, rules and skills that said physical iPhones have no
two-finger gestures, and drop the same line from `describe`'s header.

## Why

On hardware the runner could only draw one straight press-then-drag, so
an agent testing an app with a pinch-to-zoom view, a map, a canvas, or
any custom multi-touch handler had to give up or drive the app's zoom
buttons. The docs and tool descriptions say "XCTest exposes no
two-finger coordinate API". That is true of the public `XCUICoordinate`
API, but XCTest builds its own touches from `XCPointerEventPath` and
`XCSynthesizedEventRecord`, and WebDriverAgent has used them for W3C
multi-finger actions for years.

## How it works

- `ArgentTouchSynthesizer` (Objective-C) looks both classes up with
`NSClassFromString` and checks every selector with
`instancesRespondToSelector:` before use. Nothing links against a
private symbol.
- If an Xcode renames or drops one, `touchPaths` answers
`UNSUPPORTED_OPERATION` with the name of what is missing. Every other
command keeps working, and single-finger gestures are unaffected.
- The runner is a test bundle installed on the developer's own phone and
never ships through the App Store, so the private API is a maintenance
risk but not a review risk. I would like your view on whether that trade
is acceptable. There is no public route to a second finger.
- Wire shape: `fingers: [{ points: [{ x, y, tMs }] }]`, absolute points
in the app's space like `tap` and `drag`. A finger whose first waypoint
is later than 0 touches down late, which is how a second finger joins a
gesture in progress. Documented in `PROTOCOL.md`.
- Validation is on the runner: 1 to 5 fingers, finite values, times
within 0 to 30 000 ms and non-decreasing per path.
- Pinch and rotate geometry moved from the tools into `gesture-utils` so
Harmony, the simulator loops and the iPhone path share one
implementation. Frames are 16 ms apart, the cadence the simulator and
emulator paths already use.
- The event record is built with
`initWithName:displayID:interfaceOrientation:` when XCTest and the main
screen offer a display id, and with the older
`initWithName:interfaceOrientation:` otherwise. Either one passes the
availability check, so an Xcode that keeps only one of them still works.
- Only an app drawn in **portrait** is accepted. The check reads the
app's own interface orientation (`XCUIApplication`'s
`interfaceOrientation`), not the way the phone is held, so a
portrait-only app on a phone turned sideways is not refused. An app
drawn in landscape is refused because I have not checked landscape on
hardware, and a touch that lands somewhere else without an error is
worse than a refusal. An orientation the runner cannot read is refused
too, rather than guessed. Lifting this needs a point mapping into the
panel's portrait space: callstack/agent-device#3838 has one, and I can
add it once someone has verified landscape on a phone.

## Verified on hardware

iPhone 12, iOS 27.0.1, Xcode 27.1, driven through the tool-server built
from this branch with `ARGENT_IOS_RUNNER_PROJECT` pointing at it, Safari
on a Wikipedia article. There were two runs, because the third commit
changed which record initializer is used.

**First two commits** (`initWithName:interfaceOrientation:`):

| Call | Result |
| --- | --- |
| `gesture-pinch` 0.1 to 0.6 over 400 ms | page zoomed about 3x |
| `gesture-pinch` 0.6 to 0.1 | zoomed back out |
| `gesture-rotate` | returns `rotated: true` (Safari has no rotate
handler, so there is nothing visible) |
| `gesture-custom`, one finger, 3 `Move` waypoints | page scrolled |
| `gesture-custom`, second finger joining late | page zoomed further |
| `gesture-custom`, plain Down/Up | still takes the `drag` path |
| `gesture-custom`, train starting with `Move` | refused before touching
the device |

**Third commit** (`initWithName:displayID:interfaceOrientation:`,
display id 1, orientation read as portrait). A temporary log line,
removed before committing, confirmed both values. I then re-ran on the
committed code:

| Call | Result |
| --- | --- |
| `gesture-custom`, four-event train at the off-centre "Bell Labs" link
(takes `touchPaths`, not `drag`) | opened the Bell Labs article, so the
touch landed where it was sent |
| `gesture-pinch` out, then in | zoomed, then back |
| `gesture-rotate` | returns `rotated: true` |
| `gesture-custom`, train starting with `Move` | refused before touching
the device |

I confirmed the synthesizer in a standalone XCUITest before wiring it
into the runner.

**Not verified:** other Xcode or iOS versions, iPads (not supported by
the runner), landscape (refused), a portrait-only app on a phone held
sideways (accepted by the check but not tried), any app other than
Safari, and timing fidelity. In the standalone probe a 0.6 s script took
0.96 s end to end, so expect gestures to run somewhat longer than their
nominal duration.

## Tests

- `ios-device-touch-paths.test.ts` (new): exact waypoints for pinch
(spread, odd duration, angle plus drifting centre, off-screen clamp),
rotate, and trains (delay spacing, late and early second finger,
refusals), plus the `touchPaths` wire shape and the pinch/rotate tools
on a device.
- `gesture-custom.test.ts`: the "rejects what XCTest cannot replay"
cases are replaced with the new behaviour and the remaining refusals.
- `flow-pinch` / `flow-rotate` / `describe-format-tree` / capability
ratchet updated for the new behaviour.
- `npm run build`, `npm run knip` and eslint on the changed files pass.
The full tool-server suite has 3 failures that are identical on an
untouched `main` in my environment (two real-ffmpeg letterbox tests and
the `e2e-tool-aliases` listing test), so I left them alone.
- There is no unit-test harness for the Swift side, so the runner
changes are covered by the hardware runs above.

## Related

- #944 (merged) listed this as a known gap: "gesture-pinch,
gesture-rotate: no two-finger coordinate API on hardware", and made the
e2e pinch/rotate steps fail upfront. This PR lifts that limit and
removes the gate. I found no open issue or PR for it.
- #538 (closed) wanted multi-touch on hardware through CoreDevice and
noted it might need XCTest instead. This is the XCTest route.
- callstack/agent-device#3838 added the same capability to
agent-device's XCTest runner through the same private classes, verified
on an iPhone 12. The third commit follows its choice of record
initializer and its use of the app's interface orientation.
- #489 (draft, no activity since 10 Jul) proposed pinch and rotate
through WebDriverAgent, before the XCUITest runner existed. This is the
runner-native equivalent.
- #1528 and #1624 also edit `ArgentRunnerSession+Gestures.swift` and
`RunnerProtocol.swift` (#1528 also `PROTOCOL.md` and
`runner-commands.ts`). The features are independent, but expect small
textual conflicts in whichever merges second. #1624 changes how window
coordinates are mapped on foldables, so it is worth a look alongside
this PR's portrait-only rule.
- Out of scope: Android. Physical-Android two-finger gestures are
blocked by #1628 / #1645, which live in the closed-source
`simulator-server`.

---------

Co-authored-by: Hubert Gancarczyk <[email protected]>
@latekvo

latekvo commented Nov 2, 2026

Copy link
Copy Markdown
Member

Hi @GijungKim,

thanks for the PR, this is generally a great feature that we have planned on our roadmap, but the execution in this PR doesn't really align with what we're aiming for:

  • we'll want to drive interactions through selectors using the existing tooling, not by adding new tooling
  • the tool you proposed only works on iOS, we generally require some sort of platform parity for all our tools (there can be exceptions when justified), this tool is only implemented on iOS

We'll be open to discussion how you think this feature should be implemented. Right now my position is that either we completely switch to driving gestures by selectors, keeping coords as fallback, or make E2E test building more accessible to the agent - that's where the access to selectors at write time is the most important.

@latekvo latekvo closed this Nov 2, 2026
latekvo added a commit that referenced this pull request Nov 4, 2026
…at never go idle (#1722)

On a physical iPhone, a gesture on a screen that never goes idle
(Bluesky's profile: expo-blur keeps a paused `UIViewPropertyAnimator`)
took 60-120 s and then failed with `RUNNER_BUSY`. Two things caused it:

- **Slow:** XCTest waits up to 60 s before and after every event for the
app to go idle.
- **Repeated:** the MCP adapter aborts a call after 30 s and sends it
again. The tool-server keeps running each aborted call, so one
`gesture-tap` landed 2-5 times.

**Fix:**
- The runner skips XCTest's idle waits and instead waits at most 2 s for
the app to settle before each event.
- `gesture-tap`, `gesture-swipe`, `gesture-custom`, `gesture-pinch`,
`gesture-rotate` and `button` are `longRunning`, as `keyboard` already
is.

| Stalled screen, one MCP `gesture-tap` | main | this PR |
|---|---|---|
| time | 135 s, then `RUNNER_BUSY` | ~5 s, ok |
| physical taps | 2 | 1 |
| text entry (focus / type) | 75 s / 55 s, both timed out | 2.3 s / 3.4
s, text landed |

> [!NOTE]
> **This uses private XCTest API.** There's no screenshotting or
sleep-polling. `ArgentRunnerSession+Quiescence.swift`:
> 1. Calls the same check XCTest runs on its own (`applicationImpl` →
`currentProcess` → `_initiateQuiescenceChecksIncludingAnimationsIdle:`
and `_makeQuiescenceExpectation`), but waits on it for at most 2 s
instead of 60 s.
> 2. Sends the event inside `-[XCUIApplication
_performWithInteractionOptions:block:]` with options `0b11` (skip the
pre-event and post-event waits). This is the same SPI agent-device uses.
>
> Selectors are resolved with `NSSelectorFromString` and called through
`unsafeBitCast` IMPs, and each one is checked with `responds(to:)`
first. If an Xcode drops one, the gesture falls back to XCTest's normal
60 s waits rather than crashing.
>
> **Cost:** on a screen that never goes idle, every gesture now waits
the full 2 s. On main it waited 60-120 s. Skipping the wait entirely
(0.4 s) lost taps sent right after a navigation (0/5 landed).

Docs: no docs-site change. `longRunning` is internal, and the runner's
PROTOCOL.md/README are updated.

<details>
<summary>Repro and verification</summary>

No physical iPhone was used. The real runner ran on an iOS 18.5
simulator, with stub `xcrun`/`xcodebuild` and a fake usbmuxd making the
tool-server treat it as a phone. The tool-server, MCP adapter and runner
were the real code. The fixture app stalls XCTest with either a paused
`UIViewPropertyAnimator` on a visual-effect view or a repeating
`UIView.animate`.

Direct to the runner:

| Case | main | this PR |
|---|---|---|
| Stalled screen, 3 taps | 60.5 s ok; 75.1 s `COMMAND_TIMED_OUT`; then
`RUNNER_BUSY` | 0.41 s, then 2.32-2.35 s each; 6/6 landed (both stall
types) |
| Stalled screen, focus / type / delete / return | 75 / 55 / 30 / 30 s,
all timed out | 2.34 / 3.36 / 3.32 / 2.42 s, all ok |
| Stalled screen, doubleTap / longPress / drag | - | 2.48 / 3.10 / 2.80
s, all landed |
| Normal screen: open modal, wait 100 ms, close (5 runs) | 5/5, close
took 1.0-1.1 s | 5/5, close took 0.75-0.82 s |
| `touchPaths` pinch (from #1664), stalled vs normal | - | ~0.74 s on
both, 3/3 registered (synthesized directly, never waited for idle) |

Through MCP, first tap of a session with the runner build padded to 45
s: main gave 2 tool calls and 2 taps (the app saw a double tap); this PR
gives 1 call and 1 tap.

Local checks: `npm run build`, `typecheck:tests`, prettier and eslint
pass. vitest passed 826 tests across the gesture, button, keyboard,
http, ios-device, ios-physical and run-sequence suites. The runner
builds for a real device (`build-for-testing generic/platform=iOS`).
`ios-device-gestures-long-running.test.ts` fails when any single
`longRunning` flag is removed.

Not fixed here:
- A first runner build longer than 300 s is still replayed (#1632).
- Bringing a backgrounded app to the foreground still uses XCTest's
normal waits, capped at about 15 s.
- #1528's `element.tap()` would need the same `skippingQuiescence`
wrapper.
</details>
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.

2 participants