Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
eeae814
feat(snapshot): Write per-image sidecar metadata during test execution
runningcode Mar 26, 2026
167a58b
fix(snapshot): Fix compile errors in generated sidecar metadata code
runningcode Mar 26, 2026
cfd6516
feat(snapshot): Include full preview configuration in sidecar metadata
runningcode Mar 26, 2026
3f11ec6
fix(snapshot): Use HtmlReportWriter recording mode for sentry images
runningcode Mar 26, 2026
ba4c155
fix(snapshot): Make sentry snapshot output directory variant-aware
runningcode Mar 26, 2026
8db3b5b
chore: Apply spotless formatting to remaining files
runningcode Mar 26, 2026
91bc8f9
fix(snapshot): Use Paparazzi's default snapshot dir for sidecar metadata
runningcode Mar 27, 2026
31bb4b3
fix(snapshot): Remove hardcoded snapshotsPath from upload task
runningcode Mar 27, 2026
815d501
chore: Apply spotless formatting
runningcode Mar 27, 2026
2caa985
release: 6.4.0-alpha.3
runningcode Mar 27, 2026
d49ba86
fix(snapshot): Use short name for sidecar display_name
runningcode Mar 30, 2026
4e80fd8
chore: Apply spotless formatting
runningcode Mar 30, 2026
cc01abb
fix(snapshot): Remove forced record mode from SentrySnapshotPlugin
runningcode Mar 30, 2026
fb5d697
fix(snapshot): Preserve original case in sidecar filenames
runningcode Mar 31, 2026
20af204
chore: Apply spotless formatting
runningcode Mar 31, 2026
11d8b61
release: 6.4.0-alpha.4
runningcode Mar 31, 2026
67c9dc9
fix(snapshot): Lowercase sidecar filenames to match Paparazzi images
runningcode Mar 31, 2026
e2c8a67
fix(snapshot): Use symbolic constants for night mode detection
runningcode Mar 31, 2026
1ecb044
chore: Apply spotless formatting
runningcode Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# Changelog

## Unreleased
## 6.4.0-alpha.4

### Internal Changes 🔧

#### Deps

