From 67121902c54afa7c338491d5374232bc48c7eff2 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 1 Apr 2026 15:36:14 +0100 Subject: [PATCH 01/11] Add image export by way of `SNAPSHOTS_EXPORT_DIR` env var --- Package.swift | 3 + .../SnapshotCIExportCoordinator.swift | 276 ++++++++++++ Sources/SnapshottingTests/SnapshotTest.swift | 218 ++++++++-- .../SnapshotCIExportCoordinatorTests.swift | 402 ++++++++++++++++++ 4 files changed, 863 insertions(+), 36 deletions(-) create mode 100644 Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift create mode 100644 Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift diff --git a/Package.swift b/Package.swift index f5871c72..a5e5174d 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,9 @@ let package = Package( .testTarget( name: "SnapshotPreviewsTests", dependencies: ["SnapshotPreviewsCore"]), + .testTarget( + name: "SnapshottingTestsTests", + dependencies: ["SnapshottingTests", "SnapshotPreviewsCore"]), ], cxxLanguageStandard: .cxx11 ) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift new file mode 100644 index 00000000..e2cad48b --- /dev/null +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -0,0 +1,276 @@ +// +// SnapshotCIExportCoordinator.swift +// SnapshottingTests +// +// Manages CI export of snapshot PNGs and JSON sidecar metadata +// directly to the filesystem when SNAPSHOTS_EXPORT_DIR is set. +// + +import Foundation +import XCTest +@_implementationOnly import SnapshotPreviewsCore + +// MARK: - Snapshot Context + +struct SnapshotContext: Sendable, Encodable { + let baseFileName: String + let testName: String + let typeName: String + let typeDisplayName: String + let fileId: String? + let line: Int? + let previewDisplayName: String? + let previewIndex: Int + let previewId: String + let orientation: String + let declaredDevice: String? + let simulatorDeviceName: String? + let simulatorModelIdentifier: String? + let precision: Float? + let accessibilityEnabled: Bool? + let colorScheme: String? + let appStoreSnapshot: Bool? +} + +// MARK: - Sidecar Model + +private struct SnapshotCIExportSidecar: Sendable, Encodable { + let context: SnapshotContext + let imageFileName: String + let displayName: String + let group: String + + private enum ExtraKeys: String, CodingKey { + case image_file_name + case display_name + case group + } + + func encode(to encoder: Encoder) throws { + try context.encode(to: encoder) + var container = encoder.container(keyedBy: ExtraKeys.self) + try container.encode(imageFileName, forKey: .image_file_name) + try container.encode(displayName, forKey: .display_name) + try container.encode(group, forKey: .group) + } +} + +// MARK: - Coordinator + +final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { + + static let envKey = "SNAPSHOTS_EXPORT_DIR" + + private let exportDirectoryURL: URL + private let writeQueue: OperationQueue + private let fileManager: FileManager + private let stateLock = NSLock() + private var hasDrained = false + + // MARK: - Shared Instance + + private static var _shared: SnapshotCIExportCoordinator? + + static func sharedIfEnabled( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SnapshotCIExportCoordinator? { + if let _shared { return _shared } + + guard let exportDir = environment[envKey] else { + return nil + } + + let trimmed = exportDir.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + preconditionFailure( + "\(envKey) is set but empty. Provide a valid directory path." + ) + } + + let url: URL + if trimmed.hasPrefix("/") { + url = URL(fileURLWithPath: trimmed, isDirectory: true).standardizedFileURL + } else { + url = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true + ) + .appendingPathComponent(trimmed, isDirectory: true) + .standardizedFileURL + } + + let coordinator = Self(exportDirectoryURL: url) + _shared = coordinator + XCTestObservationCenter.shared.addTestObserver(coordinator) + return coordinator + } + + /// Resets shared state. Exposed for testing only. + static func resetShared() { + if let shared = _shared { + XCTestObservationCenter.shared.removeTestObserver(shared) + } + _shared = nil + } + + // MARK: - Init + + init( + exportDirectoryURL: URL, + fileManager: FileManager = .default, + writeQueue: OperationQueue = .defaultQueue + ) { + self.exportDirectoryURL = exportDirectoryURL + self.fileManager = fileManager + self.writeQueue = writeQueue + + super.init() + + do { + try self.fileManager.createDirectory( + at: exportDirectoryURL, + withIntermediateDirectories: true + ) + } catch { + preconditionFailure( + "Failed to create snapshot export directory at \(exportDirectoryURL.path): \(error)" + ) + } + } + + // MARK: - Filename Sanitization + + static func sanitize(_ raw: String) -> String { + var result = "" + var lastWasUnderscore = false + + for c in raw { + if c.isLetter || c.isNumber || c == "." || c == "-" { + result.append(c) + lastWasUnderscore = false + } else if !lastWasUnderscore { + result.append("_") + lastWasUnderscore = true + } + } + + result = result.trimmingCharacters(in: CharacterSet(charactersIn: "_.-")) + + return result.isEmpty ? "snapshot" : result + } + + // MARK: - Export + + private static func canonicalGroup(for context: SnapshotContext) -> String { + if let fileId = context.fileId, !fileId.isEmpty { + return fileId + } + + if !context.typeDisplayName.isEmpty { + return context.typeDisplayName + } + + return context.typeName + } + + private static func canonicalDisplayName(for context: SnapshotContext) -> String { + if let previewDisplayName = context.previewDisplayName, !previewDisplayName.isEmpty { + return previewDisplayName + } + + if context.fileId != nil, let line = context.line { + return "At line #\(line)" + } + + return String(context.previewIndex) + } + + /// Enqueues a snapshot export (PNG + JSON sidecar) to the export directory. + /// + /// PNG encoding and file writes are dispatched to a concurrent background queue + /// so the calling test can proceed to the next preview immediately. + func enqueueExport( + result: SnapshotResult, + context: SnapshotContext + ) { + let sanitizedName = Self.sanitize(context.baseFileName) + let pngFileName = "\(sanitizedName).png" + let jsonFileName = "\(sanitizedName).json" + + let displayName = Self.canonicalDisplayName(for: context) + let group = Self.canonicalGroup(for: context) + let exportDir = exportDirectoryURL + + guard case .success(let image) = result.image else { return } + + writeQueue.addOperation { + let pngURL = exportDir.appendingPathComponent(pngFileName) + guard let pngData = image.emg.pngData() else { + NSLog("[SnapshotCIExport] Failed to encode PNG for %@", pngFileName) + return + } + do { + try pngData.write(to: pngURL, options: .atomic) + } catch { + NSLog("[SnapshotCIExport] Failed to write PNG %@: %@", pngFileName, "\(error)") + return + } + + let sidecar = SnapshotCIExportSidecar( + context: context, + imageFileName: sanitizedName, + displayName: displayName, + group: group + ) + + let jsonURL = exportDir.appendingPathComponent(jsonFileName) + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(sidecar) + try data.write(to: jsonURL, options: .atomic) + } catch { + NSLog("[SnapshotCIExport] Failed to write sidecar %@: %@", jsonFileName, "\(error)") + } + } + } + + // MARK: - Drain + + /// Waits for all queued PNG and sidecar writes to complete. + /// + /// Called automatically via `testBundleDidFinish`. Safe to call multiple times — + /// only the first call performs the drain. + func drain() { + stateLock.lock() + guard !hasDrained else { + stateLock.unlock() + return + } + hasDrained = true + stateLock.unlock() + + guard fileManager.fileExists(atPath: exportDirectoryURL.path) else { return } + + let semaphore = DispatchSemaphore(value: 0) + writeQueue.addBarrierBlock { + semaphore.signal() + } + semaphore.wait() + } + + // MARK: - XCTestObservation + + func testBundleDidFinish(_ testBundle: Bundle) { + drain() + } +} + +private extension OperationQueue { + static var defaultQueue: OperationQueue { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 20 + queue.qualityOfService = .userInitiated + return queue + } +} diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 5309e779..a5be3ee7 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -7,8 +7,22 @@ import Foundation @_implementationOnly import SnapshotPreviewsCore +@_exported import enum SwiftUI.ColorScheme import XCTest +extension ColorScheme { + var stringValue: String { + switch self { + case .light: + return "light" + case .dark: + return "dark" + @unknown default: + return "unknown" + } + } +} + /// A test class for generating snapshots of Xcode previews. /// /// This class is designed to discover SwiftUI previews, render them, and generate snapshot images for testing purposes. @@ -30,6 +44,18 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { open class func excludedSnapshotPreviews() -> [String]? { nil } + + /// Override to render each preview in multiple color schemes. + /// + /// When `nil` (the default), each preview renders once with whatever color scheme + /// it naturally uses — no override is applied. When set to e.g. `[.light, .dark]`, + /// each preview renders once per scheme, producing separate snapshot files suffixed + /// with the scheme name. + /// + /// - Returns: An optional array of `ColorScheme` values, or `nil` for no override. + open class func colorSchemes() -> [ColorScheme]? { + nil + } #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) open class func setupA11y() -> ((UIViewController, UIWindow, PreviewLayout) -> UIView)? { @@ -63,20 +89,48 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { static private var previews: [SnapshotPreviewsCore.PreviewType] = [] static private var previewCountForFileId: [String: Int] = [:] + static private var previewDisplayNameCountByGroup: [String: [String: Int]] = [:] + + static func resolvedFileNameComponent( + fileId: String?, + line: Int?, + previewDisplayName: String?, + previewIndex: Int, + duplicateDisplayNameCount: Int + ) -> String { + if let previewDisplayName, !previewDisplayName.isEmpty, duplicateDisplayNameCount <= 1 { + return previewDisplayName + } + + if let fileId, !fileId.isEmpty, let line { + return "line-\(line)" + } + + return String(previewIndex) + } - /// Discovers all relevant previews based on inclusion and exclusion filters. Subclasses should NOT override this method. - /// - /// This method uses `FindPreviews` to locate all previews, applying any specified filters. - /// - Returns: An array of `DiscoveredPreview` objects representing the found previews. @MainActor override class func discoverPreviews() -> [DiscoveredPreview] { + _ = SnapshotCIExportCoordinator.sharedIfEnabled() + previews = FindPreviews.findPreviews(included: Self.snapshotPreviews(), excluded: Self.excludedSnapshotPreviews()) - - for preview in previews { - guard let fileId = preview.fileID else { continue } + previewCountForFileId = [:] + previewDisplayNameCountByGroup = [:] + + for previewType in previews { + if let fileId = previewType.fileID { previewCountForFileId[fileId, default: 0] += 1 + } + + let group = previewType.fileID ?? previewType.typeName + for preview in previewType.previews { + guard let previewDisplayName = preview.displayName, !previewDisplayName.isEmpty else { + continue + } + previewDisplayNameCountByGroup[group, default: [:]][previewDisplayName, default: 0] += 1 + } } - + return previews.map { DiscoveredPreview.from(previewType: $0) } } @@ -88,47 +142,139 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { /// - Parameter discoveredPreview: A `DiscoveredPreviewAndIndex` object representing the preview to be tested. @MainActor override func testPreview(_ discoveredPreview: DiscoveredPreviewAndIndex) { - let previewType = Self.previews.first { $0.typeName == discoveredPreview.preview.typeName } - guard let previewType = previewType else { + guard let previewType = Self.previews.first(where: { $0.typeName == discoveredPreview.preview.typeName }) else { XCTFail("Preview type not found") return } let preview = previewType.previews[discoveredPreview.index] - var result: SnapshotResult? = nil - let strategy: RenderingStrategy - if let renderingStrategy = Self.renderingStrategy { - strategy = renderingStrategy - } else { -#if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) - strategy = Self.makeRenderingStrategy(a11y: Self.setupA11y()) + + // Lazily create the rendering strategy + if Self.renderingStrategy == nil { + #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) + Self.renderingStrategy = Self.makeRenderingStrategy(a11y: Self.setupA11y()) #else - strategy = Self.makeRenderingStrategy() + Self.renderingStrategy = Self.makeRenderingStrategy() #endif - Self.renderingStrategy = strategy - } - let expectation = XCTestExpectation() - strategy.render(preview: preview) { snapshotResult in - result = snapshotResult - expectation.fulfill() - } - wait(for: [expectation], timeout: 10) - guard let result else { - XCTFail("Did not render") - return } + let strategy = Self.renderingStrategy! var typeFileName = previewType.displayName if let fileId = previewType.fileID, let lineNumber = previewType.line { typeFileName = Self.previewCountForFileId[fileId]! > 1 ? "\(fileId):\(lineNumber)" : fileId } - do { - let attachment = try XCTAttachment(image: result.image.get()) - attachment.name = "\(typeFileName)_\(preview.displayName ?? String(discoveredPreview.index))" - attachment.lifetime = .keepAlways - add(attachment) - } catch { - XCTFail("Error \(error)") + + let schemes = Self.colorSchemes() + let renderPasses: [(scheme: ColorScheme?, suffix: String)] = if let schemes { + schemes.map { ($0, "_\($0.stringValue)") } + } else { + [(nil, "")] + } + + for pass in renderPasses { + if let scheme = pass.scheme { + applyColorSchemeOverride(scheme) + } + + var result: SnapshotResult? = nil + let expectation = XCTestExpectation() + strategy.render(preview: preview) { snapshotResult in + result = snapshotResult + expectation.fulfill() + } + wait(for: [expectation], timeout: 10) + guard let result else { + XCTFail("Did not render") + continue + } + + let previewGroup = previewType.fileID ?? previewType.typeName + let duplicateDisplayNameCount = preview.displayName.flatMap { + Self.previewDisplayNameCountByGroup[previewGroup]?[$0] + } ?? 0 + let fileNameComponent = Self.resolvedFileNameComponent( + fileId: previewType.fileID, + line: previewType.line, + previewDisplayName: preview.displayName, + previewIndex: discoveredPreview.index, + duplicateDisplayNameCount: duplicateDisplayNameCount + ) + let baseFileName = "\(typeFileName)_\(fileNameComponent)\(pass.suffix)" + let colorSchemeValue = pass.scheme?.stringValue ?? result.colorScheme?.stringValue + + let context = SnapshotContext( + baseFileName: baseFileName, + testName: name, + typeName: previewType.typeName, + typeDisplayName: previewType.displayName, + fileId: previewType.fileID, + line: previewType.line, + previewDisplayName: preview.displayName, + previewIndex: discoveredPreview.index, + previewId: preview.previewId, + orientation: preview.orientation.id, + declaredDevice: preview.device?.rawValue, + simulatorDeviceName: ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"], + simulatorModelIdentifier: ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + precision: result.precision, + accessibilityEnabled: result.accessibilityEnabled, + colorScheme: colorSchemeValue, + appStoreSnapshot: result.appStoreSnapshot) + + if let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled() { + coordinator.enqueueExport(result: result, context: context) + } else { + do { + let attachment = try XCTAttachment(image: result.image.get()) + attachment.name = baseFileName + attachment.lifetime = .keepAlways + add(attachment) + } catch { + XCTFail("Error \(error)") + } + } + } + + // Reset override after all passes + if schemes != nil { + applyColorSchemeOverride(nil) + } + } +} + +// Color scheme override helpers — kept outside the open class to avoid +// @_implementationOnly deserialization issues with private members. + +@MainActor +private func applyColorSchemeOverride(_ scheme: ColorScheme?) { + #if canImport(UIKit) && !os(watchOS) + let style: UIUserInterfaceStyle + switch scheme { + case .light: + style = .light + case .dark: + style = .dark + case nil: + style = .unspecified + @unknown default: + style = .unspecified + } + for scene in UIApplication.shared.connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows { + window.overrideUserInterfaceStyle = style } } + #elseif canImport(AppKit) && !targetEnvironment(macCatalyst) + switch scheme { + case .light: + NSApplication.shared.appearance = NSAppearance(named: .aqua) + case .dark: + NSApplication.shared.appearance = NSAppearance(named: .darkAqua) + case nil: + NSApplication.shared.appearance = nil + @unknown default: + NSApplication.shared.appearance = nil + } + #endif } diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift new file mode 100644 index 00000000..a3280cee --- /dev/null +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -0,0 +1,402 @@ +// +// SnapshotCIExportCoordinatorTests.swift +// SnapshottingTestsTests +// + +import Foundation +import XCTest +@testable import SnapshottingTests +import SnapshotPreviewsCore + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +final class SnapshotCIExportCoordinatorTests: XCTestCase { + + private var tempDir: URL! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("SnapshotCIExportTests-\(UUID().uuidString)") + SnapshotCIExportCoordinator.resetShared() + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + SnapshotCIExportCoordinator.resetShared() + super.tearDown() + } + + // MARK: - Shared Instance Gating + + func testSharedIfEnabledReturnsNilWhenEnvVarAbsent() { + let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled(environment: [:]) + XCTAssertNil(coordinator) + } + + func testSharedIfEnabledReturnsCoordinatorWhenEnvVarSet() { + let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled( + environment: [SnapshotCIExportCoordinator.envKey: tempDir.path] + ) + XCTAssertNotNil(coordinator) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.path)) + } + + func testSharedIfEnabledReturnsSameInstanceOnRepeatedCalls() { + let env = [SnapshotCIExportCoordinator.envKey: tempDir.path] + let first = SnapshotCIExportCoordinator.sharedIfEnabled(environment: env) + let second = SnapshotCIExportCoordinator.sharedIfEnabled(environment: env) + XCTAssertTrue(first === second, "Should return the same cached instance") + } + + // MARK: - Filename Sanitization + + func testSanitizeReplacesUnsafeCharacters() { + let result = SnapshotCIExportCoordinator.sanitize("My/View:Preview 1") + let unsafeChars = CharacterSet(charactersIn: "/\\: \"'<>|?*") + XCTAssertNil(result.rangeOfCharacter(from: unsafeChars)) + } + + func testSanitizeIsDeterministic() { + let a = SnapshotCIExportCoordinator.sanitize("Some/View:Name") + let b = SnapshotCIExportCoordinator.sanitize("Some/View:Name") + XCTAssertEqual(a, b) + } + + func testSanitizeCollapsesRepeatedUnderscores() { + let result = SnapshotCIExportCoordinator.sanitize("A///B___C") + XCTAssertFalse(result.contains("__")) + } + + func testSanitizeFallsBackForEmptyResult() { + let result = SnapshotCIExportCoordinator.sanitize("///") + XCTAssertEqual(result, "snapshot") + } + + func testSanitizePreservesAlphanumericAndSafeChars() { + let result = SnapshotCIExportCoordinator.sanitize("Hello_World-2.0") + XCTAssertEqual(result, "Hello_World-2.0") + } + + func testResolvedFileNameComponentUsesDisplayNameWhenUnique() { + let component = SnapshotTest.resolvedFileNameComponent( + fileId: nil, + line: nil, + previewDisplayName: "Dark Mode", + previewIndex: 1, + duplicateDisplayNameCount: 1 + ) + + XCTAssertEqual(component, "Dark Mode") + } + + func testResolvedFileNameComponentFallsBackToLineForDuplicatePreviewMacroDisplayNames() { + let component = SnapshotTest.resolvedFileNameComponent( + fileId: "Feature/LoginView.swift", + line: 42, + previewDisplayName: "Dark Mode", + previewIndex: 0, + duplicateDisplayNameCount: 2 + ) + + XCTAssertEqual(component, "line-42") + } + + func testResolvedFileNameComponentFallsBackToIndexForDuplicatePreviewProviderDisplayNames() { + let component = SnapshotTest.resolvedFileNameComponent( + fileId: nil, + line: nil, + previewDisplayName: "Dark Mode", + previewIndex: 3, + duplicateDisplayNameCount: 2 + ) + + XCTAssertEqual(component, "3") + } + + // MARK: - Successful Export + + func testSuccessfulExportWritesPngAndSidecar() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext(baseFileName: "TestView_Preview") + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) + let jsonURL = tempDir.appendingPathComponent("\(sanitized).json") + let pngURL = tempDir.appendingPathComponent("\(sanitized).png") + + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: pngURL.path)) + } + + // MARK: - Sidecar Content + + func testSidecarUsesPreviewDisplayNameAndTypeDisplayNameForPreviewProviderPresentation() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "Login Screen_Dark Mode", + typeName: "MyModule.LoginScreen_Previews", + typeDisplayName: "Login Screen", + previewDisplayName: "Dark Mode" + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["image_file_name"] as? String, SnapshotCIExportCoordinator.sanitize(context.baseFileName)) + XCTAssertEqual(json["display_name"] as? String, "Dark Mode") + XCTAssertEqual(json["group"] as? String, "Login Screen") + } + + func testSidecarGroupPrefersFileIdForPreviewMacro() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "Feature_LoginView.swift_line-42", + typeName: "$s7MyApp11LoginViewV13Preview_42fMf_15LLPreviewRegistryMc", + typeDisplayName: "Login View", + fileId: "Feature/LoginView.swift", + line: 42, + previewDisplayName: nil + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["group"] as? String, "Feature/LoginView.swift") + } + + func testSidecarDisplayNameFallsBackToAtLineForAnonymousPreviewMacroPresentation() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "Feature_LoginView.swift_line-42", + fileId: "Feature/LoginView.swift", + line: 42, + previewDisplayName: nil + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["display_name"] as? String, "At line #42") + } + + func testSidecarDisplayNameFallsBackToIndexForUnnamedPreviewProviderVariantPresentation() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_0", + fileId: nil, + line: nil, + previewDisplayName: nil, + previewId: "0", + previewIndex: 0 + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["display_name"] as? String, "0") + } + + func testSidecarGroupFallsBackToTypeNameWhenPreviewProviderDisplayNameUnavailable() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "MyModule.TestView_Previews_0", + typeName: "MyModule.TestView_Previews", + typeDisplayName: "", + fileId: nil, + line: nil, + previewDisplayName: nil + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["group"] as? String, "MyModule.TestView_Previews") + } + + func testSidecarFlattensContextFields() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext( + baseFileName: "TestView_Preview", + line: 99, + previewId: "7", + colorScheme: "dark" + ) + + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + coordinator.drain() + + let json = try readJSON(forBaseFileName: context.baseFileName) + + XCTAssertEqual(json["typeName"] as? String, context.typeName) + XCTAssertEqual(json["orientation"] as? String, "portrait") + XCTAssertEqual(json["previewId"] as? String, "7") + XCTAssertEqual(json["line"] as? Int, 99) + XCTAssertEqual(json["colorScheme"] as? String, "dark") + XCTAssertNil(json["context"]) + } + + // MARK: - Render Failure + + func testRenderFailureProducesNoFiles() { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext(baseFileName: "TestView_Preview") + + coordinator.enqueueExport(result: makeFailureResult(), context: context) + coordinator.drain() + + let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).png").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + } + + // MARK: - Drain Semantics + + func testDrainIsIdempotent() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext(baseFileName: "TestView_Preview") + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + + coordinator.drain() + coordinator.drain() + + let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + } + + func testDrainOnEmptyQueueDoesNotCrash() { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + coordinator.drain() + } + + // MARK: - Multiple Exports + + func testMultipleExportsProduceIndividualFiles() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + + let contexts = (0..<5).map { i in + makeContext( + baseFileName: "View\(i)_Preview", + typeName: "Module.View\(i)", + previewId: "\(i)", + previewIndex: i + ) + } + + for context in contexts { + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + } + coordinator.drain() + + for context in contexts { + let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).png").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + } + } +} + +// MARK: - Test Helpers + +extension SnapshotCIExportCoordinatorTests { + + private func readJSON(forBaseFileName baseFileName: String) throws -> [String: Any] { + let sanitized = SnapshotCIExportCoordinator.sanitize(baseFileName) + let data = try Data(contentsOf: tempDir.appendingPathComponent("\(sanitized).json")) + return try JSONSerialization.jsonObject(with: data) as! [String: Any] + } + + private func makeContext( + baseFileName: String, + typeName: String = "MyModule.TestView_Previews", + typeDisplayName: String = "Test View", + fileId: String? = nil, + line: Int? = nil, + previewDisplayName: String? = "Preview", + previewId: String = "0", + previewIndex: Int = 0, + colorScheme: String? = nil + ) -> SnapshotContext { + SnapshotContext( + baseFileName: baseFileName, + testName: "-[MyTests testPreview]", + typeName: typeName, + typeDisplayName: typeDisplayName, + fileId: fileId, + line: line, + previewDisplayName: previewDisplayName, + previewIndex: previewIndex, + previewId: previewId, + orientation: "portrait", + declaredDevice: nil, + simulatorDeviceName: nil, + simulatorModelIdentifier: nil, + precision: nil, + accessibilityEnabled: nil, + colorScheme: colorScheme, + appStoreSnapshot: nil + ) + } + + private func makeTestImage() -> ImageType { + #if canImport(UIKit) + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) + return renderer.image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 1, height: 1)) + } + #else + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 1, + pixelsHigh: 1, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )! + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.addRepresentation(rep) + return image + #endif + } + + private func makeSuccessResult() -> SnapshotResult { + SnapshotResult( + image: .success(makeTestImage()), + precision: nil, + accessibilityEnabled: nil, + colorScheme: nil, + appStoreSnapshot: nil + ) + } + + private func makeFailureResult() -> SnapshotResult { + SnapshotResult( + image: .failure(NSError(domain: "test", code: 1)), + precision: nil, + accessibilityEnabled: nil, + colorScheme: nil, + appStoreSnapshot: nil + ) + } +} From ef641a43268a5f246ac36192949585ef715be006 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:17:19 +0100 Subject: [PATCH 02/11] fix(snapshots): Include underscore in filename sanitize keep-set Underscores are valid filename characters and common in Swift type names. Previously they were treated as unsafe and collapsed with adjacent non-kept characters. Now they are preserved as-is. Update test to verify unsafe char collapsing separately from underscore preservation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SnapshotCIExportCoordinator.swift | 2 +- .../SnapshotCIExportCoordinatorTests.swift | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index e2cad48b..df7e086a 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -145,7 +145,7 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { var lastWasUnderscore = false for c in raw { - if c.isLetter || c.isNumber || c == "." || c == "-" { + if c.isLetter || c.isNumber || c == "." || c == "-" || c == "_" { result.append(c) lastWasUnderscore = false } else if !lastWasUnderscore { diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift index a3280cee..99691b5b 100644 --- a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -67,9 +67,14 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { XCTAssertEqual(a, b) } - func testSanitizeCollapsesRepeatedUnderscores() { - let result = SnapshotCIExportCoordinator.sanitize("A///B___C") - XCTAssertFalse(result.contains("__")) + func testSanitizeCollapsesRepeatedUnsafeCharacters() { + let result = SnapshotCIExportCoordinator.sanitize("A///B C") + XCTAssertFalse(result.contains("__"), "Consecutive unsafe chars should collapse to a single underscore") + } + + func testSanitizePreservesExistingUnderscores() { + let result = SnapshotCIExportCoordinator.sanitize("A___B") + XCTAssertEqual(result, "A___B", "Underscores in the input should be preserved as-is") } func testSanitizeFallsBackForEmptyResult() { From 45d9c39ddb01379e7aea00376e58763e72b54698 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:18:07 +0100 Subject: [PATCH 03/11] fix(snapshots): Add @MainActor to shared instance management The _shared static var was read/written without any isolation while hasDrained used NSLock protection. Both production callers (discoverPreviews, testPreview) are already @MainActor, so this makes the contract explicit and prevents future data races. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift | 6 +++--- .../SnapshotCIExportCoordinatorTests.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index df7e086a..fd856f48 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -69,9 +69,9 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { // MARK: - Shared Instance - private static var _shared: SnapshotCIExportCoordinator? + @MainActor private static var _shared: SnapshotCIExportCoordinator? - static func sharedIfEnabled( + @MainActor static func sharedIfEnabled( environment: [String: String] = ProcessInfo.processInfo.environment ) -> SnapshotCIExportCoordinator? { if let _shared { return _shared } @@ -106,7 +106,7 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { } /// Resets shared state. Exposed for testing only. - static func resetShared() { + @MainActor static func resetShared() { if let shared = _shared { XCTestObservationCenter.shared.removeTestObserver(shared) } diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift index 99691b5b..84f82b65 100644 --- a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -14,6 +14,7 @@ import UIKit import AppKit #endif +@MainActor final class SnapshotCIExportCoordinatorTests: XCTestCase { private var tempDir: URL! From d2bab88684d2f0ca337f72cfe7b43349708ac3ab Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:20:29 +0100 Subject: [PATCH 04/11] fix(snapshots): Remove redundant directory-existence guard in drain The init already creates the directory or calls preconditionFailure, so the directory is guaranteed to exist. The guard also had the side-effect of skipping the drain while operations could still be in-flight. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index fd856f48..c99afe1b 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -250,8 +250,6 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { hasDrained = true stateLock.unlock() - guard fileManager.fileExists(atPath: exportDirectoryURL.path) else { return } - let semaphore = DispatchSemaphore(value: 0) writeQueue.addBarrierBlock { semaphore.signal() From ab8f9a5cb891d34bbe8a33b8fce2484199974964 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:20:53 +0100 Subject: [PATCH 05/11] ref(snapshots): Simplify drain with waitUntilAllOperationsAreFinished Replace manual semaphore+barrier pattern with the built-in OperationQueue API that does the same thing. Blocks the calling thread until all enqueued operations complete. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index c99afe1b..0b5876bf 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -250,11 +250,7 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { hasDrained = true stateLock.unlock() - let semaphore = DispatchSemaphore(value: 0) - writeQueue.addBarrierBlock { - semaphore.signal() - } - semaphore.wait() + writeQueue.waitUntilAllOperationsAreFinished() } // MARK: - XCTestObservation From e66fb8e5b3df49d848d2b62f3696f33c828e9c57 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:21:30 +0100 Subject: [PATCH 06/11] fix(snapshots): Use defer for color scheme override cleanup Move the color scheme reset into a defer block so the override is always cleared even if the render loop exits early due to an unexpected error. Previously the reset only ran after normal loop completion. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SnapshottingTests/SnapshotTest.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index a5be3ee7..7f4136fd 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -171,6 +171,12 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { [(nil, "")] } + defer { + if schemes != nil { + applyColorSchemeOverride(nil) + } + } + for pass in renderPasses { if let scheme = pass.scheme { applyColorSchemeOverride(scheme) @@ -234,11 +240,6 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { } } } - - // Reset override after all passes - if schemes != nil { - applyColorSchemeOverride(nil) - } } } From 7b4d63446f73f3e8a01ac845b9fecb71a3613c1c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:22:20 +0100 Subject: [PATCH 07/11] fix(snapshots): Unify grouping key via shared canonicalGroup method The grouping key in discoverPreviews and testPreview used fileID ?? typeName, skipping the typeDisplayName fallback that canonicalGroup uses. This could cause filename collisions when a PreviewProvider has no fileId but a typeDisplayName differing from typeName. Extract canonicalGroup to accept raw parameters and make it internal so both SnapshotTest and the coordinator share the same fallback chain: fileId -> typeDisplayName -> typeName. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SnapshotCIExportCoordinator.swift | 20 +++++++++++++------ Sources/SnapshottingTests/SnapshotTest.swift | 12 +++++++++-- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index 0b5876bf..fff1570a 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -161,16 +161,20 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { // MARK: - Export - private static func canonicalGroup(for context: SnapshotContext) -> String { - if let fileId = context.fileId, !fileId.isEmpty { + static func canonicalGroup( + fileId: String?, + typeDisplayName: String, + typeName: String + ) -> String { + if let fileId, !fileId.isEmpty { return fileId } - if !context.typeDisplayName.isEmpty { - return context.typeDisplayName + if !typeDisplayName.isEmpty { + return typeDisplayName } - return context.typeName + return typeName } private static func canonicalDisplayName(for context: SnapshotContext) -> String { @@ -198,7 +202,11 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { let jsonFileName = "\(sanitizedName).json" let displayName = Self.canonicalDisplayName(for: context) - let group = Self.canonicalGroup(for: context) + let group = Self.canonicalGroup( + fileId: context.fileId, + typeDisplayName: context.typeDisplayName, + typeName: context.typeName + ) 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 7f4136fd..687a9550 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -122,7 +122,11 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { previewCountForFileId[fileId, default: 0] += 1 } - let group = previewType.fileID ?? previewType.typeName + let group = SnapshotCIExportCoordinator.canonicalGroup( + fileId: previewType.fileID, + typeDisplayName: previewType.displayName, + typeName: previewType.typeName + ) for preview in previewType.previews { guard let previewDisplayName = preview.displayName, !previewDisplayName.isEmpty else { continue @@ -194,7 +198,11 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { continue } - let previewGroup = previewType.fileID ?? previewType.typeName + let previewGroup = SnapshotCIExportCoordinator.canonicalGroup( + fileId: previewType.fileID, + typeDisplayName: previewType.displayName, + typeName: previewType.typeName + ) let duplicateDisplayNameCount = preview.displayName.flatMap { Self.previewDisplayNameCountByGroup[previewGroup]?[$0] } ?? 0 From 60db1ec6463331a9858852eeea471afb9d9068ea Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Thu, 2 Apr 2026 08:26:26 +0100 Subject: [PATCH 08/11] fix(snapshots): Sanitize baseFileName at construction, not export time Move filename sanitization to the point where baseFileName is created in testPreview, so SnapshotContext.baseFileName and the sidecar image_file_name field are always consistent. Previously baseFileName stored the raw unsanitized name while image_file_name was sanitized, creating a confusing mismatch for sidecar consumers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SnapshotCIExportCoordinator.swift | 7 +++--- Sources/SnapshottingTests/SnapshotTest.swift | 4 ++- .../SnapshotCIExportCoordinatorTests.swift | 25 ++++++++----------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index fff1570a..b9d59c87 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -197,9 +197,8 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { result: SnapshotResult, context: SnapshotContext ) { - let sanitizedName = Self.sanitize(context.baseFileName) - let pngFileName = "\(sanitizedName).png" - let jsonFileName = "\(sanitizedName).json" + let pngFileName = "\(context.baseFileName).png" + let jsonFileName = "\(context.baseFileName).json" let displayName = Self.canonicalDisplayName(for: context) let group = Self.canonicalGroup( @@ -226,7 +225,7 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { let sidecar = SnapshotCIExportSidecar( context: context, - imageFileName: sanitizedName, + imageFileName: context.baseFileName, displayName: displayName, group: group ) diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 687a9550..a49f5c09 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -213,7 +213,9 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { previewIndex: discoveredPreview.index, duplicateDisplayNameCount: duplicateDisplayNameCount ) - let baseFileName = "\(typeFileName)_\(fileNameComponent)\(pass.suffix)" + let baseFileName = SnapshotCIExportCoordinator.sanitize( + "\(typeFileName)_\(fileNameComponent)\(pass.suffix)" + ) let colorSchemeValue = pass.scheme?.stringValue ?? result.colorScheme?.stringValue let context = SnapshotContext( diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift index 84f82b65..d3f54060 100644 --- a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -133,9 +133,8 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { coordinator.enqueueExport(result: makeSuccessResult(), context: context) coordinator.drain() - let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) - let jsonURL = tempDir.appendingPathComponent("\(sanitized).json") - let pngURL = tempDir.appendingPathComponent("\(sanitized).png") + let jsonURL = tempDir.appendingPathComponent("\(context.baseFileName).json") + let pngURL = tempDir.appendingPathComponent("\(context.baseFileName).png") XCTAssertTrue(FileManager.default.fileExists(atPath: jsonURL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: pngURL.path)) @@ -146,7 +145,7 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { func testSidecarUsesPreviewDisplayNameAndTypeDisplayNameForPreviewProviderPresentation() throws { let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) let context = makeContext( - baseFileName: "Login Screen_Dark Mode", + baseFileName: "Login_Screen_Dark_Mode", typeName: "MyModule.LoginScreen_Previews", typeDisplayName: "Login Screen", previewDisplayName: "Dark Mode" @@ -157,7 +156,7 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { let json = try readJSON(forBaseFileName: context.baseFileName) - XCTAssertEqual(json["image_file_name"] as? String, SnapshotCIExportCoordinator.sanitize(context.baseFileName)) + XCTAssertEqual(json["image_file_name"] as? String, context.baseFileName) XCTAssertEqual(json["display_name"] as? String, "Dark Mode") XCTAssertEqual(json["group"] as? String, "Login Screen") } @@ -267,9 +266,8 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { coordinator.enqueueExport(result: makeFailureResult(), context: context) coordinator.drain() - let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) - XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).png").path)) - XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).png").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).json").path)) } // MARK: - Drain Semantics @@ -282,8 +280,7 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { coordinator.drain() coordinator.drain() - let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) - XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).json").path)) } func testDrainOnEmptyQueueDoesNotCrash() { @@ -311,9 +308,8 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { coordinator.drain() for context in contexts { - let sanitized = SnapshotCIExportCoordinator.sanitize(context.baseFileName) - XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).png").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(sanitized).json").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).png").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).json").path)) } } } @@ -323,8 +319,7 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { extension SnapshotCIExportCoordinatorTests { private func readJSON(forBaseFileName baseFileName: String) throws -> [String: Any] { - let sanitized = SnapshotCIExportCoordinator.sanitize(baseFileName) - let data = try Data(contentsOf: tempDir.appendingPathComponent("\(sanitized).json")) + let data = try Data(contentsOf: tempDir.appendingPathComponent("\(baseFileName).json")) return try JSONSerialization.jsonObject(with: data) as! [String: Any] } From ca879ff1f30ceb08e90e132e5e189414c634ce44 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 3 Apr 2026 09:34:52 +0100 Subject: [PATCH 09/11] fix(snapshots): Remove color scheme override support Remove the branch-added color scheme override API from SnapshotTest. Render each preview once using its natural appearance again so snapshot names and exports no longer fan out by forced light and dark passes. Co-Authored-By: OpenAI Codex --- Sources/SnapshottingTests/SnapshotTest.swift | 186 ++++++------------- 1 file changed, 59 insertions(+), 127 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index a49f5c09..38f8d9f0 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -7,7 +7,7 @@ import Foundation @_implementationOnly import SnapshotPreviewsCore -@_exported import enum SwiftUI.ColorScheme +import enum SwiftUI.ColorScheme import XCTest extension ColorScheme { @@ -45,18 +45,6 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { nil } - /// Override to render each preview in multiple color schemes. - /// - /// When `nil` (the default), each preview renders once with whatever color scheme - /// it naturally uses — no override is applied. When set to e.g. `[.light, .dark]`, - /// each preview renders once per scheme, producing separate snapshot files suffixed - /// with the scheme name. - /// - /// - Returns: An optional array of `ColorScheme` values, or `nil` for no override. - open class func colorSchemes() -> [ColorScheme]? { - nil - } - #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) open class func setupA11y() -> ((UIViewController, UIWindow, PreviewLayout) -> UIView)? { return nil @@ -168,124 +156,68 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { typeFileName = Self.previewCountForFileId[fileId]! > 1 ? "\(fileId):\(lineNumber)" : fileId } - let schemes = Self.colorSchemes() - let renderPasses: [(scheme: ColorScheme?, suffix: String)] = if let schemes { - schemes.map { ($0, "_\($0.stringValue)") } - } else { - [(nil, "")] + var result: SnapshotResult? = nil + let expectation = XCTestExpectation() + strategy.render(preview: preview) { snapshotResult in + result = snapshotResult + expectation.fulfill() } - - defer { - if schemes != nil { - applyColorSchemeOverride(nil) - } + wait(for: [expectation], timeout: 10) + guard let result else { + XCTFail("Did not render") + return } - for pass in renderPasses { - if let scheme = pass.scheme { - applyColorSchemeOverride(scheme) - } - - var result: SnapshotResult? = nil - let expectation = XCTestExpectation() - strategy.render(preview: preview) { snapshotResult in - result = snapshotResult - expectation.fulfill() - } - wait(for: [expectation], timeout: 10) - guard let result else { - XCTFail("Did not render") - continue - } - - let previewGroup = SnapshotCIExportCoordinator.canonicalGroup( - fileId: previewType.fileID, - typeDisplayName: previewType.displayName, - typeName: previewType.typeName - ) - let duplicateDisplayNameCount = preview.displayName.flatMap { - Self.previewDisplayNameCountByGroup[previewGroup]?[$0] - } ?? 0 - let fileNameComponent = Self.resolvedFileNameComponent( - fileId: previewType.fileID, - line: previewType.line, - previewDisplayName: preview.displayName, - previewIndex: discoveredPreview.index, - duplicateDisplayNameCount: duplicateDisplayNameCount - ) - let baseFileName = SnapshotCIExportCoordinator.sanitize( - "\(typeFileName)_\(fileNameComponent)\(pass.suffix)" - ) - let colorSchemeValue = pass.scheme?.stringValue ?? result.colorScheme?.stringValue - - let context = SnapshotContext( - baseFileName: baseFileName, - testName: name, - typeName: previewType.typeName, - typeDisplayName: previewType.displayName, - fileId: previewType.fileID, - line: previewType.line, - previewDisplayName: preview.displayName, - previewIndex: discoveredPreview.index, - previewId: preview.previewId, - orientation: preview.orientation.id, - declaredDevice: preview.device?.rawValue, - simulatorDeviceName: ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"], - simulatorModelIdentifier: ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], - precision: result.precision, - accessibilityEnabled: result.accessibilityEnabled, - colorScheme: colorSchemeValue, - appStoreSnapshot: result.appStoreSnapshot) - - if let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled() { - coordinator.enqueueExport(result: result, context: context) - } else { - do { - let attachment = try XCTAttachment(image: result.image.get()) - attachment.name = baseFileName - attachment.lifetime = .keepAlways - add(attachment) - } catch { - XCTFail("Error \(error)") - } + let previewGroup = SnapshotCIExportCoordinator.canonicalGroup( + fileId: previewType.fileID, + typeDisplayName: previewType.displayName, + typeName: previewType.typeName + ) + let duplicateDisplayNameCount = preview.displayName.flatMap { + Self.previewDisplayNameCountByGroup[previewGroup]?[$0] + } ?? 0 + let fileNameComponent = Self.resolvedFileNameComponent( + fileId: previewType.fileID, + line: previewType.line, + previewDisplayName: preview.displayName, + previewIndex: discoveredPreview.index, + duplicateDisplayNameCount: duplicateDisplayNameCount + ) + let baseFileName = SnapshotCIExportCoordinator.sanitize( + "\(typeFileName)_\(fileNameComponent)" + ) + let colorSchemeValue = result.colorScheme?.stringValue + + let context = SnapshotContext( + baseFileName: baseFileName, + testName: name, + typeName: previewType.typeName, + typeDisplayName: previewType.displayName, + fileId: previewType.fileID, + line: previewType.line, + previewDisplayName: preview.displayName, + previewIndex: discoveredPreview.index, + previewId: preview.previewId, + orientation: preview.orientation.id, + declaredDevice: preview.device?.rawValue, + simulatorDeviceName: ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"], + simulatorModelIdentifier: ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + precision: result.precision, + accessibilityEnabled: result.accessibilityEnabled, + colorScheme: colorSchemeValue, + appStoreSnapshot: result.appStoreSnapshot) + + if let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled() { + coordinator.enqueueExport(result: result, context: context) + } else { + do { + let attachment = try XCTAttachment(image: result.image.get()) + attachment.name = baseFileName + attachment.lifetime = .keepAlways + add(attachment) + } catch { + XCTFail("Error \(error)") } } } } - -// Color scheme override helpers — kept outside the open class to avoid -// @_implementationOnly deserialization issues with private members. - -@MainActor -private func applyColorSchemeOverride(_ scheme: ColorScheme?) { - #if canImport(UIKit) && !os(watchOS) - let style: UIUserInterfaceStyle - switch scheme { - case .light: - style = .light - case .dark: - style = .dark - case nil: - style = .unspecified - @unknown default: - style = .unspecified - } - for scene in UIApplication.shared.connectedScenes { - guard let windowScene = scene as? UIWindowScene else { continue } - for window in windowScene.windows { - window.overrideUserInterfaceStyle = style - } - } - #elseif canImport(AppKit) && !targetEnvironment(macCatalyst) - switch scheme { - case .light: - NSApplication.shared.appearance = NSAppearance(named: .aqua) - case .dark: - NSApplication.shared.appearance = NSAppearance(named: .darkAqua) - case nil: - NSApplication.shared.appearance = nil - @unknown default: - NSApplication.shared.appearance = nil - } - #endif -} From 8c623c52c5474c74392b191490fa829370e0ab0c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Fri, 3 Apr 2026 09:35:18 +0100 Subject: [PATCH 10/11] fix(snapshots): Skip oversized snapshot exports Reject snapshot renders above the 40 million pixel upload limit before writing PNGs to disk. This prevents extremely tall expanding snapshots from producing files that the upload CLI will reject. Co-Authored-By: OpenAI Codex --- Sources/SnapshotPreviewsCore/View+Snapshot.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SnapshotPreviewsCore/View+Snapshot.swift b/Sources/SnapshotPreviewsCore/View+Snapshot.swift index 0cb47e2f..14456fbd 100644 --- a/Sources/SnapshotPreviewsCore/View+Snapshot.swift +++ b/Sources/SnapshotPreviewsCore/View+Snapshot.swift @@ -117,7 +117,8 @@ extension View { window: UIWindow, rootVC: UIViewController, targetView: UIView, - maxSize: Double = 1_000_000) -> Result + maxSize: Double = 1_000_000, + maxTotalPixels: Double = 40_000_000) -> Result { if renderingMode == EmergeRenderingMode.window { let renderer = UIGraphicsImageRenderer(size: window.bounds.size) @@ -155,7 +156,9 @@ extension View { success = rootVC.view.render(size: targetSize, mode: renderingMode, context: ctx) } } - if targetSize.height > maxSize || targetSize.width > maxSize { + let scale = Double(UIScreen.main.scale) + let totalPixels = Double(targetSize.width) * scale * Double(targetSize.height) * scale + if targetSize.height > maxSize || targetSize.width > maxSize || totalPixels > maxTotalPixels { return .failure(RenderingError.maxSize(targetSize)) } let renderer = UIGraphicsImageRenderer(size: targetSize) From c58a343e2aad105ee24eb4d4b954ac26946c35da Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 8 Apr 2026 11:29:54 +0100 Subject: [PATCH 11/11] fix(snapshots): Address PR review feedback - Remove force unwrap of renderingStrategy by restoring if-let pattern - Scope SnapshotContext creation to CI export path only - Replace singleton with static factory + SnapshotTest-owned property Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SnapshotCIExportCoordinator.swift | 17 +----- Sources/SnapshottingTests/SnapshotTest.swift | 58 ++++++++++--------- .../SnapshotCIExportCoordinatorTests.swift | 19 ++---- 3 files changed, 37 insertions(+), 57 deletions(-) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift index b9d59c87..00cde50c 100644 --- a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -67,15 +67,11 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { private let stateLock = NSLock() private var hasDrained = false - // MARK: - Shared Instance + // MARK: - Factory - @MainActor private static var _shared: SnapshotCIExportCoordinator? - - @MainActor static func sharedIfEnabled( + static func createFromEnvironment( environment: [String: String] = ProcessInfo.processInfo.environment ) -> SnapshotCIExportCoordinator? { - if let _shared { return _shared } - guard let exportDir = environment[envKey] else { return nil } @@ -100,19 +96,10 @@ final class SnapshotCIExportCoordinator: NSObject, XCTestObservation { } let coordinator = Self(exportDirectoryURL: url) - _shared = coordinator XCTestObservationCenter.shared.addTestObserver(coordinator) return coordinator } - /// Resets shared state. Exposed for testing only. - @MainActor static func resetShared() { - if let shared = _shared { - XCTestObservationCenter.shared.removeTestObserver(shared) - } - _shared = nil - } - // MARK: - Init init( diff --git a/Sources/SnapshottingTests/SnapshotTest.swift b/Sources/SnapshottingTests/SnapshotTest.swift index 38f8d9f0..506daedf 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -73,9 +73,10 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { } #endif private static var renderingStrategy: RenderingStrategy? = nil + @MainActor private static var ciExportCoordinator: SnapshotCIExportCoordinator? static private var previews: [SnapshotPreviewsCore.PreviewType] = [] - + static private var previewCountForFileId: [String: Int] = [:] static private var previewDisplayNameCountByGroup: [String: [String: Int]] = [:] @@ -99,7 +100,7 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { @MainActor override class func discoverPreviews() -> [DiscoveredPreview] { - _ = SnapshotCIExportCoordinator.sharedIfEnabled() + ciExportCoordinator = SnapshotCIExportCoordinator.createFromEnvironment() previews = FindPreviews.findPreviews(included: Self.snapshotPreviews(), excluded: Self.excludedSnapshotPreviews()) previewCountForFileId = [:] @@ -142,14 +143,17 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { let preview = previewType.previews[discoveredPreview.index] // Lazily create the rendering strategy - if Self.renderingStrategy == nil { + let strategy: RenderingStrategy + if let existing = Self.renderingStrategy { + strategy = existing + } else { #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) - Self.renderingStrategy = Self.makeRenderingStrategy(a11y: Self.setupA11y()) + strategy = Self.makeRenderingStrategy(a11y: Self.setupA11y()) #else - Self.renderingStrategy = Self.makeRenderingStrategy() + strategy = Self.makeRenderingStrategy() #endif + Self.renderingStrategy = strategy } - let strategy = Self.renderingStrategy! var typeFileName = previewType.displayName if let fileId = previewType.fileID, let lineNumber = previewType.line { @@ -186,28 +190,26 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { let baseFileName = SnapshotCIExportCoordinator.sanitize( "\(typeFileName)_\(fileNameComponent)" ) - let colorSchemeValue = result.colorScheme?.stringValue - - let context = SnapshotContext( - baseFileName: baseFileName, - testName: name, - typeName: previewType.typeName, - typeDisplayName: previewType.displayName, - fileId: previewType.fileID, - line: previewType.line, - previewDisplayName: preview.displayName, - previewIndex: discoveredPreview.index, - previewId: preview.previewId, - orientation: preview.orientation.id, - declaredDevice: preview.device?.rawValue, - simulatorDeviceName: ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"], - simulatorModelIdentifier: ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], - precision: result.precision, - accessibilityEnabled: result.accessibilityEnabled, - colorScheme: colorSchemeValue, - appStoreSnapshot: result.appStoreSnapshot) - - if let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled() { + if let coordinator = Self.ciExportCoordinator { + let colorSchemeValue = result.colorScheme?.stringValue + let context = SnapshotContext( + baseFileName: baseFileName, + testName: name, + typeName: previewType.typeName, + typeDisplayName: previewType.displayName, + fileId: previewType.fileID, + line: previewType.line, + previewDisplayName: preview.displayName, + previewIndex: discoveredPreview.index, + previewId: preview.previewId, + orientation: preview.orientation.id, + declaredDevice: preview.device?.rawValue, + simulatorDeviceName: ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"], + simulatorModelIdentifier: ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"], + precision: result.precision, + accessibilityEnabled: result.accessibilityEnabled, + colorScheme: colorSchemeValue, + appStoreSnapshot: result.appStoreSnapshot) coordinator.enqueueExport(result: result, context: context) } else { do { diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift index d3f54060..0762ef81 100644 --- a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -23,37 +23,28 @@ final class SnapshotCIExportCoordinatorTests: XCTestCase { super.setUp() tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("SnapshotCIExportTests-\(UUID().uuidString)") - SnapshotCIExportCoordinator.resetShared() } override func tearDown() { try? FileManager.default.removeItem(at: tempDir) - SnapshotCIExportCoordinator.resetShared() super.tearDown() } - // MARK: - Shared Instance Gating + // MARK: - Factory - func testSharedIfEnabledReturnsNilWhenEnvVarAbsent() { - let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled(environment: [:]) + func testCreateFromEnvironmentReturnsNilWhenEnvVarAbsent() { + let coordinator = SnapshotCIExportCoordinator.createFromEnvironment(environment: [:]) XCTAssertNil(coordinator) } - func testSharedIfEnabledReturnsCoordinatorWhenEnvVarSet() { - let coordinator = SnapshotCIExportCoordinator.sharedIfEnabled( + func testCreateFromEnvironmentReturnsCoordinatorWhenEnvVarSet() { + let coordinator = SnapshotCIExportCoordinator.createFromEnvironment( environment: [SnapshotCIExportCoordinator.envKey: tempDir.path] ) XCTAssertNotNil(coordinator) XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.path)) } - func testSharedIfEnabledReturnsSameInstanceOnRepeatedCalls() { - let env = [SnapshotCIExportCoordinator.envKey: tempDir.path] - let first = SnapshotCIExportCoordinator.sharedIfEnabled(environment: env) - let second = SnapshotCIExportCoordinator.sharedIfEnabled(environment: env) - XCTAssertTrue(first === second, "Should return the same cached instance") - } - // MARK: - Filename Sanitization func testSanitizeReplacesUnsafeCharacters() {