# iOS driver helper (/docs/reference/e2e-driver)



`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 [#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](https://github.com/facebook/idb), pinned to one release.

<Callout type="warn">
  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.
</Callout>

## Platforms [#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 [#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 [#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]

```bash
warden doctor
```

```text
✓ 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 [#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` [#--fetch-helper]

```bash
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 [#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-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](/docs/agents/ui#android)) 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](/docs/e2e/specs#fewer-snapshots)), 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.
