warden

e2e scripts

Claim devices and ports for a script with warden run, released on exit.

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

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:

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

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

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:

VariableValue
WARDEN_UDIDthis job's device
WARDEN_WORKERthe worker index (0-based)
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

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

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

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