diff --git a/.github/scripts/connected-android-test.sh b/.github/scripts/connected-android-test.sh index 85ae957a..ab0433dd 100755 --- a/.github/scripts/connected-android-test.sh +++ b/.github/scripts/connected-android-test.sh @@ -1,8 +1,30 @@ #!/bin/bash +set -euo pipefail OUTPUT_ROOT="build/ci-connected-android-test-reports" echo "reports-path=$OUTPUT_ROOT" >>"$GITHUB_OUTPUT" +# Keep in sync with the "Build Application" step in +# .github/workflows/connected-android-test.yml +TEST_TASKS=( + "app:connectedUnstableDebugAndroidTest" + "core:connectedDebugAndroidTest" + "shared:connectedAndroidDeviceTest" +) + +# Environment-instability signatures seen on older API images (e.g. API 24). +# When a failure matches one of these we retry ONCE after restarting adb. +# Anything else is treated as a real failure and must not be retried. +RETRY_SIGNATURES=( + "ShellCommandUnresponsiveException" + "InstallException" + "install-write" + "device offline" + "device not found" + # This might happen when device hang up or activity launch failed + "No compose hierarchies found" +) + TEST_FAILED=0 set_orientation() { @@ -12,28 +34,80 @@ set_orientation() { adb shell settings put system user_rotation "$rotation" } +is_environment_failure() { + local log=$1 signature + for signature in "${RETRY_SIGNATURES[@]}"; do + if grep -q "$signature" "$log"; then + return 0 + fi + done + return 1 +} + +dump_failure_diagnostics() { + local out="$OUTPUT_ROOT/diagnostics" + mkdir -p "$out" + echo "=== Dumping failure diagnostics to $out ===" + # The device may be half-dead at this point, so tolerate dump failures. + timeout 30 adb logcat -d >"$out/logcat.log" 2>&1 || echo "⚠️ logcat dump failed or timed out" + adb devices -l >"$out/devices.txt" 2>&1 || true +} + +run_stage() { + local stage=$1 orientation=$2 attempt + local log + mkdir -p "$OUTPUT_ROOT" + set_orientation "$orientation" + + for attempt in 1 2; do + log="$OUTPUT_ROOT/gradle-$stage-attempt$attempt.log" + if ./gradlew "${TEST_TASKS[@]}" --stacktrace 2>&1 | tee "$log"; then + return 0 + fi + if [ "$attempt" -eq 1 ] && is_environment_failure "$log"; then + echo "⚠️ Environment failure detected, retrying once" + dump_failure_diagnostics + adb kill-server || true + adb start-server || true + adb wait-for-device || true + else + return 1 + fi + done + return 1 +} + archive_reports() { local stage=$1 local target_dir="$OUTPUT_ROOT/$stage" echo "=== Archiving $stage test reports ===" - mkdir -p "$target_dir/app" "$target_dir/shared" - cp -r app/build/reports/androidTests/connected/* "$target_dir/app/" 2>/dev/null || true - cp -r shared/build/reports/androidTests/connected/* "$target_dir/shared/" 2>/dev/null || true + # Every module listed in TEST_TASKS is expected to produce a connected + # test report dir. A missing/empty one means the gradle run claimed + # success without discoverable results - fail loudly instead of + # silently uploading an empty artifact. + local module src + for module in app core shared; do + src="$module/build/reports/androidTests/connected" + if [ ! -d "$src" ] || [ -z "$(ls -A "$src")" ]; then + echo "❌ No connected test reports found for :$module: (expected at $src)" + exit 1 + fi + mkdir -p "$target_dir/$module" + cp -r "$src"/* "$target_dir/$module/" + done } echo "=== Starting Portrait Tests ===" -set_orientation 0 -if ! ./gradlew connectedDebugAndroidTest --stacktrace; then +if ! run_stage portrait 0; then echo "❌ Portrait tests failed!" TEST_FAILED=1 fi archive_reports "portrait" echo "=== Starting Landscape Tests ===" -set_orientation 1 -if ! ./gradlew connectedDebugAndroidTest --stacktrace; then +if ! run_stage landscape 1; then echo "❌ Landscape tests failed!" TEST_FAILED=1 fi @@ -44,4 +118,4 @@ if [ $TEST_FAILED -ne 0 ]; then exit 1 fi -echo "✅ All UI tests passed." +echo "✅ All connected tests passed." diff --git a/.github/workflows/connected-android-test.yml b/.github/workflows/connected-android-test.yml index 3d6c49aa..8764d868 100644 --- a/.github/workflows/connected-android-test.yml +++ b/.github/workflows/connected-android-test.yml @@ -45,8 +45,9 @@ jobs: uses: ./.github/actions/setup-ocr-model # Avoid building the application when AVD is running, so Gradle can use more RAM + # NOTE: keep this task list in sync with TEST_TASKS in .github/scripts/connected-android-test.sh - name: Build Application - run: ./gradlew assembleDebugAndroidTest --stacktrace + run: ./gradlew app:assembleUnstableDebugAndroidTest core:assembleDebugAndroidTest shared:assembleAndroidDeviceTest --stacktrace # android-emulator-runner configurations start here # See also https://github.com/ReactiveCircus/android-emulator-runner#usage--examples @@ -73,3 +74,4 @@ jobs: with: name: test-results-api${{ matrix.api-level }}-${{ matrix.target }} path: ${{ steps.ui_tests.outputs.reports-path }} + if-no-files-found: error