Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
269
skills/qa-automation/README.md
Normal file
269
skills/qa-automation/README.md
Normal 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
273
skills/qa-automation/install.sh
Executable 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
|
||||
120
skills/qa-automation/qa-device-management/COORDINATE-MAP.md
Normal file
120
skills/qa-automation/qa-device-management/COORDINATE-MAP.md
Normal 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
|
||||
```
|
||||
260
skills/qa-automation/qa-device-management/SKILL.md
Normal file
260
skills/qa-automation/qa-device-management/SKILL.md
Normal 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` |
|
||||
182
skills/qa-automation/qa-scroll/SKILL.md
Normal file
182
skills/qa-automation/qa-scroll/SKILL.md
Normal 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.
|
||||
273
skills/qa-automation/qa-scroll/flows/example-scroll-test.sh
Executable file
273
skills/qa-automation/qa-scroll/flows/example-scroll-test.sh
Executable 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 ""
|
||||
418
skills/qa-automation/qa-scroll/lib/scroll-helpers.sh
Executable file
418
skills/qa-automation/qa-scroll/lib/scroll-helpers.sh
Executable 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."
|
||||
413
skills/qa-automation/qa-scroll/lib/setup-guard.sh
Executable file
413
skills/qa-automation/qa-scroll/lib/setup-guard.sh
Executable 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
|
||||
43
skills/qa-automation/qa-scroll/run.sh
Executable file
43
skills/qa-automation/qa-scroll/run.sh
Executable 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
|
||||
113
skills/qa-automation/qa-setup/SKILL.md
Normal file
113
skills/qa-automation/qa-setup/SKILL.md
Normal 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.
|
||||
166
skills/qa-automation/qa-state-persistence/SKILL.md
Normal file
166
skills/qa-automation/qa-state-persistence/SKILL.md
Normal 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
|
||||
299
skills/qa-automation/qa-state-persistence/flows/example-state-test.sh
Executable file
299
skills/qa-automation/qa-state-persistence/flows/example-state-test.sh
Executable 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 ""
|
||||
290
skills/qa-automation/qa-state-persistence/lib/state-helpers.sh
Executable file
290
skills/qa-automation/qa-state-persistence/lib/state-helpers.sh
Executable 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."
|
||||
43
skills/qa-automation/qa-state-persistence/run.sh
Executable file
43
skills/qa-automation/qa-state-persistence/run.sh
Executable 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
|
||||
177
skills/qa-automation/qa-test-flows/SKILL.md
Normal file
177
skills/qa-automation/qa-test-flows/SKILL.md
Normal 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 |
|
||||
108
skills/qa-automation/qa-test-flows/flows/smoke/example-smoke.sh
Executable file
108
skills/qa-automation/qa-test-flows/flows/smoke/example-smoke.sh
Executable 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 ""
|
||||
340
skills/qa-automation/qa-test-flows/lib/cdp-helpers.sh
Executable file
340
skills/qa-automation/qa-test-flows/lib/cdp-helpers.sh
Executable 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}"
|
||||
453
skills/qa-automation/qa-test-flows/lib/test-helpers.sh
Executable file
453
skills/qa-automation/qa-test-flows/lib/test-helpers.sh
Executable 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"
|
||||
100
skills/qa-automation/qa-test-flows/run-all.sh
Executable file
100
skills/qa-automation/qa-test-flows/run-all.sh
Executable 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 ""
|
||||
@@ -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 ""
|
||||
217
skills/qa-automation/qa-web/SKILL.md
Normal file
217
skills/qa-automation/qa-web/SKILL.md
Normal 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 |
|
||||
103
skills/qa-automation/qa-web/flows/example-web-test.sh
Executable file
103
skills/qa-automation/qa-web/flows/example-web-test.sh
Executable 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 ""
|
||||
347
skills/qa-automation/qa-web/lib/web-helpers.sh
Executable file
347
skills/qa-automation/qa-web/lib/web-helpers.sh
Executable 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}"
|
||||
27
skills/qa-automation/qa-web/run.sh
Executable file
27
skills/qa-automation/qa-web/run.sh
Executable 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
321
skills/qa-automation/qa.config.sh
Executable 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
|
||||
Reference in New Issue
Block a user