warden

Affected e2e

Run only the e2e flows a change needs, picked from the git diff and the TypeScript import graph, and gate on the required ones.

A full e2e suite is slow; most changes touch a few screens. warden affected works out which flows a branch needs, and warden e2e runs just those on leased devices and fails when a required one fails.

warden affected mobile --base origin/main --explain   # what would run, and why
warden e2e mobile --base origin/main --count 2        # run it: exit 0 = every required flow passed

Flows stay in your flow runner's own files — YAML flows, or *.e2e.ts tests (e.g. tester.army e2e) whose relative imports count as fragments; warden only picks them, leases devices, runs your runner command once per flow, and reports.

How flows are picked

  1. Changed files. Everything that differs from merge-base(<base>, HEAD): commits on the branch, staged and unstaged edits, untracked files, both sides of a rename. --files a,b skips git.
  2. runAll. A change to a lockfile, native project or bundler config selects every flow.
  3. paths. Globs on a flow select it when a changed file matches.
  4. Import graph. Each flow lists its entries — the screens or routes it drives. Warden follows their imports, resolved the way the TypeScript compiler does (each file's nearest tsconfig.json: paths, baseUrl, extends, customConditions; workspace packages followed to their source). A changed file anywhere in that graph selects the flow.
    • Per platform. React Native platform files resolve per platform (x.ios.tsx → x.native.tsx → x.tsx), so an .android.tsx change only selects Android flows.
    • Runtime imports only. import type / export type are dropped, as the bundler drops them.
    • Router layouts. With routerRoot, every _layout above an entry counts as an entry too (file-based routers render them around the route).
    • maxDepth limits how many import hops from an entry still count. Apps with hub modules (a session context, a shared API client) otherwise reach almost everything from every screen; 3 is a good start.
  5. Flow files. A changed flow selects itself; a changed fragment selects every flow that run:s it.
  6. always flows run every time, and so do flows with no entries / paths unless unmapped is "skip".

A flow runs on the platforms named in its config, else the ios / android token in its id (store-ios-02-chats), else its per-platform launch map, else both.

--explain prints the reason for each flow, including the shortest import chain:

ios  2 of 11 flow(s)  (base origin/main, 3 changed file(s))
  ● qa-chat-attachment  required
      import   src/modules/chats/composer.tsx
               src/app/(app)/chats/[chat_id].tsx → src/modules/chats/thread.tsx → src/modules/chats/composer.tsx
  ● store-ios-03-chat-thread  optional
      import   src/modules/chats/composer.tsx
  skipped: qa-offline-banner, store-ios-01-my-day, …
  reached no flow: src/modules/voice/voice-auth.ts

"Reached no flow" lists changed files no flow covers — gaps in the map. --strict exits 3 when there are any.

Configuring a suite

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",
      tsconfig: "apps/mobile/tsconfig.json",
      routerRoot: "apps/mobile/src/app",
      maxDepth: 3,
      runAll: ["bun.lock", "patches/**", "apps/mobile/app.config.ts", "apps/mobile/ios/**", "apps/mobile/android/**"],
      ignore: ["**/*.md", "**/*.test.ts"],
      passes: 2,
      count: 2,
      flows: {
        "qa-chat-attachment": { entries: ["apps/mobile/src/app/(app)/chats/[chat_id].tsx"] },
        "qa-offline-*": { entries: ["apps/mobile/src/app/+native-intent.ts"], paths: ["apps/mobile/src/modules/linking/**"] },
        "store-*": { required: false }
      }
    }
  }
});

A flow's id is its path under flowsDir without the extension. Keys in flows are ids or globs; a flow's exact key and every matching glob merge (entries / paths add up, the exact key's required / platforms / maxDepth win). All paths are relative to the config file. See the field reference.

Running and gating

warden e2e is warden batch with the job list coming from the selection. Every batch and claim flag works (--count, --serve, --serve-ready, --app, --record …); the suite's count, profile, retry, passes, app, ports, env, serve, serveReady and serveTimeout fill in anything you don't pass, and project (a projects[].name) sets where the runner and serve run and which app is installed — the same semantics as a batch preset.

  • Each flow runs as runner with {flow} (id), {flowPath} (absolute file), {udid}, {worker} and {seq} substituted, in the config file's directory.
  • passes: 2 makes a flow pass only after two consecutive green runs on the same device (WARDEN_PASS = 0, 1), which catches flows that only pass from a lucky start state.
  • required: false flows are reported but never fail the gate. --required-only runs just the required ones.
  • e2e-report.json (in the batch dir, and at --report <file>) lists every flow with its reasons, attempts and verdict, plus a screenshot (png of the device) for a flow whose last attempt failed. --json prints it.
  • Leases are released when the run ends. Once the gate passes, the sims / emulators warden created (or booted for the run) are shut down too; a failed run leaves them booted so you can inspect them (warden release --mine --shutdown when done). --no-shutdown keeps them up after a pass.
  • Nothing affected → exit 0 without claiming a device. --dry-run selects and explains, then stops.

In CI

git fetch origin main
warden e2e mobile --base origin/main --count 2 --report e2e-report.json

The same command runs locally or in a pre-push hook. Parsed imports are cached under $WARDEN_HOME/affected, so repeat runs only re-read changed files.

Feeding a batch preset

warden affected prints one flow id per line, so it also works as a preset's job source:

jobsFrom: { command: "warden affected mobile --platform ios --base origin/main" }

Per-device setup

setup runs a shell command once on every leased device, after the app install and before that device's first flow. Typical uses: point the installed app at the suite's local API, or answer a first-launch system alert.

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

export default defineConfig({
  e2e: {
    online: {
      ports: ["8091:5"],
      serve: "bun api",
      setup: "xcrun simctl spawn {udid} defaults write com.example.app ApiUrl http://localhost:$WARDEN_PORT_0"
    }
  }
});

The command gets WARDEN_UDID, WARDEN_WORKER, WARDEN_PORT_<i> / WARDEN_PORTS, WARDEN_APP_PATH / WARDEN_APP_HASH and the suite env, and runs in the suite's project root. Each device's output is logs/setup-<worker>.log, next to the job logs. A device whose setup exits non-zero is dropped from the pool (its flows go to the remaining devices); if none is left the run fails and names the setup logs. e2e-report.json records every device under setup (worker, udid, ok, exitCode, log).

slim: true (iOS) runs warden sim slim on each device first, to save RAM and CPU when several simulators run together. A slim failure only warns; the device still runs flows. The result is recorded under setup[].slim in e2e-report.json.

On this page