From a695847acda7be6e73ac3636e48721d5829cbe86 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 15 Jun 2026 16:36:32 -0700 Subject: [PATCH 1/2] 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) --- README.md | 6 +- .../EmergeModifierFinder.swift | 5 + .../SnapshotMetadataPreference.swift | 26 ++++ .../AppKitRenderingStrategy.swift | 9 +- .../ExpandingViewController.swift | 4 +- .../SnapshotPreviewsCore/ModifierFinder.swift | 4 + .../RenderingStrategy.swift | 5 +- .../SwiftUIRenderingStrategy.swift | 4 +- .../SnapshotPreviewsCore/View+Snapshot.swift | 10 +- .../SnapshotSharedModels/SnapshotGroup.swift | 15 +++ .../SnapshotCIExportCoordinator.swift | 34 ++++- Sources/SnapshottingTests/SnapshotTest.swift | 3 +- .../SnapshotMetadataPreferenceTests.swift | 46 +++++++ .../SnapshotCIExportCoordinatorTests.swift | 124 +++++++++++++++++- 14 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 Sources/SnapshotSharedModels/SnapshotGroup.swift diff --git a/README.md b/README.md index 31f0ef7..b2defe7 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 @@ -167,6 +169,7 @@ import SnapshotPreferences MapPreview() .snapshotTags(["screen": "map"]) .snapshotAdditionalContext(["fixture": "city-route"]) + .snapshotGroup("Navigation") .snapshotDiffThreshold(0.05) } ``` @@ -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 diff --git a/Sources/SnapshotPreferences/EmergeModifierFinder.swift b/Sources/SnapshotPreferences/EmergeModifierFinder.swift index 8a16d06..64c3323 100644 --- a/Sources/SnapshotPreferences/EmergeModifierFinder.swift +++ b/Sources/SnapshotPreferences/EmergeModifierFinder.swift @@ -27,6 +27,7 @@ class EmergeModifierState: NSObject { appStoreSnapshot = nil tags = [:] additionalContext = [:] + groupOverride = nil } var expansionPreference: Bool? @@ -36,6 +37,7 @@ class EmergeModifierState: NSObject { var appStoreSnapshot: Bool? var tags: [String: String] = [:] var additionalContext: [String: SnapshotMetadataValue] = [:] + var groupOverride: SnapshotGroup? } @objc(EmergeModifierFinder) @@ -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 + }) } } diff --git a/Sources/SnapshotPreferences/SnapshotMetadataPreference.swift b/Sources/SnapshotPreferences/SnapshotMetadataPreference.swift index 5191b29..36ca855 100644 --- a/Sources/SnapshotPreferences/SnapshotMetadataPreference.swift +++ b/Sources/SnapshotPreferences/SnapshotMetadataPreference.swift @@ -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] = [:] @@ -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) + } } diff --git a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift index 2e778cd..62d8467 100644 --- a/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift @@ -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( @@ -58,7 +58,8 @@ public class AppKitRenderingStrategy: RenderingStrategy { colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, - additionalContext: additionalContext)) + additionalContext: additionalContext, + groupOverride: groupOverride)) } } } @@ -118,7 +119,7 @@ final class AppKitContainer: NSHostingController, 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 } } @@ -168,7 +169,7 @@ final class AppKitContainer: NSHostingController, 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() { diff --git a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift index f36df9f..afe891c 100644 --- a/Sources/SnapshotPreviewsCore/ExpandingViewController.swift +++ b/Sources/SnapshotPreviewsCore/ExpandingViewController.swift @@ -31,7 +31,7 @@ public final class ExpandingViewController: UIHostingController Void)? { + public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?, Error?) -> Void)? { didSet { didCall = false } } @@ -78,7 +78,7 @@ public final class ExpandingViewController: UIHostingController @@ -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 { diff --git a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift index 9372175..1dc2741 100644 --- a/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift +++ b/Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift @@ -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)) } } } diff --git a/Sources/SnapshotPreviewsCore/View+Snapshot.swift b/Sources/SnapshotPreviewsCore/View+Snapshot.swift index a4e6072..e368300 100644 --- a/Sources/SnapshotPreviewsCore/View+Snapshot.swift +++ b/Sources/SnapshotPreviewsCore/View+Snapshot.swift @@ -50,14 +50,14 @@ 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 } @@ -65,7 +65,7 @@ extension View { 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 { @@ -73,10 +73,10 @@ extension View { 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)) } } } diff --git a/Sources/SnapshotSharedModels/SnapshotGroup.swift b/Sources/SnapshotSharedModels/SnapshotGroup.swift new file mode 100644 index 0000000..de3f61f --- /dev/null +++ b/Sources/SnapshotSharedModels/SnapshotGroup.swift @@ -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 +} diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index 9b7c73e..917fcd8 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -35,6 +35,7 @@ struct SnapshotContext: Sendable { let colorScheme: String? let tags: [String: String] let additionalContext: [String: SnapshotMetadataValue] + let groupOverride: SnapshotGroup? } // MARK: - Sidecar Model @@ -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[.. String { if let previewDisplayName = context.previewDisplayName, !previewDisplayName.isEmpty { return previewDisplayName @@ -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 } diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 1d8c165..9496607 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -353,7 +353,8 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { accessibilityEnabled: result.accessibilityEnabled, colorScheme: colorSchemeValue, tags: result.tags, - additionalContext: result.additionalContext) + additionalContext: result.additionalContext, + groupOverride: result.groupOverride) coordinator.enqueueExport(result: result, context: context) } else { do { diff --git a/Tests/SnapshotPreferencesTests/SnapshotMetadataPreferenceTests.swift b/Tests/SnapshotPreferencesTests/SnapshotMetadataPreferenceTests.swift index 8ecc833..5c432f8 100644 --- a/Tests/SnapshotPreferencesTests/SnapshotMetadataPreferenceTests.swift +++ b/Tests/SnapshotPreferencesTests/SnapshotMetadataPreferenceTests.swift @@ -37,6 +37,52 @@ final class SnapshotMetadataPreferenceTests: XCTestCase { XCTAssertEqual(value["is_retry"], .bool(true)) } + func testGroupPreferenceDefaultsToNil() { + XCTAssertNil(SnapshotGroupPreferenceKey.defaultValue) + } + + func testGroupPreferenceLaterValueWins() { + var value = SnapshotGroupPreferenceKey.defaultValue + + SnapshotGroupPreferenceKey.reduce(value: &value) { .custom("First") } + SnapshotGroupPreferenceKey.reduce(value: &value) { .custom("Second") } + + XCTAssertEqual(value, .custom("Second")) + } + + func testGroupPreferenceKeepsCurrentValueWhenNextValueIsNil() { + var value = SnapshotGroupPreferenceKey.defaultValue + + SnapshotGroupPreferenceKey.reduce(value: &value) { .custom("Checkout") } + SnapshotGroupPreferenceKey.reduce(value: &value) { nil } + + XCTAssertEqual(value, .custom("Checkout")) + } + + func testGroupPreferenceStoresCustomString() { + var value = SnapshotGroupPreferenceKey.defaultValue + + SnapshotGroupPreferenceKey.reduce(value: &value) { .custom("Checkout") } + + XCTAssertEqual(value, .custom("Checkout")) + } + + func testGroupPreferenceStoresDefaultStrategy() { + var value = SnapshotGroupPreferenceKey.defaultValue + + SnapshotGroupPreferenceKey.reduce(value: &value) { .default } + + XCTAssertEqual(value, .default) + } + + func testGroupPreferenceStoresModuleStrategy() { + var value = SnapshotGroupPreferenceKey.defaultValue + + SnapshotGroupPreferenceKey.reduce(value: &value) { .module } + + XCTAssertEqual(value, .module) + } + func testMetadataValueConvertsSupportedPublicTypes() { let metadata = SnapshotMetadataValue.dictionary(from: [ "string": "value", diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift index 98225dc..b4d2901 100644 --- a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -252,6 +252,124 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { XCTAssertEqual(json["group"] as? String, "MyModule.TestView_Previews") } + // MARK: - Group Override + + func testSidecarGroupUsesTrimmedCustomOverride() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_CustomGroup", + groupOverride: .custom(" Checkout ") + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + + XCTAssertEqual(json["group"] as? String, "Checkout") + } + + func testSidecarGroupFallsBackToGeneratedGroupForEmptyCustomOverride() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_EmptyGroup", + groupOverride: .custom(" ") + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + + XCTAssertEqual(json["group"] as? String, "Test View") + } + + func testSidecarGroupDefaultOverrideMatchesGeneratedGroup() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_DefaultGroup", + groupOverride: .default + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + + XCTAssertEqual(json["group"] as? String, "Test View") + } + + func testSidecarGroupModuleOverrideUsesModuleName() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_ModuleGroup", + typeName: "MyModule.TestView_Previews", + fileId: "Feature/TestView.swift", + line: 42, + groupOverride: .module + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + + XCTAssertEqual(json["group"] as? String, "MyModule") + } + + func testSidecarGroupModuleOverrideFallsBackForUnqualifiedTypeName() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_ModuleFallback", + typeName: "TestView_Previews", + groupOverride: .module + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + + XCTAssertEqual(json["group"] as? String, "Test View") + } + + func testAdditionalContextGroupKeyStaysNestedUnderContext() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_NestedGroupKey", + additionalContext: ["group": .string("nested-group")] + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forSidecarFileName: context.sidecarFileName) + let nestedContext = try XCTUnwrap(json["context"] as? [String: Any]) + + XCTAssertEqual(json["group"] as? String, "Test View") + XCTAssertEqual(nestedContext["group"] as? String, "nested-group") + } + + func testGroupOverrideDoesNotChangeExportedFileNames() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let baseFileName = makeRawBaseFileName( + previewDisplayName: "Dark Mode", + displayNameOccurrenceCount: 1 + ) + let context = makeContext( + baseFileName: baseFileName, + groupOverride: .custom("Checkout") + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + XCTAssertEqual(context.imageFileName, "Test_View_Dark_Mode.png") + XCTAssertEqual(context.sidecarFileName, "Test_View_Dark_Mode.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("Test_View_Dark_Mode.png").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("Test_View_Dark_Mode.json").path)) + } + func testSidecarNestsContextFieldsUnderContextKey() throws { let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) let context = makeContext( @@ -485,7 +603,8 @@ extension SnapshotCIExportCoordinatorTests { diffThreshold: Float? = nil, colorScheme: String? = nil, tags: [String: String] = [:], - additionalContext: [String: SnapshotMetadataValue] = [:] + additionalContext: [String: SnapshotMetadataValue] = [:], + groupOverride: SnapshotGroup? = nil ) -> SnapshotContext { SnapshotContext( imageFileName: FileNameUtils.imageFileName(from: baseFileName), @@ -503,7 +622,8 @@ extension SnapshotCIExportCoordinatorTests { accessibilityEnabled: nil, colorScheme: colorScheme, tags: tags, - additionalContext: additionalContext + additionalContext: additionalContext, + groupOverride: groupOverride ) } From f0e2b3f0b44cbfb7f8179799439a958e0c548049 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 15 Jun 2026 16:37:04 -0700 Subject: [PATCH 2/2] 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. --- Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift | 2 ++ Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift | 2 -- Examples/DemoApp/DemoModule/FeatureCardView.swift | 2 ++ Examples/DemoApp/DemoModule/InfoCardView.swift | 2 ++ Examples/DemoApp/DemoModule/ProductCard.swift | 2 ++ Examples/DemoApp/DemoModule/TripCardView.swift | 2 ++ 6 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift b/Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift index fe032a7..52538a8 100644 --- a/Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift +++ b/Examples/DemoApp/DemoApp/TestViews/OSVersionView.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import SnapshotPreferences struct OSVersionView: View { var body: some View { @@ -35,5 +36,6 @@ struct OSVersionView: View { struct OSVersionView_Previews: PreviewProvider { static var previews: some View { OSVersionView() + .snapshotGroup(.module) } } diff --git a/Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift b/Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift index 422df9d..2204be5 100644 --- a/Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift +++ b/Examples/DemoApp/DemoApp/TestViews/RideShareButton.swift @@ -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 diff --git a/Examples/DemoApp/DemoModule/FeatureCardView.swift b/Examples/DemoApp/DemoModule/FeatureCardView.swift index 861062c..9bec31f 100644 --- a/Examples/DemoApp/DemoModule/FeatureCardView.swift +++ b/Examples/DemoApp/DemoModule/FeatureCardView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SnapshotPreferences struct FeatureCardView: View { var imageName: String @@ -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") } } diff --git a/Examples/DemoApp/DemoModule/InfoCardView.swift b/Examples/DemoApp/DemoModule/InfoCardView.swift index c1aa9c3..07cdb36 100644 --- a/Examples/DemoApp/DemoModule/InfoCardView.swift +++ b/Examples/DemoApp/DemoModule/InfoCardView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SnapshotPreferences struct InfoCardView: View { var title: String @@ -49,5 +50,6 @@ struct InfoCardView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .padding() } + .snapshotGroup("Card Views") } } diff --git a/Examples/DemoApp/DemoModule/ProductCard.swift b/Examples/DemoApp/DemoModule/ProductCard.swift index a3f0c60..6c1d6ed 100644 --- a/Examples/DemoApp/DemoModule/ProductCard.swift +++ b/Examples/DemoApp/DemoModule/ProductCard.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SnapshotPreferences struct ProductCardView: View { var imageName: String @@ -45,5 +46,6 @@ struct ProductCardView_Previews: PreviewProvider { ProductCardView(imageName: "product-image", productName: "Sample Product 2", price: 49.99) .previewLayout(.sizeThatFits) } + .snapshotGroup("Card Views") } } diff --git a/Examples/DemoApp/DemoModule/TripCardView.swift b/Examples/DemoApp/DemoModule/TripCardView.swift index 7556134..42b314c 100644 --- a/Examples/DemoApp/DemoModule/TripCardView.swift +++ b/Examples/DemoApp/DemoModule/TripCardView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import SnapshotPreferences struct TripCardView: View { var destination: String @@ -51,5 +52,6 @@ struct TripCardView_Previews: PreviewProvider { imageName: "product-image") .previewLayout(.device) .padding() + .snapshotGroup("Card Views") } }