Mobile emulator test
π¦Curated Cursor AI agent skills, slash commands, MCP configs, subagents & rules for full-stack dev β React 19, Next.js 15, Supabase, Tailwind v4, TypeScript
npx -y skills add kensaurus/cursor-kenji --skill mobile-emulator-testAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 6 stars6 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
QA a native Android build end-to-end on the emulator. Use for "test on emulator", "QA Android build", "verify native build", "white screen", "cache rehydration", "Expo dev-client QA", "adb reverse". Pairs Metro/adb walk with Supabase + Sentry MCPs for three-layer CRUD verification.
The file declares its own license as MIT. That is the authorβs claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
17.5 KB, ~4.7k tokens by cl100k_base, as published. Nobody here has run it
mobile-emulator-test β Native build QA on Android emulator + MCP loop
End-to-end verification of any native mobile build through a real Android emulator session, with backend truth (Supabase MCP) and crash telemetry (Sentry MCP) wired into the same loop. Catches the failure modes that pure unit tests and CI bundle builds miss: white screens, prototype-stripped cache rehydration, missing migrations, sync-disabled empty states, infinite refetch loops, and silent error swallows.
Critical Rules
Test as a real signed-in user AND as a brand-new guest. Walk the app the way a paying customer would β sign in, browse every tab, add a real entry, edit it, delete it β AND the way a new user would β open the app fresh, hit the guest path, drive a CRUD round-trip without any account. Both paths exercise different code (sync replica vs cloud direct vs local-only adapter) and both regress independently. While walking, watch logcat, Sentry, and the DB at the same time.
Always verify the build under test is the latest source. A "nothing changed" report is almost always a stale build: Metro served a cached bundle, the dev launcher cached a JS-bundle URL, or the emulator is running a previously-installed APK while the new one sits in
android/app/build/outputs/. Phase 1.5 is non-negotiable.
Every mutation gets verified at three layers. A create/edit/delete is not "tested" until: (1) the app shows success, (2) the row exists/changes/disappears in the DB via Supabase MCP, and (3) re-opening the screen reflects the same state from a cold fetch.
Evidence for every finding. Each bug needs:
screencapPNG, last 30 lines oflogcat ReactNativeJS, the failing Sentry issue ID (if captured), and the exact reproduction taps.
Don't trust a "white screen". A blank surface is almost always one of three things: Metro died, the bundle threw inside the React tree (caught by ErrorBoundary into Sentry), or the persisted cache hydrated a class instance as a plain object. Diagnose, don't restart blindly.
Clean up server state. Anything you POST during the walk gets deleted before you finish, via the same Supabase MCP that verified it.
Phase 0: Codebase + Environment Discovery
Before launching anything, understand the build.
0a. Detect the native stack
| Signal | Stack |
|---|---|
apps/*/app.json or apps/*/app.config.{js,ts} + expo in deps | Expo / React Native |
react-native in deps but no expo | bare React Native |
capacitor.config.{ts,json} + a web/ or dist/ build | Capacitor |
pubspec.yaml with flutter: block | Flutter |
src-tauri/ with tauri.conf.json | Tauri mobile |
android/app/src/main/AndroidManifest.xml only | native Android |
Read the dependency manifest (package.json, pubspec.yaml, etc.) to extract:
- bundler / dev server (Metro, Vite, Capacitor Live Reload, Flutter daemon)
- bundler port (Metro defaults to 8081; check
scripts.start/expo start --port) - backend client (Supabase, Firebase, Amplify, custom REST)
- error tracker (Sentry, Bugsnag, Crashlytics) β needed for Phase 5
- offline/sync layer (PowerSync, Watermelon, Realm) β needed for Phase 4 fallback
- query cache (TanStack Query, RTK Query, SWR) β needed for Phase 6 cache audit
0b. Inventory routes / tabs / mutation surfaces
| Stack | Where routes live |
|---|---|
| Expo Router | app/**/*.{tsx,ts} (file-based) |
| React Navigation | grep createNativeStackNavigator, Tab.Screen, Stack.Screen |
| Capacitor + React Router | src/App.tsx route table |
| Flutter | lib/**/router.dart, MaterialApp.routes |
For each route record: path, dynamic params, auth guard, primary CRUD entity. Also list the bottom tabs explicitly β they are the spine of the walk.
0c. Discover backend + telemetry config
# Supabase project ref
grep -r "EXPO_PUBLIC_SUPABASE_URL\|SUPABASE_URL" .env .env.local apps/*/app.config.* 2>/dev/null
# Sentry DSN + project slug
grep -r "EXPO_PUBLIC_SENTRY_DSN\|SENTRY_DSN" .env .env.local 2>/dev/null
# PowerSync (or other sync) URL
grep -r "POWERSYNC_URL\|FIREBASE_DATABASE_URL" .env .env.local 2>/dev/null
Also list available MCP servers:
- supabase β
list_tables,execute_sql,apply_migration,get_logs,get_advisors - sentry β
find_organizations,search_issues,get_sentry_resource,update_issue
If either MCP is missing, mark that verification path BLOCKED but keep walking.
0d. Test account
Find or ask for a real signed-in user with a non-trivial dataset. The empty-state path is its own test (Phase 7), but the main walk MUST exercise populated screens β that is where the cache-corruption, FX-conversion, and sync-empty-state bugs live.
Phase 0.5: Tall-handset AVD + skin/display truth (any Android app)
Use this whenever layout QA benefits from extra vertical runway (multi-section
homes, drawers, FAB stacks) or whenever the emulator exits immediately / shows
the wrong logical resolution after editing hw.lcd.*.
0.5a. Target profile (recommended debug geometry)
Goal: Physical size: 1080x4000 (or 4000x1080 in landscape β verify with
adb shell wm size after boot).
Typical knobs live in %USERPROFILE%\.android\avd\<Something>.avd\config.ini
(or ~/Library/Android/avd/ on macOS). Set at least:
hw.lcd.width=1080hw.lcd.height=4000hw.lcd.density=<match your production class, often 420β480>showDeviceFrame=no(optional but keeps focus on pixels, not chrome)
Authoritative display check (never trust the window chrome alone):
adb shell wm size
adb shell wm density
0.5b. The _no_skin / invalid-skin trap (recent emulator builds)
Older templates or blog posts recommend skin.name=_no_skin and
skin.path=_no_skin to silence device frames on custom resolutions. Current
Studio / emulator builds often terminate with unknown skin name '_no_skin'
and exit_code: 1 β so the emulator never joins adb devices, and helper
scripts that wait for reverse / intent time out.
Rule: Pick a skin path that resolves to an existing SDK skin directory, or ship a minimal custom skin next to the AVD.
0.5c. Minimal custom skin folder (generic pattern for any app)
Problem: pointing skin.path at a stock skin like WXGA720 can force the guest
framebuffer to 720Γ1280 even when config.ini declares hw.lcd.*;
adb shell wm size will disagree with what you configured.
Mitigation:
- Create e.g.
%USERPROFILE%\.android\avd\<YourAvdFolder>\emulator_skin_1080x4000/(name is arbitrary; keep it alphanumeric + underscores). - Copy all PNG +
hardware.inifrom any stock SDK skin (for exampleplatforms/android-*/skins/WXGA720/) so background assets exist β the emulator parser expects referenced files. - Replace
layoutin that folder with a definition whosedevice.displayblock matches 1080Γ4000 and whose portrait canvas includes small margins (ββ―27β―px gutters mirror stock skins):
parts {
portrait {
background { image background_port.png }
}
landscape {
background { image background_land.png }
}
device {
display {
width 1080
height 4000
x 0
y 0
}
}
}
layouts {
portrait {
width 1134 # 1080 + 27*2 gutters
height 4054 # 4000 + 27*2 gutters
color 0xe0e0e0
event EV_SW:0:1
part1 { name portrait; x 0; y 0 }
part2 { name landscape; x 4000; y 0 } # parked off-screen; adjust if emulator complains
part3 { name device; x 27; y 27 }
}
landscape {
width 4055 # swapped-ish canvas β tune if rotating AVD heavily
height 1175
color 0xe0e0e0
event EV_SW:0:0
dpad-rotation 3
part1 { name portrait; x 4000; y 0 }
part2 { name landscape; x 0; y 0 }
part3 {
name device
x 26
y 26
rotation 3
}
}
}
keyboard { charmap qwerty2 }
network { speed full; delay none }
- Update
config.ini:
skin.dynamic=no
skin.name=emulator_skin_1080x4000
skin.path=C:\\Users\\<you>\\.android\\avd\\<YourAvdFolder>\\emulator_skin_1080x4000
Reboot the AVD after edits. Expect first boot warnings about unable to resume
snapshot after geometry/skin changes (default_boot) β cold boot once is OK.
0.5d. adb / emulator ergonomics reminders
| Issue | Typical fix |
|---|---|
Stuck transports / duplicate adb.exe orphans on Windows | taskkill /IM adb.exe /F /T then adb start-server (prefer one supervised helper than many shells) |
| Need to recycle guest without wiping data | adb emu kill, relaunch same AVD |
| Ensure bundler reachable from emulator | adb reverse tcp:<metroPort> tcp:<metroPort> per device attachment |
These apply equally to RN, Flutter, Compose, Kotlin XML, Cordova/Ionic shells, and Expo dev-client builds β only the URLs / ports change.
Phase 1: Bring the loop online
1a. Verify the emulator + adb
adb devices
# Expect: emulator-5554 device (transport_id ...)
If no device appears within the scriptβs polling window:
- Boot/repair the AVD (Phase 0.5 if emulator exits instantly or wrong
wm size). - Re-run
adb reverse tcp:<port> tcp:<port>after the serial showsdeviceβ reverse mappings do not magically backfill when a late emulator attaches.
If an automation script enumerated devices once at startup, it may skip
reverse + deeplink when the emulator was offline. Prefer polling or a
manual second step: reverse + intent once adb devices is non-empty.
1b. Confirm the bundler is alive (and reachable from the emulator)
METRO_PORT=8081
curl -sf "http://localhost:${METRO_PORT}/status"
# PASS: contains "packager-status:running" (Metro/Expo) or HTTP 200 with running flag
Other stacks:
- Capacitor / Vite: usually
5173/ dev server ping you choose (curl /). - Flutter: tool-specific (often aggregator on
localhost).
If dead, boot the bundler in the background, then poll aggressively (2β5s
backoff up to ~180s on cold cache wipes):
while ! curl -sf "http://127.0.0.1:${METRO_PORT}/status" | grep -q running; do sleep 4; done
Only after /status is green should you fire a dev-client deeplink /
reload β launching first produces logcat variants of Cannot connect /
ECONNREFUSED to 127.0.0.1:<port> even when Metro comes up seconds later.
When the emulator is attached:
adb reverse tcp:${METRO_PORT} tcp:${METRO_PORT}
Re-run whenever adb reconnect / adb kill-server / new emulator session clears
the tunnel.
1c. Launch the app (scheme-agnostic placeholders)
| Stack | Intent pattern |
|---|---|
| Expo dev client | Replace <SCHEME> with app.json/app.config.* scheme (often without literal exp+ unless using classic Expo Go tokens β follow the repoβs deeplink snippet). Typical shape: "<SCHEME>://expo-development-client/?url=http%3A%2F%2Flocalhost%3A<PORT>" |
| Bare RN Android | adb shell am start -W -n <applicationId>/.MainActivity (+ dev menu attach if configured) |
| Flutter | adb shell am start -W -n <bundleId>/<activity> or flutter run |
| Capacitor / WebView shells | -n <appId>/.MainActivity plus ensure dev server URL reachable (reverse or LAN IP in config) |
Example (substitute placeholders):
adb shell am start -W -a android.intent.action.VIEW \
-d "<SCHEME>://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
If am start reports the intent delivered to an already-running activity,
that is OK β Metro may still hot-reload afterward.
After launch, wait ~12-15s for the JS bundle (longer cold start), then
screencap to disk.
1d. Health-check the first paint
The first screen MUST be:
- Not pure white (= Metro died OR JS threw before mount)
- Not the dev launcher's "Error loading app Β· unexpected end of stream"
(= bundler unreachable from device β fix
adb reverse, wrong port, or dev client opened beforecurl /statusshowed running β see Phase1b) - Not the dev launcher's "last time you tried to open β¦ crashed" warning left as the only content (= a recent change crashed before mount)
If any of those: collect logcat, fix, relaunch β do not start walking on a half-broken app.
# Tail JS errors only
adb logcat -d ReactNativeJS:V '*:S' | tail -50
# Or for Capacitor / Cordova:
adb logcat -d Capacitor:V SystemWebChromeClient:V '*:S' | tail -50
Phase 1.5: Build-freshness verification (mandatory)
If you skip this phase you WILL waste a debugging session on a stale build. The user's report of "nothing changed" is almost never the patch failing β it is the device running yesterday's bytecode.
The fundamental risk: three separate caches can each serve stale code to the device. They must each be invalidated explicitly, in order, before trusting any walkthrough finding.
1.5a. Source freshness β does the file on disk reflect the patch?
# Confirm the change you "just made" is actually saved
git diff --stat HEAD -- <changed_file>
# Or grep for a unique string from the patch
grep -n "<unique_string_from_patch>" <changed_file>
If the file shows no change, the editor lost the buffer or the StrReplace silently no-op'd. Re-apply the patch before going further.
1.5b. Bundler freshness β is Metro/Vite serving the new source?
For Metro:
# Hit the bundle URL the device uses; the body should contain the unique
# string from the patch. Use the device's bundle URL, NOT just /status.
curl -s "http://localhost:8081/index.bundle?platform=android&dev=true" \
| grep -c "<unique_string_from_patch>"
# Expect: β₯1
If the count is 0, Metro served a cached bundle. Restart Metro with a hard cache wipe:
# Kill the Metro process holding port 8081
lsof -i :8081 -t | xargs -r kill -9 # macOS / Linux
# Windows / Git-Bash:
netstat -ano | grep :8081 | awk '{print $5}' | sort -u | xargs -r -I{} taskkill //F //PID {}
# Restart with cache wipe (Expo example; adapt for bare RN / Capacitor)
( cd apps/<mobile-app> && npx expo start --clear --port 8081 ) &
Wait for Metro to print Bundling complete then re-curl the bundle to
confirm the unique string is now present.
1.5c. Device freshness β is the device running the new bundle?
The dev launcher caches the bundle URL between sessions. Even with fresh Metro, the device may still hold the old JS in memory. Force a reload:
# Method 1: Metro reload endpoint (best β works without device interaction)
curl -s -X POST http://localhost:8081/reload && echo "reload triggered"
# Method 2: Send the React Native dev-menu R+R shortcut
adb shell input keyevent 46 # send 'R' once
sleep 0.1
adb shell input keyevent 46 # send 'R' twice β reload
# Method 3: Force-stop the app and re-launch via deep link
adb shell am force-stop <app.id>
adb shell am start -W -a android.intent.action.VIEW \
-d "exp+<scheme>://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
# Method 4 (nuclear): Wipe app data, including persisted query cache
adb shell pm clear <app.id>
# Then relaunch β note this also signs the user out
After reload, wait ~10s and screencap. The unique change you patched
should be visible.
1.5d. Native-code freshness β for changes that touch android/
If the patch modified anything under android/, ios/, or any native
module (react-native-* with autolinked native code), Metro reload is
not enough. The Hermes bytecode and the APK both need rebuilding:
# Bare RN
( cd android && ./gradlew installDebug )
adb shell am force-stop <app.id>
adb shell am start -n <app.id>/.MainActivity
# Expo dev client
npx expo run:android --variant debug --no-install # build only
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am force-stop <app.id>
adb shell monkey -p <app.id> 1
1.5e. Verify-by-evidence
The build is "fresh" only when ALL of the following are true:
- β
Source file on disk contains the patched string (
git diffshows it) - β Bundle served by Metro contains the patched string (curl + grep β₯1)
- β
A unique on-screen marker from the patch is visible in
screencap - β
Logcat shows the new bundle hash in the
Running applicationline
If any of those fails, do not start Phase 2 β the walk will report stale behaviour and waste cycles.
1.5f. Stale-build smell tests
Run these whenever the user says "nothing changed" or a fix appears not to have landed:
| Smell | Likely cause | Fix |
|---|---|---|
| Same screen pixels after patch | Metro served cached bundle | 1.5b restart with --clear |
| Patch visible in some sessions only | Two Metro instances on different ports | lsof -i :8081 -i :19000 -i :19001, kill all, restart one |
| Hot reload silently broken | File-watcher hit OS handle limit | Restart Metro; on Linux sysctl fs.inotify.max_user_watches=524288 |
| New Sentry capture has old source-map line numbers | Sentry source-maps not re-uploaded for the dev build | Acceptable for dev; flag for release builds |
| Native module change doesn't take effect | APK still old | 1.5d full Gradle build |
pm clear didn't fix it either | Wrong app.id (release vs debug variant) | adb shell pm list packages | grep <project> |
Phase 2: Tap-driven walk of every tab
Further reading
What ships with it: 1 file
20.8 KB alongside SKILL.md
references/
- details.md20.8 KB