Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
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."
|
||||
Reference in New Issue
Block a user