From rn-dev-agent
Writes and runs Maestro UI test flows for React Native apps. Covers test selection, timing rules, testID conventions, multi-device testing, network mocking, and store inspection.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rn-dev-agent:rn-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
How to write and run UI test flows for React Native feature verification.
How to write and run UI test flows for React Native feature verification. Covers test runner selection, timing rules, testID conventions, multi-device testing, network mocking, and store inspection setup.
maestro-runner is a Go-based drop-in replacement for Maestro. Same YAML flow syntax, 3-4x faster, no JVM required.
| Metric | Maestro (Java) | maestro-runner (Go) |
|---|---|---|
| Binary size | ~300MB (with JVM) | 21MB single binary |
| Startup time | 2-4s (JVM cold start) | <100ms |
| Memory | ~400MB | ~30MB |
| Flow execution | Baseline | 2-3x faster |
| Install | brew install maestro + Java | Single binary download |
# Auto-detect runner (prefer maestro-runner)
if command -v maestro-runner &>/dev/null; then
RUNNER="maestro-runner"
elif command -v maestro &>/dev/null; then
RUNNER="maestro"
else
echo "Install: brew install maestro OR download maestro-runner"
exit 1
fi
# Execute flow (identical YAML syntax either way)
$RUNNER test flows/my-flow.yaml
Verification means simulating a real user. State injection that produces a matching screen is NOT verification.
The following count as shortcuts that invalidate verification unless the user explicitly sanctions them:
success-details/<existingId> instead of walking the create flow).isNewPolicy=true, fromSuccess=true, mode=edit injected via URL).cdp_dispatch from outside a real user gesture).These tools work equally well from inside a real flow and as state injection — the plugin can't tell the difference. The agent is the safeguard.
A regression in steps 1–4 of a real user flow (e.g. a mutation not firing, a navigation prop wired wrong) is invisible to a verification that deep-links past those steps. Screenshots look identical. The verification claims "passed" while the actual feature is broken.
If a real user cannot reach the screen you're verifying via UI alone (no terminal commands, no URL bar, no dev menu), and you are about to use any of the shortcuts above, STOP and either:
Do not silently take the cheaper path. The user reviewing your output cannot tell from screenshots whether the flow was real.
device_permission) — platform setup, declared upfrontIn all these cases: declare the shortcut in the test report, don't bury it.
PER STEP — OPTIMIZED (maestro-runner + JPEG):
1. maestro-runner tap action → 0.3s
2. maestro-runner assertVisible → 0.3s
3. bash: snapshot (concurrent) → 0.2s (screenshot + UI dump in parallel)
4. MCP: cdp_component_tree → 0.4s
5. MCP: cdp_store_state → 0.2s
Total per step: ~1.4s
PER STEP — BASELINE (Maestro + PNG):
Total per step: ~3.1s
Improvement: 2.2x faster per step. Over a 10-step test: saves ~17 seconds.
After any UI interaction, React needs time to commit updates to the Fiber tree.
1. device_find text="Submit" action=click → native tap
2. device_snapshot → verify UI changed (new elements, @refs)
3. cdp_store_state → now safe to read React state
1. Maestro tap/input
2. Maestro assertVisible (wait for UI to settle)
3. CDP state query (now safe to read)
WRONG (race condition — gets stale state):
1. device_find "Submit" action=click OR Maestro tap
2. Immediately: cdp_store_state → returns OLD state
If no visual indicator exists after an action, add an explicit delay:
# After interaction, wait for React to settle
# bash: sleep 0.7
# cdp_store_state
After code changes, Fast Refresh triggers automatically. Wait 1-2 seconds
before querying CDP state, or call cdp_reload for a full reload.
appId: com.example.app
---
- launchApp
- assertVisible: "Home"
WARNING: Never use clearState: true with Expo Dev Client builds — it wipes
the stored Metro server URL, causing the Dev Client launcher/picker to appear
instead of your app (EG_DEV_CLIENT_CLEARSTATE). Only use clearState with
Expo Go or bare React Native apps.
# Deep link navigation (preferred when available)
- openLink: "myapp://cart"
# Tap by testID
- tapOn:
id: "add-to-cart-btn"
# Tap by visible text
- tapOn: "Add to Cart"
# Type in input
- tapOn:
id: "search-input"
- inputText: "Nike Air Max"
# Scroll until element visible
- scrollUntilVisible:
element:
id: "checkout-btn"
# Assert element is visible
- assertVisible: "Shopping Cart"
# Assert by testID
- assertVisible:
id: "cart-badge"
# Assert text content
- assertVisible:
id: "cart-badge"
text: "3"
# Assert element is NOT visible
- assertNotVisible:
id: "error-banner"
appId: com.example.app
---
- launchApp
- assertVisible: "Home"
- tapOn:
id: "product-shoe-1"
- assertVisible: "Product Detail"
- tapOn:
id: "add-to-cart-btn"
- assertVisible:
id: "cart-badge"
text: "1"
- tapOn:
id: "cart-tab"
- assertVisible: "Shopping Cart"
- assertVisible: "Air Max 90"
cat > /tmp/step.yaml << 'EOF'
appId: com.example.app
---
- tapOn:
id: "add-to-cart-btn"
- assertVisible:
id: "cart-badge"
EOF
maestro-runner --platform <ios|android> test /tmp/step.yaml
Every reusable Maestro flow MUST declare an M7 metadata header — # key: value
comment lines above the body (Maestro ignores them; the inventory and the
/run-action pre-flight parse them). The 5 inventory keys:
| Key | Value | Notes |
|---|---|---|
id | kebab-case slug | defaults to filename without .yaml |
intent | one-line goal | surfaced verbatim by /list-learned-actions; the routing key |
tags | [a, b, c] lower-case kebab | feature area (auth, tasks), operation (create, delete), markers (smoke, regression) |
mutates | true/false | persistent residue? drives the /run-action confirmation gate; missing parses as null and renders as ? |
status | experimental | active | deprecated | start experimental; first clean replay promotes; deprecated = never replay |
Auto-generated flows from cdp_record_test_generate populate these fields
when supplied via GenerateOpts.id|intent|tags|mutates|status (see
tools/test-recorder-generators.ts). maestro_generate and hand-written
flows must add the header manually before the flow is considered reusable.
Verification rule: before approving a new flow for the artifact-first inventory, confirm the header carries all 5 keys.
For the full authoring workflow (inventory dedup, selector grounding, the
ASCII flow diagram, optional fields — params, appId, produces,
expectedRouteSequence — validation, and replay-to-promote), load the
creating-actions skill.
// Good — stable, semantic testIDs
<TouchableOpacity testID="add-to-cart-btn">
<Text testID="cart-badge">{count}</Text>
<TextInput testID="search-input" />
<View testID={`product-item-${item.id}`}>
// Bad — index-based or text-based (breaks on reorder/copy changes)
<TouchableOpacity testID="button-0">
Grep for existing testIDs before writing flows:
grep -r 'testID=' src/ --include="*.tsx" --include="*.ts"
cdp_component_tree without a filter. Full tree dumps produce 10K+ tokens.cdp_component_tree(filter="CartBadge", depth=2)
cdp_component_tree(filter="product-list", depth=3)
assertVisible
for screen-level checks, CDP for data-level checks.# ALWAYS pass --platform explicitly (global flag, before the test subcommand)
maestro-runner --platform ios test flow.yaml # iOS
maestro-runner --platform android test flow.yaml # Android
# With explicit device ID
maestro-runner --platform ios --device booted test flow.yaml
maestro-runner --platform android --device emulator-5554 test flow.yaml
# Sequential cross-platform
maestro-runner --platform ios test flows/feature.yaml && \
maestro-runner --platform android test flows/feature.yaml
ALWAYS use maestro-runner on Android — classic Maestro's gRPC driver is unreliable (UNAVAILABLE: io exception). maestro-runner talks directly to UIAutomator2 over HTTP, bypassing the fragile gRPC stack entirely.
Text input: Use device_fill for text input on Android. It auto-detects
long strings (>30 chars) or special characters (+, @, #) and chunks
the input to prevent ANR crashes. Never use raw adb shell input text for
complex strings.
Emulator boot timing: Android emulators report "device" to ADB before
the system is fully booted. Always verify sys.boot_completed == 1 before
running tests. The ensure-android-ready.sh hook checks this automatically.
Play Protect: Google Play Protect on emulators can silently block test APK installations. Disable it: Settings > Security > Play Protect.
Port 7001 conflicts: If you must use classic Maestro, clean stale
forwarding rules first: adb forward --remove-all
Before testing features that require authentication, check if the app is on a login/auth screen. If so, use the project's own Maestro subflows instead of unreliable manual coordinate taps.
Call cdp_navigation_state and check the route name. Auth-related routes
typically match: Login, Welcome, SignIn, Register, Onboarding,
Auth, Landing.
Caution: An empty navigation state may be a splash screen (loading) or the Dev Client picker (GH #9), not necessarily auth. Wait 3 seconds and retry before concluding the app is logged out.
Scan for Maestro subflows in the project:
ls .maestro/subflows/ .maestro/ 2>/dev/null
Prefer login over registration (idempotent, no backend junk):
login.yaml, sign_in.yaml, auth.yamlflow_start.yaml (often includes login)register_user.yaml (last resort — creates accounts)Read the file to confirm it performs authentication.
clearState: true: If the subflow contains it and this is a Dev
Client build, copy to /tmp/ and strip the line before running (GH #8).${EMAIL}, ${PASSWORD},
etc., check for .env or .maestro/config.yaml. Ask the user if needed.appId: Subflows often lack appId. Wrap them:
cat > /tmp/auth-wrapper.yaml << EOF
appId: <bundle-id>
---
- launchApp
- runFlow:
file: $(pwd)/.maestro/subflows/login.yaml
EOF
# ALWAYS use maestro-runner (classic Maestro gRPC is unreliable on Android)
maestro-runner --platform <ios|android> test /tmp/auth-wrapper.yaml
If maestro-runner is not installed, STOP and tell the user to install it. Do NOT fall back to classic Maestro.
After the subflow completes, verify arrival at the main app:
cdp_navigation_state → route should be a main screen (Home, Dashboard, Tabs)
clearState: true with Dev Client builds (GH #8)--platform to maestro-runnerpermissions config if testing notification
permission flows (preserve undetermined state).maestro/subflows/login.yamlBefore testing flows that depend on specific permission states (notification opt-in, camera access, location prompts), verify and set the correct state.
device_permission(action="query", permission="notifications", appId="com.example.app")
→ { state: "granted" | "denied" | "not_declared" }
Query all permissions at once:
device_permission(action="query", permission="all", appId="com.example.app")
→ { granted: ["notifications", "camera"], denied: ["location"] }
| Need | Currently | Action |
|---|---|---|
| Undetermined (fresh prompt) | granted | action="revoke" + app restart |
| Undetermined (fresh prompt) | denied | action="reset" |
| Granted | denied | action="grant" |
| Denied | granted | action="revoke" |
| Platform | Query | Grant | Revoke | Reset |
|---|---|---|---|---|
| Android | dumpsys — returns granted/denied | pm grant | pm revoke | pm reset-permissions |
| iOS Sim | Not supported (returns "unknown") | simctl privacy grant | simctl privacy revoke | simctl privacy reset |
iOS Simulator cannot query permission state. Options:
action="reset" before testing to ensure ask-again stateNEVER open the visual dev menu during automated testing — it overlays the entire screen and blocks Maestro interactions.
Use cdp_dev_settings for programmatic control:
cdp_dev_settings action=reload (or cdp_reload for auto-reconnect)cdp_dev_settings action=dismissRedBoxcdp_dev_settings action=toggleInspectorInject mocks via CDP before navigating to the screen under test:
cdp_evaluate:
expression: 'global.__RN_AGENT_MOCKS__ = { "https://api.example.com/products": [{ id: 1, name: "Test" }] }'
For the full app-side fetch-patching setup, multiple-URL mocking, and error
simulation, consult references/network-mocking-setup.md.
Zustand v4+ uses useSyncExternalStore, NOT React Context. Fiber tree walking
cannot detect Zustand stores. Register store hooks for the MCP tool:
// app/_layout.tsx or App.tsx — register the store hooks (not state snapshots)
if (__DEV__) {
global.__ZUSTAND_STORES__ = {
auth: useAuthStore,
cart: useCartStore,
settings: useSettingsStore,
};
}
cdp_store_state calls .getState() on each registered hook at query time:
cdp_store_state(path="cart.items") # reads useCartStore.getState().items
cdp_store_state(path="auth") # reads full useAuthStore.getState()
| Tool | Required | Purpose | Install |
|---|---|---|---|
| rn-fast-runner (iOS) | iOS | Live device interaction | In-tree; builds on first use (or pre-build via xcodebuild build-for-testing) |
| rn-android-runner (Android) | Android | Live device interaction | In-tree; build via ./gradlew assembleDebug assembleDebugAndroidTest |
| maestro-runner | Recommended | YAML E2E test execution | Single binary download |
| Maestro | Fallback | YAML E2E test execution | brew install maestro |
| Xcode + Simulator | iOS | iOS testing | Mac App Store |
| Android SDK + adb | Android | Android testing | developer.android.com |
| Node.js >= 18 | Required | CDP MCP server | nodejs.org |
Agents routinely skip test steps because "it looks right." Don't.
| Excuse | Reality |
|---|---|
| "I tested on iOS, Android behaves the same" | False for ~40% of features (Ralph Loop data). Keyboard, permissions, back button, text input quirks, safe-area differ. Run cross_platform_verify unless explicitly single-platform. |
| "The component renders, I don't need to check state" | Rendering with wrong state is how most production bugs ship. cdp_store_state(path="X") is one call — take it. |
| "A screenshot is enough proof" | Screenshots show pixels, not correctness. If the test is "add to cart increments badge", verify the STORE incremented (cdp_store_state) — screenshot alone may show stale render. |
| "Manual testing is faster than writing a Maestro flow" | Manual doesn't persist. Tomorrow's refactor breaks the feature silently. A 15-second Maestro flow saves hours of regression debugging. |
"I'll skip the assertVisible step — CDP is fast enough" | React render is async. cdp_component_tree called 50ms after a tap may query the old tree. Always assertVisible first, then CDP. |
| "The feature is trivial, no need for a testID" | Every interactive element needs a testID. Without them, E2E tests rely on text matching which breaks on i18n, capitalization, and whitespace changes. |
| "Network mocking is overkill for this test" | Real API calls make tests flaky (rate limits, network, staging drift). Mock at the boundary (MSW or __RN_AGENT_RESPONSE_BODIES__). |
cdp_status firstcdp_component_tree without also checking store statesleep 3 to fix flakiness instead of using assertVisiblecdp_status returns ok:true with cdp.connected: truemaestro_run on iOSmaestro_run on Android (unless explicitly skipped)cdp_store_state confirms expected state after interactionscdp_component_tree shows the expected rendered treecdp_error_log returns zero errors at the end of the flowdocs/proof/<feature>/ for PR evidencenpx claudepluginhub lykhoyda/rn-dev-agent --plugin rn-dev-agentProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.