Skip to main content

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. 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.

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:

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 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 wrapper turns the results into step outputs for github-comment and slack jobs:

# .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 }})
# .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 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, 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.

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.

# 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.

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.

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.

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.

Was this page helpful?
Last updated