From a29ffadb8b2195c7c585ad561c71999d68afe49d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:31:34 +0000 Subject: [PATCH] test(replay): [EXPERIMENT] Check PixelCopy on GH Actions emulators Co-Authored-By: Roman Zavarnitsyn --- .../workflows/experiment-replay-pixelcopy.yml | 71 +++++++++++++++++++ scripts/experiment-replay-pixelcopy.sh | 18 +++++ .../uitest/android/ReplaySnapshotTest.kt | 23 ++++-- 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/experiment-replay-pixelcopy.yml create mode 100755 scripts/experiment-replay-pixelcopy.sh diff --git a/.github/workflows/experiment-replay-pixelcopy.yml b/.github/workflows/experiment-replay-pixelcopy.yml new file mode 100644 index 00000000000..889da05c392 --- /dev/null +++ b/.github/workflows/experiment-replay-pixelcopy.yml @@ -0,0 +1,71 @@ +# EXPERIMENT (do not merge): does PixelCopy return real frames on GH Actions emulators? +name: 'Experiment: Replay PixelCopy on GH emulators' +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pixelcopy: + name: API ${{ matrix.api-level }} ${{ matrix.target }} gpu=${{ matrix.gpu }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # Config used by PR #5639 + - api-level: 35 + target: google_apis + gpu: auto + # Same image, software GPU, to isolate the -gpu flag + - api-level: 35 + target: google_apis + gpu: swiftshader_indirect + # Config used by agp-matrix.yml + - api-level: 30 + target: aosp_atd + gpu: swiftshader_indirect + + steps: + - name: Git checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: 'Set up Java: 21' + 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 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run ReplaySnapshotTest + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # pin@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + channel: canary + arch: x86_64 + disable-animations: true + disable-spellchecker: true + disk-size: 4096M + emulator-options: -memory 4096 -no-window -gpu ${{ matrix.gpu }} -noaudio -no-boot-anim -camera-back none -no-snapshot-save + script: ./scripts/experiment-replay-pixelcopy.sh replay-experiment + + - name: Upload frames and logcat + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: pixelcopy-api${{ matrix.api-level }}-${{ matrix.target }}-${{ matrix.gpu }} + path: | + replay-experiment/ + **/build/outputs/androidTest-results/**/*.xml diff --git a/scripts/experiment-replay-pixelcopy.sh b/scripts/experiment-replay-pixelcopy.sh new file mode 100755 index 00000000000..a3c5f06442f --- /dev/null +++ b/scripts/experiment-replay-pixelcopy.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# EXPERIMENT (do not merge): run ReplaySnapshotTest on a GH Actions emulator and collect +# the captured replay frames and logcat, to check whether PixelCopy works there. +set -eu + +out_dir=$1 +mkdir -p "$out_dir" + +rc=0 +./gradlew :sentry-android-integration-tests:sentry-uitest-android:connectedReleaseAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=io.sentry.uitest.android.ReplaySnapshotTest \ + -Denvironment=github || rc=$? + +adb pull /sdcard/Download/sauce_labs_custom_screenshots "$out_dir/snapshots" || + printf 'No replay snapshots to pull\n' +adb logcat -d >"$out_dir/logcat.txt" || printf 'Could not dump logcat\n' + +exit "$rc" 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..1acd274b78f 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 @@ -2,6 +2,7 @@ package io.sentry.uitest.android import android.graphics.Bitmap import android.os.Environment +import android.util.Log import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity import io.sentry.SentryReplayOptions @@ -12,17 +13,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") @@ -41,6 +37,7 @@ class ReplaySnapshotTest : BaseUiTest() { } val frameReceived = CountDownLatch(1) val capturedScreens = CopyOnWriteArraySet() + val distinctColorsPerFrame = CopyOnWriteArraySet() val activityScenario = launchActivity() activityScenario.moveToState(Lifecycle.State.RESUMED) @@ -53,6 +50,18 @@ class ReplaySnapshotTest : BaseUiTest() { hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) ?: return@ReplayFrameObserver val name = screenName ?: "unknown" + // EXPERIMENT: a failed or blank PixelCopy yields a single-color bitmap + val colors = HashSet() + for (x in 0 until bitmap.width step 8) { + for (y in 0 until bitmap.height step 8) { + colors.add(bitmap.getPixel(x, y)) + } + } + distinctColorsPerFrame.add(colors.size) + Log.i( + "PixelCopyExperiment", + "frame screen=$name size=${bitmap.width}x${bitmap.height} distinctColors=${colors.size}", + ) if (capturedScreens.add(name)) { val file = File(snapshotsDir, "${name}_$frameTimestamp.png") file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } @@ -64,6 +73,10 @@ class ReplaySnapshotTest : BaseUiTest() { assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured") + assertTrue( + distinctColorsPerFrame.any { it > 1 }, + "Expected a non-blank frame, distinct colors per frame: $distinctColorsPerFrame", + ) val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList() assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk")