Skip to content

Commit e81b819

Browse files
runningcodeclaude
andcommitted
feat(snapshots): Restructure sidecar JSON to match ingestion schema
Bucket appearance inputs (locale, device, font_scale, api_level, width_dp, height_dp, show_system_ui, show_background, preview_name) under `tags`; move preview identity (class_name, method_name, image_file_name) under `context`; replace the `night_mode` boolean with a `color_mode` enum that is emitted only when `uiMode` explicitly sets `UI_MODE_NIGHT_YES` or `UI_MODE_NIGHT_NO`. The serializer is extended with a small recursive `renderJson` helper so the template can emit nested objects without pulling in a JSON library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7072874 commit e81b819

3 files changed

Lines changed: 90 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Add `@SentrySnapshot` runtime annotation for configuring the per-snapshot diff threshold, published as `io.sentry:sentry-snapshots-runtime` ([EME-1055](https://linear.app/getsentry/issue/EME-1055))
88
- Emit `diff_threshold` in the snapshot sidecar JSON when a `@Preview` is also annotated with `@SentrySnapshot(diffThreshold = ...)` ([EME-1055](https://linear.app/getsentry/issue/EME-1055))
9+
- Restructure the snapshot sidecar JSON to match the ingestion schema: bucket appearance inputs (`locale`, `device`, `font_scale`, …) under `tags`, preview identity (`class_name`, `method_name`, `image_file_name`) under `context`, and replace the `night_mode` boolean with a `color_mode` enum (`"light"`/`"dark"`)
910

1011
### Dependencies
1112

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

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ abstract class GenerateSnapshotTestsTask : DefaultTask() {
105105
package $PACKAGE_NAME
106106
107107
import android.content.res.Configuration.UI_MODE_NIGHT_MASK
108+
import android.content.res.Configuration.UI_MODE_NIGHT_NO
108109
import android.content.res.Configuration.UI_MODE_NIGHT_YES
109110
import androidx.compose.foundation.background
110111
import androidx.compose.foundation.layout.Box
@@ -358,23 +359,33 @@ class $CLASS_NAME(
358359
val imagesDir = File(snapshotDir, "images")
359360
imagesDir.mkdirs()
360361
val info = preview.previewInfo
361-
val metadata = linkedMapOf<String, Any>(
362-
"display_name" to screenshotId.removePrefix(preview.declaringClass + "."),
362+
363+
val tags = linkedMapOf<String, Any>()
364+
if (info.name.isNotBlank()) tags["preview_name"] = info.name
365+
if (info.locale.isNotBlank()) tags["locale"] = info.locale
366+
if (info.device.isNotBlank()) tags["device"] = info.device
367+
if (info.fontScale != 1f) tags["font_scale"] = info.fontScale
368+
if (info.apiLevel != -1) tags["api_level"] = info.apiLevel
369+
if (info.widthDp > 0) tags["width_dp"] = info.widthDp
370+
if (info.heightDp > 0) tags["height_dp"] = info.heightDp
371+
if (info.showSystemUi) tags["show_system_ui"] = true
372+
if (info.showBackground) tags["show_background"] = true
373+
374+
val context = linkedMapOf<String, Any>(
363375
"image_file_name" to screenshotId,
364376
"class_name" to preview.declaringClass,
365377
"method_name" to preview.methodName,
366378
)
379+
380+
val metadata = linkedMapOf<String, Any>(
381+
"display_name" to screenshotId.removePrefix(preview.declaringClass + "."),
382+
)
367383
if (info.group.isNotBlank()) metadata["group"] = info.group
368-
if (info.name.isNotBlank()) metadata["preview_name"] = info.name
369-
if (info.locale.isNotBlank()) metadata["locale"] = info.locale
370-
if (info.device.isNotBlank()) metadata["device"] = info.device
371-
metadata["night_mode"] = (info.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES)
372-
if (info.fontScale != 1f) metadata["font_scale"] = info.fontScale
373-
if (info.apiLevel != -1) metadata["api_level"] = info.apiLevel
374-
if (info.widthDp > 0) metadata["width_dp"] = info.widthDp
375-
if (info.heightDp > 0) metadata["height_dp"] = info.heightDp
376-
if (info.showSystemUi) metadata["show_system_ui"] = true
377-
if (info.showBackground) metadata["show_background"] = true
384+
385+
when (info.uiMode and UI_MODE_NIGHT_MASK) {
386+
UI_MODE_NIGHT_YES -> metadata["color_mode"] = "dark"
387+
UI_MODE_NIGHT_NO -> metadata["color_mode"] = "light"
388+
}
378389
379390
val diffThreshold: Float? = runCatching {
380391
val declaring = Class.forName(preview.declaringClass)
@@ -387,15 +398,33 @@ class $CLASS_NAME(
387398
}.getOrNull()
388399
if (diffThreshold != null && diffThreshold != 0f) metadata["diff_threshold"] = diffThreshold
389400
390-
val json = metadata.entries.joinToString(",\n ", prefix = "{\n ", postfix = "\n}") { (k, v) ->
391-
if (v is String) "\"" + k + "\": \"" + escapeJson(v) + "\""
392-
else "\"" + k + "\": " + v
393-
}
401+
if (tags.isNotEmpty()) metadata["tags"] = tags
402+
metadata["context"] = context
403+
404+
val json = renderJson(metadata, 0)
394405
val sidecarName = "Paparazzi_Preview_Test_" +
395406
screenshotId.lowercase(Locale.US).replace("\\s".toRegex(), "_")
396407
File(imagesDir, "${'$'}{sidecarName}.json").writeText(json)
397408
}
398409
410+
private fun renderJson(value: Any, indentLevel: Int): String {
411+
val indent = " ".repeat(indentLevel)
412+
val childIndent = " ".repeat(indentLevel + 1)
413+
return when (value) {
414+
is String -> "\"" + escapeJson(value) + "\""
415+
is Boolean, is Number -> value.toString()
416+
is Map<*, *> -> when (value.isEmpty()) {
417+
true -> "{}"
418+
false -> value.entries.joinToString(
419+
separator = ",\n${'$'}childIndent",
420+
prefix = "{\n${'$'}childIndent",
421+
postfix = "\n${'$'}indent}",
422+
) { (k, v) -> "\"${'$'}k\": " + renderJson(v!!, indentLevel + 1) }
423+
}
424+
else -> "\"" + escapeJson(value.toString()) + "\""
425+
}
426+
}
427+
399428
private fun escapeJson(s: String): String =
400429
s.replace("\\", "\\\\").replace("\"", "\\\"")
401430
}

plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,50 @@ class GenerateSnapshotTestsTaskTest {
146146
)
147147
}
148148

149+
@Test
150+
fun `generated sidecar places preview location fields in context block`() {
151+
val content = generateAndRead(packageTrees = listOf("com.example"))
152+
153+
assertTrue(content.contains("val context = linkedMapOf<String, Any>("))
154+
assertTrue(content.contains("\"image_file_name\" to screenshotId"))
155+
assertTrue(content.contains("\"class_name\" to preview.declaringClass"))
156+
assertTrue(content.contains("\"method_name\" to preview.methodName"))
157+
assertTrue(content.contains("metadata[\"context\"] = context"))
158+
}
159+
160+
@Test
161+
fun `generated sidecar places appearance inputs in tags block`() {
162+
val content = generateAndRead(packageTrees = listOf("com.example"))
163+
164+
assertTrue(content.contains("val tags = linkedMapOf<String, Any>()"))
165+
assertTrue(content.contains("if (info.name.isNotBlank()) tags[\"preview_name\"] = info.name"))
166+
assertTrue(content.contains("if (info.locale.isNotBlank()) tags[\"locale\"] = info.locale"))
167+
assertTrue(content.contains("if (info.device.isNotBlank()) tags[\"device\"] = info.device"))
168+
assertTrue(content.contains("if (info.fontScale != 1f) tags[\"font_scale\"] = info.fontScale"))
169+
assertTrue(content.contains("if (info.apiLevel != -1) tags[\"api_level\"] = info.apiLevel"))
170+
assertTrue(content.contains("if (info.widthDp > 0) tags[\"width_dp\"] = info.widthDp"))
171+
assertTrue(content.contains("if (info.heightDp > 0) tags[\"height_dp\"] = info.heightDp"))
172+
assertTrue(content.contains("if (info.showSystemUi) tags[\"show_system_ui\"] = true"))
173+
assertTrue(content.contains("if (info.showBackground) tags[\"show_background\"] = true"))
174+
assertTrue(content.contains("if (tags.isNotEmpty()) metadata[\"tags\"] = tags"))
175+
}
176+
177+
@Test
178+
fun `generated sidecar maps night mode to color_mode enum`() {
179+
val content = generateAndRead(packageTrees = listOf("com.example"))
180+
181+
assertTrue(content.contains("when (info.uiMode and UI_MODE_NIGHT_MASK) {"))
182+
assertTrue(content.contains("UI_MODE_NIGHT_YES -> metadata[\"color_mode\"] = \"dark\""))
183+
assertTrue(content.contains("UI_MODE_NIGHT_NO -> metadata[\"color_mode\"] = \"light\""))
184+
}
185+
186+
@Test
187+
fun `generated sidecar does not emit legacy night_mode field`() {
188+
val content = generateAndRead(packageTrees = listOf("com.example"))
189+
190+
assertFalse(content.contains("metadata[\"night_mode\"]"))
191+
}
192+
149193
@Test
150194
fun `parseMajorVersion extracts major from standard semver`() {
151195
assertEquals(1, parseMajorVersion("1.3.5"))

0 commit comments

Comments
 (0)