> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tester.army/llms.txt
> Use this file to discover all available pages before exploring further.

# Expo EAS

> Run TesterArmy mobile tests from Expo EAS workflows: upload an EAS iOS Simulator build or Android APK and trigger a remote test group.

Run TesterArmy mobile tests from Expo EAS workflows by uploading an EAS iOS Simulator build or Android APK and triggering a remote test group.

## Prerequisites

Before you set it up, make sure you have already:

* Created mobile project (can be without uploaded app)
* Created at least one mobile test

Also make sure that you have configured [Expo EAS with Github](https://docs.expo.dev/build/setup/)

## Required environment variables

| Environment variable | Description |
| - | - |
| `TESTERARMY_API_KEY` | API key for TesterArmy |
| `TESTERARMY_PROJECT_ID` | Project ID for project you want to run tests on |
| `TESTERARMY_GROUP_ID` | Group ID for group you want to run tests on |
| `TESTERARMY_DYNAMIC_AGENT_ENABLED` | Optional. Defaults to `true`; set to `false` to skip the [exploration agent](#exploration-agent) on pull requests |

These values can be found in the TesterArmy Dashboard:

* API key - **Settings → API Keys**
* Project ID - **Project Settings**
* Group ID - **Test Tab, then click on three dots on the group you want to run tests on and copy the ID**

<Note>
  Set `TESTERARMY_API_KEY` as an EAS secret or sensitive environment variable. Do not commit it, and
  do not expose it through an `EXPO_PUBLIC_*` environment variable.
</Note>

## Instructions for AI agents

Below you will find instructions for AI agent to set up EAS integration with TesterArmy for you.

<Prompt description="Setup Expo EAS integration with TesterArmy">
  You are working in an Expo app repository. Add TesterArmy mobile testing through Expo EAS workflows.

  Before editing files:

  * Inspect the existing `eas.json`, `.eas/workflows`, `package.json`, and package manager lockfile.
  * Do not overwrite an existing EAS workflow. Create a new workflow file or make the smallest safe edit.
  * Do not hard-code secrets, API keys, project IDs, or group IDs in committed files.
  * Use the repository's existing package manager style when adding commands.

  Implement this setup:

  1. Detect which platforms are already configured in the app (`ios`, `android`, or both). If it is not clear, ask the user whether to set up iOS, Android, or both before editing workflow files.
  2. For iOS, ensure `eas.json` has a TesterArmy iOS Simulator build profile. Prefer `testerarmy-ios-simulator`. If the project already has a compatible simulator profile, reuse it and keep the workflow profile name in sync.
  3. For Android, ensure `eas.json` has a TesterArmy APK build profile. Prefer `testerarmy-android-apk`. If the project already has a compatible APK profile, reuse it and keep the workflow profile name in sync.
  4. The iOS profile must set `ios.simulator` to `true`. TesterArmy needs a `.app` simulator build, not an `.ipa` device build.
  5. The Android profile must produce an APK. Prefer `android.buildType: "apk"`. TesterArmy needs a `.apk`; `.aab` and `.xapk` are not supported.
  6. Create `.eas/workflows/testerarmy-mobile-tests.yml` if it does not already exist.
  7. The workflow should support `push` to `main`, `pull_request` to `main`, and `workflow_dispatch`.
  8. The workflow should read these values from EAS environment variables:
     * `TESTERARMY_API_KEY`
     * `TESTERARMY_PROJECT_ID`
     * `TESTERARMY_GROUP_ID`
     * `TESTERARMY_DYNAMIC_AGENT_ENABLED` (optional; defaults to `true`, set to `false` to skip the exploration agent)
  9. The workflow should build only the selected platform artifacts: an iOS Simulator app, an Android APK, or both.
  10. The workflow should download the selected build artifacts with `eas/download_build`.
  11. The workflow should upload each downloaded artifact with `npx --yes testerarmy@latest upload-app`.
  12. The workflow should run the saved TesterArmy group for each selected platform, using `--platform ios` for iOS and `--platform android` for Android, plus the uploaded app ID for that platform.
  13. The workflow should pass commit SHA for GitHub check reporting when the EAS/GitHub context provides it.
  14. The workflow should pass PR number only when the workflow is running for a pull request.
  15. The workflow should clean up each uploaded app. When you add the exploration agent (step 16), the test and agent jobs share one upload, so set `--remove-after 86400` on the upload step and let TesterArmy remove it. Otherwise, you can delete each upload after its test run.
  16. Optionally add the exploration agent: for each selected platform add a job that `needs` the matching upload job, runs only when `github.event_name == 'pull_request'`, respects `TESTERARMY_DYNAMIC_AGENT_ENABLED` (default `true`), and runs `npx --yes testerarmy@latest pr run-dynamic` with `--project`, `--platform`, `--app-id` (from the upload job output), `--pr-number`, `--pr-title`, and `--commit-sha`. Pass `--pr-description`, `--base-branch`, and `--head-branch` when available.

  Use this workflow as the baseline and adapt it to the repository if needed:

  ```yaml theme={"theme":"vesper"}
  name: TesterArmy Mobile Tests

  on:
    push:
      branches:
        - main
    pull_request:
      branches:
        - main
    workflow_dispatch: {}

  jobs:
    build_ios:
      name: Build iOS Simulator app
      type: build
      environment: preview
      params:
        platform: ios
        profile: testerarmy-ios-simulator

    build_android:
      name: Build Android app
      type: build
      environment: preview
      params:
        platform: android
        profile: testerarmy-android-apk

    upload_ios_app:
      name: Upload iOS app to TesterArmy
      needs: [build_ios]
      environment: preview
      outputs:
        app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
      steps:
        - uses: eas/checkout

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

        - name: Upload app
          id: upload_app
          run: |
            set -euo pipefail

            APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

            mkdir -p .testerarmy

            npx --yes testerarmy@latest upload-app \
              --app-path "$APP_PATH" \
              --project "$TESTERARMY_PROJECT_ID" \
              --remove-after 86400 \
              --output .testerarmy/upload.json

            set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

    upload_android_app:
      name: Upload Android app to TesterArmy
      needs: [build_android]
      environment: preview
      outputs:
        app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
      steps:
        - uses: eas/checkout

        - uses: eas/download_build
          id: download_build
          with:
            build_id: ${{ needs.build_android.outputs.build_id }}
            extensions:
              - apk

        - name: Upload app
          id: upload_app
          run: |
            set -euo pipefail

            APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

            mkdir -p .testerarmy

            npx --yes testerarmy@latest upload-app \
              --app-path "$APP_PATH" \
              --project "$TESTERARMY_PROJECT_ID" \
              --remove-after 86400 \
              --output .testerarmy/upload.json

            set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

    run_ios_tests:
      name: Run iOS TesterArmy tests
      needs: [upload_ios_app]
      environment: preview
      steps:
        - uses: eas/checkout

        - name: Run tests
          run: |
            set -euo pipefail

            APP_ID="${{ needs.upload_ios_app.outputs.app_id }}"
            COMMIT_SHA="${{ github.sha }}"
            EVENT_NAME="${{ github.event_name }}"
            PR_NUMBER="${{ github.event.pull_request.number || '' }}"
            TIMEOUT_MS="1800000"
            POLL_INTERVAL_SECONDS="10"

            mkdir -p .testerarmy

            args=(
              ci
              --group "$TESTERARMY_GROUP_ID"
              --project "$TESTERARMY_PROJECT_ID"
              --platform ios
              --app-id "$APP_ID"
              --commit-sha "$COMMIT_SHA"
              --timeout "$TIMEOUT_MS"
              --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
              --output .testerarmy/ci-result.json
            )

            if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
              args+=(--pr-number "$PR_NUMBER")
            fi

            npx --yes testerarmy@latest "${args[@]}"

    run_android_tests:
      name: Run Android TesterArmy tests
      needs: [upload_android_app]
      environment: preview
      steps:
        - uses: eas/checkout

        - name: Run tests
          run: |
            set -euo pipefail

            APP_ID="${{ needs.upload_android_app.outputs.app_id }}"
            COMMIT_SHA="${{ github.sha }}"
            EVENT_NAME="${{ github.event_name }}"
            PR_NUMBER="${{ github.event.pull_request.number || '' }}"
            TIMEOUT_MS="1800000"
            POLL_INTERVAL_SECONDS="10"

            mkdir -p .testerarmy

            args=(
              ci
              --group "$TESTERARMY_GROUP_ID"
              --project "$TESTERARMY_PROJECT_ID"
              --platform android
              --app-id "$APP_ID"
              --commit-sha "$COMMIT_SHA"
              --timeout "$TIMEOUT_MS"
              --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
              --output .testerarmy/ci-result.json
            )

            if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
              args+=(--pr-number "$PR_NUMBER")
            fi

            npx --yes testerarmy@latest "${args[@]}"

    run_ios_dynamic_agent:
      name: Run iOS TesterArmy exploration agent
      needs: [upload_ios_app]
      if: ${{ github.event_name == 'pull_request' }}
      environment: preview
      env:
        APP_ID: ${{ needs.upload_ios_app.outputs.app_id }}
        COMMIT_SHA: ${{ github.sha }}
        PR_NUMBER: ${{ github.event.pull_request.number || '' }}
        PR_TITLE: ${{ github.event.pull_request.title || '' }}
        PR_DESCRIPTION: ${{ github.event.pull_request.body || '' }}
        BASE_BRANCH: ${{ github.event.pull_request.base.ref || '' }}
        HEAD_BRANCH: ${{ github.event.pull_request.head.ref || '' }}
      steps:
        - uses: eas/checkout

        - name: Run exploration agent
          run: |
            set -euo pipefail

            if [ "${TESTERARMY_DYNAMIC_AGENT_ENABLED:-true}" = "false" ]; then
              echo "TesterArmy exploration agent is disabled because TESTERARMY_DYNAMIC_AGENT_ENABLED=false."
              exit 0
            fi

            if [ -z "$PR_NUMBER" ]; then
              echo "No pull request number found; skipping TesterArmy exploration agent."
              exit 0
            fi

            mkdir -p .testerarmy

            dynamic_pr_title="$PR_TITLE"
            if [ -z "$dynamic_pr_title" ]; then
              dynamic_pr_title="Pull request #$PR_NUMBER"
            fi

            args=(
              pr
              run-dynamic
              --project "$TESTERARMY_PROJECT_ID"
              --platform ios
              --app-id "$APP_ID"
              --pr-number "$PR_NUMBER"
              --pr-title "$dynamic_pr_title"
              --commit-sha "$COMMIT_SHA"
              --output .testerarmy/dynamic-result.json
            )

            if [ -n "$PR_DESCRIPTION" ]; then
              args+=(--pr-description "$PR_DESCRIPTION")
            fi

            if [ -n "$BASE_BRANCH" ]; then
              args+=(--base-branch "$BASE_BRANCH")
            fi

            if [ -n "$HEAD_BRANCH" ]; then
              args+=(--head-branch "$HEAD_BRANCH")
            fi

            npx --yes testerarmy@latest "${args[@]}"

    run_android_dynamic_agent:
      name: Run Android TesterArmy exploration agent
      needs: [upload_android_app]
      if: ${{ github.event_name == 'pull_request' }}
      environment: preview
      env:
        APP_ID: ${{ needs.upload_android_app.outputs.app_id }}
        COMMIT_SHA: ${{ github.sha }}
        PR_NUMBER: ${{ github.event.pull_request.number || '' }}
        PR_TITLE: ${{ github.event.pull_request.title || '' }}
        PR_DESCRIPTION: ${{ github.event.pull_request.body || '' }}
        BASE_BRANCH: ${{ github.event.pull_request.base.ref || '' }}
        HEAD_BRANCH: ${{ github.event.pull_request.head.ref || '' }}
      steps:
        - uses: eas/checkout

        - name: Run exploration agent
          run: |
            set -euo pipefail

            if [ "${TESTERARMY_DYNAMIC_AGENT_ENABLED:-true}" = "false" ]; then
              echo "TesterArmy exploration agent is disabled because TESTERARMY_DYNAMIC_AGENT_ENABLED=false."
              exit 0
            fi

            if [ -z "$PR_NUMBER" ]; then
              echo "No pull request number found; skipping TesterArmy exploration agent."
              exit 0
            fi

            mkdir -p .testerarmy

            dynamic_pr_title="$PR_TITLE"
            if [ -z "$dynamic_pr_title" ]; then
              dynamic_pr_title="Pull request #$PR_NUMBER"
            fi

            args=(
              pr
              run-dynamic
              --project "$TESTERARMY_PROJECT_ID"
              --platform android
              --app-id "$APP_ID"
              --pr-number "$PR_NUMBER"
              --pr-title "$dynamic_pr_title"
              --commit-sha "$COMMIT_SHA"
              --output .testerarmy/dynamic-result.json
            )

            if [ -n "$PR_DESCRIPTION" ]; then
              args+=(--pr-description "$PR_DESCRIPTION")
            fi

            if [ -n "$BASE_BRANCH" ]; then
              args+=(--base-branch "$BASE_BRANCH")
            fi

            if [ -n "$HEAD_BRANCH" ]; then
              args+=(--head-branch "$HEAD_BRANCH")
            fi

            npx --yes testerarmy@latest "${args[@]}"
  ```

  Because the test and exploration agent jobs share one upload, the upload steps set `--remove-after 86400` and let TesterArmy clean up the app on its own.

  Use these command shapes:

  ```bash theme={"theme":"vesper"}
  npx --yes testerarmy@latest upload-app \
    --app-path "$APP_PATH" \
    --project "$PROJECT_ID" \
    --output .testerarmy/upload.json
  ```

  ```bash theme={"theme":"vesper"}
  npx --yes testerarmy@latest ci \
    --group "$GROUP_ID" \
    --project "$PROJECT_ID" \
    --platform ios \
    --app-id "$APP_ID" \
    --commit-sha "$COMMIT_SHA" \
    --timeout "$TIMEOUT_MS" \
    --poll-interval-seconds "$POLL_INTERVAL_SECONDS" \
    --output .testerarmy/ci-result.json
  ```

  For the exploration agent (pull requests only):

  ```bash theme={"theme":"vesper"}
  npx --yes testerarmy@latest pr run-dynamic \
    --project "$PROJECT_ID" \
    --platform ios \
    --app-id "$APP_ID" \
    --pr-number "$PR_NUMBER" \
    --pr-title "$PR_TITLE" \
    --commit-sha "$COMMIT_SHA" \
    --output .testerarmy/dynamic-result.json
  ```

  Use `--platform android` for Android runs. Pass `--pr-description`, `--base-branch`, and `--head-branch` when available.

  When one binary serves many PRs (for example via EAS Update), pass `--deeplink` with a configuration deep link - an Expo update-channel or backend-selection link. TesterArmy opens it on the device after launching the app and before testing starts, so the right channel and backend are applied deterministically instead of depending on instructions in the PR description:

  ```bash theme={"theme":"vesper"}
  --deeplink "myapp://custom-setup?channel=pr-$PR_NUMBER&mock=0"
  ```

  If a PR number is available, append to the `ci` command:

  ```bash theme={"theme":"vesper"}
  --pr-number "$PR_NUMBER"
  ```

  For cleanup, set `--remove-after 86400` on the `upload-app` step so TesterArmy removes the shared upload automatically:

  ```bash theme={"theme":"vesper"}
  --remove-after 86400
  ```

  After editing:

  * Show the user which files changed.
  * Tell the user to configure `TESTERARMY_API_KEY`, `TESTERARMY_PROJECT_ID`, and `TESTERARMY_GROUP_ID` in their EAS environment. If you added the exploration agent, mention the optional `TESTERARMY_DYNAMIC_AGENT_ENABLED` toggle.
  * Tell the user which EAS workflow command to run.
  * Do not run the real TesterArmy CLI or EAS build unless the user explicitly asks you to.

  Finish by summarizing the files changed and the manual test command.
</Prompt>

## Configure mobile build profiles

TesterArmy requires an iOS Simulator build profile and an Android APK build profile in EAS. Add the following snippet to your `eas.json` file:

```json eas.json theme={"theme":"vesper"}
{
  "build": {
    "testerarmy-ios-simulator": {
      "ios": {
        "simulator": true
      }
    },
    "testerarmy-android-apk": {
      "android": {
        "buildType": "apk"
      }
    }
  }
}
```

<Note>
  For Android builds, make sure your Expo config has an Android package name, for example
  `android.package` in `app.json`.
</Note>

## Add the EAS workflow

<Tabs>
  <Tab title="Basic">
    Here is an example workflow file that:

    1. Builds the iOS Simulator app and Android APK
    2. Uploads each app to TesterArmy
    3. Runs the saved test group once for iOS and once for Android
    4. Deletes each uploaded app from TesterArmy after the tests complete

    ```yaml .eas/workflows/testerarmy-mobile-tests.yml theme={"theme":"vesper"}
    name: TesterArmy Mobile Tests

    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
      workflow_dispatch: {}

    jobs:
      build_ios:
        name: Build iOS Simulator app
        type: build
        environment: preview
        params:
          platform: ios
          profile: testerarmy-ios-simulator

      build_android:
        name: Build Android app
        type: build
        environment: preview
        params:
          platform: android
          profile: testerarmy-android-apk

      upload_ios_app:
        name: Upload iOS app to TesterArmy
        needs: [build_ios]
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

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

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      upload_android_app:
        name: Upload Android app to TesterArmy
        needs: [build_android]
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

          - uses: eas/download_build
            id: download_build
            with:
              build_id: ${{ needs.build_android.outputs.build_id }}
              extensions:
                - apk

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      run_ios_tests:
        name: Run iOS TesterArmy tests
        needs: [upload_ios_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_ios_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform ios
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
                --delete-app-after-run
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"

      run_android_tests:
        name: Run Android TesterArmy tests
        needs: [upload_android_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_android_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform android
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
                --delete-app-after-run
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"
    ```
  </Tab>

  <Tab title="Fingerprint + Repack">
    This version uses Expo's fingerprint, `get-build`, and `repack` jobs to avoid full native rebuilds when the current native runtime matches an existing iOS Simulator build or Android APK.

    The workflow:

    1. Calculates the iOS and Android fingerprints
    2. Looks for compatible iOS Simulator and Android APK builds with matching fingerprints
    3. Repacks a matching build with the current JavaScript bundle when a match exists
    4. Falls back to full platform builds when no compatible build exists
    5. Uploads each resulting app to TesterArmy
    6. Runs the saved test group once for iOS and once for Android

    <Note>
      Keep the fingerprint job's `environment` aligned with the build profile environment. Expo's
      fingerprint job is intended for CNG/managed projects; if your repository commits native `ios` or
      `android` directories, use the basic workflow or verify your Expo fingerprint setup first.
    </Note>

    ```yaml .eas/workflows/testerarmy-mobile-tests.yml theme={"theme":"vesper"}
    name: TesterArmy Mobile Tests

    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
      workflow_dispatch: {}

    jobs:
      fingerprint:
        name: Calculate app fingerprints
        type: fingerprint
        environment: preview

      get_ios_build:
        name: Find matching iOS Simulator build
        needs: [fingerprint]
        type: get-build
        params:
          platform: ios
          profile: testerarmy-ios-simulator
          simulator: true
          fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
          wait_for_in_progress: true

      get_android_build:
        name: Find matching Android app build
        needs: [fingerprint]
        type: get-build
        params:
          platform: android
          profile: testerarmy-android-apk
          fingerprint_hash: ${{ needs.fingerprint.outputs.android_fingerprint_hash }}
          wait_for_in_progress: true

      repack_ios:
        name: Repack iOS app
        needs: [get_ios_build]
        if: ${{ needs.get_ios_build.outputs.build_id }}
        type: repack
        params:
          build_id: ${{ needs.get_ios_build.outputs.build_id }}

      repack_android:
        name: Repack Android app
        needs: [get_android_build]
        if: ${{ needs.get_android_build.outputs.build_id }}
        type: repack
        params:
          build_id: ${{ needs.get_android_build.outputs.build_id }}

      build_ios:
        name: Build iOS Simulator app
        needs: [get_ios_build]
        if: ${{ !needs.get_ios_build.outputs.build_id }}
        type: build
        environment: preview
        params:
          platform: ios
          profile: testerarmy-ios-simulator

      build_android:
        name: Build Android app
        needs: [get_android_build]
        if: ${{ !needs.get_android_build.outputs.build_id }}
        type: build
        environment: preview
        params:
          platform: android
          profile: testerarmy-android-apk

      upload_ios_app:
        name: Upload iOS app to TesterArmy
        after: [repack_ios, build_ios]
        if: ${{ after.repack_ios.outputs.build_id || after.build_ios.outputs.build_id }}
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

          - uses: eas/download_build
            id: download_build
            with:
              build_id: ${{ after.repack_ios.outputs.build_id || after.build_ios.outputs.build_id }}
              extensions:
                - app

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --remove-after 86400 \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      upload_android_app:
        name: Upload Android app to TesterArmy
        after: [repack_android, build_android]
        if: ${{ after.repack_android.outputs.build_id || after.build_android.outputs.build_id }}
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

          - uses: eas/download_build
            id: download_build
            with:
              build_id: ${{ after.repack_android.outputs.build_id || after.build_android.outputs.build_id }}
              extensions:
                - apk

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --remove-after 86400 \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      run_ios_tests:
        name: Run iOS TesterArmy tests
        needs: [upload_ios_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_ios_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform ios
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
                --delete-app-after-run
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"

      run_android_tests:
        name: Run Android TesterArmy tests
        needs: [upload_android_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_android_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform android
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
                --delete-app-after-run
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"
    ```
  </Tab>

  <Tab title="With exploration agent">
    This version runs the saved test group and, on pull requests, also runs the [exploration agent](#exploration-agent), which explores what the pull request changes instead of running a fixed group. Both run against a single shared upload.

    The workflow:

    1. Builds the iOS Simulator app and Android APK
    2. Uploads each app to TesterArmy once
    3. Runs the saved test group once for iOS and once for Android
    4. On pull requests, runs the exploration agent for each platform against the same upload

    ```yaml .eas/workflows/testerarmy-mobile-tests.yml theme={"theme":"vesper"}
    name: TesterArmy Mobile Tests

    on:
      push:
        branches:
          - main
      pull_request:
        branches:
          - main
      workflow_dispatch: {}

    jobs:
      build_ios:
        name: Build iOS Simulator app
        type: build
        environment: preview
        params:
          platform: ios
          profile: testerarmy-ios-simulator

      build_android:
        name: Build Android app
        type: build
        environment: preview
        params:
          platform: android
          profile: testerarmy-android-apk

      upload_ios_app:
        name: Upload iOS app to TesterArmy
        needs: [build_ios]
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

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

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --remove-after 86400 \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      upload_android_app:
        name: Upload Android app to TesterArmy
        needs: [build_android]
        environment: preview
        outputs:
          app_id: ${{ fromJSON(steps.upload_app.outputs.upload_result).uploadedAppId }}
        steps:
          - uses: eas/checkout

          - uses: eas/download_build
            id: download_build
            with:
              build_id: ${{ needs.build_android.outputs.build_id }}
              extensions:
                - apk

          - name: Upload app
            id: upload_app
            run: |
              set -euo pipefail

              APP_PATH="${{ steps.download_build.outputs.artifact_path }}"

              mkdir -p .testerarmy

              npx --yes testerarmy@latest upload-app \
                --app-path "$APP_PATH" \
                --project "$TESTERARMY_PROJECT_ID" \
                --remove-after 86400 \
                --output .testerarmy/upload.json

              set-output upload_result "$(tr -d '\n' < .testerarmy/upload.json)"

      run_ios_tests:
        name: Run iOS TesterArmy tests
        needs: [upload_ios_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_ios_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform ios
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"

      run_android_tests:
        name: Run Android TesterArmy tests
        needs: [upload_android_app]
        environment: preview
        steps:
          - uses: eas/checkout

          - name: Run tests
            run: |
              set -euo pipefail

              APP_ID="${{ needs.upload_android_app.outputs.app_id }}"
              COMMIT_SHA="${{ github.sha }}"
              EVENT_NAME="${{ github.event_name }}"
              PR_NUMBER="${{ github.event.pull_request.number || '' }}"
              TIMEOUT_MS="1800000"
              POLL_INTERVAL_SECONDS="10"

              mkdir -p .testerarmy

              args=(
                ci
                --group "$TESTERARMY_GROUP_ID"
                --project "$TESTERARMY_PROJECT_ID"
                --platform android
                --app-id "$APP_ID"
                --commit-sha "$COMMIT_SHA"
                --timeout "$TIMEOUT_MS"
                --poll-interval-seconds "$POLL_INTERVAL_SECONDS"
                --output .testerarmy/ci-result.json
              )

              if [ "$EVENT_NAME" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
                args+=(--pr-number "$PR_NUMBER")
              fi

              npx --yes testerarmy@latest "${args[@]}"

      run_ios_dynamic_agent:
        name: Run iOS TesterArmy exploration agent
        needs: [upload_ios_app]
        if: ${{ github.event_name == 'pull_request' }}
        environment: preview
        env:
          APP_ID: ${{ needs.upload_ios_app.outputs.app_id }}
          COMMIT_SHA: ${{ github.sha }}
          PR_NUMBER: ${{ github.event.pull_request.number || '' }}
          PR_TITLE: ${{ github.event.pull_request.title || '' }}
          PR_DESCRIPTION: ${{ github.event.pull_request.body || '' }}
          BASE_BRANCH: ${{ github.event.pull_request.base.ref || '' }}
          HEAD_BRANCH: ${{ github.event.pull_request.head.ref || '' }}
        steps:
          - uses: eas/checkout

          - name: Run exploration agent
            run: |
              set -euo pipefail

              if [ "${TESTERARMY_DYNAMIC_AGENT_ENABLED:-true}" = "false" ]; then
                echo "TesterArmy exploration agent is disabled because TESTERARMY_DYNAMIC_AGENT_ENABLED=false."
                exit 0
              fi

              if [ -z "$PR_NUMBER" ]; then
                echo "No pull request number found; skipping TesterArmy exploration agent."
                exit 0
              fi

              mkdir -p .testerarmy
              rm -f .testerarmy/dynamic-result.json

              dynamic_pr_title="$PR_TITLE"
              if [ -z "$dynamic_pr_title" ]; then
                dynamic_pr_title="Pull request #$PR_NUMBER"
              fi

              args=(
                pr
                run-dynamic
                --project "$TESTERARMY_PROJECT_ID"
                --platform ios
                --app-id "$APP_ID"
                --pr-number "$PR_NUMBER"
                --pr-title "$dynamic_pr_title"
                --commit-sha "$COMMIT_SHA"
                --output .testerarmy/dynamic-result.json
              )

              if [ -n "$PR_DESCRIPTION" ]; then
                args+=(--pr-description "$PR_DESCRIPTION")
              fi

              if [ -n "$BASE_BRANCH" ]; then
                args+=(--base-branch "$BASE_BRANCH")
              fi

              if [ -n "$HEAD_BRANCH" ]; then
                args+=(--head-branch "$HEAD_BRANCH")
              fi

              npx --yes testerarmy@latest "${args[@]}"

      run_android_dynamic_agent:
        name: Run Android TesterArmy exploration agent
        needs: [upload_android_app]
        if: ${{ github.event_name == 'pull_request' }}
        environment: preview
        env:
          APP_ID: ${{ needs.upload_android_app.outputs.app_id }}
          COMMIT_SHA: ${{ github.sha }}
          PR_NUMBER: ${{ github.event.pull_request.number || '' }}
          PR_TITLE: ${{ github.event.pull_request.title || '' }}
          PR_DESCRIPTION: ${{ github.event.pull_request.body || '' }}
          BASE_BRANCH: ${{ github.event.pull_request.base.ref || '' }}
          HEAD_BRANCH: ${{ github.event.pull_request.head.ref || '' }}
        steps:
          - uses: eas/checkout

          - name: Run exploration agent
            run: |
              set -euo pipefail

              if [ "${TESTERARMY_DYNAMIC_AGENT_ENABLED:-true}" = "false" ]; then
                echo "TesterArmy exploration agent is disabled because TESTERARMY_DYNAMIC_AGENT_ENABLED=false."
                exit 0
              fi

              if [ -z "$PR_NUMBER" ]; then
                echo "No pull request number found; skipping TesterArmy exploration agent."
                exit 0
              fi

              mkdir -p .testerarmy
              rm -f .testerarmy/dynamic-result.json

              dynamic_pr_title="$PR_TITLE"
              if [ -z "$dynamic_pr_title" ]; then
                dynamic_pr_title="Pull request #$PR_NUMBER"
              fi

              args=(
                pr
                run-dynamic
                --project "$TESTERARMY_PROJECT_ID"
                --platform android
                --app-id "$APP_ID"
                --pr-number "$PR_NUMBER"
                --pr-title "$dynamic_pr_title"
                --commit-sha "$COMMIT_SHA"
                --output .testerarmy/dynamic-result.json
              )

              if [ -n "$PR_DESCRIPTION" ]; then
                args+=(--pr-description "$PR_DESCRIPTION")
              fi

              if [ -n "$BASE_BRANCH" ]; then
                args+=(--base-branch "$BASE_BRANCH")
              fi

              if [ -n "$HEAD_BRANCH" ]; then
                args+=(--head-branch "$HEAD_BRANCH")
              fi

              npx --yes testerarmy@latest "${args[@]}"
    ```
  </Tab>
</Tabs>

## Exploration agent

On pull requests you can also run the [exploration agent](/run/pr-exploration-agent), which explores what a pull request changes instead of running a fixed test group. See the **With exploration agent** tab above for a complete workflow.

The agent runs on the same app you already uploaded and only on pull requests. To turn it off, set `TESTERARMY_DYNAMIC_AGENT_ENABLED=false`.

If a pull request doesn't change anything users can see in the app, the exploration run is skipped: the PR check shows a neutral "Tests skipped" status with the reason, the run is recorded as cancelled with that reason, and the CLI reports it as skipped and exits 0, so it never blocks the PR. Real failures and cancellations still fail the job.

## GitHub PR comments and checks

When your workflow is triggered by a pull request, TesterArmy will create a GitHub check and comment on the PR.

When workflow is triggered by a commit (eg. on push to main), TesterArmy will create a GitHub check on the commit.

To enable this, you need to configure your TesterArmy project with your GitHub repository.
You can do it in **Integrations** Tab in project.

## Example repository

You can find a working example repository [here](https://github.com/tester-army/mobile-example).

## Troubleshooting

### The build has an unsupported platform or format

TesterArmy requires an iOS Simulator `.app` build or Android `.apk` build. If you are using an iOS physical-device build, build a simulator app first. If you are using Android, make sure the EAS profile produces an APK, not an `.aab`.

<Note>
  Prefer **release** builds for CI, especially for Android runs that execute without a Metro dev
  server.
</Note>

### TesterArmy warns that GitHub integration is not configured / TesterArmy cannot create a PR comment

If you are seeing this error, it means that your TesterArmy project has no GitHub integration configured.
You can configure it in **Integrations** Tab in project.

## Related docs

* [App Uploads](/mobile/app-uploads)
* [GitHub Actions](/mobile/github-actions)
* [Bitrise](/mobile/bitrise)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.