warden

Scripts & batches

Claim devices and ports for your own script with warden run or warden batch, released on exit.

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, which does all of this for you.

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

What the command gets

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

VariableValue
WARDEN_UDIDSevery claimed udid / serial, comma-separated
WARDEN_UDID_<i>the i-th device
WARDEN_LEASE_IDSevery lease id
WARDEN_PORTSevery leased port
WARDEN_PORT_<i>the port leased for the i-th --port
WARDEN_APP_PATH / WARDEN_APP_HASHwith --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

With --app, warden first ensures the project's dev build is installed on each device — see App builds.

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

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

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:

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:

VariableValue
WARDEN_UDIDthis job's device
WARDEN_WORKERthe worker index (0-based)
WARDEN_DEVICE_NAMEthe leased device's name; device.devtools uses it to pick this device's inspector page when several devices share one Metro
WARDEN_JOBthe job string
WARDEN_JOB_SEQthe run count on this worker, 0-based, retries included
WARDEN_PASSwith --passes N: which consecutive run of the job this is, 0-based
WARDEN_BATCH_DIRwhere batch.json and the logs go
WARDEN_PORTthe 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

A batch you run often can live in warden.config.ts under batches, so the whole invocation becomes warden batch <name>:

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}"]
    }
  }
});
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.

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:

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" },
    },
  },
});
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 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

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

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

--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.

On this page