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



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: `WardenProject`, `WardenBatchPreset`, `WardenE2eSuite`, `WardenE2eFlow`.

## 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 repo root.&#x22; },
  bundleId: { type: &#x22;{ ios?: string; android?: string }&#x22;, description: &#x22;Bundle id / package. Required 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.&#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; },
  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). Each key a variant 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`. With only `batches`, app builds fall back to detecting `app.json` / `app.config.*`.

## 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-flows.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`, `26.5`.&#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.&#x22;, default: &#x22;30m&#x22; },
  wait: { type: &#x22;string&#x22;, description: &#x22;Wait this long for free devices.&#x22; },
  ports: { type: &#x22;string[]&#x22;, description: &#x22;`FROM:SPAN` port specs (`WARDEN_PORT_<i>`).&#x22; },
  app: { type: &#x22;boolean | { clean?: boolean; variant?: string }&#x22;, description: &#x22;Ensure the app on every device first (`WARDEN_APP_PATH` / `WARDEN_APP_HASH` in serve and job env). `{ clean: true }` also uninstalls it first (`--clean`); `{ 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: 'string | &#x22;-&#x22; | { command: string }', description: &#x22;A file of jobs (one per line), `-` for 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; },
}"
/>

## e2e [#e2e]

`e2e` maps a suite name to the flows [`warden affected`](/docs/reference/cli#warden-affected-suite) selects and [`warden e2e`](/docs/reference/cli#warden-e2e-suite) runs. See [Affected e2e](/docs/guides/affected-e2e) 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: {
      flowsDir: "flows",
      runner: ["<flow-runner>", "run", "{flowPath}", "--device", "{udid}"],
      platform: "ios",
      routerRoot: "apps/mobile/src/app",
      maxDepth: 3,
      runAll: ["bun.lock", "apps/mobile/ios/**"],
      flows: {
        "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 runner, `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; },
  flowsDir: { type: &#x22;string&#x22;, description: &#x22;Required. Directory of flows: YAML files, or `*.e2e.ts(x)` tests (other `.ts` files there are helpers; a helper change selects every flow importing it). Searched recursively; dot dirs, `node_modules` and `__baselines__` skipped.&#x22; },
  runner: { type: &#x22;string[]&#x22;, description: &#x22;Required. Per-flow argv; `{flow}` `{flowPath}` `{udid}` `{worker}` `{seq}` are substituted. Runs in the suite's `project` root, else the config file's directory.&#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 flow.&#x22;, default: &#x22;unlimited&#x22; },
  include: { type: &#x22;string[]&#x22;, description: &#x22;Flow-id globs: only matching flows are ever selected (default: all). Lets one `flowsDir` feed several suites, e.g. `[\&#x22;online/**\&#x22;]`.&#x22; },
  exclude: { type: &#x22;string[]&#x22;, description: &#x22;Flow-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 flow (no `paths` / `entries` / `runAll` match, not `ignore`d): `run-all` selects every flow — 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 flow.&#x22; },
  ignore: { type: &#x22;string[]&#x22;, description: &#x22;Globs: matching changes are ignored.&#x22; },
  always: { type: &#x22;string[]&#x22;, description: &#x22;Flow ids / globs that always run.&#x22; },
  unmapped: { type: '&#x22;run&#x22; | &#x22;skip&#x22;', description: &#x22;Flows 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 flows.&#x22;, default: &#x22;true&#x22; },
  passes: { type: &#x22;number&#x22;, description: &#x22;Consecutive green runs a flow needs.&#x22;, default: &#x22;1&#x22; },
  retry: { type: &#x22;number&#x22;, description: &#x22;Re-run a failed flow up to N times.&#x22;, default: &#x22;0&#x22; },
  count: { type: &#x22;number&#x22;, description: &#x22;Devices to claim.&#x22; },
  profile: { type: &#x22;string&#x22;, description: &#x22;Device profile.&#x22; },
  app: { type: &#x22;boolean | { clean?: boolean; variant?: string }&#x22;, description: &#x22;Ensure the app on every device first. `{ clean: true }` also uninstalls it first (`--clean`); `{ 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 runner.&#x22; },
  env: { type: &#x22;Record<string, string>&#x22;, description: &#x22;Added to the serve, setup and runner env.&#x22; },
  serve: { type: &#x22;string&#x22;, description: &#x22;`sh -c` once per run before the flows, in its own process group; killed at the end. `--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. 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 flow (`{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 flows go to the others; none left fails the run.&#x22; },
  slim: { type: &#x22;boolean&#x22;, description: &#x22;iOS: switch off the simulator daemons flows never need on each leased device, before `setup` (`warden sim slim`). A failure only warns.&#x22; },
  flows: { type: &#x22;Record<string, Flow>&#x22;, description: &#x22;Per flow id or glob; see below.&#x22; },
}"
/>

`flows.<id|glob>`:

<TypeTable
  type="{
  entries: { type: &#x22;string[]&#x22;, description: &#x22;Files the flow drives; their imports, transitively, select it.&#x22; },
  paths: { type: &#x22;string[]&#x22;, description: &#x22;Globs that select the flow.&#x22; },
  platforms: { type: '(&#x22;ios&#x22; | &#x22;android&#x22;)[]', description: &#x22;Default: from the id (`-ios-`), then the `launch` map, 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; },
}"
/>
