diff --git a/.github/scripts/run-e2e.sh b/.github/scripts/run-e2e.sh new file mode 100755 index 000000000..cc2dcfaf0 --- /dev/null +++ b/.github/scripts/run-e2e.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# .github/scripts/run-e2e.sh +# +# Runs the In-App Message E2E test under ReactiveCircus/android-emulator-runner +# and captures diagnostics that survive the action's emulator-kill on exit. +# +# Why this is an external script and not inline YAML: +# The action runs each line of `script:` in a fresh `/bin/sh -c`, so cross-line +# variables and shell functions don't survive. We need a single bash process for +# the trap + variable + function semantics. +# +# Inputs (env, all set by the workflow step): +# ITERABLE_API_KEY — set as buildConfigField at runtime; not echoed. +# ITERABLE_SERVER_API_KEY — set as buildConfigField at runtime; not echoed. +# ITERABLE_TEST_USER_EMAIL — used by tests; not echoed (length only). +# GITHUB_WORKSPACE — set by the runner; root for diagnostics output. +# +# Outputs: +# $GITHUB_WORKSPACE/integration-tests/build/diagnostics/ +# hierarchy.xml — UiAutomator dump at the moment of test exit +# screenshot.png — device screenshot at the moment of test exit +# logcat.txt — full device logcat from start of test invocation +# +# Exit code: +# The gradle test task's exit code, propagated. +# +# This script writes nothing outside $GITHUB_WORKSPACE/integration-tests/build/. + +set -uo pipefail + +readonly TEST_CLASS="${TEST_CLASS:-com.iterable.integration.tests.InAppMessageIntegrationTest#testInAppMessageMVP}" +readonly DIAG_DIR="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE must be set}/integration-tests/build/diagnostics" +readonly TEST_PACKAGE="com.iterable.integration.tests" + +mkdir -p "$DIAG_DIR" + +log() { printf '\033[1;34m[e2e]\033[0m %s\n' "$*"; } + +log "Running E2E test: $TEST_CLASS" +log "Diagnostics will be written to: $DIAG_DIR" + +# Sanity-check env: don't echo secret values, only their lengths. The workflow's +# env: block guarantees these vars exist; ${#VAR} of an empty string is 0. +log "ITERABLE_API_KEY length: ${#ITERABLE_API_KEY}" +log "ITERABLE_SERVER_API_KEY length: ${#ITERABLE_SERVER_API_KEY}" +log "ITERABLE_TEST_USER_EMAIL length: ${#ITERABLE_TEST_USER_EMAIL}" + +# Grant permissions; ignore failures (the package may not be installed yet, +# in which case AGP will install + auto-grant during the test step). +for perm in POST_NOTIFICATIONS INTERNET ACCESS_NETWORK_STATE WAKE_LOCK; do + adb shell pm grant "$TEST_PACKAGE" "android.permission.$perm" >/dev/null 2>&1 || true +done + +# Stream full logcat to the workspace so the artifact upload always has it. +adb logcat -c >/dev/null 2>&1 || true +adb logcat > "$DIAG_DIR/logcat.txt" & +LOGCAT_PID=$! + +# Capture diagnostics that depend on a live emulator. Called from EXIT trap so +# we always run, whether tests passed, failed, or the runner timed out. +capture_post_test() { + log "Capturing post-test diagnostics..." + + # Stop logcat first so the file isn't being appended to mid-copy. + if [[ -n "${LOGCAT_PID:-}" ]]; then + kill "$LOGCAT_PID" 2>/dev/null || true + wait "$LOGCAT_PID" 2>/dev/null || true + fi + + # UiAutomator hierarchy — answers "what was UiAutomator looking at?" + if adb shell uiautomator dump /sdcard/hierarchy.xml >/dev/null 2>&1; then + adb pull /sdcard/hierarchy.xml "$DIAG_DIR/hierarchy.xml" >/dev/null 2>&1 || true + adb shell rm -f /sdcard/hierarchy.xml >/dev/null 2>&1 || true + fi + + # Screenshot — answers "what was actually on the screen?" + if adb shell screencap -p /sdcard/screenshot.png >/dev/null 2>&1; then + adb pull /sdcard/screenshot.png "$DIAG_DIR/screenshot.png" >/dev/null 2>&1 || true + adb shell rm -f /sdcard/screenshot.png >/dev/null 2>&1 || true + fi + + log "Diagnostics captured:" + ls -la "$DIAG_DIR" || true +} +trap capture_post_test EXIT + +# Run the test. Don't `set -e`; we want to capture diagnostics on failure and +# propagate the original exit code at the end. +gradle_exit=0 +./gradlew :integration-tests:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class="$TEST_CLASS" \ + --stacktrace --no-daemon || gradle_exit=$? + +if [[ "$gradle_exit" -ne 0 ]]; then + log "::error::Gradle test task failed with exit code $gradle_exit — see e2e-diagnostics-api artifact" +fi + +# capture_post_test runs via EXIT trap; just propagate the exit code. +exit "$gradle_exit" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b072cdad2..9b3ccbf50 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,13 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 # v3.3.2 + uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 - name: Configure JDK - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2 # v1.4.3 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 with: - java-version: 17 + distribution: temurin + java-version: '17' - run: touch local.properties @@ -36,12 +37,13 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 # v3.3.2 + uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 - name: Configure JDK - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2 # v1.4.3 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 with: - java-version: 17 + distribution: temurin + java-version: '17' - run: touch local.properties @@ -66,12 +68,13 @@ jobs: uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Validate Gradle Wrapper - uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 # v3.3.2 + uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 - name: Configure JDK - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2 # v1.4.3 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 with: - java-version: 17 + distribution: temurin + java-version: '17' - run: touch local.properties diff --git a/.github/workflows/inapp-e2e-tests.yml b/.github/workflows/inapp-e2e-tests.yml index ba68dbd6c..b5cd28d29 100644 --- a/.github/workflows/inapp-e2e-tests.yml +++ b/.github/workflows/inapp-e2e-tests.yml @@ -10,16 +10,27 @@ on: jobs: inapp-e2e-tests: name: In-App Message E2E Tests - runs-on: macos-15-intel - + # SDK-170: macOS Intel runners (2 cores / 3GB AVD on HVF) starved system_server during + # cold boot and produced cascading ANRs (systemui / nexuslauncher / gms / phone …), + # leaving a system dialog on top of MainActivity so UiAutomator could not find the + # in-app button. Ubuntu runners with KVM acceleration and 4 vCPU / 16GB stop the storm. + runs-on: ubuntu-latest + strategy: matrix: api-level: [34] # MVP testing on most relevant API level only - + steps: - name: Checkout code uses: actions/checkout@v4 - + + - name: Enable KVM device permissions + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -69,15 +80,15 @@ jobs: ./gradlew :integration-tests:assembleDebug :integration-tests:assembleDebugAndroidTest --no-daemon & echo "Build started in background..." - - name: Run UI Tests with Emulator (Intel / x86_64) + - name: Run UI Tests with Emulator (KVM / x86_64) uses: ReactiveCircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 profile: pixel_6 - cores: 2 - ram-size: 3072M + cores: 4 + ram-size: 4096M heap-size: 576M force-avd-creation: true disable-animations: true @@ -87,98 +98,30 @@ jobs: # Clean + start adb after platform-tools exist (avoids tcp:5037 noise) adb kill-server >/dev/null 2>&1 || true adb start-server - script: | - echo "Emulator is ready! Running tests..." - echo "Setting up permissions..." - adb shell pm grant com.iterable.integration.tests android.permission.POST_NOTIFICATIONS - adb shell pm grant com.iterable.integration.tests android.permission.INTERNET - adb shell pm grant com.iterable.integration.tests android.permission.ACCESS_NETWORK_STATE - adb shell pm grant com.iterable.integration.tests android.permission.WAKE_LOCK - - echo "Running In-App Message MVP test..." - echo "Debug: Checking if APKs are ready..." - ls -la integration-tests/build/outputs/apk/ || echo "APK directory not found" - - echo "Debug: Verifying API keys are set..." - echo "ITERABLE_API_KEY length: ${#ITERABLE_API_KEY}" - echo "ITERABLE_SERVER_API_KEY length: ${#ITERABLE_SERVER_API_KEY}" - echo "ITERABLE_TEST_USER_EMAIL: $ITERABLE_TEST_USER_EMAIL" - - # Start logcat in background for crash debugging - adb logcat > /tmp/test-logcat.log & - LOGCAT_PID=$! - - # Run the specific test with better error handling - ./gradlew :integration-tests:connectedDebugAndroidTest \ - -Pandroid.testInstrumentationRunnerArguments.class=com.iterable.integration.tests.InAppMessageIntegrationTest#testInAppMessageMVP \ - --stacktrace --no-daemon || { - echo "Test failed! Collecting crash logs..." - kill $LOGCAT_PID 2>/dev/null || true - echo "=== CRASH LOGS ===" - tail -100 /tmp/test-logcat.log - echo "=== END CRASH LOGS ===" - exit 1 - } - - # Stop logcat - kill $LOGCAT_PID 2>/dev/null || true + # The android-emulator-runner action runs each line of an inline `script:` + # in a fresh `/bin/sh -c`, so cross-line variables and bash functions don't + # survive. Externalise the whole thing to a single bash file that runs in + # one process — see .github/scripts/run-e2e.sh for the actual logic. + script: bash "$GITHUB_WORKSPACE/.github/scripts/run-e2e.sh" env: ITERABLE_API_KEY: ${{ secrets.BCIT_ITERABLE_API_KEY }} ITERABLE_SERVER_API_KEY: ${{ secrets.BCIT_ITERABLE_SERVER_API_KEY }} ITERABLE_TEST_USER_EMAIL: ${{ secrets.BCIT_ITERABLE_TEST_USER_EMAIL }} - - # - name: Generate Test Report - # if: always() - # run: | - # echo "Generating E2E test report..." - # ./gradlew :integration-tests:jacocoIntegrationTestReport - - # - name: Collect Test Logs - # if: always() - # run: | - # echo "Collecting E2E test logs..." - # adb logcat -d > integration-tests/build/e2e-test-logs.txt - - # # Also collect specific test logs - # adb logcat -d | grep -E "(InAppMessageIntegrationTest|BaseIntegrationTest|IterableApi)" > integration-tests/build/inapp-specific-logs.txt - - # - name: Take Screenshots for Debugging - # if: always() - # run: | - # echo "Taking screenshots for debugging..." - # mkdir -p integration-tests/screenshots - # adb shell screencap -p /sdcard/screenshot.png - # adb pull /sdcard/screenshot.png integration-tests/screenshots/final-state-api-${{ matrix.api-level }}.png - - # - name: Upload Test Results - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: inapp-e2e-test-results-api-${{ matrix.api-level }} - # path: | - # integration-tests/build/reports/ - # integration-tests/build/outputs/ - # integration-tests/build/e2e-test-logs.txt - # integration-tests/build/inapp-specific-logs.txt - - # - name: Upload Coverage Report - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: inapp-e2e-coverage-api-${{ matrix.api-level }} - # path: integration-tests/build/reports/jacoco/ - - # - name: Upload Screenshots - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: inapp-e2e-screenshots-api-${{ matrix.api-level }} - # path: integration-tests/screenshots/ - - # - name: Cleanup - # if: always() - # run: | - # echo "Test cleanup completed" + + # SDK-170: do NOT upload integration-tests/build/outputs/ — that path contains the + # built APKs which embed BuildConfig.ITERABLE_API_KEY and BuildConfig.ITERABLE_SERVER_API_KEY + # as compile-time string constants. On a public repo, anyone who can download the + # artifact could `strings`/`apktool` the APK and recover both keys. + - name: Upload E2E diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-diagnostics-api-${{ matrix.api-level }} + path: | + integration-tests/build/diagnostics/ + integration-tests/build/reports/ + if-no-files-found: warn + retention-days: 7 # test-summary: # name: Test Summary diff --git a/integration-tests/src/androidTest/java/com/iterable/integration/tests/InAppMessageIntegrationTest.kt b/integration-tests/src/androidTest/java/com/iterable/integration/tests/InAppMessageIntegrationTest.kt index ace6b02af..c5026915d 100644 --- a/integration-tests/src/androidTest/java/com/iterable/integration/tests/InAppMessageIntegrationTest.kt +++ b/integration-tests/src/androidTest/java/com/iterable/integration/tests/InAppMessageIntegrationTest.kt @@ -11,9 +11,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage -import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until import com.iterable.iterableapi.IterableApi import com.iterable.iterableapi.IterableInAppMessage import com.iterable.iterableapi.IterableInAppLocation @@ -38,6 +38,10 @@ class InAppMessageIntegrationTest : BaseIntegrationTest() { private const val TAG = "InAppMessageIntegrationTest" private const val TEST_CAMPAIGN_ID = TestConstants.TEST_INAPP_CAMPAIGN_ID private const val TEST_EVENT_NAME = "test_inapp_event" + + // SDK-170: ample wait so a slow CI emulator (SurfaceSyncGroup timeouts in logs) can + // finish drawing MainActivity before we look for the button. Local cost: 0 (passes fast). + private const val BTN_IN_APP_TIMEOUT_MS = 30_000L } private lateinit var uiDevice: UiDevice @@ -55,6 +59,20 @@ class InAppMessageIntegrationTest : BaseIntegrationTest() { super.setUp() Log.d(TAG, "🔧 Base setup complete, SDK initialized with test handlers") + + // SDK-170: Pause in-app auto-display and drain the inbox BEFORE launching the + // activity. Otherwise messages already staged for the test user will be fetched + // by setEmail() during super.setUp() and shown over MainActivity, occluding the + // buttons UiAutomator is about to look for. The test calls + // inAppManager.showMessage() explicitly later, which still works while paused. + // Pattern matches PushNotificationIntegrationTest / EmbeddedMessageIntegrationTest / + // DeepLinkIntegrationTest. + IterableApi.getInstance().inAppManager.setAutoDisplayPaused(true) + IterableApi.getInstance().inAppManager.messages.forEach { + Log.d(TAG, "Clearing pre-existing in-app message before navigation: ${it.messageId}") + IterableApi.getInstance().inAppManager.removeMessage(it) + } + Log.d(TAG, "🔧 MainActivity will skip initialization due to test mode flag") // Now launch the app flow with custom handlers already configured @@ -86,16 +104,22 @@ class InAppMessageIntegrationTest : BaseIntegrationTest() { Log.d(TAG, "🔧 MainActivity is ready!") - // Step 2: Click the "In-App Messages" button to navigate to InAppMessageTestActivity - Log.d(TAG, "🔧 Step 2: Clicking 'In-App Messages' button...") - val inAppButton = uiDevice.findObject(UiSelector().resourceId("com.iterable.integration.tests:id/btnInAppMessages")) - if (inAppButton.exists()) { + // Step 2: Click the "In-App Messages" button to navigate to InAppMessageTestActivity. + // SDK-170: Lifecycle.RESUMED does not guarantee the layout is drawn — under CI load + // (SurfaceSyncGroup transaction-ready timeouts), `findObject(...).exists()` ran ~2s + // after RESUMED and returned false because the button view was not yet present in the + // accessibility tree. `uiDevice.wait(Until.findObject(...))` polls until the node + // appears or the timeout elapses. + Log.d(TAG, "🔧 Step 2: Waiting for and clicking 'In-App Messages' button...") + val inAppButton = uiDevice.wait( + Until.findObject(By.res("com.iterable.integration.tests", "btnInAppMessages")), + BTN_IN_APP_TIMEOUT_MS + ) + if (inAppButton != null) { inAppButton.click() Log.d(TAG, "🔧 Clicked In-App Messages button successfully") } else { - //Take screenshot for debugging -// uiDevice.takeScreenshot(File("/sdcard/Download/InAppButtonNotFound.png")) - Log.e(TAG, "❌ In-App Messages button not found!") + Log.e(TAG, "❌ In-App Messages button not found within ${BTN_IN_APP_TIMEOUT_MS}ms (current package: ${uiDevice.currentPackageName})") Assert.fail("In-App Messages button not found in MainActivity") } @@ -116,10 +140,10 @@ class InAppMessageIntegrationTest : BaseIntegrationTest() { Assert.assertTrue("User should be signed in", userSignedIn) Log.d(TAG, "✅ User signed in successfully: ${TestConstants.TEST_USER_EMAIL}") - // Step 2: Debug API key configuration - Log.d(TAG, "🔍 Debug: ITERABLE_API_KEY = ${BuildConfig.ITERABLE_API_KEY}") - Log.d(TAG, "🔍 Debug: ITERABLE_SERVER_API_KEY = ${BuildConfig.ITERABLE_SERVER_API_KEY}") - Log.d(TAG, "🔍 Debug: ITERABLE_TEST_USER_EMAIL = ${BuildConfig.ITERABLE_TEST_USER_EMAIL}") + // SDK-170: log presence/length only (never values) — these end up in CI logcat artifacts. + Log.d(TAG, "API key configured: length=${BuildConfig.ITERABLE_API_KEY.length}") + Log.d(TAG, "Server API key configured: length=${BuildConfig.ITERABLE_SERVER_API_KEY.length}") + Log.d(TAG, "Test user email configured: length=${BuildConfig.ITERABLE_TEST_USER_EMAIL.length}") // Step 3: Try to trigger campaign via API (but don't fail if it doesn't work) Log.d(TAG, "🎯 Step 3: Attempting to trigger campaign via API...") diff --git a/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt index 674985665..9b3d2faec 100644 --- a/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt +++ b/integration-tests/src/main/java/com/iterable/integration/tests/MainActivity.kt @@ -89,8 +89,16 @@ class MainActivity : AppCompatActivity() { } private fun setupUI() { - // Set API key text - findViewById(R.id.tvApiKey).text = "API Key: ${BuildConfig.ITERABLE_API_KEY}" + // SDK-170: never render the full API key into the view hierarchy — the integration + // tests CI captures hierarchy.xml and screenshot.png as artifacts on a public repo. + // Show only enough to confirm a non-empty key was loaded. + val apiKey = BuildConfig.ITERABLE_API_KEY + val keyDisplay = when { + apiKey.isEmpty() -> "API Key: (empty)" + apiKey.length < 8 -> "API Key: (length=${apiKey.length})" + else -> "API Key: ****${apiKey.takeLast(4)} (length=${apiKey.length})" + } + findViewById(R.id.tvApiKey).text = keyDisplay findViewById(R.id.btnPushNotifications).setOnClickListener { startActivity(Intent(this@MainActivity, PushNotificationTestActivity::class.java))