Skip to content

Commit a51e5be

Browse files
runningcodeclaude
andauthored
feat(snapshot): Write per-image sidecar metadata during test execution (#1127)
* feat(snapshot): Write per-image sidecar metadata during test execution The sentry-cli `build snapshots` command supports per-image metadata via companion JSON sidecar files, but this was disconnected from the preview metadata the plugin already knows about. This adds a `SentrySnapshotHandler` to the generated Paparazzi test that writes each rendered image (with its screenshotId as filename) plus a companion JSON sidecar to `build/sentry-snapshots/images/`. The sidecar includes `display_name`, `image_file_name`, `group`, `className`, and `methodName` fields that sentry-cli merges into the upload manifest. The upload task's `snapshotsPath` is now automatically wired to this output directory, and the `sentry.snapshot.output` system property is set on all Test tasks so the generated code writes to the correct path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Fix compile errors in generated sidecar metadata code - Remove SentrySnapshotHandler that used java.awt.image.BufferedImage and javax.imageio.ImageIO which are unavailable in Android test classpath - Use SnapshotVerifier pointed at sentry output dir instead, which writes images with predictable names (not content hashes) - Fix methodName access: use preview.methodName (ComposablePreview) instead of info.methodName (AndroidPreviewInfo doesn't have it) - Fix declaringClass: it's a non-nullable String on ComposablePreview - Write sidecar filenames matching SnapshotVerifier's naming pattern (Paparazzi_Preview_Test_{screenshotId_lowercased}) - Clean sentry output dir before each test run to ensure fresh writes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(snapshot): Include full preview configuration in sidecar metadata Add nightMode, fontScale, locale, device, apiLevel, widthDp, heightDp, showSystemUi, showBackground, previewName, and group fields to the sidecar JSON. Non-default values are included to keep the file compact. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Use HtmlReportWriter recording mode for sentry images SnapshotVerifier is verify-only and always compares against golden files. Replace it with HtmlReportWriter which has a recording mode (paparazzi.test.record=true) that copies images with predictable names to a configurable snapshot directory. Pass snapshotRootDirectory=sentryOutputDir to HtmlReportWriter so recorded images go to build/sentry-snapshots/images/ with names matching the sidecar JSONs (Paparazzi_Preview_Test_{screenshotId}). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Make sentry snapshot output directory variant-aware Use build/sentry-snapshots/{variant}/images/ instead of a shared directory so that different build variants don't overwrite each other. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Apply spotless formatting to remaining files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Use Paparazzi's default snapshot dir for sidecar metadata Instead of configuring a custom snapshotRootDirectory on HtmlReportWriter and passing it via a sentry.snapshot.output system property, reuse the paparazzi.snapshot.dir that Paparazzi's plugin already sets. Sidecar JSON files are now written alongside golden images in the same directory. Also simplifies sidecar JSON generation using a linkedMapOf and removes dead code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Remove hardcoded snapshotsPath from upload task Let users pass the path via --snapshots-path instead of hardcoding the now-removed build/sentry-snapshots directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * release: 6.4.0-alpha.3 * fix(snapshot): Use short name for sidecar display_name Strip the fully qualified class prefix from the screenshotId so display_name shows e.g. "BookmarksScreenAppStorePreview.NIGHT_PIXEL_5" instead of the full package path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Remove forced record mode from SentrySnapshotPlugin Stop unconditionally setting paparazzi.test.record=true on the variant test task, which forced all Paparazzi tests in the module into record mode and silently overwrote golden files for existing user tests. Users should run recordPaparazzi<Variant> which sets this property correctly via Paparazzi's own plugin. Also adds unit tests for GenerateSnapshotTestsTask. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Preserve original case in sidecar filenames The sidecar JSON and PNG filenames were being lowercased, causing a mismatch with the original screenshotId casing. Remove the `.lowercase(Locale.US)` call so filenames keep their original case. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * release: 6.4.0-alpha.4 * fix(snapshot): Lowercase sidecar filenames to match Paparazzi images Paparazzi's `Snapshot.toFileName()` applies `.lowercase(Locale.US)` to the snapshot name when generating image filenames. The sidecar JSON filenames must match, so re-add the lowercase call that was removed in e460156. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(snapshot): Use symbolic constants for night mode detection Replace hardcoded hex literals (0x30, 0x20) with UI_MODE_NIGHT_MASK and UI_MODE_NIGHT_YES constants from PreviewConstants for consistency with the rest of the generated test code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Apply spotless formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: runningcode <332597+runningcode@users.noreply.github.com>
1 parent 83d86bf commit a51e5be

