#!/usr/bin/env bash # run-emulator-tests.sh — Run all emulator/simulator UI tests for PeriodVault # Usage: ./scripts/run-emulator-tests.sh [android|ios|all] # Logs to build/emulator-tests.log; script reads the log to detect adb errors (e.g. multiple devices). # # iOS watchdog env controls: # IOS_HEARTBEAT_SECONDS (default: 30) # IOS_STARTUP_PROGRESS_TIMEOUT_SECONDS (default: 900) # IOS_TEST_STALL_TIMEOUT_SECONDS (default: 480) # IOS_UNRESPONSIVE_STALL_TIMEOUT_SECONDS(default: 120) # IOS_HARD_TIMEOUT_SECONDS (default: 10800) # IOS_ACTIVE_CPU_THRESHOLD (default: 1.0) set -euo pipefail PLATFORM="${1:-all}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$PROJECT_ROOT" # shellcheck source=scripts/lib.sh source "$SCRIPT_DIR/lib.sh" ensure_log_file "emulator-tests.log" # Start Android emulator headless for test runs (no GUI window needed) export EMULATOR_HEADLESS=1 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' ANDROID_PASS=0 IOS_PASS=0 ANDROID_FAIL=0 IOS_FAIL=0 run_android() { echo -e "${YELLOW}=== Android Emulator Tests ===${NC}" if ! ensure_android_emulator; then echo -e "${RED}ERROR: Could not start or connect to Android emulator. See $LOG_FILE${NC}" ANDROID_FAIL=1 return 1 fi # Disable animations for stable UI tests run_and_log "adb_disable_animations" adb shell "settings put global window_animation_scale 0; settings put global transition_animation_scale 0; settings put global animator_duration_scale 0" || true # Pre-flight: verify emulator is responsive via adb shell echo "Verifying Android emulator is responsive..." if ! adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then echo -e "${RED}ERROR: Android emulator not responsive (sys.boot_completed != 1). Aborting.${NC}" ANDROID_FAIL=1 return 1 fi echo "Android emulator is responsive." # Uninstall the app to ensure a clean database for tests echo "Cleaning app data..." adb uninstall periodvault.androidApp 2>/dev/null || true adb uninstall periodvault.androidApp.test 2>/dev/null || true echo "Running Android instrumented tests..." local GRADLE_PID local GRADLE_EXIT=0 local TOTAL_ANDROID_TESTS=0 TOTAL_ANDROID_TESTS=$(find androidApp/src/androidTest -name '*.kt' -type f -exec grep -hE '@Test' {} + 2>/dev/null | wc -l | tr -d ' ') if [[ -z "$TOTAL_ANDROID_TESTS" ]]; then TOTAL_ANDROID_TESTS=0 fi ./gradlew androidApp:connectedDebugAndroidTest 2>&1 & GRADLE_PID=$! # Progress/liveness watchdog: # - emits heartbeat every 30s with completed Android test cases and emulator health # - kills early only if emulator is unresponsive and test progress is stalled for 10m # - retains a generous hard timeout as last-resort safety net local HEARTBEAT_SECONDS=30 local UNRESPONSIVE_STALL_TIMEOUT_SECONDS=600 local HARD_TIMEOUT_SECONDS=7200 # 2 hours ( local start_ts now_ts elapsed local last_progress_ts local completed=0 local last_completed=0 local stale_seconds=0 local emu_health="" start_ts=$(date +%s) last_progress_ts=$start_ts while kill -0 $GRADLE_PID 2>/dev/null; do sleep "$HEARTBEAT_SECONDS" now_ts=$(date +%s) elapsed=$((now_ts - start_ts)) completed=$(find androidApp/build/outputs/androidTest-results/connected -name '*.xml' -type f -exec grep -ho "/dev/null | wc -l | tr -d ' ') if [[ -z "$completed" ]]; then completed=0 fi if [[ "$completed" -gt "$last_completed" ]]; then last_progress_ts=$now_ts last_completed=$completed fi if adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then emu_health="responsive" else emu_health="UNRESPONSIVE" fi stale_seconds=$((now_ts - last_progress_ts)) local elapsed_mm elapsed_ss elapsed_mm=$((elapsed / 60)) elapsed_ss=$((elapsed % 60)) if [[ "$TOTAL_ANDROID_TESTS" -gt 0 ]]; then echo "Android progress: ${completed}/${TOTAL_ANDROID_TESTS} tests complete | elapsed ${elapsed_mm}m${elapsed_ss}s | emulator ${emu_health}" else echo "Android progress: ${completed} tests complete | elapsed ${elapsed_mm}m${elapsed_ss}s | emulator ${emu_health}" fi if [[ "$elapsed" -ge "$HARD_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing Gradle (PID $GRADLE_PID) after hard timeout ${HARD_TIMEOUT_SECONDS}s" kill $GRADLE_PID 2>/dev/null || true sleep 5 kill -9 $GRADLE_PID 2>/dev/null || true break fi if [[ "$emu_health" == "UNRESPONSIVE" ]] && [[ "$stale_seconds" -ge "$UNRESPONSIVE_STALL_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing Gradle (PID $GRADLE_PID) - emulator unresponsive and no progress for ${stale_seconds}s" kill $GRADLE_PID 2>/dev/null || true sleep 5 kill -9 $GRADLE_PID 2>/dev/null || true break fi done ) & local WATCHDOG_PID=$! wait $GRADLE_PID 2>/dev/null || GRADLE_EXIT=$? kill $WATCHDOG_PID 2>/dev/null || true wait $WATCHDOG_PID 2>/dev/null || true if [[ $GRADLE_EXIT -eq 137 ]] || [[ $GRADLE_EXIT -eq 143 ]]; then echo -e "${RED}Android emulator tests terminated by watchdog${NC}" ANDROID_FAIL=1 run_and_log "adb_restore_animations" adb shell "settings put global window_animation_scale 1; settings put global transition_animation_scale 1; settings put global animator_duration_scale 1" || true return 1 elif [[ $GRADLE_EXIT -eq 0 ]]; then echo -e "${GREEN}Android emulator tests PASSED${NC}" ANDROID_PASS=1 # Emit runtime evidence for CI tracking local android_duration_s="" local android_test_count="" if [[ -f androidApp/build/reports/androidTests/connected/debug/index.html ]]; then android_test_count="$(grep -o '
[0-9]*
' androidApp/build/reports/androidTests/connected/debug/index.html | head -1 | grep -o '[0-9]*' || echo "")" android_duration_s="$(grep -o '
[0-9a-z.]*s
' androidApp/build/reports/androidTests/connected/debug/index.html | head -1 | grep -o '[0-9.]*' || echo "")" fi echo "RUNTIME_EVIDENCE: {\"suite\": \"android_ui\", \"tests\": ${android_test_count:-0}, \"duration\": \"${android_duration_s:-unknown}s\", \"timestamp\": \"$(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" else echo -e "${RED}Android emulator tests FAILED${NC}" ANDROID_FAIL=1 echo "Test reports: androidApp/build/reports/androidTests/connected/debug/" run_and_log "adb_restore_animations" adb shell "settings put global window_animation_scale 1; settings put global transition_animation_scale 1; settings put global animator_duration_scale 1" || true return 1 fi # Re-enable animations run_and_log "adb_restore_animations" adb shell "settings put global window_animation_scale 1; settings put global transition_animation_scale 1; settings put global animator_duration_scale 1" || true } run_ios() { echo -e "${YELLOW}=== iOS Simulator Tests ===${NC}" # Find an available simulator local SIM_ID SIM_ID=$(xcrun simctl list devices available -j 2>/dev/null | python3 -c " import json, sys data = json.load(sys.stdin) for runtime, devices in data.get('devices', {}).items(): if 'iOS' in runtime: for d in devices: if d.get('isAvailable'): print(d['udid']) sys.exit(0) sys.exit(1) " 2>/dev/null) || true if [[ -z "$SIM_ID" ]]; then echo -e "${RED}ERROR: No available iOS simulator found.${NC}" IOS_FAIL=1 return 1 fi local SIM_NAME SIM_NAME=$(xcrun simctl list devices available | grep "$SIM_ID" | sed 's/ (.*//' | xargs) echo "Using simulator: $SIM_NAME ($SIM_ID)" # Boot simulator if needed xcrun simctl boot "$SIM_ID" 2>/dev/null || true # Health check: verify simulator is actually responsive (not just "Booted" in simctl) echo "Verifying simulator is responsive..." local HEALTH_OK=false for i in 1 2 3 4 5; do if xcrun simctl spawn "$SIM_ID" launchctl print system >/dev/null 2>&1; then HEALTH_OK=true break fi echo " Attempt $i/5: simulator not responsive, waiting 5s..." sleep 5 done if [[ "$HEALTH_OK" != "true" ]]; then echo -e "${RED}ERROR: Simulator $SIM_NAME ($SIM_ID) reports Booted but is not responsive.${NC}" echo "Attempting full restart..." xcrun simctl shutdown "$SIM_ID" 2>/dev/null || true sleep 3 xcrun simctl boot "$SIM_ID" 2>/dev/null || true sleep 10 if ! xcrun simctl spawn "$SIM_ID" launchctl print system >/dev/null 2>&1; then echo -e "${RED}ERROR: Simulator still unresponsive after restart. Aborting.${NC}" IOS_FAIL=1 return 1 fi echo "Simulator recovered after restart." fi echo "Simulator is responsive." # Generate Xcode project if needed if [[ ! -f iosApp/iosApp.xcodeproj/project.pbxproj ]]; then echo "Generating Xcode project..." (cd iosApp && xcodegen generate) fi # --- Phase 1: Build (synchronous, fail-fast) --- echo "Building iOS UI tests..." local BUILD_DIR BUILD_DIR=$(mktemp -d) local BUILD_LOG BUILD_LOG=$(mktemp) local BUILD_START BUILD_START=$(date +%s) xcodebuild build-for-testing \ -project iosApp/iosApp.xcodeproj \ -scheme iosApp \ -destination "platform=iOS Simulator,id=$SIM_ID" \ -derivedDataPath "$BUILD_DIR" \ > "$BUILD_LOG" 2>&1 local BUILD_EXIT=$? local BUILD_END BUILD_END=$(date +%s) echo "iOS build phase: $((BUILD_END - BUILD_START))s (exit=$BUILD_EXIT)" if [[ $BUILD_EXIT -ne 0 ]]; then echo -e "${RED}BUILD FAILED — last 30 lines:${NC}" tail -30 "$BUILD_LOG" rm -f "$BUILD_LOG" rm -rf "$BUILD_DIR" IOS_FAIL=1 return 1 fi rm -f "$BUILD_LOG" # Disable animations for stable, faster UI tests echo "Disabling simulator animations..." xcrun simctl spawn "$SIM_ID" defaults write com.apple.Accessibility ReduceMotionEnabled -bool YES 2>/dev/null || true # Uninstall the app to ensure a clean database for tests echo "Cleaning app data..." xcrun simctl uninstall "$SIM_ID" com.periodvault.app 2>/dev/null || true # --- Phase 2: Test (background with watchdog, parallel execution) --- echo "Running iOS UI tests (parallel enabled)..." local TEST_EXIT=0 local TEST_LOG TEST_LOG=$(mktemp) local RESULT_BUNDLE_DIR RESULT_BUNDLE_DIR=$(mktemp -d) local RESULT_BUNDLE_PATH="$RESULT_BUNDLE_DIR/ios-ui-tests.xcresult" local TOTAL_IOS_TESTS=0 TOTAL_IOS_TESTS=$(find iosApp/iosAppUITests -name '*.swift' -print0 2>/dev/null | xargs -0 grep -hE '^[[:space:]]*func[[:space:]]+test' 2>/dev/null | wc -l | tr -d ' ') if [[ -z "$TOTAL_IOS_TESTS" ]]; then TOTAL_IOS_TESTS=0 fi local TEST_START TEST_START=$(date +%s) xcodebuild test-without-building \ -project iosApp/iosApp.xcodeproj \ -scheme iosApp \ -destination "platform=iOS Simulator,id=$SIM_ID" \ -only-testing:iosAppUITests \ -derivedDataPath "$BUILD_DIR" \ -resultBundlePath "$RESULT_BUNDLE_PATH" \ -parallel-testing-enabled YES \ > "$TEST_LOG" 2>&1 & local XCODE_PID=$! # Progress/liveness watchdog: # - emits heartbeat with completed test count and simulator health # - fails fast when CoreSimulatorService is unhealthy # - treats test completion, xcodebuild CPU, and log growth as activity # - fails when startup/test activity stalls beyond configured thresholds # - keeps a hard cap as a final safety net local HEARTBEAT_SECONDS="${IOS_HEARTBEAT_SECONDS:-30}" local STARTUP_PROGRESS_TIMEOUT_SECONDS="${IOS_STARTUP_PROGRESS_TIMEOUT_SECONDS:-900}" local TEST_STALL_TIMEOUT_SECONDS="${IOS_TEST_STALL_TIMEOUT_SECONDS:-480}" local UNRESPONSIVE_STALL_TIMEOUT_SECONDS="${IOS_UNRESPONSIVE_STALL_TIMEOUT_SECONDS:-120}" local HARD_TIMEOUT_SECONDS="${IOS_HARD_TIMEOUT_SECONDS:-10800}" # 3 hours local ACTIVE_CPU_THRESHOLD="${IOS_ACTIVE_CPU_THRESHOLD:-1.0}" echo "iOS watchdog: heartbeat=${HEARTBEAT_SECONDS}s startup_timeout=${STARTUP_PROGRESS_TIMEOUT_SECONDS}s test_stall_timeout=${TEST_STALL_TIMEOUT_SECONDS}s unresponsive_timeout=${UNRESPONSIVE_STALL_TIMEOUT_SECONDS}s hard_timeout=${HARD_TIMEOUT_SECONDS}s cpu_active_threshold=${ACTIVE_CPU_THRESHOLD}%" ( local start_ts now_ts elapsed local last_test_progress_ts local last_activity_ts local completed=0 local last_completed=0 local stale_seconds=0 local sim_health="" local first_test_seen=false local simctl_health_output="" local log_size=0 local last_log_size=0 local xcode_cpu="0.0" local xcode_cpu_raw="" start_ts=$(date +%s) last_test_progress_ts=$start_ts last_activity_ts=$start_ts while kill -0 $XCODE_PID 2>/dev/null; do sleep "$HEARTBEAT_SECONDS" now_ts=$(date +%s) elapsed=$((now_ts - start_ts)) # Keep watchdog alive before first completed test appears; do not fail on zero matches. completed=$(grep -E -c "Test [Cc]ase .* (passed|failed)" "$TEST_LOG" 2>/dev/null || true) if [[ -z "$completed" ]]; then completed=0 fi if [[ "$completed" -gt "$last_completed" ]]; then last_test_progress_ts=$now_ts last_activity_ts=$now_ts last_completed=$completed first_test_seen=true fi # xcodebuild output growth indicates ongoing work even when a test has not completed yet. log_size=$(wc -c < "$TEST_LOG" 2>/dev/null || echo 0) if [[ -n "$log_size" ]] && [[ "$log_size" -gt "$last_log_size" ]]; then last_log_size=$log_size last_activity_ts=$now_ts fi # CPU usage provides another liveness signal during long-running UI tests. xcode_cpu_raw=$(ps -p "$XCODE_PID" -o %cpu= 2>/dev/null | tr -d ' ' || true) if [[ -n "$xcode_cpu_raw" ]]; then xcode_cpu="$xcode_cpu_raw" else xcode_cpu="0.0" fi if awk "BEGIN { exit !($xcode_cpu >= $ACTIVE_CPU_THRESHOLD) }"; then last_activity_ts=$now_ts fi if simctl_health_output=$(xcrun simctl spawn "$SIM_ID" launchctl print system 2>&1); then sim_health="responsive" else sim_health="UNRESPONSIVE" # Fail fast when the simulator service itself is down. Waiting longer does not recover this state. if echo "$simctl_health_output" | grep -Eiq "CoreSimulatorService connection became invalid|not connected to CoreSimulatorService|Unable to locate device set|Connection refused|simdiskimaged.*(crashed|not responding)|Unable to discover any Simulator runtimes"; then echo "WATCHDOG: CoreSimulatorService unhealthy; killing xcodebuild (PID $XCODE_PID) immediately" echo "$simctl_health_output" | head -5 | sed 's/^/ simctl: /' kill $XCODE_PID 2>/dev/null || true sleep 5 kill -9 $XCODE_PID 2>/dev/null || true break fi fi stale_seconds=$((now_ts - last_activity_ts)) local elapsed_mm elapsed_ss elapsed_mm=$((elapsed / 60)) elapsed_ss=$((elapsed % 60)) if [[ "$TOTAL_IOS_TESTS" -gt 0 ]]; then echo "iOS progress: ${completed}/${TOTAL_IOS_TESTS} tests complete | elapsed ${elapsed_mm}m${elapsed_ss}s | simulator ${sim_health} | xcodebuild cpu ${xcode_cpu}%" else echo "iOS progress: ${completed} tests complete | elapsed ${elapsed_mm}m${elapsed_ss}s | simulator ${sim_health} | xcodebuild cpu ${xcode_cpu}%" fi if [[ "$elapsed" -ge "$HARD_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing xcodebuild (PID $XCODE_PID) after hard timeout ${HARD_TIMEOUT_SECONDS}s" kill $XCODE_PID 2>/dev/null || true sleep 5 kill -9 $XCODE_PID 2>/dev/null || true break fi if [[ "$first_test_seen" != "true" ]] && [[ "$elapsed" -ge "$STARTUP_PROGRESS_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing xcodebuild (PID $XCODE_PID) - no completed iOS test observed within startup timeout (${STARTUP_PROGRESS_TIMEOUT_SECONDS}s)" kill $XCODE_PID 2>/dev/null || true sleep 5 kill -9 $XCODE_PID 2>/dev/null || true break fi if [[ "$first_test_seen" == "true" ]] && [[ "$stale_seconds" -ge "$TEST_STALL_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing xcodebuild (PID $XCODE_PID) - no iOS test activity for ${stale_seconds}s" kill $XCODE_PID 2>/dev/null || true sleep 5 kill -9 $XCODE_PID 2>/dev/null || true break fi if [[ "$sim_health" == "UNRESPONSIVE" ]] && [[ "$stale_seconds" -ge "$UNRESPONSIVE_STALL_TIMEOUT_SECONDS" ]]; then echo "WATCHDOG: killing xcodebuild (PID $XCODE_PID) - simulator unresponsive and no test activity for ${stale_seconds}s" kill $XCODE_PID 2>/dev/null || true sleep 5 kill -9 $XCODE_PID 2>/dev/null || true break fi done ) & local WATCHDOG_PID=$! wait $XCODE_PID 2>/dev/null || TEST_EXIT=$? kill $WATCHDOG_PID 2>/dev/null || true wait $WATCHDOG_PID 2>/dev/null || true local TEST_END TEST_END=$(date +%s) echo "iOS test phase: $((TEST_END - TEST_START))s (exit=$TEST_EXIT)" echo "iOS total (build+test): $((TEST_END - BUILD_START))s" # Show test summary (passed/failed counts and any failures) echo "--- Test Results ---" grep -E "Test [Cc]ase .* (passed|failed)" "$TEST_LOG" || true echo "" echo "--- Failures ---" grep -E "(FAIL|error:|\*\* TEST FAILED)" "$TEST_LOG" || echo " (none)" echo "" echo "--- Last 20 lines ---" tail -20 "$TEST_LOG" rm -f "$TEST_LOG" if [[ $TEST_EXIT -eq 0 ]]; then local SKIP_ALLOWLIST="${IOS_SKIPPED_TESTS_ALLOWLIST:-audit/ios-skipped-tests-allowlist.txt}" if ! bash "$SCRIPT_DIR/validate-ios-skipped-tests.sh" "$RESULT_BUNDLE_PATH" "$SKIP_ALLOWLIST"; then echo -e "${RED}iOS skipped-test gate FAILED${NC}" TEST_EXIT=1 fi fi rm -rf "$RESULT_BUNDLE_DIR" rm -rf "$BUILD_DIR" # Re-enable animations xcrun simctl spawn "$SIM_ID" defaults write com.apple.Accessibility ReduceMotionEnabled -bool NO 2>/dev/null || true if [[ $TEST_EXIT -eq 137 ]] || [[ $TEST_EXIT -eq 143 ]]; then echo -e "${RED}iOS simulator tests terminated by watchdog${NC}" IOS_FAIL=1 return 1 elif [[ $TEST_EXIT -eq 0 ]]; then echo -e "${GREEN}iOS simulator tests PASSED${NC}" IOS_PASS=1 # Emit runtime evidence for CI tracking local ios_test_count="" ios_test_count="$TOTAL_IOS_TESTS" local ios_elapsed_s="" ios_elapsed_s="$(($(date +%s) - $(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +%s 2>/dev/null || echo 0)))" echo "RUNTIME_EVIDENCE: {\"suite\": \"ios_ui\", \"tests\": ${ios_test_count:-0}, \"timestamp\": \"$(date -u '+%Y-%m-%dT%H:%M:%SZ')\"}" else echo -e "${RED}iOS simulator tests FAILED${NC}" IOS_FAIL=1 return 1 fi } echo "================================================" echo " PeriodVault Emulator/Simulator Test Runner" echo "================================================" echo "" case "$PLATFORM" in android) run_android ;; ios) run_ios ;; all) run_android || true echo "" run_ios || true ;; *) echo "Usage: $0 [android|ios|all]" exit 1 ;; esac echo "" echo "================================================" echo " Results Summary" echo "================================================" if [[ "$PLATFORM" == "all" || "$PLATFORM" == "android" ]]; then if [[ $ANDROID_PASS -eq 1 ]]; then echo -e " Android: ${GREEN}PASSED${NC}" elif [[ $ANDROID_FAIL -eq 1 ]]; then echo -e " Android: ${RED}FAILED${NC}" else echo -e " Android: ${YELLOW}SKIPPED${NC}" fi fi if [[ "$PLATFORM" == "all" || "$PLATFORM" == "ios" ]]; then if [[ $IOS_PASS -eq 1 ]]; then echo -e " iOS: ${GREEN}PASSED${NC}" elif [[ $IOS_FAIL -eq 1 ]]; then echo -e " iOS: ${RED}FAILED${NC}" else echo -e " iOS: ${YELLOW}SKIPPED${NC}" fi fi echo "================================================" # Exit with failure if any platform failed if [[ $ANDROID_FAIL -eq 1 ]] || [[ $IOS_FAIL -eq 1 ]]; then exit 1 fi