-
-
Notifications
You must be signed in to change notification settings - Fork 468
feat(replay): Add ReplaySnapshotObserver for snapshot testing #5386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d49b64a
4b5fb3a
9d6f12a
240dd96
1f6b03c
d2b2259
2b576be
f2c0c49
f39d8f5
1720230
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,4 +32,5 @@ artifacts: | |
| when: always | ||
| match: | ||
| - junit.xml | ||
| - "*.png" | ||
| directory: ./artifacts/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package io.sentry.uitest.android | ||
|
|
||
| import android.graphics.Bitmap | ||
| import android.os.Environment | ||
| import androidx.lifecycle.Lifecycle | ||
| import androidx.test.core.app.launchActivity | ||
| import io.sentry.Sentry | ||
| import io.sentry.android.replay.ReplayIntegration | ||
| import io.sentry.android.replay.ReplaySnapshotObserver | ||
| import java.io.File | ||
| import java.util.concurrent.CopyOnWriteArraySet | ||
| 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)) | ||
| } | ||
|
|
||
| @Test | ||
| fun captureComposeReplayFrameSnapshots() { | ||
| val snapshotsDir = | ||
| File( | ||
| Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), | ||
| "sauce_labs_custom_screenshots", | ||
| ) | ||
| .apply { | ||
| deleteRecursively() | ||
| mkdirs() | ||
| } | ||
| val frameReceived = CountDownLatch(1) | ||
| val capturedScreens = CopyOnWriteArraySet<String>() | ||
|
|
||
| val activityScenario = launchActivity<ComposeActivity>() | ||
| activityScenario.moveToState(Lifecycle.State.RESUMED) | ||
|
|
||
| initSentry { it.sessionReplay.sessionSampleRate = 1.0 } | ||
|
|
||
| val integration = Sentry.getCurrentScopes().options.replayController as? ReplayIntegration | ||
| integration?.snapshotObserver = ReplaySnapshotObserver { bitmap, frameTimestamp, screenName -> | ||
| val name = screenName ?: "unknown" | ||
| if (capturedScreens.add(name)) { | ||
| val file = File(snapshotsDir, "${name}_$frameTimestamp.png") | ||
| file.outputStream().use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) } | ||
| } | ||
| frameReceived.countDown() | ||
| } | ||
|
|
||
| assertTrue(frameReceived.await(10, TimeUnit.SECONDS), "Expected at least one replay frame") | ||
| assertTrue(capturedScreens.isNotEmpty(), "Expected at least one screen captured") | ||
|
|
||
| val files = snapshotsDir.listFiles()?.filter { it.extension == "png" } ?: emptyList() | ||
| assertTrue(files.isNotEmpty(), "Expected snapshot PNG files on disk") | ||
| assertTrue(files.all { it.length() > 0 }, "Snapshot files should not be empty") | ||
|
|
||
| activityScenario.moveToState(Lifecycle.State.DESTROYED) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,6 +61,8 @@ android { | |
|
|
||
| buildFeatures { buildConfig = true } | ||
|
|
||
| configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I need to dig deeper in to why we need to add this to all the modules that add |
||
|
|
||
| androidComponents.beforeVariants { | ||
| it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) | ||
| } | ||
|
|
@@ -71,6 +73,7 @@ kotlin { explicitApi() } | |
| dependencies { | ||
| api(projects.sentry) | ||
|
|
||
| compileOnly(libs.jetbrains.annotations) | ||
| compileOnly(libs.androidx.compose.ui.replay) | ||
| implementation(kotlin(Config.kotlinStdLib, Config.kotlinStdLibVersionAndroid)) | ||
| // tests | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| package io.sentry.android.replay | ||
|
|
||
| import android.graphics.Bitmap | ||
| import io.sentry.SentryReplayOptions | ||
| import org.jetbrains.annotations.ApiStatus | ||
|
|
||
| // since we don't have getters for maskAllText and maskAllimages, they won't be accessible as | ||
| // properties in Kotlin, therefore we create these extensions where a getter is dummy, but a setter | ||
|
|
@@ -29,3 +31,18 @@ public var SentryReplayOptions.maskAllImages: Boolean | |
| @Deprecated("Getter is unsupported.", level = DeprecationLevel.ERROR) | ||
| get() = error("Getter not supported") | ||
| set(value) = setMaskAllImages(value) | ||
|
|
||
| /** | ||
| * Observer that is notified when a replay snapshot is captured. The snapshot bitmap has masking | ||
| * already applied. | ||
| * | ||
| * **Bitmap lifecycle:** The bitmap is owned by the replay system and may be reused. Do not store a | ||
| * reference to it or access it after this method returns — copy the pixel data (e.g., compress to a | ||
| * file) within this method if you need it later. Do not recycle the bitmap. | ||
| * | ||
| * The callback runs on a background thread (the replay executor). | ||
| */ | ||
| @ApiStatus.Experimental | ||
|
runningcode marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Annotation is Experimental instead of agreed-upon InternalMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit f39d8f5. Configure here. |
||
| public fun interface ReplaySnapshotObserver { | ||
| public fun onSnapshotCaptured(bitmap: Bitmap, frameTimestamp: Long, screenName: String?) | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I copied this from
ReplayTestbut why are we even running these on emulators in gh actions?