Skip to content

Commit 74b9ab9

Browse files
Cameron Cookeclaude
andauthored
feat(snapshots): Add snapshot group sidecar override (#271)
* feat(snapshots): Add snapshotGroup modifier to override sidecar group Introduce a `.snapshotGroup(...)` view modifier that lets authors override the top-level `group` field in the exported JSON sidecar. Accepts a custom string or a `SnapshotGroup` strategy: `.default` keeps the generated group, `.custom` uses a provided name, and `.module` groups by the preview container's module name. Previously the sidecar `group` was always derived from the preview name, file path, and module with no way to influence it. The override is plumbed from the preference key through the rendering strategies and resolved during export, with empty or whitespace-only custom values falling back to the generated group. Exported filenames and manifest output are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(snapshots): Apply snapshotGroup modifier to demo previews Exercise the new snapshotGroup modifier across the DemoModule card previews by grouping them under "Card Views" and overriding OSVersionView to the module group. Remove the unused snapshotDiffThreshold override from RideShareButton now that grouping covers the demo coverage. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b49dfd0 commit 74b9ab9

20 files changed

Lines changed: 282 additions & 25 deletions

‎Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Foundation
99
import SwiftUI
10+
import SnapshotPreferences
1011

1112
struct OSVersionView: View {
1213
var body: some View {
@@ -35,5 +36,6 @@ struct OSVersionView: View {
3536
struct OSVersionView_Previews: PreviewProvider {
3637
static var previews: some View {
3738
OSVersionView()
39+
.snapshotGroup(.module)
3840
}
3941
}

‎Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ struct RideShareButtonView_Previews: PreviewProvider {
3434
.previewLayout(.sizeThatFits)
3535
.padding()
3636
.previewDisplayName("Ride Share Button View - Light")
37-
// This should never show as a diff
38-
.snapshotDiffThreshold(1.0)
3937
#if os(iOS)
4038
.snapshotRenderingMode(.coreAnimation)
4139
#endif

‎Examples/DemoApp/DemoModule/FeatureCardView.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import SnapshotPreferences
910

1011
struct FeatureCardView: View {
1112
var imageName: String
@@ -45,5 +46,6 @@ struct FeatureCardView_Previews: PreviewProvider {
4546
description: "This is a description of the feature and its benefits.")
4647
.previewLayout(.sizeThatFits)
4748
.padding()
49+
.snapshotGroup("Card Views")
4850
}
4951
}

‎Examples/DemoApp/DemoModule/InfoCardView.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import SnapshotPreferences
910

1011
struct InfoCardView: View {
1112
var title: String
@@ -49,5 +50,6 @@ struct InfoCardView_Previews: PreviewProvider {
4950
.previewLayout(.sizeThatFits)
5051
.padding()
5152
}
53+
.snapshotGroup("Card Views")
5254
}
5355
}

‎Examples/DemoApp/DemoModule/ProductCard.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import SnapshotPreferences
910

1011
struct ProductCardView: View {
1112
var imageName: String
@@ -45,5 +46,6 @@ struct ProductCardView_Previews: PreviewProvider {
4546
ProductCardView(imageName: "product-image", productName: "Sample Product 2", price: 49.99)
4647
.previewLayout(.sizeThatFits)
4748
}
49+
.snapshotGroup("Card Views")
4850
}
4951
}

‎Examples/DemoApp/DemoModule/TripCardView.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import SnapshotPreferences
910

1011
struct TripCardView: View {
1112
var destination: String
@@ -51,5 +52,6 @@ struct TripCardView_Previews: PreviewProvider {
5152
imageName: "product-image")
5253
.previewLayout(.device)
5354
.padding()
55+
.snapshotGroup("Card Views")
5456
}
5557
}

‎README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ Each `.json` sidecar contains the following fields:
149149
| Field | Description |
150150
| --- | --- |
151151
| `display_name` | Snapshot name shown in Sentry. Generated from the preview name, file path, and module so exported filenames stay stable and unambiguous. |
152-
| `group` | Grouping key Sentry uses to organize related snapshots. Generated from the preview name, file path, and module. |
152+
| `group` | Grouping key Sentry uses to organize related snapshots. Generated from the preview name, file path, and module by default. |
153153
| `diff_threshold` | Allowed visual difference for this snapshot. See details below. |
154154
| `tags` | Optional key-value pairs used to filter and group snapshots in Sentry. |
155155
| `context` | Supporting metadata such as test name, simulator info, orientation, color scheme, source line, preview attributes, and any custom context you add. These fields are surfaced on the snapshot detail page in Sentry's UI. |
@@ -158,6 +158,8 @@ SnapshotPreviews adds these `context` keys by default when values are available:
158158

159159
Use `.snapshotAdditionalContext(...)` to add custom fields to `context`. Custom context is shallow-merged into the generated context, so a custom key such as `"test_name"` replaces the generated value. Supported custom values are strings, numbers, booleans, and nested objects.
160160

161+
Use `.snapshotGroup(...)` to override the default `group` behaviour in the Sentry Snapshots UI. Pass a custom string such as `.snapshotGroup("Checkout")`, or a strategy: `.snapshotGroup(.module)` which groups by the preview container's module name.
162+
161163
Use the `.snapshotDiffThreshold(...)` view modifier from the `SnapshotPreferences` product to customize the allowed visual difference for a specific preview. For example, `.snapshotDiffThreshold(0.05)` allows up to a 5% difference for that snapshot.
162164

163165
```swift
@@ -167,6 +169,7 @@ import SnapshotPreferences
167169
MapPreview()
168170
.snapshotTags(["screen": "map"])
169171
.snapshotAdditionalContext(["fixture": "city-route"])
172+
.snapshotGroup("Navigation")
170173
.snapshotDiffThreshold(0.05)
171174
}
172175
```
@@ -218,6 +221,7 @@ Link the `SnapshotPreferences` product to your app target to customize individua
218221
| `.snapshotExpansion(false)` | You want to preserve the visible scroll viewport instead of capturing all scroll content. | By default, scroll views are expanded so the snapshot includes their full content. Setting this to `false` keeps the scroll view at its normal visible height. |
219222
| `.snapshotTags(["area": "checkout"])` | You want searchable labels on the exported snapshot. | Adds top-level `tags` to the JSON sidecar. Repeated tag modifiers merge, and later duplicate keys win. |
220223
| `.snapshotAdditionalContext(["fixture": "loaded"])` | You want extra sidecar metadata for debugging or filtering. | Adds fields to the JSON sidecar `context` object. Repeated context modifiers merge, and later duplicate keys win. Custom keys override generated context keys. |
224+
| `.snapshotGroup("Checkout")` | You want to control how related snapshots are grouped in Sentry. | Overrides the top-level `group` in the JSON sidecar. Also accepts `.snapshotGroup(.module)` to group by the preview container's module name, and `.snapshotGroup(.default)` to keep the generated group. Empty or whitespace-only custom strings fall back to the generated group. Does not change exported filenames or all-image-names manifest output. |
221225
| `.snapshotDiffThreshold(0.05)` | A snapshot has small expected pixel differences. | Sets `diff_threshold` in the exported sidecar for this preview. `0.05` allows up to a 5% changed-pixel share before Sentry marks the image as changed. |
222226

223227
### Variants

‎Sources/SnapshotPreferences/EmergeModifierFinder.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class EmergeModifierState: NSObject {
2727
appStoreSnapshot = nil
2828
tags = [:]
2929
additionalContext = [:]
30+
groupOverride = nil
3031
}
3132

3233
var expansionPreference: Bool?
@@ -36,6 +37,7 @@ class EmergeModifierState: NSObject {
3637
var appStoreSnapshot: Bool?
3738
var tags: [String: String] = [:]
3839
var additionalContext: [String: SnapshotMetadataValue] = [:]
40+
var groupOverride: SnapshotGroup?
3941
}
4042

4143
@objc(EmergeModifierFinder)
@@ -64,5 +66,8 @@ class EmergeModifierFinder: NSObject {
6466
.onPreferenceChange(SnapshotAdditionalContextPreferenceKey.self, perform: { value in
6567
EmergeModifierState.shared.additionalContext = value
6668
})
69+
.onPreferenceChange(SnapshotGroupPreferenceKey.self, perform: { value in
70+
EmergeModifierState.shared.groupOverride = value
71+
})
6772
}
6873
}

