--- name: app-tester description: > Build and maintain a navigable graph of any iOS, macOS, or Android app's screens. Reads the project's navigation source to understand flows, instruments screen files with structured print logs and accessibility identifiers, then drives flows end-to-end using console logs and the accessibility tree — without requiring screenshots. Use when: (1) user says "test this", "test it", "test [feature/screen/flow]", "test the [flow] flow", "test all flows", "instrument screens", "update the flow graph", "rebuild the graph", "run flow tests", or "check that [feature] works end to end", (2) after adding or modifying screens or navigation logic, (3) when debugging a broken navigation flow. Works on iOS simulator, macOS, and Android emulator/device. For Flutter Android apps: uses ADB + uiautomator for UI inspection, flutter run + SIGUSR1 for hot reload. For Expo / React Native apps: uses AXe on iOS and ADB + uiautomator on Android for UI inspection, with `console.log` + Metro logs for confirmation and `testID` / `accessibilityLabel` for instrumentation. --- # 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:** 1. Read the project's navigation source to build `.tester/app-graph.yaml` and `.tester/flows/*.yaml` at the project root. 2. Instrument screen files with structured `print` logs and accessibility identifiers. 3. Drive flows by tapping elements and confirming transitions via console logs. 4. 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: `axe` for accessibility tree + taps, `xcrun simctl` for 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-tools` is on PATH. Flutter apps: `~/fvm/versions/stable/bin/flutter` (or `flutter` on PATH). --- ## Sensitive Credentials (.env) Some flows require authentication. Store credentials in `.env` at the project root (add to `.gitignore`): ```bash 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//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 `Screen` or `Route` enum with all cases - **Coordinators**: `navigate(to:)` calls covering all destinations - **UIKit**: Router with `push`/`present` calls - **Flutter/GoRouter**: Files with `GoRoute` definitions or `context.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/.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`. ```swift 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 ```swift .onAppear { print("[AppName] [Feature] ScreenName appeared") // existing code } ``` ### 2.4 Action tap logs ```swift 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`: ```dart @override void initState() { super.initState(); print('[AppName] [Feature] ScreenName appeared'); } ``` **Button tap logs** — add before navigation calls: ```dart GestureDetector( onTap: () { print('[AppName] [Feature] primaryButton tapped'); context.go('/next-screen'); }, child: ..., ) ``` **Semantic labels** for UI identification (used by `android_screen_mapper.py --find`): ```dart Semantics( label: 'sign_in_google_button', child: GestureDetector(onTap: ..., child: ...), ) ``` > Flutter's content-desc in ADB uiautomator dump aggregates all Semantics labels in the widget tree. If a widget has no explicit `Semantics`, its visible text and child descriptions are used automatically. ### 2.6 Expo / React Native instrumentation (iOS + Android) React Native apps use `console.log()` for log confirmation and `testID` + `accessibilityLabel` props for UI identification. The same instrumentation works on both iOS and Android — `testID` becomes `accessibilityIdentifier` on iOS and `resource-id` (last segment) / `content-desc` on Android. **Screen appearance logs** — add a `useEffect` in each screen component: ```tsx import { useEffect } from 'react'; export default function HomeScreen() { useEffect(() => { console.log('[GameCu] [Home] HomeScreen appeared'); }, []); // ... } ``` **Screen root identifier** — wrap the outermost return value: ```tsx return ( {/* ... */} ); ``` > Always set **both** `testID` and `accessibilityLabel` to the same `snake_case` value — `testID` is iOS-only on some component variants, while Android's uiautomator reads `accessibilityLabel` via content-desc. Pairing them gives you one identifier that works across both platforms. **Action tap logs** — add inside the `onPress` handler: ```tsx { console.log('[GameCu] [Home] primaryAction tapped'); router.push('/listing/123'); }} > Continue ``` **Naming conventions** (mirror the iOS section): - Screens: `_screen` — e.g. `home_screen`, `login_screen`, `listing_detail_screen` - Buttons: `_button` — e.g. `submit_button`, `chat_now_button`, `buy_now_button` - Text inputs: `_input` — e.g. `phone_input`, `otp_input`, `price_input` - Tab items: `_tab` — e.g. `home_tab`, `search_tab` **Reading logs** — Metro/Expo writes `console.log` to its own console plus the device system log: - iOS Simulator: `xcrun simctl spawn booted log stream --predicate 'eventMessage CONTAINS "[GameCu]"'` - Android emulator: `adb logcat -s ReactNativeJS:V | grep '\[GameCu\]'` - Or directly from the Metro terminal where `npx expo start` is running. ### 2.7 Build to verify **iOS:** ```bash xcodebuild -scheme -destination 'platform=iOS Simulator,name=' build ``` **macOS:** ```bash xcodebuild -scheme -destination 'platform=macOS' build ``` **Android / Flutter:** ```bash # Hot reload if flutter run is already active (fastest) python3 ~/.claude/skills/app-tester/scripts/android.py hot-reload # Full rebuild + deploy export PATH="$PATH:$HOME/Library/Android/sdk/platform-tools" cd ~/fvm/versions/stable/bin/flutter run -d > /tmp/flutter_android.log 2>&1 & ``` **Expo / React Native:** Most code changes (screens, stores, components) hot-reload automatically through Metro — no manual rebuild needed. A native rebuild is only required when adding/removing native modules, changing `app.json` plugins, or modifying iOS/Android folders directly. ```bash # First-time iOS run — generates ios/ folder + builds + launches simulator cd npx expo run:ios > /tmp/expo_ios.log 2>&1 & # First-time Android run — generates android/ folder + builds + launches emulator npx expo run:android > /tmp/expo_android.log 2>&1 & # Subsequent JS-only iterations — just keep Metro running: npx expo start > /tmp/expo_metro.log 2>&1 & # Force a full rebuild after a native dep change: npx expo prebuild --clean && npx expo run:ios ``` > If `expo run:ios` fails with `xcrun simctl` errors, ensure `xcode-select -p` points to a full Xcode install (not just CLT). For SDK 52+, also confirm the `newArchEnabled: true` flag in `app.json` matches the device's RN architecture. --- ## Phase 3: Flow Testing (iOS) > **Requirements:** `axe` + `xcrun simctl` ### 3.1 Load the graph Read `.tester/app-graph.yaml` for screen data. Read flow files from `.tester/flows/` — target by name or all files with `enabled: true`. ### 3.2 Pre-grant permissions ```bash python3 ~/.claude/skills/app-tester/scripts/privacy_manager.py \ --bundle-id --grant camera,location,notifications ``` ### 3.2b Dismiss system prompts Run after every launch and every tap (no-op if no dialog): ```bash python3 ~/.claude/skills/app-tester/scripts/dismiss_prompts.py \ --policy "$SYSTEM_PROMPT_DISMISS" ``` --- ### 3.2c System Alerts Reference Different alert types require different strategies. Choose based on the alert's source process. #### General rule When any unexpected dialog blocks a flow step: 1. Run `axe describe-ui --udid ` and check if the dialog's buttons appear in the output. 2. **If visible** — it runs in-process. Tap the dismiss button by coordinate (see [D]). 3. **If not visible** — it runs in a separate OS process (e.g. `com.apple.AuthKitUIService`). AXe cannot interact with it; use credential injection to bypass the flow entirely (see [C]). Add recurring dismiss labels (e.g. `"Not Now"`, `"Ask App Not to Track"`) to `SYSTEM_PROMPT_DISMISS` in `.env` so `dismiss_prompts.py` handles them automatically on every launch and tap. #### Quick decision tree ``` System alert appeared? ├─ Permission dialog (location, camera, contacts, notifications)? │ → Pre-grant via simctl privacy before launch — see [A] │ → Or tap via idb if dialog still appears (visible in screen tree) ├─ ATT (App Tracking Transparency)? │ → Tap via idb — visible in screen tree — see [B] ├─ Sign In with Apple? │ → Cross-process — bypass via credential injection — see [C] └─ Unknown / other dialog? → Run idb ui describe-all — if visible, tap dismiss — see [D] → If not visible, it's cross-process — use credential injection — see [C] ``` --- #### [A] Permission dialogs — pre-grant before launch (preferred) Avoids the dialog entirely. Run before `app_launcher.py`: ```bash # Grant individual permissions xcrun simctl privacy booted grant location dev.example.app xcrun simctl privacy booted grant camera dev.example.app xcrun simctl privacy booted grant contacts dev.example.app xcrun simctl privacy booted grant photos dev.example.app xcrun simctl privacy booted grant microphone dev.example.app # Or revoke to reset state xcrun simctl privacy booted revoke location dev.example.app ``` Use `privacy_manager.py` to grant multiple at once from `TEST_PERMISSIONS`: ```bash python3 ~/.claude/skills/app-tester/scripts/privacy_manager.py \ --bundle-id dev.example.app --grant camera,location,notifications ``` If a permission dialog still appears at runtime, find and tap its button via AXe (these run in-process): ```bash axe describe-ui --udid | python3 -c " import json, sys data = json.load(sys.stdin) nodes = data if isinstance(data, list) else [data] def walk(n): if n.get('AXLabel','') in ('Allow','Allow Once',\"Don't Allow\",'OK'): print(n['AXLabel'], n.get('frame')) for c in n.get('children', []): walk(c) for n in nodes: walk(n) " # Then tap the button's center coordinate: axe tap -x -y --udid ``` --- #### [B] ATT (App Tracking Transparency) — tap via AXe ATT runs **in-process** — `axe describe-ui` can see it. It will appear as 4–5 nodes with no `AXUniqueId`. Detection and tap pattern: ```bash # Tap the ATT dismiss button directly by label axe tap --label "Ask App Not to Track" --udid ``` Or add `"Ask App Not to Track"` to `SYSTEM_PROMPT_DISMISS` in `.env` — `dismiss_prompts.py` handles it automatically. --- #### [C] Sign In with Apple — cross-process, use credential injection Sign In with Apple runs in **`com.apple.AuthKitUIService`** — a separate OS process. `idb ui describe-all` cannot see its elements. Coordinate tapping returns `ASAuthorizationError error 1000` on simulator. **Solution: bypass the Apple sheet entirely — inject an email/password session via launch arguments** Create a dedicated test account in your auth backend (email + password), then intercept app launch in a `#if DEBUG` guard before the normal auth flow runs. **Step 1 — Create a test account (one-time)** Use your auth backend's signup API to create a machine account (e.g. `test-automation@yourapp.dev`). Do not use a real Apple ID or production account. **Step 2 — Instrument the app** Find the earliest point in the app's auth initialization that runs before any auth check. Inject a login using the backend's email/password method: ```swift // In your auth service's initialize() / setup() method — before any auth state checks #if DEBUG if ProcessInfo.processInfo.arguments.contains("-UITestInjectSession"), let email = ProcessInfo.processInfo.environment["TEST_EMAIL"], let password = ProcessInfo.processInfo.environment["TEST_PASSWORD"] { do { // Replace with your auth backend's email+password sign-in call: // Firebase: Auth.auth().signIn(withEmail: email, password: password) // Supabase: client.auth.signIn(email: email, password: password) // Custom JWT: authService.signIn(email: email, password: password) let result = try await yourAuthBackend.signIn(email: email, password: password) // ⚠️ Do NOT rely on currentUser/currentSession properties immediately after sign-in. // Some SDKs (e.g. Supabase Swift) do not synchronously populate them. // Extract the user ID from the returned result object directly: let userId = result.user.id // or result.uid, result.accessToken, etc. print("[Auth] test sign-in succeeded — userId=\(userId)") // Run your normal post-auth setup with the known userId, then return early try await setupAuthenticatedSession(userId: userId) return } catch { print("[Auth] test sign-in FAILED: \(error)") // Fall through to normal (unauthenticated) initialization } } #endif ``` > **SDK gotcha — `currentUser` nil after `signIn()`**: Some auth SDKs (notably Supabase Swift) do not synchronously update `currentUser` / `currentSession` after a `signIn()` call returns. Always read the user ID from the `Session`/`AuthResult` object that `signIn()` returns, not from a separate `currentUser` property access right after. **Step 3 — Launch with credentials** `SIMCTL_CHILD_` env vars are forwarded by simctl to the launched process: ```bash SIMCTL_CHILD_TEST_EMAIL="test-automation@yourapp.dev" \ SIMCTL_CHILD_TEST_PASSWORD="YourTestPass123!" \ xcrun simctl launch --console-pty booted com.example.app \ -UITestInjectSession ``` Store in `.env` (add to `.gitignore`): ```bash TEST_EMAIL=test-automation@yourapp.dev TEST_PASSWORD=YourTestPass123! ``` **Step 4 — Confirm injection worked** Check console output for your success log line (e.g. `[Auth] test sign-in succeeded`) and that the app reaches an authenticated screen rather than the login screen. --- #### [D] Unknown in-process dialog — general dismiss pattern Use this when an unexpected dialog blocks a flow step and `idb ui describe-all` shows it in the accessibility tree. **Step 1 — Identify the dismiss button:** ```bash axe describe-ui --udid # Review the JSON to find the button label ``` **Step 2 — Tap the dismiss button by label:** ```bash axe tap --label "