8 files changed

Lines changed: 224 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# Changelog
22

3-
## Unreleased
3+
## 6.4.0-alpha.4
4+
5+
### Internal Changes 🔧
6+
7+
#### Deps
8+
9+
- Update CLI to v3.3.5 by @github-actions in [#1132](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1132)
10+
- Update CLI to v3.3.4 by @github-actions in [#1122](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1122)
11+
12+
## 6.4.0-alpha.3
413

514
### Dependencies
615

plugin-build/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ org.gradle.parallel=true
77

88
name = sentry-android-gradle-plugin
99
group = io.sentry
10-
version = 6.3.0
10+
version = 6.4.0-alpha.4
1111
sdk_version = 8.37.1
1212

1313
# publication pom properties

plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,11 +469,11 @@ private fun ApplicationVariant.configureSnapshotsTasks(
469469
cliExecutable: Provider<String>,
470470
sentryOrg: String?,
471471
sentryProject: String?,
472-
) {
472+
): TaskProvider<SentryUploadSnapshotsTask> {
473473
val variant = AndroidVariant74(this)
474474
val sentryProps = getPropertiesFilePath(project, variant)
475475

476-
SentryUploadSnapshotsTask.register(
476+
return SentryUploadSnapshotsTask.register(
477477
project = project,
478478
extension = extension,
479479
sentryTelemetryProvider = sentryTelemetryProvider,

plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ import app.cash.paparazzi.TestName
108108
import app.cash.paparazzi.detectEnvironment
109109
import com.android.ide.common.rendering.api.SessionParams
110110
import com.android.resources.*
111+
import java.io.File
112+
import java.util.Locale
111113
import kotlin.math.ceil
112114
import org.junit.Rule
113115
import org.junit.Test
@@ -219,6 +221,7 @@ private object PaparazziPreviewRule {
219221
return Paparazzi(
220222
environment = detectEnvironment().copy(compileSdkVersion = previewApiLevel),
221223
deviceConfig = DeviceConfigBuilder.build(preview.previewInfo),
224+
theme = "android:Theme.Translucent.NoTitleBar",
222225
supportsRtl = true,
223226
showSystemUi = previewInfo.showSystemUi,
224227
renderingMode = when {
@@ -328,7 +331,47 @@ class $CLASS_NAME(
328331
}
329332
}
330333
}
334+
335+
writeSidecarMetadata(screenshotId, preview)
336+
}
337+
338+
private fun writeSidecarMetadata(
339+
screenshotId: String,
340+
preview: ComposablePreview<AndroidPreviewInfo>,
341+
) {
342+
val snapshotDir = File(System.getProperty("paparazzi.snapshot.dir"))
343+
val imagesDir = File(snapshotDir, "images")
344+
imagesDir.mkdirs()
345+
val info = preview.previewInfo
346+
val metadata = linkedMapOf<String, Any>(
347+
"display_name" to screenshotId.removePrefix(preview.declaringClass + "."),
348+
"image_file_name" to screenshotId,
349+
"className" to preview.declaringClass,
350+
"methodName" to preview.methodName,
351+
)
352+
if (info.group.isNotBlank()) metadata["group"] = info.group
353+
if (info.name.isNotBlank()) metadata["previewName"] = info.name
354+
if (info.locale.isNotBlank()) metadata["locale"] = info.locale
355+
if (info.device.isNotBlank()) metadata["device"] = info.device
356+
metadata["nightMode"] = (info.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES)
357+
if (info.fontScale != 1f) metadata["fontScale"] = info.fontScale
358+
if (info.apiLevel != -1) metadata["apiLevel"] = info.apiLevel
359+
if (info.widthDp > 0) metadata["widthDp"] = info.widthDp
360+
if (info.heightDp > 0) metadata["heightDp"] = info.heightDp
361+
if (info.showSystemUi) metadata["showSystemUi"] = true
362+
if (info.showBackground) metadata["showBackground"] = true
363+
364+
val json = metadata.entries.joinToString(",\n ", prefix = "{\n ", postfix = "\n}") { (k, v) ->
365+
if (v is String) "\"" + k + "\": \"" + escapeJson(v) + "\""
366+
else "\"" + k + "\": " + v
367+
}
368+
val sidecarName = "Paparazzi_Preview_Test_" +
369+
screenshotId.lowercase(Locale.US).replace("\\s".toRegex(), "_")
370+
File(imagesDir, "${'$'}{sidecarName}.json").writeText(json)
331371
}
372+
373+
private fun escapeJson(s: String): String =
374+
s.replace("\\", "\\\\").replace("\"", "\\\"")
332375
}
333376
"""
334377
.trimStart()

plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/SentrySnapshotPlugin.kt

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package io.sentry.android.gradle.snapshot
22

33
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
4-
import com.android.build.api.variant.HostTestBuilder
4+
import com.android.build.api.variant.HostTestBuilder.Companion.UNIT_TEST_TYPE
55
import com.android.build.gradle.BaseExtension
66
import io.sentry.android.gradle.util.AgpVersions
77
import kotlin.jvm.java
@@ -35,19 +35,21 @@ class SentrySnapshotPlugin : Plugin<Project> {
3535
// Right now it seems we only have HostTestBuilder.UNIT_TEST_TYPE as the key but we are
3636
// creating screenshot tests like HostTestBuilder.SCREENSHOT_TEST_TYPE
3737
// We should adjust this once the API is stable and documented.
38-
variant.hostTests[HostTestBuilder.UNIT_TEST_TYPE]
39-
// Using `sources?.kotlin` is broken so we have to use sources?.java:
40-
// https://issuetracker.google.com/issues/268248348
41-
?.sources
42-
?.java
43-
?.addGeneratedSourceDirectory(generateTask, GenerateSnapshotTestsTask::outputDir)
38+
variant.hostTests[UNIT_TEST_TYPE]?.apply {
39+
sources.java?.addGeneratedSourceDirectory(
40+
generateTask,
41+
GenerateSnapshotTestsTask::outputDir,
42+
)
43+
}
4444
} else {
45-
// `unitTest` is deprecated, the replacement above is complex
4645
@Suppress("DEPRECATION_ERROR")
47-
variant.unitTest
48-
?.sources
49-
?.java
50-
?.addGeneratedSourceDirectory(generateTask, GenerateSnapshotTestsTask::outputDir)
46+
// `unitTest` is deprecated, the replacement above is complex
47+
variant.unitTest?.apply {
48+
sources.java?.addGeneratedSourceDirectory(
49+
generateTask,
50+
GenerateSnapshotTestsTask::outputDir,
51+
)
52+
}
5153
}
5254
}
5355
}

plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/metadata/PreviewConstants.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ private const val SQUARE = "id:wearos_square"
4747
// region UI mode constants
4848
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/res/Configuration.java
4949

50-
private const val UI_MODE_NIGHT_YES: Int = 32
50+
internal const val UI_MODE_NIGHT_MASK: Int = 0x30
51+
internal const val UI_MODE_NIGHT_YES: Int = 0x20
5152
private const val UI_MODE_TYPE_NORMAL: Int = 1
5253

5354
// endregion
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package io.sentry.android.gradle.snapshot
2+
3+
import java.io.File
4+
import kotlin.test.assertFalse
5+
import kotlin.test.assertTrue
6+
import org.gradle.testfixtures.ProjectBuilder
7+
import org.junit.Rule
8+
import org.junit.Test
9+
import org.junit.rules.TemporaryFolder
10+
11+
class GenerateSnapshotTestsTaskTest {
12+
13+
@get:Rule val tmpDir = TemporaryFolder()
14+
15+
@Test
16+
fun `generates test file in correct package directory`() {
17+
val task = createTask(packageTrees = listOf("com.example"))
18+
19+
task.generate()
20+
21+
val outputDir = task.outputDir.get().asFile
22+
val expectedFile = File(outputDir, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
23+
assertTrue(expectedFile.exists())
24+
}
25+
26+
@Test
27+
fun `cleans output directory on re-run`() {
28+
val task = createTask(packageTrees = listOf("com.example"))
29+
val outputDir = task.outputDir.get().asFile
30+
31+
// Create a stale file in the output directory
32+
val staleDir = File(outputDir, "io/sentry/snapshot")
33+
staleDir.mkdirs()
34+
val staleFile = File(staleDir, "OldFile.kt")
35+
staleFile.writeText("old content")
36+
assertTrue(staleFile.exists())
37+
38+
task.generate()
39+
40+
assertFalse(staleFile.exists())
41+
val expectedFile = File(outputDir, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
42+
assertTrue(expectedFile.exists())
43+
}
44+
45+
@Test
46+
fun `generated file contains correct package declaration`() {
47+
val content = generateAndRead(packageTrees = listOf("com.example"))
48+
49+
assertTrue(content.contains("package io.sentry.snapshot"))
50+
}
51+
52+
@Test
53+
fun `generated file scans configured package tree`() {
54+
val content = generateAndRead(packageTrees = listOf("com.example.app"))
55+
56+
assertTrue(content.contains(".scanPackageTrees(\"com.example.app\")"))
57+
}
58+
59+
@Test
60+
fun `generated file scans multiple package trees`() {
61+
val content =
62+
generateAndRead(packageTrees = listOf("com.example.feature1", "com.example.feature2"))
63+
64+
assertTrue(
65+
content.contains(".scanPackageTrees(\"com.example.feature1\", \"com.example.feature2\")")
66+
)
67+
}
68+
69+
@Test
70+
fun `generated file includes private previews when enabled`() {
71+
val content =
72+
generateAndRead(packageTrees = listOf("com.example"), includePrivatePreviews = true)
73+
74+
assertTrue(content.contains(".includePrivatePreviews()"))
75+
}
76+
77+
@Test
78+
fun `generated file excludes private previews by default`() {
79+
val content = generateAndRead(packageTrees = listOf("com.example"))
80+
81+
assertFalse(content.contains(".includePrivatePreviews()"))
82+
}
83+
84+
@Test
85+
fun `generated file contains parameterized test runner`() {
86+
val content = generateAndRead(packageTrees = listOf("com.example"))
87+
88+
assertTrue(content.contains("@RunWith(Parameterized::class)"))
89+
assertTrue(content.contains("class ComposablePreviewSnapshotTest"))
90+
}
91+
92+
@Test
93+
fun `generated sidecar metadata uses short display name`() {
94+
val content = generateAndRead(packageTrees = listOf("com.example"))
95+
96+
assertTrue(
97+
content.contains(
98+
"\"display_name\" to screenshotId.removePrefix(preview.declaringClass + \".\")"
99+
)
100+
)
101+
}
102+
103+
@Test
104+
fun `generated sidecar metadata uses full screenshotId for image file name`() {
105+
val content = generateAndRead(packageTrees = listOf("com.example"))
106+
107+
assertTrue(content.contains("\"image_file_name\" to screenshotId"))
108+
}
109+
110+
@Test
111+
fun `generated file writes sidecar json to images directory`() {
112+
val content = generateAndRead(packageTrees = listOf("com.example"))
113+
114+
assertTrue(content.contains("val imagesDir = File(snapshotDir, \"images\")"))
115+
assertTrue(content.contains("File(imagesDir, \"\${sidecarName}.json\").writeText(json)"))
116+
}
117+
118+
@Test
119+
fun `generated sidecar filename is lowercased to match Paparazzi image filenames`() {
120+
val content = generateAndRead(packageTrees = listOf("com.example"))
121+
122+
assertTrue(
123+
content.contains("screenshotId.lowercase(Locale.US).replace(\"\\\\s\".toRegex(), \"_\")")
124+
)
125+
}
126+
127+
private fun generateAndRead(
128+
packageTrees: List<String>,
129+
includePrivatePreviews: Boolean = false,
130+
): String {
131+
val task = createTask(packageTrees, includePrivatePreviews)
132+
task.generate()
133+
val file =
134+
File(task.outputDir.get().asFile, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
135+
return file.readText()
136+
}
137+
138+
private fun createTask(
139+
packageTrees: List<String>,
140+
includePrivatePreviews: Boolean = false,
141+
): GenerateSnapshotTestsTask {
142+
val project = ProjectBuilder.builder().build()
143+
return project.tasks
144+
.register("testGenerateSnapshotTests", GenerateSnapshotTestsTask::class.java) { task ->
145+
task.includePrivatePreviews.set(includePrivatePreviews)
146+
task.packageTrees.set(packageTrees)
147+
task.outputDir.set(tmpDir.newFolder("output"))
148+
}
149+
.get()
150+
}
151+
}

sentry-kotlin-compiler-plugin/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ org.gradle.parallel=true
44

55
GROUP = io.sentry
66
POM_ARTIFACT_ID = sentry-kotlin-compiler-plugin
7-
VERSION_NAME = 6.3.0
7+
VERSION_NAME = 6.4.0-alpha.4
88

99
# publication pom properties
1010
POM_NAME=Sentry Kotlin Compiler Plugin

0 commit comments

Comments
 (0)