Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi

This commit is contained in:
Kunthawat Greethong
2026-05-25 16:38:02 +07:00
commit 69f7d8bdda
1689 changed files with 342427 additions and 0 deletions

View File

@@ -0,0 +1,269 @@
# QA Automation Skills
A comprehensive, reusable QA automation skill package for AI coding agents. Tests native mobile apps (iOS/Android) and web applications using a **dual-driver architecture**: **agent-device** for native simulator control + **CDP** for React Native runtime inspection, and **agent-browser** for web app testing.
## Quick Start
### 1. Install Dependencies
```bash
bash install.sh
```
This checks and installs: `agent-device`, `agent-browser`, `node`, `ws`, and verifies `xcrun` (iOS) and `adb` (Android).
### 2. Configure for Your App
Edit `qa.config.sh` or create `qa.config.local.sh`:
```bash
# Required — your app's identifiers
export APP_BUNDLE_ID="com.yourapp.dev"
export PROJECT_DIR="/path/to/your/project"
# Navigation screens (for CDP)
export SCREEN_EXPLORE="FeedScreen"
export SCREEN_SEARCH="SearchScreen"
export SCREEN_PROFILE="ProfileScreen"
export SCREEN_SETTINGS="SettingsScreen"
# Web app (for agent-browser tests)
export WEB_BASE_URL="http://localhost:3000"
```
### 3. Run a Test
```bash
# Native app — scroll test
bash qa-scroll/run.sh
# Native app — state persistence test
bash qa-state-persistence/run.sh
# Web app test
bash qa-web/run.sh
# All test flows
bash qa-test-flows/run-all.sh
```
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ QA Automation Skills │
├────────────────────────────┬────────────────────────────────────┤
│ Native App Testing │ Web App Testing │
│ │ │
│ ┌────────────────────┐ │ ┌────────────────────┐ │
│ │ agent-device │ │ │ agent-browser │ │
│ │ • Screenshots │ │ │ • Screenshots │ │
│ │ • Tap / Swipe │ │ │ • Click / Fill │ │
│ │ • A11y Snapshots │ │ │ • A11y Snapshots │ │
│ │ • App Lifecycle │ │ │ • Navigation │ │
│ └─────────┬──────────┘ │ └─────────┬──────────┘ │
│ │ │ │ │
│ ┌─────────▼──────────┐ │ ┌─────────▼──────────┐ │
│ │ CDP (Hermes) │ │ │ Browser DOM │ │
│ │ • JS Evaluation │ │ │ • JS Evaluation │ │
│ │ • Navigation │ │ │ • State Check │ │
│ │ • State Query │ │ │ • Cookie/Storage │ │
│ │ • Debug Hooks │ │ │ • Network │ │
│ └────────────────────┘ │ └────────────────────┘ │
└────────────────────────────┴────────────────────────────────────┘
```
## Skills Overview
| Skill | Purpose | Tools Used |
|-------|---------|------------|
| **qa-setup** | Verify/install all dependencies | npm, node |
| **qa-device-management** | Boot simulators, launch apps, manage sessions | agent-device, xcrun, adb |
| **qa-test-flows** | Core test framework + example flows | agent-device, CDP |
| **qa-scroll** | Scroll-based media feed testing (autoplay, mute, progress) | agent-device, CDP |
| **qa-state-persistence** | UI state persistence across navigation | agent-device, CDP |
| **qa-web** | Web application testing (forms, navigation, responsive) | agent-browser |
## Package Structure
```
qa-automation/
├── README.md ← You are here
├── install.sh ← Dependency checker & installer
├── qa.config.sh ← Central configuration
├── qa-setup/
│ └── SKILL.md ← Setup verification skill
├── qa-device-management/
│ ├── SKILL.md ← Simulator/emulator management
│ └── COORDINATE-MAP.md ← Template for tap target coordinates
├── qa-test-flows/
│ ├── SKILL.md ← Test framework documentation
│ ├── lib/
│ │ ├── test-helpers.sh ← Core: lifecycle, logging, assertions
│ │ └── cdp-helpers.sh ← Core: CDP eval, navigation, state
│ ├── flows/
│ │ └── smoke/
│ │ └── example-smoke.sh ← Example smoke test
│ ├── templates/
│ │ └── new-flow.sh.template ← Template for new tests
│ └── run-all.sh ← Master test runner
├── qa-scroll/
│ ├── SKILL.md ← Scroll/media test documentation
│ ├── lib/
│ │ ├── setup-guard.sh ← Prerequisites auto-checker
│ │ └── scroll-helpers.sh ← Video state, mute, feed scroll
│ ├── flows/
│ │ └── example-scroll-test.sh ← Example scroll test
│ └── run.sh ← Scroll test runner
├── qa-state-persistence/
│ ├── SKILL.md ← State persistence documentation
│ ├── lib/
│ │ └── state-helpers.sh ← State query, mutation, assertions
│ ├── flows/
│ │ └── example-state-test.sh ← Example state test
│ └── run.sh ← State test runner
└── qa-web/
├── SKILL.md ← Web testing documentation
├── lib/
│ └── web-helpers.sh ← Web: open, click, fill, assert
├── flows/
│ └── example-web-test.sh ← Example web test
└── run.sh ← Web test runner
```
## Configuration Reference
All configuration lives in `qa.config.sh`. Override any variable by exporting it before sourcing, or create `qa.config.local.sh` (automatically loaded, gitignored).
### App Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `APP_BUNDLE_ID` | `com.example.app.dev` | Bundle/package ID for dev builds |
| `APP_BUNDLE_ID_PROD` | `com.example.app` | Bundle/package ID for production |
| `PROJECT_DIR` | `$(pwd)` | Project root (where package.json is) |
### iOS Simulator
| Variable | Default | Description |
|----------|---------|-------------|
| `SIMULATOR_UDID` | `auto` | `"auto"` to detect, or specific UDID |
| `SIMULATOR_DEVICE_NAME` | `iPhone 16 Pro` | Device name for creating simulators |
### Android Emulator
| Variable | Default | Description |
|----------|---------|-------------|
| `ANDROID_AVD` | `Pixel_8` | AVD name |
| `ANDROID_SERIAL` | `emulator-5554` | Serial for ADB |
### Dev Server
| Variable | Default | Description |
|----------|---------|-------------|
| `METRO_PORT` | `8081` | Dev server port |
| `DEV_SERVER_CMD` | `npx expo start --port $METRO_PORT` | Command to start dev server |
| `DEV_SERVER_HEALTH` | `http://localhost:$METRO_PORT/status` | Health check endpoint |
### CDP
| Variable | Default | Description |
|----------|---------|-------------|
| `CDP_WS_URL` | `auto` | `"auto"` for auto-discovery, or explicit URL |
| `MODULE_SCAN_START` | `0` | Start of module ID scan range |
| `MODULE_SCAN_END` | `5000` | End of module ID scan range |
### Screen Names
| Variable | Default | Description |
|----------|---------|-------------|
| `SCREEN_HOME` | `HomeScreen` | Home screen name |
| `SCREEN_EXPLORE` | `ExploreScreen` | Explore/feed screen name |
| `SCREEN_SEARCH` | `SearchScreen` | Search screen name |
| `SCREEN_PROFILE` | `ProfileScreen` | Profile screen name |
| `SCREEN_SETTINGS` | `SettingsScreen` | Settings screen name |
| `TAB_NAVIGATOR_NAME` | `BottomTab` | Tab navigator component name |
### Video/Media
| Variable | Default | Description |
|----------|---------|-------------|
| `VIDEO_PLAYER_CLASS` | `VideoPlayer` | Video player class name to patch |
| `GLOBAL_PLAYERS_VAR` | `__qaVideoPlayers` | Global var for tracking players |
| `GLOBAL_FEED_VAR` | `__qaFeedState` | Global var for feed debug hook |
### State Persistence
| Variable | Default | Description |
|----------|---------|-------------|
| `STATE_PROPERTY` | `isLiked` | Property to test (e.g., isLiked, isBookmarked) |
| `STATE_COUNTER_PROPERTY` | `likesCount` | Associated counter property |
| `STATE_SCROLL_COUNT` | `5` | Items to scroll past before checking |
### Web Testing
| Variable | Default | Description |
|----------|---------|-------------|
| `WEB_BASE_URL` | `http://localhost:3000` | Web app URL |
| `WEB_SESSION` | `qa` | Browser session name |
| `WEB_VIEWPORT_WIDTH` | `1280` | Default viewport width |
| `WEB_VIEWPORT_HEIGHT` | `720` | Default viewport height |
## Setup Guard
The setup guard (`qa-scroll/lib/setup-guard.sh`) runs automatically before scroll and state tests. It checks 7 prerequisites in order and auto-fixes what it can:
1. ✅ iOS Simulator booted (boots one if needed)
2. ✅ Dev server running (starts it in background)
3. ✅ App in foreground (launches it)
4. ✅ CDP Hermes target available (polls with timeout)
5. ✅ CDP connection functional (eval 1+1)
6. ✅ Navigation module ID valid (auto-scans with caching)
7. ✅ Error overlay dismissed (suppresses LogBox)
## Creating Custom Tests
1. **Copy the template**: `cp qa-test-flows/templates/new-flow.sh.template qa-test-flows/flows/my-suite/my-test.sh`
2. **Edit**: Replace `CUSTOMIZE` markers with your app details
3. **Run**: `bash qa-test-flows/flows/my-suite/my-test.sh`
4. **Review**: Check screenshots in `/tmp/qa-tests/screenshots/`
For detailed patterns (form testing, auth flows, responsive testing), see the individual skill SKILL.md files.
## Exposing Debug Hooks (for React Native apps)
For the scroll and state tests to read runtime data via CDP, your app needs to expose debug hooks in dev builds:
```javascript
// In your feed component (e.g., ExploreFeed.tsx):
if (__DEV__) {
globalThis.__qaFeedState = {
currentIndex: currentIndex,
scrollToNext: () => flatListRef.current?.scrollToIndex({ index: currentIndex + 1 }),
scrollToIndex: (i) => flatListRef.current?.scrollToIndex({ index: i }),
getData: () => feedData,
getItem: (i) => feedData[i],
dataLength: feedData.length,
};
}
```
The video player debug hook is installed automatically via CDP — it patches `VideoPlayer.prototype.play()` to track instances. No app code changes needed for video state testing.
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `agent-device: not found` | Run `bash install.sh` or `npm install -g agent-device` |
| `agent-browser: not found` | Run `bash install.sh` or `npm install -g agent-browser` |
| Setup guard fails | Check the `[SETUP]` line that says FAILED for details |
| CDP timeout | Verify dev server: `curl http://localhost:$METRO_PORT/status` |
| No video players tracked | Ensure the video player library uses the class name in `VIDEO_PLAYER_CLASS` |
| State test inconclusive | Ensure your feed component exposes `__qaFeedState` debug hook |
| Web test can't access localhost | agent-browser runs locally — check your dev server is running |

273
skills/qa-automation/install.sh Executable file
View File

@@ -0,0 +1,273 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ install.sh — QA Automation Dependency Checker & Installer ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Checks for all required tools and installs any that are missing. ║
# ║ ║
# ║ Usage: ║
# ║ bash install.sh # Check + install missing ║
# ║ bash install.sh --check # Check only, don't install ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
CHECK_ONLY=false
if [ "${1:-}" = "--check" ]; then
CHECK_ONLY=true
fi
# ── Colors ───────────────────────────────────────────────────────────
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
PASS_COUNT=0
FAIL_COUNT=0
INSTALLED_COUNT=0
echo ""
echo -e "${BOLD}╔═══════════════════════════════════════════════════════════╗${RESET}"
echo -e "${BOLD}║ QA Automation — Dependency Check & Install ║${RESET}"
echo -e "${BOLD}╚═══════════════════════════════════════════════════════════╝${RESET}"
echo ""
# ── Helper Functions ─────────────────────────────────────────────────
check_ok() {
echo -e " ${GREEN}${RESET} $1"
PASS_COUNT=$((PASS_COUNT + 1))
}
check_fail() {
echo -e " ${RED}${RESET} $1"
FAIL_COUNT=$((FAIL_COUNT + 1))
}
check_warn() {
echo -e " ${YELLOW}⚠️${RESET} $1"
}
check_install() {
echo -e " ${CYAN}📦${RESET} $1"
INSTALLED_COUNT=$((INSTALLED_COUNT + 1))
}
section() {
echo ""
echo -e "${BOLD}── $1 ──${RESET}"
}
# ── 1. Node.js ───────────────────────────────────────────────────────
section "Runtime"
if command -v node >/dev/null 2>&1; then
NODE_VERSION=$(node --version 2>/dev/null || echo "unknown")
check_ok "Node.js ${NODE_VERSION}"
else
check_fail "Node.js — not found"
echo " Install: https://nodejs.org or 'brew install node'"
fi
if command -v npm >/dev/null 2>&1; then
NPM_VERSION=$(npm --version 2>/dev/null || echo "unknown")
check_ok "npm ${NPM_VERSION}"
else
check_fail "npm — not found (usually installed with Node.js)"
fi
if command -v npx >/dev/null 2>&1; then
check_ok "npx available"
else
check_fail "npx — not found (usually installed with Node.js)"
fi
# ── 2. agent-device ──────────────────────────────────────────────────
section "agent-device (Native App Testing)"
if command -v agent-device >/dev/null 2>&1; then
AD_VERSION=$(agent-device --version 2>/dev/null || echo "unknown")
check_ok "agent-device ${AD_VERSION}"
else
check_fail "agent-device — not installed"
if [ "$CHECK_ONLY" = false ]; then
echo -e " ${CYAN}Installing agent-device...${RESET}"
if npm install -g agent-device 2>/dev/null; then
check_install "agent-device installed successfully"
else
check_warn "Install failed. Try: npm install -g agent-device"
fi
else
echo " Install: npm install -g agent-device"
fi
fi
# ── 3. agent-browser ─────────────────────────────────────────────────
section "agent-browser (Web App Testing)"
if command -v agent-browser >/dev/null 2>&1; then
AB_VERSION=$(agent-browser --version 2>/dev/null || echo "unknown")
check_ok "agent-browser ${AB_VERSION}"
else
check_fail "agent-browser — not installed"
if [ "$CHECK_ONLY" = false ]; then
echo -e " ${CYAN}Installing agent-browser...${RESET}"
if npm install -g agent-browser 2>/dev/null; then
check_install "agent-browser installed successfully"
else
check_warn "Install failed. Try: npm install -g agent-browser"
fi
else
echo " Install: npm install -g agent-browser"
fi
fi
# ── 4. WebSocket library (for CDP) ───────────────────────────────────
section "CDP Dependencies"
# Check if 'ws' is available (needed for CDP WebSocket connections)
WS_AVAILABLE=false
if node -e "require('ws')" 2>/dev/null; then
WS_VERSION=$(node -e "console.log(require('ws/package.json').version)" 2>/dev/null || echo "unknown")
check_ok "ws (WebSocket) ${WS_VERSION}"
WS_AVAILABLE=true
else
check_fail "ws (WebSocket) — not installed"
if [ "$CHECK_ONLY" = false ]; then
echo -e " ${CYAN}Installing ws...${RESET}"
if npm install -g ws 2>/dev/null; then
check_install "ws installed globally"
WS_AVAILABLE=true
else
# Try installing locally in project
echo " Global install failed. Trying local install..."
if npm install ws 2>/dev/null; then
check_install "ws installed locally"
WS_AVAILABLE=true
else
check_warn "Install failed. Run: npm install ws"
fi
fi
else
echo " Install: npm install ws (in your project)"
fi
fi
# ── 5. iOS Development Tools ─────────────────────────────────────────
section "iOS (optional — for native iOS testing)"
if command -v xcrun >/dev/null 2>&1; then
XCODE_VERSION=$(xcodebuild -version 2>/dev/null | head -1 || echo "unknown")
check_ok "Xcode / xcrun ($XCODE_VERSION)"
# Check for simulators
SIM_COUNT=$(xcrun simctl list devices available 2>/dev/null | grep -c "iPhone\|iPad" || echo "0")
if [ "$SIM_COUNT" -gt 0 ]; then
check_ok "iOS Simulators available ($SIM_COUNT devices)"
else
check_warn "No iOS simulators found. Create one via Xcode → Window → Devices and Simulators"
fi
else
check_warn "Xcode / xcrun — not found (needed for iOS simulator testing)"
echo " Install Xcode from the Mac App Store"
fi
# ── 6. Android Development Tools ─────────────────────────────────────
section "Android (optional — for native Android testing)"
ADB_FOUND=false
if command -v adb >/dev/null 2>&1; then
ADB_VERSION=$(adb --version 2>/dev/null | head -1 || echo "unknown")
check_ok "adb ($ADB_VERSION)"
ADB_FOUND=true
elif [ -f "$HOME/Library/Android/sdk/platform-tools/adb" ]; then
check_ok "adb (found at ~/Library/Android/sdk/platform-tools/adb)"
ADB_FOUND=true
else
check_warn "adb — not found (needed for Android emulator testing)"
echo " Install Android Studio or: brew install android-platform-tools"
fi
if [ -f "$HOME/Library/Android/sdk/emulator/emulator" ]; then
check_ok "Android Emulator available"
else
check_warn "Android Emulator — not found at default SDK path"
fi
# ── 7. Shell Environment ─────────────────────────────────────────────
section "Shell"
BASH_VERSION_STR=$(bash --version | head -1 | grep -oE '[0-9]+\.[0-9]+' | head -1 || echo "unknown")
BASH_MAJOR=$(echo "$BASH_VERSION_STR" | cut -d. -f1)
if [ "$BASH_MAJOR" -ge 4 ] 2>/dev/null; then
check_ok "Bash ${BASH_VERSION_STR} (4.0+ required for arrays)"
else
check_warn "Bash ${BASH_VERSION_STR} — version 4.0+ recommended"
echo " Install: brew install bash"
fi
if command -v curl >/dev/null 2>&1; then
check_ok "curl available"
else
check_fail "curl — not found"
fi
if command -v jq >/dev/null 2>&1; then
check_ok "jq available (JSON processing)"
else
check_warn "jq — not found (optional, for JSON parsing)"
echo " Install: brew install jq"
fi
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""
TOTAL=$((PASS_COUNT + FAIL_COUNT))
echo -e " ${GREEN}Passed:${RESET} $PASS_COUNT / $TOTAL"
if [ $FAIL_COUNT -gt 0 ]; then
echo -e " ${RED}Failed:${RESET} $FAIL_COUNT"
fi
if [ $INSTALLED_COUNT -gt 0 ]; then
echo -e " ${CYAN}Installed:${RESET} $INSTALLED_COUNT"
fi
echo ""
if [ $FAIL_COUNT -eq 0 ]; then
echo -e " ${GREEN}${BOLD}All required dependencies are available! ✅${RESET}"
echo ""
echo " Next steps:"
echo " 1. Copy qa.config.sh and set your app-specific values"
echo " 2. Run: bash qa-scroll/run.sh (or any skill runner)"
echo ""
exit 0
else
CRITICAL_MISSING=""
if ! command -v agent-device >/dev/null 2>&1; then
CRITICAL_MISSING="$CRITICAL_MISSING agent-device"
fi
if ! command -v agent-browser >/dev/null 2>&1; then
CRITICAL_MISSING="$CRITICAL_MISSING agent-browser"
fi
if ! command -v node >/dev/null 2>&1; then
CRITICAL_MISSING="$CRITICAL_MISSING node"
fi
if [ -n "$CRITICAL_MISSING" ]; then
echo -e " ${RED}${BOLD}Missing critical dependencies:${RESET}${CRITICAL_MISSING}"
echo ""
echo " Install them and re-run: bash install.sh"
echo ""
exit 1
else
echo -e " ${YELLOW}Some optional tools are missing, but core functionality is available.${RESET}"
echo ""
exit 0
fi
fi

View File

