Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Foundation
import SwiftUI
import SnapshotPreferences

struct OSVersionView: View {
var body: some View {
Expand Down Expand Up @@ -35,5 +36,6 @@ struct OSVersionView: View {
struct OSVersionView_Previews: PreviewProvider {
static var previews: some View {
OSVersionView()
.snapshotGroup(.module)
}
}
2 changes: 0 additions & 2 deletions Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ struct RideShareButtonView_Previews: PreviewProvider {
.previewLayout(.sizeThatFits)
.padding()
.previewDisplayName("Ride Share Button View - Light")
// This should never show as a diff
.snapshotDiffThreshold(1.0)
#if os(iOS)
.snapshotRenderingMode(.coreAnimation)
#endif
Expand Down
2 changes: 2 additions & 0 deletions Examples/DemoApp/DemoModule/FeatureCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import SnapshotPreferences

struct FeatureCardView: View {
var imageName: String
Expand Down Expand Up @@ -45,5 +46,6 @@ struct FeatureCardView_Previews: PreviewProvider {
description: "This is a description of the feature and its benefits.")
.previewLayout(.sizeThatFits)
.padding()
.snapshotGroup("Card Views")
}
}
2 changes: 2 additions & 0 deletions Examples/DemoApp/DemoModule/InfoCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import SnapshotPreferences

struct InfoCardView: View {
var title: String
Expand Down Expand Up @@ -49,5 +50,6 @@ struct InfoCardView_Previews: PreviewProvider {
.previewLayout(.sizeThatFits)
.padding()
}
.snapshotGroup("Card Views")
}
}
2 changes: 2 additions & 0 deletions Examples/DemoApp/DemoModule/ProductCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import SnapshotPreferences

struct ProductCardView: View {
var imageName: String
Expand Down Expand Up @@ -45,5 +46,6 @@ struct ProductCardView_Previews: PreviewProvider {
ProductCardView(imageName: "product-image", productName: "Sample Product 2", price: 49.99)
.previewLayout(.sizeThatFits)
}
.snapshotGroup("Card Views")
}
}
2 changes: 2 additions & 0 deletions Examples/DemoApp/DemoModule/TripCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import SwiftUI
import SnapshotPreferences

struct TripCardView: View {
var destination: String
Expand Down Expand Up @@ -51,5 +52,6 @@ struct TripCardView_Previews: PreviewProvider {
imageName: "product-image")
.previewLayout(.device)
.padding()
.snapshotGroup("Card Views")
}
}
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Each `.json` sidecar contains the following fields:
| Field | Description |
| --- | --- |
| `display_name` | Snapshot name shown in Sentry. Generated from the preview name, file path, and module so exported filenames stay stable and unambiguous. |
| `group` | Grouping key Sentry uses to organize related snapshots. Generated from the preview name, file path, and module. |
| `group` | Grouping key Sentry uses to organize related snapshots. Generated from the preview name, file path, and module by default. |
| `diff_threshold` | Allowed visual difference for this snapshot. See details below. |
| `tags` | Optional key-value pairs used to filter and group snapshots in Sentry. |
| `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. |
Expand All @@ -158,6 +158,8 @@ SnapshotPreviews adds these `context` keys by default when values are available:

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.

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.

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.

```swift
Expand All @@ -167,6 +169,7 @@ import SnapshotPreferences
MapPreview()
.snapshotTags(["screen": "map"])
.snapshotAdditionalContext(["fixture": "city-route"])
.snapshotGroup("Navigation")
.snapshotDiffThreshold(0.05)
}
```
Expand Down Expand Up @@ -218,6 +221,7 @@ Link the `SnapshotPreferences` product to your app target to customize individua
| `.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. |
| `.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. |
| `.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. |
| `.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. |
| `.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. |

### Variants
Expand Down
5 changes: 5 additions & 0 deletions Sources/SnapshotPreferences/EmergeModifierFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class EmergeModifierState: NSObject {
appStoreSnapshot = nil
tags = [:]
additionalContext = [:]
groupOverride = nil
}

var expansionPreference: Bool?
Expand All @@ -36,6 +37,7 @@ class EmergeModifierState: NSObject {
var appStoreSnapshot: Bool?
var tags: [String: String] = [:]
var additionalContext: [String: SnapshotMetadataValue] = [:]
var groupOverride: SnapshotGroup?
}

