Install
openclaw skills install @dennisrongo/maestro-mobile-testWrite and run E2E tests for React Native / Expo apps using Maestro CLI — the open-source tool that drives the **real native app** on an Android emulator or iOS simulator, not a web build. Covers what browser-based e2e (Playwright, Expect, e2e-verify) cannot reach: native components, biometrics, secure storage, push notifications, platform-specific modules. Write YAML flows, run headed for authoring or headless for CI. Scripts currently automate the Android workflow end-to-end (Java + SDK detection, emulator boot, adb reverse); iOS simulator setup requires Xcode + xcrun simctl and is documented but not automated by the setup script. Use this skill whenever the user says "test my react native app", "e2e test the mobile app", "write maestro tests", "test the expo app", "automate emulator testing", or "/maestro-mobile-test" — even if they don't name the skill. Not for web apps (e2e-verify), unit/component tests (write-tests with RNTL + Jest), or API contract tests.
openclaw skills install @dennisrongo/maestro-mobile-testThe browser is the wrong tool for a React Native app — it renders to native components (UIView / android.view), not a DOM. Maestro drives the actual native binary on a real emulator/simulator: real taps, real native assertions, real platform behavior. It's the open-source (Apache-2.0) standard for RN E2E, and it writes tests as YAML flows — no test framework setup, no instrumentation in your app code.
The discipline is identical to e2e-verify's Route B (durable tests): ration by journey risk, selectors a user would recognize, prove red-capable, quote the run summary. What changes is the surface — native instead of browser — and the harness-specific pitfalls that will eat your time if you don't know them.
expo-local-authentication, expo-secure-store, expo-notifications) that no browser-based tool can reach.Do not auto-trigger for web app e2e (e2e-verify — Playwright/Expect), unit/component authoring (write-tests — RNTL + Jest), or API contract tests.
| Requirement | How to check | Install |
|---|---|---|
| Java 17+ | java -version | brew install --cask temurin@17 (macOS), sudo apt install openjdk-17-jdk (Linux), winget install Microsoft.OpenJDK.17 (Windows) — or run scripts/setup.sh which auto-installs |
| Android SDK | echo $ANDROID_HOME | Android Studio |
| AVD (emulator image) | emulator -list-avds | Android Studio → AVD Manager |
| Maestro CLI | maestro --version | curl -Ls https://get.maestro.mobile.dev | bash — or run scripts/setup.sh |
| Built APK (Android) | Check android/app/build/outputs/apk/ | cd android && ./gradlew assembleDebug |
The scripts/setup.sh handles everything — it detects your OS (macOS, Linux, Windows/git-bash) and installs the prerequisites in the right locations:
ANDROID_HOME, or ~/Library/Android/sdk, ~/AppData/Local/Android/Sdk, /opt/android-sdk)~/.maestro/binbash skills/maestro-mobile-test/scripts/setup.sh
After setup, activate before running tests:
source ~/.maestro/activate.sh
These are not theoretical. Each one was learned by hitting it in a real Expo app.
An Expo dev build (the kind you get from npx expo start) needs a live Metro bundler connection. Maestro's launchApp cold-starts the app, which drops the Metro connection. The app then shows the Expo Dev Launcher instead of your screens.
Strategy A — Dev build (local authoring only):
npx expo start, connect the app to Metro before running MaestrolaunchApp with clearState: trueextendedWaitUntil to give the app time to reconnectback exits to the launcher, hideKeyboard backgrounds the app, rapid hot reloads crash native modules. Fine for authoring. Not fine for reliable runs.Strategy B — Debug APK (recommended for reliable runs and CI):
cd android && ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
Now Maestro can launchApp freely — the JS bundle is compiled into the APK. No Metro, no fragility.
The Android emulator runs in its own network namespace. localhost:8888 inside the emulator means the emulator itself — not your machine's backend.
Symptom: App shows "Connection error" / "Couldn't load" even though your backend runs fine on localhost:8888.
Fix — adb reverse (standard pattern, run before launching the app):
adb reverse tcp:8888 tcp:8888 # map emulator localhost:8888 → host localhost:8888
adb reverse tcp:8081 tcp:8081 # Metro, if using a dev build
This resets on emulator reboot — re-run it each session.
timeout is NOT a property of assertVisible# WRONG — "Unknown Property: timeout"
- assertVisible:
text: "Welcome"
timeout: 10000
# CORRECT — use extendedWaitUntil for timed waits
- extendedWaitUntil:
visible: "Welcome"
timeout: 10000
MaterialIcons buttons have no text for Maestro to match. Add testID and accessibilityLabel to the source:
// Before — Maestro cannot tap this reliably (no text to match)
<TouchableOpacity onPress={...}>
<MaterialIcons name="add" size={28} color="..." />
</TouchableOpacity>
// After — Maestro taps by accessibility label
<TouchableOpacity
onPress={...}
testID="add-item-button"
accessible={true}
accessibilityLabel="Add Item"
>
<MaterialIcons name="add" size={28} color="..." />
</TouchableOpacity>
Then: - tapOn: "Add Item"
hideKeyboard and back exit dev buildsOn dev builds, both can send the app to the home screen. Dismiss the keyboard by tapping a non-interactive element instead:
# WRONG — can exit the app on dev builds
- hideKeyboard
- back
# CORRECT — tap a header to blur the input
- tapOn: "Screen Title"
- waitForAnimationToEnd
appId: com.example.app
---
# Assertions
- assertVisible: "Text"
- extendedWaitUntil:
visible: "Text"
timeout: 20000
# Taps and input
- tapOn: "Button Label"
- tapOn:
id: "add-button"
- doubleTapOn: "Text"
- longPressOn: "Text"
- inputText: "Hello"
# Navigation
- back
- swipe:
direction: UP
- scrollUntilVisible:
element: "Target Text"
# Waits
- waitForAnimationToEnd
# App control
- launchApp:
appId: com.example.app
clearState: false
- killApp: com.example.app
# Conditionals / branching (runFlow with when condition)
- runFlow:
when:
visible: "Welcome"
commands:
- tapOn: "Get Started"
# Repeat a sequence
- repeat:
times: 3
commands:
- tapOn: "Next"
# Screenshots
- captureScreenshot: "step-name"
# Deep links
- openLink: myapp://screen/path
# Sub-flow inclusion (essential for suite patterns)
- runFlow: ../setup.yaml # run another flow file
- runFlow: ../config.yaml # shared config
# Visibility negation
- assertNotVisible: "Loading..."
# Text extraction (store for later assertions)
- copyTextFrom:
id: "total-amount"
- assertTrue: ${output.total-amount} == "$54.20"
# Input helpers
- inputRandomEmail # generates and types a random email
- pasteText # paste from clipboard
# Location mocking (travel)
- travel:
points: "37.7749,-122.4194"
# Media injection (camera roll)
- addMedia:
- "./test-assets/photo.jpg"
project-root/
├── .maestro/
│ ├── navigation/
│ │ ├── tab-nav.yaml
│ │ └── dashboard-elements.yaml
│ ├── features/
│ │ ├── create-item.yaml
│ │ └── view-list.yaml
│ └── suites/
│ ├── smoke.yaml # fast: navigation + visibility (pre-merge)
│ └── regression.yaml # full: all flows (pre-release)
Suite levels:
# Boot emulator
$ANDROID_HOME/emulator/emulator.exe -avd my-phone -no-snapshot-load &
adb wait-for-device
adb shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;'
# Run a flow
maestro test .maestro/navigation/tab-nav.yaml
# Run a suite
maestro test .maestro/suites/regression.yaml
# Android: boot emulator headless
$ANDROID_HOME/emulator/emulator.exe -avd my-phone -no-window -no-audio -no-snapshot-load &
# Then run the same flows
maestro test .maestro/suites/regression.yaml
GitHub Actions CI (uses reactivecircus/android-emulator-runner to boot the emulator):
# .github/workflows/maestro-e2e.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
script: |
# Install Maestro + app, then run flows
curl -Ls "https://get.maestro.mobile.dev" | bash
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
$HOME/.maestro/bin/maestro test .maestro/suites/smoke.yaml
iOS CI: Maestro supports iOS simulator in CI but requires a macOS runner with Xcode. Use
xcrun simctl boot <udid>to boot the simulator instead of the Android emulator runner.
("Ration by journey risk" means: test only the user-facing flows that matter — login, checkout, CRUD — not every internal code path; push business logic to unit tests. "Prove red-capable" means: break the behavior, run, confirm the test fails; revert, run, confirm it passes. A test that's never been seen failing is unproven.)
accessibilityLabel > testID > text > coordinates. Never raw coordinates — they break on any layout change.
tapOn: { point: 93%,9% } — breaks on any layout change, catches nothing a user cares about.tapOn: "Add Item" (backed by accessibilityLabel) — breaks only when the user's world breaks.waitForAnimationToEnd and extendedWaitUntil auto-wait.not run, never "passed."User: "I just finished the item creation flow — test it on the emulator."
Claude: Builds a debug APK (not dev build — "dev builds are fragile under Maestro"), installs it, boots the emulator headed. Writes .maestro/features/create-item.yaml: navigate to Items → tap "Add Item" (via accessibilityLabel) → fill title + description → save → verify on list. Proves it red by running with a wrong assertion target, quotes both runs. Adds it to the smoke suite.
User: "Test my expo app."
Claude: Checks for .maestro/ — none exists. Checks the app ID from app.json. Scaffolds the directory structure, writes a tab-nav.yaml smoke flow first (fastest signal), runs it headed so the user sees the taps. Then writes feature flows for the core journeys identified from the navigation structure.
back exits the app. Use a debug APK.point: 93%,9%) — weld the test to today's layout. Use accessibilityLabel.assertVisible with a timeout — use extendedWaitUntil.adb reverse — the app can't reach your backend and every flow reports "connection error."adb reverse → headed authoring → accessibility-label selectors → prove red-capable → quote the run.| Problem | Cause | Fix |
|---|---|---|
java not found | Java not installed | Install JDK 17+ |
| App shows "Connection error" | Emulator can't reach backend | adb reverse tcp:<port> tcp:<port> |
launchApp breaks dev build | Cold start drops Metro | Use debug APK, or don't use clearState: true |
Unknown Property: timeout | assertVisible doesn't accept it | Use extendedWaitUntil: { visible: ..., timeout: ... } |
| Flaky tap failures | Element not loaded yet | Add waitForAnimationToEnd or extendedWaitUntil before tapping |
hideKeyboard exits the app | Dev build treats it as back | Tap a non-interactive element to blur |
This skill ships with reusable scripts in scripts/:
| Script | Purpose |
|---|---|
scripts/setup.sh | One-time install: Java 17, Maestro CLI, verifies Android SDK + AVDs |
scripts/run_flow.sh | Boots emulator (if needed), sets up adb reverse, runs a flow |
scripts/flow_template.yaml | Copy-and-edit template for new Maestro flows |
# 1. One-time setup
bash skills/maestro-mobile-test/scripts/setup.sh
# 2. Run a flow (boots emulator if needed, sets up adb reverse)
source ~/.maestro/activate.sh
bash skills/maestro-mobile-test/scripts/run_flow.sh my-avd .maestro/navigation/tab-nav.yaml 8888