@@ -0,0 +1,120 @@
# QA Device Coordinate Map — Template
## How to Use This File
This is a **template** for documenting your app's tap targets. Fill in the coordinates for your specific app by:
1. Take a screenshot: `agent-device screenshot /tmp/debug.png`
2. Open it in an image viewer
3. Measure the tap target position in **pixels**
4. Divide by the device scale factor to get **logical points**
5. Record the coordinates below
## Device Dimensions
### iPhone 16 Pro (default)
- Logical resolution: **402 × 874** points
- Pixel resolution: 1206 × 2622 (3x scale)
- Safe area top: ~59 points (Dynamic Island + status bar)
- Safe area bottom: ~34 points (home indicator)
### iPhone 15 / 16
- Logical resolution: **393 × 852** points
- Pixel resolution: 1179 × 2556 (3x scale)
### Pixel 8 / 9
- Logical resolution: **411 × 915** points
- Pixel resolution: 1080 × 2400 (2.625x scale)
### iPad Pro 11"
- Logical resolution: **834 × 1194** points
## Bottom Tab Bar
Most apps have a bottom tab bar. Measure the Y position and X position of each tab.
| Tab | Name | x | y | Notes |
|-----|------|---|---|-------|
| 1 | *(your tab)* | `___` | `___` | |
| 2 | *(your tab)* | `___` | `___` | |
| 3 | *(your tab)* | `___` | `___` | |
| 4 | *(your tab)* | `___` | `___` | |
| 5 | *(your tab)* | `___` | `___` | |
**Tip:** Tab bar Y is usually around 850-860 on iPhone Pro models.
## Common UI Elements
| Element | x | y | Notes |
|---------|---|---|-------|
| Back button (top-left) | `___` | `___` | Usually ~30, 60 |
| Settings/Menu (top-right) | `___` | `___` | Usually ~380, 60 |
| Screen center | `___` | `___` | ~200, 437 on iPhone Pro |
## Screen-Specific Elements
### Screen: *(your screen name)*
| Element | x | y | Notes |
|---------|---|---|-------|
| *(element)* | `___` | `___` | |
| *(element)* | `___` | `___` | |
### Screen: *(another screen)*
| Element | x | y | Notes |
|---------|---|---|-------|
| *(element)* | `___` | `___` | |
## Tips for Finding Coordinates
### Method 1: Screenshot + Image Viewer
```bash
agent-device screenshot /tmp/debug.png
open /tmp/debug.png # Opens in Preview on macOS
```
Use Preview's inspector to read pixel coordinates, then divide by scale factor.
### Method 2: Accessibility Snapshot
```bash
agent-device snapshot -i
```
Elements with tap targets show bounding boxes. Use the center of the box.
### Method 3: agent-device highlight
```bash
agent-device snapshot -i # Get refs like @e1, @e2
agent-device highlight @e1 # Highlights the element on screen
```
### Method 4: Trial and Error
```bash
agent-device click 200 400 # Try a tap
agent-device screenshot /tmp/after-tap.png # Check result
```
## Using Coordinates in Tests
Set coordinate overrides in `qa.config.sh` or `qa.config.local.sh`:
```bash
# Tab bar
export TAB_BAR_Y=855
export TAB_1_X=60
export TAB_2_X=170
export TAB_3_X=290
export TAB_4_X=400
export TAB_5_X=520
# Custom elements
export BACK_BUTTON_X=30
export BACK_BUTTON_Y=60
```
Then use them in test scripts:
```bash
source qa.config.sh
tap $TAB_1_X $TAB_BAR_Y # Tap first tab
tap $BACK_BUTTON_X $BACK_BUTTON_Y # Tap back
```

View File

@@ -0,0 +1,260 @@
---
name: qa-device-management
description: >
Boot and control iOS Simulators and Android Emulators for QA testing using agent-device CLI.
Manage sessions, capture screenshots, launch apps, and control devices across platforms.
Invoke when user says "bring up simulators", "boot the device", "take a screenshot",
"launch the app", "compare iOS and Android", "test both platforms", "visual parity check",
or any task requiring device/simulator lifecycle management.
allowed-tools: Bash(agent-device:*) Bash(xcrun:*) Bash(adb:*) Bash(open:*) Bash(find:*) Bash(npx:*) Read
---
# qa-device-management
Side-by-side iOS Simulator and Android Emulator control using the `agent-device` CLI. Manages the device lifecycle for all QA Automation skills.
## When to Use
- Bringing up simulators/emulators for testing
- Launching your app on one or both platforms
- Capturing baseline or comparison screenshots
- Checking device state, app state, or accessibility tree
- Managing agent-device sessions across platforms
## Configuration
All device settings are in `qa.config.sh`. Set these before using:
```bash
# In qa.config.sh or qa.config.local.sh:
export APP_BUNDLE_ID="com.yourapp.dev" # Your app's bundle/package ID
export SIMULATOR_UDID="auto" # "auto" or specific UDID
export SIMULATOR_DEVICE_NAME="iPhone 16 Pro" # For creating simulators
export ANDROID_AVD="Pixel_8" # Android AVD name
export ANDROID_SERIAL="emulator-5554" # Android serial
```
## Quick Start — Full Startup Sequence
### Boot iOS Simulator
```bash
# Auto-detect a booted simulator, or boot one
source .pi/skills/qa-automation/qa.config.sh
UDID=$(qa_detect_simulator_udid)
# Or boot a specific one
xcrun simctl boot "$SIMULATOR_UDID" 2>&1 || true
open -a Simulator
```
### Boot Android Emulator
```bash
nohup $EMULATOR_PATH -avd "$ANDROID_AVD" -no-snapshot-load > /tmp/qa-emu.log 2>&1 &
# Wait for boot
for i in $(seq 1 30); do
BOOT=$($ADB_PATH shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
if [ "$BOOT" = "1" ]; then
echo "Android emulator booted after ~$((i*2))s"
break
fi
sleep 2
done
```
### Verify Both Devices
```bash
agent-device devices --json
```
### Launch App on iOS
```bash
agent-device open "$APP_BUNDLE_ID" --platform ios --session default
```
### Launch App on Android
```bash
agent-device open "$APP_BUNDLE_ID" --platform android --serial "$ANDROID_SERIAL" --session android
```
### Capture Baseline Screenshots
```bash
agent-device screenshot /tmp/qa-ios-baseline.png --session default
agent-device screenshot /tmp/qa-android-baseline.png --session android
```
## Session Management
agent-device binds each session to one platform. Use the correct session flag:
| Platform | Session Flag | Example |
|----------|-------------|---------|
| iOS | `--session default` (or omit) | `agent-device snapshot --session default` |
| Android | `--session android` | `agent-device snapshot --session android` |
```bash
# List active sessions
agent-device session list
# Release a session
agent-device close --session default
```
## Common Operations
### Screenshots (Both Platforms)
```bash
agent-device screenshot /tmp/qa-ios.png --session default
agent-device screenshot /tmp/qa-android.png --session android
```
### Accessibility Snapshots
```bash
agent-device snapshot --session default # iOS
agent-device snapshot --session android # Android
# With options
agent-device snapshot -i --session default # Interactive elements only
agent-device snapshot --depth 3 # Limit depth
```
### Navigate and Interact
```bash
# Tap
agent-device click 200 400 --session default
# Swipe
agent-device swipe 200 800 200 200 --session default
# Scroll
agent-device scroll down --session default
# Type
agent-device fill @e3 "hello world" --session default
```
### Go Home / Go Back
```bash
agent-device home --session default
agent-device back --session android
```
### Check Foreground App
```bash
agent-device appstate --session default
agent-device appstate --session android
```
### Relaunch App (Fresh State)
```bash
agent-device open "$APP_BUNDLE_ID" --session default --relaunch
```
## Building and Installing Your App
### iOS — Expo
```bash
cd "$PROJECT_DIR"
npx expo run:ios --device "$SIMULATOR_DEVICE_NAME"
```
### iOS — React Native CLI
```bash
cd "$PROJECT_DIR"
npx react-native run-ios --simulator "$SIMULATOR_DEVICE_NAME"
```
### Android — Expo
```bash
cd "$PROJECT_DIR"
npx expo run:android
```
### Android — React Native CLI
```bash
cd "$PROJECT_DIR"
npx react-native run-android
```
### Check If App Is Installed
```bash
# iOS
xcrun simctl listapps booted 2>/dev/null | grep "$APP_BUNDLE_ID"
# Android
$ADB_PATH shell pm list packages | grep "$APP_BUNDLE_ID"
```
## Shutdown Sequence
```bash
# Close agent-device sessions
agent-device close --session default
agent-device close --session android
# Shutdown simulators
xcrun simctl shutdown "$SIMULATOR_UDID"
$ADB_PATH emu kill
```
## CDP Connection (for React Native apps)
When coordinate-based tapping fails (e.g., full-screen video players intercept touches), use CDP to control navigation directly via the React Native Hermes runtime.
### Prerequisites
- Dev server running (e.g., `npx expo start`)
- `ws` npm package available
### Discover CDP Endpoints
```bash
# List available debug targets
curl -s http://localhost:$METRO_PORT/json
# Get WebSocket URL (auto-detected by qa.config.sh)
source qa.config.sh
qa_detect_cdp_ws_url
```
### Navigate via CDP
```bash
source .pi/skills/qa-automation/qa-test-flows/lib/cdp-helpers.sh
# Navigate to a screen
cdp_navigate "SettingsScreen"
# Navigate to a tab
cdp_navigate_tab "ProfileScreen"
# Get current route
cdp_get_route
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "No device found" | Verify simulator is booted: `xcrun simctl list devices \| grep Booted` |
| Session bound to wrong platform | Use `--session android` for Android, close session and rebind |
| Simulator already booted (error 149) | Safe to ignore — simulator is already running |
| App not installed | Build and install: `npx expo run:ios` or `npx expo run:android` |
| agent-device click hangs | Wrap with timeout: `timeout 5 agent-device click 100 200` |
| CDP "Connection refused" | Ensure dev server is running: `curl http://localhost:$METRO_PORT/status` |

View File

@@ -0,0 +1,182 @@
---
name: qa-scroll
description: >
QA test skill for verifying scroll-based media feeds — video autoplay, scroll navigation,
mute/unmute, and playback progression. Uses the CDP + agent-device dual-driver architecture.
Works with any React Native app using expo-video or similar video player libraries.
Invoke when user says "test scroll feed", "test video playback", "QA the feed",
"verify video autoplay", "run scroll tests", "test media player", or any task
requiring scroll-based media feed verification.
allowed-tools: Bash(agent-device:*) Bash(agent-browser:*) Bash(xcrun:*) Bash(node:*) Bash(curl:*) Bash(npx:*) Read
---
# qa-scroll
QA test skill for verifying scroll-based media feeds — **video autoplay**, **scroll navigation**, **mute/unmute toggle**, and **playback progression**. Uses the dual-driver architecture (CDP + agent-device) to test media feeds in native mobile apps.
## What It Tests
| Test | Method | Assertion |
|------|--------|-----------|
| First video autoplays | CDP player state query | `player.playing === true`, `currentTime > 0` |
| Video progress advances | CDP currentTime check after delay | `currentTime` increased between checks |
| Scroll to next video | CDP scroll hook or agent-device swipe | New video starts playing |
| Mute toggle | CDP `player.muted = !player.muted` | Mute state flips |
| Unmute toggle | CDP `player.muted = !player.muted` | Mute state flips back |
| Scroll continuity | Multiple swipes | Each new video autoplays |
| Final route check | CDP `cdp_get_route` | Still on the feed screen |
## Setup Guard (Automatic)
The skill includes a **setup guard** that runs before every test. It checks and auto-fixes:
| Check | What it does if missing |
|-------|------------------------|
| iOS Simulator booted | Boots it or auto-detects a booted one |
| Dev server running | Starts it in background, waits up to 60s |
| App in foreground | Launches via `xcrun simctl launch` |
| CDP target available | Polls `/json` endpoint for up to 30s |
| CDP connection functional | Sends `eval 1+1`, retries 3x |
| Navigation module ID valid | Auto-scans Metro modules with caching |
| Error overlay | Suppresses LogBox errors via CDP |
## Configuration
Before running, set your app-specific values in `qa.config.sh` or `qa.config.local.sh`:
```bash
# Required
export APP_BUNDLE_ID="com.yourapp.dev"
export PROJECT_DIR="/path/to/your/project"
# Video player (for expo-video apps)
export VIDEO_PLAYER_CLASS="VideoPlayer" # Class to look for
export GLOBAL_PLAYERS_VAR="__qaVideoPlayers" # Global tracking variable
# Screen names (for CDP navigation)
export SCREEN_EXPLORE="ExploreScreen" # Your feed screen name
# Optional: if your app has a feed scroll hook
export GLOBAL_FEED_VAR="__qaFeedState" # Feed state debug hook name
```
## Test Result States
| Status | Meaning |
|--------|---------|
| **passed** | Assertion verified via CDP |
| **failed** | Assertion verified but wrong |
| **skipped** | CDP query inconclusive — cannot determine pass/fail |
## Architecture
```
CDP (Hermes Runtime) agent-device (Simulator)
┌──────────────────────┐ ┌──────────────────────┐
│ navigate to tab │ │ swipe up (scroll) │
│ install debug hook │ │ tap center (fallback) │
│ query player state │ │ screenshot capture │
│ .playing │ │ appstate check │
│ .muted │ │ │
│ .currentTime │ │ │
│ toggle mute via CDP │ │ │
│ dismiss error overlay│ │ │
└──────────────────────┘ └──────────────────────┘
```
### Why CDP, not agent-browser?
`agent-browser` uses Playwright's CDP protocol which sends `Target.setDiscoverTargets` — a method Hermes doesn't support. Native apps don't have a DOM to interact with. Raw CDP via WebSocket to Hermes is the correct approach for JS runtime queries in React Native apps.
### Why agent-device, not agent-browser?
`agent-device` is purpose-built for iOS/Android simulator control — screenshots, swipe gestures, accessibility snapshots. `agent-browser` is for web pages. Native apps render native views, not web views.
## Usage
### Run the example test
```bash
bash .pi/skills/qa-automation/qa-scroll/run.sh
```
### Run a specific flow
```bash
bash .pi/skills/qa-automation/qa-scroll/flows/example-scroll-test.sh
```
### View results
- Screenshots: `/tmp/qa-tests/screenshots/<test-name>/`
- Report JSON: `/tmp/qa-tests/<test-name>-report.json`
## Customizing for Your App
### Step 1: Configure video player detection
The debug hook patches `VideoPlayer.prototype.play()` to track instances. If your app uses a different video player:
```bash
# In qa.config.sh:
export VIDEO_PLAYER_CLASS="MyVideoPlayer" # Your player class name
```
The hook scans Metro modules looking for `module.default.VideoPlayer` or `module.VideoPlayer`. Adjust the scan in `scroll-helpers.sh` if your player is exported differently.
### Step 2: Configure feed scrolling
If your app exposes a scroll-to-next function via a debug hook:
```bash
# In your app code (dev builds only):
globalThis.__qaFeedState = {
currentIndex: 0,
scrollToNext: () => { /* scroll logic */ },
scrollToIndex: (i) => { /* scroll to index */ },
getData: () => { /* return feed data array */ },
getItem: (i) => { /* return item at index */ }
};
```
If no hook is available, the skill falls back to `agent-device swipe` gestures.
### Step 3: Create your test flow
Copy `flows/example-scroll-test.sh` and customize the steps for your app's feed structure.
## File Structure
```
qa-scroll/
├── SKILL.md # This file
├── lib/
│ ├── setup-guard.sh # Prerequisites checker + auto-fixer
│ └── scroll-helpers.sh # Video state, mute control, feed interaction
├── flows/
│ └── example-scroll-test.sh # Example test (customize for your app)
└── run.sh # Runner with JSON report output
```
## Troubleshooting
### "Setup guard failed"
Check the specific `[SETUP]` line that shows `FAILED`. Common causes:
- Simulator not installed or wrong UDID
- Dev server port in use by another process
- App not installed (build and install first)
### "No CDP target found"
The app needs to be connected to the dev server. After a fresh install, launch the app and wait for the connection.
### "VideoPlayer class not found"
The module scan didn't find your video player class. Check:
- Is the video player library installed? (`expo-video`, `react-native-video`, etc.)
- Does the class name match `VIDEO_PLAYER_CLASS` in config?
- Try widening the scan range: `export MODULE_SCAN_END=10000`
### Tests show "skipped"
CDP couldn't read player state. The debug hook may not have captured any players yet. Common causes:
- No video content loaded (API returned empty feed)
- Players created before hook was installed (hook captures on `play()` call)
- Video library doesn't use the expected class structure
### Error overlay appears
The setup guard suppresses LogBox, but errors during CDP eval may trigger new overlays. The test automatically checks for and dismisses overlays before screenshots.

View File

@@ -0,0 +1,273 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ Example Scroll Test — Feed Scroll & Video Playback QA ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ CUSTOMIZE: Replace the marked sections with your app's specific ║
# ║ screen names, navigation patterns, and video player details. ║
# ║ ║
# ║ Usage: bash .pi/skills/qa-automation/qa-scroll/flows/example-scroll-test.sh
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source Libraries ─────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/../lib/scroll-helpers.sh"
# ── Test Setup ───────────────────────────────────────────────────────
TEST_NAME="feed-scroll-play"
setup_test "$TEST_NAME"
# Run setup guard — checks all prerequisites
run_setup_guard || {
echo "FATAL: Setup guard failed. Cannot run tests."
teardown_test
exit 1
}
# Track results for report
declare -a TEST_RESULTS=()
add_test_result() {
local name="$1"
local status="$2"
local error="${3:-}"
local screenshots="${4:-}"
TEST_RESULTS+=("$(cat <<RESULT
{
"name": "$name",
"suite": "Feed Scroll & Play",
"status": "$status",
"error": "$error",
"screenshots": [$screenshots]
}
RESULT
)")
}
# ════════════════════════════════════════════════════════════════════
# CUSTOMIZE: Step 0 — Install Debug Hook & Verify App
# ════════════════════════════════════════════════════════════════════
step "Verify app is running and install video debug hook"
assert_app_foreground || {
log_fail "App not in foreground"
add_test_result "App Foreground Check" "failed" "App not in foreground"
}
log_info "Installing video player debug hook..."
hook_result=$(install_debug_hook 2>&1 || echo '{"error":"hook install failed"}')
log_info "Debug hook result: $hook_result"
take_screenshot "00-initial"
assert_screenshot "00-initial" || true
# ════════════════════════════════════════════════════════════════════
# CUSTOMIZE: Step 1 — Navigate to Your Feed Screen
# Replace SCREEN_EXPLORE with your app's feed screen name.
# ════════════════════════════════════════════════════════════════════
step "Navigate to feed screen"
# Navigate away first, then to feed (ensures fresh mount)
nav_home 2>/dev/null || true
sleep 1
# CUSTOMIZE: Change this to your feed screen's navigation command
nav_explore
sleep 4
# Check for error overlay
overlay=$(check_error_overlay)
if [ "$overlay" = "visible" ]; then
log_warn "Error overlay detected — dismissing"
dismiss_error_overlay
sleep 1
fi
take_screenshot "01-feed-screen"
if assert_screenshot "01-feed-screen"; then
log_pass "Navigated to feed screen"
add_test_result "Navigate to Feed" "passed" "" "\"01-feed-screen.png\""
else
log_fail "Failed to capture feed screen"
add_test_result "Navigate to Feed" "failed" "Screenshot failed"
fi
# ════════════════════════════════════════════════════════════════════
# Step 2 — Verify First Video Autoplays
# ════════════════════════════════════════════════════════════════════
step "Verify first video autoplays"
sleep 3
video_state=$(query_video_playing 2>&1 || echo '{"playing":false}')
log_info "Video state: $video_state"
playing=$(echo "$video_state" | node -e "
var d=''; process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try { console.log(JSON.parse(d).playing?'true':'false'); }
catch(e) { console.log('false'); }
});
" 2>/dev/null || echo "false")
has_error=$(echo "$video_state" | node -e "
var d=''; process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try { console.log(JSON.parse(d).error?'yes':'no'); }
catch(e) { console.log('yes'); }
});
" 2>/dev/null || echo "yes")
take_screenshot "02-first-video"
if [ "$playing" = "true" ]; then
log_pass "First video is autoplaying"
add_test_result "First Video Autoplay" "passed" "" "\"02-first-video.png\""
elif [ "$has_error" = "yes" ]; then
log_warn "Autoplay check skipped — CDP returned error"
add_test_result "First Video Autoplay" "skipped" "CDP error: $video_state" "\"02-first-video.png\""
else
log_fail "First video is NOT autoplaying"
add_test_result "First Video Autoplay" "failed" "Not playing" "\"02-first-video.png\""
fi
# ════════════════════════════════════════════════════════════════════
# Step 3 — Verify Video Progress
# ════════════════════════════════════════════════════════════════════
step "Verify video playback is progressing"
if [ "$playing" = "false" ] && [ "$has_error" = "yes" ]; then
log_warn "Progress check skipped — no tracked players"
take_screenshot "03-progress"
add_test_result "Video Progress" "skipped" "No players" "\"03-progress.png\""
else
progress=$(check_video_progress 2>&1 || echo "unknown")
take_screenshot "03-progress"
if [ "$progress" = "advancing" ]; then
log_pass "Video playback is progressing"
add_test_result "Video Progress" "passed" "" "\"03-progress.png\""
elif [ "$progress" = "stalled" ]; then
log_fail "Video playback is stalled"
add_test_result "Video Progress" "failed" "Stalled" "\"03-progress.png\""
else
log_warn "Progress check: $progress"
add_test_result "Video Progress" "skipped" "$progress" "\"03-progress.png\""
fi
fi
# ════════════════════════════════════════════════════════════════════
# Step 4 — Scroll to Next Video
# ════════════════════════════════════════════════════════════════════
step "Scroll to next video"
scroll_to_next_video
sleep 3
take_screenshot "04-second-video"
add_test_result "Scroll to Next" "passed" "" "\"04-second-video.png\""
# ════════════════════════════════════════════════════════════════════
# Step 5 — Mute Toggle
# ════════════════════════════════════════════════════════════════════
step "Toggle mute via CDP"
initial_mute=$(get_mute_state 2>&1 || echo "unknown")
new_mute=$(toggle_mute_cdp 2>&1 || echo "unknown")
take_screenshot "05-mute-toggle"
if [ "$initial_mute" != "unknown" ] && [ "$new_mute" != "unknown" ] && [ "$initial_mute" != "$new_mute" ]; then
log_pass "Mute toggled: $initial_mute -> $new_mute"
add_test_result "Mute Toggle" "passed" "" "\"05-mute-toggle.png\""
elif [ "$initial_mute" = "unknown" ] || [ "$new_mute" = "unknown" ]; then
log_warn "Mute toggle skipped — state unknown"
add_test_result "Mute Toggle" "skipped" "$initial_mute -> $new_mute" "\"05-mute-toggle.png\""
else
log_fail "Mute did not toggle"
add_test_result "Mute Toggle" "failed" "$initial_mute -> $new_mute" "\"05-mute-toggle.png\""
fi
# Toggle back
toggle_mute_cdp >/dev/null 2>&1 || true
# ════════════════════════════════════════════════════════════════════
# Step 6 — Scroll Through More Videos
# ════════════════════════════════════════════════════════════════════
for i in 3 4 5; do
step "Scroll to video #$i"
scroll_to_next_video
sleep 2
take_screenshot "06-video-${i}"
add_test_result "Scroll to Video $i" "passed" "" "\"06-video-${i}.png\""
done
# ════════════════════════════════════════════════════════════════════
# Step 7 — Final State Verification
# ════════════════════════════════════════════════════════════════════
step "Final state verification"
overlay=$(check_error_overlay)
if [ "$overlay" = "visible" ]; then
dismiss_error_overlay
sleep 1
fi
take_screenshot "07-final"
# CUSTOMIZE: Change the route name to match your feed screen
route=$(cdp_get_route 2>&1 || echo "unknown")
log_info "Current route: $route"
add_test_result "Final State" "passed" "Route: $route" "\"07-final.png\""
assert_app_foreground || log_warn "App foreground check failed"
# ════════════════════════════════════════════════════════════════════
# Generate Report
# ════════════════════════════════════════════════════════════════════
step "Generating test report"
SKIP_COUNT=0
for result in "${TEST_RESULTS[@]}"; do
if echo "$result" | grep -q '"status": "skipped"'; then
SKIP_COUNT=$((SKIP_COUNT + 1))
fi
done
TESTS_JSON=""
for result in "${TEST_RESULTS[@]}"; do
[ -n "$TESTS_JSON" ] && TESTS_JSON+=","
TESTS_JSON+="$result"
done
REPORT_FILE="$TEST_OUTPUT_DIR/${TEST_NAME}-report.json"
end_time=$(date +%s)
duration=$((end_time - TEST_START_TIME))
duration_ms=$((duration * 1000))
cat > "$REPORT_FILE" <<REPORT
{
"title": "Feed Scroll & Play QA",
"generatedAt": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
"suites": [{
"name": "Feed Scroll & Play",
"type": "e2e",
"passed": $PASS_COUNT,
"failed": $FAIL_COUNT,
"skipped": $SKIP_COUNT,
"duration": $duration_ms,
"tests": [$TESTS_JSON],
"screenshotDir": "$SCREENSHOT_DIR/$TEST_NAME"
}],
"totalPassed": $PASS_COUNT,
"totalFailed": $FAIL_COUNT,
"totalSkipped": $SKIP_COUNT,
"totalDuration": $duration_ms
}
REPORT
log_info "Report saved to: $REPORT_FILE"
teardown_test
echo ""
echo "Scroll test completed!"
echo " Screenshots: $SCREENSHOT_DIR/$TEST_NAME/"
echo " Report: $REPORT_FILE"
echo ""

