# warden.config.ts (/docs/reference/configuration)



`warden.config.ts` declares projects, app builds, saved batches and e2e suites for a repo; add one when you outgrow the defaults. Optional, at the repo root. Without it, warden detects a single Expo project from `app.json` / `app.config.*`.

## Setup [#setup]

Add `@delacour/warden` as a dev dependency, then wrap the config in `defineConfig`. Your editor gets autocompletion, the defaults and docs on hover, and type errors for typos:

```bash
bun add -d @delacour/warden
```

warden runs the file itself (Bun transpiles it), so it needs no build step. It looks for `warden.config.ts`, `.mts`, `.js`, `.mjs` and then `warden.config.json`, walking up from the cwd. Keep only one of them per directory. JSON configs still work but have no autocompletion.

Instead of an object, the default export can be a **sync** function. It receives `{ configDir, env }`:

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

export default defineConfig(({ env }) => ({
  projects: [{ name: "app", bundleId: { ios: env.IOS_BUNDLE_ID ?? "com.example.app" } }],
}));
```

If you'd rather not install the package, use a type-only import. It is erased before the file runs:

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

export default { batches: { smoke: { platform: "ios", cmd: ["true"] } } } satisfies WardenConfig;
```

The section types are exported too: `WardenConfig`, `WardenProject`, `WardenBatchPreset`, `WardenE2eSuite`, `WardenE2eSpecEntry`, `WardenE2eMetro`, `WardenE2eSpec`, `WardenE2eDeviceLeg`, `WardenAppOption`.

## Top level [#top-level]

Besides `projects`, `batches` and `e2e`, the top level takes:

<TypeTable
  type="{
  headless: { type: &#x22;boolean&#x22;, description: &#x22;iOS: close the Simulator.app windows of every device an `e2e` suite or `batches` preset leases. The default for those that don't set their own `headless`. `--headless` / `--no-headless` override it; `WARDEN_HEADLESS=1` applies only when it's unset. `warden dev` ignores it. See [Headless e2e](/docs/devices/headless).&#x22;, default: &#x22;false&#x22; },
  &#x22;perf.budgets&#x22;: { type: &#x22;{ startupColdMs?, jankPct?, maxCommitMs?, p95CpuPct?, peakMemoryMb? }&#x22;, description: &#x22;Ceilings `device.perf.stop()` (and `warden e2e --perf`) hold a perf report to, lower is better: cold start (ms), janky frames (%), the slowest React commit (ms), 95th percentile CPU (%), peak memory (MB). A breach fails the test with a `perf_budget` error naming metric, value, budget and the report. A metric whose capture did not run has no number and cannot breach. A suite's own `perf.budgets` replace these per key; `device.perf.stop({ budgets })` replaces both for one spec.&#x22; },
  &#x22;android.snapshot&#x22;: { type: &#x22;boolean&#x22;, description: &#x22;The only key of the `android` section. Android: launch claimed emulators from warden's quick-boot snapshot of the AVD when it exists; `warden gc` builds it once per AVD from one writable cold boot while the AVD is idle (never under `CI`), and `false` means warden never boots your AVD writable (see [Golden images](/docs/devices/golden-images#android-wardens-quick-boot-snapshot)). `WARDEN_ANDROID_SNAPSHOT=0` / `=1` override it.&#x22;, default: &#x22;true&#x22; },
}"
/>

## Projects [#projects]

```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", android: "com.example.salient" },
    fingerprint: { command: "bun run --silent fingerprint:{platform}" },
    eas: { profile: "development-simulator", workflow: ".eas/workflows/dev-build.yml", trigger: false },
    build: { ios: "bunx expo run:ios --no-install --no-bundler" }
  }]
});
```

