React Native E2E tests in GitHub Actions

Run React Native and Expo E2E tests (warden e2e) in GitHub Actions on a macOS runner, reuse an EAS build, install warden with the action or npm, and gate a pull request on the affected specs.

warden e2e runs on a macOS GitHub Actions runner (other CI). The smallest workflow that works is the e2e action, which installs warden, restores the build and golden caches and uploads the evidence when a spec fails:

.github/workflows/e2e.yml
name: e2e
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  e2e:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          suite: my-app
          args: --base origin/main
          save-cache: ${{ github.event_name != 'pull_request' }}

suite is the name under e2e in warden.config.ts (optional with one suite). --base origin/main runs only the specs the diff needs (Affected), so fetch-depth: 0 is needed for the merge-base. The job fails when a required spec fails. Details of what the action does and every input: Run e2e in GitHub Actions.

Without the action, install warden and call it yourself:

      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - uses: delacournz/warden/.github/actions/install-warden@main
      - run: warden e2e my-app --base origin/main --count 2 --report e2e-report.json

The same command runs locally or in a pre-push hook.

Install warden

Two routes:

  • npm: npm i -g @delacour/warden, or bunx @delacour/warden. It is a node shim plus one prebuilt binary per platform (macOS and Linux, arm64 and x64); an install with --omit=optional leaves the shim without a binary.
  • Install action: delacournz/warden/.github/actions/install-warden downloads the release binary for the runner (warden-darwin-arm64 …), checks it against checksums.txt, installs it to ~/.local/bin/warden and puts that on PATH. Inputs: token (default: the workflow's github.token, which can read the public releases), version (a tag such as v0.1.0; default latest) and repo (default delacournz/warden).

A private fork in repo needs a token with Contents: read on that repo.

@delacour/warden-e2e is a dev dependency of your app at the same version as warden; bun install brings it in. A mismatch makes the preload exit 2.

What the runner needs

  • macOS for iOS simulators, with Xcode and the iOS runtime installed; Android needs the SDK. There is no Windows support.
  • No bun install: warden runs the specs with the bun built into its own binary, so CI needs no bun install (WARDEN_BUN picks a different one).
  • No prior state. The --profile is created on claim, cloned from a golden image that is built on first use (about four minutes on a hosted runner); keep it between runs with warden golden export / import and actions/cache (below). If cloning fails it falls back to simctl create.

Gate a pull request

warden e2e <suite> --base origin/main runs only the specs the diff needs (Affected). It exits 0 when every required spec passed or nothing was affected (no device claimed), and 1 when a required one failed. Parsed imports are cached under $WARDEN_HOME/affected. The compiled warden binary carries no TypeScript compiler, so add typescript 5.0 or later as a dev dependency of the app.

Keep the evidence when a run fails:

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: warden-e2e
          path: |
            e2e-report.json
            ~/.warden/batches

Reuse a prebuilt app and the golden

On a fresh runner, a simulator-kind suite (app: true) pays twice before the first spec runs: a native Release build of the simulator base, and the golden image's first boot. When another job already builds the Release simulator .app, import it; cache the golden between runs.

.github/workflows/ios.yml
jobs:
  ios-build:
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v4
      # … install dependencies, pod install …
      - run: |
          xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Release \
            -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
            ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO \
            -derivedDataPath ios/build/dd build
      # upload-artifact zips files and drops the executable bit: ship the .app as a tarball
      - run: tar -czf App.app.tgz -C ios/build/dd/Build/Products/Release-iphonesimulator App.app
      - uses: actions/upload-artifact@v4
        with: { name: ios-simulator-app, path: App.app.tgz }

  e2e:
    needs: ios-build
    runs-on: macos-26
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - uses: delacournz/warden/.github/actions/install-warden@main

      - uses: actions/download-artifact@v4
        with: { name: ios-simulator-app }
      - run: tar -xzf App.app.tgz && warden builds import App.app --kind simulator-base

      - id: golden
        run: echo "name=$(warden golden key --profile iphone-17)" >> "$GITHUB_OUTPUT"
      - id: golden-cache
        uses: actions/cache/restore@v4
        with: { path: ~/warden-goldens, key: "${{ steps.golden.outputs.name }}" }
      - run: warden golden import ~/warden-goldens

      - run: warden e2e mobile --all --count 1

      - if: steps.golden-cache.outputs.cache-hit != 'true'
        run: warden golden export ~/warden-goldens --profile iphone-17
      - if: steps.golden-cache.outputs.cache-hit != 'true'
        uses: actions/cache/save@v4
        with: { path: ~/warden-goldens, key: "${{ steps.golden.outputs.name }}" }

The base. warden builds import <App.app> --kind simulator-base files the .app as the base every repack clones, under warden's own native fingerprint of the project (--project <dir> when the app is not in the working directory). The next warden e2e / warden repack bundles this checkout's JS into it in seconds instead of building. The import refuses a .app that is not a simulator build, has no slice for this Mac (arm64 on Apple silicon), has another bundle id (--bundle-id overrides), is a Debug or expo-dev-client build, or whose executable lost its mode bits. A main.jsbundle embedded by the build (no SKIP_BUNDLING) is stripped from the cached copy; every repack writes its own.

The fingerprint is warden's, not @expo/fingerprint's: they differ. To key an artifact or check which base a run will look for, print it with warden app fingerprint ios --kind simulator --json (fingerprints.ios, and hashes.ios = <fp>:base). --hash <fp> files the import under a fingerprint you computed earlier. A base whose fingerprint no longer matches the checkout is never used: the run builds one, as without an import.

The golden. warden golden key prints the golden's name, warden-golden-<profile>-<key>-r<recipe>, where the key covers the Xcode build, the runtime build, the device type and the recipe: a runner image with another Xcode or runtime gets another cache entry. warden golden import <dir> exits 0 whatever it finds: a cache miss, an archive for another Xcode or runtime, or a broken one is skipped with the reason, and the first claim builds the golden as before. Use the same --profile (and --runtime) as the suite's devices. Keep the export and save steps on the default success() condition (not always() or !cancelled()): a golden saved by a failed run is restored by every later run. warden golden export itself refuses a golden that never settled after its first boot. An archive is about 2 GB before compression; GitHub keeps 10 GB of caches per repository. See Golden images.

Run e2e in GitHub Actions

delacournz/warden/.github/actions/e2e is a composite action that runs a suite on a macOS runner with every cache and guard this page describes already wired up. Call it after actions/checkout:

.github/workflows/e2e.yml
name: e2e
on:
  pull_request:
    paths: [apps/mobile/**, bun.lock]
  push:
    branches: [main]
    paths: [apps/mobile/**, bun.lock]

permissions:
  contents: read

jobs:
  ios:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          working-directory: apps/mobile
          suite: mobile
          count: 1
          retry: 1
          args: --base origin/main
          save-cache: ${{ github.event_name != 'pull_request' }}

Pin the action to a commit SHA or release tag. In order, it:

  1. Sets WARDEN_TELEMETRY_DISABLED=1 and DO_NOT_TRACK=1, selects xcode-path through DEVELOPER_DIR when given, and lets simctl answer once so the first CoreSimulator call does not land inside a claim's 30 s timeout.

  2. Installs bun from npm (npm i -g bun@<version>) when none is on PATH, so it needs no third-party action; bun-version defaults to the root packageManager. Runs install-command (default bun install --frozen-lockfile) with npm-token as NPM_TOKEN.

  3. Installs the release binary of warden-version (checksum verified) and runs warden doctor --fetch-helper.

  4. Restores the caches below and imports app-path when given. On a native build cache miss it says so first (a native build cache miss notice: the app is built from source before the first spec), installs ccache (a pinned release binary when none is on PATH, Homebrew if that download fails) and restores the compiler cache. Then it builds the app in its own step (warden e2e --build-only with the same suite, profile and args, so the run finds it cached), runs warden e2e with --report and the inputs as flags, and prints one line splitting the job's time up to the suite's end:

    warden e2e: took 2m40s — setup 1m22s · tests 1m18s · teardown 0s
    warden e2e exited 0 after 161 s
    job timing: prep 1m46s (workflow steps 1m20s · action 26s) · app build 12s · warden e2e 2m41s · total 4m39s

    workflow steps is the job's steps before this action, action is its own install and cache steps, app build is the build step (seconds on a cache hit, minutes on a native build), and warden e2e matches warden's took line (claim, boot, install and the specs). The job's start comes from the jobs API with github-token, which needs actions: read in the workflow's permissions; without it the line reads action prep 26s · warden e2e 2m41s (job start unknown: …).

  5. Prints each native build log the run wrote into a collapsed ::group::, with ccache's hit rate. While a build runs, warden logs still building ios (6m 0s): <last build line> every minute, so a long build never looks hung.

  6. On failure prints every screen-at-failure.txt into the job log (one ::group:: each), then collects the run's artifacts and uploads each group as its own artifact (retention-days, default 3).

  7. After a green run only, and only when save-cache is true, saves the caches. Restore and save are separate steps, so a failed run saves nothing. The compiler cache is the exception: it is saved after a failed run too, since its objects are content-addressed, but only when the build stored new objects, under a per-run key (<key>-<run id>-<attempt>) so it keeps growing.

InputDefaultMeaning
working-directory.where warden.config.ts lives
suitesuite's onlye2e.<suite>
profile, count, retrythe suite's--profile, --count, --retry
argsnoneextra warden e2e flags (--all, --base origin/main, --specs a,b)
app-pathnonea simulator .app built earlier in the workflow, imported as the base
warden-versionlatestrelease tag, or none when warden is already on PATH
bun-versionroot packageManagerbun to install from npm when none is on PATH; none skips
install-commandbun install --frozen-lockfileempty skips; install-working-directory picks where it runs
github-tokengithub.tokenreads the job's start for the job timing line (needs actions: read); never passed to warden
xcode-pathrunner defaulte.g. /Applications/Xcode_27.2.app
headroom-gb1 on hosts of 16 GB or lessWARDEN_CAPACITY_HEADROOM_GB
suite-timeout-minutes35stops the suite (SIGINT, SIGKILL 60 s later) so a hung run still prints and uploads its evidence. The app build runs before it and is not counted: set the job timeout above a cold native build plus this
report-pathwarden-e2e-report.jsonwhere the report goes
cachegithubgithub saves and restores with actions/cache; none leaves storing to you (see below)
cache-prefixwarden-e2e-v1change it to drop every cache
js-inputssrc assets app.config.ts index.ts metro.config.js package.jsongit pathspecs that key the repack cache; mirror kinds.simulator.jsInputs
save-cachetruefalse on pull requests
compiler-cachetrueon a native build cache miss, install ccache and restore / save the compiler cache (skipped when the project sets build.cache: false); false skips both
artifactsallgroups to collect: all, none, or a list of report,batches,logs,app,golden (see Artifacts)
upload-artifactstruefalse collects without uploading: upload from artifact-paths yourself
retention-days, artifact-name3, warden-e2ehow long the artifacts are kept; their name prefix (warden-e2e-report, …)

Outputs: report-path (absolute), passed (true / false), cache-groups (the JSON below), artifact-groups (comma list) and artifact-paths (JSON, see Artifacts). The job fails when the gate fails.

Artifacts

After the run, pass or fail, the action collects what it left on the runner into groups and uploads each as <artifact-name>-<group>:

GroupWhatSize
reporte2e-report.jsonKB
batches~/.warden/batches: per-spec logs, results.jsonl, screen-at-failure.txt, metro.logKB to MB
logs~/.warden/logs: native build logs; simd-logs/: each device helper's ~/.warden/simd/*.log (stalls, a wedged accessibility service)MB
appapp.tar: the .app / .apk builds the run used (warden builds ls, last used since the suite started), paths kept relative to $HOME (.warden/builds/<project>/…/App.app) so a base and its repack never clash~100 MB each
goldengolden.tar: the golden simulator archive from ~/warden-goldens. tar -xf golden.tar -C ~/warden-goldens && warden golden import ~/warden-goldens restores it on a Mac with the same Xcode and runtimeGBs

app and golden are tarballs because upload-artifact drops the executable bit and follows symlinks: a raw .app would not launch and a raw golden would not import. They are written to $RUNNER_TEMP/warden-artifacts, so the golden needs its size again in free disk.

all (the default) is every group after a green run and report,batches,logs,golden after a failed one: a failed run's golden is what reproduces it. A green run whose golden was restored from the cache skips it: the cache already holds the same archive, and uploading GBs is the slowest step of a warm run. Name golden in a list to collect it anyway. List groups to pick your own (artifacts: report,batches), or none to skip the rest. logs is collected whatever you pick, even with none: it is small, and when the build broke it is the only evidence. The golden is never built just to upload it: a run that broke before building one has no golden group. When it was built but not archived, collecting it runs warden golden export --allow-unsettled, and the cache save reuses that archive.

To upload with your own steps, set upload-artifacts: false and read artifact-paths, {report, batches, logs, app, golden} each a list of absolute paths (empty when not collected):

- id: e2e
  uses: delacournz/warden/.github/actions/e2e@<sha>
  with:
    working-directory: apps/mobile
    artifacts: report,batches,app
    upload-artifacts: false
- if: always()
  uses: actions/upload-artifact@v4
  with:
    name: mobile-app
    path: ${{ join(fromJSON(steps.e2e.outputs.artifact-paths).app, fromJSON('"\n"')) }}

The caches

CachePathKeySaved
Native build~/.warden/builds without the repacksrunner + warden app fingerprint ios --kind simulator --json (.fingerprints.ios)after a green run on a miss
JS repack~/.warden/builds/*/ios/*/simulator/repacksthe fingerprint + a hash of js-inputs (git ls-files -s)after a green run on a miss
Golden simulator~/warden-goldenswarden golden key --profile <p> (Xcode build, runtime build, device type, recipe)after a green run on a miss; only for a suite with golden: true
Compiler (ccache)~/.warden/ccacherunner + the fingerprint + the run; restores the newest save for this fingerprint, else for any (restore-keys)after any run whose native build stored new objects; capped at 2 GB (CCACHE_MAXSIZE)

A native build is minutes of xcodebuild, the JS bundle about 3.5 minutes on a hosted runner, and a cold golden up to five more minutes of first boot and settling. A pull request restores what the default branch saved but never saves (save-cache: false), so a branch cannot evict the default branch's entries; a push to the default branch fills them. A native change (a new native dependency, a config plugin, an SDK upgrade) misses the native build cache: the app is built from source, 15-20 minutes on a 3-core hosted runner. The compiler cache restored from the previous fingerprint cuts that for the C, C++ and Objective-C sources that did not change (most pods); Swift is not cached. A JS-only change misses only the repack cache. A new Xcode or runtime image misses only the golden. The caches hold files, not warden.db: a base or repack dir that comes back complete with no row is registered on its first lookup (warden builds --json shows "source": "restored") and used as it is.

Bring your own cache

With cache: none the action never calls actions/cache. It still imports the golden from ~/warden-goldens before the run, but never exports it for the cache: a golden your cache already restored would be exported for nothing. When your cache missed it, run the golden's beforeSave after a green run, then save the directory. Collecting the golden artifact (in the default all) already archives a missing golden there, so beforeSave then has nothing left to do. warden ci cache --json (the action's cache-groups output) lists them, on any CI:

{
  "builds": { "kind": "paths", "key": "warden-e2e-v1-builds-macOS-ARM64-<fp>", "paths": ["/Users/runner/.warden/builds", "!/Users/runner/.warden/builds/*/ios/*/simulator/repacks"] },
  "repack": { "kind": "paths", "key": "warden-e2e-v1-repack-macOS-ARM64-<fp>-<js>", "paths": ["/Users/runner/.warden/builds/*/ios/*/simulator/repacks"] },
  "ccache": { "kind": "paths", "key": "warden-e2e-v1-ccache-macOS-ARM64-<fp>", "restoreKeys": ["warden-e2e-v1-ccache-macOS-ARM64-"], "paths": ["/Users/runner/.warden/ccache"] },
  "golden": {
    "kind": "archive",
    "key": "warden-e2e-v1-macOS-ARM64-warden-golden-iphone-17-<key>-r2",
    "paths": ["/Users/runner/warden-goldens"],
    "afterRestore": "warden golden import /Users/runner/warden-goldens",
    "beforeSave": "warden golden export /Users/runner/warden-goldens --allow-unsettled"
  }
}

A paths group is stored as it is (a ! path is left out of the one before it). restoreKeys (only ccache) are prefixes to fall back to on a miss: any older compiler cache helps, and it only helps when ccache is on PATH (brew install ccache). An exact key is immutable in most CI caches, so save the compiler cache under a key of your own that starts with key (the action appends the run id), and only when ccache --print-stats reports local_storage_write above 0. ccache is null when the project sets build.cache: false: the build never calls it. An archive group needs its commands around it: afterRestore after you restore it, beforeSave before you save it. With cache: none the action runs afterRestore for you; beforeSave is yours. Run it from the project (--project <dir>), with the same --prefix, --profile and --js-input you pass the action. On a runner with a persistent cache volume (Namespace, a self-hosted Mac), mount the paths and skip the keys:

- uses: namespacelabs/nscloud-cache-action@v1
  with:
    path: |
      ~/.warden/builds
      ~/warden-goldens
- uses: delacournz/warden/.github/actions/e2e@<sha>
  with:
    working-directory: apps/mobile
    cache: none
- name: Keep the golden on the volume
  if: success()
  working-directory: apps/mobile
  run: '[ -n "$(ls -A ~/warden-goldens 2>/dev/null)" ] || warden golden export ~/warden-goldens --allow-unsettled'

On another CI, key each group by its key and restore before warden e2e:

groups=$(warden ci cache --json --project apps/mobile)
# restore every group's paths under its key, then:
warden golden import ~/warden-goldens
warden e2e --project apps/mobile
# after a green run, only when the golden missed: warden golden export ~/warden-goldens --allow-unsettled; then save each group under its key

Reuse an app built earlier in the workflow

Build the .app with ad-hoc signing and pass it in. CODE_SIGNING_ALLOWED=NO leaves the simulator app without entitlements, and every Keychain call then fails with errSecMissingEntitlement (-34018).

      - run: |
          xcodebuild -workspace ios/App.xcworkspace -scheme App -configuration Release \
            -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
            ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGN_IDENTITY=- \
            -derivedDataPath ios/build/dd build
      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          app-path: ios/build/dd/Build/Products/Release-iphonesimulator/App.app

warden builds import prints a warning when the .app has no application-identifier entitlement. An ad-hoc simulator build signs with no entitlements and links them into the executable instead: otool -X -s __TEXT __entitlements App.app/App shows them, codesign -d --entitlements :- App.app shows an empty list. warden reads both.

Troubleshooting

SymptomCauseFix
queued #1 of 1 … mem binds: need 1792 of 0 memorya 7 GB hosted runner leaves no budget after warden's 8 GB headroomthe action sets WARDEN_CAPACITY_HEADROOM_GB=1; a lone claim is admitted either way
xcrun simctl list devices … did not finish within 30sthe first CoreSimulator call of a cold image, under a native build's loadthe action calls it once before the run; keep count at 1 on a 3-core runner
first spec on each simulator fails, the rest passthe in-sim bridge is up but silentretry: 1; warm-up retries its snapshot for 120 s and warms one device at a time
one simulator's every spec fails E_TIMEOUT (did not answer within the query's 10.0s timeout), warm-up timed out after 120sthe sim's accessibility service in backboardd wedged on first use (its log: Could not auto-register for pid status change)the warm-up restarts backboardd once on that line (warm-<n>.log: accessibility service wedged: restarted backboardd); upgrade warden if you still see it
errSecMissingEntitlement / -34018 in the appsimulator app built with CODE_SIGNING_ALLOWED=NObuild with CODE_SIGN_IDENTITY=-
golden "not settled after 300s"a loaded 3-core runnerthe run still works. With cache: github the action exports it with warden golden export --allow-unsettled, so the next run restores it instead of building it again. A bigger runner settles it
warden: command not foundwarden-version: none without an earlier installinstall warden first or leave the default
bun: command not found after a blocked setup-bunan allowlist blocks third-party actionsthe action never uses one: leave bun-version empty or pin it
cache never hitsthe key moved: Xcode image, fingerprint or js-inputs changedread the Key the caches step: it prints the fingerprint and golden name

Reuse an EAS build

When EAS Build already makes a Release simulator build of the app (an eas.json profile with ios.simulator: true, ios.buildConfiguration: "Release" and developmentClient: false; see Expo Workflows), the runner can download the one for this native fingerprint instead of building it. Add an Expo access token as the EXPO_TOKEN repository secret, set up EAS CLI with expo/expo-github-action, and hand the .app to the e2e action:

.github/workflows/e2e.yml
name: e2e
on:
  pull_request:

permissions:
  contents: read

jobs:
  ios:
    runs-on: macos-26
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: oven-sh/setup-bun@v2
      - run: bun install --frozen-lockfile
      - uses: expo/expo-github-action@v9
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}

      - name: Download the EAS build for this fingerprint
        id: eas
        run: |
          set -euo pipefail
          profile=e2e-ios-simulator
          hash=$(eas fingerprint:generate --platform ios --build-profile "$profile" --json | jq -r .hash)
          id=$(eas build:list --platform ios --build-profile "$profile" --fingerprint-hash "$hash" \
            --status finished --simulator --limit 1 --json --non-interactive | jq -r '.[0].id // empty')
          if [ -n "$id" ]; then
            echo "app-path=$(eas build:download --build-id "$id" --json | jq -r .path)" >> "$GITHUB_OUTPUT"
          fi

      - uses: delacournz/warden/.github/actions/e2e@v0.16.0
        with:
          suite: my-app
          args: --base origin/main
          install-command: ""
          app-path: ${{ steps.eas.outputs.app-path }}
  • eas fingerprint:generate hashes the native project the way EAS indexes builds (@expo/fingerprint), so it needs the dependencies installed first. It is not warden's fingerprint: warden builds import files the .app under warden's own.
  • eas build:download --build-id <id> --json prints { "path": … }, the extracted .app.
  • No build for the fingerprint (a native change nobody built yet): app-path stays empty and the action builds the base on the runner, as without EAS.
  • install-command: "" because the dependencies are already installed for the fingerprint.

Without the action, the same .app goes through warden builds import "$APP" --kind simulator-base, then warden e2e my-app, which repacks the JavaScript into it before the first install.

Start an EAS workflow from GitHub Actions

To run the specs on an EAS macOS worker instead of a GitHub runner, keep the job in Expo Workflows and start it from GitHub Actions:

.github/workflows/e2e-eas.yml
name: e2e on EAS
on:
  pull_request:

jobs:
  eas:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: expo/expo-github-action@v9
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas workflow:run .eas/workflows/e2e.yml --wait -F suite=my-app

--wait keeps the step running until the EAS run finishes. A Linux runner is enough: the build and the simulator run on EAS. With the Expo GitHub app linked to the repository, an on.pull_request trigger in the EAS workflow does the same without a GitHub Actions job.

Headless and cleanup

Simulators boot without a window. On a CI runner nothing else is open, so no flag is needed; --headless guarantees it when Simulator.app is running (Headless). Under CI, a passed gate shuts down the simulators and emulators warden created for the run, and a failed run leaves them booted. --no-shutdown keeps them up. A leftover test.only fails the run under CI.

On this page