Suites
Declare e2e suites in warden.config.ts and run them with warden e2e on leased devices.
A suite is a named entry under e2e in warden.config.ts: which specs, which app build, how many devices, whether warden runs Metro. You run it with warden e2e <suite>.
import { defineConfig } from "@delacour/warden/config";
export default defineConfig({
e2e: {
"example-dev": {
project: "example",
specsDir: "e2e",
platform: "ios",
app: { variant: "dev" },
metro: { env: { EXPO_PUBLIC_E2E: "1" } },
spec: { bundleId: "com.example.app" },
count: 2,
jobTimeoutMs: 15 * 60_000,
},
},
});warden e2e example-dev --allNo device to pick, no port to export, no bundler to start first. warden claims the simulators, installs the cached build, starts and verifies its own Metro when the suite has one, runs the specs and releases everything on exit (Ctrl-C included).
What a suite needs
specsDir(required): where the*.e2e.ts(x)specs are. Searched recursively; other.tsfiles there are helpers.spec: how specs run.bundleId(default the project's), extralaunchArgs, andtimeout(default2m, per test),actionTimeout(30s),assertionTimeout(5s). Specs run in the suite'sprojectroot, else the config file's directory.project: theprojects[].namewhose root runs specs andserve, and whose app is installed. Needed when the config has several projects and warden runs from the repo root.app:trueinstalls the project's app on every device first;{ variant }installs aprojects[].variantsbuild;{ installMode: "clean" | "reinstall", resetKeychain }resets the installed app first.metro: {}: warden leases a port (from8081:100), startsbunx expo start --dev-client --port <p>in the project root withCI=1, and calls it ready only once/statusproves the port serves this checkout. Another worktree's Metro on the port stops the run withport <p> is served by <dir>, not this worktree. With a Metro suite, warden passes--initialUrl <metro url>as the first launch argument, so everyapp.open()lands on warden's bundler. Metro's log is<batchDir>/metro.log.count,profile: devices to claim and their profile.isolate:"device"(default): onebun testprocess per device runs its share of the specs, sharded round-robin by spec id."spec": one process per spec, devices share the specs dynamically; each spec starts fresh.deviceis not allowed withpasses> 1, which defaults tospec.jobTimeoutMs(default 600000): per spec. A spec process still going after that long for each spec it runs is screenshotted, killed (SIGTERM, SIGKILL 5 s later, whole process group) and counted failed (exit 124).retry,passes: rerun a failed spec up to N times; require N consecutive green runs on the same device (WARDEN_PASS= 0, 1).serve,serveReady,serveTimeout,ports,env,setup,slim: a local API, per-device setup and spare ports; see Affected e2e and the field reference.
The first run of a native fingerprint builds the app (minutes); later runs reuse the cache.
Device legs
devices replaces profile / count with several legs, each claiming its own pool and running the selected specs concurrently with the others:
devices: [
{ profile: "iphone-17", count: 2 },
{ profile: "iphone-se", count: 1, env: { SMALL_SCREEN: "1" } },
{ profile: "ipad-pro", default: false },
]Each leg's env is added to its processes and only its own devices appear in WARDEN_UDIDS. A leg with default: false runs only with --devices all or --devices <profile>.
Run it
warden e2e example --all # every spec
warden e2e example-dev # only the specs this branch touches
warden e2e example-dev --specs todos,settings
warden e2e mobile --base origin/main --count 2 --report e2e-report.json
warden e2e --dry-run --files src/modules/chat/api.tsWith one suite the name is optional. warden e2e is warden batch with the job list coming from the selection, so every batch and claim flag works.
| Flag | Effect |
|---|---|
--all, --specs <list> | every spec of the suite, or exactly these ids / globs, skipping the git diff |
--base <ref>, --files <list> | compare against merge-base with this ref (default the suite's base, main), or give changed files instead of git |
--required-only, --explain, --dry-run | run only required specs; print why each spec was chosen; select and explain, run nothing |
--platform ios|android | default the suite's platform; without either, warden e2e needs it |
--count <n>, --profile <slug>, --runtime <rt>, --max <n> | devices to claim, profile, runtime, cap on warden devices of the profile |
--devices <legs> | which devices legs run: default, all or <profile,…> |
--wait <duration>, --no-wait, --priority, --ttl, --label | queue up to 30m for a device (default) or fail at once; boot-queue priority; lease TTL (default 30m); label in warden ls |
--app, --install-mode, --reset-keychain, --variant, --project, --bundle-id, --no-eas, --no-build | the app install, as in the config's app |
--retry <n>, --passes <n>, --job-timeout <duration> | override the suite's values |
--serve, --serve-ready, --serve-timeout, --port <from:span> | a local service and leased ports for the run |
--headless, --no-headless | close or keep Simulator.app windows (Headless) |
--record <dir>, --logs <dir>, --no-tui, --json, --report <file> | record every simulator and the TUI (iOS); job logs dir; plain lines instead of the live grid; JSON output; also write e2e-report.json here |
--no-shutdown, --no-warm, --adopt | keep devices up after a pass; skip the iOS driver pre-warm; allow foreign devices |
Exit 0: every required spec passed (or nothing was selected, in which case no device is claimed). Exit 1: a required spec failed, or setup failed. Exit 130: interrupted. required: false specs are reported but never fail the gate.
With no e2e suites configured and none named, warden e2e runs every e2e/*.e2e.ts spec of the Expo app around the current directory on the dev client with warden's Metro (2 sims). warden init scaffolds it.
After a pass, devices warden created stay booted locally (the next run skips the 10-30 s boot; warden gc shuts them down after 20 min idle) and are shut down under CI. A failed run always leaves them booted. shutdown: "always" | "idle" on the suite overrides this.
What a run prints
Each spec process prints one line when a test starts and ends, and a pair per step (a tap, a fill, an assertion):
[00:54:25.766] ▶ test |ios| e2e/todos.e2e.ts › adds a todo
[00:54:25.942] ▸ todos › app app.open
[00:54:27.193] ▪ todos › app app.open passed 1251ms
[00:54:29.470] ■ test |ios| e2e/todos.e2e.ts › adds a todo passed 3.21s on warden-iphone-17-2The [HH:MM:SS.mmm] stamp is warden's: every line in a job log, setup-<worker>.log, metro.log and serve.log carries the local time it arrived, and spec processes get WARDEN_LOG_TIMESTAMPS=1. The batch log (and --no-tui) adds one line per finished test, and the live grid ticks specs off:
✓ todos › adds a todo (3.21s)
✗ sign-in › a locked account shows the form alert (8.04s)When the run ends, every failed spec is listed with its failing tests, the first line of each error and the artifact directory. See Debugging.
As soon as each device is leased, warden starts its iOS driver helper and takes one accessibility snapshot (warmDrivers, on by default) while the app installs and Metro builds, so the first spec does not pay the cold start. warmDrivers: false or --no-warm turns it off; a failed warm-up (2 min limit) only warns.
Don't
- Start Metro yourself, or point a suite at a fixed port.
- Export
WARDEN_UDID*or port variables by hand for a suite; they exist only inside a run. - Hold devices with a bare
warden claimfor a test run. It lasts about 5 minutes;warden e2eholds its leases for as long as it runs.
Author specs by recording
Turn a warden ui session into a reviewed, passing e2e spec - record start, note, undo, stop with verify, lints, helpers for shared steps, secrets and the failure loop.
Affected e2e
Run only the e2e specs a change needs, picked from the git diff and the TypeScript import graph, and gate on the required ones.