# e2e scripts (/docs/guides/e2e)



`warden run` is the primary integration for any repo's e2e script:

```bash
warden run ios --count 2 --port 8091:20 --port 3208:20 [--app] -- bun run e2e
```

It claims the devices and ports, then runs the command with these set:

| Variable                              | Value                                                 |
| ------------------------------------- | ----------------------------------------------------- |
| `WARDEN_UDIDS`                        | every claimed udid / serial, comma-separated          |
| `WARDEN_UDID_<i>`                     | the i-th device                                       |
| `WARDEN_LEASE_IDS`                    | every lease id                                        |
| `WARDEN_PORTS`                        | every leased port                                     |
| `WARDEN_PORT_<i>`                     | the port leased for the i-th `--port`                 |
| `WARDEN_APP_PATH` / `WARDEN_APP_HASH` | with `--app`: the installed build and its fingerprint |

It heartbeats every 30 s, forwards SIGINT / SIGTERM, releases everything on exit and passes the exit code through.

## Installing the app first [#installing-the-app-first]

With `--app`, warden first ensures the project's dev build is installed on each device — see [App builds](/docs/guides/app-builds).

```bash
warden run ios --count 2 --app --project apps/mobile -- bun run e2e
```

`--no-eas` skips EAS downloads; `--no-build` refuses to build locally on a cache miss. `--clean` uninstalls the app first (`xcrun simctl uninstall` / `adb uninstall`), even when the device already has the right build, so each run starts with an empty app container. In a preset or suite, `"app": { "clean": true }` is the same as `--app --clean`.

## Reading the env in a script [#reading-the-env-in-a-script]

```ts title="e2e/run.ts"
const udids = (process.env.WARDEN_UDIDS ?? "").split(",").filter(Boolean);
const metroPort = Number(process.env.WARDEN_PORT_0);
```

## Batches: one job per device [#batches-one-job-per-device]

When the work is a list of independent jobs — test flows, screenshot scenes — `warden batch` does the scheduling. It claims N devices, gives each one a worker, and hands the next job to whichever device frees up first:

```bash
ls flows | warden batch ios --count 5 --max 5 --port 8091:20 \
  --serve "bun run metro" --serve-ready tcp:8091 \
  --jobs-from - --retry 1 \
  -- bun run e2e:flow {job} --device {udid}
```

Each job gets the `warden run` variables plus:

| Variable           | Value                                                                |
| ------------------ | -------------------------------------------------------------------- |
| `WARDEN_UDID`      | this job's device                                                    |
| `WARDEN_WORKER`    | the worker index (0-based)                                           |
| `WARDEN_JOB`       | the job string                                                       |
| `WARDEN_JOB_SEQ`   | the run count on this worker, 0-based, retries included              |
| `WARDEN_PASS`      | with `--passes N`: which consecutive run of the job this is, 0-based |
| `WARDEN_BATCH_DIR` | where `batch.json` and the logs go                                   |

`--serve` runs in its own process group with the full lease in its env; it is SIGTERMed (then SIGKILLed after 15 s) once the jobs are done. If it exits before `--serve-ready` passes, the batch fails without waiting out the timeout.

`--passes N` makes a job pass only after N consecutive green runs on its device; the first red run is that attempt's result (and `--retry` can re-run it).

A live grid shows every device's current job, elapsed time and pass/fail counts; with `--no-tui` or no terminal it prints plain lines like `[03/30] warden-iphone-17-2 ▶ login` / `✓ login (12.4 s)` / `✗ login exit 1 (9.0 s)` (03/30 = the job's place in start order). Each job's output goes to `logs/<worker>-<seq>-<job>.log`.

### Saving a batch as a preset [#saving-a-batch-as-a-preset]

A batch you run often can live in [`warden.config.ts`](/docs/reference/configuration#batches) under `batches`, so the whole invocation becomes `warden batch <name>`:

```ts title="warden.config.ts"
import { defineConfig } from "@delacour/warden/config";

export default defineConfig({
  projects: [{ name: "salient", root: "apps/salient/app", bundleId: { ios: "com.example.salient" } }],
  batches: {
    "salient-e2e": {
      project: "salient",
      platform: "ios", count: 5, max: 5, profile: "iphone-17",
      ports: ["8091:20"], app: true, retry: 1,
      env: { E2E_SESSION_FILE: "e2e-artifacts/batch/session.json" },
      serve: "bun scripts/e2e/run-ios.ts --session",
      serveReady: "file:e2e-artifacts/batch/session.json",
      serveTimeout: "20m",
      jobsFrom: { command: "bun scripts/e2e/select-flows.ts --list --offline" },
      cmd: ["bun", "scripts/e2e/run-ios.ts", "--attach", "{job}", "--device", "{udid}"]
    }
  }
});
```

```bash
warden batch salient-e2e                       # 5 sims, the offline flows
warden batch salient-e2e --count 2 --jobs qa-login   # same setup, 2 sims, one flow
```

With `project` set, serve, the jobs and `jobsFrom.command` run in that project's root, relative paths (`serveReady` `file:`, `record`, `logs`, a `jobsFrom` file) resolve against it, and `app` installs that project's build. `env` is added to the serve and job env. Flags you pass override the preset, and `-- <cmd>` overrides `cmd`.

To queue only the flows a change needs, see [Affected e2e](/docs/guides/affected-e2e).

### Failure screenshots [#failure-screenshots]

When a job exits non-zero, warden screenshots its device right away (iOS `simctl io screenshot`, Android `adb exec-out screencap -p`) into the logs directory, named after the job's log (`0-2-login.log` → `0-2-login.png`; with `passes`, `….pass1.png`). Each failed retry attempt gets its own. The absolute path is the `screenshot` field on that job in `batch.json`, and on the flow in `e2e-report.json`. It is best effort with a 5 s cap: if the device can't be captured the field is simply absent and the batch carries on.

### Recording a run [#recording-a-run]

`--record <dir>` (iOS) records every simulator with `simctl io recordVideo` from the moment the serve is ready, mirrors the grid into an asciicast (`tui.cast`) and writes a `batch.json` timeline of every job. `scripts/demo/compose.ts` in the warden repo turns that directory into a single tiled video — it's how the landing page demo was made.
