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

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

View File

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

View File

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

View File

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

View File

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

View File

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