<TypeTable
  type="{
  name: { type: &#x22;string&#x22;, description: &#x22;Project name, used in the cache key.&#x22; },
  root: { type: &#x22;string&#x22;, description: &#x22;Project directory, relative to the config file.&#x22;, default: '&#x22;.&#x22;' },
  bundleId: { type: &#x22;{ ios?: string; android?: string }&#x22;, description: &#x22;Bundle id / package per platform. The key is required (`{}` is accepted); the values are needed for a dynamic app.config.&#x22; },
  &#x22;fingerprint.command&#x22;: { type: &#x22;string&#x22;, description: &#x22;Command that prints the native fingerprint. `{platform}` is substituted. Default: `@expo/fingerprint`.&#x22; },
  &#x22;fingerprint.include&#x22;: { type: '&#x22;native&#x22; | &#x22;native+js&#x22;', description: &#x22;What the cache / install key covers. `native+js` adds a content hash of `jsInputs`, for Release builds that embed the JS bundle.&#x22;, default: '&#x22;native&#x22;' },
  &#x22;fingerprint.jsInputs&#x22;: { type: &#x22;string[]&#x22;, description: &#x22;Required with `native+js`. Globs, relative to the project root, of the JS sources. A glob starting with `../` reaches outside the project (a workspace package the bundle embeds, e.g. `../../../packages/engine/src/**`). Only git-tracked and untracked-not-ignored files count.&#x22; },
  &#x22;eas.profile&#x22;: { type: &#x22;string&#x22;, description: &#x22;EAS build profile to look up builds under. Without an `eas` section, EAS is used only when the project has an `eas.json` (this profile, no trigger). `eas: false` never consults eas-cli.&#x22;, default: '&#x22;development-simulator&#x22;' },
  &#x22;eas.workflow&#x22;: { type: &#x22;string&#x22;, description: &#x22;EAS workflow file to trigger on a miss.&#x22; },
  &#x22;eas.trigger&#x22;: { type: &#x22;boolean&#x22;, description: &#x22;Trigger the workflow when no EAS build matches.&#x22;, default: &#x22;false&#x22; },
  build: { type: &#x22;{ ios?: string; android?: string; configuration?: \&#x22;Debug\&#x22; | \&#x22;Release\&#x22;; cache?: boolean }&#x22;, description: &#x22;Local build command per platform, used as the last resort.&#x22; },
  &#x22;build.configuration&#x22;: { type: '&#x22;Debug&#x22; | &#x22;Release&#x22;', description: &#x22;Which build a local build produces. Sets the artifact search (`<Config>-iphonesimulator`, `apk/release`) and, unless `build.<platform>` is set, the default command (`--configuration Release` / `--variant release`).&#x22;, default: '&#x22;Debug&#x22;' },
  &#x22;build.cache&#x22;: { type: &#x22;boolean&#x22;, description: &#x22;Shared compiler caches for local builds: ccache (`$WARDEN_HOME/ccache`, `CCACHE_BASEDIR` = git toplevel, so worktrees hit each other's objects), Xcode 26 compilation caching, Gradle build cache. `false` turns them off.&#x22;, default: &#x22;true&#x22; },
  cacheDirs: { type: &#x22;string[]&#x22;, description: &#x22;Extra (legacy) cache roots laid out as `<dir>/<hash>/*.app|*.apk`, imported on demand. `~` expands to `$HOME`; relative paths resolve against the project root.&#x22; },
  variants: { type: &#x22;Record<string, { fingerprint?; eas?; build? }>&#x22;, description: &#x22;Named builds (`dev`, `e2e`…) picked with `--variant`, `app: { variant }`, or `warden dev` (uses `dev` when present). A variant takes `fingerprint`, `eas` and `build`; each key it sets replaces the project's own wholesale. Every variant has its own cache key.&#x22; },
}"
/>

The file needs at least one of `projects`, `batches` and `e2e`. Without `projects`, app builds fall back to detecting `app.json` / `app.config.*`. With several projects, the one whose root contains the cwd is picked (deepest wins), else pass `--project`.

## Batches [#batches]

`batches` maps a name to a saved [`warden batch`](/docs/reference/cli#warden-batch-platformpreset----cmd) invocation, run with `warden batch <name>`. Each field mirrors the flag of the same name. Flags you pass on the command line override the preset, and `-- <cmd>` overrides `cmd`. `ios` and `android` can't be used as names.

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

The preset's cwd is its project's root, or the config file's directory when there is no `project`. Serve, the jobs and `jobsFrom.command` run there, and relative paths in the preset resolve against it.

<TypeTable
  type="{
  platform: { type: '&#x22;ios&#x22; | &#x22;android&#x22;', description: &#x22;Required.&#x22; },
  cmd: { type: &#x22;string[]&#x22;, description: &#x22;Required. Per-job argv; `{job}` `{udid}` `{worker}` `{seq}` are substituted.&#x22; },
  project: { type: &#x22;string&#x22;, description: &#x22;A `projects[].name`: its root is the preset's cwd, and `app` installs its build.&#x22; },
  count: { type: &#x22;number&#x22;, description: &#x22;Devices to claim.&#x22;, default: &#x22;1&#x22; },
  max: { type: &#x22;number&#x22;, description: &#x22;Max warden devices of this profile.&#x22; },
  profile: { type: &#x22;string&#x22;, description: &#x22;Device profile, e.g. `iphone-17`.&#x22; },
  runtime: { type: &#x22;string&#x22;, description: &#x22;Runtime, e.g. `latest`.&#x22; },
  label: { type: &#x22;string&#x22;, description: &#x22;Label on the device and port leases.&#x22;, default: &#x22;the preset name&#x22; },
  ttl: { type: &#x22;string&#x22;, description: &#x22;Lease TTL without heartbeat (`30s`, `10m`, `2h`).&#x22;, default: &#x22;30m&#x22; },
  wait: { type: &#x22;string&#x22;, description: &#x22;Wait this long for free devices (`30s`, `10m`, `2h`).&#x22; },
  ports: { type: &#x22;string[]&#x22;, description: &#x22;`FROM[:SPAN]` port specs (`WARDEN_PORT_<i>`).&#x22; },
  app: { type: &#x22;boolean | { installMode?: 'clean' | 'reinstall'; resetKeychain?: boolean; variant?: string }&#x22;, description: &#x22;Ensure the app on every device first (`WARDEN_APP_PATH` / `WARDEN_APP_HASH` in serve and job env). Default: install only when the build differs (kept data). `{ installMode: \&#x22;clean\&#x22; }` = fresh app data each run without reinstalling an unchanged build (iOS: terminate, defaults delete, wipe data + app-group containers, privacy reset; Android: `pm clear`; falls back to reinstall if the reset fails); `{ installMode: \&#x22;reinstall\&#x22; }` = uninstall + install each run; `resetKeychain: true` (needs `installMode`) also resets the iOS sim keychain. `{ clean: true }` is a deprecated alias of reinstall. `{ variant }` installs that `projects[].variants` build.&#x22; },
  retry: { type: &#x22;number&#x22;, description: &#x22;Re-run a failed job up to N times on the same device.&#x22;, default: &#x22;0&#x22; },
  passes: { type: &#x22;number&#x22;, description: &#x22;A job passes only after N consecutive green runs on its device.&#x22;, default: &#x22;1&#x22; },
  env: { type: &#x22;Record<string, string>&#x22;, description: &#x22;Added to the serve and job env.&#x22; },
  serve: { type: &#x22;string&#x22;, description: &#x22;Long-lived `sh -c` command started before the jobs.&#x22; },
  serveReady: { type: &#x22;string&#x22;, description: &#x22;`http://…`, `tcp:PORT` or `file:PATH` (relative to the preset's cwd).&#x22; },
  serveTimeout: { type: &#x22;string&#x22;, description: &#x22;Give up waiting for `serveReady`.&#x22;, default: &#x22;10m&#x22; },
  jobs: { type: &#x22;string[]&#x22;, description: &#x22;The jobs. At most one of `jobs` / `jobsFrom`.&#x22; },
  jobsFrom: { type: &#x22;string | { command: string }&#x22;, description: &#x22;A file of jobs (one per line; the string `-` reads stdin), or a `sh -c` command whose stdout lines are the jobs (nonzero exit fails the batch).&#x22; },
  record: { type: &#x22;string&#x22;, description: &#x22;iOS: record every simulator and the TUI into this dir.&#x22; },
  logs: { type: &#x22;string&#x22;, description: &#x22;Per-job log dir.&#x22; },
  headless: { type: &#x22;boolean&#x22;, description: &#x22;iOS: close this run's Simulator.app windows (see [Headless e2e](/docs/devices/headless)). Overrides the top-level `headless`; `--headless` / `--no-headless` override both.&#x22;, default: &#x22;top-level `headless`&#x22; },
}"
/>

## e2e [#e2e]

`e2e` maps a suite name to the specs [`warden affected`](/docs/reference/cli#warden-affected-suite) selects and [`warden e2e`](/docs/reference/cli#warden-e2e-suite) runs. See [Affected e2e](/docs/e2e/affected) for how selection works. Every path and glob is relative to the config file.

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

export default defineConfig({
  e2e: {
    mobile: {
      specsDir: "specs",
      spec: { bundleId: "com.example.mobile" },
      platform: "ios",
      routerRoot: "apps/mobile/src/app",
      maxDepth: 3,
      runAll: ["bun.lock", "apps/mobile/ios/**"],
      specs: {
        "qa-login": { entries: ["apps/mobile/src/app/(auth)/login.tsx"] },
        "store-*": { required: false }
      }
    }
  }
});
```

<TypeTable
  type="{
  project: { type: &#x22;string&#x22;, description: &#x22;`projects[].name`: the specs, `serve` and `setup` run in its root, relative `serveReady` `file:` paths resolve against it, and `app` installs that project's build. Needed when the config has several projects and warden runs from the repo root.&#x22; },
  specsDir: { type: &#x22;string&#x22;, description: &#x22;Required. Directory of specs: `*.e2e.ts(x)` specs (other `.ts` files there are helpers; a helper change selects every spec importing it). Searched recursively; dot dirs, `node_modules` and `__baselines__` skipped.&#x22; },
  spec: { type: &#x22;{ bundleId?; launchArgs?; timeout?; actionTimeout?; assertionTimeout? }&#x22;, description: &#x22;How `warden e2e` runs the `*.e2e.ts` specs (`bun test` with `@delacour/warden-e2e`, see [Write e2e specs](/docs/e2e/specs)). `bundleId`: the app under test (default: the suite project's `bundleId` for the platform). `launchArgs`: passed by `app.open()` after `--initialUrl <metro url>` and `metro.launchArgs`. `timeout` (default `\&#x22;2m\&#x22;`): per test. `actionTimeout` (default `\&#x22;30s\&#x22;`): per tap, fill or wait for an element. `assertionTimeout` (default `\&#x22;5s\&#x22;`): polling timeout of `expect(locator)`. The specs run in the suite's `project` root, else the config file's directory.&#x22; },
  isolate: { type: '&#x22;device&#x22; | &#x22;spec&#x22;', description: &#x22;`device`: one `bun test` process per device runs its share of the specs (sharded round-robin by spec id), so a device's specs run in order in one process and the devices run in parallel; start-up is paid once per device. Every spec still gets its own verdict from `results.jsonl`, and a retry reruns only the specs that did not pass. `spec`: one process per spec, one worker per device: slower (each process pays bun's start, the preload and a driver connect), but a spec never sees what the one before left behind, and the devices share the specs dynamically. `device` is not allowed with `passes` > 1.&#x22;, default: '&#x22;device&#x22; (&#x22;spec&#x22; when `passes` > 1)' },
  shutdown: { type: '&#x22;always&#x22; | &#x22;idle&#x22;', description: &#x22;What a passed gate does to the devices warden created or booted for the run. `always`: shut them down before releasing. `idle`: leave them booted so the next run skips the 10-30 s boot, and let `warden gc` shut them down once they have been idle for 20 min. A failed run always leaves them up. `--no-shutdown` wins.&#x22;, default: '&#x22;idle&#x22; locally, &#x22;always&#x22; when `CI` is set' },
  jobTimeoutMs: { type: &#x22;number&#x22;, description: &#x22;Per spec: a spec process still going after this many milliseconds for each spec it runs (one spec, or a device's share with `isolate: \&#x22;device\&#x22;`) is screenshotted, killed (SIGTERM, SIGKILL 5 s later, whole process group) and counted as failed (exit 124). `--job-timeout` overrides.&#x22;, default: &#x22;600000&#x22; },
  devices: { type: &#x22;{ profile; count?; env?; default? }[]&#x22;, description: &#x22;Device legs, replacing `profile` / `count`: each claims its own pool (`count`, default 1) and runs the selected specs concurrently with the others, with its `env` added and only its own devices in `WARDEN_UDIDS`. `default: false` legs run only with `--devices all` or `--devices <profile>`. `profile` is required and names the leg (`{leg}`, `WARDEN_LEG`).&#x22; },
  metro: { type: &#x22;{ enabled?; port?; launchArgs?; open?; env?; prewarm?; readyTimeout? }&#x22;, description: &#x22;Dev-client suites: warden starts one Metro for the run; see below.&#x22; },
  platform: { type: '&#x22;ios&#x22; | &#x22;android&#x22;', description: &#x22;Default platform. Without it `warden affected` reports both and `warden e2e` needs `--platform`.&#x22; },
  base: { type: &#x22;string&#x22;, description: &#x22;Ref changes are measured against (merge-base with HEAD).&#x22;, default: '&#x22;main&#x22;' },
  tsconfig: { type: &#x22;string&#x22;, description: &#x22;tsconfig for files with no `tsconfig.json` between them and the config file.&#x22; },
  routerRoot: { type: &#x22;string&#x22;, description: &#x22;File-based router directory: `_layout` files above an entry count as entries.&#x22; },
  maxDepth: { type: &#x22;number&#x22;, description: &#x22;Import hops from an entry that still select a spec.&#x22;, default: &#x22;unlimited&#x22; },
  include: { type: &#x22;string[]&#x22;, description: &#x22;Spec-id globs: only matching specs are ever selected (default: all). Lets one `specsDir` feed several suites, e.g. `[\&#x22;online/**\&#x22;]`.&#x22; },
  exclude: { type: &#x22;string[]&#x22;, description: &#x22;Spec-id globs that are never selected, e.g. `[\&#x22;online/**\&#x22;]`.&#x22; },
  scope: { type: &#x22;string[]&#x22;, description: &#x22;Changed-file globs: only matching changes count at all (default: every change).&#x22; },
  unmatched: { type: '&#x22;run-all&#x22; | &#x22;skip&#x22;', description: &#x22;A counted change that reaches no spec (no `paths` / `entries` / `runAll` match, not `ignore`d): `run-all` selects every spec — a new `src/` area never silently runs nothing — `skip` selects nothing.&#x22;, default: '&#x22;skip&#x22;' },
  runAll: { type: &#x22;string[]&#x22;, description: &#x22;Globs: a matching change selects every spec.&#x22; },
  ignore: { type: &#x22;string[]&#x22;, description: &#x22;Globs: matching changes are ignored.&#x22; },
  always: { type: &#x22;string[]&#x22;, description: &#x22;Spec ids / globs that always run.&#x22; },
  unmapped: { type: '&#x22;run&#x22; | &#x22;skip&#x22;', description: &#x22;Specs with no `entries` / `paths`: run every time, or only via `runAll` / `always`.&#x22;, default: '&#x22;run&#x22;' },
  required: { type: &#x22;boolean&#x22;, description: &#x22;Default `required` for specs.&#x22;, default: &#x22;true&#x22; },
  passes: { type: &#x22;number&#x22;, description: &#x22;Consecutive green runs a spec needs.&#x22;, default: &#x22;1&#x22; },
  retry: { type: &#x22;number&#x22;, description: &#x22;Re-run a failed spec up to N times.&#x22;, default: &#x22;0&#x22; },
  count: { type: &#x22;number&#x22;, description: &#x22;Devices to claim (not with `devices`).&#x22; },
  profile: { type: &#x22;string&#x22;, description: &#x22;Device profile (not with `devices`).&#x22; },
  app: { type: &#x22;boolean | { installMode?: 'clean' | 'reinstall'; resetKeychain?: boolean; variant?: string }&#x22;, description: &#x22;Ensure the app on every device first. `{ installMode: \&#x22;clean\&#x22; | \&#x22;reinstall\&#x22; }` resets the installed app first (`--install-mode`), `resetKeychain: true` also resets the iOS sim keychain (`--reset-keychain`); `{ clean: true }` is a deprecated alias of reinstall. `{ variant }` installs that `projects[].variants` build (must exist on the suite's project).&#x22; },
  ports: { type: &#x22;string[]&#x22;, description: &#x22;Ports leased for the run (`<from>[:<span>]`, like `--port`), exported as `WARDEN_PORT_<i>` / `WARDEN_PORTS` to serve, setup and every spec process.&#x22; },
  env: { type: &#x22;Record<string, string>&#x22;, description: &#x22;Added to the serve, setup and spec env.&#x22; },
  serve: { type: &#x22;string&#x22;, description: &#x22;`sh -c` once per run before the specs, in its own process group; killed at the end. `{port}` / `{metroUrl}` are substituted. `--serve` overrides.&#x22; },
  serveReady: { type: &#x22;string&#x22;, description: &#x22;Wait for `http://…`, `tcp:PORT` or `file:PATH` (relative to the project root) before starting; `{port}` is the first leased port (`tcp:{port}`). Needs `serve`.&#x22; },
  serveTimeout: { type: &#x22;string&#x22;, description: &#x22;Give up waiting for `serveReady` after this long (`30s`, `10m`).&#x22;, default: '&#x22;10m&#x22;' },
  setup: { type: &#x22;string&#x22;, description: &#x22;`sh -c` once per leased device, after the app install and before that device's first spec (`{udid}` is substituted). Env: `WARDEN_UDID`, `WARDEN_WORKER`, `WARDEN_PORT_<i>`, `WARDEN_APP_PATH` / `WARDEN_APP_HASH`, plus `env`. Output goes to `logs/setup-<worker>.log`. A non-zero exit drops that device and its specs go to the others; none left fails the run.&#x22; },
  slim: { type: &#x22;boolean&#x22;, description: &#x22;iOS: switch off the simulator daemons specs never need on each leased device, before `setup` (`warden sim slim`). A failure only warns. Skipped with a note on an Android run; fails config validation on a suite fixed to `platform: \&#x22;android\&#x22;`.&#x22; },
  warmDrivers: { type: &#x22;boolean&#x22;, default: &#x22;true&#x22;, description: &#x22;iOS: the moment each device is leased (alongside the app install, Metro and `setup`), start its `warden-simd` helper and take one accessibility snapshot, so the first spec on each device does not pay the cold start. Jobs get its socket as `WARDEN_SIMD_SOCKET`; the log is `logs/warm-<worker>.log`. A failed or timed-out (2 min) warm-up only warns. `false` (or `--no-warm`) turns it off. The option's previous name fails validation with a pointer here.&#x22; },
  headless: { type: &#x22;boolean&#x22;, description: &#x22;iOS: close the Simulator.app windows of this suite's devices, at claim and again before the first spec, so the run shows nothing even with Simulator.app open (see [Headless e2e](/docs/devices/headless)). Overrides the top-level `headless`; `--headless` / `--no-headless` override both. Android is always headless.&#x22;, default: &#x22;top-level `headless`&#x22; },
  &#x22;perf.budgets&#x22;: { type: &#x22;{ startupColdMs?, jankPct?, maxCommitMs?, p95CpuPct?, peakMemoryMb? }&#x22;, description: &#x22;Perf budgets for this suite; each key replaces the top-level `perf.budgets` one (see above).&#x22; },
  specs: { type: &#x22;Record<string, SpecEntry>&#x22;, description: &#x22;Per spec id or glob: what each spec covers and whether it gates; see below. Not to be confused with `spec` above, which sets how every spec runs.&#x22; },
}"
/>

`metro` (a dev-client build needs a bundler; warden owns it for the run):

<TypeTable
  type="{
  enabled: { type: &#x22;boolean&#x22;, description: &#x22;Start Metro for the run.&#x22;, default: &#x22;true&#x22; },
  port: { type: &#x22;string&#x22;, description: &#x22;Where the Metro port is leased from (`<from>[:<span>]`). It is leased before the suite's `ports`, so it is `WARDEN_PORT`, `WARDEN_PORT_0` and `{port}`.&#x22;, default: '&#x22;8081:100&#x22;' },
  launchArgs: { type: &#x22;string[]&#x22;, description: &#x22;Extra launch arguments when warden opens the app on each device before its first spec (iOS: after `--initialUrl <metroUrl>`).&#x22;, default: &#x22;[]&#x22; },
  open: { type: &#x22;boolean&#x22;, description: &#x22;Open the app on each device once Metro is ready. `false` for a spec that installs the app itself and launches it against `WARDEN_METRO_URL`.&#x22;, default: &#x22;true&#x22; },
  env: { type: &#x22;Record<string, string>&#x22;, description: &#x22;Added to Metro's env only.&#x22; },
  prewarm: { type: &#x22;boolean&#x22;, description: &#x22;Build the bundle once before the first spec; a bundle that does not build fails the run right there.&#x22;, default: &#x22;true&#x22; },
  readyTimeout: { type: &#x22;string&#x22;, description: &#x22;Give up waiting for Metro after this long.&#x22;, default: '&#x22;2m&#x22;' },
}"
/>

Metro runs `bunx expo start --dev-client --port <p>` in the project root with `CI=1`, logging to `<batch dir>/metro.log`. It counts as ready only once `/status` on that port is proven to serve this checkout (the `X-React-Native-Project-Root` header, else the listening process's cwd); another worktree's Metro on the port fails the run with `port <p> is served by <dir>, not this worktree`. Spec processes get `WARDEN_METRO_URL`; `serve` gets `{metroUrl}`.

`specs.<id|glob>`:

<TypeTable
  type="{
  entries: { type: &#x22;string[]&#x22;, description: &#x22;Files the spec drives; their imports, transitively, select it.&#x22; },
  paths: { type: &#x22;string[]&#x22;, description: &#x22;Globs that select the spec.&#x22; },
  platforms: { type: '(&#x22;ios&#x22; | &#x22;android&#x22;)[]', description: &#x22;Default: from the id (`-ios-`), else both.&#x22; },
  required: { type: &#x22;boolean&#x22;, description: &#x22;A failure fails `warden e2e`; `false` = reported only.&#x22; },
  maxDepth: { type: &#x22;number&#x22;, description: &#x22;Overrides the suite's `maxDepth`.&#x22; },
}"
/>
