feat(ios): experimental Swift iOS engine over the engine protocol
Stacked on #1234. A second language behind the engine protocol: an iOS engine written in Swift and hosted by an XCUITest bundle on the simulator. It measures what driving the app straight from XCUITest buys over mobile() on agent-device.
How it works
flowchart LR
H[harness: connectEngine] -- JSON-RPC over TCP 127.0.0.1 --> S[EngineServer.testServe<br/>Swift, in the XCUITest runner]
T[transport.ts] -- xcodebuild test-without-building --> S
S -- XCUIApplication.snapshot, XCUICoordinate --> A[app under test]
packages/ios/server/E2EServer.xcodeprojis a host app plus theE2EServerUI test bundle. It's hand-written, so there's no generator dependency, and it builds withbuild:server.EngineServer.testServelistens on a localhost port, which the simulator shares with its Mac. It answers the protocol on the main thread, one connection at a time, and honors$/abortand abandonment as the Android server does.Device.swiftbuilds the tree from one XCUITest snapshot. It masks secure fields in pixels and screenshots, and acts with XCUITest gestures at the observed frames.transport.tsinstallsappPathand starts the test withxcodebuild test-without-buildingon a free port. It waits forE2E_SERVER_READY, then connects with a plain socket.ios({ waitForIdle: false })replaces XCUITest's private idle wait with a no-op, as WebDriverAgent can. It's opt-in and exists to measure what that wait costs.apps/mobile-benchmark/e2e.ios.config.tsruns the deterministic selection on it.BENCH_ENGINE=agent-deviceruns the same selection on the default engine.
Results
iPhone 17 Pro simulator, iOS 26.5, mobile benchmark app, agent-device and the Swift engine both with the benchmark's transition: 1500, one worker. Both pass all 41 suite tests, twice in a row.
| Suite | agent-device | Swift | Speedup |
|---|---|---|---|
| controls | 164.5s | 138.5s | 1.2x |
| flows | 69.5s | 57.3s | 1.2x |
| flows-lists | 233.3s | 231.2s | 1.0x |
| login-form | 47.1s | 28.1s | 1.7x |
| smoke | 8.9s | 6.4s | 1.4x |
| total | 523s | 461s | 1.13x |
Engine calls (p50):
| Operation | agent-device | Swift | Swift, no idle wait |
|---|---|---|---|
| observe | 94.4 ms | 18.0 ms | 17.6 ms |
| locate | 76.2 ms | 14.6 ms | 14.4 ms |
| screenshot | 671 ms | 105 ms | 104 ms |
| fill (19 chars) | 4852 ms | 1989 ms | 1538 ms |
| keyboard.type (10 chars) | 1213 ms | 429 ms | 428 ms |
| tap | 484 ms | 422 ms | 390 ms |
| back, list up | 1055 ms | 1710 ms | 1034 ms |
| swipe | 558 ms | 1433 ms | 1423 ms |
| restart app | 710 ms | 1705 ms | 1714 ms |
To match the mobile engine where the platform gives no signal, the Swift engine:
- Reads React Native's checkbox and radio roles out of the accessibility value.
- Taps a uniquely addressed node as an XCUITest element. That scrolls a covered one clear (the sticky footer) and reaches another process's sheet (Save Password).
- Makes a control that arrived with the last action wait out the transition budget, because a presenting view drops touches while it reports its final frame.
- Relaunches from the Mac with
simctlfor restart andclearState, and returns once the app answers.
The gain is much smaller than on Android (2.4x there). agent-device already drives iOS through an XCTest runner, so going direct removes only the daemon and HTTP hop:
- Reads are 5-6x faster (snapshot, locate, screenshot).
- Gestures are paced by XCUITest itself: its idle wait, and over a second per
press(forDuration:thenDragTo:)swipe.
The next lever is XCTest's private event synthesis for gestures (what WebDriverAgent uses).
Verified
Ran it locally: yes
- Suite:
apps/mobile-benchmark,e2e.ios.config.tson simulator stab-1, 41/41 on two consecutive runs (461s, 462s), againstBENCH_ENGINE=agent-device(41/41, 526s). - Micro-bench:
E2E_IOS_UDID=<udid> node bench/bench.ts --rounds 10, run twice with matching numbers. - Clean exit: the runner's
xcodebuildprocess ends on dispose, with no orphan left after a run.