View File

@@ -0,0 +1,418 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ scroll-helpers.sh — Video/Media State & Feed Interaction ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file in scroll test scripts: ║
# ║ source "$(dirname "$0")/../lib/scroll-helpers.sh" ║
# ║ ║
# ║ Provides: ║
# ║ • Debug hook installation (patches video player prototype) ║
# ║ • Video state queries (playing, muted, currentTime) ║
# ║ • Mute/unmute toggle via CDP ║
# ║ • Feed scrolling (CDP hook or agent-device swipe fallback) ║
# ║ • Error overlay detection and dismissal ║
# ║ • Assertion helpers for video state ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source shared helpers ────────────────────────────────────────────
SCROLL_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
QA_ROOT_SCR="$(cd "$SCROLL_SKILL_DIR/../.." && pwd 2>/dev/null || cd "$SCROLL_SKILL_DIR/.." && pwd)"
source "$SCROLL_SKILL_DIR/lib/setup-guard.sh"
source "$QA_ROOT_SCR/qa-test-flows/lib/test-helpers.sh"
source "$QA_ROOT_SCR/qa-test-flows/lib/cdp-helpers.sh"
# ── Swipe Coordinates (configurable) ────────────────────────────────
# Center-ish coordinates for swipe gestures. Override in qa.config.sh.
SWIPE_START_X="${SWIPE_START_X:-$((SCREEN_WIDTH / 2))}"
SWIPE_START_Y="${SWIPE_START_Y:-$((SCREEN_HEIGHT * 2 / 3))}"
SWIPE_END_X="${SWIPE_END_X:-$((SCREEN_WIDTH / 2))}"
SWIPE_END_Y="${SWIPE_END_Y:-$((SCREEN_HEIGHT / 4))}"
VIDEO_CENTER_X="${VIDEO_CENTER_X:-$((SCREEN_WIDTH / 2))}"
VIDEO_CENTER_Y="${VIDEO_CENTER_Y:-$((SCREEN_HEIGHT / 2))}"
# ── Install Debug Hook ───────────────────────────────────────────────
# Patches VideoPlayer.prototype to track all player instances.
# Works by finding the VideoPlayer class in Metro modules, then patching
# play() and replaceAsync() to capture references.
install_debug_hook() {
cdp_eval_safe "
// Find the VideoPlayer class
var VideoPlayerClass = null;
for (var i = ${MODULE_SCAN_START}; i < ${MODULE_SCAN_END}; i++) {
try {
var m = __r(i);
if (m && m.default && m.default.${VIDEO_PLAYER_CLASS} && typeof m.default.${VIDEO_PLAYER_CLASS} === 'function') {
VideoPlayerClass = m.default.${VIDEO_PLAYER_CLASS};
break;
}
if (m && m.${VIDEO_PLAYER_CLASS} && typeof m.${VIDEO_PLAYER_CLASS} === 'function') {
VideoPlayerClass = m.${VIDEO_PLAYER_CLASS};
break;
}
} catch(e) {}
}
if (!VideoPlayerClass) {
return JSON.stringify({error: '${VIDEO_PLAYER_CLASS} class not found'});
}
// Set up global tracking array
if (!globalThis.${GLOBAL_PLAYERS_VAR}) {
globalThis.${GLOBAL_PLAYERS_VAR} = [];
}
// Patch prototype methods to capture instances
if (!VideoPlayerClass.prototype.__qaPatched) {
var origPlay = VideoPlayerClass.prototype.play;
VideoPlayerClass.prototype.play = function() {
var found = false;
for (var k = 0; k < globalThis.${GLOBAL_PLAYERS_VAR}.length; k++) {
if (globalThis.${GLOBAL_PLAYERS_VAR}[k] === this) { found = true; break; }
}
if (!found) {
globalThis.${GLOBAL_PLAYERS_VAR}.push(this);
if (globalThis.${GLOBAL_PLAYERS_VAR}.length > ${MAX_TRACKED_PLAYERS}) {
globalThis.${GLOBAL_PLAYERS_VAR} = globalThis.${GLOBAL_PLAYERS_VAR}.slice(-${MAX_TRACKED_PLAYERS});
}
}
return origPlay.apply(this, arguments);
};
if (VideoPlayerClass.prototype.replaceAsync) {
var origReplaceAsync = VideoPlayerClass.prototype.replaceAsync;
VideoPlayerClass.prototype.replaceAsync = function() {
var found2 = false;
for (var k2 = 0; k2 < globalThis.${GLOBAL_PLAYERS_VAR}.length; k2++) {
if (globalThis.${GLOBAL_PLAYERS_VAR}[k2] === this) { found2 = true; break; }
}
if (!found2) {
globalThis.${GLOBAL_PLAYERS_VAR}.push(this);
}
return origReplaceAsync.apply(this, arguments);
};
}
VideoPlayerClass.prototype.__qaPatched = true;
}
// Debug accessor function
globalThis.__qaDebugPlayers = function() {
var players = globalThis.${GLOBAL_PLAYERS_VAR} || [];
var activePlayers = [];
for (var i = 0; i < players.length; i++) {
try {
var p = players[i];
activePlayers.push({
index: i,
playing: !!p.playing,
muted: !!p.muted,
currentTime: p.currentTime || 0,
duration: p.duration || 0,
status: p.status || 'unknown'
});
} catch(e) {
activePlayers.push({index: i, error: e.message});
}
}
var currentPlayer = null;
for (var j = activePlayers.length - 1; j >= 0; j--) {
if (activePlayers[j].playing) {
currentPlayer = activePlayers[j];
break;
}
}
return {
totalPlayers: activePlayers.length,
currentPlayer: currentPlayer,
allPlayers: activePlayers
};
};
return JSON.stringify({
ok: true,
playersTracked: globalThis.${GLOBAL_PLAYERS_VAR}.length
});
"
}
# ── Query Video State ────────────────────────────────────────────────
# Query the current playing video state.
# Returns JSON: {playing, muted, currentTime, playerIndex}
query_video_playing() {
local attempt=0
local max_attempts=3
local result=""
while [ $attempt -lt $max_attempts ]; do
result=$(cdp_eval "
(function() {
try {
if (globalThis.${GLOBAL_PLAYERS_VAR} && globalThis.${GLOBAL_PLAYERS_VAR}.length > 0) {
var players = globalThis.${GLOBAL_PLAYERS_VAR};
for (var i = players.length - 1; i >= 0; i--) {
try {
if (players[i].playing) {
return JSON.stringify({
playing: true,
muted: !!players[i].muted,
currentTime: players[i].currentTime || 0,
playerIndex: i
});
}
} catch(e) {}
}
return JSON.stringify({playing: false, muted: false, currentTime: 0, reason: 'no playing player'});
}
return JSON.stringify({playing: false, error: 'no tracked players'});
} catch(e) {
return JSON.stringify({error: e.message, playing: false});
}
})();
" 2>/dev/null || echo '{"error":"cdp failed","playing":false}')
local has_playing
has_playing=$(echo "$result" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.error?'no':'yes')}catch(e){console.log('no')}});
" 2>/dev/null || echo "no")
if [ "$has_playing" = "yes" ]; then
echo "$result"
return 0
fi
attempt=$((attempt + 1))
[ $attempt -lt $max_attempts ] && sleep 1
done
echo "$result"
}
# Check if video currentTime is advancing.
# Returns: "advancing" or "stalled"
check_video_progress() {
local time1
local time2
time1=$(cdp_eval "
(function() {
if (!globalThis.${GLOBAL_PLAYERS_VAR}) return '0';
var players = globalThis.${GLOBAL_PLAYERS_VAR};
for (var i = players.length - 1; i >= 0; i--) {
try { if (players[i].playing) return '' + players[i].currentTime; } catch(e) {}
}
return '0';
})();
" 2>/dev/null || echo "0")
sleep 2
time2=$(cdp_eval "
(function() {
if (!globalThis.${GLOBAL_PLAYERS_VAR}) return '0';
var players = globalThis.${GLOBAL_PLAYERS_VAR};
for (var i = players.length - 1; i >= 0; i--) {
try { if (players[i].playing) return '' + players[i].currentTime; } catch(e) {}
}
return '0';
})();
" 2>/dev/null || echo "0")
local result
result=$(node -e "
var t1 = parseFloat('$time1') || 0;
var t2 = parseFloat('$time2') || 0;
console.log(t2 > t1 ? 'advancing' : 'stalled');
" 2>/dev/null || echo "stalled")
echo "$result"
}
# ── Mute Control ─────────────────────────────────────────────────────
# Returns: "muted", "unmuted", or "unknown"
get_mute_state() {
local result
result=$(cdp_eval "
(function() {
if (!globalThis.${GLOBAL_PLAYERS_VAR}) return 'unknown';
var players = globalThis.${GLOBAL_PLAYERS_VAR};
for (var i = players.length - 1; i >= 0; i--) {
try {
if (players[i].playing) return players[i].muted ? 'muted' : 'unmuted';
} catch(e) {}
}
if (players.length > 0) {
try { return players[players.length-1].muted ? 'muted' : 'unmuted'; } catch(e) {}
}
return 'unknown';
})();
" 2>/dev/null || echo "unknown")
echo "$result"
}
# Toggle mute via CDP (deterministic, no coordinate tap).
# Returns: "muted" or "unmuted" (the new state)
toggle_mute_cdp() {
local result
result=$(cdp_eval "
(function() {
if (!globalThis.${GLOBAL_PLAYERS_VAR} || globalThis.${GLOBAL_PLAYERS_VAR}.length === 0) return 'unknown';
var players = globalThis.${GLOBAL_PLAYERS_VAR};
var target = null;
for (var i = players.length - 1; i >= 0; i--) {
try { if (players[i].playing) { target = players[i]; break; } } catch(e) {}
}
if (!target && players.length > 0) target = players[players.length - 1];
if (!target) return 'unknown';
try {
target.muted = !target.muted;
return target.muted ? 'muted' : 'unmuted';
} catch(e) { return 'error: ' + e.message; }
})();
" 2>/dev/null || echo "unknown")
echo "$result"
}
# ── Feed Interaction ─────────────────────────────────────────────────
# Scroll to next video via CDP hook or agent-device swipe fallback.
scroll_to_next_video() {
step "Scrolling to next video"
# Try CDP hook first
local result
result=$(cdp_eval "
(function() {
if (globalThis.${GLOBAL_FEED_VAR} && globalThis.${GLOBAL_FEED_VAR}.scrollToNext) {
var before = globalThis.${GLOBAL_FEED_VAR}.currentIndex;
globalThis.${GLOBAL_FEED_VAR}.scrollToNext();
return JSON.stringify({ok:true, before:before, after:globalThis.${GLOBAL_FEED_VAR}.currentIndex});
}
return JSON.stringify({error:'feed hook not available'});
})();
" 2>/dev/null || echo '{"error":"cdp failed"}')
local has_ok
has_ok=$(echo "$result" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).ok?'yes':'no')}catch(e){console.log('no')}});
" 2>/dev/null || echo "no")
if [ "$has_ok" = "yes" ]; then
log_info "Scroll result: $result"
else
# Fallback: agent-device swipe up
log_info "CDP scroll hook not available — using swipe gesture"
swipe $SWIPE_START_X $SWIPE_START_Y $SWIPE_END_X $SWIPE_END_Y
fi
sleep 3
}
# Tap center of video (fallback interaction)
tap_video_center() {
tap "$VIDEO_CENTER_X" "$VIDEO_CENTER_Y"
sleep 1
}
# ── Error Overlay Detection ──────────────────────────────────────────
# Returns: "visible" or "clear"
check_error_overlay() {
local result
result=$(cdp_eval "
(function() {
try {
var LogBoxData = require('react-native/Libraries/LogBox/Data/LogBoxData');
if (LogBoxData) {
var errors = LogBoxData.errors && LogBoxData.errors();
var warnings = LogBoxData.warnings && LogBoxData.warnings();
var hasErrors = (errors && errors.length > 0) || (warnings && warnings.length > 0);
return hasErrors ? 'visible' : 'clear';
}
return 'clear';
} catch(e) { return 'clear'; }
})();
" 2>/dev/null || echo "clear")
echo "$result"
}
# Dismiss the error overlay via CDP
dismiss_error_overlay() {
cdp_eval "
(function() {
try {
var LogBox = require('react-native/Libraries/LogBox/LogBox');
if (LogBox && LogBox.ignoreAllLogs) LogBox.ignoreAllLogs(true);
var LogBoxData = require('react-native/Libraries/LogBox/Data/LogBoxData');
if (LogBoxData && LogBoxData.clear) LogBoxData.clear();
return 'cleared';
} catch(e) { return 'cdp-only: ' + e.message; }
})();
" >/dev/null 2>&1
sleep 0.5
}
# ── Assertion Helpers ────────────────────────────────────────────────
assert_video_playing() {
local state
state=$(query_video_playing)
local playing
playing=$(echo "$state" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).playing?'true':'false')}catch(e){console.log('false')}});
" 2>/dev/null || echo "false")
if [ "$playing" = "true" ]; then
log_pass "Video is playing"
return 0
else
log_fail "Video is NOT playing (state: $state)"
return 1
fi
}
assert_video_progressing() {
local progress
progress=$(check_video_progress)
if [ "$progress" = "advancing" ]; then
log_pass "Video playback is progressing"
return 0
else
log_fail "Video playback is stalled"
return 1
fi
}
assert_video_muted() {
local state
state=$(get_mute_state)
if [ "$state" = "muted" ]; then
log_pass "Video is muted"
return 0
else
log_fail "Video is NOT muted (state: $state)"
return 1
fi
}
assert_video_unmuted() {
local state
state=$(get_mute_state)
if [ "$state" = "unmuted" ]; then
log_pass "Video is unmuted"
return 0
else
log_fail "Video is NOT unmuted (state: $state)"
return 1
fi
}
echo "Scroll QA helpers loaded."

View File

