Created
June 4, 2026 19:52
-
-
Save LethalMaus/a4e8330ba3f07419faefe922eb0640f7 to your computer and use it in GitHub Desktop.
Paparazzi preview snapshot test setup
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package dev.jamescullimore.app.testing | |
| import android.content.res.Configuration.UI_MODE_NIGHT_MASK | |
| import android.content.res.Configuration.UI_MODE_NIGHT_YES | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.unit.dp | |
| import app.cash.paparazzi.DeviceConfig | |
| import app.cash.paparazzi.HtmlReportWriter | |
| import app.cash.paparazzi.Paparazzi | |
| import app.cash.paparazzi.Snapshot | |
| import app.cash.paparazzi.SnapshotHandler | |
| import app.cash.paparazzi.SnapshotVerifier | |
| import app.cash.paparazzi.TestName | |
| import com.android.ide.common.rendering.api.SessionParams | |
| import com.android.resources.Density | |
| import com.android.resources.NightMode | |
| import com.android.resources.ScreenOrientation | |
| import com.android.resources.ScreenRatio | |
| import com.android.resources.ScreenRound | |
| import com.android.resources.ScreenSize | |
| import com.google.testing.junit.testparameterinjector.TestParameter | |
| import com.google.testing.junit.testparameterinjector.TestParameterInjector | |
| import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider | |
| import org.junit.Rule | |
| import org.junit.Test | |
| import org.junit.runner.RunWith | |
| import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner | |
| import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo | |
| import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser | |
| import sergio.sastre.composable.preview.scanner.android.device.domain.Device | |
| import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview | |
| import kotlin.math.ceil | |
| @RunWith(TestParameterInjector::class) | |
| class PreviewSnapshotTests( | |
| @TestParameter(valuesProvider = PreviewProvider::class) | |
| private val preview: ComposablePreview<AndroidPreviewInfo>, | |
| ) { | |
| @get:Rule | |
| val paparazzi: Paparazzi = PreviewSnapshotRule.createFor(preview) | |
| @Test | |
| fun snapshot() { | |
| val info = preview.previewInfo | |
| paparazzi.snapshot(name = info.name) { | |
| PreviewBackground( | |
| showBackground = info.showSystemUi || info.showBackground, | |
| backgroundColor = info.backgroundColor, | |
| ) { | |
| if (info.showSystemUi) { | |
| DevicePreviewInfoParser.parse(info.device)?.inDp()?.let { device -> | |
| PreviewSizedToSystemUi( | |
| widthInDp = device.dimensions.width.toInt(), | |
| heightInDp = device.dimensions.height.toInt(), | |
| ) { | |
| preview() | |
| } | |
| } ?: preview() | |
| } else { | |
| preview() | |
| } | |
| } | |
| } | |
| } | |
| } | |
| object PreviewProvider : TestParameterValuesProvider() { | |
| override fun provideValues(context: Context?): List<ComposablePreview<AndroidPreviewInfo>> = | |
| AndroidComposablePreviewScanner() | |
| .scanPackageTrees("dev.jamescullimore.app.ui") | |
| .getPreviews() | |
| } | |
| object PreviewSnapshotRule { | |
| fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi { | |
| val info = preview.previewInfo | |
| return Paparazzi( | |
| deviceConfig = DeviceConfigBuilder.build(info), | |
| supportsRtl = true, | |
| showSystemUi = info.showSystemUi, | |
| renderingMode = | |
| when { | |
| info.showSystemUi -> SessionParams.RenderingMode.NORMAL | |
| info.widthDp > 0 && info.heightDp > 0 -> SessionParams.RenderingMode.FULL_EXPAND | |
| info.heightDp > 0 -> SessionParams.RenderingMode.V_SCROLL | |
| else -> SessionParams.RenderingMode.SHRINK | |
| }, | |
| snapshotHandler = | |
| when (System.getProperty("paparazzi.test.verify")?.toBoolean() == true) { | |
| true -> PreviewSnapshotVerifier(maxPercentDifference = 0.0) | |
| false -> PreviewHtmlReportWriter() | |
| }, | |
| ) | |
| } | |
| } | |
| object DeviceConfigBuilder { | |
| fun build(preview: AndroidPreviewInfo): DeviceConfig { | |
| val parsedDevice = DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig() | |
| val dimensions = previewDimensions(parsedDevice, preview.widthDp, preview.heightDp) | |
| return DeviceConfig( | |
| screenHeight = dimensions.heightPx, | |
| screenWidth = dimensions.widthPx, | |
| density = Density(parsedDevice.densityDpi), | |
| xdpi = parsedDevice.densityDpi, | |
| ydpi = parsedDevice.densityDpi, | |
| fontScale = preview.fontScale, | |
| size = ScreenSize.valueOf(parsedDevice.screenSize.name), | |
| ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name), | |
| screenRound = ScreenRound.valueOf(parsedDevice.shape.name), | |
| orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name), | |
| locale = preview.locale.ifBlank { "en" }, | |
| nightMode = | |
| when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) { | |
| true -> NightMode.NIGHT | |
| false -> NightMode.NOTNIGHT | |
| }, | |
| ) | |
| } | |
| private fun previewDimensions( | |
| parsedDevice: Device, | |
| widthDp: Int, | |
| heightDp: Int, | |
| ): PreviewDimensions { | |
| val conversionFactor = parsedDevice.densityDpi / 160f | |
| val widthPx = ceil(widthDp * conversionFactor).toInt() | |
| val heightPx = ceil(heightDp * conversionFactor).toInt() | |
| return PreviewDimensions( | |
| widthPx = if (widthDp > 0) widthPx else parsedDevice.dimensions.width.toInt(), | |
| heightPx = if (heightDp > 0) heightPx else parsedDevice.dimensions.height.toInt(), | |
| ) | |
| } | |
| } | |
| data class PreviewDimensions( | |
| val widthPx: Int, | |
| val heightPx: Int, | |
| ) | |
| @Composable | |
| private fun PreviewSizedToSystemUi( | |
| widthInDp: Int, | |
| heightInDp: Int, | |
| content: @Composable () -> Unit, | |
| ) { | |
| Box( | |
| Modifier | |
| .size(width = widthInDp.dp, height = heightInDp.dp) | |
| .background(Color.White), | |
| ) { | |
| content() | |
| } | |
| } | |
| @Composable | |
| private fun PreviewBackground( | |
| showBackground: Boolean, | |
| backgroundColor: Long, | |
| content: @Composable () -> Unit, | |
| ) { | |
| if (!showBackground) { | |
| content() | |
| return | |
| } | |
| val color = if (backgroundColor != 0L) Color(backgroundColor) else Color.White | |
| Box(Modifier.background(color)) { | |
| content() | |
| } | |
| } | |
| private class PreviewSnapshotVerifier( | |
| maxPercentDifference: Double, | |
| ) : SnapshotHandler { | |
| private val delegate = SnapshotVerifier(maxPercentDifference = maxPercentDifference) | |
| override fun newFrameHandler( | |
| snapshot: Snapshot, | |
| frameCount: Int, | |
| fps: Int, | |
| ): SnapshotHandler.FrameHandler { | |
| return delegate.newFrameHandler( | |
| snapshot = snapshot.normalized(), | |
| frameCount = frameCount, | |
| fps = fps, | |
| ) | |
| } | |
| override fun close() { | |
| delegate.close() | |
| } | |
| } | |
| private class PreviewHtmlReportWriter : SnapshotHandler { | |
| private val delegate = HtmlReportWriter() | |
| override fun newFrameHandler( | |
| snapshot: Snapshot, | |
| frameCount: Int, | |
| fps: Int, | |
| ): SnapshotHandler.FrameHandler { | |
| return delegate.newFrameHandler( | |
| snapshot = snapshot.normalized(), | |
| frameCount = frameCount, | |
| fps = fps, | |
| ) | |
| } | |
| override fun close() { | |
| delegate.close() | |
| } | |
| } | |
| private fun Snapshot.normalized(): Snapshot { | |
| val methodName = testName.methodName.substringAfterLast("_").substringBeforeLast("]") | |
| return Snapshot( | |
| name = name, | |
| testName = TestName(packageName = "", className = "", methodName = methodName), | |
| timestamp = timestamp, | |
| tags = tags, | |
| file = file, | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment