# Scripts & batches (/docs/devices/scripts)



`warden run` claims devices and ports, runs your command and releases everything on exit; use it (or `warden batch`) when you have your own e2e script. For specs written with `@delacour/warden-e2e`, use [`warden e2e`](/docs/e2e/suites), which does all of this for you.

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

## What the command gets [#what-the-command-gets]

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/devices/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. By default the app is installed only when the device lacks this fingerprint's build (a different build is installed over the old one, keeping its data). `--install-mode clean` gives each run fresh app data without reinstalling an unchanged build (iOS: terminate, `defaults delete`, wipe the data and app-group containers, privacy reset; Android: `pm clear`); if the reset fails it falls back to a reinstall. `--install-mode reinstall` uninstalls the app (`xcrun simctl uninstall` / `adb uninstall`) and installs it every run. `--reset-keychain` (needs `--install-mode`) also resets the iOS simulator's whole keychain, which an uninstall does not clear either. In a preset or suite, `"app": { "installMode": "clean", "resetKeychain": true }` is the same as `--app --install-mode clean --reset-keychain`. `--clean` and `"app": { "clean": true }` are deprecated aliases of `reinstall`.

## 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 specs, 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 specs | 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:spec {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_DEVICE_NAME` | the leased device's name; `device.devtools` uses it to pick this device's inspector page when several devices share one Metro |
| `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                                                                                            |
| `WARDEN_PORT`        | the first leased port (also `{port}` in the command, `--serve` and `--serve-ready`)                                           |

`--job-timeout 10m` kills a job that is still running after that long (its device is screenshotted first) and counts it as failed with exit 124, so one hung runner cannot stall the queue.

`--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-specs.ts --list --offline" },
      cmd: ["bun", "scripts/e2e/run-ios.ts", "--attach", "{job}", "--device", "{udid}"]
    }
  }
});
```

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

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 specs a change needs, see [Affected e2e](/docs/e2e/affected).

### Dev-client suites: `warden e2e` owns Metro [#dev-client-suites-warden-e2e-owns-metro]

A Release build embeds its JS and needs no bundler. A dev-client build does, and "whatever answers on the port" is not good enough: with several worktrees, that can be another checkout's Metro, and the specs then test the wrong code. Give the suite a `metro` block and warden runs the bundler itself:

```ts title="warden.config.ts"
export default defineConfig({
  projects: [{ name: "app", root: "apps/mobile", bundleId: { ios: "com.example.app" }, variants: { dev: { build: { configuration: "Debug" } } } }],
  e2e: {
    mobile: {
      project: "app",
      specsDir: "e2e",
      platform: "ios",
      app: { variant: "dev" },
      metro: { launchArgs: ["-E2E", "YES"] },
      devices: [
        { profile: "iphone-17", count: 2 },
        { profile: "ipad-pro-11-inch-m5", default: false, env: { E2E_LAYOUT: "regular" } },
      ],
      spec: { bundleId: "com.example.app" },
    },
  },
});
```

```bash
warden e2e mobile                 # from a cold shell, no env: 2 iPhones
warden e2e mobile --devices all   # the iPad leg too, concurrently
```

One command does the whole run: claim the devices of every leg, install the build, lease a port and start Metro on it in the project root, wait until that port is verified to serve **this** checkout, build the bundle once, open the app on each device with `--initialUrl`, run the specs ([`@delacour/warden-e2e`](/docs/e2e/specs) under `bun test`), stop Metro, release. A spec reads `WARDEN_METRO_URL` and `WARDEN_UDID` (its own device) and never has to guess a port or start a bundler. If the leased port is answered by another worktree's Metro, the run stops with `port <p> is served by <dir>, not this worktree` before any spec.

### Isolation [#isolation]

By default (`isolate: "device"`) one `bun test` process runs per device: the selected specs are sharded over the leg's devices round-robin by spec id, so the same specs always land together and a device's specs run in order in one process. That saves bun's start, the preload and the driver connect (150-250 ms) on every spec after a device's first. Under device isolation the batch jobs are named `shard-<k>` (`WARDEN_JOB`, `logs/<worker>-<seq>-shard-<k>.log`). Every spec still gets its own verdict, read from that process's `results.jsonl`, and its own per-test artifact directory.

* **A crash** (the process is killed, or exits without finishing): specs whose tests passed before it keep their pass; the spec that was running and every spec it never reached fail, with the process log.
* **Retries** (`retry`, `--retry`) rerun only the specs that did not pass, in a fresh process. A spec's verdict is the one from the last attempt that ran it, and `attempts` in `e2e-report.json` counts the attempts that ran it.
* **`jobTimeoutMs` is per spec**: a process gets it once for each spec it runs.
* **Load is split up front**, by spec id, not as devices free up. A suite whose specs differ a lot in length, on several devices, can keep one device busy after the others finish.

`isolate: "spec"` keeps one process per spec and one worker per device: each spec starts from a fresh process and the devices share the specs dynamically, at the cost of the start-up above. A suite with `passes` above 1 defaults to this (a spec's passes are re-runs of that spec); `passes` above 1 with `isolate: "device"` is a config error, and so is `--passes N` on a suite that sets `isolate: "device"`.

### 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 spec in `e2e-report.json`. For `warden e2e` specs the report prefers the spec runner's own `failure.png` of the failed test (the device at the failing step) over this after-the-fact shot. 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.
