From rn-dev-agent
Generates and runs Maestro E2E tests for React Native UI verification using maestro-runner. Covers testIDs, timing rules, network mocking, Zustand inspection, multi-device testing, and avoiding verification shortcuts.
npx claudepluginhub lykhoyda/rn-dev-agent --plugin rn-dev-agentThis skill uses the workspace's default tool permissions.
How to write and run UI test flows for React Native feature verification.
Suggests manual /compact at logical task boundaries in long Claude Code sessions and multi-phase tasks to avoid arbitrary auto-compaction losses.
Share bugs, ideas, or general feedback.
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 a 5-field metadata header so
/rn-dev-agent:list-learned-actions and /rn-dev-agent:run-action can
filter, replay, and reason about it without parsing the YAML body. The
header lives in YAML comments above (or just below) the --- separator
so Maestro itself ignores it.
appId: com.example.app
---
# id: <stable-slug> # Stable identifier. Defaults to the filename
# without `.yaml`. Set explicitly only when you
# want to rename the file later without breaking
# references.
# intent: <one-line goal> # Human-readable goal. Surfaced verbatim by the
# `list-learned-actions` inventory; replaces the
# older "first comment block = purpose" heuristic.
# tags: [a, b, c] # Filterable keywords (lower-case kebab-case).
# Conventions: feature area (tasks, auth,
# profile), operation (create, update, delete),
# markers (smoke, regression).
# mutates: true|false # `true` if the flow leaves persistent residue
# (created/deleted rows, toggled settings,
# anything a subsequent test would need to clean
# up). Read-only flows are `false`. Consumed by
# `/run-action` to require explicit confirmation
# before replay.
# status: active|deprecated|experimental
# `active` = production-quality, replay anytime.
# `experimental` = under construction, may break.
# `deprecated` = kept for history, do NOT replay.
- launchApp
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). 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. A flow missing mutates: is
parsed as mutates: null and list-learned-actions renders it as ?.
// 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 |
|---|---|---|---|
| agent-device | Recommended | Live device interaction | npm install -g agent-device |
| 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 evidence