@objc(EmergeModifierFinder)
Expand Down Expand Up @@ -64,5 +66,8 @@ class EmergeModifierFinder: NSObject {
.onPreferenceChange(SnapshotAdditionalContextPreferenceKey.self, perform: { value in
EmergeModifierState.shared.additionalContext = value
})
.onPreferenceChange(SnapshotGroupPreferenceKey.self, perform: { value in
EmergeModifierState.shared.groupOverride = value
})
}
}
26 changes: 26 additions & 0 deletions Sources/SnapshotPreferences/SnapshotMetadataPreference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ struct SnapshotTagsPreferenceKey: PreferenceKey {
}
}

struct SnapshotGroupPreferenceKey: PreferenceKey {
static var defaultValue: SnapshotGroup? = nil

static func reduce(value: inout SnapshotGroup?, nextValue: () -> SnapshotGroup?) {
value = nextValue() ?? value
}
}

struct SnapshotAdditionalContextPreferenceKey: PreferenceKey {
static var defaultValue: [String: SnapshotMetadataValue] = [:]

Expand Down Expand Up @@ -45,4 +53,22 @@ extension View {
value: SnapshotMetadataValue.dictionary(from: context)
)
}

/// Overrides the top-level `group` field in the exported snapshot sidecar.
///
/// If the same group modifier is set more than once, the later modifier value is used.
/// This changes only JSON sidecar metadata — exported PNG/JSON filenames are unaffected.
public func snapshotGroup(_ group: String) -> some View {
snapshotGroup(.custom(group))
}

/// Overrides the top-level `group` field in the exported snapshot sidecar using a strategy.
///
/// `.default` keeps the generated group, `.custom` uses the provided name, and `.module`
/// groups by the preview container's module name. If the same group modifier is set more
/// than once, the later modifier value is used. This changes only JSON sidecar metadata —
/// exported PNG/JSON filenames are unaffected.
public func snapshotGroup(_ group: SnapshotGroup) -> some View {
preference(key: SnapshotGroupPreferenceKey.self, value: group)
}
}
9 changes: 5 additions & 4 deletions Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public class AppKitRenderingStrategy: RenderingStrategy {
window.contentViewController = NSViewController()
window.setContentSize(AppKitContainer.defaultSize)
window.contentViewController = vc
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext in
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride in
DispatchQueue.main.async {
Self.takeSnapshot(mode: mode ?? .nsView, viewController: vc, window: window) { image in
completion(
Expand All @@ -58,7 +58,8 @@ public class AppKitRenderingStrategy: RenderingStrategy {
colorScheme: _colorScheme,
appStoreSnapshot: appStoreSnapshot,
tags: tags,
additionalContext: additionalContext))
additionalContext: additionalContext,
groupOverride: groupOverride))
}
}
}
Expand Down Expand Up @@ -118,7 +119,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
var heightAnchor: NSLayoutConstraint?
var previousHeight: CGFloat?

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

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

didCall = true
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext)
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride)
}

override func updateViewConstraints() {
Expand Down
4 changes: 2 additions & 2 deletions Sources/SnapshotPreviewsCore/ExpandingViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
private var startTime: UInt64?
private var timer: Timer?

public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], Error?) -> Void)? {
public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?, Error?) -> Void)? {
didSet { didCall = false }
}

Expand Down Expand Up @@ -78,7 +78,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
guard !didCall else { return }

didCall = true
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, error)
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride, error)
stopAndResetTimer()
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/SnapshotPreviewsCore/ModifierFinder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ public struct EmergeModifierView: View {
stateMirror?.descendant("additionalContext") as? [String: SnapshotMetadataValue] ?? [:]
}

var groupOverride: SnapshotGroup? {
stateMirror?.descendant("groupOverride") as? SnapshotGroup
}

