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/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) diff --git a/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift new file mode 100644 index 00000000..00cde50c --- /dev/null +++ b/Sources/SnapshottingTests/SnapshotCIExportCoordinator.swift @@ -0,0 +1,264 @@ +// +// 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: - Factory + + static func createFromEnvironment( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> SnapshotCIExportCoordinator? { + 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) + XCTestObservationCenter.shared.addTestObserver(coordinator) + return coordinator + } + + // 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 == "-" || 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 + + static func canonicalGroup( + fileId: String?, + typeDisplayName: String, + typeName: String + ) -> String { + if let fileId, !fileId.isEmpty { + return fileId + } + + if !typeDisplayName.isEmpty { + return typeDisplayName + } + + return 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 pngFileName = "\(context.baseFileName).png" + let jsonFileName = "\(context.baseFileName).json" + + let displayName = Self.canonicalDisplayName(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 } + + 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: context.baseFileName, + 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() + + writeQueue.waitUntilAllOperationsAreFinished() + } + + // 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..506daedf 100644 --- a/Sources/SnapshottingTests/SnapshotTest.swift +++ b/Sources/SnapshottingTests/SnapshotTest.swift @@ -7,8 +7,22 @@ import Foundation @_implementationOnly import SnapshotPreviewsCore +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,7 +44,7 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { open class func excludedSnapshotPreviews() -> [String]? { nil } - + #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) open class func setupA11y() -> ((UIViewController, UIWindow, PreviewLayout) -> UIView)? { return nil @@ -59,24 +73,57 @@ 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]] = [:] + + 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] { + ciExportCoordinator = SnapshotCIExportCoordinator.createFromEnvironment() + 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 = 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 + } + previewDisplayNameCountByGroup[group, default: [:]][previewDisplayName, default: 0] += 1 + } } - + return previews.map { DiscoveredPreview.from(previewType: $0) } } @@ -88,25 +135,32 @@ 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 + + // Lazily create the rendering strategy let strategy: RenderingStrategy - if let renderingStrategy = Self.renderingStrategy { - strategy = renderingStrategy + if let existing = Self.renderingStrategy { + strategy = existing } else { -#if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) + #if canImport(UIKit) && !os(watchOS) && !os(visionOS) && !os(tvOS) strategy = Self.makeRenderingStrategy(a11y: Self.setupA11y()) #else strategy = Self.makeRenderingStrategy() #endif Self.renderingStrategy = strategy } + + var typeFileName = previewType.displayName + if let fileId = previewType.fileID, let lineNumber = previewType.line { + typeFileName = Self.previewCountForFileId[fileId]! > 1 ? "\(fileId):\(lineNumber)" : fileId + } + + var result: SnapshotResult? = nil let expectation = XCTestExpectation() strategy.render(preview: preview) { snapshotResult in result = snapshotResult @@ -118,17 +172,54 @@ open class SnapshotTest: PreviewBaseTest, PreviewFilters { return } - 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 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)" + ) + 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 { + let attachment = try XCTAttachment(image: result.image.get()) + attachment.name = baseFileName + attachment.lifetime = .keepAlways + add(attachment) + } catch { + XCTFail("Error \(error)") + } } } } diff --git a/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift new file mode 100644 index 00000000..0762ef81 --- /dev/null +++ b/Tests/SnapshottingTestsTests/SnapshotCIExportCoordinatorTests.swift @@ -0,0 +1,394 @@ +// +// SnapshotCIExportCoordinatorTests.swift +// SnapshottingTestsTests +// + +import Foundation +import XCTest +@testable import SnapshottingTests +import SnapshotPreviewsCore + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +@MainActor +final class SnapshotCIExportCoordinatorTests: XCTestCase { + + private var tempDir: URL! + + override func setUp() { + super.setUp() + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("SnapshotCIExportTests-\(UUID().uuidString)") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: tempDir) + super.tearDown() + } + + // MARK: - Factory + + func testCreateFromEnvironmentReturnsNilWhenEnvVarAbsent() { + let coordinator = SnapshotCIExportCoordinator.createFromEnvironment(environment: [:]) + XCTAssertNil(coordinator) + } + + func testCreateFromEnvironmentReturnsCoordinatorWhenEnvVarSet() { + let coordinator = SnapshotCIExportCoordinator.createFromEnvironment( + environment: [SnapshotCIExportCoordinator.envKey: tempDir.path] + ) + XCTAssertNotNil(coordinator) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.path)) + } + + // 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 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() { + 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 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)) + } + + // 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, 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() + + 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 + + func testDrainIsIdempotent() throws { + let coordinator = SnapshotCIExportCoordinator(exportDirectoryURL: tempDir) + let context = makeContext(baseFileName: "TestView_Preview") + coordinator.enqueueExport(result: makeSuccessResult(), context: context) + + coordinator.drain() + coordinator.drain() + + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).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 { + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).png").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: tempDir.appendingPathComponent("\(context.baseFileName).json").path)) + } + } +} + +// MARK: - Test Helpers + +extension SnapshotCIExportCoordinatorTests { + + private func readJSON(forBaseFileName baseFileName: String) throws -> [String: Any] { + let data = try Data(contentsOf: tempDir.appendingPathComponent("\(baseFileName).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 + ) + } +}