---
title: Maestro tests for Expo / EAS Build | TestingBot
description: 'Run Maestro flows on TestingBot from Expo: pass an EAS Build URL with
  --app-url, or run inside EAS Workflows with the @testingbot/eas-workflow wrapper.
  Android and iOS.'
source_url:
  html: https://testingbot.com/support/app-automate/maestro/ci-cd/expo-eas
  md: https://testingbot.com/support/app-automate/maestro/ci-cd/expo-eas.md
---

# Maestro tests for Expo apps on TestingBot

With Expo there is no local binary: `eas build` produces a download URL, a `.tar.gz` for iOS simulator builds and an `.apk` for Android. The TestingBot CLI takes that URL directly, and inside EAS Workflows a wrapper turns the results into step outputs. Both paths run your flows on TestingBot real devices, emulators and simulators with no Maestro Cloud account.

## Prerequisites and secrets

- An Expo project with an EAS build profile for the platform you test. iOS flows need a **simulator** build (`"ios": { "simulator": true }` in `eas.json`) unless you run on real devices with an `.ipa`.
- Maestro flows in the repository, by convention under `.maestro/`.
- Your TestingBot key and secret from the [dashboard](https://testingbot.com/members/user/edit). In EAS Workflows a `linux-medium` runner is enough for both platforms, because the flows run on TestingBot's simulators and devices; no macOS runner is needed.

Store the credentials as EAS project secrets once. EAS injects them into every step environment; the `${{ secrets.X }}` syntax does not exist in EAS Workflows.

```bash
eas env:create --scope project --name TB_KEY --value <your-key> --visibility secret --type string \
  --environment production --environment preview --environment development
eas env:create --scope project --name TB_SECRET --value <your-secret> --visibility secret --type string \
  --environment production --environment preview --environment development
```

Secrets your flows need, such as a test user's password, are created the same way and passed with `--env "PASSWORD=$TEST_PASSWORD"`.

## From any CI: eas build and --app-url

With Expo there is no local binary: `eas build` produces a download URL, a `.tar.gz` for iOS simulator builds and an `.apk` for Android. From any CI, pass that URL with `--app-url`; the CLI downloads it and, for iOS, extracts the `.app` before uploading:

```bash
URL=$(eas build --platform ios --profile preview --json --non-interactive | jq -r '.[0].artifacts.buildUrl')
npx --yes @testingbot/cli@latest maestro --app-url "$URL" ./flows --device "iPhone 16"
```

## Inside EAS Workflows

Inside [EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) the build is already on the runner after `eas/download_build`, so pass the artifact path. Credentials come from EAS project secrets (`eas env:create --name TB_KEY --visibility secret …`), which EAS injects into the step environment; the `${{ secrets.X }}` syntax is not available there. The [@testingbot/eas-workflow](https://github.com/testingbot/testingbot-eas) wrapper turns the results into step outputs for `github-comment` and `slack` jobs:

[Android](https://testingbot.com#) [iOS](https://testingbot.com#)

```yaml
# .eas/workflows/maestro-android.yml
name: Maestro E2E (Android)
on:
  pull_request: {}

jobs:
  build_android:
    type: build
    params:
      platform: android
      profile: preview

  e2e_android:
    needs: [build_android]
    runs_on: linux-medium
    # Job-level env: EAS may silently ignore env set on a step.
    env:
      TB_EAS_BUILD_ID: ${{ needs.build_android.outputs.build_id }}
      TB_EAS_PLATFORM: android
      TB_EAS_PROFILE: preview
      TB_GH_SHA: ${{ github.sha }}
      TB_GH_BRANCH: ${{ github.ref_name }}
      TB_GH_REPO_NAME: ${{ github.repository }}
    outputs:
      console_url: ${{ steps.testingbot.outputs.console_url }}
      run_status: ${{ steps.testingbot.outputs.run_status }}
      summary: ${{ steps.testingbot.outputs.summary }}
    steps:
      - uses: eas/checkout
      - uses: eas/download_build
        id: download
        with:
          build_id: ${{ needs.build_android.outputs.build_id }}
      - id: testingbot
        run: |
          npx --yes @testingbot/eas-workflow@v1 \
            --app-file ${{ steps.download.outputs.artifact_path }} \
            --flows ./.maestro \
            --device "Pixel 9" --deviceVersion "14" \
            --report junit --report-output-dir ./reports

  report:
    after: [e2e_android]
    type: github-comment
    params:
      payload: |
        ### Maestro on TestingBot — ${{ after.e2e_android.outputs.run_status }}
        ${{ after.e2e_android.outputs.summary }}
        [View results](${{ after.e2e_android.outputs.console_url }})
```

```yaml
# .eas/workflows/maestro-ios.yml
name: Maestro E2E (iOS)
on:
  pull_request: {}

jobs:
  build_ios:
    type: build
    params:
      platform: ios
      profile: preview # a simulator profile: "ios": { "simulator": true }

  e2e_ios:
    needs: [build_ios]
    runs_on: linux-medium
    env:
      TB_EAS_BUILD_ID: ${{ needs.build_ios.outputs.build_id }}
      TB_EAS_PLATFORM: ios
      TB_EAS_PROFILE: preview
      TB_GH_SHA: ${{ github.sha }}
      TB_GH_BRANCH: ${{ github.ref_name }}
      TB_GH_REPO_NAME: ${{ github.repository }}
    outputs:
      console_url: ${{ steps.testingbot.outputs.console_url }}
      run_status: ${{ steps.testingbot.outputs.run_status }}
      summary: ${{ steps.testingbot.outputs.summary }}
    steps:
      - uses: eas/checkout
      - uses: eas/download_build
        id: download
        with:
          build_id: ${{ needs.build_ios.outputs.build_id }}
      - id: testingbot
        run: |
          npx --yes @testingbot/eas-workflow@v1 \
            --app-file ${{ steps.download.outputs.artifact_path }} \
            --flows ./.maestro \
            --device "iPhone 16" --deviceVersion "18"

  report:
    after: [e2e_ios]
    type: github-comment
    params:
      payload: |
        ### Maestro on TestingBot — ${{ after.e2e_ios.outputs.run_status }}
        ${{ after.e2e_ios.outputs.summary }}
        [View results](${{ after.e2e_ios.outputs.console_url }})
```

`after` rather than `needs` on the report job means the comment is still posted when the e2e job fails, which is exactly when you want it. Downstream jobs read the outputs as `needs.e2e_android.outputs.console_url`; the same values feed a `slack` job.

The CLI records the EAS build id, profile, platform and commit on the run automatically when it runs inside an EAS job, and the `TB_GH_*` variables add the repository and pull request so a [GitHub status check](https://testingbot.com/support/integrations/ci-cd/github-pr-checks#maestro) is posted too.

EAS download links are signed and expire after about an hour; a rejected link is reported as such so you know to request a fresh one. A local `.tar.gz` can also be passed as the app file directly, and `testingbot upload <url>` uploads once for reuse with `--app-binary-id`.

## Pitfalls specific to EAS Workflows

- **Set `env` on the job, not the step.** Step-level `env` can be ignored silently by EAS.
- **Pull request expressions are `null` outside pull requests.** `github.event.pull_request.number` evaluates to `null` on a manual or push trigger and EAS rejects the job. Set `TB_GH_PR_NUMBER` and `TB_GH_PR_URL` only in a workflow that runs on `pull_request`, or leave them out; `github.sha` and `github.ref_name` safely become empty strings.
- **`github.repository` is `owner/repo`.** Pass it as `TB_GH_REPO_NAME`; the wrapper splits it, so `TB_GH_REPO_OWNER` is optional.
- **Never name a variable `EAS_BUILD_*`.** That namespace belongs to the EAS Build worker; overwriting `EAS_BUILD_ID` ends the job silently after the prepare phase. The wrapper's variables use the `TB_` prefix for this reason.
- **Build links expire.** An `eas build` URL is signed and stops working after about an hour; inside EAS Workflows use `eas/download_build` instead of a stored URL.

## Wrapper options and outputs

`@testingbot/eas-workflow` takes `--app-file`, `--app-url` or `--app-binary-id` plus `--flows`; every other flag is forwarded to `testingbot maestro`, so `--device`, `--device-matrix`, `--retry`, `--report` and the rest all work. Step outputs: `console_url`, `app_id`, `run_urls`, `run_status`, `outcome`, `total_flows_count`, `successful_flows_count`, `failed_flows_count`, the flow name lists and a one-line `summary`. The step exits 2 when a flow fails and 1 on a CLI or infrastructure error.

Environment variables: `TB_KEY` and `TB_SECRET` (required), `TB_GH_SHA`, `TB_GH_BRANCH`, `TB_GH_PR_NUMBER`, `TB_GH_PR_URL`, `TB_GH_REPO_OWNER` and `TB_GH_REPO_NAME` for the run's CI metadata and [PR status check](https://testingbot.com/support/app-automate/maestro/ci-cd/github-pr-checks), `TB_CHECK_NAME` to name that check per job (`iOS`, `Android`), `TB_RUN_NAME` and `TB_GROUPS` for the dashboard, `TB_CLI_VERSION` to pin the CLI. Never name a variable `EAS_BUILD_*`; that namespace belongs to the EAS Build worker.

Full reference and complete Android and iOS workflow files: [testingbot/testingbot-eas](https://github.com/testingbot/testingbot-eas).

## Migrating from the maestro-cloud job type

Replace the pre-packaged job with a custom job. No Maestro Cloud project id or API key is needed.

```yaml
# Before
jobs:
  e2e:
    needs: [build_android]
    type: maestro-cloud
    params:
      build_id: ${{ needs.build_android.outputs.build_id }}
      maestro_project_id: proj_abc123
      flows: ./.maestro

# After
jobs:
  e2e:
    needs: [build_android]
    steps:
      - uses: eas/checkout
      - uses: eas/download_build
        id: download
        with:
          build_id: ${{ needs.build_android.outputs.build_id }}
      - id: testingbot
        run: |
          npx --yes @testingbot/eas-workflow@v1 \
            --app-file ${{ steps.download.outputs.artifact_path }} \
            --flows ./.maestro
```

`include_tags`, `exclude_tags`, `maestro_version`, `maestro_config`, `device_locale`, `name` and `async` become the CLI flags of the same meaning; `device_model` and `device_os` become `--device` and `--deviceVersion`. Output names carry over, only `maestro_cloud_url` is renamed to `console_url`, so downstream `github-comment` and `slack` jobs need no changes.

## Frequently asked questions

### How do I run Maestro flows against an EAS Build?

Pass the build URL with `--app-url`. The CLI downloads it and, for an iOS simulator build, extracts the `.app` from the `.tar.gz` before uploading. See [eas build and --app-url](https://testingbot.com#app-url).

### How do I use it inside EAS Workflows?

Download the build with `eas/download_build` and run `npx --yes @testingbot/eas-workflow@v1 --app-file ... --flows ...`. Credentials come from EAS project secrets. See [inside EAS Workflows](https://testingbot.com#workflows).

### Why can I not use ${{ secrets.TB\_KEY }} in an EAS workflow?

EAS Workflows do not support that syntax. Create `TB_KEY` and `TB_SECRET` with `eas env:create --visibility secret`; EAS injects them into the step environment.

### Do I need a macOS runner for iOS flows?

No. The flows run on TestingBot's simulators and devices; `linux-medium` is enough for both platforms. The iOS build job itself still runs on EAS's macOS workers.

### Why does my job fail with a null expression?

`github.event.pull_request.*` is `null` on manual and push triggers and EAS rejects it. Keep those variables in workflows triggered by `pull_request` only. See [pitfalls](https://testingbot.com#pitfalls).

### Does an EAS Build link expire?

Yes, after about an hour. The CLI reports a rejected link as expired so you know to request a fresh one, for example by re-running `eas build --json`.

### Can I replace the maestro-cloud job type?

Yes. Swap it for a custom job with `eas/download_build` and the wrapper; parameters map to CLI flags of the same meaning and the outputs keep their names. See [migrating](https://testingbot.com#migrate).
