iOS driver helper
warden-simd, the helper that taps and reads the accessibility tree of iOS simulators - where warden looks for it, the variables that steer it, and what warden doctor reports.
warden-simd is the macOS helper behind the iOS driver and warden ui; read this page when warden doctor reports a helper problem or you need to point warden at another binary.
What it is
warden-simd is a small macOS binary the iOS e2e driver and warden ui talk to. It does the three things xcrun simctl cannot:
- host-side touch and keyboard input (HID)
- the accessibility tree of the frontmost app, in one round trip, through a prebuilt guest that runs inside the simulator
- fast screenshots, orientation, and waiting for the UI to settle
Touch goes through two ops: pinch and gesture. gesture takes 1-2 equal-length point paths already resampled by the driver (about 16 ms a frame) and replays them as HID frames, one finger as touch, two as twoFingerTouch. Both fingers go down and lift together: the simulator's HID event carries one phase for both contacts. A helper built before this op answers E_UNSUPPORTED with reason: "helper_outdated"; restart it (warden ui close).
Everything else (install, launch, open URL, permissions, pasteboard, appearance, status bar, video) stays on xcrun simctl. There is no runner app to build or install on the simulator and no long-lived daemon: a helper starts on demand for a device and exits after ten idle minutes (no client connected, no request running). A finished run leaves it up for those minutes, so the next run on the same device skips the start. It is stopped sooner by warden release --shutdown, warden ui close, a shutdown of the device, or the lease passing to another owner, and replaced when the warden-simd binary was rebuilt or upgraded.
It links the MIT-licensed FBControlCore, FBSimulatorControl and FBSimulatorAX libraries from idb, pinned to one release.
The helper uses private CoreSimulator and Accessibility APIs, the same ones idb and Xcode use. They can change in any Xcode release. warden doctor shows the idb and Xcode versions the helper was built with, and the probe below tells you when a new Xcode has broken it.
Platforms
| Machine | Helper |
|---|---|
| macOS arm64 | shipped |
| macOS x64 | not in this version (idb's libraries are arm64-only); warden doctor says so. Android e2e is unaffected. |
| Linux | n/a, there is no iOS |
Where warden looks
The first match wins. An override that points at nothing is an error, never a fall-through, so a stale setting cannot quietly pick another binary.
WARDEN_SIMD: the path of thewarden-simdfileWARDEN_HELPER_DIR: a directory containingwarden-simd- next to the running
wardenbinary (this is where the npm@delacour/warden-darwin-arm64package puts it) packages/e2e/native/dist/in a warden source checkout (bun run --cwd packages/e2e native:deps && native:build)$WARDEN_HOME/simd/<warden version>/, filled bywarden doctor --fetch-helper
The helper needs its neighbours: Resources/SimulatorFrameworkBridge-iOS (the in-simulator guest) and two stub *.framework directories. They live in the same directory as the binary, and manifest.json next to them records the helper, idb and Xcode versions. Keep the directory together if you copy it.
A standalone warden binary (from GitHub releases, or replaced by warden update) has no helper beside it. Run warden doctor --fetch-helper once per version to put one in $WARDEN_HOME/simd/<version>/.
Environment variables
| Variable | Effect |
|---|---|
WARDEN_SIMD | use this warden-simd binary |
WARDEN_HELPER_DIR | look for warden-simd in this directory |
WARDEN_SIMD_PACE_MS, WARDEN_SIMD_HOLD_MS, WARDEN_SIMD_REPEAT_GAP_MS | key timing for typeText: gap after each key (30 ms), hold per key (40 ms), extra wait before a repeated key (300 ms). The client sends the whole string; the helper paces it. Before the first key the spec fill / clear / type / press and warden ui fill (and a spec's fill) wait until the tapped field reports focused (the helper reports the text field UIKit is editing, trait is-editing, as focused): keys sent while focus is still moving reach a React Native input during its focus re-render and are lost or reordered. A field that already has focus is not tapped again by type / press, so they type at its caret. |
WARDEN_SIMD_SOCKET | path of a running helper's socket. warden e2e and warden batch start one helper per leased device before the specs and set this for each job, so spec workers connect in milliseconds. Unset (a by-hand bun test run), the driver starts a helper on stdio itself. |
WARDEN_SIMD_IDLE_TIMEOUT | read by warden: how long a helper it starts waits with no client and no request before it exits (10m by default; any duration such as 90s). Applies to helpers started after it is set. |
WARDEN_SIMD_TIMEOUT | read by warden ui: how long one request to the helper may take before the client gives up with E_TIMEOUT (the client's 10 s by default; any duration such as 60s). The first snapshot injects the in-sim bridge, which on a cold hosted simulator can outlast 10 s. |
WARDEN_SIMD_STREAM_IDLE_S | seconds of no settle after which the helper closes its quiescence stream. Unset or 0 (the default) keeps it open for the helper's life. See "After an app relaunch" below. |
WARDEN_SIMD_TRACE | 1: the helper logs one debug event per quiescence sample (same as warden-simd --trace) |
WARDEN_E2E_DEBUG | 1: log every helper operation and simctl call with its duration to stderr |
WARDEN_HELPER_URL | --fetch-helper only: directory URL holding warden-simd-darwin-arm64.tgz and SHA256SUMS, instead of the GitHub release for this version |
WARDEN_RELEASE_REPO | GitHub repo --fetch-helper downloads from (default delacournz/warden) |
warden doctor
warden doctor✓ sim-helper /Users/me/.warden/simd/0.3.0/warden-simd (warden-home) — warden-simd 0.1.0, idb 1.1.8, xcode 26.6 (17F113)
✓ sim-helper-health 4A1B…: hid.transport=indigo ax.bridge=availablesim-helper is the path the lookup found, which step found it, and the build info from manifest.json. When nothing is found it warns, lists every path it tried, and names the fix. These rows only ever warn; doctor still exits 0.
sim-helper-health runs warden-simd --health --udid <udid> against the first booted iOS simulator, with a 5 second limit, and prints what it negotiated:
hid.transportis how input reaches the simulator:indigo(Xcode 26) ordtuhid(Xcode 27).ax.bridgeis the fast accessibility path.availablemeans the in-simulator guest answered.missingorfailedmeans snapshots fall back to the slower host-side accessibility API, with the reason printed.degradedmeans the guest is there but the last read through it failed, so reads may be falling back to the slower path. The row warns withax.lastError(the failure and how long ago it happened), for exampleax.bridge=degraded (last read failed, 4s ago: The application with pid 7 did not respond ...). A fresh--healthprocess has no read history, sodoctoralso asks a helper that is already serving that simulator (over$WARDEN_HOME/simd/<udid>.sock); only that one can reportdegraded. The next successful read clears it.
With no booted simulator there is nothing to probe and the row says so. doctor does not boot one.
After an app relaunch
The guest only reads a freshly launched app once a quiescence stream (the one settle uses) is bound to its process. The helper keeps one stream open for its whole life, so an app you terminate and launch again is readable straight away (about 100 ms for the first read in our measurements). If the stream is not open (it was closed, or the helper was started with WARDEN_SIMD_STREAM_IDLE_S), the first read after a relaunch fails with E_APP_NOT_RESPONDING; the helper then opens a stream, waits for its first state and reads again, which costs about 2 seconds once. The retry is logged as a warn event, and a read that still fails falls back to host-side accessibility and marks the bridge degraded.
WARDEN_SIMD_STREAM_IDLE_S=<seconds> closes an idle stream after that long (unset or 0 keeps it open). Use it only if another quiescence consumer shares the simulator: two concurrent streams leave each other stuck.
--fetch-helper
warden doctor --fetch-helperDownloads warden-simd-darwin-arm64.tgz and SHA256SUMS from the GitHub release v<version> (or WARDEN_HELPER_URL), checks the tarball against the sum, and unpacks it into $WARDEN_HOME/simd/<version>/. Nothing is unpacked when the checksum does not match. Then it runs the usual checks, so the sim-helper rows show the result. It exits 1 if the download fails or the machine is not macOS arm64.
How it is shipped
- npm:
@delacour/warden-darwin-arm64carriesbin/warden-simdand its resources next tobin/warden. The package.json records the helper build underwarden.helper. - GitHub releases:
warden-simd-darwin-arm64.tgz(the helper directory) andSHA256SUMS.
The helper is ad-hoc signed without the hardened runtime, like Homebrew's idb_companion.
Android driver
Android needs no helper and no warden doctor check: the driver is TypeScript over adb (packages/e2e/src/driver/android/), used by warden ui and the spec runner alike.
- One resident shell. Each driver keeps a single
adb -s <serial> shellprocess and writes one line per command,(<cmd>) 2>&1 </dev/null; echo "__WDN_<id>_$?__", then reads until that sentinel. Commands run one at a time, in order; the exit code is the number in the sentinel. A timeout (default 10 s) kills the shell and the next command starts a new one. If the shell dies, that command fails withE_HELPER_CRASHEDand one respawn is allowed; a respawn that also dies before finishing a command makes the session give up. Binary output never goes through it: screenshots use a separateadb exec-out screencap -p, and so does one of the dump routes. - Opening a driver takes one probe command (
sys.boot_completed, the sdk level,wm size,wm density), polled for up to 30 s (E_NOT_BOOTEDafter that), then one command that sets the three animation scales to 0. - Snapshots come from
warden-uiad, a UiAutomation server the driver starts on the first snapshot, settle or input and stops on close: a ~27 KB dex (embedded in@delacour/warden-e2e, written to/data/local/tmp/warden-uiad-<hash>.dex) run withapp_processover a secondadb shell, speaking NDJSON; a snapshot is 10-40 ms. It falls back, per call, touiautomator dumpparsed by a small XML walker (no dependency; the route that works is tried first next time, see Drive a device) and toinput.WARDEN_ANDROID_UIAD=0keeps the fallback, and so do Android older than 8 and a device the server cannot run on (remembered in$WARDEN_HOME/android/<serial>.json). Only one UiAutomation client can be on a device: when another holds it (Maestro,am instrument,uiautomator) snapshots fail naming it instead of falling back to a dump that would be killed. The driver keeps no snapshot of its own: everysnapshot()and every hit-test (elementAtPoint) is a fresh read. The spec runner reuses a whole-screen snapshot for up to a second when nothing has been done since (see the observation cache), and answersalert()from it, using the driver'salertIn(snapshot). - Input with the server is one request each: a tap (with
countand hold), a swipe (a drag is a swipe with a hold before the move and before the release), a key (pressKeyRepeat(key, n)is one request withtimes), andtypeText, which inserts into the focused field at the caret (any characters, sounicodeTextis true).selectAllselects the field's text on any supported level and the typing layer types over the selection (aclearfollows it with one Backspace).setTextreplaces the focused field's whole text in one request; afillover a field that already holds text uses it only underWARDEN_ANDROID_SETTEXT=1(the server sets the node its cached focus names, which right after the tap can still be the field focused before), and reads the field back as usual; empty and secure fields are always typed. After text or a key the driver waits 80 ms of quiet, because a press that lands while a controlled React Native input is still sending the change to JS is lost. A call the server refuses (nothing focused, injection refused) is done the old way,input tap|swipe|text|keyevent|motionevent:pressKeyRepeatis oneinput keyeventcall (32 per call), and on Android 13+selectAllis Ctrl+A (input keycombination 113 29). A server that dies mid-call is stopped; a tap, swipe, key or text in flight is reported as the error (it may have landed), the next call starts a new server. Settling is the server's quiet wait (via: "stream",nativeSettletrue), else snapshot hashing. - Apps: one
am start -W -S -n <launcher>to launch (the launcher activity is resolved once per package withcmd package resolve-activity --brief;-Wreturns after the first frame,-Sforce-stops first;monkey -p <package> -c android.intent.category.LAUNCHER 1afteram force-stopis the fallback when the launcher cannot be resolved),am start -a android.intent.action.VIEW -d <url>,pm clear, and hostadb install -r. - Gestures. The driver contract has
pinch,rotate(center, degrees, opts?)(positive is clockwise) andgesture(paths, opts?), andcapabilities.maxPointers(0 | 1 | 2);TwoFingerOptionsis{ durationMs?, radius?, angle? }. Geometry and timing are planned in TypeScript;warden-uiadhas agestureop that injects a multi-pointerMotionEventstream (ACTION_DOWN,ACTION_POINTER_DOWN, moves on an absolute schedule, then the ups) and replies{ frames, pointers, maxLagMs, via }. With the servercapabilities.pinchis true andmaxPointersis 2. Without itpinchis false,maxPointersis 1 on Android 8+ (else 0), and a 1-fingergesturefalls back toinput motioneventwhile pinch, rotate and two fingers returnE_UNSUPPORTED. A failure after the first down sendsACTION_CANCELand is never re-injected. - Unsupported (they return
E_UNSUPPORTED, orE_UNSUPPORTED_TEXTfor non-ASCII text): multi-finger gestures withoutwarden-uiad, clipboard, launch arguments and environment, shake, and key modifiers before Android 13.