Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
108 changes: 20 additions & 88 deletions .github/workflows/inapp-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,98 +87,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
30 changes: 30 additions & 0 deletions integration-tests/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ dependencies {
androidTestImplementation 'com.google.code.gson:gson:2.8.9'
}

// SDK-170 — debug task that surfaces the build-time BuildConfig inputs the test APK was
// built with. We only print *length* and *first 4 chars* of secrets so they never appear
// in CI logs verbatim. Used by scripts/trace-bcit.sh.
tasks.register('printBuildConfig') {
group = 'verification'
description = 'Print the integration-test BuildConfig inputs as seen by the build (length-only for secrets).'
doLast {
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withInputStream { localProperties.load(it) }
}
def showPrefix = System.getenv('TRACE_SHOW_PREFIX') == '1'
def report = { String name ->
def fromLocal = localProperties.getProperty(name)
def fromEnv = System.getenv(name)
def chosen = fromLocal ?: fromEnv ?: ''
def source = fromLocal ? 'local.properties' : (fromEnv ? 'env' : 'default-fallback')
def line = "${name} length=${chosen.length()} source=${source}"
if (showPrefix && chosen.length() >= 4) {
line += " prefix=${chosen.substring(0, 4)}\u2026"
}
println line
}
report('ITERABLE_API_KEY')
report('ITERABLE_SERVER_API_KEY')
report('ITERABLE_TEST_USER_EMAIL')
}
}

// Jacoco coverage for integration tests
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,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
Expand Down Expand Up @@ -116,10 +130,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...")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,16 @@ class MainActivity : AppCompatActivity() {
}

private fun setupUI() {
// Set API key text
findViewById<android.widget.TextView>(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<android.widget.TextView>(R.id.tvApiKey).text = keyDisplay

findViewById<android.widget.Button>(R.id.btnPushNotifications).setOnClickListener {
startActivity(Intent(this@MainActivity, PushNotificationTestActivity::class.java))
Expand Down
Loading
Loading