From 8e011a664488c10ded36855740f232b02c0f7a00 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:01:26 +0000 Subject: [PATCH 1/6] ci(android): Run UI tests incl. replay on API 35 in AGP matrix, drop Sauce Labs UI job Co-Authored-By: Roman Zavarnitsyn --- .github/workflows/agp-matrix.yml | 39 ++++++-- .github/workflows/integration-tests-ui.yml | 93 ------------------- .sauce/sentry-uitest-android-ui.yml | 36 ------- scripts/pull-replay-snapshots.sh | 14 +++ .../sentry-uitest-android/README.md | 10 +- .../sentry-uitest-android/build.gradle.kts | 2 - .../io/sentry/uitest/android/BaseUiTest.kt | 3 + .../io/sentry/uitest/android/ReplayTest.kt | 4 +- .../uitest/android/UserFeedbackUiTest.kt | 3 +- .../uitest/android/ReplaySnapshotTest.kt | 8 +- 10 files changed, 53 insertions(+), 159 deletions(-) delete mode 100644 .github/workflows/integration-tests-ui.yml delete mode 100644 .sauce/sentry-uitest-android-ui.yml create mode 100755 scripts/pull-replay-snapshots.sh diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 81127cb468f..0fef78ccadc 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -24,6 +24,8 @@ jobs: env: VERSION_AGP: ${{ matrix.agp }} APPLY_SENTRY_INTEGRATIONS: ${{ matrix.integrations }} + # Upload replay snapshots from one cell only. Forks don't have access to the upload secret. + UPLOAD_REPLAY_SNAPSHOTS: ${{ matrix.agp == '9.4.0' && matrix.integrations && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} steps: - name: Checkout Repo @@ -51,20 +53,21 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-30-x86_64-aosp_atd + key: avd-api-35-x86_64-google_apis - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: - api-level: 30 - target: aosp_atd - channel: canary # Necessary for ATDs + api-level: 35 + # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need + target: google_apis + channel: canary arch: x86_64 force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -memory 4096 -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M script: echo "Generated AVD snapshot for caching." @@ -76,16 +79,31 @@ jobs: - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: - api-level: 30 - target: aosp_atd - channel: canary # Necessary for ATDs + api-level: 35 + # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need + target: google_apis + channel: canary arch: x86_64 force-avd-creation: false disable-animations: true disable-spellchecker: true - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -memory 4096 -no-snapshot-save -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M - script: ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest -Denvironment=github --daemon + script: | + ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest --daemon + ./scripts/pull-replay-snapshots.sh replay-snapshots + + - name: Install Sentry CLI + if: ${{ !cancelled() && env.UPLOAD_REPLAY_SNAPSHOTS == 'true' }} + uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 + + - name: Upload Replay Snapshots to Sentry + if: ${{ !cancelled() && env.UPLOAD_REPLAY_SNAPSHOTS == 'true' && hashFiles('replay-snapshots/*.png') != '' }} + run: sentry-cli snapshots upload ./replay-snapshots --app-id sentry-android-replay + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: sentry-sdks + SENTRY_PROJECT: sentry-android - name: Upload test results if: always() @@ -96,6 +114,7 @@ jobs: **/build/reports/* **/build/outputs/*/connected/* **/build/outputs/mapping/release/* + replay-snapshots/ - name: Test Report uses: phoenix-actions/test-reporting@7317eea6e13c47348dd0bb318669485157c518d6 # pin@v16 diff --git a/.github/workflows/integration-tests-ui.yml b/.github/workflows/integration-tests-ui.yml deleted file mode 100644 index 5704347f497..00000000000 --- a/.github/workflows/integration-tests-ui.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: 'Integration Tests' -on: - push: - branches: - - main - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - name: Ui tests - runs-on: ubuntu-latest - - # we copy the secret to the env variable in order to access it in the workflow - env: - SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} - SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} - - steps: - - name: Git checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: 'Set up Java: 17' - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 - with: - distribution: 'temurin' - java-version: '21' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 - - # Clean, build and release a test apk, but only if we will run the benchmark - - name: Make assembleUiTests - if: env.SAUCE_USERNAME != null - run: make assembleUiTests - - - name: Install SauceLabs CLI - uses: saucelabs/saucectl-run-action@283660aa934c02723c497efa151d582a3acc5801 # pin@v4.5.0 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - skip-run: true - if: env.SAUCE_USERNAME != null - - - name: Run Tests - id: saucelabs - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - exec &> >(tee -a "test_logs.txt") - saucectl run -c .sauce/sentry-uitest-android-ui.yml - if: env.SAUCE_USERNAME != null - continue-on-error: true - - - name: Verify Test Results - run: | - processCrashed=$(cat test_logs.txt | grep "Instrumentation run failed due to 'Process crashed.'" | wc -l) - if [[ ${{ steps.saucelabs.outcome }} == 'success' ]]; then - exit 0 - elif [[ "$processCrashed" -ne 0 ]]; then - exit 0 - else - exit 1 - fi - if: env.SAUCE_USERNAME != null - - - - name: Install Sentry CLI - if: ${{ !cancelled() && env.SAUCE_USERNAME != null }} - uses: getsentry/action-setup-cli@70d7e587b84c2e78cf4d37cd33d7b74fb3729c1b # v1 - - - name: Upload Replay Snapshots to Sentry - # Skip on PRs from forks, which don't have access to the upload secret - if: ${{ !cancelled() && env.SAUCE_USERNAME != null && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - run: | - # find instead of `shopt -s globstar`, which macOS's bash 3.2 doesn't support - mkdir -p replay-snapshots - if [ -d artifacts ]; then - find artifacts -type f -name '*.png' -exec cp {} replay-snapshots/ \; - fi - if [ -n "$(ls -A replay-snapshots)" ]; then - sentry-cli snapshots upload ./replay-snapshots \ - --app-id sentry-android-replay - else - echo "No replay snapshot files found, skipping upload" - fi - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: sentry-sdks - SENTRY_PROJECT: sentry-android diff --git a/.sauce/sentry-uitest-android-ui.yml b/.sauce/sentry-uitest-android-ui.yml deleted file mode 100644 index a00ee10614b..00000000000 --- a/.sauce/sentry-uitest-android-ui.yml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: v1alpha -kind: espresso -sauce: - region: us-west-1 - # Controls how many suites are executed at the same time (sauce test env only). - concurrency: 4 - metadata: - build: sentry-uitest-android-ui-$GITHUB_REF-$GITHUB_SHA - tags: - - e2e - - android - -defaults: - timeout: 45m - -espresso: - app: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/release/sentry-uitest-android-release.apk - testApp: ./sentry-android-integration-tests/sentry-uitest-android/build/outputs/apk/androidTest/release/sentry-uitest-android-release-androidTest.apk -suites: - - - name: "Android 15 Ui test (api 35)" - testOptions: - clearPackageData: true - useTestOrchestrator: true - devices: - - name: ".*" - platformVersion: "15" - -# Controls what artifacts to fetch when the suite on Sauce Cloud has finished. -artifacts: - download: - when: always - match: - - junit.xml - - "*.png" - directory: ./artifacts/ diff --git a/scripts/pull-replay-snapshots.sh b/scripts/pull-replay-snapshots.sh new file mode 100755 index 00000000000..13763912c52 --- /dev/null +++ b/scripts/pull-replay-snapshots.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Pulls the replay frame snapshots that ReplaySnapshotTest writes on the connected device. +# Keep the device directory in sync with ReplaySnapshotTest. +set -eu + +out_dir=$1 +device_dir=/sdcard/Download/sentry_replay_snapshots + +mkdir -p "$out_dir" +if adb shell test -d "$device_dir"; then + adb pull "$device_dir/." "$out_dir" +else + printf 'No replay snapshots on the device, skipping\n' +fi diff --git a/sentry-android-integration-tests/sentry-uitest-android/README.md b/sentry-android-integration-tests/sentry-uitest-android/README.md index d6ccf2f1f93..145df22485c 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/README.md +++ b/sentry-android-integration-tests/sentry-uitest-android/README.md @@ -7,13 +7,9 @@ By default the envelopes sent to relay are caught by a mock server which allows Simply run `./gradlew connectedCheck` to run all ui tests of all modules (requires a connected device, either physical or an emulator). -# SauceLabs -To run on saucelabs execute following commands (need also `SAUCE_USERNAME` and `SAUCE_ACCESS_KEY` environment variables): -For End 2 End: -``` -./gradlew :sentry-android-integration-tests:sentry-uitest-android:assembleRelease :sentry-android-integration-tests:sentry-uitest-android:assembleAndroidTest -saucectl run -c .sauce/sentry-uitest-android-end2end.yml -``` +# CI +CI runs these tests on a GitHub Actions emulator in the AGP matrix workflow (`.github/workflows/agp-matrix.yml`). +The emulator must not be an ATD image: PixelCopy returns no data there, and the replay tests need it. # Troubleshooting diff --git a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts index 7bbbe39e04f..0af3e1bbb0d 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts +++ b/sentry-android-integration-tests/sentry-uitest-android/build.gradle.kts @@ -30,7 +30,6 @@ android { // This doesn't work on some devices with Android 11+. Clearing package data resets permissions. // Check the readme for more info. testInstrumentationRunnerArguments["clearPackageData"] = "true" - buildConfigField("String", "ENVIRONMENT", "\"${System.getProperty("environment", "")}\"") } testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } @@ -40,7 +39,6 @@ android { // Note that the viewBinding.enabled property is now deprecated. viewBinding = true compose = true - buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() } diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt index 62be835eb2b..529d840b3d3 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/BaseUiTest.kt @@ -125,6 +125,9 @@ fun waitUntilIdle() { Espresso.onIdle() } +/** Whether sentry-android-replay is on the classpath (APPLY_SENTRY_INTEGRATIONS=true). */ +fun isReplayAvailable(): Boolean = classExists("io.sentry.android.replay.ReplayIntegration") + fun classExists(className: String): Boolean { try { Class.forName(className) diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt index 9c53f0a022d..86f30dc1a1a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/ReplayTest.kt @@ -18,9 +18,7 @@ import shark.ReferencePattern class ReplayTest : BaseUiTest() { @Before fun setup() { - // we can't run on GH actions emulator, because they don't allow capturing screenshots properly - @Suppress("KotlinConstantConditions") - assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) + assumeThat(isReplayAvailable(), `is`(true)) // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions // fail this on-device test instead of silently under-masking (see SentryReplayDebug) System.setProperty("io.sentry.replay.compose.fail-fast", "true") diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt index bfcaf2845b3..6bbbe95253a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/UserFeedbackUiTest.kt @@ -464,8 +464,7 @@ class UserFeedbackUiTest : BaseUiTest() { @Test fun userFeedbackSendEnvelope() { - // GH actions emulator don't allow capturing screenshots properly - val enableReplay = BuildConfig.ENVIRONMENT != "github" + val enableReplay = isReplayAvailable() initSentry(true) { // When sending the feedback we want to wait for relay to receive it. // We can't simply increment the idling resource, diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 6d45b2d1f9c..3a2805e8f25 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -12,17 +12,12 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertTrue -import org.hamcrest.CoreMatchers.`is` -import org.junit.Assume.assumeThat import org.junit.Before class ReplaySnapshotTest : BaseUiTest() { @Before fun setup() { - // GH Actions emulators don't support capturing screenshots for replay - @Suppress("KotlinConstantConditions") - assumeThat(BuildConfig.ENVIRONMENT != "github", `is`(true)) // crash on swallowed Compose masking errors (e.g. broken obfuscated internals) so regressions // fail this on-device test instead of silently under-masking (see SentryReplayDebug) System.setProperty("io.sentry.replay.compose.fail-fast", "true") @@ -33,7 +28,8 @@ class ReplaySnapshotTest : BaseUiTest() { val snapshotsDir = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "sauce_labs_custom_screenshots", + // pulled and uploaded by scripts/pull-replay-snapshots.sh in CI + "sentry_replay_snapshots", ) .apply { deleteRecursively() From 7237f508e00173776283bbc0a638a1389af1683f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:33:32 +0000 Subject: [PATCH 2/6] ci(android): Pull replay snapshots even when the AGP matrix UI tests fail android-emulator-runner runs each script line in its own shell and stops at the first failing line, so a failing connectedReleaseAndroidTest skipped the snapshot pull. Run the tests and the pull from one script that exits with the tests' status. #skip-changelog --- .github/workflows/agp-matrix.yml | 4 +--- scripts/pull-replay-snapshots.sh | 14 ------------- scripts/test-ui-android.sh | 21 +++++++++++++++++++ .../uitest/android/ReplaySnapshotTest.kt | 2 +- 4 files changed, 23 insertions(+), 18 deletions(-) delete mode 100755 scripts/pull-replay-snapshots.sh create mode 100755 scripts/test-ui-android.sh diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index 0fef78ccadc..a0c60560ea2 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -89,9 +89,7 @@ jobs: disable-spellchecker: true emulator-options: -memory 4096 -no-snapshot-save -no-window -gpu auto -noaudio -no-boot-anim -camera-back none disk-size: 4096M - script: | - ./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest --daemon - ./scripts/pull-replay-snapshots.sh replay-snapshots + script: ./scripts/test-ui-android.sh replay-snapshots - name: Install Sentry CLI if: ${{ !cancelled() && env.UPLOAD_REPLAY_SNAPSHOTS == 'true' }} diff --git a/scripts/pull-replay-snapshots.sh b/scripts/pull-replay-snapshots.sh deleted file mode 100755 index 13763912c52..00000000000 --- a/scripts/pull-replay-snapshots.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Pulls the replay frame snapshots that ReplaySnapshotTest writes on the connected device. -# Keep the device directory in sync with ReplaySnapshotTest. -set -eu - -out_dir=$1 -device_dir=/sdcard/Download/sentry_replay_snapshots - -mkdir -p "$out_dir" -if adb shell test -d "$device_dir"; then - adb pull "$device_dir/." "$out_dir" -else - printf 'No replay snapshots on the device, skipping\n' -fi diff --git a/scripts/test-ui-android.sh b/scripts/test-ui-android.sh new file mode 100755 index 00000000000..5867d4dd394 --- /dev/null +++ b/scripts/test-ui-android.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Runs the sentry-uitest-android instrumentation tests on the connected device, then pulls the +# replay frame snapshots that ReplaySnapshotTest writes, also when the tests fail. +# android-emulator-runner runs each `script:` line in its own shell, so this stays one script to +# keep the tests' exit code. Keep the device directory in sync with ReplaySnapshotTest. +set -eu + +out_dir=$1 +device_dir=/sdcard/Download/sentry_replay_snapshots + +test_status=0 +./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest --daemon || test_status=$? + +mkdir -p "$out_dir" +if adb shell test -d "$device_dir"; then + adb pull "$device_dir/." "$out_dir" +else + printf 'No replay snapshots on the device, skipping\n' +fi + +exit "$test_status" diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt index 3a2805e8f25..f1099d04348 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTestReplay/java/io/sentry/uitest/android/ReplaySnapshotTest.kt @@ -28,7 +28,7 @@ class ReplaySnapshotTest : BaseUiTest() { val snapshotsDir = File( Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - // pulled and uploaded by scripts/pull-replay-snapshots.sh in CI + // pulled and uploaded by scripts/test-ui-android.sh in CI "sentry_replay_snapshots", ) .apply { From 368573750a1c579247a8f36262d67ca9f5c48857 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 12:39:53 +0000 Subject: [PATCH 3/6] ci(android): Drop --daemon from the AGP matrix UI test run Co-Authored-By: Roman Zavarnitsyn #skip-changelog --- scripts/test-ui-android.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-ui-android.sh b/scripts/test-ui-android.sh index 5867d4dd394..4567f96afc4 100755 --- a/scripts/test-ui-android.sh +++ b/scripts/test-ui-android.sh @@ -9,7 +9,7 @@ out_dir=$1 device_dir=/sdcard/Download/sentry_replay_snapshots test_status=0 -./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest --daemon || test_status=$? +./gradlew sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest || test_status=$? mkdir -p "$out_dir" if adb shell test -d "$device_dir"; then From ada4b873a0602d1835b2487251b5df923e7d285c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:08:39 +0000 Subject: [PATCH 4/6] ci(android): Run the AGP matrix UI tests on API 30 google_apis API 30 google_apis captures replay frames with PixelCopy (verified in #6495) and avoids the Android 15 ART method-tracing crash that the profiling tests hit on API 35. Co-Authored-By: Roman Zavarnitsyn --- .github/workflows/agp-matrix.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/agp-matrix.yml b/.github/workflows/agp-matrix.yml index a0c60560ea2..9c08aa084d4 100644 --- a/.github/workflows/agp-matrix.yml +++ b/.github/workflows/agp-matrix.yml @@ -53,13 +53,13 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-35-x86_64-google_apis + key: avd-api-30-x86_64-google_apis - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: - api-level: 35 + api-level: 30 # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need target: google_apis channel: canary @@ -79,7 +79,7 @@ jobs: - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 with: - api-level: 35 + api-level: 30 # Not an ATD image: PixelCopy returns no data on ATDs, which the replay tests need target: google_apis channel: canary From 41e5572ac89c8b4d632fed01f4ec64e79ca5bc5b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:51:57 +0000 Subject: [PATCH 5/6] test(profiling): Run checkTimedOutProfile on emulators with a shorter timeout The emulator skip was added in #3074 because the timed-out profile's trace file was sometimes missing on emulators. Since #3478 timed-out profiles are discarded and the test asserts no profile is sent, so it no longer reads a trace file. Drop the skip, and add a test-only hook to shorten the 30s profiling timeout so the test waits 5s instead of 35s. Co-Authored-By: Roman Zavarnitsyn --- .../api/sentry-android-core.api | 1 + .../sentry/android/core/AndroidProfiler.java | 17 +++++++++++-- .../android/core/AndroidProfilerTest.kt | 15 +++++++++++ .../io/sentry/uitest/android/EnvelopeTests.kt | 25 ++++++++----------- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 62d9b0d4960..6a641cbf067 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -123,6 +123,7 @@ public class io/sentry/android/core/AndroidProfiler { public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Lio/sentry/ILogger;)V public fun close ()V public fun endAndCollect (ZLjava/util/List;)Lio/sentry/android/core/AndroidProfiler$ProfileEndData; + public static fun setProfilingTimeoutMillis (J)V public fun start ()Lio/sentry/android/core/AndroidProfiler$ProfileStartData; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java index 3f569df5378..6b72dbbe1f4 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -30,6 +30,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public class AndroidProfiler { @@ -78,7 +79,10 @@ public ProfileEndData( */ private static final int BUFFER_SIZE_BYTES = 3_000_000; - private static final int PROFILING_TIMEOUT_MILLIS = 30_000; + private static final long PROFILING_TIMEOUT_MILLIS = 30_000; + + private static volatile long profilingTimeoutMillis = PROFILING_TIMEOUT_MILLIS; + private long profileStartNanos = 0; private final @NotNull File traceFilesDir; private final int intervalUs; @@ -192,7 +196,7 @@ public void onFrameMetricCollected( scheduledFinish = timeoutExecutorServiceSupplier .evaluate() - .schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); + .schedule(() -> endAndCollect(true, null), profilingTimeoutMillis); } } catch (RejectedExecutionException e) { logger.log( @@ -284,6 +288,15 @@ public void onFrameMetricCollected( } } + /** + * Overrides the profiling timeout for profiles started after this call. Pass a value <= 0 to + * restore the default of 30 seconds. + */ + @TestOnly + public static void setProfilingTimeoutMillis(final long timeoutMillis) { + profilingTimeoutMillis = timeoutMillis > 0 ? timeoutMillis : PROFILING_TIMEOUT_MILLIS; + } + public void close() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { // we cancel any scheduled work diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index f402af24f80..54266427f3d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -20,6 +20,7 @@ import java.util.concurrent.FutureTask import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -54,6 +55,7 @@ class AndroidProfilerTest { private val mockDsn = "http://key@localhost/proj" val mockLogger = mock() var lastScheduledRunnable: Runnable? = null + var lastScheduledDelayMillis: Long? = null val mockExecutorService = object : ISentryExecutorService { override fun submit(runnable: Runnable): Future<*> { @@ -75,6 +77,7 @@ class AndroidProfilerTest { override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> { lastScheduledRunnable = runnable + lastScheduledDelayMillis = delayMillis return FutureTask {} } @@ -145,6 +148,7 @@ class AndroidProfilerTest { @AfterTest fun clear() { context.cacheDir.deleteRecursively() + AndroidProfiler.setProfilingTimeoutMillis(0) } @Test @@ -232,6 +236,17 @@ class AndroidProfilerTest { assertNull(endData) } + @Test + fun `profiling timeout can be overridden and reset`() { + AndroidProfiler.setProfilingTimeoutMillis(2_000) + fixture.getSut().start() + assertEquals(2_000L, fixture.lastScheduledDelayMillis) + + AndroidProfiler.setProfilingTimeoutMillis(0) + fixture.getSut().start() + assertEquals(30_000L, fixture.lastScheduledDelayMillis) + } + @Test fun `subsequent start call doesn't do anything`() { val profiler = fixture.getSut() diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index 3fa2c904873..bcdbe81c5ac 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -3,17 +3,15 @@ package io.sentry.uitest.android import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso -import androidx.test.espresso.IdlingPolicies import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.action.ViewActions import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.sentry.NoOpLogger import io.sentry.ProfilingTraceData import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.android.core.AndroidLogger -import io.sentry.android.core.BuildInfoProvider +import io.sentry.android.core.AndroidProfiler import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeProfile import io.sentry.assertEnvelopeTransaction @@ -21,16 +19,21 @@ import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.protocol.SentryTransaction import java.io.File import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -import org.junit.Assume import org.junit.Assume.assumeNotNull import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EnvelopeTests : BaseUiTest() { + @AfterTest + fun resetProfilingTimeout() { + AndroidProfiler.setProfilingTimeoutMillis(0) + } + @Test fun checkEnvelopeCaptureMessage() { initSentry(true) @@ -198,14 +201,8 @@ class EnvelopeTests : BaseUiTest() { @Test fun checkTimedOutProfile() { - Assume.assumeFalse( - "Sometimes traceFile does not exist for profiles on emulators and it fails." + - " Although, Android Runtime is the one that manages the file, so something" + - " must be wrong with Debug.startMethodTracing", - BuildInfoProvider(NoOpLogger.getInstance()).isEmulator ?: true, - ) - // We increase the IdlingResources timeout to exceed the profiling timeout - IdlingPolicies.setIdlingResourceTimeout(1, TimeUnit.MINUTES) + // Shorten the profiling timeout so the test doesn't wait the default 30 seconds + AndroidProfiler.setProfilingTimeoutMillis(2_000) initSentry(true) { options: SentryAndroidOptions -> options.tracesSampleRate = 1.0 options.profilesSampleRate = 1.0 @@ -213,11 +210,11 @@ class EnvelopeTests : BaseUiTest() { relayIdlingResource.increment() val transaction = Sentry.startTransaction("timedOutProfile", "testTimeout") Thread { - Thread.sleep(35_000) + Thread.sleep(5_000) transaction.finish() } .start() - // We call transaction.finish() after 35 seconds, and check profile times out after 30 seconds + // We call transaction.finish() after 5 seconds, and check profile times out after 2 seconds relay.assert { findEnvelope { From 422ea6449f6b9853d2c536af8faceb32ba436829 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:00:08 +0000 Subject: [PATCH 6/6] test(uitest): Remove checkTimedOutProfile The test only checks that a timed-out transaction profile is discarded, which AndroidProfilerTest and AndroidTransactionProfilerTest already cover. It was skipped on emulators, so nothing in CI ran it after the Sauce Labs job was removed. Also reverts the test-only profiling timeout hook. Co-Authored-By: Roman Zavarnitsyn --- .../api/sentry-android-core.api | 1 - .../sentry/android/core/AndroidProfiler.java | 17 +------- .../android/core/AndroidProfilerTest.kt | 15 ------- .../io/sentry/uitest/android/EnvelopeTests.kt | 39 ------------------- 4 files changed, 2 insertions(+), 70 deletions(-) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 6a641cbf067..62d9b0d4960 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -123,7 +123,6 @@ public class io/sentry/android/core/AndroidProfiler { public fun (Ljava/lang/String;ILio/sentry/android/core/internal/util/SentryFrameMetricsCollector;Lio/sentry/util/LazyEvaluator$Evaluator;Lio/sentry/ILogger;)V public fun close ()V public fun endAndCollect (ZLjava/util/List;)Lio/sentry/android/core/AndroidProfiler$ProfileEndData; - public static fun setProfilingTimeoutMillis (J)V public fun start ()Lio/sentry/android/core/AndroidProfiler$ProfileStartData; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java index 6b72dbbe1f4..3f569df5378 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidProfiler.java @@ -30,7 +30,6 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.TestOnly; @ApiStatus.Internal public class AndroidProfiler { @@ -79,10 +78,7 @@ public ProfileEndData( */ private static final int BUFFER_SIZE_BYTES = 3_000_000; - private static final long PROFILING_TIMEOUT_MILLIS = 30_000; - - private static volatile long profilingTimeoutMillis = PROFILING_TIMEOUT_MILLIS; - + private static final int PROFILING_TIMEOUT_MILLIS = 30_000; private long profileStartNanos = 0; private final @NotNull File traceFilesDir; private final int intervalUs; @@ -196,7 +192,7 @@ public void onFrameMetricCollected( scheduledFinish = timeoutExecutorServiceSupplier .evaluate() - .schedule(() -> endAndCollect(true, null), profilingTimeoutMillis); + .schedule(() -> endAndCollect(true, null), PROFILING_TIMEOUT_MILLIS); } } catch (RejectedExecutionException e) { logger.log( @@ -288,15 +284,6 @@ public void onFrameMetricCollected( } } - /** - * Overrides the profiling timeout for profiles started after this call. Pass a value <= 0 to - * restore the default of 30 seconds. - */ - @TestOnly - public static void setProfilingTimeoutMillis(final long timeoutMillis) { - profilingTimeoutMillis = timeoutMillis > 0 ? timeoutMillis : PROFILING_TIMEOUT_MILLIS; - } - public void close() { try (final @NotNull ISentryLifecycleToken ignored = lock.acquire()) { // we cancel any scheduled work diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index 54266427f3d..f402af24f80 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -20,7 +20,6 @@ import java.util.concurrent.FutureTask import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.assertContentEquals -import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -55,7 +54,6 @@ class AndroidProfilerTest { private val mockDsn = "http://key@localhost/proj" val mockLogger = mock() var lastScheduledRunnable: Runnable? = null - var lastScheduledDelayMillis: Long? = null val mockExecutorService = object : ISentryExecutorService { override fun submit(runnable: Runnable): Future<*> { @@ -77,7 +75,6 @@ class AndroidProfilerTest { override fun schedule(runnable: Runnable, delayMillis: Long): Future<*> { lastScheduledRunnable = runnable - lastScheduledDelayMillis = delayMillis return FutureTask {} } @@ -148,7 +145,6 @@ class AndroidProfilerTest { @AfterTest fun clear() { context.cacheDir.deleteRecursively() - AndroidProfiler.setProfilingTimeoutMillis(0) } @Test @@ -236,17 +232,6 @@ class AndroidProfilerTest { assertNull(endData) } - @Test - fun `profiling timeout can be overridden and reset`() { - AndroidProfiler.setProfilingTimeoutMillis(2_000) - fixture.getSut().start() - assertEquals(2_000L, fixture.lastScheduledDelayMillis) - - AndroidProfiler.setProfilingTimeoutMillis(0) - fixture.getSut().start() - assertEquals(30_000L, fixture.lastScheduledDelayMillis) - } - @Test fun `subsequent start call doesn't do anything`() { val profiler = fixture.getSut() diff --git a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt index bcdbe81c5ac..1d77c95663a 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt +++ b/sentry-android-integration-tests/sentry-uitest-android/src/androidTest/java/io/sentry/uitest/android/EnvelopeTests.kt @@ -11,7 +11,6 @@ import io.sentry.ProfilingTraceData import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.android.core.AndroidLogger -import io.sentry.android.core.AndroidProfiler import io.sentry.android.core.SentryAndroidOptions import io.sentry.assertEnvelopeProfile import io.sentry.assertEnvelopeTransaction @@ -19,7 +18,6 @@ import io.sentry.profilemeasurements.ProfileMeasurement import io.sentry.protocol.SentryTransaction import java.io.File import java.util.concurrent.TimeUnit -import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -29,11 +27,6 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class EnvelopeTests : BaseUiTest() { - @AfterTest - fun resetProfilingTimeout() { - AndroidProfiler.setProfilingTimeoutMillis(0) - } - @Test fun checkEnvelopeCaptureMessage() { initSentry(true) @@ -199,38 +192,6 @@ class EnvelopeTests : BaseUiTest() { } } - @Test - fun checkTimedOutProfile() { - // Shorten the profiling timeout so the test doesn't wait the default 30 seconds - AndroidProfiler.setProfilingTimeoutMillis(2_000) - initSentry(true) { options: SentryAndroidOptions -> - options.tracesSampleRate = 1.0 - options.profilesSampleRate = 1.0 - } - relayIdlingResource.increment() - val transaction = Sentry.startTransaction("timedOutProfile", "testTimeout") - Thread { - Thread.sleep(5_000) - transaction.finish() - } - .start() - // We call transaction.finish() after 5 seconds, and check profile times out after 2 seconds - - relay.assert { - findEnvelope { - assertEnvelopeTransaction(it.items.toList(), AndroidLogger()).transaction == - "timedOutProfile" - } - .assert { - val transactionItem: SentryTransaction = it.assertTransaction() - // Profile should not be present, as it timed out and is discarded - it.assertNoOtherItems() - assertEquals("timedOutProfile", transactionItem.transaction) - } - assertNoOtherEnvelopes() - } - } - @Test fun sendProfiledTransaction() { // This is a dogfooding test