React Native E2E tests on Expo Workflows

Run React Native and Expo E2E tests (warden e2e) on an Expo Workflows macOS worker, reusing the EAS build for the native fingerprint and repacking only the JavaScript.

Expo Workflows can run warden e2e on a macOS worker with an iOS Simulator. The workflow on this page reuses the EAS build that matches the app's native fingerprint (it builds one only when none matches), installs warden, repacks this commit's JavaScript into the build in seconds and runs the specs. No native build when only JavaScript changed.

warden's own repository runs this workflow on its example app: apps/example/.eas/workflows/e2e.yml. It runs warden from that checkout by default, to test changes before they are released; -F warden=npm runs the npm install on this page.

Prerequisites

  • An Expo project linked to EAS (eas init), with ios.bundleIdentifier in the app config.
  • Continuous Native Generation: the fingerprint job hashes the generated native project.
  • @delacour/warden-e2e as a dev dependency, and a suite in warden.config.ts that installs the app (app: true). See Suites.
warden.config.ts
export default {
  projects: [{ name: "my-app", bundleId: "com.example.app" }],
  e2e: {
    "my-app": {
      project: "my-app",
      specsDir: "e2e",
      platform: "ios",
      app: true,
    },
  },
};

Add a build profile

Add a profile that makes a Release iOS Simulator build without the dev client:

eas.json
{
  "build": {
    "e2e-ios-simulator": {
      "environment": "preview",
      "developmentClient": false,
      "ios": {
        "simulator": true,
        "buildConfiguration": "Release"
      }
    }
  }
}

The e2e job hands this build to warden builds import --kind simulator-base, which files it as the base that warden repacks this checkout's JavaScript into. The import refuses a Debug or expo-dev-client build, a device build, a build with no arm64 slice (EAS macOS workers are Apple silicon) and a build with another bundle id. The JavaScript bundle the build embeds is stripped from the cached copy, and every repack writes its own, so any build of the right native fingerprint will do. EXPO_PUBLIC_* values on the profile do not reach the specs: the repack inlines the suite's bundle env (metro.env, see Build kinds).

environment: "preview" makes the build read the same EAS environment variables as the e2e job.

Create the workflow

.eas/workflows/e2e.yml
name: e2e

on:
  workflow_dispatch: {}

jobs:
  # Hash the native project, then look for a finished build of the profile with that hash.
  fingerprint:
    type: fingerprint

  get_ios_build:
    needs: [fingerprint]
    type: get-build
    params:
      platform: ios
      profile: e2e-ios-simulator
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}

  # Build only when no build matches the fingerprint.
  build_ios:
    needs: [get_ios_build]
    if: ${{ !needs.get_ios_build.outputs.build_id }}
    type: build
    params:
      platform: ios
      profile: e2e-ios-simulator

  e2e_ios:
    name: warden e2e (iOS)
    after: [get_ios_build, build_ios]
    if: ${{ always() && (needs.get_ios_build.outputs.build_id || needs.build_ios.outputs.build_id) }}
    runs_on: macos-large
    environment: preview
    env:
      WARDEN_GOLDEN: '0'
    steps:
      - uses: eas/checkout

      - uses: eas/install_node_modules

      # warden must match the @delacour/warden-e2e your specs import.
      - name: Pick the warden version
        id: warden
        run: set-output version "$(node -p "require('./node_modules/@delacour/warden-e2e/package.json').version")"

      - uses: eas/restore_cache
        id: warden_cache
        with:
          key: warden-cli-darwin-arm64-${{ steps.warden.outputs.version }}
          path: /Users/expo/.warden-cli

      - name: Install warden
        run: |
          BIN="$HOME/.warden-cli/lib/node_modules/@delacour/warden/node_modules/@delacour/warden-darwin-arm64/bin"
          if [ ! -x "$BIN/warden" ]; then
            npm install --global --prefix "$HOME/.warden-cli" "@delacour/warden@${{ steps.warden.outputs.version }}"
          fi
          set-env PATH "$BIN:$PATH"

      - uses: eas/save_cache
        if: ${{ steps.warden_cache.outputs.cache_hit != 'true' }}
        with:
          key: warden-cli-darwin-arm64-${{ steps.warden.outputs.version }}
          path: /Users/expo/.warden-cli

      - uses: eas/download_build
        id: download_build
        with:
          build_id: ${{ needs.get_ios_build.outputs.build_id || needs.build_ios.outputs.build_id }}
          extensions: [app]

      - name: Use the EAS build as the simulator base
        run: warden builds import "${{ steps.download_build.outputs.artifact_path }}" --kind simulator-base

      - name: Run the specs
        run: warden e2e my-app --all --headless --report /Users/expo/warden-e2e/e2e-report.json

      - uses: eas/upload_artifact
        if: ${{ always() }}
        with:
          type: other
          name: warden-e2e
          ignore_error: true
          path: |
            /Users/expo/warden-e2e/**/*
            /Users/expo/.warden/batches/**/*
            /Users/expo/.warden/logs/**/*