@@ -0,0 +1,413 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ setup-guard.sh — Prerequisites Checker & Auto-Fixer ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source at the top of any test flow or runner script: ║
# ║ source "$(dirname "$0")/../lib/setup-guard.sh" ║
# ║ ║
# ║ Checks (in order): ║
# ║ 1. iOS Simulator booted ║
# ║ 2. Dev server (Metro/Vite/etc.) running ║
# ║ 3. App in foreground ║
# ║ 4. CDP Hermes target available ║
# ║ 5. CDP connection functional (eval 1+1) ║
# ║ 6. Navigation module ID valid ║
# ║ 7. LogBox/error overlay dismissed ║
# ║ ║
# ║ Each check logs [SETUP] with OK/FIXING/FAILED status. ║
# ║ If a critical check fails, the guard exits non-zero. ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source configuration and helpers ─────────────────────────────────
SETUP_GUARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
QA_ROOT_SG="$(cd "$SETUP_GUARD_DIR/../.." && pwd 2>/dev/null || cd "$SETUP_GUARD_DIR/.." && pwd)"
# Source config if not already loaded
if [ -z "${QA_AUTOMATION_DIR:-}" ]; then
source "$QA_ROOT_SG/qa.config.sh" 2>/dev/null || source "$SETUP_GUARD_DIR/../../qa.config.sh"
fi
# Track state
_SETUP_GUARD_RAN=false
_SETUP_CDP_WS_URL=""
# ── Logging ──────────────────────────────────────────────────────────
_setup_log() {
local status="$1"
local message="$2"
printf " [SETUP] %-40s %s\n" "$message" "$status"
}
_setup_ok() { _setup_log "OK" "$1"; }
_setup_fixing() { _setup_log "FIXING" "$1"; }
_setup_failed() { _setup_log "FAILED" "$1"; }
_setup_skip() { _setup_log "SKIP" "$1"; }
# ── 1. Check/Boot iOS Simulator ─────────────────────────────────────
_check_simulator() {
# Auto-detect UDID if needed
if [ "$SIMULATOR_UDID" = "auto" ]; then
local detected
detected=$(qa_detect_simulator_udid 2>/dev/null || echo "")
if [ -n "$detected" ]; then
SIMULATOR_UDID="$detected"
_setup_ok "iOS Simulator auto-detected: $SIMULATOR_UDID"
return 0
fi
# No booted simulator — try to find any available one and boot it
_setup_fixing "No booted simulator — finding one to boot..."
local first_sim
first_sim=$(xcrun simctl list devices available 2>/dev/null | grep "iPhone" | head -1 | grep -oE '[A-F0-9-]{36}' || echo "")
if [ -n "$first_sim" ]; then
xcrun simctl boot "$first_sim" 2>/dev/null || true
open -a Simulator 2>/dev/null || true
sleep 5
SIMULATOR_UDID="$first_sim"
_setup_ok "iOS Simulator booted: $SIMULATOR_UDID"
return 0
fi
_setup_failed "No iOS Simulator found"
return 1
fi
# Specific UDID provided
local booted
booted=$(xcrun simctl list devices 2>/dev/null | grep "$SIMULATOR_UDID" | grep -c "Booted" || true)
if [ "$booted" -ge 1 ]; then
_setup_ok "iOS Simulator booted"
return 0
fi
_setup_fixing "iOS Simulator not booted — booting..."
xcrun simctl boot "$SIMULATOR_UDID" 2>/dev/null || true
open -a Simulator 2>/dev/null || true
sleep 5
booted=$(xcrun simctl list devices 2>/dev/null | grep "$SIMULATOR_UDID" | grep -c "Booted" || true)
if [ "$booted" -ge 1 ]; then
_setup_ok "iOS Simulator booted (after fix)"
return 0
fi
_setup_failed "iOS Simulator could not be booted"
return 1
}
# ── 2. Check/Start Dev Server ────────────────────────────────────────
_check_dev_server() {
local status_code
status_code=$(curl -s -o /dev/null -w "%{http_code}" "$DEV_SERVER_HEALTH" 2>/dev/null || echo "000")
if [ "$status_code" = "200" ]; then
_setup_ok "Dev server running on :${METRO_PORT}"
return 0
fi
_setup_fixing "Dev server not running — starting in background..."
cd "$PROJECT_DIR"
eval "$DEV_SERVER_CMD" > /tmp/qa-dev-server.log 2>&1 &
local server_pid=$!
echo "$server_pid" > /tmp/qa-dev-server.pid
local elapsed=0
while [ $elapsed -lt $DEV_SERVER_TIMEOUT ]; do
status_code=$(curl -s -o /dev/null -w "%{http_code}" "$DEV_SERVER_HEALTH" 2>/dev/null || echo "000")
if [ "$status_code" = "200" ]; then
_setup_ok "Dev server started (took ${elapsed}s)"
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
_setup_failed "Dev server did not start within ${DEV_SERVER_TIMEOUT}s"
return 1
}
# ── 3. Check/Launch App in Foreground ────────────────────────────────
_check_app_foreground() {
# Use xcrun simctl launch (idempotent: if already running, brings to front)
local launch_result
launch_result=$(xcrun simctl launch "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>&1 || true)
if echo "$launch_result" | grep -q "$APP_BUNDLE_ID"; then
_setup_ok "App in foreground ($APP_BUNDLE_ID)"
sleep 2
return 0
fi
_setup_fixing "App not launching — terminating and retrying..."
xcrun simctl terminate "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || true
sleep 2
launch_result=$(xcrun simctl launch "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>&1 || true)
sleep "$APP_SETTLE_TIME"
if echo "$launch_result" | grep -q "$APP_BUNDLE_ID"; then
_setup_ok "App launched and in foreground"
return 0
fi
_setup_failed "Could not launch app"
return 1
}
# ── 4. Wait for CDP Hermes Target ───────────────────────────────────
_check_cdp_target() {
local elapsed=0
local json
local relaunched=false
while [ $elapsed -lt $CDP_TIMEOUT ]; do
json=$(curl -s "$CDP_DISCOVERY_URL" 2>/dev/null || echo "[]")
if echo "$json" | grep -q '"webSocketDebuggerUrl"'; then
_SETUP_CDP_WS_URL=$(echo "$json" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try{
const targets=JSON.parse(d);
const hermes=targets.find(t=>t.description && t.description.includes('Bridgeless'));
console.log(hermes?hermes.webSocketDebuggerUrl:targets[0].webSocketDebuggerUrl);
}catch(e){console.log('');}
});
" 2>/dev/null || echo "")
if [ -n "$_SETUP_CDP_WS_URL" ]; then
export CDP_WS_URL="$_SETUP_CDP_WS_URL"
_setup_ok "CDP Hermes target available"
return 0
fi
fi
# If no CDP target after 10s, try relaunching the app
if [ $elapsed -ge 10 ] && [ "$relaunched" = false ]; then
_setup_fixing "No CDP target — relaunching app..."
xcrun simctl terminate "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || true
sleep 2
xcrun simctl launch "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || true
relaunched=true
sleep "$APP_SETTLE_TIME"
fi
sleep 2
elapsed=$((elapsed + 2))
done
_setup_failed "No CDP target found within ${CDP_TIMEOUT}s"
return 1
}
# ── 5. Validate CDP Connection ───────────────────────────────────────
_check_cdp_connection() {
local ws_url="${_SETUP_CDP_WS_URL:-${CDP_WS_URL:-}}"
if [ -z "$ws_url" ] || [ "$ws_url" = "auto" ]; then
_setup_failed "No CDP WebSocket URL available"
return 1
fi
local attempt=0
local max_attempts=3
while [ $attempt -lt $max_attempts ]; do
local result
result=$(cd "$PROJECT_DIR" && node -e "
const WebSocket=require('ws');
const ws=new WebSocket('$ws_url');
ws.on('open',()=>{
ws.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:'1+1',returnByValue:true}}));
});
ws.on('message',d=>{
const m=JSON.parse(d);
if(m.id===1){
const v=m.result?.result?.value;
console.log(v===2?'ok':'fail');
ws.close();process.exit(0);
}
});
ws.on('error',e=>{console.log('error');process.exit(1)});
setTimeout(()=>{console.log('timeout');process.exit(1)},5000);
" 2>/dev/null || echo "error")
if [ "$result" = "ok" ]; then
_setup_ok "CDP connection functional (eval 1+1=2)"
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
_setup_failed "CDP connection failed after $max_attempts attempts"
return 1
}
# ── 6. Validate Navigation Module ID ────────────────────────────────
_check_nav_module() {
local ws_url="${_SETUP_CDP_WS_URL:-${CDP_WS_URL:-}}"
if [ -z "$ws_url" ] || [ "$ws_url" = "auto" ]; then
_setup_failed "No CDP URL for nav module check"
return 1
fi
local attempt=0
local max_attempts=3
local result=""
while [ $attempt -lt $max_attempts ]; do
result=$(cd "$PROJECT_DIR" && node -e "
const WebSocket=require('ws');
const ws=new WebSocket('$ws_url');
ws.on('open',()=>{
const expr=\`
(function(){
var origHandler=globalThis.ErrorUtils?ErrorUtils.getGlobalHandler():null;
var origCE=console.error;
if(globalThis.ErrorUtils)ErrorUtils.setGlobalHandler(function(){});
console.error=function(){};
try{
for(var j=${MODULE_SCAN_START};j<${MODULE_SCAN_END};j++){
try{
var m=__r(j);
if(m&&m.navigationRef&&m.navigationRef.current){
return JSON.stringify({ok:true,moduleId:j});
}
}catch(e){}
}
return JSON.stringify({error:'not found'});
}finally{
if(globalThis.ErrorUtils&&origHandler)ErrorUtils.setGlobalHandler(origHandler);
console.error=origCE;
}
})();
\`;
ws.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:expr,returnByValue:true}}));
});
ws.on('message',d=>{
const m=JSON.parse(d);
if(m.id===1){console.log(m.result?.result?.value||'error');ws.close();process.exit(0);}
});
ws.on('error',()=>{console.log('error');process.exit(1)});
setTimeout(()=>{console.log('timeout');process.exit(1)},10000);
" 2>/dev/null || echo '{"error":"node failed"}')
local module_id
module_id=$(echo "$result" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.moduleId||'')}catch(e){console.log('')}})" 2>/dev/null || echo "")
if [ -n "$module_id" ]; then
echo "$module_id" > "$NAV_MODULE_CACHE"
_setup_ok "Navigation module ID: $module_id"
export NAV_MODULE_ID="$module_id"
return 0
fi
attempt=$((attempt + 1))
if [ $attempt -lt $max_attempts ]; then
sleep $((attempt * 3))
fi
done
_setup_failed "Navigation module not found after $max_attempts attempts"
return 1
}
# ── 7. Dismiss LogBox Error Overlay ──────────────────────────────────
_dismiss_logbox() {
local ws_url="${_SETUP_CDP_WS_URL:-${CDP_WS_URL:-}}"
if [ -z "$ws_url" ] || [ "$ws_url" = "auto" ]; then
_setup_ok "LogBox check skipped (no CDP URL)"
return 0
fi
local result
result=$(cd "$PROJECT_DIR" && node -e "
const WebSocket=require('ws');
const ws=new WebSocket('$ws_url');
ws.on('open',()=>{
ws.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{
expression:\`
(function(){
try{
var LogBox=require('react-native/Libraries/LogBox/LogBox');
if(LogBox&&LogBox.ignoreAllLogs) LogBox.ignoreAllLogs(true);
var LogBoxData=require('react-native/Libraries/LogBox/Data/LogBoxData');
if(LogBoxData&&LogBoxData.clear) LogBoxData.clear();
return 'suppressed';
}catch(e){
return 'no-logbox: '+e.message;
}
})();
\`,
returnByValue:true
}}));
});
ws.on('message',d=>{
const m=JSON.parse(d);
if(m.id===1){console.log(m.result?.result?.value||'unknown');ws.close();process.exit(0);}
});
ws.on('error',()=>{console.log('error');process.exit(1)});
setTimeout(()=>{console.log('timeout');process.exit(1)},5000);
" 2>/dev/null || echo "error")
if [ "$result" = "suppressed" ]; then
_setup_ok "LogBox suppressed for session"
else
_setup_ok "LogBox suppress attempted (result: $result)"
fi
return 0
}
# ── Main: Run All Checks ────────────────────────────────────────────
run_setup_guard() {
if [ "$_SETUP_GUARD_RAN" = true ]; then
return 0
fi
echo ""
echo "┌─────────────────────────────────────────────┐"
echo "│ SETUP GUARD — Checking prerequisites... │"
echo "└─────────────────────────────────────────────┘"
echo ""
local failed=0
_check_simulator || failed=$((failed + 1))
_check_dev_server || failed=$((failed + 1))
_check_app_foreground || failed=$((failed + 1))
_check_cdp_target || failed=$((failed + 1))
_check_cdp_connection || failed=$((failed + 1))
_check_nav_module || failed=$((failed + 1))
_dismiss_logbox || true # Non-critical
echo ""
if [ $failed -gt 0 ]; then
echo " SETUP GUARD: $failed critical check(s) failed. Aborting."
echo ""
return 1
fi
echo " SETUP GUARD: All checks passed. Ready to test."
echo ""
_SETUP_GUARD_RAN=true
return 0
}
# ── Export ────────────────────────────────────────────────────────────
export -f run_setup_guard 2>/dev/null || true
export VIDEO_MODULE_CACHE NAV_MODULE_CACHE 2>/dev/null || true

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Run the Feed Scroll & Play QA test
# Setup guard runs automatically — checks/fixes simulator, dev server, app, CDP.
#
# Usage: bash .pi/skills/qa-automation/qa-scroll/run.sh
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
REPORT_FILE="/tmp/qa-tests/feed-scroll-play-report.json"
echo ""
echo "╔═══════════════════════════════════════════════════╗"
echo "║ Feed Scroll & Play QA ║"
echo "║ Started: $(date '+%Y-%m-%d %H:%M:%S') "
echo "╚═══════════════════════════════════════════════════╝"
echo ""
bash "$SKILL_DIR/flows/example-scroll-test.sh"
EXIT_CODE=$?
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -f "$REPORT_FILE" ]; then
echo "Report JSON: $REPORT_FILE"
echo ""
passed=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalPassed);" 2>/dev/null || echo "?")
failed=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalFailed);" 2>/dev/null || echo "?")
skipped=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalSkipped);" 2>/dev/null || echo "?")
duration=$(node -e "var r=require('$REPORT_FILE'); console.log((r.totalDuration/1000).toFixed(1));" 2>/dev/null || echo "?")
echo " Passed: $passed"
echo " Failed: $failed"
echo " Skipped: $skipped"
echo " Duration: ${duration}s"
else
echo "WARNING: No report file generated at $REPORT_FILE"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
exit $EXIT_CODE

View File

@@ -0,0 +1,113 @@
---
name: qa-setup
description: >
Verify and install all dependencies for the QA Automation skill package.
Checks for agent-device, agent-browser, Node.js, iOS/Android tools, and
the 'ws' WebSocket library. Installs missing tools automatically.
Invoke when user says "check qa setup", "install qa tools", "verify qa dependencies",
"set up qa automation", "prepare for testing", or any task requiring QA tool verification.
allowed-tools: Bash(agent-device:*) Bash(agent-browser:*) Bash(npm:*) Bash(npx:*) Bash(node:*) Bash(which:*) Bash(xcrun:*) Bash(brew:*) Read
---
# qa-setup
Dependency verification and installation for the QA Automation skill package. Ensures both **agent-device** (native app testing) and **agent-browser** (web app testing) are installed and functional, along with all supporting tools.
## What Gets Checked
| Dependency | Purpose | Auto-Install? |
|------------|---------|---------------|
| **agent-device** | Native iOS/Android simulator control, screenshots, gestures | ✅ `npm install -g agent-device` |
| **agent-browser** | Web browser automation, form filling, screenshots | ✅ `npm install -g agent-browser` |
| **Node.js** | Runtime for CDP WebSocket and JSON processing | ❌ Manual install required |
| **npm / npx** | Package management | ❌ Comes with Node.js |
| **ws** | WebSocket library for CDP connections | ✅ `npm install ws` |
| **Xcode / xcrun** | iOS Simulator management | ❌ Manual install (Mac App Store) |
| **adb** | Android emulator management | ❌ Manual install (Android Studio) |
| **Bash 4.0+** | Required for array support in test scripts | ❌ `brew install bash` |
| **curl** | HTTP requests for health checks | ❌ Usually pre-installed |
| **jq** | JSON processing (optional) | ❌ `brew install jq` |
## Usage
### Full check + auto-install
```bash
bash .pi/skills/qa-automation/install.sh
```
### Check only (don't install anything)
```bash
bash .pi/skills/qa-automation/install.sh --check
```
### Manual install commands
```bash
# Core tools
npm install -g agent-device
npm install -g agent-browser
# CDP dependency (in your project)
cd /path/to/your/project
npm install ws
# iOS tools
xcode-select --install
# Android tools
brew install android-platform-tools
# Optional
brew install jq
brew install bash # If Bash < 4.0
```
## After Setup
1. **Configure**: Copy `qa.config.sh` and set your app-specific values:
```bash
export APP_BUNDLE_ID="com.yourapp.dev"
export PROJECT_DIR="/path/to/your/project"
```
2. **Verify**: Run the setup guard to check everything works:
```bash
source .pi/skills/qa-automation/qa-scroll/lib/setup-guard.sh
run_setup_guard
```
3. **Test**: Run an example test flow:
```bash
bash .pi/skills/qa-automation/qa-scroll/run.sh
```
## Architecture
The QA Automation package uses a **dual-driver architecture**:
```
┌─────────────────────────────────────────────────────────────┐
│ QA Automation Skills │
├──────────────────────┬──────────────────────────────────────┤
│ Native App Testing │ Web App Testing │
│ │ │
│ ┌──────────────┐ │ ┌──────────────┐ │
│ │ agent-device │ │ │ agent-browser │ │
│ │ │ │ │ │ │
│ │ • Screenshots │ │ │ • Screenshots │ │
│ │ • Gestures │ │ │ • Click/Fill │ │
│ │ • A11y tree │ │ │ • A11y tree │ │
│ │ • App launch │ │ │ • Navigation │ │
│ └──────┬───────┘ │ └──────┬────────┘ │
│ │ │ │ │
│ ┌──────▼───────┐ │ ┌──────▼────────┐ │
│ │ CDP (Hermes) │ │ │ Browser DOM │ │
│ │ │ │ │ │ │
│ │ • JS eval │ │ │ • JS eval │ │
│ │ • Navigation │ │ │ • State check │ │
│ │ • State query │ │ │ • Network │ │
│ │ • Debug hooks │ │ │ • Cookies │ │
│ └──────────────┘ │ └───────────────┘ │
└──────────────────────┴──────────────────────────────────────┘
```
Both drivers are required for full functionality. agent-device handles the physical device/simulator layer, while CDP (or agent-browser for web) handles the application runtime layer.

View File

@@ -0,0 +1,166 @@
---
name: qa-state-persistence
description: >
QA test skill for verifying UI state persistence across navigation. Tests that
state changes (likes, bookmarks, cart items, form inputs) survive scrolling away
and returning. Uses CDP + agent-device dual-driver architecture. Works with any
React Native app that uses list-based feeds or scrollable content.
Invoke when user says "test state persistence", "test like state", "verify bookmark persists",
"QA the state", "test data survives scroll", "verify UI state", or any task requiring
state persistence verification across navigation.
allowed-tools: Bash(agent-device:*) Bash(agent-browser:*) Bash(xcrun:*) Bash(node:*) Bash(curl:*) Bash(npx:*) Read
---
# qa-state-persistence
QA test skill for verifying **UI state persistence** across navigation. Tests that state changes (likes, bookmarks, cart additions, form inputs) survive scrolling away and returning. Uses the dual-driver architecture (CDP + agent-device).
## What It Tests
| Test | Method | Assertion |
|------|--------|-----------|
| Navigate to feed | CDP navigation | Route is on feed screen |
| Record item identity | CDP feed data query | Item ID and initial state captured |
| Verify initial state | CDP property query | State property is in expected initial value |
| Mutate state | CDP data mutation | Property flips (e.g., `isLiked: false → true`) |
| Scroll away (N items) | CDP scroll or agent-device swipe | Feed index advances |
| Scroll back | CDP `scrollToIndex(0)` | Feed index returns to 0 |
| **State persisted** | CDP property query | **KEY ASSERTION: property still has mutated value** |
| Cleanup | CDP data mutation | Restore original state |
| Final route check | CDP `cdp_get_route` | Still on feed screen |
## Configuration
Set your app-specific values in `qa.config.sh`:
```bash
# What property to test
export STATE_PROPERTY="isLiked" # Property name to toggle
export STATE_COUNTER_PROPERTY="likesCount" # Associated counter (optional)
export STATE_SCROLL_COUNT=5 # How many items to scroll past
# Feed screen
export SCREEN_EXPLORE="ExploreScreen" # Your feed screen name
# Feed debug hook (set up in your app's dev build)
export GLOBAL_FEED_VAR="__qaFeedState" # Global variable name
```
### Setting Up the Feed Debug Hook
In your app's feed component (dev builds only), expose:
```javascript
// In your feed component (e.g., ExploreFeed.tsx):
if (__DEV__) {
globalThis.__qaFeedState = {
currentIndex: currentIndex,
scrollToNext: () => flatListRef.current?.scrollToIndex({ index: currentIndex + 1 }),
scrollToIndex: (i) => flatListRef.current?.scrollToIndex({ index: i }),
getData: () => feedData, // Return the full data array
getItem: (i) => feedData[i], // Return item at index
dataLength: feedData.length,
};
}
```
## Architecture
```
CDP (Hermes Runtime) agent-device (Simulator)
┌──────────────────────┐ ┌──────────────────────┐
│ navigate to tab │ │ screenshot capture │
│ install state hook │ │ tap fallback (if CDP │
│ query item property │ │ mutation fails) │
│ mutate item property │ │ swipe fallback (if │
│ scrollToNext() │ │ scroll hook absent) │
│ scrollToIndex(0) │ │ │
│ read feed data │ │ │
└──────────────────────┘ └──────────────────────┘
```
## Usage
### Run the example test
```bash
bash .pi/skills/qa-automation/qa-state-persistence/run.sh
```
### View results
- Screenshots: `/tmp/qa-tests/screenshots/<test-name>/`
- Report JSON: `/tmp/qa-tests/<test-name>-report.json`
## Test Flow Diagram
```
┌─────────────────┐
│ Navigate to │
│ Feed Screen │
└────────┬────────┘
┌────────▼────────┐
│ Record item #0 │
│ (state=initial) │
└────────┬────────┘
┌────────▼────────┐
│ Mutate state │
│ (state=changed) │
└────────┬────────┘
┌────────▼────────┐
│ Scroll away Nx │
│ (index → N) │
└────────┬────────┘
┌────────▼────────┐
│ Scroll back to 0 │
│ (index → 0) │
└────────┬────────┘
┌────────▼────────┐
│ ★ VERIFY: state │
│ persisted! │
└────────┬────────┘
┌────────▼────────┐
│ Cleanup (restore │
│ original state) │
└─────────────────┘
```
## File Structure
```
qa-state-persistence/
├── SKILL.md # This file
├── lib/
│ └── state-helpers.sh # State query, mutation, scroll-to-index
├── flows/
│ └── example-state-test.sh # Example test (customize for your app)
└── run.sh # Runner with JSON report output
```
## Troubleshooting
### "Feed hook not available"
The `__qaFeedState` global isn't set. Ensure:
- Your feed component sets it up in `__DEV__` mode
- The feed screen is mounted (navigate to it first)
- The hook name matches `GLOBAL_FEED_VAR` in config
### "Could not read feed data"
`getData()` or `getItem()` failed. Possible causes:
- Component hasn't fully mounted yet (increase settle time)
- Feed data structure changed
- Hook was set up before data loaded
### "scrollToIndex not available"
The hook doesn't expose `scrollToIndex`. The test falls back to repeated swipe-down gestures.
### "State lost after scroll"
**This is the real failure the test catches.** If state doesn't persist, investigate:
- List item recycling (FlatList/FlashList virtualization)
- State management (local vs global state)
- Cache invalidation during scroll
- Component unmount/remount cycles

View File

@@ -0,0 +1,299 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ Example State Persistence Test ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Tests: mutate state → scroll away → scroll back → verify state ║
# ║ ║
# ║ CUSTOMIZE: Set STATE_PROPERTY and SCREEN_EXPLORE in qa.config.sh ║
# ║ ║
# ║ Usage: bash .pi/skills/qa-automation/qa-state-persistence/flows/example-state-test.sh
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source Libraries ─────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/../lib/state-helpers.sh"
# ── Test Setup ───────────────────────────────────────────────────────
TEST_NAME="state-persistence"
setup_test "$TEST_NAME"
run_setup_guard || {
echo "FATAL: Setup guard failed."
teardown_test
exit 1
}
declare -a TEST_RESULTS=()
add_test_result() {
local name="$1"
local status="$2"
local error="${3:-}"
local screenshots="${4:-}"
TEST_RESULTS+=("$(cat <<RESULT
{
"name": "$name",
"suite": "State Persistence",
"status": "$status",
"error": "$error",
"screenshots": [$screenshots]
}
RESULT
)")
}
# ════════════════════════════════════════════════════════════════════
# Step 0 — Install Hooks
# ════════════════════════════════════════════════════════════════════
step "Install debug hooks"
assert_app_foreground || add_test_result "App Check" "failed" "Not foreground"
hook_result=$(install_debug_hook 2>&1 || echo '{"error":"failed"}')
log_info "Video hook: $hook_result"
take_screenshot "00-initial"
# ════════════════════════════════════════════════════════════════════
# Step 1 — Navigate to Feed
# CUSTOMIZE: Change nav_explore to your feed navigation
# ════════════════════════════════════════════════════════════════════
step "Navigate to feed screen"
nav_home 2>/dev/null || true
sleep 1
nav_explore
sleep 4
overlay=$(check_error_overlay)
[ "$overlay" = "visible" ] && dismiss_error_overlay && sleep 1
# Reset to index 0
current_idx=$(get_current_feed_index)
if [ "$current_idx" != "0" ] && [ "$current_idx" != "-1" ]; then
scroll_to_index 0
sleep 3
fi
# Install state hook after feed is mounted
state_hook_result=$(install_state_debug_hook 2>&1 || echo '{"error":"failed"}')
log_info "State hook: $state_hook_result"
take_screenshot "01-feed"
add_test_result "Navigate to Feed" "passed" "" "\"01-feed.png\""
# ════════════════════════════════════════════════════════════════════
# Step 2 — Record Item Identity
# ════════════════════════════════════════════════════════════════════
step "Record first item identity"
first_item=$(query_item_state 0 2>&1 || echo '{"error":"query failed"}')
log_info "First item: $first_item"
item_id=$(echo "$first_item" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).id||'unknown')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
has_ok=$(echo "$first_item" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).ok?'yes':'no')}catch(e){console.log('no')}});
" 2>/dev/null || echo "no")
if [ "$has_ok" = "yes" ]; then
log_pass "Item captured: $item_id"
add_test_result "Record Identity" "passed" ""
else
# Retry with re-installed hook
install_state_debug_hook >/dev/null 2>&1 || true
sleep 2
first_item=$(query_item_state 0 2>&1 || echo '{"error":"retry failed"}')
has_ok=$(echo "$first_item" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).ok?'yes':'no')}catch(e){console.log('no')}});
" 2>/dev/null || echo "no")
if [ "$has_ok" = "yes" ]; then
add_test_result "Record Identity" "passed" "Retry succeeded"
else
add_test_result "Record Identity" "skipped" "Could not read data"
fi
fi
# ════════════════════════════════════════════════════════════════════
# Step 3 — Verify Initial State
# ════════════════════════════════════════════════════════════════════
step "Verify initial state (${STATE_PROPERTY} should be false)"
initial_value=$(echo "$first_item" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).${STATE_PROPERTY}?'true':'false')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
take_screenshot "02-initial-state"
if [ "$initial_value" = "true" ]; then
log_warn "State is already true — toggling off for clean baseline"
toggle_item_property_cdp >/dev/null 2>&1
sleep 2
add_test_result "Initial State" "passed" "Reset to false"
elif [ "$initial_value" = "false" ]; then
log_pass "Clean baseline (${STATE_PROPERTY}=false)"
add_test_result "Initial State" "passed" "" "\"02-initial-state.png\""
else
add_test_result "Initial State" "skipped" "Unknown" "\"02-initial-state.png\""
fi
# ════════════════════════════════════════════════════════════════════
# Step 4 — Mutate State
# ════════════════════════════════════════════════════════════════════
step "Mutate state (set ${STATE_PROPERTY}=true)"
toggle_result=$(toggle_item_property_cdp 2>&1 || echo '{"error":"failed"}')
log_info "Toggle result: $toggle_result"
take_screenshot "03-after-mutate"
after_value=$(query_item_state 0 2>&1 || echo '{"error":"failed"}')
after_prop=$(echo "$after_value" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).${STATE_PROPERTY}?'true':'false')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
if [ "$after_prop" = "true" ]; then
log_pass "State mutated successfully"
add_test_result "Mutate State" "passed" "" "\"03-after-mutate.png\""
else
log_warn "Could not verify mutation: $after_prop"
add_test_result "Mutate State" "skipped" "Verification failed" "\"03-after-mutate.png\""
fi
# ════════════════════════════════════════════════════════════════════
# Step 5 — Scroll Away
# ════════════════════════════════════════════════════════════════════
for i in $(seq 1 $STATE_SCROLL_COUNT); do
step "Scroll to item #$i"
scroll_to_next_video
sleep 2
take_screenshot "04-scroll-${i}"
add_test_result "Scroll to Item $i" "passed" "" "\"04-scroll-${i}.png\""
done
# Verify we scrolled away
away_index=$(get_current_feed_index)
log_info "Current index: $away_index"
take_screenshot "05-scrolled-away"
add_test_result "Scrolled Away" "passed" "Index: $away_index" "\"05-scrolled-away.png\""
# ════════════════════════════════════════════════════════════════════
# Step 6 — Scroll Back
# ════════════════════════════════════════════════════════════════════
step "Scroll back to first item"
scroll_to_index 0
sleep 3
back_index=$(get_current_feed_index)
take_screenshot "06-scrolled-back"
if [ "$back_index" = "0" ]; then
log_pass "Back at first item"
add_test_result "Scroll Back" "passed" "" "\"06-scrolled-back.png\""
else
scroll_back_to_start
sleep 2
add_test_result "Scroll Back" "passed" "Used fallback" "\"06-scrolled-back.png\""
fi
# ════════════════════════════════════════════════════════════════════
# Step 7 — KEY ASSERTION: State Persisted
# ════════════════════════════════════════════════════════════════════
step "★ KEY ASSERTION: Verify ${STATE_PROPERTY} persisted"
# Re-install hook (fiber tree may have shifted)
install_state_debug_hook >/dev/null 2>&1 || true
sleep 2
persist_state=$(query_item_state 0 2>&1 || echo '{"error":"failed"}')
persist_value=$(echo "$persist_state" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{console.log(JSON.parse(d).${STATE_PROPERTY}?'true':'false')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
take_screenshot "07-persisted"
if [ "$persist_value" = "true" ]; then
log_pass "★ STATE PERSISTED! ${STATE_PROPERTY} is still true after scrolling away and back"
add_test_result "State Persisted" "passed" "" "\"07-persisted.png\""
elif [ "$persist_value" = "unknown" ]; then
log_warn "Could not verify — CDP state read failed"
add_test_result "State Persisted" "skipped" "CDP failed" "\"07-persisted.png\""
else
log_fail "★ STATE LOST! ${STATE_PROPERTY} is false after scrolling back"
add_test_result "State Persisted" "failed" "${STATE_PROPERTY}=false after scroll" "\"07-persisted.png\""
fi
# ════════════════════════════════════════════════════════════════════
# Step 8 — Cleanup
# ════════════════════════════════════════════════════════════════════
step "Cleanup: restore original state"
if [ "$persist_value" = "true" ]; then
toggle_item_property_cdp >/dev/null 2>&1 || true
sleep 1
log_pass "State restored"
add_test_result "Cleanup" "passed" ""
else
log_info "No cleanup needed"
add_test_result "Cleanup" "passed" "Not needed"
fi
take_screenshot "08-final"
# ════════════════════════════════════════════════════════════════════
# Generate Report
# ════════════════════════════════════════════════════════════════════
step "Generating report"
SKIP_COUNT=0
for r in "${TEST_RESULTS[@]}"; do
echo "$r" | grep -q '"status": "skipped"' && SKIP_COUNT=$((SKIP_COUNT + 1))
done
TESTS_JSON=""
for r in "${TEST_RESULTS[@]}"; do
[ -n "$TESTS_JSON" ] && TESTS_JSON+=","
TESTS_JSON+="$r"
done
REPORT_FILE="$TEST_OUTPUT_DIR/${TEST_NAME}-report.json"
end_time=$(date +%s)
duration=$((end_time - TEST_START_TIME))
cat > "$REPORT_FILE" <<REPORT
{
"title": "State Persistence QA",
"generatedAt": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
"suites": [{
"name": "State Persistence (${STATE_PROPERTY})",
"type": "e2e",
"passed": $PASS_COUNT,
"failed": $FAIL_COUNT,
"skipped": $SKIP_COUNT,
"duration": $((duration * 1000)),
"tests": [$TESTS_JSON],
"screenshotDir": "$SCREENSHOT_DIR/$TEST_NAME"
}],
"totalPassed": $PASS_COUNT,
"totalFailed": $FAIL_COUNT,
"totalSkipped": $SKIP_COUNT,
"totalDuration": $((duration * 1000))
}
REPORT
log_info "Report: $REPORT_FILE"
teardown_test
echo ""
echo "State persistence test completed!"
echo " Screenshots: $SCREENSHOT_DIR/$TEST_NAME/"
echo " Report: $REPORT_FILE"
echo ""

View File

@@ -0,0 +1,290 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ state-helpers.sh — State Persistence Inspection & Mutation ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file in state persistence test scripts: ║
# ║ source "$(dirname "$0")/../lib/state-helpers.sh" ║
# ║ ║
# ║ Provides: ║
# ║ • State debug hook installation ║
# ║ • Item state queries (any property at any index) ║
# ║ • CDP-first state mutation with tap fallback ║
# ║ • Feed index tracking and scroll-to-index ║
# ║ • Assertion helpers for property values ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source shared helpers (chains to test-helpers + cdp-helpers) ─────
STATE_SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCROLL_SKILL_DIR="$(cd "$STATE_SKILL_DIR/../qa-scroll" && pwd)"
source "$SCROLL_SKILL_DIR/lib/scroll-helpers.sh"
# ── Install State Debug Hook ────────────────────────────────────────
# Sets up a global accessor for reading feed item properties.
install_state_debug_hook() {
cdp_eval_safe "
globalThis.__qaItemState = function(targetIndex) {
try {
var feed = globalThis.${GLOBAL_FEED_VAR};
if (!feed) return JSON.stringify({error: '${GLOBAL_FEED_VAR} not available', method: 'none'});
var idx = (targetIndex !== undefined && targetIndex !== null) ? targetIndex : feed.currentIndex;
// Method 1: getItem() directly
if (typeof feed.getItem === 'function') {
var item = feed.getItem(idx);
if (item && typeof item === 'object') {
return JSON.stringify({
ok: true,
method: 'getItem',
index: idx,
${STATE_PROPERTY}: !!item.${STATE_PROPERTY},
${STATE_COUNTER_PROPERTY}: item.${STATE_COUNTER_PROPERTY} || 0,
id: item.id || item._id || 'unknown',
title: (item.description || item.title || item.name || '').substring(0, 50)
});
}
}
// Method 2: getData() array
if (typeof feed.getData === 'function') {
var data = feed.getData();
if (data && idx < data.length && data[idx]) {
var it = data[idx];
return JSON.stringify({
ok: true,
method: 'getData',
index: idx,
${STATE_PROPERTY}: !!it.${STATE_PROPERTY},
${STATE_COUNTER_PROPERTY}: it.${STATE_COUNTER_PROPERTY} || 0,
id: it.id || it._id || 'unknown',
title: (it.description || it.title || it.name || '').substring(0, 50)
});
}
}
return JSON.stringify({
error: 'feed methods not available',
method: 'none',
hasGetItem: typeof feed.getItem === 'function',
hasGetData: typeof feed.getData === 'function',
dataLength: feed.dataLength,
currentIndex: feed.currentIndex
});
} catch(e) {
return JSON.stringify({error: e.message, method: 'exception'});
}
};
var feed = globalThis.${GLOBAL_FEED_VAR};
return JSON.stringify({
ok: true,
hookInstalled: 'itemState',
hasGetItem: !!(feed && typeof feed.getItem === 'function'),
hasGetData: !!(feed && typeof feed.getData === 'function'),
dataLength: feed ? feed.dataLength : 0
});
"
}
# ── Query Item State ─────────────────────────────────────────────────
# Returns JSON: {ok, STATE_PROPERTY, STATE_COUNTER_PROPERTY, id, ...}
query_item_state() {
local target_index="${1:-}"
local index_arg=""
[ -n "$target_index" ] && index_arg="$target_index"
local attempt=0
local max_attempts=3
local result=""
while [ $attempt -lt $max_attempts ]; do
result=$(cdp_eval "
(function() {
try {
if (globalThis.__qaItemState) {
return globalThis.__qaItemState($index_arg);
}
return JSON.stringify({error: 'state hook not installed'});
} catch(e) {
return JSON.stringify({error: e.message});
}
})();
" 2>/dev/null || echo '{"error":"cdp failed"}')
local has_ok
has_ok=$(echo "$result" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.ok?'yes':'no')}catch(e){console.log('no')}});
" 2>/dev/null || echo "no")
if [ "$has_ok" = "yes" ]; then
echo "$result"
return 0
fi
attempt=$((attempt + 1))
[ $attempt -lt $max_attempts ] && sleep 1
done
echo "$result"
}
# ── Feed Index ───────────────────────────────────────────────────────
get_current_feed_index() {
local result
result=$(cdp_eval "
(function() {
if (globalThis.${GLOBAL_FEED_VAR}) {
return '' + (globalThis.${GLOBAL_FEED_VAR}.currentIndex || 0);
}
return '-1';
})();
" 2>/dev/null || echo "-1")
echo "$result"
}
scroll_to_index() {
local target_index="$1"
step "Scrolling to feed index $target_index"
local result
result=$(cdp_eval "
(function() {
if (globalThis.${GLOBAL_FEED_VAR} && globalThis.${GLOBAL_FEED_VAR}.scrollToIndex) {
var before = globalThis.${GLOBAL_FEED_VAR}.currentIndex;
globalThis.${GLOBAL_FEED_VAR}.scrollToIndex($target_index);
return JSON.stringify({ok:true, before:before, after:$target_index});
}
return JSON.stringify({error:'scrollToIndex not available'});
})();
" 2>/dev/null || echo '{"error":"cdp failed"}')
log_info "Scroll to index result: $result"
sleep 3
}
scroll_back_to_start() {
local current_index
current_index=$(get_current_feed_index)
if [ "$current_index" = "-1" ] || [ "$current_index" = "0" ]; then
log_info "Already at start or unknown index"
return 0
fi
# Try scrollToIndex first
local result
result=$(cdp_eval "
(function() {
if (globalThis.${GLOBAL_FEED_VAR} && globalThis.${GLOBAL_FEED_VAR}.scrollToIndex) {
globalThis.${GLOBAL_FEED_VAR}.scrollToIndex(0);
return 'ok';
}
return 'no_hook';
})();
" 2>/dev/null || echo "error")
if [ "$result" = "ok" ]; then
log_info "Scrolled to index 0 via scrollToIndex"
sleep 3
return 0
fi
# Fallback: swipe down N times
log_info "Using swipe-down fallback ($current_index times)"
local i=0
while [ $i -lt "$current_index" ]; do
swipe $SWIPE_END_X $SWIPE_END_Y $SWIPE_START_X $SWIPE_START_Y
sleep 2
i=$((i + 1))
done
}
# ── State Mutation ───────────────────────────────────────────────────
# Toggle a boolean property on the current feed item via CDP.
toggle_item_property_cdp() {
local current_index
current_index=$(get_current_feed_index)
local result
result=$(cdp_eval_safe "
var feed = globalThis.${GLOBAL_FEED_VAR};
if (!feed || typeof feed.getItem !== 'function') {
return JSON.stringify({error: 'feed not available'});
}
var item = feed.getItem($current_index);
if (!item) {
return JSON.stringify({error: 'no item at index $current_index'});
}
try {
var oldValue = !!item.${STATE_PROPERTY};
item.${STATE_PROPERTY} = !oldValue;
if (item.${STATE_COUNTER_PROPERTY} !== undefined) {
item.${STATE_COUNTER_PROPERTY} = oldValue
? (item.${STATE_COUNTER_PROPERTY} - 1)
: (item.${STATE_COUNTER_PROPERTY} + 1);
}
return JSON.stringify({ok: true, was: oldValue, now: !oldValue, id: item.id});
} catch(e) {
return JSON.stringify({error: e.message});
}
" 2>/dev/null || echo '{"error":"cdp failed"}')
echo "$result"
}
# ── Assertion Helpers ────────────────────────────────────────────────
# Assert that item at index has the property set to true
assert_item_property_true() {
local target_index="${1:-}"
local state
state=$(query_item_state "$target_index")
local value
value=$(echo "$state" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.${STATE_PROPERTY}?'true':'false')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
if [ "$value" = "true" ]; then
log_pass "Item ${STATE_PROPERTY} is true"
return 0
elif [ "$value" = "unknown" ]; then
log_warn "Could not determine ${STATE_PROPERTY} state"
return 2
else
log_fail "Item ${STATE_PROPERTY} is false (expected true)"
return 1
fi
}
# Assert that item at index has the property set to false
assert_item_property_false() {
local target_index="${1:-}"
local state
state=$(query_item_state "$target_index")
local value
value=$(echo "$state" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.${STATE_PROPERTY}?'true':'false')}catch(e){console.log('unknown')}});
" 2>/dev/null || echo "unknown")
if [ "$value" = "false" ]; then
log_pass "Item ${STATE_PROPERTY} is false (as expected)"
return 0
elif [ "$value" = "unknown" ]; then
log_warn "Could not determine ${STATE_PROPERTY} state"
return 2
else
log_fail "Item ${STATE_PROPERTY} is true (expected false)"
return 1
fi
}
echo "State persistence helpers loaded."

View File

@@ -0,0 +1,43 @@
#!/bin/bash
# Run the State Persistence QA test
# Setup guard runs automatically.
#
# Usage: bash .pi/skills/qa-automation/qa-state-persistence/run.sh
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
REPORT_FILE="/tmp/qa-tests/state-persistence-report.json"
echo ""
echo "╔═══════════════════════════════════════════════════╗"
echo "║ State Persistence QA ║"
echo "║ Started: $(date '+%Y-%m-%d %H:%M:%S') "
echo "╚═══════════════════════════════════════════════════╝"
echo ""
bash "$SKILL_DIR/flows/example-state-test.sh"
EXIT_CODE=$?
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ -f "$REPORT_FILE" ]; then
echo "Report JSON: $REPORT_FILE"
echo ""
passed=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalPassed);" 2>/dev/null || echo "?")
failed=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalFailed);" 2>/dev/null || echo "?")
skipped=$(node -e "var r=require('$REPORT_FILE'); console.log(r.totalSkipped);" 2>/dev/null || echo "?")
duration=$(node -e "var r=require('$REPORT_FILE'); console.log((r.totalDuration/1000).toFixed(1));" 2>/dev/null || echo "?")
echo " Passed: $passed"
echo " Failed: $failed"
echo " Skipped: $skipped"
echo " Duration: ${duration}s"
else
echo "WARNING: No report file generated"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
exit $EXIT_CODE

View File

@@ -0,0 +1,177 @@
---
name: qa-test-flows
description: >
Automated UI test flow framework using CDP + agent-device for native apps and
agent-browser for web apps. Zero-framework approach — bash scripts orchestrate
simulators with no Detox, Maestro, or Appium dependencies. Provides test lifecycle,
screenshot capture, assertion helpers, and JSON report generation.
Invoke when user says "run tests", "test the app", "smoke test", "regression test",
"verify flows", "run e2e tests", or any task requiring automated UI testing.
allowed-tools: Bash(agent-device:*) Bash(agent-browser:*) Bash(xcrun:*) Bash(adb:*) Bash(open:*) Bash(find:*) Bash(npx:*) Bash(node:*) Read
---
# qa-test-flows
Automated UI test flow framework using a **dual-driver architecture**: **CDP** for navigation/state control and **agent-device** for screenshots/visual assertions in native apps, plus **agent-browser** for web app testing. This is a zero-framework approach — bash scripts orchestrate the entire test lifecycle with no Detox, Maestro, or Appium dependencies.
## Key Innovation: CDP + agent-device + agent-browser
Native mobile apps with complex gesture handlers (full-screen video players, swipe-based feeds) often make coordinate-based tapping unreliable. We solve this by connecting directly to the React Native Hermes runtime via Metro's CDP WebSocket, giving us:
- **Direct navigation control** via `navigationRef.current.navigate()` — no touch coordinates needed
- **Runtime state inspection** — check current route, user state, storage
- **Module access** via Metro's `__r()` require — load any app module at runtime
- **Screenshots & visual verification** via agent-device (best-in-class for simulators)
- **Web app testing** via agent-browser when the same app has a web version
## When to Use
- Smoke testing before submission or release builds
- Regression testing after navigation or feature changes
- Flow verification (auth, navigation, data entry, checkout)
- Platform parity checks (iOS vs Android)
- Pre-PR validation of multi-screen flows
- State persistence verification
## Test Architecture
### Dual Driver System
| Driver | Purpose | Use For |
|--------|---------|---------|
| **CDP (WebSocket)** | Navigation, state queries, JS execution | React Native apps (Hermes runtime) |
| **agent-device** | Screenshots, coordinate taps, swipes, accessibility | Native app simulators/emulators |
| **agent-browser** | Full browser automation, DOM interaction | Web apps, PWAs, browser testing |
### Test Flow Format
All test flows follow a consistent bash template:
```bash
#!/bin/bash
source "$(dirname "$0")/../../lib/test-helpers.sh"
source "$(dirname "$0")/../../lib/cdp-helpers.sh"
TEST_NAME="my-flow"
setup_test "$TEST_NAME"
# Step 1: Navigate
step "Navigate to target screen"
cdp_navigate "TargetScreen"
sleep 2
take_screenshot "01-target-screen"
assert_screenshot "01-target-screen"
# Step 2: Interact
step "Perform action"
tap 200 400
sleep 1
take_screenshot "02-after-action"
# Step 3: Verify
step "Verify result"
route=$(cdp_get_route)
log_info "Route: $route"
teardown_test
```
## Running Tests
### Single Test
```bash
bash .pi/skills/qa-automation/qa-test-flows/flows/smoke/example-smoke.sh
```
### All Tests
```bash
bash .pi/skills/qa-automation/qa-test-flows/run-all.sh
```
### With Logging
```bash
bash .pi/skills/qa-automation/qa-test-flows/flows/smoke/example-smoke.sh 2>&1 | tee /tmp/smoke.log
```
## Writing New Tests
### 1. Copy the template
```bash
cp .pi/skills/qa-automation/qa-test-flows/templates/new-flow.sh.template \
.pi/skills/qa-automation/qa-test-flows/flows/my-suite/my-test.sh
chmod +x .pi/skills/qa-automation/qa-test-flows/flows/my-suite/my-test.sh
```
### 2. Edit the template
Replace `CUSTOMIZE` markers with your app-specific details.
### 3. Find coordinates
```bash
agent-device screenshot /tmp/debug.png
open /tmp/debug.png # Measure tap targets
```
### 4. Run and verify
```bash
bash .pi/skills/qa-automation/qa-test-flows/flows/my-suite/my-test.sh
ls /tmp/qa-tests/screenshots/my-test/
```
## Helper Libraries
### test-helpers.sh
| Function | Purpose |
|----------|---------|
| `setup_test "name"` | Initialize test, create directories |
| `teardown_test` | Report results, cleanup |
| `step "description"` | Log a numbered test step |
| `log_pass/fail/info/warn` | Status logging |
| `take_screenshot "name"` | Capture screenshot |
| `tap x y` | Tap at coordinates |
| `swipe x1 y1 x2 y2` | Swipe gesture |
| `scroll_dir direction` | Scroll up/down/left/right |
| `assert_app_foreground` | Verify app is running |
| `assert_screenshot "name"` | Verify screenshot exists |
| `assert_text_visible "text"` | Check accessibility tree |
| `launch_app` | Launch/relaunch the app |
| `close_app` | Terminate the app |
### cdp-helpers.sh
| Function | Purpose |
|----------|---------|
| `cdp_eval "expression"` | Execute JS in Hermes |
| `cdp_eval_safe "expression"` | Eval with ErrorUtils suppression |
| `cdp_navigate "Screen"` | Navigate to screen |
| `cdp_navigate_tab "TabScreen"` | Navigate to tab |
| `cdp_get_route` | Get current route name |
| `cdp_get_state` | Get full nav state |
| `cdp_go_back` | Go back |
| `nav_explore/search/home/profile` | Quick tab navigation |
## File Structure
```
qa-test-flows/
├── SKILL.md # This file
├── lib/
│ ├── test-helpers.sh # Core test framework
│ └── cdp-helpers.sh # CDP interaction helpers
├── flows/
│ └── smoke/
│ └── example-smoke.sh # Example smoke test
├── templates/
│ └── new-flow.sh.template # Template for new tests
└── run-all.sh # Master test runner
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| agent-device click hangs | Commands run in background with timeout — if still hanging, increase `DEVICE_CMD_TIMEOUT` |
| Dev console overlay covers screen | Call `dismiss_error_overlay` from scroll-helpers, or use CDP to suppress LogBox |
| Accessibility tree is sparse | React Native trees are thinner than web — prefer coordinates + screenshots |
| CDP timeout | Check dev server is running: `curl $DEV_SERVER_HEALTH` |
| Module ID changed after code update | Delete cache: `rm $NAV_MODULE_CACHE` — setup guard will re-scan |

View File

@@ -0,0 +1,108 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ Example Smoke Test — Quick Verification of Core Flows ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ CUSTOMIZE: Replace screen names and navigation with your app's. ║
# ║ ║
# ║ Usage: bash .pi/skills/qa-automation/qa-test-flows/flows/smoke/example-smoke.sh
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/../../lib/test-helpers.sh"
source "$SCRIPT_DIR/../../lib/cdp-helpers.sh"
TEST_NAME="smoke-test"
setup_test "$TEST_NAME"
# ── Step 1: App Launch ───────────────────────────────────────────────
step "Verify app is running"
assert_app_foreground || {
launch_app
sleep 3
}
take_screenshot "01-app-launched"
assert_screenshot "01-app-launched"
log_pass "App is running"
# ── Step 2: Check Login State ────────────────────────────────────────
step "Check authentication state"
logged_in=$(cdp_is_logged_in 2>/dev/null || echo "unknown")
log_info "Logged in: $logged_in"
if [ "$logged_in" = "false" ]; then
log_warn "App is on login screen — some tests may be skipped"
fi
take_screenshot "02-auth-state"
# ── Step 3: Navigate Through Main Tabs ───────────────────────────────
# CUSTOMIZE: Replace with your app's tab screens
step "Navigate to Tab 1 (Explore/Home)"
nav_explore 2>/dev/null || tap_tab 1
sleep 2
take_screenshot "03-tab-1"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route: $route"
log_pass "Tab 1 loaded"
step "Navigate to Tab 2 (Search)"
nav_search 2>/dev/null || tap_tab 2
sleep 2
take_screenshot "04-tab-2"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route: $route"
log_pass "Tab 2 loaded"
step "Navigate to Tab 3 (Home/Create)"
nav_home 2>/dev/null || tap_tab 3
sleep 2
take_screenshot "05-tab-3"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route: $route"
log_pass "Tab 3 loaded"
step "Navigate to Tab 5 (Profile)"
nav_profile 2>/dev/null || tap_tab 5
sleep 2
take_screenshot "06-tab-5"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route: $route"
log_pass "Tab 5 loaded"
# ── Step 4: Navigate to Detail Screen ────────────────────────────────
# CUSTOMIZE: Replace with a screen in your app
step "Navigate to Settings (detail screen)"
cdp_navigate "$SCREEN_SETTINGS" 2>/dev/null || {
log_warn "CDP navigation failed — trying tap"
tap $SETTINGS_BUTTON_X $SETTINGS_BUTTON_Y
}
sleep 2
take_screenshot "07-settings"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route: $route"
# ── Step 5: Go Back ──────────────────────────────────────────────────
step "Navigate back"
cdp_go_back 2>/dev/null || go_back
sleep 2
take_screenshot "08-back"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Route after back: $route"
log_pass "Back navigation works"
# ── Step 6: Final State ─────────────────────────────────────────────
step "Final state check"
assert_app_foreground
take_screenshot "09-final"
log_pass "Smoke test complete — app is stable"
# ── Report ───────────────────────────────────────────────────────────
teardown_test
echo ""
echo "Smoke test completed!"
echo " Screenshots: $SCREENSHOT_DIR/$TEST_NAME/"
echo ""

View File

@@ -0,0 +1,340 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ cdp-helpers.sh — Chrome DevTools Protocol Helpers ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file for CDP interaction with React Native apps: ║
# ║ source "$(dirname "$0")/../../lib/cdp-helpers.sh" ║
# ║ ║
# ║ Provides: ║
# ║ • CDP evaluation (cdp_eval, cdp_eval_safe) ║
# ║ • Navigation (cdp_navigate, cdp_navigate_tab) ║
# ║ • State queries (cdp_get_route, cdp_get_state) ║
# ║ • Auto-discovery (CDP target, navigation module) ║
# ║ ║
# ║ Requires: Node.js, 'ws' npm package, Metro dev server running ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source configuration ─────────────────────────────────────────────
CDP_HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CDP_QA_ROOT="$(cd "$CDP_HELPERS_DIR/../.." && pwd)"
# Only source config if not already loaded
if [ -z "${QA_AUTOMATION_DIR:-}" ]; then
source "$CDP_QA_ROOT/qa.config.sh"
fi
# ── CDP State ────────────────────────────────────────────────────────
_CDP_WS_URL_RESOLVED=""
_CDP_NAV_MODULE_ID=""
# ── CDP Auto-Discovery ──────────────────────────────────────────────
# Ensure we have a CDP WebSocket URL
_ensure_cdp_ws_url() {
if [ -n "$_CDP_WS_URL_RESOLVED" ]; then
echo "$_CDP_WS_URL_RESOLVED"
return 0
fi
local ws_url
ws_url=$(qa_detect_cdp_ws_url 2>/dev/null || echo "")
if [ -n "$ws_url" ]; then
_CDP_WS_URL_RESOLVED="$ws_url"
echo "$ws_url"
return 0
fi
# Last resort: construct from config
if [ "$CDP_WS_URL" != "auto" ] && [ -n "$CDP_WS_URL" ]; then
_CDP_WS_URL_RESOLVED="$CDP_WS_URL"
echo "$CDP_WS_URL"
return 0
fi
echo ""
return 1
}
# Ensure we have a navigation module ID
_ensure_nav_module_id() {
if [ -n "$_CDP_NAV_MODULE_ID" ]; then
echo "$_CDP_NAV_MODULE_ID"
return 0
fi
# Check cache
if [ -f "$NAV_MODULE_CACHE" ]; then
local cached
cached=$(cat "$NAV_MODULE_CACHE" 2>/dev/null || echo "")
if [ -n "$cached" ]; then
_CDP_NAV_MODULE_ID="$cached"
echo "$cached"
return 0
fi
fi
# Auto-detect
local module_id
module_id=$(qa_detect_nav_module 2>/dev/null || echo "")
if [ -n "$module_id" ]; then
_CDP_NAV_MODULE_ID="$module_id"
echo "$module_id"
return 0
fi
echo ""
return 1
}
# ── Core CDP Functions ───────────────────────────────────────────────
# Execute JavaScript in the React Native Hermes runtime via CDP.
# Usage: cdp_eval "javascript expression"
# Returns: the evaluated result
cdp_eval() {
local expression="$1"
local ws_url
ws_url=$(_ensure_cdp_ws_url)
if [ -z "$ws_url" ]; then
echo '{"error":"no CDP WebSocket URL"}'
return 1
fi
cd "$PROJECT_DIR"
node -e "
const WebSocket=require('ws');
const ws=new WebSocket('$ws_url');
ws.on('open',()=>{
ws.send(JSON.stringify({
id:1,
method:'Runtime.evaluate',
params:{
expression: \`$expression\`,
returnByValue:true
}
}));
});
ws.on('message',d=>{
const m=JSON.parse(d);
if(m.id===1){
if(m.result?.result?.value) console.log(m.result.result.value);
else if(m.result?.exceptionDetails) console.error('CDP Error:', m.result.exceptionDetails.text);
else console.log(JSON.stringify(m.result));
ws.close();
process.exit(0);
}
});
ws.on('error',e=>{console.error('WS Error:',e.message);process.exit(1)});
setTimeout(()=>{console.error('CDP Timeout');process.exit(1)},8000);
" 2>&1
}
# Safe CDP eval — suppresses ErrorUtils/LogBox during execution.
# Use for any eval that touches Metro modules or could trigger side effects.
# Usage: cdp_eval_safe "javascript expression"
cdp_eval_safe() {
local expression="$1"
cdp_eval "
(function() {
var _origHandler = globalThis.ErrorUtils ? ErrorUtils.getGlobalHandler() : null;
var _origCE = console.error;
if (globalThis.ErrorUtils) ErrorUtils.setGlobalHandler(function() {});
console.error = function() {};
try {
return (function() { $expression })();
} finally {
if (globalThis.ErrorUtils && _origHandler) ErrorUtils.setGlobalHandler(_origHandler);
console.error = _origCE;
}
})();
"
}
# ── Navigation Functions ─────────────────────────────────────────────
# Navigate to a screen by name.
# Usage: cdp_navigate "ScreenName" [params_json]
cdp_navigate() {
local screen="$1"
local params="${2:-}"
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo '{"error":"navigation module not found"}'
return 1
fi
if [ -n "$params" ]; then
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
ref.current.navigate('${screen}', ${params});
JSON.stringify({ ok: true, route: ref.current.getCurrentRoute()?.name });
"
else
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
ref.current.navigate('${screen}');
JSON.stringify({ ok: true, route: ref.current.getCurrentRoute()?.name });
"
fi
}
# Navigate to a bottom tab screen.
# Usage: cdp_navigate_tab "ScreenName"
cdp_navigate_tab() {
local tab="$1"
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo '{"error":"navigation module not found"}'
return 1
fi
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
ref.current.navigate('${TAB_NAVIGATOR_NAME}', { screen: '${tab}' });
JSON.stringify({ ok: true, route: ref.current.getCurrentRoute()?.name });
"
}
# Get current route name.
# Usage: route=$(cdp_get_route)
cdp_get_route() {
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo "unknown"
return 1
fi
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
ref.current.getCurrentRoute()?.name || 'unknown';
"
}
# Get full navigation state as JSON.
cdp_get_state() {
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo '{"error":"navigation module not found"}'
return 1
fi
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
const state = ref.current.getRootState();
function getRoutes(s, depth) {
let routes = [];
if (s.routes) {
for (const r of s.routes) {
routes.push({name: r.name, depth});
if (r.state) routes = routes.concat(getRoutes(r.state, depth+1));
}
}
return routes;
}
JSON.stringify({
current: ref.current.getCurrentRoute()?.name,
routes: getRoutes(state, 0)
});
"
}
# Go back in navigation.
cdp_go_back() {
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo "error: no nav module"
return 1
fi
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
if (ref.current.canGoBack()) {
ref.current.goBack();
'went back to: ' + ref.current.getCurrentRoute()?.name;
} else {
'cannot go back';
}
"
}
# Reset navigation to initial state.
cdp_reset_navigation() {
local nav_id
nav_id=$(_ensure_nav_module_id)
if [ -z "$nav_id" ]; then
echo "error: no nav module"
return 1
fi
cdp_eval "
const ref = __r(${nav_id}).navigationRef;
ref.current.reset({
index: 0,
routes: [{ name: 'Main', state: { routes: [{ name: '${TAB_NAVIGATOR_NAME}' }] } }]
});
'reset to ${TAB_NAVIGATOR_NAME}';
"
}
# ── Convenience Tab Navigation ───────────────────────────────────────
# These use the screen names from qa.config.sh
nav_tab_1() { [ -n "$SCREEN_EXPLORE" ] && cdp_navigate_tab "$SCREEN_EXPLORE" || echo "SCREEN_EXPLORE not configured"; }
nav_tab_2() { [ -n "$SCREEN_SEARCH" ] && cdp_navigate_tab "$SCREEN_SEARCH" || echo "SCREEN_SEARCH not configured"; }
nav_tab_3() { [ -n "$SCREEN_HOME" ] && cdp_navigate_tab "$SCREEN_HOME" || echo "SCREEN_HOME not configured"; }
nav_tab_4() { echo "Tab 4 not configured — set SCREEN name and add to cdp-helpers.sh"; }
nav_tab_5() { [ -n "$SCREEN_PROFILE" ] && cdp_navigate_tab "$SCREEN_PROFILE" || echo "SCREEN_PROFILE not configured"; }
# Generic by name
nav_explore() { nav_tab_1; }
nav_search() { nav_tab_2; }
nav_home() { nav_tab_3; }
nav_profile() { nav_tab_5; }
# ── Utility Functions ────────────────────────────────────────────────
# Check if user is logged in (heuristic: check current route)
cdp_is_logged_in() {
local route
route=$(cdp_get_route 2>/dev/null || echo "unknown")
if echo "$route" | grep -qiE "login|signup|auth|welcome|launch"; then
echo "false"
else
echo "true"
fi
}
# Clear cached module IDs (force re-discovery on next call)
cdp_clear_cache() {
_CDP_WS_URL_RESOLVED=""
_CDP_NAV_MODULE_ID=""
[ -f "$NAV_MODULE_CACHE" ] && rm -f "$NAV_MODULE_CACHE" 2>/dev/null || true
[ -f "$VIDEO_MODULE_CACHE" ] && rm -f "$VIDEO_MODULE_CACHE" 2>/dev/null || true
echo "CDP cache cleared"
}
# ── Export Functions ─────────────────────────────────────────────────
export -f cdp_eval cdp_eval_safe 2>/dev/null || true
export -f cdp_navigate cdp_navigate_tab cdp_get_route cdp_get_state 2>/dev/null || true
export -f cdp_go_back cdp_reset_navigation cdp_is_logged_in 2>/dev/null || true
export -f nav_explore nav_search nav_home nav_profile 2>/dev/null || true
export -f cdp_clear_cache 2>/dev/null || true
echo "CDP helpers loaded. WebSocket: ${_CDP_WS_URL_RESOLVED:-auto-detect}"

View File

@@ -0,0 +1,453 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ test-helpers.sh — Core Test Framework for QA Automation ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file at the top of every test flow script: ║
# ║ source "$(dirname "$0")/../../lib/test-helpers.sh" ║
# ║ ║
# ║ Provides: ║
# ║ • Test lifecycle (setup_test, teardown_test, step) ║
# ║ • Logging (log_pass, log_fail, log_info, log_warn) ║
# ║ • agent-device wrappers (tap, swipe, scroll, screenshot) ║
# ║ • Assertion functions (assert_app_foreground, etc.) ║
# ║ • Navigation helpers (tap_tab, launch_app, close_app) ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source configuration ─────────────────────────────────────────────
HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QA_ROOT="$(cd "$HELPERS_DIR/../.." && pwd)"
source "$QA_ROOT/qa.config.sh"
# ── Auto-detect simulator UDID if set to "auto" ─────────────────────
if [ "$SIMULATOR_UDID" = "auto" ]; then
_detected_udid=$(qa_detect_simulator_udid 2>/dev/null || echo "")
if [ -n "$_detected_udid" ]; then
SIMULATOR_UDID="$_detected_udid"
fi
fi
# ── Test State ───────────────────────────────────────────────────────
TEST_NAME=""
STEP_COUNT=0
PASS_COUNT=0
FAIL_COUNT=0
TEST_START_TIME=""
# ── Initialization ───────────────────────────────────────────────────
init_test_env() {
mkdir -p "$TEST_OUTPUT_DIR"
mkdir -p "$SCREENSHOT_DIR"
if [ ! -f "$RESULTS_FILE" ]; then
echo "Test Run: $(date '+%Y-%m-%d %H:%M:%S')" > "$RESULTS_FILE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >> "$RESULTS_FILE"
fi
}
init_test_env
# ── Test Lifecycle ───────────────────────────────────────────────────
setup_test() {
local name="$1"
TEST_NAME="$name"
TEST_START_TIME=$(date +%s)
STEP_COUNT=0
PASS_COUNT=0
FAIL_COUNT=0
mkdir -p "$SCREENSHOT_DIR/$TEST_NAME"
echo ""
echo "═══════════════════════════════════════════"
echo "🧪 TEST: $TEST_NAME"
echo "═══════════════════════════════════════════"
echo "Started: $(date '+%Y-%m-%d %H:%M:%S')"
echo ""
}
teardown_test() {
local name="${1:-$TEST_NAME}"
local end_time=$(date +%s)
local duration=$((end_time - TEST_START_TIME))
echo ""
echo "───────────────────────────────────────────"
echo "📊 RESULTS: $name"
echo " Steps: $STEP_COUNT | Passed: $PASS_COUNT | Failed: $FAIL_COUNT"
echo " Duration: ${duration}s"
if [ $FAIL_COUNT -eq 0 ]; then
echo " Status: ✅ ALL PASSED"
else
echo " Status: ❌ $FAIL_COUNT FAILURES"
fi
echo "───────────────────────────────────────────"
echo ""
echo "$(date '+%Y-%m-%d %H:%M:%S') | $name | Steps:$STEP_COUNT Pass:$PASS_COUNT Fail:$FAIL_COUNT | ${duration}s" >> "$RESULTS_FILE"
}
# ── Logging ──────────────────────────────────────────────────────────
step() {
STEP_COUNT=$((STEP_COUNT + 1))
echo " [$STEP_COUNT] $1"
}
log_pass() {
PASS_COUNT=$((PASS_COUNT + 1))
echo "$1"
}
log_fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$1"
}
log_info() {
echo " $1"
}
log_warn() {
echo " ⚠️ $1"
}
# ── agent-device Wrappers ────────────────────────────────────────────
# Take a screenshot using xcrun simctl (avoids agent-device focus issues)
# Falls back to agent-device if xcrun isn't available.
# Usage: take_screenshot "step_name" [session]
take_screenshot() {
local name="$1"
local session="${2:-$ACTIVE_SESSION}"
local path="$SCREENSHOT_DIR/$TEST_NAME/${name}.png"
mkdir -p "$(dirname "$path")"
# Prefer xcrun simctl for iOS (doesn't steal focus)
if [ "$SIMULATOR_UDID" != "auto" ] && command -v xcrun >/dev/null 2>&1; then
xcrun simctl io "$SIMULATOR_UDID" screenshot "$path" 2>/dev/null || {
# Fallback to agent-device
agent-device screenshot "$path" --session "$session" 2>/dev/null || {
log_warn "Screenshot failed for: $name"
return 1
}
}
else
agent-device screenshot "$path" --session "$session" 2>/dev/null || {
log_warn "Screenshot failed for: $name"
return 1
}
fi
echo "$path"
}
# Tap at coordinates with timeout handling
# Usage: tap x y [session]
tap() {
local x="$1"
local y="$2"
local session="${3:-$ACTIVE_SESSION}"
agent-device click "$x" "$y" --session "$session" 2>/dev/null &
local pid=$!
sleep 0.5
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 1
}
# Swipe gesture
# Usage: swipe x1 y1 x2 y2 [session]
swipe() {
local x1="$1"
local y1="$2"
local x2="$3"
local y2="$4"
local session="${5:-$ACTIVE_SESSION}"
agent-device swipe "$x1" "$y1" "$x2" "$y2" --session "$session" 2>/dev/null &
local pid=$!
sleep 0.5
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 1
}
# Scroll in a direction
# Usage: scroll_dir direction [session]
scroll_dir() {
local direction="$1"
local session="${2:-$ACTIVE_SESSION}"
agent-device scroll "$direction" --session "$session" 2>/dev/null &
local pid=$!
sleep 0.5
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 1
}
# Type text into focused field
# Usage: type_text ref_or_coords text [session]
type_text() {
local ref="$1"
local text="$2"
local session="${3:-$ACTIVE_SESSION}"
agent-device fill "$ref" "$text" --session "$session" 2>/dev/null &
local pid=$!
sleep 0.5
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 1
}
# Press home button
go_home() {
local session="${1:-$ACTIVE_SESSION}"
agent-device home --session "$session" 2>/dev/null &
local pid=$!
sleep 1
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 2
}
# Go back
go_back() {
local session="${1:-$ACTIVE_SESSION}"
agent-device back --session "$session" 2>/dev/null &
local pid=$!
sleep 1
if ps -p $pid > /dev/null 2>&1; then
kill $pid 2>/dev/null || true
wait $pid 2>/dev/null || true
fi
sleep 2
}
# Check if the app process is running on the simulator
check_appstate() {
local session="${1:-$ACTIVE_SESSION}"
if [ "$SIMULATOR_UDID" != "auto" ] && command -v xcrun >/dev/null 2>&1; then
xcrun simctl spawn "$SIMULATOR_UDID" launchctl list 2>/dev/null | grep "UIKitApplication:${APP_BUNDLE_ID}" || echo ""
else
agent-device appstate --session "$session" 2>/dev/null || echo ""
fi
}
# Get accessibility snapshot
get_snapshot() {
local session="${1:-$ACTIVE_SESSION}"
local depth="${2:-5}"
timeout 10 agent-device snapshot -i --depth "$depth" --session "$session" 2>/dev/null || echo "(snapshot timeout)"
}
# ── Assertion Functions ──────────────────────────────────────────────
# Assert app is in foreground
assert_app_foreground() {
local session="${1:-$ACTIVE_SESSION}"
local state=$(check_appstate "$session" 2>/dev/null | grep -o "$APP_BUNDLE_ID" || true)
if [ -n "$state" ]; then
log_pass "App is in foreground ($APP_BUNDLE_ID)"
return 0
else
log_fail "App is NOT in foreground"
return 1
fi
}
# Assert screenshot was captured
assert_screenshot() {
local name="$1"
local path="$SCREENSHOT_DIR/$TEST_NAME/${name}.png"
if [ -f "$path" ] && [ -s "$path" ]; then
log_pass "Screenshot captured: $name"
return 0
else
log_fail "Screenshot missing or empty: $name"
return 1
fi
}
# Assert text is visible in accessibility tree
assert_text_visible() {
local text="$1"
local session="${2:-$ACTIVE_SESSION}"
local snapshot=$(get_snapshot "$session")
if echo "$snapshot" | grep -qi "$text"; then
log_pass "Text visible: '$text'"
return 0
else
log_warn "Text not found in accessibility tree: '$text'"
return 1
fi
}
# Assert element exists in accessibility tree
assert_element_exists() {
local label="$1"
local session="${2:-$ACTIVE_SESSION}"
local snapshot=$(get_snapshot "$session")
if echo "$snapshot" | grep -qi "label.*$label\|name.*$label"; then
log_pass "Element found: '$label'"
return 0
else
log_fail "Element not found: '$label'"
return 1
fi
}
# ── Navigation Helpers ───────────────────────────────────────────────
# Tap a tab by position (1-5)
# Usage: tap_tab 1 (first tab)
tap_tab_by_index() {
local index="$1"
local x_var="TAB_${index}_X"
local x="${!x_var:-}"
if [ -z "$x" ]; then
log_fail "No coordinate defined for tab index $index (set TAB_${index}_X)"
return 1
fi
tap "$x" "$TAB_BAR_Y"
sleep 2
}
# Tap a tab by name (reads screen names from config and maps to tab index)
# Usage: tap_tab "explore"
tap_tab() {
local tab_name="$1"
local tab_name_upper=$(echo "$tab_name" | tr '[:lower:]' '[:upper:]')
# Map common names to tab indices — customize in qa.config.sh
case "$tab_name_upper" in
EXPLORE|HOME|TAB1|1) tap_tab_by_index 1 ;;
SEARCH|TAB2|2) tap_tab_by_index 2 ;;
CREATE|TAB3|3) tap_tab_by_index 3 ;;
WALLET|TAB4|4) tap_tab_by_index 4 ;;
PROFILE|TAB5|5) tap_tab_by_index 5 ;;
*)
log_fail "Unknown tab: $tab_name"
return 1
;;
esac
}
# Launch the app fresh
launch_app() {
local session="${1:-$ACTIVE_SESSION}"
step "Launching app ($APP_BUNDLE_ID)"
if [ "$SIMULATOR_UDID" != "auto" ] && command -v xcrun >/dev/null 2>&1; then
xcrun simctl terminate "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || true
sleep 1
xcrun simctl launch "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || {
log_fail "Failed to launch app via xcrun"
return 1
}
else
agent-device open "$APP_BUNDLE_ID" --session "$session" --relaunch 2>/dev/null || {
log_fail "Failed to launch app"
return 1
}
fi
sleep "$APP_SETTLE_TIME"
log_pass "App launched"
}
# Close the app
close_app() {
local session="${1:-$ACTIVE_SESSION}"
if [ "$SIMULATOR_UDID" != "auto" ] && command -v xcrun >/dev/null 2>&1; then
xcrun simctl terminate "$SIMULATOR_UDID" "$APP_BUNDLE_ID" 2>/dev/null || {
log_warn "Could not terminate app via xcrun"
return 1
}
else
agent-device close --session "$session" 2>/dev/null || {
log_warn "Could not close app"
return 1
}
fi
sleep 2
}
# Wait for app to settle
wait_settle() {
local seconds="${1:-3}"
sleep "$seconds"
}
# ── Utility Functions ────────────────────────────────────────────────
# Print test summary
print_test_summary() {
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📋 TEST EXECUTION SUMMARY"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
tail -20 "$RESULTS_FILE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
}
log_debug() {
local msg="$1"
echo "[DEBUG] $(date '+%H:%M:%S') $msg" >> "$RESULTS_FILE"
}
save_snapshot() {
local desc="$1"
local session="${2:-$ACTIVE_SESSION}"
local filename="$(echo "$desc" | tr ' ' '_' | tr -cd '[:alnum:]._-')"
local snap=$(get_snapshot "$session")
echo "$snap" > "$SCREENSHOT_DIR/$TEST_NAME/${filename}_snapshot.txt"
log_info "Saved snapshot: $filename"
}
# ── Error Handling ───────────────────────────────────────────────────
trap 'teardown_test 2>/dev/null || true' EXIT
on_error() {
local line=$1
log_fail "Error on line $line"
log_debug "Exit code: $?"
}
trap 'on_error ${LINENO}' ERR
# ── Export Functions ─────────────────────────────────────────────────
export -f setup_test teardown_test step log_pass log_fail log_info log_warn 2>/dev/null || true
export -f take_screenshot tap swipe scroll_dir type_text 2>/dev/null || true
export -f go_home go_back check_appstate get_snapshot 2>/dev/null || true
export -f assert_app_foreground assert_screenshot assert_text_visible assert_element_exists 2>/dev/null || true
export -f tap_tab tap_tab_by_index launch_app close_app wait_settle 2>/dev/null || true
export -f print_test_summary log_debug save_snapshot 2>/dev/null || true
echo "Test helpers loaded. Session: $ACTIVE_SESSION | App: $APP_BUNDLE_ID"

View File

@@ -0,0 +1,100 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ run-all.sh — Master Test Runner ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Runs all test flows in the flows/ directory tree. ║
# ║ ║
# ║ Usage: ║
# ║ bash run-all.sh # Run all tests ║
# ║ bash run-all.sh smoke # Run only smoke suite ║
# ║ bash run-all.sh --list # List available tests ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
RUNNER_DIR="$(cd "$(dirname "$0")" && pwd)"
FLOWS_DIR="$RUNNER_DIR/flows"
# ── Parse args ───────────────────────────────────────────────────────
SUITE_FILTER="${1:-}"
LIST_ONLY=false
if [ "$SUITE_FILTER" = "--list" ]; then
LIST_ONLY=true
fi
# ── Find all test scripts ───────────────────────────────────────────
find_tests() {
if [ -n "$SUITE_FILTER" ] && [ "$SUITE_FILTER" != "--list" ]; then
find "$FLOWS_DIR/$SUITE_FILTER" -name "*.sh" -type f 2>/dev/null | sort
else
find "$FLOWS_DIR" -name "*.sh" -type f 2>/dev/null | sort
fi
}
TESTS=$(find_tests)
TEST_COUNT=$(echo "$TESTS" | grep -c "." || echo "0")
if [ "$TEST_COUNT" -eq 0 ]; then
echo "No test scripts found in $FLOWS_DIR/"
[ -n "$SUITE_FILTER" ] && echo "Suite filter: $SUITE_FILTER"
exit 1
fi
# ── List mode ────────────────────────────────────────────────────────
if [ "$LIST_ONLY" = true ]; then
echo ""
echo "Available test flows ($TEST_COUNT):"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "$TESTS" | while read -r test; do
suite=$(basename "$(dirname "$test")")
name=$(basename "$test" .sh)
printf " %-15s %s\n" "[$suite]" "$name"
done
echo ""
exit 0
fi
# ── Run mode ─────────────────────────────────────────────────────────
TOTAL_PASS=0
TOTAL_FAIL=0
TOTAL_SKIP=0
START_TIME=$(date +%s)
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ QA Test Runner — Running $TEST_COUNT test(s) "
echo "║ Started: $(date '+%Y-%m-%d %H:%M:%S') "
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
declare -a RESULTS=()
echo "$TESTS" | while read -r test; do
suite=$(basename "$(dirname "$test")")
name=$(basename "$test" .sh)
echo "──────────────────────────────────────────────────────────────"
echo " Running: [$suite] $name"
echo "──────────────────────────────────────────────────────────────"
if bash "$test" 2>&1; then
echo " Result: ✅ PASSED"
else
echo " Result: ❌ FAILED (exit code: $?)"
fi
echo ""
done
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo ""
echo "══════════════════════════════════════════════════════════════════"
echo " QA Test Run Complete"
echo " Total tests: $TEST_COUNT"
echo " Duration: ${DURATION}s"
echo " Screenshots: /tmp/qa-tests/screenshots/"
echo "══════════════════════════════════════════════════════════════════"
echo ""

View File

@@ -0,0 +1,64 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ [TEST NAME] — [DESCRIPTION] ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ CUSTOMIZE: Fill in the steps below for your test flow. ║
# ║ ║
# ║ Usage: bash .pi/skills/qa-automation/qa-test-flows/flows/[suite]/[name].sh
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source Libraries ─────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/../../lib/test-helpers.sh"
source "$SCRIPT_DIR/../../lib/cdp-helpers.sh"
# ── Test Setup ───────────────────────────────────────────────────────
# CUSTOMIZE: Change this test name
TEST_NAME="my-test-flow"
setup_test "$TEST_NAME"
# ── Step 1: [Description] ───────────────────────────────────────────
step "Verify app is running"
assert_app_foreground || launch_app
take_screenshot "01-initial"
# ── Step 2: [Navigation] ────────────────────────────────────────────
# CUSTOMIZE: Navigate to your target screen
step "Navigate to target screen"
cdp_navigate "YourScreenName" # or: tap_tab 1, nav_explore, etc.
sleep 2
take_screenshot "02-target-screen"
assert_screenshot "02-target-screen"
# ── Step 3: [Interaction] ───────────────────────────────────────────
# CUSTOMIZE: Perform your test actions
step "Perform test action"
# tap 200 400 # Tap a button
# swipe 200 600 200 200 # Swipe up
# type_text @e3 "hello" # Type into a field
sleep 1
take_screenshot "03-after-action"
# ── Step 4: [Verification] ──────────────────────────────────────────
# CUSTOMIZE: Verify the expected outcome
step "Verify result"
route=$(cdp_get_route 2>/dev/null || echo "unknown")
log_info "Current route: $route"
# assert_text_visible "Expected Text"
# assert_screenshot "04-verified"
# ── Step 5: [Cleanup] ───────────────────────────────────────────────
step "Cleanup and final state"
take_screenshot "05-final"
assert_app_foreground
# ── Teardown ─────────────────────────────────────────────────────────
teardown_test
echo ""
echo "Test completed!"
echo " Screenshots: $SCREENSHOT_DIR/$TEST_NAME/"
echo ""

View File

@@ -0,0 +1,217 @@
---
name: qa-web
description: >
QA test skill for web applications using agent-browser. Tests forms, navigation,
responsive layouts, state persistence via cookies/localStorage, and visual regression.
The web counterpart to agent-device — together they cover native + web testing.
Invoke when user says "test the web app", "test localhost", "QA the website",
"test the form", "verify responsive layout", "run web tests", "test browser",
or any task requiring automated web application testing.
allowed-tools: Bash(agent-browser:*) Bash(node:*) Bash(curl:*) Read
---
# qa-web
Web application QA testing using **agent-browser**. The web counterpart to agent-device — together they provide full coverage for apps with both native and web versions.
## When to Use
| Scenario | Tool |
|----------|------|
| Test native iOS/Android app | agent-device + CDP |
| Test web app / localhost | **agent-browser** (this skill) |
| Test React Native Web | **agent-browser** (this skill) |
| Test responsive layouts | **agent-browser** (this skill) |
| Test forms and navigation | **agent-browser** (this skill) |
## Core Workflow
Every web test follows the same pattern as native tests:
1. **Navigate**: `agent-browser open <url>`
2. **Snapshot**: `agent-browser snapshot -i` (get element refs)
3. **Interact**: Use refs to click, fill, select
4. **Re-snapshot**: After navigation/DOM changes, get fresh refs
5. **Assert**: Verify URL, text, element state
```bash
agent-browser open http://localhost:3000/login
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Sign In"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
```
## Configuration
Set web-specific values in `qa.config.sh`:
```bash
export WEB_BASE_URL="http://localhost:3000" # Your web app URL
export WEB_SESSION="qa" # Browser session name
export WEB_VIEWPORT_WIDTH=1280 # Default viewport
export WEB_VIEWPORT_HEIGHT=720
```
## Usage
### Run example web test
```bash
bash .pi/skills/qa-automation/qa-web/run.sh
```
### Run with headed browser (see what's happening)
```bash
export WEB_HEADED=true
bash .pi/skills/qa-automation/qa-web/flows/example-web-test.sh
```
## Helper Library — web-helpers.sh
Source this in your web test scripts for consistent patterns:
```bash
source .pi/skills/qa-automation/qa-web/lib/web-helpers.sh
```
### Available Functions
| Function | Purpose |
|----------|---------|
| `web_open "url"` | Navigate to URL |
| `web_snapshot` | Get interactive element refs |
| `web_click "ref_or_selector"` | Click element |
| `web_fill "ref" "text"` | Fill input field |
| `web_select "ref" "value"` | Select dropdown option |
| `web_wait "selector_or_ms"` | Wait for element/time |
| `web_screenshot "name"` | Screenshot with naming convention |
| `web_get_text "ref"` | Get element text |
| `web_get_url` | Get current URL |
| `web_get_title` | Get page title |
| `web_is_visible "ref"` | Check element visibility |
| `web_assert_url "pattern"` | Assert URL matches pattern |
| `web_assert_title "text"` | Assert page title |
| `web_assert_text "text"` | Assert text visible on page |
| `web_scroll "direction" [px]` | Scroll page |
| `web_save_state "file"` | Save cookies/storage |
| `web_load_state "file"` | Restore saved state |
## Test Patterns
### Form Testing
```bash
source web-helpers.sh
web_open "$WEB_BASE_URL/signup"
web_snapshot
web_fill @e1 "Jane Doe"
web_fill @e2 "jane@example.com"
web_fill @e3 "password123"
web_select @e4 "California"
web_click @e5 # Submit
web_wait --load networkidle
web_assert_url "**/dashboard"
web_assert_text "Welcome, Jane"
web_screenshot "signup-success"
```
### Navigation Testing
```bash
web_open "$WEB_BASE_URL"
web_snapshot
# Click nav links
web_click @e3 # "About" link
web_assert_url "**/about"
web_screenshot "about-page"
# Go back
agent-browser back
web_assert_url "**/"
```
### Responsive Testing
```bash
# Desktop
agent-browser set viewport 1440 900
web_open "$WEB_BASE_URL"
web_screenshot "desktop-home"
# Tablet
agent-browser set viewport 768 1024
web_screenshot "tablet-home"
# Mobile
agent-browser set viewport 375 812
web_screenshot "mobile-home"
```
### State Persistence (Cookies/Storage)
```bash
# Login and save state
web_open "$WEB_BASE_URL/login"
web_fill @e1 "user@example.com"
web_fill @e2 "password"
web_click @e3
web_wait --load networkidle
web_save_state "/tmp/qa-auth-state.json"
# Reuse in future tests
web_load_state "/tmp/qa-auth-state.json"
web_open "$WEB_BASE_URL/dashboard"
web_assert_text "Welcome" # Still logged in
```
### Accessibility Testing
```bash
web_open "$WEB_BASE_URL"
agent-browser snapshot # Full a11y tree
agent-browser screenshot --full /tmp/qa-full-page.png
```
## File Structure
```
qa-web/
├── SKILL.md # This file
├── lib/
│ └── web-helpers.sh # Web test helper functions
├── flows/
│ └── example-web-test.sh # Example test
└── run.sh # Runner
```
## Integration with Native Tests
For apps with both native and web versions, use both tools in the same test run:
```bash
# Test native app
bash .pi/skills/qa-automation/qa-scroll/run.sh
# Test web app
bash .pi/skills/qa-automation/qa-web/run.sh
# Both results in /tmp/qa-tests/
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "agent-browser: command not found" | Install: `npm install -g agent-browser` |
| Browser launches but page is blank | Check URL and dev server: `curl $WEB_BASE_URL` |
| Refs invalidated after click | Always re-snapshot after navigation/DOM changes |
| Can't access localhost | agent-browser runs locally — it CAN access localhost (unlike web_remote) |
| Headed mode not showing | Set `export WEB_HEADED=true` before running |

View File

@@ -0,0 +1,103 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ Example Web Test — Form Submission & Navigation ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ CUSTOMIZE: Replace URLs, selectors, and assertions for your app. ║
# ║ ║
# ║ Usage: bash .pi/skills/qa-automation/qa-web/flows/example-web-test.sh
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/../lib/web-helpers.sh"
TEST_NAME="web-form-test"
setup_test "$TEST_NAME"
# ── Step 1: Open the web app ────────────────────────────────────────
step "Open web app"
web_open "$WEB_BASE_URL"
web_screenshot "01-homepage"
url=$(web_get_url)
log_info "URL: $url"
log_pass "Web app loaded"
# ── Step 2: Take a snapshot of interactive elements ──────────────────
step "Snapshot interactive elements"
snapshot=$(web_snapshot)
log_info "Snapshot:"
echo "$snapshot" | head -20
web_screenshot "02-snapshot"
# ── Step 3: Navigate to a page ──────────────────────────────────────
# CUSTOMIZE: Change the selector to match your app's navigation
step "Navigate to a page"
# web_click @e3 # Click a nav link (use ref from snapshot)
# Or navigate directly:
# web_open "$WEB_BASE_URL/about"
sleep 1
url=$(web_get_url)
log_info "URL after navigation: $url"
web_screenshot "03-navigated"
log_pass "Navigation successful"
# ── Step 4: Fill and submit a form ──────────────────────────────────
# CUSTOMIZE: Replace with your app's form fields
step "Fill form (if present)"
# web_snapshot # Get fresh refs
# web_fill @e1 "Jane Doe"
# web_fill @e2 "jane@example.com"
# web_click @e5 # Submit button
# web_wait_network
# web_screenshot "04-form-submitted"
log_info "Form step skipped — customize for your app"
web_screenshot "04-current-state"
# ── Step 5: Check responsive layout ─────────────────────────────────
step "Check responsive layouts"
# Desktop
web_set_viewport 1440 900
sleep 1
web_screenshot "05a-desktop"
log_pass "Desktop viewport captured"
# Tablet
web_set_viewport 768 1024
sleep 1
web_screenshot "05b-tablet"
log_pass "Tablet viewport captured"
# Mobile
web_set_viewport 375 812
sleep 1
web_screenshot "05c-mobile"
log_pass "Mobile viewport captured"
# Reset
web_set_viewport "$WEB_VIEWPORT_WIDTH" "$WEB_VIEWPORT_HEIGHT"
# ── Step 6: Final state ─────────────────────────────────────────────
step "Final state check"
title=$(web_get_title)
url=$(web_get_url)
log_info "Title: $title"
log_info "URL: $url"
web_screenshot "06-final"
log_pass "Web test complete"
# ── Cleanup ──────────────────────────────────────────────────────────
web_close
teardown_test
echo ""
echo "Web test completed!"
echo " Screenshots: $SCREENSHOT_DIR/$TEST_NAME/"
echo ""

View File

@@ -0,0 +1,347 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ web-helpers.sh — Web Test Helpers using agent-browser ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file in web test scripts: ║
# ║ source "$(dirname "$0")/../lib/web-helpers.sh" ║
# ║ ║
# ║ Provides consistent wrappers around agent-browser commands ║
# ║ with the same test lifecycle as native test-helpers.sh. ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Source configuration and test framework ──────────────────────────
WEB_HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QA_ROOT_WEB="$(cd "$WEB_HELPERS_DIR/../.." && pwd)"
source "$QA_ROOT_WEB/qa.config.sh"
# Source test-helpers for lifecycle (setup_test, teardown_test, step, log_*)
source "$QA_ROOT_WEB/qa-test-flows/lib/test-helpers.sh"
# ── Session flags ────────────────────────────────────────────────────
_WEB_SESSION_FLAG=""
[ -n "$WEB_SESSION" ] && _WEB_SESSION_FLAG="--session $WEB_SESSION"
_WEB_HEADED_FLAG=""
[ "${WEB_HEADED:-false}" = "true" ] && _WEB_HEADED_FLAG="--headed"
# ── Navigation ───────────────────────────────────────────────────────
# Open a URL in the browser
web_open() {
local url="$1"
agent-browser $_WEB_SESSION_FLAG $_WEB_HEADED_FLAG open "$url" 2>/dev/null || {
log_warn "Failed to open: $url"
return 1
}
sleep 1
}
# Get interactive element snapshot (refs like @e1, @e2)
web_snapshot() {
agent-browser $_WEB_SESSION_FLAG snapshot -i 2>/dev/null || echo "(snapshot failed)"
}
# Full accessibility tree snapshot
web_full_snapshot() {
agent-browser $_WEB_SESSION_FLAG snapshot 2>/dev/null || echo "(snapshot failed)"
}
# ── Interaction ──────────────────────────────────────────────────────
# Click an element by ref or selector
web_click() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG click "$target" 2>/dev/null || {
log_warn "Click failed: $target"
return 1
}
sleep 0.5
}
# Fill an input field (clears first)
web_fill() {
local target="$1"
local text="$2"
agent-browser $_WEB_SESSION_FLAG fill "$target" "$text" 2>/dev/null || {
log_warn "Fill failed: $target"
return 1
}
sleep 0.3
}
# Type text without clearing
web_type() {
local target="$1"
local text="$2"
agent-browser $_WEB_SESSION_FLAG type "$target" "$text" 2>/dev/null || {
log_warn "Type failed: $target"
return 1
}
sleep 0.3
}
# Select dropdown option
web_select() {
local target="$1"
local value="$2"
agent-browser $_WEB_SESSION_FLAG select "$target" "$value" 2>/dev/null || {
log_warn "Select failed: $target $value"
return 1
}
sleep 0.3
}
# Check a checkbox
web_check() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG check "$target" 2>/dev/null || return 1
}
# Uncheck a checkbox
web_uncheck() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG uncheck "$target" 2>/dev/null || return 1
}
# Press a key
web_press() {
local key="$1"
agent-browser $_WEB_SESSION_FLAG press "$key" 2>/dev/null || return 1
}
# Hover over an element
web_hover() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG hover "$target" 2>/dev/null || return 1
}
# ── Wait ─────────────────────────────────────────────────────────────
# Wait for element, time, or network idle
web_wait() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG wait "$target" 2>/dev/null || return 1
}
# Wait for network idle
web_wait_network() {
agent-browser $_WEB_SESSION_FLAG wait --load networkidle 2>/dev/null || return 1
}
# Wait for specific text to appear
web_wait_text() {
local text="$1"
agent-browser $_WEB_SESSION_FLAG wait --text "$text" 2>/dev/null || return 1
}
# ── Get Information ──────────────────────────────────────────────────
# Get element text
web_get_text() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG get text "$target" 2>/dev/null || echo ""
}
# Get current URL
web_get_url() {
agent-browser $_WEB_SESSION_FLAG get url 2>/dev/null || echo ""
}
# Get page title
web_get_title() {
agent-browser $_WEB_SESSION_FLAG get title 2>/dev/null || echo ""
}
# Get element attribute
web_get_attr() {
local target="$1"
local attr="$2"
agent-browser $_WEB_SESSION_FLAG get attr "$target" "$attr" 2>/dev/null || echo ""
}
# Get input value
web_get_value() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG get value "$target" 2>/dev/null || echo ""
}
# Count matching elements
web_count() {
local selector="$1"
agent-browser $_WEB_SESSION_FLAG get count "$selector" 2>/dev/null || echo "0"
}
# ── Check State ──────────────────────────────────────────────────────
# Check if element is visible
web_is_visible() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG is visible "$target" 2>/dev/null && echo "true" || echo "false"
}
# Check if element is enabled
web_is_enabled() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG is enabled "$target" 2>/dev/null && echo "true" || echo "false"
}
# Check if checkbox is checked
web_is_checked() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG is checked "$target" 2>/dev/null && echo "true" || echo "false"
}
# ── Screenshots ──────────────────────────────────────────────────────
# Take a screenshot with test naming convention
web_screenshot() {
local name="$1"
local path="$SCREENSHOT_DIR/$TEST_NAME/${name}.png"
mkdir -p "$(dirname "$path")"
agent-browser $_WEB_SESSION_FLAG screenshot "$path" 2>/dev/null || {
log_warn "Web screenshot failed: $name"
return 1
}
echo "$path"
}
# Full-page screenshot
web_screenshot_full() {
local name="$1"
local path="$SCREENSHOT_DIR/$TEST_NAME/${name}.png"
mkdir -p "$(dirname "$path")"
agent-browser $_WEB_SESSION_FLAG screenshot "$path" --full 2>/dev/null || {
log_warn "Full web screenshot failed: $name"
return 1
}
echo "$path"
}
# ── Scroll ───────────────────────────────────────────────────────────
# Scroll page
web_scroll() {
local direction="${1:-down}"
local amount="${2:-300}"
agent-browser $_WEB_SESSION_FLAG scroll "$direction" "$amount" 2>/dev/null || return 1
}
# Scroll element into view
web_scroll_into_view() {
local target="$1"
agent-browser $_WEB_SESSION_FLAG scrollintoview "$target" 2>/dev/null || return 1
}
# ── State Management ─────────────────────────────────────────────────
# Save browser state (cookies, localStorage, auth)
web_save_state() {
local path="$1"
agent-browser $_WEB_SESSION_FLAG state save "$path" 2>/dev/null || {
log_warn "Failed to save state to: $path"
return 1
}
log_info "Browser state saved to: $path"
}
# Load browser state
web_load_state() {
local path="$1"
if [ -f "$path" ]; then
agent-browser $_WEB_SESSION_FLAG state load "$path" 2>/dev/null || {
log_warn "Failed to load state from: $path"
return 1
}
log_info "Browser state loaded from: $path"
else
log_warn "State file not found: $path"
return 1
fi
}
# ── Assertion Helpers ────────────────────────────────────────────────
# Assert current URL matches pattern
web_assert_url() {
local pattern="$1"
local url
url=$(web_get_url)
if echo "$url" | grep -q "$pattern"; then
log_pass "URL matches: $pattern"
return 0
else
log_fail "URL mismatch: expected '$pattern', got '$url'"
return 1
fi
}
# Assert page title contains text
web_assert_title() {
local expected="$1"
local title
title=$(web_get_title)
if echo "$title" | grep -qi "$expected"; then
log_pass "Title contains: $expected"
return 0
else
log_fail "Title mismatch: expected '$expected', got '$title'"
return 1
fi
}
# Assert text is visible on page
web_assert_text() {
local text="$1"
local body
body=$(agent-browser $_WEB_SESSION_FLAG get text body 2>/dev/null || echo "")
if echo "$body" | grep -qi "$text"; then
log_pass "Text visible: $text"
return 0
else
log_fail "Text not found: $text"
return 1
fi
}
# Assert element is visible
web_assert_visible() {
local target="$1"
local visible
visible=$(web_is_visible "$target")
if [ "$visible" = "true" ]; then
log_pass "Element visible: $target"
return 0
else
log_fail "Element NOT visible: $target"
return 1
fi
}
# ── Browser Lifecycle ────────────────────────────────────────────────
# Close the browser session
web_close() {
agent-browser $_WEB_SESSION_FLAG close 2>/dev/null || true
}
# Set viewport size
web_set_viewport() {
local width="${1:-$WEB_VIEWPORT_WIDTH}"
local height="${2:-$WEB_VIEWPORT_HEIGHT}"
agent-browser $_WEB_SESSION_FLAG set viewport "$width" "$height" 2>/dev/null || return 1
}
# Emulate a mobile device
web_set_device() {
local device="$1"
agent-browser $_WEB_SESSION_FLAG set device "$device" 2>/dev/null || return 1
}
echo "Web helpers loaded. Base URL: $WEB_BASE_URL | Session: ${WEB_SESSION:-default}"

View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Run the Web QA test
#
# Usage: bash .pi/skills/qa-automation/qa-web/run.sh
set -euo pipefail
SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
echo ""
echo "╔═══════════════════════════════════════════════════╗"
echo "║ Web Application QA ║"
echo "║ Started: $(date '+%Y-%m-%d %H:%M:%S') "
echo "╚═══════════════════════════════════════════════════╝"
echo ""
bash "$SKILL_DIR/flows/example-web-test.sh"
EXIT_CODE=$?
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Exit code: $EXIT_CODE"
echo " Screenshots: /tmp/qa-tests/screenshots/web-form-test/"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
exit $EXIT_CODE

321
skills/qa-automation/qa.config.sh Executable file
View File

@@ -0,0 +1,321 @@
#!/bin/bash
# ╔═══════════════════════════════════════════════════════════════════╗
# ║ qa.config.sh — Central Configuration for QA Automation Skills ║
# ╠═══════════════════════════════════════════════════════════════════╣
# ║ Source this file at the top of every QA script: ║
# ║ source "$(dirname "$0")/../qa.config.sh" ║
# ║ ║
# ║ Override any variable by exporting it before sourcing: ║
# ║ export APP_BUNDLE_ID="com.myapp.dev" ║
# ║ source qa.config.sh ║
# ║ ║
# ║ Or create a local override file: ║
# ║ qa.config.local.sh (gitignored, sourced automatically) ║
# ╚═══════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Resolve paths ────────────────────────────────────────────────────
QA_AUTOMATION_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── App Configuration ────────────────────────────────────────────────
# Bundle/package identifier for your app (dev build)
export APP_BUNDLE_ID="${APP_BUNDLE_ID:-com.example.app.dev}"
# Bundle/package identifier for production builds
export APP_BUNDLE_ID_PROD="${APP_BUNDLE_ID_PROD:-com.example.app}"
# Project root directory (where package.json lives)
export PROJECT_DIR="${PROJECT_DIR:-$(pwd)}"
# ── iOS Simulator ────────────────────────────────────────────────────
# Simulator UDID — set to "auto" to detect the first booted simulator
export SIMULATOR_UDID="${SIMULATOR_UDID:-auto}"
# Simulator device name (used when creating/finding simulators)
export SIMULATOR_DEVICE_NAME="${SIMULATOR_DEVICE_NAME:-iPhone 16 Pro}"
# ── Android Emulator ─────────────────────────────────────────────────
# Android AVD name
export ANDROID_AVD="${ANDROID_AVD:-Pixel_8}"
# Android serial (usually emulator-5554)
export ANDROID_SERIAL="${ANDROID_SERIAL:-emulator-5554}"
# Android SDK paths
export ANDROID_SDK="${ANDROID_SDK:-$HOME/Library/Android/sdk}"
export ADB_PATH="${ADB_PATH:-$ANDROID_SDK/platform-tools/adb}"
export EMULATOR_PATH="${EMULATOR_PATH:-$ANDROID_SDK/emulator/emulator}"
# ── Dev Server / Metro ───────────────────────────────────────────────
# Port for the dev server (Metro bundler, Vite, webpack, etc.)
export METRO_PORT="${METRO_PORT:-8081}"
# URL for the dev server
export METRO_URL="${METRO_URL:-http://localhost:${METRO_PORT}}"
# Command to start the dev server (run from PROJECT_DIR)
export DEV_SERVER_CMD="${DEV_SERVER_CMD:-npx expo start --port $METRO_PORT}"
# Health check endpoint (returns 200 when server is ready)
export DEV_SERVER_HEALTH="${DEV_SERVER_HEALTH:-${METRO_URL}/status}"
# ── CDP (Chrome DevTools Protocol) ───────────────────────────────────
# CDP discovery URL (Metro exposes debug targets here)
export CDP_DISCOVERY_URL="${CDP_DISCOVERY_URL:-${METRO_URL}/json}"
# CDP WebSocket URL — set to "auto" for auto-discovery via /json endpoint
export CDP_WS_URL="${CDP_WS_URL:-auto}"
# CDP device ID — set to "auto" to pick from /json response
export CDP_DEVICE_ID="${CDP_DEVICE_ID:-auto}"
# ── Navigation ───────────────────────────────────────────────────────
# React Navigation module ID cache file
export NAV_MODULE_CACHE="${NAV_MODULE_CACHE:-/tmp/qa-nav-module-id}"
# Video/media player module cache file
export VIDEO_MODULE_CACHE="${VIDEO_MODULE_CACHE:-/tmp/qa-video-module-id}"
# Module scan range for auto-discovery (start, end)
export MODULE_SCAN_START="${MODULE_SCAN_START:-0}"
export MODULE_SCAN_END="${MODULE_SCAN_END:-5000}"
# ── Screen Names (configure per app) ────────────────────────────────
# These are used by CDP navigation helpers. Set them to your app's
# React Navigation screen names. Leave empty to skip.
export SCREEN_HOME="${SCREEN_HOME:-HomeScreen}"
export SCREEN_EXPLORE="${SCREEN_EXPLORE:-ExploreScreen}"
export SCREEN_SEARCH="${SCREEN_SEARCH:-SearchScreen}"
export SCREEN_PROFILE="${SCREEN_PROFILE:-ProfileScreen}"
export SCREEN_SETTINGS="${SCREEN_SETTINGS:-SettingsScreen}"
# Tab navigator name (for BottomTab-style navigation)
export TAB_NAVIGATOR_NAME="${TAB_NAVIGATOR_NAME:-BottomTab}"
# ── Coordinate System ────────────────────────────────────────────────
# Override these for your specific device's logical point dimensions.
# Default: iPhone 16 Pro logical resolution (402 × 874 points)
export SCREEN_WIDTH="${SCREEN_WIDTH:-402}"
export SCREEN_HEIGHT="${SCREEN_HEIGHT:-874}"
export SCREEN_SCALE="${SCREEN_SCALE:-3}"
# Tab bar Y position (bottom of screen, above safe area)
export TAB_BAR_Y="${TAB_BAR_Y:-855}"
# Tab positions (x coordinates, left to right)
# Set these to match your app's bottom tab layout
export TAB_1_X="${TAB_1_X:-60}"
export TAB_2_X="${TAB_2_X:-170}"
export TAB_3_X="${TAB_3_X:-290}"
export TAB_4_X="${TAB_4_X:-400}"
export TAB_5_X="${TAB_5_X:-520}"
# Common UI element coordinates
export BACK_BUTTON_X="${BACK_BUTTON_X:-30}"
export BACK_BUTTON_Y="${BACK_BUTTON_Y:-60}"
export SETTINGS_BUTTON_X="${SETTINGS_BUTTON_X:-510}"
export SETTINGS_BUTTON_Y="${SETTINGS_BUTTON_Y:-140}"
# ── Video/Media Player ──────────────────────────────────────────────
# Class name to look for when installing debug hooks
# For expo-video: "VideoPlayer" (looks for m.default.VideoPlayer or m.VideoPlayer)
export VIDEO_PLAYER_CLASS="${VIDEO_PLAYER_CLASS:-VideoPlayer}"
# Global variable name for tracking video player instances
export GLOBAL_PLAYERS_VAR="${GLOBAL_PLAYERS_VAR:-__qaVideoPlayers}"
# Global variable name for feed state debug hook
export GLOBAL_FEED_VAR="${GLOBAL_FEED_VAR:-__qaFeedState}"
# Maximum tracked player instances (prevents memory leaks)
export MAX_TRACKED_PLAYERS="${MAX_TRACKED_PLAYERS:-20}"
# ── Feed/List State (for state persistence tests) ───────────────────
# Property name to test for state persistence (e.g., "isLiked", "isBookmarked", "isInCart")
export STATE_PROPERTY="${STATE_PROPERTY:-isLiked}"
# Counter property that accompanies the state (e.g., "likesCount", "saveCount")
export STATE_COUNTER_PROPERTY="${STATE_COUNTER_PROPERTY:-likesCount}"
# Number of items to scroll through before checking persistence
export STATE_SCROLL_COUNT="${STATE_SCROLL_COUNT:-5}"
# ── Output & Reporting ──────────────────────────────────────────────
# Base directory for test output
export TEST_OUTPUT_DIR="${TEST_OUTPUT_DIR:-/tmp/qa-tests}"
# Screenshot subdirectory
export SCREENSHOT_DIR="${SCREENSHOT_DIR:-$TEST_OUTPUT_DIR/screenshots}"
# Test results log
export RESULTS_FILE="${RESULTS_FILE:-$TEST_OUTPUT_DIR/results.log}"
# ── Timeouts ─────────────────────────────────────────────────────────
# Dev server startup timeout (seconds)
export DEV_SERVER_TIMEOUT="${DEV_SERVER_TIMEOUT:-60}"
# CDP target discovery timeout (seconds)
export CDP_TIMEOUT="${CDP_TIMEOUT:-30}"
# App settle time after launch (seconds)
export APP_SETTLE_TIME="${APP_SETTLE_TIME:-6}"
# agent-device command timeout (seconds)
export DEVICE_CMD_TIMEOUT="${DEVICE_CMD_TIMEOUT:-5}"
# ── agent-device Sessions ───────────────────────────────────────────
# iOS session name
export IOS_SESSION="${IOS_SESSION:-default}"
# Android session name
export ANDROID_SESSION="${ANDROID_SESSION:-android}"
# Active session (set at runtime)
export ACTIVE_SESSION="${ACTIVE_SESSION:-$IOS_SESSION}"
# ── Web Testing (agent-browser) ─────────────────────────────────────
# Base URL for web testing
export WEB_BASE_URL="${WEB_BASE_URL:-http://localhost:3000}"
# Browser session name
export WEB_SESSION="${WEB_SESSION:-qa}"
# Default viewport for web tests
export WEB_VIEWPORT_WIDTH="${WEB_VIEWPORT_WIDTH:-1280}"
export WEB_VIEWPORT_HEIGHT="${WEB_VIEWPORT_HEIGHT:-720}"
# ── Auto-Detection Functions ─────────────────────────────────────────
# Auto-detect simulator UDID (finds first booted iOS simulator)
qa_detect_simulator_udid() {
if [ "$SIMULATOR_UDID" != "auto" ]; then
echo "$SIMULATOR_UDID"
return 0
fi
local udid
udid=$(xcrun simctl list devices 2>/dev/null | grep "Booted" | head -1 | grep -oE '[A-F0-9-]{36}' || echo "")
if [ -n "$udid" ]; then
export SIMULATOR_UDID="$udid"
echo "$udid"
return 0
fi
echo ""
return 1
}
# Auto-detect CDP WebSocket URL from Metro /json endpoint
qa_detect_cdp_ws_url() {
if [ "$CDP_WS_URL" != "auto" ]; then
echo "$CDP_WS_URL"
return 0
fi
local json
json=$(curl -s "$CDP_DISCOVERY_URL" 2>/dev/null || echo "[]")
local ws_url
ws_url=$(echo "$json" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{
try {
const targets=JSON.parse(d);
// Prefer 'Bridgeless' target (Hermes), fall back to first
const hermes=targets.find(t=>t.description && t.description.includes('Bridgeless'));
console.log(hermes ? hermes.webSocketDebuggerUrl : (targets[0]?.webSocketDebuggerUrl || ''));
} catch(e) { console.log(''); }
});
" 2>/dev/null || echo "")
if [ -n "$ws_url" ]; then
export CDP_WS_URL="$ws_url"
echo "$ws_url"
return 0
fi
echo ""
return 1
}
# Auto-detect and cache the React Navigation module ID
qa_detect_nav_module() {
# Check cache first
if [ -f "$NAV_MODULE_CACHE" ]; then
local cached
cached=$(cat "$NAV_MODULE_CACHE" 2>/dev/null || echo "")
if [ -n "$cached" ]; then
echo "$cached"
return 0
fi
fi
local ws_url
ws_url=$(qa_detect_cdp_ws_url)
if [ -z "$ws_url" ]; then
echo ""
return 1
fi
local module_id
module_id=$(cd "$PROJECT_DIR" && node -e "
const WebSocket=require('ws');
const ws=new WebSocket('$ws_url');
ws.on('open',()=>{
const expr=\`
(function(){
var origHandler=globalThis.ErrorUtils?ErrorUtils.getGlobalHandler():null;
var origCE=console.error;
if(globalThis.ErrorUtils)ErrorUtils.setGlobalHandler(function(){});
console.error=function(){};
try{
for(var j=${MODULE_SCAN_START};j<${MODULE_SCAN_END};j++){
try{
var m=__r(j);
if(m&&m.navigationRef&&m.navigationRef.current){
return JSON.stringify({ok:true,moduleId:j});
}
}catch(e){}
}
return JSON.stringify({error:'not found'});
}finally{
if(globalThis.ErrorUtils&&origHandler)ErrorUtils.setGlobalHandler(origHandler);
console.error=origCE;
}
})();
\`;
ws.send(JSON.stringify({id:1,method:'Runtime.evaluate',params:{expression:expr,returnByValue:true}}));
});
ws.on('message',d=>{
const m=JSON.parse(d);
if(m.id===1){console.log(m.result?.result?.value||'');ws.close();process.exit(0);}
});
ws.on('error',()=>{console.log('');process.exit(1)});
setTimeout(()=>{console.log('');process.exit(1)},10000);
" 2>/dev/null || echo "")
local id
id=$(echo "$module_id" | node -e "
let d='';process.stdin.on('data',c=>d+=c);
process.stdin.on('end',()=>{try{const o=JSON.parse(d);console.log(o.moduleId||'')}catch(e){console.log('')}});
" 2>/dev/null || echo "")
if [ -n "$id" ]; then
echo "$id" > "$NAV_MODULE_CACHE"
echo "$id"
return 0
fi
echo ""
return 1
}
# ── Source local overrides (if present) ──────────────────────────────
if [ -f "$QA_AUTOMATION_DIR/qa.config.local.sh" ]; then
source "$QA_AUTOMATION_DIR/qa.config.local.sh"
fi
# ── Ensure output directories exist ─────────────────────────────────
mkdir -p "$TEST_OUTPUT_DIR" "$SCREENSHOT_DIR" 2>/dev/null || true