- Update CLI to v3.3.5 by @github-actions in [#1132](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1132)
- Update CLI to v3.3.4 by @github-actions in [#1122](https://github.com/getsentry/sentry-android-gradle-plugin/pull/1122)

## 6.4.0-alpha.3

### Dependencies

Expand Down
2 changes: 1 addition & 1 deletion plugin-build/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ org.gradle.parallel=true

name = sentry-android-gradle-plugin
group = io.sentry
version = 6.3.0
version = 6.4.0-alpha.4
sdk_version = 8.37.1

# publication pom properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,11 @@ private fun ApplicationVariant.configureSnapshotsTasks(
cliExecutable: Provider<String>,
sentryOrg: String?,
sentryProject: String?,
) {
): TaskProvider<SentryUploadSnapshotsTask> {
val variant = AndroidVariant74(this)
val sentryProps = getPropertiesFilePath(project, variant)

SentryUploadSnapshotsTask.register(
return SentryUploadSnapshotsTask.register(
project = project,
extension = extension,
sentryTelemetryProvider = sentryTelemetryProvider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ import app.cash.paparazzi.TestName
import app.cash.paparazzi.detectEnvironment
import com.android.ide.common.rendering.api.SessionParams
import com.android.resources.*
import java.io.File
import java.util.Locale
import kotlin.math.ceil
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -219,6 +221,7 @@ private object PaparazziPreviewRule {
return Paparazzi(
environment = detectEnvironment().copy(compileSdkVersion = previewApiLevel),
deviceConfig = DeviceConfigBuilder.build(preview.previewInfo),
theme = "android:Theme.Translucent.NoTitleBar",
supportsRtl = true,
showSystemUi = previewInfo.showSystemUi,
renderingMode = when {
Expand Down Expand Up @@ -328,7 +331,47 @@ class $CLASS_NAME(
}
}
}

writeSidecarMetadata(screenshotId, preview)
}

private fun writeSidecarMetadata(
screenshotId: String,
preview: ComposablePreview<AndroidPreviewInfo>,
) {
val snapshotDir = File(System.getProperty("paparazzi.snapshot.dir"))
Comment thread
cursor[bot] marked this conversation as resolved.
val imagesDir = File(snapshotDir, "images")
imagesDir.mkdirs()
Comment on lines +342 to +344
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The task to generate snapshot sidecar files writes to a different directory (src/test/snapshots/images/) than the upload task reads from (build/sentry-snapshots/), causing the upload to fail.
Severity: HIGH

Suggested Fix

Update the snapshotsPath configuration for the SentryUploadSnapshotsTask in AndroidComponentsConfig.kt. It should be configured to use the same path derived from System.getProperty("paparazzi.snapshot.dir") that the GenerateSnapshotTestsTask uses for writing the files, ensuring both tasks point to the same directory.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location:
plugin-build/src/main/kotlin/io/sentry/android/gradle/snapshot/GenerateSnapshotTestsTask.kt#L341-L343

Potential issue: The `GenerateSnapshotTestsTask` writes sidecar metadata JSON files to
the directory specified by the `paparazzi.snapshot.dir` system property, which defaults
to `src/test/snapshots/images/`. However, the `SentryUploadSnapshotsTask` is configured
in `AndroidComponentsConfig.kt` to look for these files in a completely different
location: `build/sentry-snapshots/<variant>/images/`. Because there is no mechanism to
copy the files between these two directories, the upload task will either fail its input
validation because the directory does not exist, or it will find no files to upload.
This completely breaks the new sidecar metadata feature.

Did we get this right? 👍 / 👎 to inform future reviews.

val info = preview.previewInfo
val metadata = linkedMapOf<String, Any>(
"display_name" to screenshotId.removePrefix(preview.declaringClass + "."),
"image_file_name" to screenshotId,
"className" to preview.declaringClass,
Comment thread
sentry[bot] marked this conversation as resolved.
"methodName" to preview.methodName,
)
if (info.group.isNotBlank()) metadata["group"] = info.group
if (info.name.isNotBlank()) metadata["previewName"] = info.name
if (info.locale.isNotBlank()) metadata["locale"] = info.locale
if (info.device.isNotBlank()) metadata["device"] = info.device
metadata["nightMode"] = (info.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES)
if (info.fontScale != 1f) metadata["fontScale"] = info.fontScale
if (info.apiLevel != -1) metadata["apiLevel"] = info.apiLevel
if (info.widthDp > 0) metadata["widthDp"] = info.widthDp
if (info.heightDp > 0) metadata["heightDp"] = info.heightDp
if (info.showSystemUi) metadata["showSystemUi"] = true
if (info.showBackground) metadata["showBackground"] = true

val json = metadata.entries.joinToString(",\n ", prefix = "{\n ", postfix = "\n}") { (k, v) ->
if (v is String) "\"" + k + "\": \"" + escapeJson(v) + "\""
else "\"" + k + "\": " + v
}
val sidecarName = "Paparazzi_Preview_Test_" +
screenshotId.lowercase(Locale.US).replace("\\s".toRegex(), "_")
File(imagesDir, "${'$'}{sidecarName}.json").writeText(json)
Comment thread
cursor[bot] marked this conversation as resolved.
}

private fun escapeJson(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete JSON escape misses control characters

Low Severity

The escapeJson function only escapes backslashes and double quotes, but JSON requires escaping of all control characters (U+0000–U+001F) including \n, \r, \t, \b, and \f. If any preview metadata field (e.g., group, name, device) contains these characters, the generated sidecar JSON would be malformed and sentry-cli could fail to parse it.

Fix in Cursor Fix in Web

}
"""
.trimStart()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.sentry.android.gradle.snapshot

import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.HostTestBuilder
import com.android.build.api.variant.HostTestBuilder.Companion.UNIT_TEST_TYPE
import com.android.build.gradle.BaseExtension
import io.sentry.android.gradle.util.AgpVersions
import kotlin.jvm.java
Expand Down Expand Up @@ -35,19 +35,21 @@ class SentrySnapshotPlugin : Plugin<Project> {
// Right now it seems we only have HostTestBuilder.UNIT_TEST_TYPE as the key but we are
// creating screenshot tests like HostTestBuilder.SCREENSHOT_TEST_TYPE
// We should adjust this once the API is stable and documented.
variant.hostTests[HostTestBuilder.UNIT_TEST_TYPE]
// Using `sources?.kotlin` is broken so we have to use sources?.java:
// https://issuetracker.google.com/issues/268248348
?.sources
?.java
?.addGeneratedSourceDirectory(generateTask, GenerateSnapshotTestsTask::outputDir)
variant.hostTests[UNIT_TEST_TYPE]?.apply {
sources.java?.addGeneratedSourceDirectory(
generateTask,
GenerateSnapshotTestsTask::outputDir,
)
}
} else {
// `unitTest` is deprecated, the replacement above is complex
@Suppress("DEPRECATION_ERROR")
variant.unitTest
?.sources
?.java
?.addGeneratedSourceDirectory(generateTask, GenerateSnapshotTestsTask::outputDir)
// `unitTest` is deprecated, the replacement above is complex
variant.unitTest?.apply {
sources.java?.addGeneratedSourceDirectory(
generateTask,
GenerateSnapshotTestsTask::outputDir,
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ private const val SQUARE = "id:wearos_square"
// region UI mode constants
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/content/res/Configuration.java

private const val UI_MODE_NIGHT_YES: Int = 32
internal const val UI_MODE_NIGHT_MASK: Int = 0x30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Newly added UI_MODE_NIGHT_MASK constant is unused in plugin

Low Severity

UI_MODE_NIGHT_MASK was added as internal in PreviewConstants.kt but is never referenced anywhere in the plugin's own code. The generated test code imports UI_MODE_NIGHT_MASK from android.content.res.Configuration instead, making this constant dead code.

Fix in Cursor Fix in Web

internal const val UI_MODE_NIGHT_YES: Int = 0x20
private const val UI_MODE_TYPE_NORMAL: Int = 1

// endregion
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package io.sentry.android.gradle.snapshot

import java.io.File
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.gradle.testfixtures.ProjectBuilder
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

class GenerateSnapshotTestsTaskTest {

@get:Rule val tmpDir = TemporaryFolder()

@Test
fun `generates test file in correct package directory`() {
val task = createTask(packageTrees = listOf("com.example"))

task.generate()

val outputDir = task.outputDir.get().asFile
val expectedFile = File(outputDir, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
assertTrue(expectedFile.exists())
}

@Test
fun `cleans output directory on re-run`() {
val task = createTask(packageTrees = listOf("com.example"))
val outputDir = task.outputDir.get().asFile

// Create a stale file in the output directory
val staleDir = File(outputDir, "io/sentry/snapshot")
staleDir.mkdirs()
val staleFile = File(staleDir, "OldFile.kt")
staleFile.writeText("old content")
assertTrue(staleFile.exists())

task.generate()

assertFalse(staleFile.exists())
val expectedFile = File(outputDir, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
assertTrue(expectedFile.exists())
}

@Test
fun `generated file contains correct package declaration`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(content.contains("package io.sentry.snapshot"))
}

@Test
fun `generated file scans configured package tree`() {
val content = generateAndRead(packageTrees = listOf("com.example.app"))

assertTrue(content.contains(".scanPackageTrees(\"com.example.app\")"))
}

@Test
fun `generated file scans multiple package trees`() {
val content =
generateAndRead(packageTrees = listOf("com.example.feature1", "com.example.feature2"))

assertTrue(
content.contains(".scanPackageTrees(\"com.example.feature1\", \"com.example.feature2\")")
)
}

@Test
fun `generated file includes private previews when enabled`() {
val content =
generateAndRead(packageTrees = listOf("com.example"), includePrivatePreviews = true)

assertTrue(content.contains(".includePrivatePreviews()"))
}

@Test
fun `generated file excludes private previews by default`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertFalse(content.contains(".includePrivatePreviews()"))
}

@Test
fun `generated file contains parameterized test runner`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(content.contains("@RunWith(Parameterized::class)"))
assertTrue(content.contains("class ComposablePreviewSnapshotTest"))
}

@Test
fun `generated sidecar metadata uses short display name`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(
content.contains(
"\"display_name\" to screenshotId.removePrefix(preview.declaringClass + \".\")"
)
)
}

@Test
fun `generated sidecar metadata uses full screenshotId for image file name`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(content.contains("\"image_file_name\" to screenshotId"))
}

@Test
fun `generated file writes sidecar json to images directory`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(content.contains("val imagesDir = File(snapshotDir, \"images\")"))
assertTrue(content.contains("File(imagesDir, \"\${sidecarName}.json\").writeText(json)"))
}

@Test
fun `generated sidecar filename is lowercased to match Paparazzi image filenames`() {
val content = generateAndRead(packageTrees = listOf("com.example"))

assertTrue(
content.contains("screenshotId.lowercase(Locale.US).replace(\"\\\\s\".toRegex(), \"_\")")
)
}

private fun generateAndRead(
packageTrees: List<String>,
includePrivatePreviews: Boolean = false,
): String {
val task = createTask(packageTrees, includePrivatePreviews)
task.generate()
val file =
File(task.outputDir.get().asFile, "io/sentry/snapshot/ComposablePreviewSnapshotTest.kt")
return file.readText()
}

private fun createTask(
packageTrees: List<String>,
includePrivatePreviews: Boolean = false,
): GenerateSnapshotTestsTask {
val project = ProjectBuilder.builder().build()
return project.tasks
.register("testGenerateSnapshotTests", GenerateSnapshotTestsTask::class.java) { task ->
task.includePrivatePreviews.set(includePrivatePreviews)
task.packageTrees.set(packageTrees)
task.outputDir.set(tmpDir.newFolder("output"))
}
.get()
}
}
2 changes: 1 addition & 1 deletion sentry-kotlin-compiler-plugin/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ org.gradle.parallel=true

GROUP = io.sentry
POM_ARTIFACT_ID = sentry-kotlin-compiler-plugin
VERSION_NAME = 6.3.0
VERSION_NAME = 6.4.0-alpha.4

# publication pom properties
POM_NAME=Sentry Kotlin Compiler Plugin
Expand Down
Loading