From 8eacc386a3fdb1eea166dbebb79b97ab1b3534ea Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Apr 2026 10:33:23 +0200 Subject: [PATCH 1/2] 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) --- .../snapshot/GenerateSnapshotTestsTask.kt | 61 ++++++++++++++----- .../snapshot/GenerateSnapshotTestsTaskTest.kt | 44 +++++++++++++ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt index 1b38a3e2..3aa24689 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt @@ -104,6 +104,7 @@ abstract class GenerateSnapshotTestsTask : DefaultTask() { package $PACKAGE_NAME import android.content.res.Configuration.UI_MODE_NIGHT_MASK +import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -357,23 +358,33 @@ class $CLASS_NAME( val imagesDir = File(snapshotDir, "images") imagesDir.mkdirs() val info = preview.previewInfo - val metadata = linkedMapOf( - "display_name" to screenshotId.removePrefix(preview.declaringClass + "."), + + val tags = linkedMapOf() + if (info.name.isNotBlank()) tags["preview_name"] = info.name + if (info.locale.isNotBlank()) tags["locale"] = info.locale + if (info.device.isNotBlank()) tags["device"] = info.device + if (info.fontScale != 1f) tags["font_scale"] = info.fontScale + if (info.apiLevel != -1) tags["api_level"] = info.apiLevel + if (info.widthDp > 0) tags["width_dp"] = info.widthDp + if (info.heightDp > 0) tags["height_dp"] = info.heightDp + if (info.showSystemUi) tags["show_system_ui"] = true + if (info.showBackground) tags["show_background"] = true + + val context = linkedMapOf( "image_file_name" to screenshotId, "class_name" to preview.declaringClass, "method_name" to preview.methodName, ) + + val metadata = linkedMapOf( + "display_name" to screenshotId.removePrefix(preview.declaringClass + "."), + ) if (info.group.isNotBlank()) metadata["group"] = info.group - if (info.name.isNotBlank()) metadata["preview_name"] = info.name - if (info.locale.isNotBlank()) metadata["locale"] = info.locale - if (info.device.isNotBlank()) metadata["device"] = info.device - metadata["night_mode"] = (info.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES) - if (info.fontScale != 1f) metadata["font_scale"] = info.fontScale - if (info.apiLevel != -1) metadata["api_level"] = info.apiLevel - if (info.widthDp > 0) metadata["width_dp"] = info.widthDp - if (info.heightDp > 0) metadata["height_dp"] = info.heightDp - if (info.showSystemUi) metadata["show_system_ui"] = true - if (info.showBackground) metadata["show_background"] = true + + when (info.uiMode and UI_MODE_NIGHT_MASK) { + UI_MODE_NIGHT_YES -> metadata["color_mode"] = "dark" + UI_MODE_NIGHT_NO -> metadata["color_mode"] = "light" + } val diffThreshold: Float? = runCatching { val declaring = Class.forName(preview.declaringClass) @@ -386,15 +397,33 @@ class $CLASS_NAME( }.getOrNull() if (diffThreshold != null && diffThreshold != 0f) metadata["diff_threshold"] = diffThreshold - val json = metadata.entries.joinToString(",\n ", prefix = "{\n ", postfix = "\n}") { (k, v) -> - if (v is String) "\"" + k + "\": \"" + escapeJson(v) + "\"" - else "\"" + k + "\": " + v - } + if (tags.isNotEmpty()) metadata["tags"] = tags + metadata["context"] = context + + val json = renderJson(metadata, 0) val sidecarName = "Paparazzi_Preview_Test_" + screenshotId.lowercase(Locale.US).replace("\\s".toRegex(), "_") File(imagesDir, "${'$'}{sidecarName}.json").writeText(json) } + private fun renderJson(value: Any, indentLevel: Int): String { + val indent = " ".repeat(indentLevel) + val childIndent = " ".repeat(indentLevel + 1) + return when (value) { + is String -> "\"" + escapeJson(value) + "\"" + is Boolean, is Number -> value.toString() + is Map<*, *> -> when (value.isEmpty()) { + true -> "{}" + false -> value.entries.joinToString( + separator = ",\n${'$'}childIndent", + prefix = "{\n${'$'}childIndent", + postfix = "\n${'$'}indent}", + ) { (k, v) -> "\"${'$'}k\": " + renderJson(v!!, indentLevel + 1) } + } + else -> "\"" + escapeJson(value.toString()) + "\"" + } + } + private fun escapeJson(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"") } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt index a93e6bad..ee9d1213 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt @@ -146,6 +146,50 @@ class GenerateSnapshotTestsTaskTest { ) } + @Test + fun `generated sidecar places preview location fields in context block`() { + val content = generateAndRead(packageTrees = listOf("com.example")) + + assertTrue(content.contains("val context = linkedMapOf(")) + assertTrue(content.contains("\"image_file_name\" to screenshotId")) + assertTrue(content.contains("\"class_name\" to preview.declaringClass")) + assertTrue(content.contains("\"method_name\" to preview.methodName")) + assertTrue(content.contains("metadata[\"context\"] = context")) + } + + @Test + fun `generated sidecar places appearance inputs in tags block`() { + val content = generateAndRead(packageTrees = listOf("com.example")) + + assertTrue(content.contains("val tags = linkedMapOf()")) + assertTrue(content.contains("if (info.name.isNotBlank()) tags[\"preview_name\"] = info.name")) + assertTrue(content.contains("if (info.locale.isNotBlank()) tags[\"locale\"] = info.locale")) + assertTrue(content.contains("if (info.device.isNotBlank()) tags[\"device\"] = info.device")) + assertTrue(content.contains("if (info.fontScale != 1f) tags[\"font_scale\"] = info.fontScale")) + assertTrue(content.contains("if (info.apiLevel != -1) tags[\"api_level\"] = info.apiLevel")) + assertTrue(content.contains("if (info.widthDp > 0) tags[\"width_dp\"] = info.widthDp")) + assertTrue(content.contains("if (info.heightDp > 0) tags[\"height_dp\"] = info.heightDp")) + assertTrue(content.contains("if (info.showSystemUi) tags[\"show_system_ui\"] = true")) + assertTrue(content.contains("if (info.showBackground) tags[\"show_background\"] = true")) + assertTrue(content.contains("if (tags.isNotEmpty()) metadata[\"tags\"] = tags")) + } + + @Test + fun `generated sidecar maps night mode to color_mode enum`() { + val content = generateAndRead(packageTrees = listOf("com.example")) + + assertTrue(content.contains("when (info.uiMode and UI_MODE_NIGHT_MASK) {")) + assertTrue(content.contains("UI_MODE_NIGHT_YES -> metadata[\"color_mode\"] = \"dark\"")) + assertTrue(content.contains("UI_MODE_NIGHT_NO -> metadata[\"color_mode\"] = \"light\"")) + } + + @Test + fun `generated sidecar does not emit legacy night_mode field`() { + val content = generateAndRead(packageTrees = listOf("com.example")) + + assertFalse(content.contains("metadata[\"night_mode\"]")) + } + @Test fun `parseMajorVersion extracts major from standard semver`() { assertEquals(1, parseMajorVersion("1.3.5")) From 67d690464fe0e2a40852cb13699a1838acfdb64b Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Apr 2026 18:04:08 +0200 Subject: [PATCH 2/2] feat(snapshots): Move ui_mode into sidecar tags block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the uiMode → light/dark mapping from the top-level metadata field `color_mode` into the `tags` map, keyed as `ui_mode`. This aligns the field with the other appearance inputs (locale, device, font_scale, etc.) and matches the ingestion schema's expectation that preview configuration travels as tags rather than first-class metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../android/gradle/snapshot/GenerateSnapshotTestsTask.kt | 9 ++++----- .../gradle/snapshot/GenerateSnapshotTestsTaskTest.kt | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt b/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt index 3aa24689..0c47abb2 100644 --- a/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt +++ b/plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt @@ -369,6 +369,10 @@ class $CLASS_NAME( if (info.heightDp > 0) tags["height_dp"] = info.heightDp if (info.showSystemUi) tags["show_system_ui"] = true if (info.showBackground) tags["show_background"] = true + when (info.uiMode and UI_MODE_NIGHT_MASK) { + UI_MODE_NIGHT_YES -> tags["ui_mode"] = "dark" + UI_MODE_NIGHT_NO -> tags["ui_mode"] = "light" + } val context = linkedMapOf( "image_file_name" to screenshotId, @@ -381,11 +385,6 @@ class $CLASS_NAME( ) if (info.group.isNotBlank()) metadata["group"] = info.group - when (info.uiMode and UI_MODE_NIGHT_MASK) { - UI_MODE_NIGHT_YES -> metadata["color_mode"] = "dark" - UI_MODE_NIGHT_NO -> metadata["color_mode"] = "light" - } - val diffThreshold: Float? = runCatching { val declaring = Class.forName(preview.declaringClass) val method = declaring.declaredMethods.firstOrNull { it.name == preview.methodName } diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt index ee9d1213..113a106c 100644 --- a/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTaskTest.kt @@ -175,12 +175,12 @@ class GenerateSnapshotTestsTaskTest { } @Test - fun `generated sidecar maps night mode to color_mode enum`() { + fun `generated sidecar places ui_mode in tags block`() { val content = generateAndRead(packageTrees = listOf("com.example")) assertTrue(content.contains("when (info.uiMode and UI_MODE_NIGHT_MASK) {")) - assertTrue(content.contains("UI_MODE_NIGHT_YES -> metadata[\"color_mode\"] = \"dark\"")) - assertTrue(content.contains("UI_MODE_NIGHT_NO -> metadata[\"color_mode\"] = \"light\"")) + assertTrue(content.contains("UI_MODE_NIGHT_YES -> tags[\"ui_mode\"] = \"dark\"")) + assertTrue(content.contains("UI_MODE_NIGHT_NO -> tags[\"ui_mode\"] = \"light\"")) } @Test