var supportsExpansion: Bool {
stateMirror?.descendant("expansionPreference") as? Bool ?? true
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/SnapshotPreviewsCore/RenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public struct SnapshotResult {
colorScheme: ColorScheme?,
appStoreSnapshot: Bool?,
tags: [String: String] = [:],
additionalContext: [String: SnapshotMetadataValue] = [:])
additionalContext: [String: SnapshotMetadataValue] = [:],
groupOverride: SnapshotGroup? = nil)
{
self.image = image
self.precision = precision
Expand All @@ -41,6 +42,7 @@ public struct SnapshotResult {
self.appStoreSnapshot = appStoreSnapshot
self.tags = tags
self.additionalContext = additionalContext
self.groupOverride = groupOverride
}

public let image: Result<ImageType, Error>
Expand All @@ -50,6 +52,7 @@ public struct SnapshotResult {
public let appStoreSnapshot: Bool?
public let tags: [String: String]
public let additionalContext: [String: SnapshotMetadataValue]
public let groupOverride: SnapshotGroup?
}

public protocol RenderingStrategy {
Expand Down
4 changes: 2 additions & 2 deletions Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ public class SwiftUIRenderingStrategy: RenderingStrategy {
let image = renderer.nsImage
#endif
if let image {
completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext))
completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride))
} else {
completion(SnapshotResult(image: .failure(RenderingError.failedRendering(.zero)), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext))
completion(SnapshotResult(image: .failure(RenderingError.failedRendering(.zero)), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride))
}
}
}
10 changes: 5 additions & 5 deletions Sources/SnapshotPreviewsCore/View+Snapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,33 @@ extension View {
a11yWrapper: ((UIViewController, UIWindow, PreviewLayout) -> UIView)? = nil,
completion: @escaping (SnapshotResult) -> Void)
{
controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, error in
controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride, error in
guard let controller, let window, let containerVC = controller.parent else {
return
}

if let error {
DispatchQueue.main.async {
completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext))
completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
}
return
}

if async {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: controller.view)
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext))
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
}
} else {
DispatchQueue.main.async {
if let a11yWrapper, let accessibilityEnabled, accessibilityEnabled {
let a11yView = a11yWrapper(controller, window, layout)
let result = Self.takeSnapshot(layout: .sizeThatFits, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: a11yView)
a11yView.removeFromSuperview()
completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext))
completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
} else {
let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: controller.view)
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext))
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/SnapshotSharedModels/SnapshotGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// SnapshotGroup.swift
//

import Foundation

/// Strategy for the top-level `group` field written to a snapshot's JSON sidecar.
public enum SnapshotGroup: Sendable, Equatable {
/// Use the generated group. Equivalent to not overriding the group.
case `default`
/// Use a custom group name.
case custom(String)
/// Use the module name from the preview container's type name.
case module
}
34 changes: 29 additions & 5 deletions Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct SnapshotContext: Sendable {
let colorScheme: String?
let tags: [String: String]
let additionalContext: [String: SnapshotMetadataValue]
let groupOverride: SnapshotGroup?
}

// MARK: - Sidecar Model
Expand Down Expand Up @@ -250,6 +251,33 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation {
)
}

/// Resolves the top-level sidecar `group`, preferring the author-declared override
/// and falling back to the generated canonical group.
static func resolvedGroup(for context: SnapshotContext) -> String {
lazy var fallback = canonicalGroup(
fileId: context.fileId,
typeDisplayName: context.typeDisplayName,
typeName: context.typeName
)

switch context.groupOverride {
case .none, .default:
return fallback
case .custom(let group):
let trimmed = group.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? fallback : trimmed
case .module:
return moduleName(from: context.typeName) ?? fallback
}
}

private static func moduleName(from typeName: String) -> String? {
guard let dotIndex = typeName.firstIndex(of: "."), dotIndex != typeName.startIndex else {
return nil
}
return String(typeName[..<dotIndex])
}

private static func canonicalDisplayName(for context: SnapshotContext) -> String {
if let previewDisplayName = context.previewDisplayName, !previewDisplayName.isEmpty {
return previewDisplayName
Expand All @@ -274,11 +302,7 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation {
let jsonFileName = context.sidecarFileName

let displayName = Self.canonicalDisplayName(for: context)
let group = Self.canonicalGroup(
fileId: context.fileId,
typeDisplayName: context.typeDisplayName,
typeName: context.typeName
)
let group = Self.resolvedGroup(for: context)
let exportDir = exportDirectoryURL

guard case .success(let image) = result.image else { return }
Expand Down
Loading
Loading