warden

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

MachineHelper
macOS arm64shipped
macOS x64not in this version (idb's libraries are arm64-only); warden doctor says so. Android e2e is unaffected.
Linuxn/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.

  1. WARDEN_SIMD: the path of the warden-simd file
  2. WARDEN_HELPER_DIR: a directory containing warden-simd
  3. next to the running warden binary (this is where the npm @delacour/warden-darwin-arm64 package puts it)
  4. packages/e2e/native/dist/ in a warden source checkout (bun run --cwd packages/e2e native:deps && native:build)
  5. $WARDEN_HOME/simd/<warden version>/, filled by warden 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

VariableEffect
WARDEN_SIMDuse this warden-simd binary
WARDEN_HELPER_DIRlook for warden-simd in this directory
WARDEN_SIMD_PACE_MS, WARDEN_SIMD_HOLD_MS, WARDEN_SIMD_REPEAT_GAP_MSkey 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_SOCKETpath 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_TIMEOUTread 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_TIMEOUTread 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_Sseconds 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_TRACE1: the helper logs one debug event per quiescence sample (same as warden-simd --trace)
WARDEN_E2E_DEBUG1: 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_REPOGitHub 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=available

sim-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.transport is how input reaches the simulator: indigo (Xcode 26) or dtuhid (Xcode 27).
  • ax.bridge is the fast accessibility path. available means the in-simulator guest answered. missing or failed means snapshots fall back to the slower host-side accessibility API, with the reason printed.
  • degraded means the guest is there but the last read through it failed, so reads may be falling back to the slower path. The row warns with ax.lastError (the failure and how long ago it happened), for example ax.bridge=degraded (last read failed, 4s ago: The application with pid 7 did not respond ...). A fresh --health process has no read history, so doctor also asks a helper that is already serving that simulator (over $WARDEN_HOME/simd/<udid>.sock); only that one can report degraded. 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-helper

Downloads 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-arm64 carries bin/warden-simd and its resources next to bin/warden. The package.json records the helper build under warden.helper.
  • GitHub releases: warden-simd-darwin-arm64.tgz (the helper directory) and SHA256SUMS.

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> shell process 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 with E_HELPER_CRASHED and 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 separate adb 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_BOOTED after 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 with app_process over a second adb shell, speaking NDJSON; a snapshot is 10-40 ms. It falls back, per call, to uiautomator dump parsed by a small XML walker (no dependency; the route that works is tried first next time, see Drive a device) and to input. WARDEN_ANDROID_UIAD=0 keeps 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: every snapshot() 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 answers alert() from it, using the driver's alertIn(snapshot).
  • Input with the server is one request each: a tap (with count and 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 with times), and typeText, which inserts into the focused field at the caret (any characters, so unicodeText is true). selectAll selects the field's text on any supported level and the typing layer types over the selection (a clear follows it with one Backspace). setText replaces the focused field's whole text in one request; a fill over a field that already holds text uses it only under WARDEN_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: pressKeyRepeat is one input keyevent call (32 per call), and on Android 13+ selectAll is 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", nativeSettle true), else snapshot hashing.
  • Apps: one am start -W -S -n <launcher> to launch (the launcher activity is resolved once per package with cmd package resolve-activity --brief; -W returns after the first frame, -S force-stops first; monkey -p <package> -c android.intent.category.LAUNCHER 1 after am force-stop is the fallback when the launcher cannot be resolved), am start -a android.intent.action.VIEW -d <url>, pm clear, and host adb install -r.
  • Gestures. The driver contract has pinch, rotate(center, degrees, opts?) (positive is clockwise) and gesture(paths, opts?), and capabilities.maxPointers (0 | 1 | 2); TwoFingerOptions is { durationMs?, radius?, angle? }. Geometry and timing are planned in TypeScript; warden-uiad has a gesture op that injects a multi-pointer MotionEvent stream (ACTION_DOWN, ACTION_POINTER_DOWN, moves on an absolute schedule, then the ups) and replies { frames, pointers, maxLagMs, via }. With the server capabilities.pinch is true and maxPointers is 2. Without it pinch is false, maxPointers is 1 on Android 8+ (else 0), and a 1-finger gesture falls back to input motionevent while pinch, rotate and two fingers return E_UNSUPPORTED. A failure after the first down sends ACTION_CANCEL and is never re-injected.
  • Unsupported (they return E_UNSUPPORTED, or E_UNSUPPORTED_TEXT for non-ASCII text): multi-finger gestures without warden-uiad, clipboard, launch arguments and environment, shake, and key modifiers before Android 13.

On this page