Fix alarm counting to correctly parse dumpsys output where app ID and action appear on different lines. Add screenshot capture for test diagnostics and create golden run documentation. Test Harness Improvements: - Fix get_plugin_alarm_count() to track app ID and action separately across alarm block lines (fixes false 0-count bug) - Add show_plugin_alarms_compact() to display complete alarm blocks - Add wait_for_stable_plugin_alarm_count() polling helper to reduce race condition false negatives - Add take_screenshot() and take_failure_screenshot() helpers for automatic test state capture - Integrate screenshots into TEST 0 at key checkpoints - Update TEST 0 messaging to handle race conditions gracefully - Add screenshots/ to .gitignore Documentation: - Create PHASE1_TEST0_GOLDEN.md with actual values from successful run - Document expected script output, UI state, dumpsys shape, and logcat patterns - Include pass/fail checklist for future test runs This fixes the issue where alarm counting always returned 0 because the AWK logic required app ID and action on the same line, but dumpsys output has them on separate lines (header line has app ID, tag line has action).
614 lines
16 KiB
Bash
614 lines
16 KiB
Bash
#!/usr/bin/env bash
|
||
|
||
# ========================================
|
||
# Shared Alarm Test Library
|
||
# ========================================
|
||
#
|
||
# Common helpers for Phase 1, 2, and 3 test scripts
|
||
#
|
||
# Usage: source this file in your test script:
|
||
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
# source "${SCRIPT_DIR}/alarm-test-lib.sh"
|
||
#
|
||
# Configuration can be overridden before sourcing:
|
||
# APP_ID="custom.package" source "${SCRIPT_DIR}/alarm-test-lib.sh"
|
||
|
||
# --- Config Defaults (can be overridden before sourcing) ---
|
||
|
||
: "${APP_ID:=com.timesafari.dailynotification}"
|
||
: "${APK_PATH:=./app/build/outputs/apk/debug/app-debug.apk}"
|
||
: "${ADB_BIN:=adb}"
|
||
|
||
# Reactivation log tag (common across phases)
|
||
: "${REACTIVATION_TAG:=DNP-REACTIVATION}"
|
||
: "${SCENARIO_KEY:=Detected scenario: }"
|
||
|
||
# Screenshot configuration
|
||
: "${SCREENSHOT_ROOT:=screenshots}"
|
||
: "${ENABLE_SCREENSHOTS:=1}"
|
||
|
||
# Derived config (for backward compatibility with Phase 1)
|
||
PACKAGE="${APP_ID}"
|
||
ACTIVITY="${APP_ID}/.MainActivity"
|
||
|
||
# Colors for output (used by Phase 1 print_* functions)
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# --- UI/Log Helpers ---
|
||
|
||
section() {
|
||
echo
|
||
echo "========================================"
|
||
echo "$1"
|
||
echo "========================================"
|
||
echo
|
||
}
|
||
|
||
substep() {
|
||
echo "→ $1"
|
||
}
|
||
|
||
info() {
|
||
echo -e "ℹ️ $1"
|
||
}
|
||
|
||
ok() {
|
||
echo -e "✅ $1"
|
||
}
|
||
|
||
warn() {
|
||
echo -e "⚠️ $1"
|
||
}
|
||
|
||
error() {
|
||
echo -e "❌ $1"
|
||
}
|
||
|
||
pause() {
|
||
echo
|
||
read -rp "Press Enter when ready to continue..."
|
||
echo
|
||
}
|
||
|
||
ui_prompt() {
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo "👆 UI ACTION REQUIRED"
|
||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||
echo -e "$1"
|
||
echo
|
||
read -rp "Press Enter after completing the action above..."
|
||
echo
|
||
}
|
||
|
||
# Phase 1 compatibility aliases (print_* functions)
|
||
print_header() {
|
||
echo ""
|
||
echo -e "${BLUE}========================================${NC}"
|
||
echo -e "${BLUE}$1${NC}"
|
||
echo -e "${BLUE}========================================${NC}"
|
||
echo ""
|
||
}
|
||
|
||
print_step() {
|
||
echo -e "${GREEN}→ Step $1:${NC} $2"
|
||
}
|
||
|
||
print_wait() {
|
||
echo -e "${YELLOW}⏳ $1${NC}"
|
||
}
|
||
|
||
print_success() {
|
||
echo -e "${GREEN}✅ $1${NC}"
|
||
}
|
||
|
||
print_error() {
|
||
echo -e "${RED}❌ $1${NC}"
|
||
}
|
||
|
||
print_info() {
|
||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||
}
|
||
|
||
print_warn() {
|
||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||
}
|
||
|
||
wait_for_user() {
|
||
echo ""
|
||
read -p "Press Enter when ready to continue..."
|
||
echo ""
|
||
}
|
||
|
||
wait_for_ui_action() {
|
||
ui_prompt "$1"
|
||
}
|
||
|
||
# --- ADB/Build Helpers ---
|
||
|
||
require_adb_device() {
|
||
section "Pre-Flight Checks"
|
||
|
||
if ! $ADB_BIN devices | awk 'NR>1 && $2=="device"{found=1} END{exit !found}'; then
|
||
error "No emulator/device in 'device' state. Start your emulator first."
|
||
exit 1
|
||
fi
|
||
ok "ADB device connected"
|
||
|
||
info "Checking emulator status..."
|
||
if ! $ADB_BIN shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then
|
||
info "Waiting for emulator to boot..."
|
||
$ADB_BIN wait-for-device
|
||
while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do
|
||
sleep 2
|
||
done
|
||
fi
|
||
ok "Emulator is ready"
|
||
}
|
||
|
||
# Phase 1 compatibility: check_adb_connection + check_emulator_ready
|
||
check_adb_connection() {
|
||
if ! $ADB_BIN devices | grep -q "device$"; then
|
||
print_error "No Android device/emulator connected"
|
||
echo "Please connect a device or start an emulator, then run:"
|
||
echo " adb devices"
|
||
exit 1
|
||
fi
|
||
print_success "ADB device connected"
|
||
}
|
||
|
||
check_emulator_ready() {
|
||
print_info "Checking emulator status..."
|
||
if ! $ADB_BIN shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then
|
||
print_wait "Waiting for emulator to boot..."
|
||
$ADB_BIN wait-for-device
|
||
while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do
|
||
sleep 2
|
||
done
|
||
fi
|
||
print_success "Emulator is ready"
|
||
}
|
||
|
||
build_app() {
|
||
section "Building Test App"
|
||
|
||
substep "Step 1: Building debug APK..."
|
||
if ./gradlew :app:assembleDebug; then
|
||
ok "Build successful"
|
||
else
|
||
error "Build failed"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -f "$APK_PATH" ]]; then
|
||
ok "APK ready: $APK_PATH"
|
||
else
|
||
error "APK not found at $APK_PATH"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
install_app() {
|
||
section "Installing App"
|
||
|
||
substep "Step 1: Uninstalling existing app (if present)..."
|
||
set +e
|
||
uninstall_output="$($ADB_BIN uninstall "$APP_ID" 2>&1)"
|
||
uninstall_status=$?
|
||
set -e
|
||
|
||
if [[ $uninstall_status -ne 0 ]]; then
|
||
if grep -q "DELETE_FAILED_INTERNAL_ERROR" <<<"$uninstall_output"; then
|
||
info "No existing app to uninstall (continuing)"
|
||
else
|
||
warn "Uninstall returned non-zero status: $uninstall_output (continuing anyway)"
|
||
fi
|
||
else
|
||
ok "Previous app uninstall succeeded"
|
||
fi
|
||
|
||
substep "Step 2: Installing new APK..."
|
||
if $ADB_BIN install -r "$APK_PATH"; then
|
||
ok "App installed successfully"
|
||
else
|
||
error "App installation failed"
|
||
exit 1
|
||
fi
|
||
|
||
substep "Step 3: Verifying installation..."
|
||
if $ADB_BIN shell pm list packages | grep -q "$APP_ID"; then
|
||
ok "App verified in package list"
|
||
else
|
||
error "App not found in package list"
|
||
exit 1
|
||
fi
|
||
|
||
info "Clearing logcat buffer..."
|
||
$ADB_BIN logcat -c
|
||
ok "Logcat cleared"
|
||
}
|
||
|
||
launch_app() {
|
||
# Check if we're in Phase 1 context (has print_info function)
|
||
if type print_info >/dev/null 2>&1; then
|
||
print_info "Launching app..."
|
||
$ADB_BIN shell am start -n "${ACTIVITY}"
|
||
sleep 3 # Give app time to load and check status
|
||
print_success "App launched"
|
||
else
|
||
substep "Launching app main activity..."
|
||
$ADB_BIN shell am start -n "${ACTIVITY}" >/dev/null 2>&1
|
||
sleep 3 # Give app time to load
|
||
ok "App launched"
|
||
fi
|
||
}
|
||
|
||
clear_logs() {
|
||
info "Clearing logcat buffer..."
|
||
$ADB_BIN logcat -c
|
||
ok "Logs cleared"
|
||
}
|
||
|
||
show_alarms() {
|
||
info "Checking AlarmManager status..."
|
||
echo
|
||
$ADB_BIN shell dumpsys alarm | grep -A3 "$APP_ID" || true
|
||
echo
|
||
}
|
||
|
||
# Plugin-specific alarm action (must match AndroidManifest.xml)
|
||
PLUGIN_ALARM_ACTION="com.timesafari.daily.NOTIFICATION"
|
||
|
||
get_plugin_alarm_count() {
|
||
# Returns count of ONLY the plugin's NOTIFICATION alarms (not prefetch - that uses WorkManager)
|
||
# Expected: 1 notification alarm per daily schedule
|
||
#
|
||
# This function counts ALARM_CLOCK wake alarms (RTC_WAKEUP) tagged as:
|
||
# tag=*walarm*:com.timesafari.daily.NOTIFICATION
|
||
#
|
||
# Uses deduplicating parser to avoid double-counting the same alarm that appears in both:
|
||
# - Main alarm list
|
||
# - "Next wake from idle" section (ignored - only counts RTC_WAKEUP blocks)
|
||
# - Alarm Stats section (ignored - only counts actual alarm blocks)
|
||
#
|
||
# Tracks unique Alarm handles to ensure each alarm is counted only once.
|
||
# Checks for app package AND action string anywhere in the block (they appear on different lines).
|
||
local count app_id action
|
||
app_id="$APP_ID"
|
||
action="com.timesafari.daily.NOTIFICATION"
|
||
count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | awk -v app="$app_id" -v action="$action" '
|
||
BEGIN {
|
||
in_block = 0
|
||
alarmId = ""
|
||
hasAppLine = 0
|
||
hasActionLine = 0
|
||
}
|
||
# Start of a new RTC_WAKEUP alarm block
|
||
/^[[:space:]]*RTC_WAKEUP/ {
|
||
# Flush previous block if it was a plugin notification
|
||
if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") {
|
||
seen[alarmId] = 1
|
||
}
|
||
in_block = 1
|
||
alarmId = ""
|
||
hasAppLine = 0
|
||
hasActionLine = 0
|
||
# Extract alarmId from "Alarm{11245c ..."
|
||
if (match($0, /Alarm\{[0-9a-f]+/)) {
|
||
# match is like "Alarm{11245c", extract just the hex part
|
||
alarmId = substr($0, RSTART + 6, RLENGTH - 6)
|
||
}
|
||
}
|
||
# Blank line = end of block
|
||
/^[[:space:]]*$/ {
|
||
if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") {
|
||
seen[alarmId] = 1
|
||
}
|
||
in_block = 0
|
||
alarmId = ""
|
||
hasAppLine = 0
|
||
hasActionLine = 0
|
||
}
|
||
# Lines inside an alarm block
|
||
in_block == 1 {
|
||
if ($0 ~ app) {
|
||
hasAppLine = 1
|
||
}
|
||
if ($0 ~ action) {
|
||
hasActionLine = 1
|
||
}
|
||
}
|
||
END {
|
||
# Flush final block if it was a plugin notification
|
||
if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") {
|
||
seen[alarmId] = 1
|
||
}
|
||
count = 0
|
||
for (id in seen) {
|
||
count++
|
||
}
|
||
print count + 0
|
||
}
|
||
' 2>/dev/null || echo "0")"
|
||
echo "$count" | tr -d '\n\r' | head -1
|
||
}
|
||
|
||
# Alias for backward compatibility
|
||
get_notification_alarm_count() {
|
||
get_plugin_alarm_count
|
||
}
|
||
|
||
get_prefetch_work_count() {
|
||
# Returns count of prefetch WorkManager jobs (not AlarmManager alarms)
|
||
# Note: Prefetch uses WorkManager, not AlarmManager, so it won't appear in dumpsys alarm
|
||
# This is a placeholder - actual WorkManager job counting would require different approach
|
||
local count
|
||
count="$($ADB_BIN shell dumpsys jobscheduler | grep -c "prefetch" 2>/dev/null || echo "0")"
|
||
echo "$count" | tr -d '\n\r' | head -1
|
||
}
|
||
|
||
get_system_alarm_count() {
|
||
# Returns total RTC_WAKEUP alarms on system (for debugging/context)
|
||
local count
|
||
count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | grep -c "RTC_WAKEUP" || echo "0")"
|
||
echo "$count" | tr -d '\n\r' | head -1
|
||
}
|
||
|
||
count_alarms() {
|
||
# Legacy function: now returns plugin-specific alarm count
|
||
# Use get_plugin_alarm_count() for clarity, or get_system_alarm_count() for total
|
||
get_plugin_alarm_count
|
||
}
|
||
|
||
show_plugin_alarms_compact() {
|
||
# Prints only the plugin's alarm block(s) for debugging
|
||
# Shows complete RTC_WAKEUP alarm blocks that contain the app ID
|
||
# This makes it visually obvious why the AWK matcher should pick up alarms
|
||
# (app ID on header line, action on tag line within the same block)
|
||
$ADB_BIN shell dumpsys alarm 2>/dev/null \
|
||
| awk -v app="$APP_ID" '
|
||
BEGIN {
|
||
in_block = 0
|
||
block = ""
|
||
found_app = 0
|
||
}
|
||
/^[[:space:]]*RTC_WAKEUP/ {
|
||
# Print previous block if it contained app ID
|
||
if (in_block && found_app) {
|
||
print block ORS
|
||
}
|
||
# Start new block
|
||
in_block = 1
|
||
block = $0 ORS
|
||
found_app = ($0 ~ app) ? 1 : 0
|
||
next
|
||
}
|
||
/^[[:space:]]*$/ {
|
||
# End of block
|
||
if (in_block && found_app) {
|
||
print block ORS
|
||
}
|
||
in_block = 0
|
||
block = ""
|
||
found_app = 0
|
||
next
|
||
}
|
||
{
|
||
if (in_block) {
|
||
block = block $0 ORS
|
||
if ($0 ~ app) {
|
||
found_app = 1
|
||
}
|
||
}
|
||
}
|
||
END {
|
||
if (in_block && found_app) {
|
||
print block
|
||
}
|
||
}
|
||
' \
|
||
| sed -n '1,80p' || true
|
||
}
|
||
|
||
wait_for_stable_plugin_alarm_count() {
|
||
# Polls for plugin alarm count to stabilize (reduces race condition false negatives)
|
||
# Usage: wait_for_stable_plugin_alarm_count [attempts] [delay_seconds]
|
||
# Default: 5 attempts, 2 second delay (total ~10 seconds)
|
||
# Returns: alarm count (0 if none found after all attempts)
|
||
local attempts=${1:-5}
|
||
local delay=${2:-2}
|
||
local count=0
|
||
local i
|
||
|
||
for i in $(seq 1 "$attempts"); do
|
||
count="$(get_plugin_alarm_count)"
|
||
if [ "$count" -ge 1 ] 2>/dev/null; then
|
||
echo "$count"
|
||
return 0
|
||
fi
|
||
if [ "$i" -lt "$attempts" ]; then
|
||
sleep "$delay"
|
||
fi
|
||
done
|
||
echo "$count"
|
||
}
|
||
|
||
# --- Screenshot Helpers ---
|
||
|
||
take_screenshot() {
|
||
# Captures a device screenshot and saves it with test name, step, and timestamp
|
||
# Usage: take_screenshot "test_name" "step_name"
|
||
# Example: take_screenshot "phase1_test0_daily_rollover" "before_scheduling"
|
||
local test_name="$1"
|
||
local step_name="$2"
|
||
|
||
# Do nothing if screenshots are disabled
|
||
if [ "$ENABLE_SCREENSHOTS" != "1" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if [ -z "$ADB_BIN" ]; then
|
||
echo "⚠️ ADB_BIN is not set; cannot take screenshot." >&2
|
||
return 0
|
||
fi
|
||
|
||
# Timestamp for uniqueness
|
||
local ts
|
||
ts="$(date '+%Y%m%d-%H%M%S' 2>/dev/null)" || ts="unknown"
|
||
|
||
# Directory: screenshots/<test_name>/
|
||
# Use absolute path relative to script directory if SCREENSHOT_ROOT is relative
|
||
local dir
|
||
if [ -n "$SCRIPT_DIR" ] && [ -d "$SCRIPT_DIR" ]; then
|
||
dir="${SCRIPT_DIR}/${SCREENSHOT_ROOT}/${test_name}"
|
||
elif [ -n "${BASH_SOURCE[0]}" ]; then
|
||
# Fallback: derive from this script's location
|
||
local lib_dir
|
||
lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || lib_dir=""
|
||
if [ -n "$lib_dir" ]; then
|
||
dir="${lib_dir}/${SCREENSHOT_ROOT}/${test_name}"
|
||
else
|
||
dir="${SCREENSHOT_ROOT}/${test_name}"
|
||
fi
|
||
else
|
||
dir="${SCREENSHOT_ROOT}/${test_name}"
|
||
fi
|
||
mkdir -p "$dir" 2>/dev/null || {
|
||
echo "⚠️ Failed to create screenshot directory: $dir" >&2
|
||
return 0
|
||
}
|
||
|
||
# File name: <test>_<step>_<timestamp>.png, with spaces converted to dashes
|
||
local safe_step="${step_name// /-}"
|
||
local file="${dir}/${test_name}_${safe_step}_${ts}.png"
|
||
|
||
echo "📸 Capturing screenshot: ${file}" >&2
|
||
# Use exec-out to avoid newline mangling
|
||
if ! "$ADB_BIN" exec-out screencap -p > "$file" 2>/dev/null; then
|
||
echo "⚠️ Failed to capture screenshot via adb." >&2
|
||
# Clean up empty file if created
|
||
[ -s "$file" ] || rm -f "$file" 2>/dev/null || true
|
||
return 0
|
||
fi
|
||
|
||
# Verify file was created and has content
|
||
if [ ! -s "$file" ]; then
|
||
echo "⚠️ Screenshot file is empty or missing: $file" >&2
|
||
rm -f "$file" 2>/dev/null || true
|
||
return 0
|
||
fi
|
||
}
|
||
|
||
take_failure_screenshot() {
|
||
# Convenience wrapper for failure cases
|
||
# Usage: take_failure_screenshot "test_name" "reason"
|
||
# Example: take_failure_screenshot "phase1_test0_daily_rollover" "no_alarm_after_rollover"
|
||
local test_name="$1"
|
||
local reason="$2"
|
||
take_screenshot "$test_name" "FAIL_${reason}"
|
||
}
|
||
|
||
force_stop_app() {
|
||
info "Forcing stop of app process..."
|
||
$ADB_BIN shell am force-stop "$APP_ID" || true
|
||
sleep 2
|
||
ok "Force stop issued"
|
||
}
|
||
|
||
# Phase 1 compatibility alias
|
||
kill_app() {
|
||
print_info "Killing app process..."
|
||
$ADB_BIN shell am kill "$APP_ID"
|
||
sleep 2
|
||
|
||
# Verify process is killed
|
||
if $ADB_BIN shell ps | grep -q "$APP_ID"; then
|
||
print_wait "Process still running, using force-stop..."
|
||
$ADB_BIN shell am force-stop "$APP_ID"
|
||
sleep 1
|
||
fi
|
||
|
||
if ! $ADB_BIN shell ps | grep -q "$APP_ID"; then
|
||
print_success "App process terminated"
|
||
else
|
||
print_error "App process still running"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
reboot_emulator() {
|
||
info "Rebooting emulator..."
|
||
$ADB_BIN reboot
|
||
ok "Reboot initiated"
|
||
|
||
info "Waiting for emulator to come back online..."
|
||
$ADB_BIN wait-for-device
|
||
|
||
info "Waiting for system to fully boot..."
|
||
while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do
|
||
sleep 2
|
||
done
|
||
sleep 5 # Additional buffer for system services
|
||
ok "Emulator back online"
|
||
}
|
||
|
||
# --- Log Parsing Helpers ---
|
||
|
||
get_recovery_logs() {
|
||
# Collect only the relevant reactivation logs
|
||
$ADB_BIN logcat -d | grep "$REACTIVATION_TAG" || true
|
||
}
|
||
|
||
extract_field_from_logs() {
|
||
# Usage: extract_field_from_logs "$logs" "rescheduled"
|
||
local logs="$1"
|
||
local key="$2"
|
||
echo "$logs" | grep -E "$key=" | sed -E "s/.*$key=([0-9]+).*/\1/" | tail -n1 || echo "0"
|
||
}
|
||
|
||
extract_scenario_from_logs() {
|
||
local logs="$1"
|
||
echo "$logs" | grep -E "$SCENARIO_KEY" | sed -E "s/.*$SCENARIO_KEY([A-Z_]+).*/\1/" | tail -n1 || echo ""
|
||
}
|
||
|
||
# Phase 1 compatibility: check_recovery_logs
|
||
check_recovery_logs() {
|
||
print_info "Checking recovery logs..."
|
||
echo ""
|
||
$ADB_BIN logcat -d | grep -E "$REACTIVATION_TAG" | tail -10
|
||
echo ""
|
||
}
|
||
|
||
# Phase 1 compatibility: check_alarm_status
|
||
check_alarm_status() {
|
||
print_info "Checking AlarmManager status..."
|
||
echo ""
|
||
$ADB_BIN shell dumpsys alarm | grep -i "$APP_ID" | head -5
|
||
echo ""
|
||
}
|
||
|
||
# --- Test Selection Helper ---
|
||
|
||
should_run_test() {
|
||
# Usage: should_run_test "1" SELECTED_TESTS
|
||
# Returns 0 (success) if test should run, 1 (failure) if not
|
||
local id="$1"
|
||
local -n selected_tests_ref="$2"
|
||
|
||
# If no tests are specified, run all tests
|
||
if [[ "${#selected_tests_ref[@]}" -eq 0 ]]; then
|
||
return 0
|
||
fi
|
||
|
||
for t in "${selected_tests_ref[@]}"; do
|
||
if [[ "$t" == "$id" ]]; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|