Files
daily-notification-plugin/test-apps/android-test-app/alarm-test-lib.sh
Matthew Raymer 1103513db3 test(android): fix alarm counting logic and add screenshot capture
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).
2025-12-04 09:28:28 +00:00

614 lines
16 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
}