warden

Benchmark e2e frameworks

Run the same 8-test suite through warden, Argent, agent-device and tester.army on one device and compare the numbers.

packages/e2e-bench answers one question: how long does the same suite take in each framework, on the same build and the same device. It runs warden, Argent, agent-device and tester.army one at a time and writes a Markdown and an HTML report. No LLM is involved.

Build the app once

The benchmark installs one pinned app on every framework's run. It is built once and stored at .bench/app/<platform>/ (the .app or .apk plus a manifest.json with the warden fingerprint, sha256, source and commit). Every bench run reuses it. bench run never builds, fingerprints or calls EAS: it installs the pinned artifact (simctl install / adb install -r) after claiming the device, and builds it once only when it is missing.

bun run bench app build --platform ios            # build (warden cache, then a local Expo build) and pin it
bun run bench app build --platform ios --force    # rebuild even though one is pinned
bun run bench app build --platform android --eas  # also look the build up on EAS
bun run bench app status --platform ios           # hash, source, builtAt; fresh or stale, exit 1 when missing or stale
bun run bench app clean --platform ios            # remove the pinned app
bun run bench run --platform ios --app path/to/Example.app   # use a pinned dir or import a bare .app / .apk

app build resolves the build with warden app ensure --variant bench on apps/example. Without --force it reuses the pinned app without fingerprinting. app status compares the pinned hash with warden app fingerprint --variant bench.

Rebuild with --force after changing apps/example or its Expo and React Native dependencies. Changes to packages/e2e or warden do not need a rebuild.

Build on EAS

The app is the Expo project @delacour/warden (extra.eas.projectId in app.config.ts, GitHub base directory apps/example), so bench app build --eas also looks the bench profile up on EAS (bunx eas-cli, logged in to the delacour account). The bench variant in apps/example/warden.config.ts is the only place EAS is configured, and eas.json holds the Release simulator and APK profile. .eas/workflows/bench-build.yml builds both platforms on EAS: run it with eas workflow:run .eas/workflows/bench-build.yml, or it runs on pushes to main that touch apps/example/.

Run the baseline on EAS

.eas/workflows/bench-baseline.yml builds the bench iOS app once, then runs one job per framework, each on its own EAS macos-large worker with its own simulator, so a measurement never touches your Mac and nothing one framework leaves behind reaches the next. Dispatch it from apps/example:

bunx eas-cli workflow:run .eas/workflows/bench-baseline.yml -F runs=5                                  # all four frameworks
bunx eas-cli workflow:run .eas/workflows/bench-baseline.yml -F runs=5 -F frameworks=argent,agent-device

