Skip to content

Commit be57112

Browse files
runningcodeclaude
andauthored
feat(snapshot): Add io.sentry.android.snapshot Gradle plugin (#1112)
* feat(snapshot): Add `io.sentry.android.snapshot` Gradle plugin Add a new Gradle plugin that generates Paparazzi screenshot tests for all Compose @Preview composables. When applied to an app module alongside Paparazzi, it registers a `generateSnapshotTests` task that produces a parameterized JUnit 4 test class using ComposablePreviewScanner to discover previews at test runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(snapshot): Mark extension as experimental Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(snapshot): Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ref(snapshot): Deduplicate SnapshotHandler wrappers Replace PreviewSnapshotVerifier and PreviewHtmlReportWriter with a single TestNameOverrideHandler that wraps any SnapshotHandler delegate. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ref(snapshot): Simplify packageTrees and error on missing Paparazzi android.namespace is always non-null, so packageTrees can never be empty after the fallback. Remove the dead scanAllPackages branch and fail the build if Paparazzi is not applied instead of just warning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Add non-null assertion on android.namespace Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0c7157 commit be57112

File tree

4 files changed

+413
-0
lines changed

4 files changed

+413
-0
lines changed

plugin-build/build.gradle.kts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ gradlePlugin {
157157
id = "io.sentry.jvm.gradle"
158158
implementationClass = "io.sentry.jvm.gradle.SentryJvmPlugin"
159159
}
160+
register("sentrySnapshotPlugin") {
161+
id = "io.sentry.android.snapshot"
162+
implementationClass = "io.sentry.android.gradle.snapshot.SentrySnapshotPlugin"
163+
}
160164
}
161165
}
162166

@@ -189,6 +193,9 @@ distributions {
189193
create("sentryJvmPluginMarker") {
190194
contents { from("build${sep}publications${sep}sentryJvmPluginPluginMarkerMaven") }
191195
}
196+
create("sentrySnapshotPluginMarker") {
197+
contents { from("build${sep}publications${sep}sentrySnapshotPluginPluginMarkerMaven") }
198+
}
192199
}
193200

