-
-
Notifications
You must be signed in to change notification settings - Fork 468
feat(replay): Add beforeStoreFrame callback 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
9b05c20
2aff4b0
90ff469
983a3f3
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,74 @@ | ||
| 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.SentryReplayOptions | ||
| import io.sentry.TypeCheckHint | ||
| import java.io.File | ||
| import java.util.concurrent.CopyOnWriteArrayList | ||
| 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 = CopyOnWriteArrayList<String>() | ||
|
|
||
| val activityScenario = launchActivity<ComposeActivity>() | ||
| activityScenario.moveToState(Lifecycle.State.RESUMED) | ||
|
|
||
| initSentry { | ||
| it.sessionReplay.sessionSampleRate = 1.0 | ||
| it.sessionReplay.setBeforeStoreFrame( | ||
| SentryReplayOptions.BeforeStoreFrameCallback { hint, frameTimestamp, screenName -> | ||
| val frameBitmap = | ||
| hint.getAs(TypeCheckHint.REPLAY_FRAME_BITMAP, Bitmap::class.java) | ||
| ?: return@BeforeStoreFrameCallback | ||
| val name = screenName ?: "unknown" | ||
| if (!capturedScreens.contains(name)) { | ||
| val file = File(snapshotsDir, "${name}_$frameTimestamp.png") | ||
| file.outputStream().use { out -> | ||
| frameBitmap.compress(Bitmap.CompressFormat.PNG, 100, out) | ||
| } | ||
| capturedScreens.add(name) | ||
| } | ||
| 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 |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import android.view.MotionEvent | |
| import io.sentry.Breadcrumb | ||
| import io.sentry.DataCategory.All | ||
| import io.sentry.DataCategory.Replay | ||
| import io.sentry.Hint | ||
| import io.sentry.IConnectionStatusProvider.ConnectionStatus | ||
| import io.sentry.IConnectionStatusProvider.ConnectionStatus.DISCONNECTED | ||
| import io.sentry.IConnectionStatusProvider.IConnectionStatusObserver | ||
|
|
@@ -17,8 +18,10 @@ import io.sentry.ReplayBreadcrumbConverter | |
| import io.sentry.ReplayController | ||
| import io.sentry.SentryIntegrationPackageStorage | ||
| import io.sentry.SentryLevel.DEBUG | ||
| import io.sentry.SentryLevel.ERROR | ||
| import io.sentry.SentryLevel.INFO | ||
| import io.sentry.SentryOptions | ||
| import io.sentry.TypeCheckHint | ||
| import io.sentry.android.replay.ReplayState.CLOSED | ||
| import io.sentry.android.replay.ReplayState.PAUSED | ||
| import io.sentry.android.replay.ReplayState.RESUMED | ||
|
|
@@ -308,6 +311,16 @@ public class ReplayIntegration( | |
| var screen: String? = null | ||
| scopes?.configureScope { screen = it.screen?.substringAfterLast('.') } | ||
| captureStrategy?.onScreenshotRecorded(bitmap) { frameTimeStamp -> | ||
| val callback = options.sessionReplay.beforeStoreFrame | ||
| if (callback != null) { | ||
| try { | ||
| val hint = Hint() | ||
| hint.set(TypeCheckHint.REPLAY_FRAME_BITMAP, bitmap) | ||
| callback.execute(hint, frameTimeStamp, screen) | ||
| } catch (e: Throwable) { | ||
| options.logger.log(ERROR, "Error in beforeStoreFrame callback", e) | ||
| } | ||
| } | ||
|
Comment on lines
+314
to
+323
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. Bug: A race condition exists where the shared Suggested FixProvide a copy of the Prompt for AI AgentDid we get this right? 👍 / 👎 to inform future reviews. |
||
| addFrame(bitmap, frameTimeStamp, screen) | ||
| } | ||
| checkCanRecord() | ||
|
|
||
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?