‎Sources/SnapshotPreferences/SnapshotMetadataPreference.swift‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ struct SnapshotTagsPreferenceKey: PreferenceKey {
1414
}
1515
}
1616

17+
struct SnapshotGroupPreferenceKey: PreferenceKey {
18+
static var defaultValue: SnapshotGroup? = nil
19+
20+
static func reduce(value: inout SnapshotGroup?, nextValue: () -> SnapshotGroup?) {
21+
value = nextValue() ?? value
22+
}
23+
}
24+
1725
struct SnapshotAdditionalContextPreferenceKey: PreferenceKey {
1826
static var defaultValue: [String: SnapshotMetadataValue] = [:]
1927

@@ -45,4 +53,22 @@ extension View {
4553
value: SnapshotMetadataValue.dictionary(from: context)
4654
)
4755
}
56+
57+
/// Overrides the top-level `group` field in the exported snapshot sidecar.
58+
///
59+
/// If the same group modifier is set more than once, the later modifier value is used.
60+
/// This changes only JSON sidecar metadata — exported PNG/JSON filenames are unaffected.
61+
public func snapshotGroup(_ group: String) -> some View {
62+
snapshotGroup(.custom(group))
63+
}
64+
65+
/// Overrides the top-level `group` field in the exported snapshot sidecar using a strategy.
66+
///
67+
/// `.default` keeps the generated group, `.custom` uses the provided name, and `.module`
68+
/// groups by the preview container's module name. If the same group modifier is set more
69+
/// than once, the later modifier value is used. This changes only JSON sidecar metadata —
70+
/// exported PNG/JSON filenames are unaffected.
71+
public func snapshotGroup(_ group: SnapshotGroup) -> some View {
72+
preference(key: SnapshotGroupPreferenceKey.self, value: group)
73+
}
4874
}

‎Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public class AppKitRenderingStrategy: RenderingStrategy {
4747
window.contentViewController = NSViewController()
4848
window.setContentSize(AppKitContainer.defaultSize)
4949
window.contentViewController = vc
50-
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext in
50+
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride in
5151
DispatchQueue.main.async {
5252
Self.takeSnapshot(mode: mode ?? .nsView, viewController: vc, window: window) { image in
5353
completion(
@@ -58,7 +58,8 @@ public class AppKitRenderingStrategy: RenderingStrategy {
5858
colorScheme: _colorScheme,
5959
appStoreSnapshot: appStoreSnapshot,
6060
tags: tags,
61-
additionalContext: additionalContext))
61+
additionalContext: additionalContext,
62+
groupOverride: groupOverride))
6263
}
6364
}
6465
}
@@ -118,7 +119,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
118119
var heightAnchor: NSLayoutConstraint?
119120
var previousHeight: CGFloat?
120121

121-
public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue]) -> Void)? {
122+
public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?) -> Void)? {
122123
didSet { didCall = false }
123124
}
124125

@@ -168,7 +169,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
168169
guard !didCall else { return }
169170

170171
didCall = true
171-
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext)
172+
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride)
172173
}
173174

174175
override func updateViewConstraints() {

0 commit comments

Comments
 (0)