Skip to content

feat(snapshot): Write per-image sidecar metadata during test execution#1127

Merged
runningcode merged 19 commits intomainfrom
no/snapshot-sidecar-metadata
Mar 31, 2026
Merged

feat(snapshot): Write per-image sidecar metadata during test execution#1127
runningcode merged 19 commits intomainfrom
no/snapshot-sidecar-metadata

Conversation

@runningcode
Copy link
Copy Markdown
Contributor

@runningcode runningcode commented Mar 26, 2026

Summary

  • Writes a companion JSON sidecar for each rendered snapshot image during Paparazzi test execution
  • The sidecar includes display_name, image_file_name, className, methodName, and optional fields like group, locale, device, nightMode, fontScale, apiLevel, dimensions, and UI flags
  • Uses Paparazzi's default snapshot directory (paparazzi.snapshot.dir) for sidecar output
  • Automatically wires the upload task's snapshotsPath and sets the sentry.snapshot.output system property on Test tasks

#skip-changelog

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 26, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


This PR will not appear in the changelog.


🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 26, 2026

Messages
📖 Do not forget to update Sentry-docs with your feature once the pull request gets approved.

Generated by 🚫 dangerJS against 1ecb044

@runningcode runningcode marked this pull request as ready for review March 26, 2026 16:00
}

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

Comment on lines +341 to +343
val snapshotDir = File(System.getProperty("paparazzi.snapshot.dir"))
val imagesDir = File(snapshotDir, "images")
imagesDir.mkdirs()
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.

Comment on lines +41 to +51
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)
configureTestTask { it.systemProperty("paparazzi.test.record", "true") }
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 plugin unconditionally sets paparazzi.test.record=true on the main test task, which forces existing user Paparazzi tests into record mode, breaking their verification behavior.
Severity: HIGH

Suggested Fix

The paparazzi.test.record system property should not be set on the main test task. Instead, it should be configured only for the specific, generated Sentry snapshot test. This could be achieved by creating a dedicated Gradle task for the Sentry snapshot test or by finding a way to apply system properties to a single test class within the larger test task.

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/SentrySnapshotPlugin.kt#L39-L51

Potential issue: The `SentrySnapshotPlugin` unconditionally sets the system property
`paparazzi.test.record` to `true` for the entire variant-level test task, such as
`testDebugUnitTest`. This property is JVM-wide for the test fork, affecting all tests,
not just those generated by Sentry. As a result, any existing Paparazzi tests within the
same module are forced into "record mode". This causes them to overwrite their golden
snapshot files instead of performing verification, silently breaking the user's existing
test suite. The generated Sentry test itself is unaffected as it uses different logic
based on `paparazzi.test.verify`.

@runningcode runningcode force-pushed the no/snapshot-sidecar-metadata branch from c71b78a to 6906dcb Compare March 27, 2026 14:29
Comment thread plugin-build/gradle.properties Outdated
Comment thread plugin-build/src/main/kotlin/io/sentry/android/gradle/AndroidComponentsConfig.kt Outdated
@runningcode runningcode force-pushed the no/snapshot-sidecar-metadata branch from 1cf4262 to 59cf660 Compare March 27, 2026 15:13
@runningcode runningcode force-pushed the no/snapshot-sidecar-metadata branch from aed4be7 to e460156 Compare March 31, 2026 06:51
runningcode and others added 9 commits March 31, 2026 13:21
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>
- 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>
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>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
runningcode and others added 9 commits March 31, 2026 13:21
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
@runningcode runningcode force-pushed the no/snapshot-sidecar-metadata branch from 38025e0 to e2c8a67 Compare March 31, 2026 11:24
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

// 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

@runningcode runningcode merged commit a51e5be into main Mar 31, 2026
21 checks passed
@runningcode runningcode deleted the no/snapshot-sidecar-metadata branch March 31, 2026 11:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants