App Tester
Tests iOS, macOS, and Android app navigation flows without relying on screenshots. Works on SwiftUI, UIKit, Flutter, and Expo / React Native projects.
Strategy:
- Read the project's navigation source to build
.tester/app-graph.yamland.tester/flows/*.yamlat the project root. - Instrument screen files with structured
printlogs and accessibility identifiers. - Drive flows by tapping elements and confirming transitions via console logs.
- Take screenshots only when a step fails.
Bundled scripts:
~/.claude/skills/app-tester/scripts/
iOS:
ios.py — unified entry point: tap, swipe, screenshot, logs
app_launcher.py — launch/terminate via xcrun simctl
screen_mapper.py — read accessibility tree via idb
navigator.py — tap/interact via idb (used by ios.py)
log_monitor.py — stream simulator logs
privacy_manager.py — pre-grant permissions
dismiss_prompts.py — dismiss system dialogs
common/ — shared idb/simctl utils
macOS:
macos_launcher.py — launch/terminate macOS .app bundles
macos_screen_mapper.py — read window/toolbar elements via System Events
macos_navigator.py — click toolbar/window elements via System Events
macos_log_monitor.py — stream app logs via `log stream`
Android:
android.py — unified entry point: tap, swipe, screenshot, logs, hot-reload
android_launcher.py — launch/terminate/install via ADB; flutter run management
android_screen_mapper.py — read UI tree via adb uiautomator dump
android_log_monitor.py — stream adb logcat
Requirements:
- iOS:
axefor accessibility tree + taps,xcrun simctlfor launch + logs. Install:brew tap cameroncooke/axe && brew install axe - macOS:
osascript(built-in) for accessibility,log(built-in) for logs. No extra deps. - Android:
adb(Android SDK platform-tools). Ensure$HOME/Library/Android/sdk/platform-toolsis on PATH. Flutter apps:~/fvm/versions/stable/bin/flutter(orflutteron PATH).
Sensitive Credentials (.env)
Some flows require authentication. Store credentials in .env at the project root (add to .gitignore):
TEST_USERNAME=your@email.com
TEST_PASSWORD=yourpassword
TEST_PERMISSIONS=camera,location,notifications # iOS only
SYSTEM_PROMPT_DISMISS=Ask App Not to Track,Don't Allow,Allow Once,Not Now,Dismiss,OK,Allow
Load before testing: export $(grep -v '^#' .env | xargs)
Step 0: Project Setup
Identify these values before any phase:
iOS / macOS (SwiftUI/UIKit):
| Value | How to find it |
|---|---|
| Platform | SUPPORTED_PLATFORMS in build settings — macosx = macOS, iphonesimulator = iOS |
| Bundle ID | PRODUCT_BUNDLE_IDENTIFIER in build settings or Info.plist |
| App name | CFBundleDisplayName / CFBundleName in Info.plist or the Xcode scheme name |
| Screen files | Directory containing *View.swift or *ViewController.swift |
| Navigation source | File with Screen/Route enum or coordinator |
| Log prefix | [AppName] — used in all instrumentation |
Android / Flutter:
| Value | How to find it |
|---|---|
| Platform | Presence of android/ directory; Flutter = pubspec.yaml present |
| Package ID | applicationId in android/app/build.gradle or build.gradle.kts |
| App name | CFBundleDisplayName in iOS Info.plist or android:label in AndroidManifest.xml |
| Screen files | Flutter: lib/features/*/screens/ or lib/screens/ — *Screen.dart or *Page.dart |
| Navigation source | Flutter: GoRouter config file, or files with GoRoute/Navigator.push calls |
| Device serial | adb devices — use emulator serial (e.g. emulator-5554) |
| Log prefix | [AppName] — used in print() calls throughout Flutter code |
Expo / React Native (iOS + Android):
Identify by package.json containing expo or react-native dependency. Expo Router projects also have an app/ directory with file-based routes.
| Value | How to find it |
|---|---|
| Platform | package.json has expo → Expo. react-native only → bare RN. Both iOS and Android are typically supported |
| Bundle ID (iOS) | app.json → expo.ios.bundleIdentifier, or ios/<App>/Info.plist for prebuild projects |
| Package ID (Android) | app.json → expo.android.package, or android/app/build.gradle applicationId |
| App name | app.json → expo.name, or app.config.{js,ts} |
| Router type | app/ directory exists → Expo Router (file-based). Otherwise look for @react-navigation/* config |
| Screen files | Expo Router: app/**/*.tsx (route files) and src/features/*/screens/*.tsx (feature screens). Bare RN: src/screens/, screens/ |
| Navigation source | Expo Router: the app/ tree IS the route graph (each .tsx file = a route). Bare RN: the NavigationContainer + Stack.Navigator config file |
| Log prefix | [AppName] — used in console.log() calls (visible in Metro / npx expo start console and via xcrun simctl spawn booted log stream on iOS / adb logcat on Android) |
Phase 1: App Discovery
Run when .tester/app-graph.yaml does not exist, the navigation source has changed, or the user says "rebuild the graph".
1.1 Read the navigation source
Find files defining all screens/routes:
- NavigationStack / FlowStacks: A
ScreenorRouteenum with all cases - Coordinators:
navigate(to:)calls covering all destinations - UIKit: Router with
push/presentcalls - Flutter/GoRouter: Files with
GoRoutedefinitions orcontext.go()/context.push()calls - Flutter/Navigator:
Navigator.push()/Navigator.pushNamed()call sites
1.2 Read every screen file
For each screen extract:
- Outgoing navigation calls (
push,present,NavigationLink, etc.) — these are edges .onAppear/viewDidAppear— where appearance logs go- Primary action closures — where tap logs go
1.3 Determine feature groupings
Group screens by directory structure, naming conventions, or functional area.
1.4 Write the graph
Create .tester/app-graph.yaml at the project root (screens + metadata). For each named flow, create a separate .tester/flows/<flow-id>.yaml file using the kebab-case flow name (e.g. create-game.yaml, edit-profile.yaml). See Graph Schema below.
Phase 2: Instrumentation
2.1 Accessibility IDs — screen roots
Add .accessibilityIdentifier("snake_case_screen") to the outermost container of each screen's body.
var body: some View {
VStack { ... }
.accessibilityIdentifier("game_list_screen")
.onAppear { viewModel.load() }
}
2.2 Accessibility IDs — action elements
Tag primary navigation triggers:
primary_action_button,secondary_action_button,cancel_button- Named per feature:
create_game_button,invite_button, etc.
2.3 Screen appearance logs
.onAppear {
print("[AppName] [Feature] ScreenName appeared")
// existing code
}
2.4 Action tap logs
Button("Create Game") {
print("[AppName] [Feature] createGame tapped")
navigator.show(screen: .createGame(group))
}
2.5 Flutter instrumentation (Android)
Flutter apps use print() for log confirmation and Semantics widgets for UI identification.
Screen appearance logs — add to each screen's initState or build:
@override
void initState() {
super.initState();
print('[AppName] [Feature] ScreenName appeared');
}
Button tap logs — add before navigation calls:
GestureDetector(
onTap: () {
print('[AppName] [Feature] primaryButton tapped');
context.go('/next-screen');
},
child: ...,
)
Semantic labels for UI identification (used by android_screen_mapper.py --find):
Semantics(
label: 'sign_in_google_button',
child: GestureDetector(onTap: ..., child: ...),
)