419 lines
16 KiB
Bash
Executable File
419 lines
16 KiB
Bash
Executable File
#!/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."
|