194201
tasks.named("distZip") {
@@ -235,6 +242,14 @@ tasks.named("sentryPluginMarkerDistZip").configure {
235242
dependsOn("generatePomFileForSentryPluginPluginMarkerMavenPublication")
236243
}
237244

245+
tasks.named("sentrySnapshotPluginMarkerDistTar").configure {
246+
dependsOn("generatePomFileForSentrySnapshotPluginPluginMarkerMavenPublication")
247+
}
248+
249+
tasks.named("sentrySnapshotPluginMarkerDistZip").configure {
250+
dependsOn("generatePomFileForSentrySnapshotPluginPluginMarkerMavenPublication")
251+
}
252+
238253
tasks.withType<Test>().configureEach {
239254
testLogging {
240255
events = setOf(TestLogEvent.SKIPPED, TestLogEvent.PASSED, TestLogEvent.FAILED)
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
package io.sentry.android.gradle.snapshot
2+
3+
import java.io.File
4+
import org.gradle.api.DefaultTask
5+
import org.gradle.api.Project
6+
import org.gradle.api.file.DirectoryProperty
7+
import org.gradle.api.provider.ListProperty
8+
import org.gradle.api.provider.Property
9+
import org.gradle.api.tasks.CacheableTask
10+
import org.gradle.api.tasks.Input
11+
import org.gradle.api.tasks.OutputDirectory
12+
import org.gradle.api.tasks.TaskAction
13+
import org.gradle.api.tasks.TaskProvider
14+
15+
@CacheableTask
16+
abstract class GenerateSnapshotTestsTask : DefaultTask() {
17+
18+
init {
19+
description =
20+
"Generates a parameterized Paparazzi test that snapshots all Compose @Preview composables"
21+
}
22+
23+
@get:Input abstract val includePrivatePreviews: Property<Boolean>
24+
25+
@get:Input abstract val packageTrees: ListProperty<String>
26+
27+
@get:OutputDirectory abstract val outputDir: DirectoryProperty
28+
29+
@TaskAction
30+
fun generate() {
31+
val outDir = outputDir.get().asFile
32+
if (outDir.exists()) {
33+
outDir.deleteRecursively()
34+
}
35+
36+
val packageDir = File(outDir, PACKAGE_NAME.replace('.', '/'))
37+
packageDir.mkdirs()
38+
39+
val content =
40+
generateTestFileContent(
41+
includePrivatePreviews = includePrivatePreviews.get(),
42+
packageTrees = packageTrees.get(),
43+
)
44+
File(packageDir, "$CLASS_NAME.kt").writeText(content)
45+
logger.lifecycle("Generated snapshot test: ${packageDir.absolutePath}/$CLASS_NAME.kt")
46+
}
47+
48+
companion object {
49+
private const val PACKAGE_NAME = "io.sentry.snapshot"
50+
private const val CLASS_NAME = "ComposablePreviewSnapshotTest"
51+
52+
fun register(
53+
project: Project,
54+
extension: SentrySnapshotExtension,
55+
android: com.android.build.gradle.BaseExtension,
56+
): TaskProvider<GenerateSnapshotTestsTask> {
57+
return project.tasks.register(
58+
"generateSnapshotTests",
59+
GenerateSnapshotTestsTask::class.java,
60+
) { task ->
61+
task.includePrivatePreviews.set(extension.includePrivatePreviews)
62+
// Fall back to the Android namespace when the user doesn't configure packageTrees
63+
task.packageTrees.set(
64+
extension.packageTrees.map { packages ->
65+
packages.ifEmpty { listOf(android.namespace!!) }
66+
}
67+
)
68+
task.outputDir.set(project.layout.buildDirectory.dir("generated/sentry/snapshotTests"))
69+
}
70+
}
71+
72+
@Suppress("LongMethod")
73+
private fun generateTestFileContent(
74+
includePrivatePreviews: Boolean,
75+
packageTrees: List<String>,
76+
): String {
77+
val includePrivateExpr =
78+
if (includePrivatePreviews) "\n .includePrivatePreviews()" else ""
79+
80+
val packages = packageTrees.joinToString(", ") { "\"$it\"" }
81+
val scanExpr = ".scanPackageTrees($packages)"
82+
83+
return """
84+
package $PACKAGE_NAME
85+
86+
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
87+
import android.content.res.Configuration.UI_MODE_NIGHT_YES
88+
import androidx.compose.foundation.background
89+
import androidx.compose.foundation.layout.Box
90+
import androidx.compose.foundation.layout.size
91+
import androidx.compose.runtime.Composable
92+
import androidx.compose.ui.Modifier
93+
import androidx.compose.ui.graphics.Color
94+
import androidx.compose.ui.unit.dp
95+
import app.cash.paparazzi.DeviceConfig
96+
import app.cash.paparazzi.HtmlReportWriter
97+
import app.cash.paparazzi.Paparazzi
98+
import app.cash.paparazzi.Snapshot
99+
import app.cash.paparazzi.SnapshotHandler
100+
import app.cash.paparazzi.SnapshotVerifier
101+
import app.cash.paparazzi.TestName
102+
import app.cash.paparazzi.detectEnvironment
103+
import com.android.ide.common.rendering.api.SessionParams
104+
import com.android.resources.*
105+
import kotlin.math.ceil
106+
import org.junit.Rule
107+
import org.junit.Test
108+
import org.junit.runner.RunWith
109+
import org.junit.runners.Parameterized
110+
import sergio.sastre.composable.preview.scanner.android.AndroidComposablePreviewScanner
111+
import sergio.sastre.composable.preview.scanner.android.AndroidPreviewInfo
112+
import sergio.sastre.composable.preview.scanner.android.device.DevicePreviewInfoParser
113+
import sergio.sastre.composable.preview.scanner.android.device.domain.Device
114+
import sergio.sastre.composable.preview.scanner.android.device.types.DEFAULT
115+
import sergio.sastre.composable.preview.scanner.android.screenshotid.AndroidPreviewScreenshotIdBuilder
116+
import sergio.sastre.composable.preview.scanner.core.preview.ComposablePreview
117+
118+
private class Dimensions(
119+
val screenWidthInPx: Int,
120+
val screenHeightInPx: Int,
121+
)
122+
123+
private object ScreenDimensions {
124+
fun dimensions(
125+
parsedDevice: Device,
126+
widthDp: Int,
127+
heightDp: Int,
128+
): Dimensions {
129+
val conversionFactor = parsedDevice.densityDpi / 160f
130+
val previewWidthInPx = ceil(widthDp * conversionFactor).toInt()
131+
val previewHeightInPx = ceil(heightDp * conversionFactor).toInt()
132+
return Dimensions(
133+
screenWidthInPx = when (widthDp > 0) {
134+
true -> previewWidthInPx
135+
false -> parsedDevice.dimensions.width.toInt()
136+
},
137+
screenHeightInPx = when (heightDp > 0) {
138+
true -> previewHeightInPx
139+
false -> parsedDevice.dimensions.height.toInt()
140+
},
141+
)
142+
}
143+
}
144+
145+
private object DeviceConfigBuilder {
146+
fun build(preview: AndroidPreviewInfo): DeviceConfig {
147+
val parsedDevice =
148+
DevicePreviewInfoParser.parse(preview.device)?.inPx() ?: return DeviceConfig()
149+
150+
val dimensions = ScreenDimensions.dimensions(
151+
parsedDevice = parsedDevice,
152+
widthDp = preview.widthDp,
153+
heightDp = preview.heightDp,
154+
)
155+
156+
return DeviceConfig(
157+
screenHeight = dimensions.screenHeightInPx,
158+
screenWidth = dimensions.screenWidthInPx,
159+
density = Density(parsedDevice.densityDpi),
160+
xdpi = parsedDevice.densityDpi,
161+
ydpi = parsedDevice.densityDpi,
162+
size = ScreenSize.valueOf(parsedDevice.screenSize.name),
163+
ratio = ScreenRatio.valueOf(parsedDevice.screenRatio.name),
164+
screenRound = ScreenRound.valueOf(parsedDevice.shape.name),
165+
orientation = ScreenOrientation.valueOf(parsedDevice.orientation.name),
166+
locale = preview.locale.ifBlank { "en" },
167+
fontScale = preview.fontScale,
168+
nightMode = when (preview.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) {
169+
true -> NightMode.NIGHT
170+
false -> NightMode.NOTNIGHT
171+
},
172+
)
173+
}
174+
}
175+
176+
private val paparazziTestName =
177+
TestName(packageName = "Paparazzi", className = "Preview", methodName = "Test")
178+
179+
private class TestNameOverrideHandler(
180+
private val delegate: SnapshotHandler,
181+
) : SnapshotHandler {
182+
override fun newFrameHandler(
183+
snapshot: Snapshot,
184+
frameCount: Int,
185+
fps: Int,
186+
): SnapshotHandler.FrameHandler {
187+
val newSnapshot = Snapshot(
188+
name = snapshot.name,
189+
testName = paparazziTestName,
190+
timestamp = snapshot.timestamp,
191+
tags = snapshot.tags,
192+
file = snapshot.file,
193+
)
194+
return delegate.newFrameHandler(newSnapshot, frameCount, fps)
195+
}
196+
197+
override fun close() {
198+
delegate.close()
199+
}
200+
}
201+
202+
private object PaparazziPreviewRule {
203+
const val UNDEFINED_API_LEVEL = -1
204+
const val MAX_API_LEVEL = 36
205+
206+
fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi {
207+
val previewInfo = preview.previewInfo
208+
val previewApiLevel = when (previewInfo.apiLevel == UNDEFINED_API_LEVEL) {
209+
true -> MAX_API_LEVEL
210+
false -> previewInfo.apiLevel
211+
}
212+
val tolerance = 0.0
213+
return Paparazzi(
214+
environment = detectEnvironment().copy(compileSdkVersion = previewApiLevel),
215+
deviceConfig = DeviceConfigBuilder.build(preview.previewInfo),
216+
supportsRtl = true,
217+
showSystemUi = previewInfo.showSystemUi,
218+
renderingMode = when {
219+
previewInfo.showSystemUi -> SessionParams.RenderingMode.NORMAL
220+
previewInfo.widthDp > 0 && previewInfo.heightDp > 0 -> SessionParams.RenderingMode.FULL_EXPAND
221+
else -> SessionParams.RenderingMode.SHRINK
222+
},
223+
snapshotHandler = TestNameOverrideHandler(
224+
when (System.getProperty("paparazzi.test.verify")?.toBoolean() == true) {
225+
true -> SnapshotVerifier(maxPercentDifference = tolerance)
226+
false -> HtmlReportWriter(maxPercentDifference = tolerance)
227+
}
228+
),
229+
maxPercentDifference = tolerance,
230+
)
231+
}
232+
}
233+
234+
@Composable
235+
private fun SystemUiSize(
236+
widthInDp: Int,
237+
heightInDp: Int,
238+
content: @Composable () -> Unit,
239+
) {
240+
Box(
241+
Modifier
242+
.size(width = widthInDp.dp, height = heightInDp.dp)
243+
.background(Color.White)
244+
) {
245+
content()
246+
}
247+
}
248+
249+
@Composable
250+
private fun PreviewBackground(
251+
showBackground: Boolean,
252+
backgroundColor: Long,
253+
content: @Composable () -> Unit,
254+
) {
255+
when (showBackground) {
256+
false -> content()
257+
true -> {
258+
val color = when (backgroundColor != 0L) {
259+
true -> Color(backgroundColor)
260+
false -> Color.White
261+
}
262+
Box(Modifier.background(color)) {
263+
content()
264+
}
265+
}
266+
}
267+
}
268+
269+
/**
270+
* Auto-generated by Sentry Snapshot Plugin.
271+
*/
272+
@RunWith(Parameterized::class)
273+
class $CLASS_NAME(
274+
private val preview: ComposablePreview<AndroidPreviewInfo>,
275+
) {
276+
277+
companion object {
278+
private val cachedPreviews: List<ComposablePreview<AndroidPreviewInfo>> by lazy {
279+
AndroidComposablePreviewScanner()
280+
$scanExpr$includePrivateExpr
281+
.getPreviews()
282+
}
283+
284+
@JvmStatic
285+
@Parameterized.Parameters
286+
fun values(): List<ComposablePreview<AndroidPreviewInfo>> = cachedPreviews
287+
}
288+
289+
@get:Rule
290+
val paparazzi: Paparazzi = PaparazziPreviewRule.createFor(preview)
291+
292+
@Test
293+
fun snapshot() {
294+
val screenshotId = AndroidPreviewScreenshotIdBuilder(preview)
295+
.doNotIgnoreMethodParametersType()
296+
.encodeUnsafeCharacters()
297+
.build()
298+
299+
paparazzi.snapshot(name = screenshotId) {
300+
val previewInfo = preview.previewInfo
301+
when (previewInfo.showSystemUi) {
302+
false -> PreviewBackground(
303+
showBackground = previewInfo.showBackground,
304+
backgroundColor = previewInfo.backgroundColor,
305+
) {
306+
preview()
307+
}
308+
309+
true -> {
310+
val parsedDevice = (DevicePreviewInfoParser.parse(previewInfo.device) ?: DEFAULT).inDp()
311+
SystemUiSize(
312+
widthInDp = parsedDevice.dimensions.width.toInt(),
313+
heightInDp = parsedDevice.dimensions.height.toInt(),
314+
) {
315+
PreviewBackground(
316+
showBackground = true,
317+
backgroundColor = previewInfo.backgroundColor,
318+
) {
319+
preview()
320+
}
321+
}
322+
}
323+
}
324+
}
325+
}
326+
}
327+
"""
328+
.trimStart()
329+
}
330+
}
331+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.sentry.android.gradle.snapshot
2+
3+
import org.gradle.api.model.ObjectFactory
4+
import org.gradle.api.provider.ListProperty
5+
import org.gradle.api.provider.Property
6+
import org.jetbrains.annotations.ApiStatus
7+
8+
/**
9+
* Experimental extension for configuring Compose @Preview snapshot testing. This API is subject to
10+
* change and will eventually be merged into the main `sentry` extension.
11+
*/
12+
@ApiStatus.Experimental
13+
abstract class SentrySnapshotExtension(objects: ObjectFactory) {
14+
15+
val includePrivatePreviews: Property<Boolean> =
16+
objects.property(Boolean::class.java).convention(false)
17+
18+
val packageTrees: ListProperty<String> =
19+
objects.listProperty(String::class.java).convention(emptyList())
20+
}

0 commit comments

Comments
 (0)