Skip to content
Open
99 changes: 99 additions & 0 deletions .github/scripts/run-e2e.sh
Original file line number Diff line number Diff line change
@@ -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"
21 changes: 12 additions & 9 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
133 changes: 38 additions & 95 deletions .github/workflows/inapp-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,15 @@ jobs:

- name: Collect Test Logs
if: always()
# SDK-170: `adb logcat -d` blocks indefinitely when no device is connected
# (e.g. when the emulator failed to boot). That hang has been making this
# workflow consume the full 6h GitHub Actions job timeout on every run
# since the macos-latest flip to Apple Silicon. Cap it at 2 minutes; on a
# healthy emulator the command finishes in <1s.
timeout-minutes: 2
run: |
echo "Collecting test logs..."
adb logcat -d > integration-tests/build/test-logs.txt
adb logcat -d > integration-tests/build/test-logs.txt || true

- name: Upload Test Results
if: always()
Expand Down Expand Up @@ -295,10 +301,12 @@ jobs:

- name: Stop emulator
if: always()
# SDK-170: `adb emu kill` can hang waiting for a device that never appeared.
timeout-minutes: 1
run: |
echo "Stopping emulator..."
adb emu kill
adb emu kill || true

integration-tests-nightly:
name: Nightly Integration Tests
runs-on: macos-latest
Expand Down Expand Up @@ -444,6 +452,8 @@ jobs:

- name: Stop emulator
if: always()
# SDK-170: see main `Stop emulator` step above.
timeout-minutes: 1
run: |
echo "Stopping emulator..."
adb emu kill
adb emu kill || true
Loading
Loading