What each part does:

StepWhy
fingerprint → get-build → buildA build of the profile for this native fingerprint is reused; build_ios runs only on a miss (a native change).
after:, not needs:When a build was found, build_ios is skipped, and a skipped job in needs would skip e2e_ios too. after waits for both, and the if runs the job when either produced a build.
warden versionThe spec preload exits 2 when the CLI and @delacour/warden-e2e differ, so the CLI is installed at the version in node_modules. The file path in require gets past the package's exports.
--prefix "$HOME/.warden-cli" + cacheOne directory holds the CLI, keyed on its version, so later runs skip the download.
BIN = the warden-darwin-arm64 package's binThe native binary, with the simulator helper beside it: nothing else to fetch. eas/save_cache stores files, not symlinks, so npm's bin/warden link comes back as a copy that warden 0.16.0 and earlier cannot resolve; the package's own bin has no links.
set-env PATHPuts $BIN (so warden) on PATH for the later steps; an export in one step does not reach the next.
WARDEN_GOLDEN: '0'Each simulator is a fresh simctl create. A hosted worker starts empty every run, so a golden image would be built and thrown away.
builds import --kind simulator-baseFiles the downloaded .app as the simulator base, under warden's own native fingerprint.
warden e2eRepacks this commit's JavaScript into the imported base (seconds, logged as simulator repack <jsKey>), then claims a simulator, installs and runs the specs.
--headless --reportSimulators stay windowless; e2e-report.json lands in the upload directory.
upload_artifact with if: always()The evidence is uploaded on a failed run too.

macos-large (10 cores) is the worker size warden's own runs use; macos-medium works for one simulator. Add --count 2 to run two simulators on macos-large, and --retry 1 to rerun a failed spec once.

Why warden repacks, not eas/repack

EAS has its own repack (the repack job and @expo/repack-app). This workflow does not use it: warden e2e repacks the downloaded build itself (about a minute on macos-large).

  • The same repack everywhere. It is what warden e2e does on a laptop and on GitHub Actions, for Expo apps (expo export:embed) and bare React Native apps (react-native bundle) alike.
  • Cached by content. A repack is keyed on the native base and a hash of the JavaScript inputs, and every simulator in the run installs the same one. Save ~/.warden/builds/*/ios/*/simulator/repacks with eas/save_cache to reuse it on a later run with the same JavaScript.
  • Made for testing. It turns expo-updates off in the repacked app, so the app runs the bundle under test rather than an update it fetched, and keeps the base's entitlements when it re-signs.
  • One step. The repack job's output is a new EAS build, which the e2e job would still download and import before warden repacks it again.

Run the workflow

eas workflow:run .eas/workflows/e2e.yml

The run page on expo.dev shows each job; the e2e job's log prints each failing spec and the path of its evidence. To pass the suite and arguments per run, declare workflow_dispatch inputs and use them as ${{ inputs.suite }} in the run lines, then run with -F suite=my-app -F args='--specs app/todos' (inputs).

Run on pull requests

Link the GitHub repository to the Expo project (the Expo GitHub app), then add a pull_request trigger:

.eas/workflows/e2e.yml
on:
  workflow_dispatch: {}
  pull_request:
    branches: [main]

To start the workflow from a GitHub Actions job instead, see Start an EAS workflow from GitHub Actions.

Environment variables and secrets

environment: preview on the e2e job reads the variables of the project's preview environment, the same ones the build profile reads. Store tokens there with the visibility set to Secret, for example NPM_TOKEN when eas/install_node_modules installs packages from a private registry. See Environment variables in EAS.

Read the results

The run page's Artifacts section holds warden-e2e: e2e-report.json, the batch directories (results.jsonl, and per failed test failure.png, screen-at-failure.txt, trace.jsonl) and warden's logs. Read screen-at-failure.txt first: it is the full accessibility tree at the moment the test failed. See Debugging.

Troubleshooting

SymptomCauseFix
build_ios runs on every runno build of the profile matches the fingerprint, or the last one faileda native change needs one build; after it, runs reuse it. Check the profile name in get_ios_build and build_ios match
warden builds import refuses the builda Debug or dev-client build, a device build, no arm64 slice, or another bundle iduse the profile above (developmentClient: false, simulator: true, Release); --bundle-id when the suite's id differs on purpose
the spec run exits 2 before any specwarden and @delacour/warden-e2e versions differinstall the CLI at the node_modules version, as in the workflow
npm install fails for @delacour/wardenthe version is not published (an unreleased local bump)pin @delacour/warden-e2e to a released version
errSecMissingEntitlement / -34018 in the appthe simulator build has no application-identifier entitlementwarden builds import warns about it; Keychain calls need a build signed with entitlements
e2e_ios is skippedneither get_ios_build nor build_ios produced a build idopen the build_ios job: the build failed

On this page