Inputs: runs (reps, default 5; rep 1 is cold), frameworks (comma list of warden,argent,agent-device,tester-army, default all four; a framework's job is skipped unless named) and max_minutes (default 55). Each framework job runs packages/e2e-bench/scripts/eas-bench.sh: install deps, build warden-simd, install argent or agent-device with bun add -g when that is its framework (tester-army comes with bun install), claim a headless simulator, record a 60 s idle control and run bench run against the downloaded .app. A watchdog stops the bench when the job reaches max_minutes; the results so far are kept. On the way out the job screenshots the simulator (sim-final.png), lists the apps it still runs and releases the lease, even when the run fails.

The workflow fingerprints the native project and reuses an existing bench build with the same fingerprint, so it only builds when apps/example changed. Each job uploads an eas-ios-<framework> artifact to the run page (expo.dev/accounts/delacour/projects/warden/workflows); bunx eas-cli workflow:view <run-id> prints the download URLs. Each archive unpacks to Users/expo/bench-results. Copy one to .bench/eas-ios-<framework> and re-render:

bun run bench report .bench/eas-ios-warden

A hosted macOS worker idles hotter than a Mac and shares its host, so EAS numbers compare only with EAS numbers from the same worker class (macos-large). Never compare them with local runs.

Run it

From the repo root:

bun run bench doctor                         # is every framework installed? prints install hints
bun run bench run --platform ios --runs 3    # full run on a device warden claims
bun run bench run --frameworks warden,argent --runs 2 --udid <leased-udid>
bun run bench list                           # frameworks, platforms, the 8 canonical test ids
bun run bench report .bench/<run-id>         # re-render the reports
bun run bench compare .bench/<run-a> .bench/<run-b>
bun run bench summary .bench/<run-id>        # slim summary.json for the docs page
FlagDefaultMeaning
--platform ios|androidioswhich leg to run
--frameworks a,ball fourcomma list of warden,argent,agent-device,tester-army
--runs N3reps per framework. Rep 1 is cold, the rest are warm
--retries N0forwarded to every framework
--app PATH.bench/app/<platform>pinned app dir, or a bare .app / .apk to import and pin
--udid Xclaim onereuse a device you leased; nothing is claimed or released
--reboot-betweenoffreboot the device between frameworks
--out DIR.bench/<timestamp>-<platform>output directory
--skip-doctoroffskip the per-framework doctor
--forceoffrun even when the load average is above the core count
--keep-driversoffkeep driver apps installed (no cold uninstall)
--node-bin PATHnonenode bin dir to put first on PATH

A full iOS run takes about an hour. Keep the Mac awake and use a dedicated simulator. Ctrl-C tears down, releases the lease and still writes results.

Prerequisites

FrameworkNeeds
wardenbun run --cwd packages/e2e native:build for the iOS helper
Argentbun add -g @swmansion/argent
agent-devicebun add -g agent-device
tester.armyNode ^22.22.3 || >=24.8 (for example nvm install 24)

The harness never installs global tools. bench doctor prints the exact command for anything missing.

VariableEffect
BENCH_NODE_BINnode bin dir used for tester.army when the default Node is too old
BENCH_TESTER_ARMY_STATE_DIRprivate agent-device state dir for tester.army

Read the report

A run writes .bench/<run-id>/:

bench-results.json   machine-readable, schema v1
summary.json         slim summary the docs comparison page reads
report.md            the tables below
report.html          one self-contained file with charts
logs/                one log per framework and phase
artifacts/<fw>/rep-<n>/   native reporter output and resource samples
  • Summary table. One row per framework: warm suite median and p95, ratio to the fastest, cold rep 1, cold-start cost, prepare and teardown time, overhead, pass and flake rate, residue and footprint. Warm numbers exclude rep 1.
  • Per-test matrix. Median milliseconds per canonical test and framework, with the ratio to the fastest in bold. Compare per-test totals first; step timings are not equally defined in every tool.
  • Cold start. Rep 1 minus the warm median, plus the time spent in prepare. This is the cost of installing a runner app or starting a daemon, not steady-state speed.
  • Overhead. Suite wall time minus the sum of test durations: what the framework spends outside the tests (process start, device attach, reporting).
  • Residue. Processes, driver apps and directories left behind after teardown. A healthy run leaves none unless --keep-drivers is set.
  • Footprint. Lines and bytes of suite source, size of package-local dependencies and binaries. A rough measure of what you maintain.
  • Resources. Host and simulator CPU and RSS, sampled every 500 ms. The CPU headline is host CPU-seconds per warm rep (timed run only); %cpu is a decaying average on macOS, so do not judge a framework on it.
  • Unsupported and failed. A framework that cannot drive the app lands here with its error. A native test name missing from the manifest is a parity breach and fails the framework.

Fairness rules

  • Same pinned app for every framework, with its sha256 recorded.
  • Zero LLM calls.
  • Same assertions per canonical test id.
  • Same retry helpers: dropped first tap, dropped deep link, system alerts.
  • The iOS "Open in …?" sheet is answered once per boot by the harness, before every framework's prepare: iOS asks it for the first custom-scheme link opened from outside the app and not again, so without that the first framework on a shared simulator would pay for it and no other would see it, and on one simulator per framework a tool with no optional step would lose the deep-link test to it.
  • Fresh app launch per test.
  • retries=0.
  • Frameworks run sequentially, with daemons stopped between them.
  • caffeinate -dims held for the run.
  • Warm medians exclude rep 1.
  • Machine-readable reporters only.
  • Host otherwise idle, with the load average recorded.
  • Android sim counts the app and every framework's on-device helpers, matched case-insensitively.
  • Host processes are tracked by pid and start time, so a reused pid is never counted.
  • Android animation scales are reset to the device's original values before every framework and restored at the end.

Caveats per framework

FrameworkGap
wardenCopied from the example app, retries written with warden's own options: tap({ until }), a deep-link retry, systemAlerts: "dismiss". The hand-rolled helpers it replaced ran the published headline numbers; the idiomatic run is reported beside them.
ArgentRetries are nested when: guards capped at two retaps; the deep-link flow retries a dropped link the same way. Flow selectors read only the app's view hierarchy, never SpringBoard, so the flow could not answer the "Open in …?" sheet itself; the harness primes it. The checked state of a checkbox is not readable, so the remaining-count assertion covers the effect.
agent-device.ad scripts have no retry or conditional, only wait and press, so the deep-link script could not answer a sheet that may or may not show (alert accept polls about 2 s whatever its argument); the harness primes it. Needs a runner prepare step and a daemon.
tester.armyHas its own copy of the hand-rolled helpers warden used before. Runs with a private agent-device state dir and Node 24 on PATH.

Add a framework

  1. Add the id to FRAMEWORK_IDS in adapter.types.ts.
  2. Write src/adapters/<id>.ts exporting a BenchAdapter with doctor, prepare, run, teardown and residuePatterns. Spawn only through the injected ctx.exec.
  3. Add a parser under src/adapters/parse/ and a real output sample under src/adapters/fixtures/, and test against it with fakeExec.
  4. Register it in src/adapters/registry.ts.
  5. Add a native.<id> name for every test in suites/manifest.json.
  6. Create suites/<id>/ with all 8 tests and the same retries as apps/example/e2e/helpers/steps.ts. Document any gap in the caveats.

Add a canonical test

  1. Add the id to CANONICAL_IDS in src/results/schema.ts.
  2. Add an entry to suites/manifest.json with a title, the step labels and a native name per framework.
  3. Write the test in every suites/<framework>/ with identical assertions and retries.
  4. Run bun run --cwd packages/e2e-bench test and bun run bench doctor.

Results

The framework comparison page charts the published runs: warm suite time, per-test medians, step latency, cold start, resources, residue and footprint. The full Markdown reports live in docs/bench/ in the repo.

To publish a run, write its slim summary into the docs site and commit the report next to it:

bun run bench summary .bench/<run> --out apps/docs/content/docs/bench/data/<platform>-<date>.json
cp .bench/<run>/report.md docs/bench/<platform>-<date>.md

The comparison page picks up every *.json in apps/docs/content/docs/bench/data/, with a platform and date selector when there is more than one. bench run and bench report also write summary.json next to report.md.

On this page