From 517e43f28dd7817f53bb5932812ffbe34a0e1b97 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 00:53:51 +0200 Subject: [PATCH 01/22] feat: add support for a plugin mechanism --- Package.swift | 10 +++- .../ImageSerializationPlugin.swift | 37 ++++++++++++ Sources/SnapshotTesting/AssertSnapshot.swift | 4 ++ .../Plug-ins/ImageSerializer.swift | 57 +++++++++++++++++++ .../Plug-ins/PluginRegistry.swift | 44 ++++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift create mode 100644 Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift create mode 100644 Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift diff --git a/Package.swift b/Package.swift index 3ea312104..4ba316a13 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,10 @@ let package = Package( name: "SnapshotTesting", targets: ["SnapshotTesting"] ), + .library( + name: "ImageSerializationPlugin", + targets: ["ImageSerializationPlugin"] + ), .library( name: "InlineSnapshotTesting", targets: ["InlineSnapshotTesting"] @@ -25,7 +29,11 @@ let package = Package( ], targets: [ .target( - name: "SnapshotTesting" + name: "SnapshotTesting", + dependencies: ["ImageSerializationPlugin"] + ), + .target( + name: "ImageSerializationPlugin" ), .target( name: "InlineSnapshotTesting", diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift new file mode 100644 index 000000000..40b261e8c --- /dev/null +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -0,0 +1,37 @@ +import Foundation + +#if !os(macOS) +import UIKit.UIImage +public typealias SnapImage = UIImage +#else +import AppKit.NSImage +public typealias SnapImage = NSImage +#endif + +// I need this to behave like a string +public enum ImageSerializationFormat: RawRepresentable { + case png + case plugins(String) + + public init?(rawValue: String) { + switch rawValue { + case "png": self = .png + default: self = .plugins(rawValue) + } + } + + public var rawValue: String { + switch self { + case .png: return "png" + case let .plugins(value): return value + } + } +} + +@objc // Required initializer for creating instances dynamically +public protocol ImageSerializationPlugin { + static var fileExt: String { get } + init() // Required initializer for creating instances dynamically + func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? + func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? +} diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 8837fd9db..9a979d55b 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -1,4 +1,5 @@ import XCTest +import ImageSerializationPlugin #if canImport(Testing) // NB: We are importing only the implementation of Testing because that framework is not available @@ -6,6 +7,9 @@ import XCTest @_implementationOnly import Testing #endif +/// We can set the image format globally to better test +public var imageFormat = ImageSerializationFormat.png + /// Enhances failure messages with a command line diff tool expression that can be copied and pasted /// into a terminal. @available( diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift new file mode 100644 index 000000000..543efcf16 --- /dev/null +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -0,0 +1,57 @@ +import Foundation +import ImageSerializationPlugin + +#if canImport(UIKit) +import UIKit +#endif +#if canImport(AppKit) +import AppKit +#endif + +public class ImageSerializer { + public init() {} + + // 🥲 waiting for SE-0438 to land https://github.com/swiftlang/swift-evolution/blob/main/proposals/0438-metatype-keypath.md + // public func encodeImage(_ image: SnapImage, format: KeyPath) -> Data? { + + public func encodeImage(_ image: SnapImage, format: String) /*async throws*/ -> Data? { + for plugin in PluginRegistry.shared.allPlugins() { + if type(of: plugin).fileExt == format { + return /*try await*/ plugin.encodeImage(image) + } + } + // Default to PNG + return encodePNG(image) + } + + public func decodeImage(_ data: Data, format: String) /*async throws*/ -> SnapImage? { + for plugin in PluginRegistry.shared.allPlugins() { + if type(of: plugin).fileExt == format { + return /*try await*/ plugin.decodeImage(data) + } + } + // Default to PNG + return decodePNG(data) + } + + // MARK: - Actual default Image Serializer + private func encodePNG(_ image: SnapImage) -> Data? { +#if !os(macOS) + return image.pngData() +#else + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return nil + } + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + return bitmapRep.representation(using: .png, properties: [:]) +#endif + } + + private func decodePNG(_ data: Data) -> SnapImage? { +#if !os(macOS) + return UIImage(data: data) +#else + return NSImage(data: data) +#endif + } +} diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift new file mode 100644 index 000000000..75627d426 --- /dev/null +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -0,0 +1,44 @@ +import ImageSerializationPlugin + +public class PluginRegistry { + public static let shared = PluginRegistry() + private var plugins: [String: ImageSerializationPlugin] = [:] + + private init() {} + + public func registerPlugin(_ plugin: ImageSerializationPlugin) { + plugins[type(of: plugin).identifier] = plugin + } + + public func plugin(for identifier: String) -> ImageSerializationPlugin? { + return plugins[identifier] + } + + public func allPlugins() -> [ImageSerializationPlugin] { + return Array(plugins.values) + } +} + +// MARK: - AutoRegistry +import Foundation +import ObjectiveC.runtime + +var hasRegisterPlugins = false +func registerAllPlugins() { + if hasRegisterPlugins { return } + defer { hasRegisterPlugins = true } + let count = objc_getClassList(nil, 0) + let classes = UnsafeMutablePointer.allocate(capacity: Int(count)) + let autoreleasingClasses = AutoreleasingUnsafeMutablePointer(classes) + objc_getClassList(autoreleasingClasses, count) + + for i in 0.. Date: Wed, 11 Sep 2024 00:57:01 +0200 Subject: [PATCH 02/22] feat: add documentation --- .../ImageSerializationPlugin/ImageSerializationPlugin.swift | 2 ++ Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index 40b261e8c..b4f93c594 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -8,6 +8,8 @@ import AppKit.NSImage public typealias SnapImage = NSImage #endif +// I would like to have something like this as something that represent the fileformat/identifier +// but due to the limitation of @objc that can only represent have Int for RawType for enum i'ml blocked. // I need this to behave like a string public enum ImageSerializationFormat: RawRepresentable { case png diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 543efcf16..4821a280c 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -12,8 +12,10 @@ public class ImageSerializer { public init() {} // 🥲 waiting for SE-0438 to land https://github.com/swiftlang/swift-evolution/blob/main/proposals/0438-metatype-keypath.md + // or using ImageSerializationFormat as an extensible enum // public func encodeImage(_ image: SnapImage, format: KeyPath) -> Data? { - + + // async throws will be added later public func encodeImage(_ image: SnapImage, format: String) /*async throws*/ -> Data? { for plugin in PluginRegistry.shared.allPlugins() { if type(of: plugin).fileExt == format { @@ -24,6 +26,7 @@ public class ImageSerializer { return encodePNG(image) } + // async throws will be added later public func decodeImage(_ data: Data, format: String) /*async throws*/ -> SnapImage? { for plugin in PluginRegistry.shared.allPlugins() { if type(of: plugin).fileExt == format { From 329aab16be748f0e131e04ad12c4026301ebf0c7 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 01:23:27 +0200 Subject: [PATCH 03/22] feat: add format everywhere --- .../ImageSerializationPlugin.swift | 3 +- .../Plug-ins/ImageSerializer.swift | 8 ++--- .../Snapshotting/CALayer.swift | 13 +++---- .../SnapshotTesting/Snapshotting/CGPath.swift | 16 +++++---- .../Snapshotting/NSBezierPath.swift | 7 ++-- .../Snapshotting/NSImage.swift | 36 ++++++++----------- .../SnapshotTesting/Snapshotting/NSView.swift | 7 ++-- .../Snapshotting/NSViewController.swift | 7 ++-- .../Snapshotting/SceneKit.swift | 13 +++---- .../Snapshotting/SpriteKit.swift | 13 +++---- .../Snapshotting/SwiftUIView.swift | 8 +++-- .../Snapshotting/UIBezierPath.swift | 7 ++-- .../Snapshotting/UIImage.swift | 28 ++++++++------- .../SnapshotTesting/Snapshotting/UIView.swift | 8 +++-- .../Snapshotting/UIViewController.swift | 13 ++++--- 15 files changed, 101 insertions(+), 86 deletions(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index b4f93c594..1a018e038 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -32,7 +32,8 @@ public enum ImageSerializationFormat: RawRepresentable { @objc // Required initializer for creating instances dynamically public protocol ImageSerializationPlugin { - static var fileExt: String { get } + // This should be the fileExtention + static var identifier: String { get } init() // Required initializer for creating instances dynamically func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 4821a280c..88428a543 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -16,9 +16,9 @@ public class ImageSerializer { // public func encodeImage(_ image: SnapImage, format: KeyPath) -> Data? { // async throws will be added later - public func encodeImage(_ image: SnapImage, format: String) /*async throws*/ -> Data? { + public func encodeImage(_ image: SnapImage, format: ImageSerializationFormat) /*async throws*/ -> Data? { for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).fileExt == format { + if type(of: plugin).identifier == format.rawValue { return /*try await*/ plugin.encodeImage(image) } } @@ -27,9 +27,9 @@ public class ImageSerializer { } // async throws will be added later - public func decodeImage(_ data: Data, format: String) /*async throws*/ -> SnapImage? { + public func decodeImage(_ data: Data, format: ImageSerializationFormat) /*async throws*/ -> SnapImage? { for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).fileExt == format { + if type(of: plugin).identifier == format.rawValue { return /*try await*/ plugin.decodeImage(data) } } diff --git a/Sources/SnapshotTesting/Snapshotting/CALayer.swift b/Sources/SnapshotTesting/Snapshotting/CALayer.swift index 74c512c12..27ad8dd9d 100644 --- a/Sources/SnapshotTesting/Snapshotting/CALayer.swift +++ b/Sources/SnapshotTesting/Snapshotting/CALayer.swift @@ -2,6 +2,7 @@ import AppKit import Cocoa import QuartzCore + import ImageSerializationPlugin extension Snapshotting where Value == CALayer, Format == NSImage { /// A snapshot strategy for comparing layers based on pixel equality. @@ -14,7 +15,7 @@ /// assertSnapshot(of: layer, as: .image(precision: 0.99)) /// ``` public static var image: Snapshotting { - return .image(precision: 1) + return .image(precision: 1, imageFormat: imageFormat) } /// A snapshot strategy for comparing layers based on pixel equality. @@ -25,9 +26,9 @@ /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. - public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting { + public static func image(precision: Float, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).pullback { layer in let image = NSImage(size: layer.bounds.size) image.lockFocus() @@ -46,7 +47,7 @@ extension Snapshotting where Value == CALayer, Format == UIImage { /// A snapshot strategy for comparing layers based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing layers based on pixel equality. @@ -59,12 +60,12 @@ /// human eye. /// - traits: A trait collection override. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init() + precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init(), imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat ).pullback { layer in renderer(bounds: layer.bounds, for: traits).image { ctx in layer.setNeedsLayout() diff --git a/Sources/SnapshotTesting/Snapshotting/CGPath.swift b/Sources/SnapshotTesting/Snapshotting/CGPath.swift index 65470605c..557b0e2bf 100644 --- a/Sources/SnapshotTesting/Snapshotting/CGPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/CGPath.swift @@ -1,12 +1,14 @@ #if os(macOS) + import AppKit import Cocoa import CoreGraphics + import ImageSerializationPlugin extension Snapshotting where Value == CGPath, Format == NSImage { /// A snapshot strategy for comparing bezier paths based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing bezier paths based on pixel equality. @@ -29,10 +31,11 @@ public static func image( precision: Float = 1, perceptualPrecision: Float = 1, - drawingMode: CGPathDrawingMode = .eoFill + drawingMode: CGPathDrawingMode = .eoFill, + imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).pullback { path in let bounds = path.boundingBoxOfPath var transform = CGAffineTransform(translationX: -bounds.origin.x, y: -bounds.origin.y) @@ -52,10 +55,11 @@ #elseif os(iOS) || os(tvOS) import UIKit + extension Snapshotting where Value == CGPath, Format == UIImage { /// A snapshot strategy for comparing bezier paths based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing bezier paths based on pixel equality. @@ -68,10 +72,10 @@ /// human eye. public static func image( precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, - drawingMode: CGPathDrawingMode = .eoFill + drawingMode: CGPathDrawingMode = .eoFill, imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: scale + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, imageFormat: imageFormat ).pullback { path in let bounds = path.boundingBoxOfPath let format: UIGraphicsImageRendererFormat diff --git a/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift b/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift index b84a59bf3..8577ef296 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSBezierPath.swift @@ -1,11 +1,12 @@ #if os(macOS) import AppKit import Cocoa + import ImageSerializationPlugin extension Snapshotting where Value == NSBezierPath, Format == NSImage { /// A snapshot strategy for comparing bezier paths based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing bezier paths based on pixel equality. @@ -24,9 +25,9 @@ /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).pullback { path in // Move path info frame: let bounds = path.bounds diff --git a/Sources/SnapshotTesting/Snapshotting/NSImage.swift b/Sources/SnapshotTesting/Snapshotting/NSImage.swift index be4fd7cd4..45e5c0158 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSImage.swift @@ -1,10 +1,11 @@ #if os(macOS) import Cocoa import XCTest + import ImageSerializationPlugin extension Diffing where Value == NSImage { /// A pixel-diffing strategy for NSImage's which requires a 100% match. - public static let image = Diffing.image() + public static let image = Diffing.image(imageFormat: imageFormat) /// A pixel-diffing strategy for NSImage that allows customizing how precise the matching must be. /// @@ -15,14 +16,15 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - Returns: A new diffing strategy. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Diffing { + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Diffing { + let imageSerializer = ImageSerializer() return .init( - toData: { NSImagePNGRepresentation($0)! }, - fromData: { NSImage(data: $0)! } + toData: { imageSerializer.encodeImage($0, format: imageFormat)! }, + fromData: { imageSerializer.decodeImage($0, format: imageFormat)! } ) { old, new in guard let message = compare( - old, new, precision: precision, perceptualPrecision: perceptualPrecision) + old, new, precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat) else { return nil } let difference = SnapshotTesting.diff(old, new) let oldAttachment = XCTAttachment(image: old) @@ -42,7 +44,7 @@ extension Snapshotting where Value == NSImage, Format == NSImage { /// A snapshot strategy for comparing images based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing images based on pixel equality. @@ -53,24 +55,15 @@ /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1) -> Snapshotting { + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Snapshotting { return .init( - pathExtension: "png", - diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision) + pathExtension: imageFormat.rawValue, + diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat) ) } } - private func NSImagePNGRepresentation(_ image: NSImage) -> Data? { - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - let rep = NSBitmapImageRep(cgImage: cgImage) - rep.size = image.size - return rep.representation(using: .png, properties: [:]) - } - - private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float) + private func compare(_ old: NSImage, _ new: NSImage, precision: Float, perceptualPrecision: Float, imageFormat: ImageSerializationFormat) -> String? { guard let oldCgImage = old.cgImage(forProposedRect: nil, context: nil, hints: nil) else { @@ -93,9 +86,10 @@ } let byteCount = oldContext.height * oldContext.bytesPerRow if memcmp(oldData, newData, byteCount) == 0 { return nil } + let imageSerializer = ImageSerializer() guard - let pngData = NSImagePNGRepresentation(new), - let newerCgImage = NSImage(data: pngData)?.cgImage( + let imageData = imageSerializer.encodeImage(new, format: imageFormat), + let newerCgImage = imageSerializer.decodeImage(imageData, format: imageFormat)?.cgImage( forProposedRect: nil, context: nil, hints: nil), let newerContext = context(for: newerCgImage), let newerData = newerContext.data diff --git a/Sources/SnapshotTesting/Snapshotting/NSView.swift b/Sources/SnapshotTesting/Snapshotting/NSView.swift index b2e7edfb0..b83240926 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSView.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSView.swift @@ -1,11 +1,12 @@ #if os(macOS) import AppKit import Cocoa + import ImageSerializationPlugin extension Snapshotting where Value == NSView, Format == NSImage { /// A snapshot strategy for comparing views based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing views based on pixel equality. @@ -21,10 +22,10 @@ /// human eye. /// - size: A view size override. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil + precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil, imageFormat: ImageSerializationFormat = imageFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).asyncPullback { view in let initialSize = view.frame.size if let size = size { view.frame.size = size } diff --git a/Sources/SnapshotTesting/Snapshotting/NSViewController.swift b/Sources/SnapshotTesting/Snapshotting/NSViewController.swift index 69ec72dde..2d841701c 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSViewController.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSViewController.swift @@ -1,11 +1,12 @@ #if os(macOS) import AppKit import Cocoa + import ImageSerializationPlugin extension Snapshotting where Value == NSViewController, Format == NSImage { /// A snapshot strategy for comparing view controller views based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing view controller views based on pixel equality. @@ -18,10 +19,10 @@ /// human eye. /// - size: A view size override. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil + precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil, imageFormat: ImageSerializationFormat ) -> Snapshotting { return Snapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, size: size + precision: precision, perceptualPrecision: perceptualPrecision, size: size, imageFormat: imageFormat ).pullback { $0.view } } } diff --git a/Sources/SnapshotTesting/Snapshotting/SceneKit.swift b/Sources/SnapshotTesting/Snapshotting/SceneKit.swift index 94ff90459..758296e23 100644 --- a/Sources/SnapshotTesting/Snapshotting/SceneKit.swift +++ b/Sources/SnapshotTesting/Snapshotting/SceneKit.swift @@ -1,5 +1,6 @@ #if os(iOS) || os(macOS) || os(tvOS) import SceneKit + import ImageSerializationPlugin #if os(macOS) import Cocoa #elseif os(iOS) || os(tvOS) @@ -17,10 +18,10 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { - return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size, imageFormat: imageFormat) } } #elseif os(iOS) || os(tvOS) @@ -34,20 +35,20 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { - return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + return .scnScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size, imageFormat: imageFormat) } } #endif extension Snapshotting where Value == SCNScene, Format == Image { - fileprivate static func scnScene(precision: Float, perceptualPrecision: Float, size: CGSize) + fileprivate static func scnScene(precision: Float, perceptualPrecision: Float, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { return Snapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).pullback { scene in let view = SCNView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) view.scene = scene diff --git a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift b/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift index ad515050a..a073f190b 100644 --- a/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift +++ b/Sources/SnapshotTesting/Snapshotting/SpriteKit.swift @@ -1,5 +1,6 @@ #if os(iOS) || os(macOS) || os(tvOS) import SpriteKit + import ImageSerializationPlugin #if os(macOS) import Cocoa #elseif os(iOS) || os(tvOS) @@ -17,10 +18,10 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { - return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size, imageFormat: imageFormat) } } #elseif os(iOS) || os(tvOS) @@ -34,20 +35,20 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - size: The size of the scene. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize) + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { - return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size) + return .skScene(precision: precision, perceptualPrecision: perceptualPrecision, size: size, imageFormat: imageFormat) } } #endif extension Snapshotting where Value == SKScene, Format == Image { - fileprivate static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize) + fileprivate static func skScene(precision: Float, perceptualPrecision: Float, size: CGSize, imageFormat: ImageSerializationFormat) -> Snapshotting { return Snapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision + precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat ).pullback { scene in let view = SKView(frame: .init(x: 0, y: 0, width: size.width, height: size.height)) view.presentScene(scene) diff --git a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift index 8d85e1f0b..673ce859c 100644 --- a/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift +++ b/Sources/SnapshotTesting/Snapshotting/SwiftUIView.swift @@ -1,6 +1,7 @@ #if canImport(SwiftUI) import Foundation import SwiftUI + import ImageSerializationPlugin /// The size constraint for a snapshot (similar to `PreviewLayout`). public enum SwiftUISnapshotLayout { @@ -20,7 +21,7 @@ /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing SwiftUI Views based on pixel equality. @@ -41,7 +42,8 @@ precision: Float = 1, perceptualPrecision: Float = 1, layout: SwiftUISnapshotLayout = .sizeThatFits, - traits: UITraitCollection = .init() + traits: UITraitCollection = .init(), + imageFormat: ImageSerializationFormat = imageFormat ) -> Snapshotting { @@ -60,7 +62,7 @@ } return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat ).asyncPullback { view in var config = config diff --git a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift index 6b48d622d..86f15d05d 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift @@ -1,10 +1,11 @@ #if os(iOS) || os(tvOS) import UIKit + import ImageSerializationPlugin extension Snapshotting where Value == UIBezierPath, Format == UIImage { /// A snapshot strategy for comparing bezier paths based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing bezier paths based on pixel equality. @@ -17,10 +18,10 @@ /// human eye. /// - scale: The scale to use when loading the reference image from disk. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1 + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, format imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: scale + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, imageFormat: imageFormat ).pullback { path in let bounds = path.bounds let format: UIGraphicsImageRendererFormat diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/SnapshotTesting/Snapshotting/UIImage.swift index 3d1bb5319..59a043064 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIImage.swift @@ -1,10 +1,11 @@ #if os(iOS) || os(tvOS) import UIKit import XCTest + import ImageSerializationPlugin extension Diffing where Value == UIImage { /// A pixel-diffing strategy for UIImage's which requires a 100% match. - public static let image = Diffing.image() + public static let image = Diffing.image(imageFormat: imageFormat) /// A pixel-diffing strategy for UIImage that allows customizing how precise the matching must be. /// @@ -18,7 +19,7 @@ /// `UITraitCollection`s default value of `0.0`, the screens scale is used. /// - Returns: A new diffing strategy. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat ) -> Diffing { let imageScale: CGFloat if let scale = scale, scale != 0.0 { @@ -26,14 +27,14 @@ } else { imageScale = UIScreen.main.scale } - + let imageSerializer = ImageSerializer() return Diffing( - toData: { $0.pngData() ?? emptyImage().pngData()! }, - fromData: { UIImage(data: $0, scale: imageScale)! } + toData: { imageSerializer.encodeImage($0, format: imageFormat) ?? emptyImage().pngData()! }, // this seems inconsistant with macOS implementation + fromData: { imageSerializer.decodeImage($0, format: imageFormat)! } // missing imageScale here ) { old, new in guard let message = compare( - old, new, precision: precision, perceptualPrecision: perceptualPrecision) + old, new, precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat) else { return nil } let difference = SnapshotTesting.diff(old, new) let oldAttachment = XCTAttachment(image: old) @@ -65,7 +66,7 @@ extension Snapshotting where Value == UIImage, Format == UIImage { /// A snapshot strategy for comparing images based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing images based on pixel equality. @@ -78,12 +79,12 @@ /// human eye. /// - scale: The scale of the reference image stored on disk. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat ) -> Snapshotting { return .init( - pathExtension: "png", + pathExtension: format.rawValue, diffing: .image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: scale) + precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, imageFormat: imageFormat) ) } } @@ -93,7 +94,7 @@ private let imageContextBitsPerComponent = 8 private let imageContextBytesPerPixel = 4 - private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptualPrecision: Float) + private func compare(_ old: UIImage, _ new: UIImage, precision: Float, perceptualPrecision: Float, imageFormat: ImageSerializationFormat) -> String? { guard let oldCgImage = old.cgImage else { @@ -118,9 +119,10 @@ if memcmp(oldData, newData, byteCount) == 0 { return nil } } var newerBytes = [UInt8](repeating: 0, count: byteCount) + let imageSerializer = ImageSerializer() guard - let pngData = new.pngData(), - let newerCgImage = UIImage(data: pngData)?.cgImage, + let imageData = imageSerializer.encodeImage(new, format: imageFormat), + let newerCgImage = imageSerializer.decodeImage(imageData, format: imageFormat)?.cgImage, let newerContext = context(for: newerCgImage, data: &newerBytes), let newerData = newerContext.data else { diff --git a/Sources/SnapshotTesting/Snapshotting/UIView.swift b/Sources/SnapshotTesting/Snapshotting/UIView.swift index 7244f67d1..44885d80c 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIView.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIView.swift @@ -1,10 +1,11 @@ #if os(iOS) || os(tvOS) import UIKit + import ImageSerializationPlugin extension Snapshotting where Value == UIView, Format == UIImage { /// A snapshot strategy for comparing views based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing views based on pixel equality. @@ -25,13 +26,14 @@ precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil, - traits: UITraitCollection = .init() + traits: UITraitCollection = .init(), + imageFormat: ImageSerializationFormat = imageFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat ).asyncPullback { view in snapshotView( config: .init(safeArea: .zero, size: size ?? view.frame.size, traits: .init()), diff --git a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift b/Sources/SnapshotTesting/Snapshotting/UIViewController.swift index b08b8bf59..f6562b320 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIViewController.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIViewController.swift @@ -1,10 +1,11 @@ #if os(iOS) || os(tvOS) import UIKit + import ImageSerializationPlugin extension Snapshotting where Value == UIViewController, Format == UIImage { /// A snapshot strategy for comparing view controller views based on pixel equality. public static var image: Snapshotting { - return .image() + return .image(imageFormat: imageFormat) } /// A snapshot strategy for comparing view controller views based on pixel equality. @@ -23,13 +24,14 @@ precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil, - traits: UITraitCollection = .init() + traits: UITraitCollection = .init(), + imageFormat: ImageSerializationFormat = imageFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat ).asyncPullback { viewController in snapshotView( config: size.map { .init(safeArea: config.safeArea, size: $0, traits: config.traits) } @@ -60,13 +62,14 @@ precision: Float = 1, perceptualPrecision: Float = 1, size: CGSize? = nil, - traits: UITraitCollection = .init() + traits: UITraitCollection = .init(), + imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( - precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale + precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat ).asyncPullback { viewController in snapshotView( config: .init(safeArea: .zero, size: size, traits: traits), From a3a50c816e2a22ae875d3d787208a3453dba981c Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 01:26:52 +0200 Subject: [PATCH 04/22] fix: building issues --- Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift | 8 ++++---- Sources/SnapshotTesting/Snapshotting/CALayer.swift | 2 +- Sources/SnapshotTesting/Snapshotting/CGPath.swift | 2 +- Sources/SnapshotTesting/Snapshotting/NSImage.swift | 8 ++++---- .../SnapshotTesting/Snapshotting/UIBezierPath.swift | 2 +- Sources/SnapshotTesting/Snapshotting/UIImage.swift | 10 +++++----- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 88428a543..34aa683be 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -16,9 +16,9 @@ public class ImageSerializer { // public func encodeImage(_ image: SnapImage, format: KeyPath) -> Data? { // async throws will be added later - public func encodeImage(_ image: SnapImage, format: ImageSerializationFormat) /*async throws*/ -> Data? { + public func encodeImage(_ image: SnapImage, imageFormat: ImageSerializationFormat) /*async throws*/ -> Data? { for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).identifier == format.rawValue { + if type(of: plugin).identifier == imageFormat.rawValue { return /*try await*/ plugin.encodeImage(image) } } @@ -27,9 +27,9 @@ public class ImageSerializer { } // async throws will be added later - public func decodeImage(_ data: Data, format: ImageSerializationFormat) /*async throws*/ -> SnapImage? { + public func decodeImage(_ data: Data, imageFormat: ImageSerializationFormat) /*async throws*/ -> SnapImage? { for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).identifier == format.rawValue { + if type(of: plugin).identifier == imageFormat.rawValue { return /*try await*/ plugin.decodeImage(data) } } diff --git a/Sources/SnapshotTesting/Snapshotting/CALayer.swift b/Sources/SnapshotTesting/Snapshotting/CALayer.swift index 27ad8dd9d..5f4f4a1bf 100644 --- a/Sources/SnapshotTesting/Snapshotting/CALayer.swift +++ b/Sources/SnapshotTesting/Snapshotting/CALayer.swift @@ -1,8 +1,8 @@ +import ImageSerializationPlugin #if os(macOS) import AppKit import Cocoa import QuartzCore - import ImageSerializationPlugin extension Snapshotting where Value == CALayer, Format == NSImage { /// A snapshot strategy for comparing layers based on pixel equality. diff --git a/Sources/SnapshotTesting/Snapshotting/CGPath.swift b/Sources/SnapshotTesting/Snapshotting/CGPath.swift index 557b0e2bf..368ab5196 100644 --- a/Sources/SnapshotTesting/Snapshotting/CGPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/CGPath.swift @@ -1,9 +1,9 @@ +import ImageSerializationPlugin #if os(macOS) import AppKit import Cocoa import CoreGraphics - import ImageSerializationPlugin extension Snapshotting where Value == CGPath, Format == NSImage { /// A snapshot strategy for comparing bezier paths based on pixel equality. diff --git a/Sources/SnapshotTesting/Snapshotting/NSImage.swift b/Sources/SnapshotTesting/Snapshotting/NSImage.swift index 45e5c0158..e2b0091fe 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSImage.swift @@ -19,8 +19,8 @@ public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Diffing { let imageSerializer = ImageSerializer() return .init( - toData: { imageSerializer.encodeImage($0, format: imageFormat)! }, - fromData: { imageSerializer.decodeImage($0, format: imageFormat)! } + toData: { imageSerializer.encodeImage($0, imageFormat: imageFormat)! }, + fromData: { imageSerializer.decodeImage($0, imageFormat: imageFormat)! } ) { old, new in guard let message = compare( @@ -88,8 +88,8 @@ if memcmp(oldData, newData, byteCount) == 0 { return nil } let imageSerializer = ImageSerializer() guard - let imageData = imageSerializer.encodeImage(new, format: imageFormat), - let newerCgImage = imageSerializer.decodeImage(imageData, format: imageFormat)?.cgImage( + let imageData = imageSerializer.encodeImage(new, imageFormat: imageFormat), + let newerCgImage = imageSerializer.decodeImage(imageData, imageFormat: imageFormat)?.cgImage( forProposedRect: nil, context: nil, hints: nil), let newerContext = context(for: newerCgImage), let newerData = newerContext.data diff --git a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift index 86f15d05d..78b1891fd 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIBezierPath.swift @@ -18,7 +18,7 @@ /// human eye. /// - scale: The scale to use when loading the reference image from disk. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, format imageFormat: ImageSerializationFormat + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat = 1, imageFormat: ImageSerializationFormat ) -> Snapshotting { return SimplySnapshotting.image( precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, imageFormat: imageFormat diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/SnapshotTesting/Snapshotting/UIImage.swift index 59a043064..79a876241 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIImage.swift @@ -29,8 +29,8 @@ } let imageSerializer = ImageSerializer() return Diffing( - toData: { imageSerializer.encodeImage($0, format: imageFormat) ?? emptyImage().pngData()! }, // this seems inconsistant with macOS implementation - fromData: { imageSerializer.decodeImage($0, format: imageFormat)! } // missing imageScale here + toData: { imageSerializer.encodeImage($0, imageFormat: imageFormat) ?? emptyImage().pngData()! }, // this seems inconsistant with macOS implementation + fromData: { imageSerializer.decodeImage($0, imageFormat: imageFormat)! } // missing imageScale here ) { old, new in guard let message = compare( @@ -82,7 +82,7 @@ precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat ) -> Snapshotting { return .init( - pathExtension: format.rawValue, + pathExtension: imageFormat.rawValue, diffing: .image( precision: precision, perceptualPrecision: perceptualPrecision, scale: scale, imageFormat: imageFormat) ) @@ -121,8 +121,8 @@ var newerBytes = [UInt8](repeating: 0, count: byteCount) let imageSerializer = ImageSerializer() guard - let imageData = imageSerializer.encodeImage(new, format: imageFormat), - let newerCgImage = imageSerializer.decodeImage(imageData, format: imageFormat)?.cgImage, + let imageData = imageSerializer.encodeImage(new, imageFormat: imageFormat), + let newerCgImage = imageSerializer.decodeImage(imageData, imageFormat: imageFormat)?.cgImage, let newerContext = context(for: newerCgImage, data: &newerBytes), let newerData = newerContext.data else { From 476e1ecf67229aaf7f54d45fda8f0d6f5dcc9392 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 01:34:18 +0200 Subject: [PATCH 05/22] fix: for linux --- Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 75627d426..6a67a5fe4 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -19,6 +19,8 @@ public class PluginRegistry { } } +// If we are not on macOS the autoregistration mechanism won't work. +#if canImport(ObjectiveC.runtime) // MARK: - AutoRegistry import Foundation import ObjectiveC.runtime @@ -41,4 +43,4 @@ func registerAllPlugins() { } classes.deallocate() } - +#endif From 1cb2b121972769ac602b61ff61c1431aef167363 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 01:51:01 +0200 Subject: [PATCH 06/22] fix: api issue --- .../ImageSerializationPlugin/ImageSerializationPlugin.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index 1a018e038..c0ec46563 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -11,7 +11,7 @@ public typealias SnapImage = NSImage // I would like to have something like this as something that represent the fileformat/identifier // but due to the limitation of @objc that can only represent have Int for RawType for enum i'ml blocked. // I need this to behave like a string -public enum ImageSerializationFormat: RawRepresentable { +public enum ImageSerializationFormat: RawRepresentable, Sendable { case png case plugins(String) @@ -30,6 +30,10 @@ public enum ImageSerializationFormat: RawRepresentable { } } +public protocol ImageSerializationPublicFormat { + static var imageFormat: ImageSerializationFormat { get } +} + @objc // Required initializer for creating instances dynamically public protocol ImageSerializationPlugin { // This should be the fileExtention From 12fe6398221b43c45c568d8034cf1b4d05299eae Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 02:33:35 +0200 Subject: [PATCH 07/22] fix: tests --- .../ImageSerializationPlugin/ImageSerializationPlugin.swift | 3 --- Sources/SnapshotTesting/Snapshotting/NSImage.swift | 4 ++-- Sources/SnapshotTesting/Snapshotting/UIImage.swift | 4 ++-- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index c0ec46563..d87e8b933 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -8,9 +8,6 @@ import AppKit.NSImage public typealias SnapImage = NSImage #endif -// I would like to have something like this as something that represent the fileformat/identifier -// but due to the limitation of @objc that can only represent have Int for RawType for enum i'ml blocked. -// I need this to behave like a string public enum ImageSerializationFormat: RawRepresentable, Sendable { case png case plugins(String) diff --git a/Sources/SnapshotTesting/Snapshotting/NSImage.swift b/Sources/SnapshotTesting/Snapshotting/NSImage.swift index e2b0091fe..c8978e27c 100644 --- a/Sources/SnapshotTesting/Snapshotting/NSImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/NSImage.swift @@ -16,7 +16,7 @@ /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. /// - Returns: A new diffing strategy. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Diffing { + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat = imageFormat) -> Diffing { let imageSerializer = ImageSerializer() return .init( toData: { imageSerializer.encodeImage($0, imageFormat: imageFormat)! }, @@ -55,7 +55,7 @@ /// match. 98-99% mimics /// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the /// human eye. - public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Snapshotting { + public static func image(precision: Float = 1, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat = imageFormat) -> Snapshotting { return .init( pathExtension: imageFormat.rawValue, diffing: .image(precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat) diff --git a/Sources/SnapshotTesting/Snapshotting/UIImage.swift b/Sources/SnapshotTesting/Snapshotting/UIImage.swift index 79a876241..8d97de37f 100644 --- a/Sources/SnapshotTesting/Snapshotting/UIImage.swift +++ b/Sources/SnapshotTesting/Snapshotting/UIImage.swift @@ -19,7 +19,7 @@ /// `UITraitCollection`s default value of `0.0`, the screens scale is used. /// - Returns: A new diffing strategy. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat = imageFormat ) -> Diffing { let imageScale: CGFloat if let scale = scale, scale != 0.0 { @@ -79,7 +79,7 @@ /// human eye. /// - scale: The scale of the reference image stored on disk. public static func image( - precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat + precision: Float = 1, perceptualPrecision: Float = 1, scale: CGFloat? = nil, imageFormat: ImageSerializationFormat = imageFormat ) -> Snapshotting { return .init( pathExtension: imageFormat.rawValue, From 18a2ba75294a90c35844cc56d3a00d60e709be46 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 02:42:37 +0200 Subject: [PATCH 08/22] fix: the plugin should work only where we could generate image Apple platform at the moment --- .../ImageSerializationPlugin.swift | 6 ++++-- .../SnapshotTesting/Plug-ins/ImageSerializer.swift | 13 +++++++------ .../SnapshotTesting/Plug-ins/PluginRegistry.swift | 2 ++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index d87e8b933..14835a709 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -1,9 +1,10 @@ +#if canImport(SwiftUI) import Foundation -#if !os(macOS) +#if canImport(UIKit) import UIKit.UIImage public typealias SnapImage = UIImage -#else +#elseif canImport(AppKit) import AppKit.NSImage public typealias SnapImage = NSImage #endif @@ -39,3 +40,4 @@ public protocol ImageSerializationPlugin { func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? } +#endif diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 34aa683be..b6b027efe 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -1,10 +1,10 @@ +#if canImport(SwiftUI) import Foundation import ImageSerializationPlugin #if canImport(UIKit) import UIKit -#endif -#if canImport(AppKit) +#elseif canImport(AppKit) import AppKit #endif @@ -39,9 +39,9 @@ public class ImageSerializer { // MARK: - Actual default Image Serializer private func encodePNG(_ image: SnapImage) -> Data? { -#if !os(macOS) +#if canImport(UIKit) return image.pngData() -#else +#elseif canImport(AppKit) guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } @@ -51,10 +51,11 @@ public class ImageSerializer { } private func decodePNG(_ data: Data) -> SnapImage? { -#if !os(macOS) +#if canImport(UIKit) return UIImage(data: data) -#else +#elseif canImport(AppKit) return NSImage(data: data) #endif } } +#endif diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 6a67a5fe4..2111d456e 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -1,3 +1,4 @@ +#if canImport(SwiftUI) import ImageSerializationPlugin public class PluginRegistry { @@ -44,3 +45,4 @@ func registerAllPlugins() { classes.deallocate() } #endif +#endif From 67597a73c3b8c9409e1b9e2d131e2f4bfd656dee Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 02:47:55 +0200 Subject: [PATCH 09/22] fix: the plugin should work only where we could generate image Apple platform at the moment --- .../ImageSerializationPlugin.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index 14835a709..8af38010a 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -9,6 +9,20 @@ import AppKit.NSImage public typealias SnapImage = NSImage #endif +public protocol ImageSerializationPublicFormat { + static var imageFormat: ImageSerializationFormat { get } +} + +@objc // Required initializer for creating instances dynamically +public protocol ImageSerializationPlugin { + // This should be the fileExtention + static var identifier: String { get } + init() // Required initializer for creating instances dynamically + func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? + func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? +} +#endif + public enum ImageSerializationFormat: RawRepresentable, Sendable { case png case plugins(String) @@ -19,7 +33,7 @@ public enum ImageSerializationFormat: RawRepresentable, Sendable { default: self = .plugins(rawValue) } } - + public var rawValue: String { switch self { case .png: return "png" @@ -27,17 +41,3 @@ public enum ImageSerializationFormat: RawRepresentable, Sendable { } } } - -public protocol ImageSerializationPublicFormat { - static var imageFormat: ImageSerializationFormat { get } -} - -@objc // Required initializer for creating instances dynamically -public protocol ImageSerializationPlugin { - // This should be the fileExtention - static var identifier: String { get } - init() // Required initializer for creating instances dynamically - func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? - func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? -} -#endif From 3ff01dba16b5ad437428b531ee366ff7a471c692 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 03:11:37 +0200 Subject: [PATCH 10/22] feat: add a more abstract plugin interface to add other type of plugin in the future --- Package.swift | 8 +++++- .../ImageSerializationPlugin.swift | 15 +++++------ .../Plug-ins/ImageSerializer.swift | 8 +++--- .../Plug-ins/PluginRegistry.swift | 26 +++++++++++++------ .../SnapshotTestingPlugin.swift | 8 ++++++ 5 files changed, 43 insertions(+), 22 deletions(-) create mode 100644 Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift diff --git a/Package.swift b/Package.swift index 4ba316a13..3fbdcd4f3 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,10 @@ let package = Package( name: "SnapshotTesting", targets: ["SnapshotTesting"] ), + .library( + name: "SnapshotTestingPlugin", + targets: ["SnapshotTestingPlugin"] + ), .library( name: "ImageSerializationPlugin", targets: ["ImageSerializationPlugin"] @@ -32,8 +36,10 @@ let package = Package( name: "SnapshotTesting", dependencies: ["ImageSerializationPlugin"] ), + .target(name: "SnapshotTestingPlugin"), .target( - name: "ImageSerializationPlugin" + name: "ImageSerializationPlugin", + dependencies: ["SnapshotTestingPlugin"] ), .target( name: "InlineSnapshotTesting", diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index 8af38010a..ba200e994 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -1,5 +1,6 @@ #if canImport(SwiftUI) import Foundation +import SnapshotTestingPlugin #if canImport(UIKit) import UIKit.UIImage @@ -9,21 +10,17 @@ import AppKit.NSImage public typealias SnapImage = NSImage #endif -public protocol ImageSerializationPublicFormat { - static var imageFormat: ImageSerializationFormat { get } -} +// Way to go around the limitation of @objc +public typealias ImageSerializationPlugin = ImageSerialization & SnapshotTestingPlugin -@objc // Required initializer for creating instances dynamically -public protocol ImageSerializationPlugin { - // This should be the fileExtention - static var identifier: String { get } - init() // Required initializer for creating instances dynamically +public protocol ImageSerialization { + static var imageFormat: ImageSerializationFormat { get } func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? } #endif -public enum ImageSerializationFormat: RawRepresentable, Sendable { +public enum ImageSerializationFormat: RawRepresentable, Sendable, Equatable { case png case plugins(String) diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index b6b027efe..6288e4a41 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -17,8 +17,8 @@ public class ImageSerializer { // async throws will be added later public func encodeImage(_ image: SnapImage, imageFormat: ImageSerializationFormat) /*async throws*/ -> Data? { - for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).identifier == imageFormat.rawValue { + for plugin in PluginRegistry.shared.imageSerializerPlugins() { + if type(of: plugin).imageFormat == imageFormat { return /*try await*/ plugin.encodeImage(image) } } @@ -28,8 +28,8 @@ public class ImageSerializer { // async throws will be added later public func decodeImage(_ data: Data, imageFormat: ImageSerializationFormat) /*async throws*/ -> SnapImage? { - for plugin in PluginRegistry.shared.allPlugins() { - if type(of: plugin).identifier == imageFormat.rawValue { + for plugin in PluginRegistry.shared.imageSerializerPlugins() { + if type(of: plugin).imageFormat == imageFormat { return /*try await*/ plugin.decodeImage(data) } } diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 2111d456e..7404275f4 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -1,22 +1,32 @@ #if canImport(SwiftUI) import ImageSerializationPlugin +@objc +public protocol SnapshotTestingPlugin { + static var identifier: String { get } + init() +} + public class PluginRegistry { public static let shared = PluginRegistry() - private var plugins: [String: ImageSerializationPlugin] = [:] + private var plugins: [String: AnyObject] = [:] private init() {} - public func registerPlugin(_ plugin: ImageSerializationPlugin) { + public func registerPlugin(_ plugin: SnapshotTestingPlugin) { plugins[type(of: plugin).identifier] = plugin } - public func plugin(for identifier: String) -> ImageSerializationPlugin? { - return plugins[identifier] + public func plugin(for identifier: String) -> SnapshotTestingPlugin? { + return plugins[identifier] as? SnapshotTestingPlugin + } + + public func allPlugins() -> [SnapshotTestingPlugin] { + return Array(plugins.values.compactMap { $0 as? SnapshotTestingPlugin }) } - public func allPlugins() -> [ImageSerializationPlugin] { - return Array(plugins.values) + public func imageSerializerPlugins() -> [ImageSerialization] { + return Array(plugins.values).compactMap { $0 as? ImageSerialization } } } @@ -36,8 +46,8 @@ func registerAllPlugins() { objc_getClassList(autoreleasingClasses, count) for i in 0.. Date: Wed, 11 Sep 2024 03:30:38 +0200 Subject: [PATCH 11/22] feat: should compile on linux --- Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift | 6 +----- Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift | 2 +- Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift | 3 ++- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 6288e4a41..47f62d486 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -10,11 +10,7 @@ import AppKit public class ImageSerializer { public init() {} - - // 🥲 waiting for SE-0438 to land https://github.com/swiftlang/swift-evolution/blob/main/proposals/0438-metatype-keypath.md - // or using ImageSerializationFormat as an extensible enum - // public func encodeImage(_ image: SnapImage, format: KeyPath) -> Data? { - + // async throws will be added later public func encodeImage(_ image: SnapImage, imageFormat: ImageSerializationFormat) /*async throws*/ -> Data? { for plugin in PluginRegistry.shared.imageSerializerPlugins() { diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 7404275f4..b98328a16 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -1,4 +1,4 @@ -#if canImport(SwiftUI) +#if canImport(SwiftUI) && canImport(ObjectiveC) import ImageSerializationPlugin @objc diff --git a/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift b/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift index 31801ff94..b8ea3ddc4 100644 --- a/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift +++ b/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift @@ -1,5 +1,6 @@ -#if canImport(Foundation) +#if canImport(Foundation) && canImport(ObjectiveC) import Foundation + @objc public protocol SnapshotTestingPlugin { static var identifier: String { get } From 432fea9a0a2bbc513b3720ab1d3781db525f7174 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 03:46:25 +0200 Subject: [PATCH 12/22] feat: remove dup --- Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index b98328a16..72264946e 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -1,12 +1,8 @@ #if canImport(SwiftUI) && canImport(ObjectiveC) import ImageSerializationPlugin +import SnapshotTestingPlugin -@objc -public protocol SnapshotTestingPlugin { - static var identifier: String { get } - init() -} - +// MARK: - PluginRegistry public class PluginRegistry { public static let shared = PluginRegistry() private var plugins: [String: AnyObject] = [:] @@ -30,9 +26,9 @@ public class PluginRegistry { } } +// MARK: - Plugin AutoRegistry // If we are not on macOS the autoregistration mechanism won't work. #if canImport(ObjectiveC.runtime) -// MARK: - AutoRegistry import Foundation import ObjectiveC.runtime From 54c11f6eecc7fc61c71f265e807ead8ce7de379b Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 09:10:26 +0200 Subject: [PATCH 13/22] fix: registration code --- .../Plug-ins/PluginRegistry.swift | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 72264946e..6c09de261 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -1,4 +1,6 @@ #if canImport(SwiftUI) && canImport(ObjectiveC) +import Foundation +import ObjectiveC.runtime import ImageSerializationPlugin import SnapshotTestingPlugin @@ -6,8 +8,10 @@ import SnapshotTestingPlugin public class PluginRegistry { public static let shared = PluginRegistry() private var plugins: [String: AnyObject] = [:] - - private init() {} + + private init() { + defer { registerAllPlugins() } + } public func registerPlugin(_ plugin: SnapshotTestingPlugin) { plugins[type(of: plugin).identifier] = plugin @@ -24,31 +28,21 @@ public class PluginRegistry { public func imageSerializerPlugins() -> [ImageSerialization] { return Array(plugins.values).compactMap { $0 as? ImageSerialization } } -} - -// MARK: - Plugin AutoRegistry -// If we are not on macOS the autoregistration mechanism won't work. -#if canImport(ObjectiveC.runtime) -import Foundation -import ObjectiveC.runtime - -var hasRegisterPlugins = false -func registerAllPlugins() { - if hasRegisterPlugins { return } - defer { hasRegisterPlugins = true } - let count = objc_getClassList(nil, 0) - let classes = UnsafeMutablePointer.allocate(capacity: Int(count)) - let autoreleasingClasses = AutoreleasingUnsafeMutablePointer(classes) - objc_getClassList(autoreleasingClasses, count) - - for i in 0...allocate(capacity: Int(count)) + let autoreleasingClasses = AutoreleasingUnsafeMutablePointer(classes) + objc_getClassList(autoreleasingClasses, count) + + for i in 0.. Date: Wed, 11 Sep 2024 12:46:47 +0200 Subject: [PATCH 14/22] fix: improve documentation --- .../Plug-ins/PluginRegistry.swift | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 6c09de261..0d77796dd 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -29,20 +29,39 @@ public class PluginRegistry { return Array(plugins.values).compactMap { $0 as? ImageSerialization } } + /// Registers all classes that conform to the `SnapshotTestingPlugin` protocol. + /// + /// This function iterates over all classes known to the Objective-C runtime and registers any class + /// that conforms to the `SnapshotTestingPlugin` protocol as a plugin. The plugin classes are expected to + /// implement the `SnapshotTestingPlugin` protocol and have a parameterless initializer. + /// + /// The process is as follows: + /// 1. The function first queries the Objective-C runtime to get the total number of classes. + /// 2. It allocates memory to hold references to these classes. + /// 3. It retrieves all class references into the allocated memory. + /// 4. It then iterates through each class reference, checking if it conforms to the `SnapshotTestingPlugin` protocol. + /// 5. If a class conforms, it is instantiated and registered as a plugin using the `registerPlugin(_:)` method. func registerAllPlugins() { - let count = objc_getClassList(nil, 0) - let classes = UnsafeMutablePointer.allocate(capacity: Int(count)) + let classCount = objc_getClassList(nil, 0) + guard classCount > 0 else { return } + + let classes = UnsafeMutablePointer.allocate(capacity: Int(classCount)) + defer { classes.deallocate() } + let autoreleasingClasses = AutoreleasingUnsafeMutablePointer(classes) - objc_getClassList(autoreleasingClasses, count) + objc_getClassList(autoreleasingClasses, classCount) - for i in 0.. Date: Wed, 11 Sep 2024 12:48:40 +0200 Subject: [PATCH 15/22] fix: improve documentation --- Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index 0d77796dd..d4fe9247c 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -54,11 +54,8 @@ public class PluginRegistry { for i in 0.. Date: Wed, 11 Sep 2024 13:23:17 +0200 Subject: [PATCH 16/22] fix: improve documentation --- .../ImageSerializationPlugin.swift | 53 ++++++++++++++++- .../Plug-ins/ImageSerializer.swift | 52 +++++++++++++++-- .../Plug-ins/PluginRegistry.swift | 57 +++++++++++++------ .../SnapshotTestingPlugin.swift | 20 ++++++- 4 files changed, 154 insertions(+), 28 deletions(-) diff --git a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift index ba200e994..f50901e76 100644 --- a/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift +++ b/Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift @@ -4,26 +4,70 @@ import SnapshotTestingPlugin #if canImport(UIKit) import UIKit.UIImage +/// A type alias for `UIImage` when UIKit is available. public typealias SnapImage = UIImage #elseif canImport(AppKit) import AppKit.NSImage +/// A type alias for `NSImage` when AppKit is available. public typealias SnapImage = NSImage #endif -// Way to go around the limitation of @objc +/// A type alias that combines `ImageSerialization` and `SnapshotTestingPlugin` protocols. +/// +/// `ImageSerializationPlugin` is a convenient alias used to conform to both `ImageSerialization` and `SnapshotTestingPlugin` protocols. +/// This allows for image serialization plugins that also support snapshot testing, leveraging the Objective-C runtime while maintaining image serialization capabilities. public typealias ImageSerializationPlugin = ImageSerialization & SnapshotTestingPlugin +// TODO: async throws will be added later to encodeImage and decodeImage +/// A protocol that defines methods for encoding and decoding images in various formats. +/// +/// The `ImageSerialization` protocol is intended for classes that provide functionality to serialize (encode) and deserialize (decode) images. +/// Implementing this protocol allows a class to specify the image format it supports and to handle image data conversions. +/// This protocol is designed to be used in environments where SwiftUI is available and supports platform-specific image types via `SnapImage`. public protocol ImageSerialization { + + /// The image format that the serialization plugin supports. + /// + /// Each conforming class must specify the format it handles, using the `ImageSerializationFormat` enum. This property helps the `ImageSerializer` + /// determine which plugin to use for a given format during image encoding and decoding. static var imageFormat: ImageSerializationFormat { get } - func encodeImage(_ image: SnapImage) /*async throws*/ -> Data? - func decodeImage(_ data: Data) /*async throws*/ -> SnapImage? + + /// Encodes a `SnapImage` into a data representation. + /// + /// This method converts the provided image into the appropriate data format. It may eventually support asynchronous operations and error handling using `async throws`. + /// + /// - Parameter image: The image to be encoded. + /// - Returns: The encoded image data, or `nil` if encoding fails. + func encodeImage(_ image: SnapImage) -> Data? + + /// Decodes image data into a `SnapImage`. + /// + /// This method converts the provided data back into an image. It may eventually support asynchronous operations and error handling using `async throws`. + /// + /// - Parameter data: The image data to be decoded. + /// - Returns: The decoded image, or `nil` if decoding fails. + func decodeImage(_ data: Data) -> SnapImage? } #endif +/// An enumeration that defines the image formats supported by the `ImageSerialization` protocol. +/// +/// The `ImageSerializationFormat` enum is used to represent various image formats. It includes a predefined case for PNG images and a flexible case for plugins, +/// allowing for the extension of formats via plugins identified by unique string values. public enum ImageSerializationFormat: RawRepresentable, Sendable, Equatable { + /// Represents the default image format aka PNG. case png + + /// Represents a custom image format provided by a plugin. + /// + /// This case allows for the extension of image formats beyond the predefined ones by using a unique string identifier. case plugins(String) + /// Initializes an `ImageSerializationFormat` instance from a raw string value. + /// + /// This initializer converts a string value into an appropriate `ImageSerializationFormat` case. + /// + /// - Parameter rawValue: The string representation of the image format. public init?(rawValue: String) { switch rawValue { case "png": self = .png @@ -31,6 +75,9 @@ public enum ImageSerializationFormat: RawRepresentable, Sendable, Equatable { } } + /// The raw string value of the `ImageSerializationFormat`. + /// + /// This computed property returns the string representation of the current image format. public var rawValue: String { switch self { case .png: return "png" diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift index 47f62d486..5704ed3b9 100644 --- a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift @@ -8,12 +8,32 @@ import UIKit import AppKit #endif +/// A class responsible for encoding and decoding images using various image serialization plugins. +/// +/// The `ImageSerializer` class leverages plugins that conform to the `ImageSerialization` protocol to encode and decode images in different formats. +/// It automatically retrieves all available image serialization plugins from the `PluginRegistry` and uses them based on the specified `ImageSerializationFormat`. +/// If no plugin is found for the requested format, it defaults to using PNG encoding/decoding. public class ImageSerializer { - public init() {} + + /// A collection of plugins that conform to the `ImageSerialization` protocol. + let plugins: [ImageSerialization] + + public init() { + self.plugins = PluginRegistry.shared.allPlugins() + } - // async throws will be added later + // TODO: async throws will be added later + /// Encodes a given image into the specified image format using the appropriate plugin. + /// + /// This method attempts to encode the provided `SnapImage` into the desired format using the first plugin that supports the specified `ImageSerializationFormat`. + /// If no plugin is found for the format, it defaults to encoding the image as PNG. + /// + /// - Parameters: + /// - image: The `SnapImage` to encode. + /// - imageFormat: The format in which to encode the image. + /// - Returns: The encoded image data, or `nil` if encoding fails. public func encodeImage(_ image: SnapImage, imageFormat: ImageSerializationFormat) /*async throws*/ -> Data? { - for plugin in PluginRegistry.shared.imageSerializerPlugins() { + for plugin in self.plugins { if type(of: plugin).imageFormat == imageFormat { return /*try await*/ plugin.encodeImage(image) } @@ -22,9 +42,18 @@ public class ImageSerializer { return encodePNG(image) } - // async throws will be added later + // TODO: async throws will be added later + /// Decodes image data into a `SnapImage` using the appropriate plugin based on the specified image format. + /// + /// This method attempts to decode the provided data into a `SnapImage` using the first plugin that supports the specified `ImageSerializationFormat`. + /// If no plugin is found for the format, it defaults to decoding the data as PNG. + /// + /// - Parameters: + /// - data: The image data to decode. + /// - imageFormat: The format in which the image data is encoded. + /// - Returns: The decoded `SnapImage`, or `nil` if decoding fails. public func decodeImage(_ data: Data, imageFormat: ImageSerializationFormat) /*async throws*/ -> SnapImage? { - for plugin in PluginRegistry.shared.imageSerializerPlugins() { + for plugin in self.plugins { if type(of: plugin).imageFormat == imageFormat { return /*try await*/ plugin.decodeImage(data) } @@ -34,6 +63,13 @@ public class ImageSerializer { } // MARK: - Actual default Image Serializer + + /// Encodes a `SnapImage` as PNG data. + /// + /// This method provides a default implementation for encoding images as PNG. It is used as a fallback if no suitable plugin is found for the requested format. + /// + /// - Parameter image: The `SnapImage` to encode. + /// - Returns: The encoded PNG data, or `nil` if encoding fails. private func encodePNG(_ image: SnapImage) -> Data? { #if canImport(UIKit) return image.pngData() @@ -46,6 +82,12 @@ public class ImageSerializer { #endif } + /// Decodes PNG data into a `SnapImage`. + /// + /// This method provides a default implementation for decoding PNG data into a `SnapImage`. It is used as a fallback if no suitable plugin is found for the requested format. + /// + /// - Parameter data: The PNG data to decode. + /// - Returns: The decoded `SnapImage`, or `nil` if decoding fails. private func decodePNG(_ data: Data) -> SnapImage? { #if canImport(UIKit) return UIImage(data: data) diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index d4fe9247c..f47c2485f 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -4,42 +4,63 @@ import ObjectiveC.runtime import ImageSerializationPlugin import SnapshotTestingPlugin -// MARK: - PluginRegistry +/// A singleton class responsible for managing and registering plugins conforming to the `SnapshotTestingPlugin` protocol. +/// +/// The `PluginRegistry` class automatically discovers and registers all classes that conform to the `SnapshotTestingPlugin` protocol +/// within the Objective-C runtime. It provides methods to retrieve specific plugins by their identifier, get all registered plugins, +/// and filter plugins that conform to the `ImageSerialization` protocol. public class PluginRegistry { + /// The shared instance of the `PluginRegistry`, providing a single point of access. public static let shared = PluginRegistry() + + /// A dictionary holding the registered plugins, keyed by their identifier. private var plugins: [String: AnyObject] = [:] + /// Private initializer to enforce the singleton pattern. + /// + /// Upon initialization, the registry automatically calls `registerAllPlugins()` to discover and register plugins. private init() { defer { registerAllPlugins() } } + /// Registers a given plugin in the registry. + /// + /// - Parameter plugin: An instance of a class conforming to `SnapshotTestingPlugin`. public func registerPlugin(_ plugin: SnapshotTestingPlugin) { plugins[type(of: plugin).identifier] = plugin } - - public func plugin(for identifier: String) -> SnapshotTestingPlugin? { - return plugins[identifier] as? SnapshotTestingPlugin - } - - public func allPlugins() -> [SnapshotTestingPlugin] { - return Array(plugins.values.compactMap { $0 as? SnapshotTestingPlugin }) + + /// Retrieves a plugin from the registry by its identifier and casts it to the specified type. + /// + /// This method attempts to find a plugin in the registry that matches the given identifier and cast it to the specified generic type `Output`. + /// If the plugin exists and can be cast to the specified type, it is returned; otherwise, `nil` is returned. + /// + /// - Parameter identifier: A unique string identifier for the plugin. + /// - Returns: The plugin instance cast to the specified type `Output` if found and castable, otherwise `nil`. + public func plugin(for identifier: String) -> Output? { + return plugins[identifier] as? Output } - public func imageSerializerPlugins() -> [ImageSerialization] { - return Array(plugins.values).compactMap { $0 as? ImageSerialization } + /// Returns all registered plugins that can be cast to the specified type. + /// + /// This method retrieves all registered plugins and attempts to cast each one to the specified generic type `Output`. + /// Only the plugins that can be successfully cast to `Output` are included in the returned array. + /// + /// - Returns: An array of all registered plugins that can be cast to the specified type `Output`. + public func allPlugins() -> [Output] { + return Array(plugins.values.compactMap { $0 as? Output }) } - /// Registers all classes that conform to the `SnapshotTestingPlugin` protocol. + /// Discovers and registers all classes that conform to the `SnapshotTestingPlugin` protocol. /// - /// This function iterates over all classes known to the Objective-C runtime and registers any class - /// that conforms to the `SnapshotTestingPlugin` protocol as a plugin. The plugin classes are expected to - /// implement the `SnapshotTestingPlugin` protocol and have a parameterless initializer. + /// This method iterates over all classes in the Objective-C runtime, identifies those that conform to the `SnapshotTestingPlugin` + /// protocol, and registers them as plugins. The plugins are expected to have a parameterless initializer. /// /// The process is as follows: - /// 1. The function first queries the Objective-C runtime to get the total number of classes. - /// 2. It allocates memory to hold references to these classes. - /// 3. It retrieves all class references into the allocated memory. - /// 4. It then iterates through each class reference, checking if it conforms to the `SnapshotTestingPlugin` protocol. + /// 1. The function queries the Objective-C runtime for the total number of classes. + /// 2. Memory is allocated to hold references to these classes. + /// 3. All class references are retrieved into the allocated memory. + /// 4. Each class reference is checked for conformance to the `SnapshotTestingPlugin` protocol. /// 5. If a class conforms, it is instantiated and registered as a plugin using the `registerPlugin(_:)` method. func registerAllPlugins() { let classCount = objc_getClassList(nil, 0) diff --git a/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift b/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift index b8ea3ddc4..e49b4be42 100644 --- a/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift +++ b/Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift @@ -1,9 +1,25 @@ #if canImport(Foundation) && canImport(ObjectiveC) import Foundation -@objc -public protocol SnapshotTestingPlugin { +/// A protocol that defines a plugin for snapshot testing, designed to be used in environments that support Objective-C. +/// +/// The `SnapshotTestingPlugin` protocol is intended to be adopted by classes that provide specific functionality for snapshot testing. +/// It requires each conforming class to have a unique identifier and a parameterless initializer. This protocol is designed to be used in +/// environments where both Foundation and Objective-C are available, making it compatible with Objective-C runtime features. +/// +/// Conforming classes must be marked with `@objc` to ensure compatibility with Objective-C runtime mechanisms. +@objc public protocol SnapshotTestingPlugin { + + /// A unique string identifier for the plugin. + /// + /// Each plugin must provide a static identifier that uniquely distinguishes it from other plugins. This identifier is used + /// to register and retrieve plugins within a registry, ensuring that each plugin can be easily identified and utilized. static var identifier: String { get } + + /// Initializes a new instance of the plugin. + /// + /// This initializer is required to allow the Objective-C runtime to create instances of the plugin class when registering + /// and utilizing plugins. The initializer must not take any parameters. init() } #endif From db82c1a064e32710b2c89bd486c5956f7e5a58dd Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 16:35:57 +0200 Subject: [PATCH 17/22] feat: add support for withSnapshotTesting --- Sources/SnapshotTesting/AssertSnapshot.swift | 31 +++++++++++++++++-- .../Plug-ins/PluginRegistry.swift | 1 + .../SnapshotTestingConfiguration.swift | 17 +++++++--- .../SnapshotTesting/SnapshotsTestTrait.swift | 7 +++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 9a979d55b..3e28dca08 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -7,8 +7,35 @@ import ImageSerializationPlugin @_implementationOnly import Testing #endif -/// We can set the image format globally to better test -public var imageFormat = ImageSerializationFormat.png +public var imageFormat: ImageSerializationFormat { + get { + _imageFormat + } + set { _imageFormat = newValue } +} + +@_spi(Internals) +public var _imageFormat: ImageSerializationFormat { + get { +#if canImport(Testing) + if let test = Test.current { + for trait in test.traits.reversed() { + if let diffTool = (trait as? _SnapshotsTestTrait)?.configuration.imageFormat { + return diffTool + } + } + } +#endif + return __imageFormat + } + set { + __imageFormat = newValue + } +} + +@_spi(Internals) +public var __imageFormat: ImageSerializationFormat = .png + /// Enhances failure messages with a command line diff tool expression that can be copied and pasted /// into a terminal. diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift index f47c2485f..be018edbb 100644 --- a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift @@ -75,6 +75,7 @@ public class PluginRegistry { for i in 0..( record: SnapshotTestingConfiguration.Record? = nil, diffTool: SnapshotTestingConfiguration.DiffTool? = nil, + imageFormat: ImageSerializationFormat? = nil, operation: () throws -> R ) rethrows -> R { try SnapshotTestingConfiguration.$current.withValue( SnapshotTestingConfiguration( record: record ?? SnapshotTestingConfiguration.current?.record ?? _record, - diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool - ?? SnapshotTesting._diffTool + diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? SnapshotTesting._diffTool, + imageFormat: imageFormat ?? SnapshotTestingConfiguration.current?.imageFormat ?? _imageFormat ) ) { try operation() @@ -45,12 +48,14 @@ public func withSnapshotTesting( public func withSnapshotTesting( record: SnapshotTestingConfiguration.Record? = nil, diffTool: SnapshotTestingConfiguration.DiffTool? = nil, + imageFormat: ImageSerializationFormat? = nil, operation: () async throws -> R ) async rethrows -> R { try await SnapshotTestingConfiguration.$current.withValue( SnapshotTestingConfiguration( record: record ?? SnapshotTestingConfiguration.current?.record ?? _record, - diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? _diffTool + diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? _diffTool, + imageFormat: imageFormat ?? SnapshotTestingConfiguration.current?.imageFormat ?? _imageFormat ) ) { try await operation() @@ -71,13 +76,17 @@ public struct SnapshotTestingConfiguration: Sendable { /// /// See ``Record-swift.struct`` for more information. public var record: Record? + + public var imageFormat: ImageSerializationFormat? public init( record: Record?, - diffTool: DiffTool? + diffTool: DiffTool?, + imageFormat: ImageSerializationFormat? ) { self.diffTool = diffTool self.record = record + self.imageFormat = imageFormat } /// The record mode of the snapshot test. diff --git a/Sources/SnapshotTesting/SnapshotsTestTrait.swift b/Sources/SnapshotTesting/SnapshotsTestTrait.swift index 95c4b7915..dd3905865 100644 --- a/Sources/SnapshotTesting/SnapshotsTestTrait.swift +++ b/Sources/SnapshotTesting/SnapshotsTestTrait.swift @@ -2,6 +2,7 @@ // NB: We are importing only the implementation of Testing because that framework is not available // in Xcode UI test targets. @_implementationOnly import Testing + import ImageSerializationPlugin @_spi(Experimental) extension Trait where Self == _SnapshotsTestTrait { @@ -12,12 +13,14 @@ /// - diffTool: The diff tool to use in failure messages. public static func snapshots( record: SnapshotTestingConfiguration.Record? = nil, - diffTool: SnapshotTestingConfiguration.DiffTool? = nil + diffTool: SnapshotTestingConfiguration.DiffTool? = nil, + imageFormat: ImageSerializationFormat? = nil ) -> Self { _SnapshotsTestTrait( configuration: SnapshotTestingConfiguration( record: record, - diffTool: diffTool + diffTool: diffTool, + imageFormat: imageFormat ) ) } From 54cfbba88c6f7a079af49fcf4e83b8a9267344df Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Wed, 11 Sep 2024 21:33:05 +0200 Subject: [PATCH 18/22] feat: small renaming --- .../SnapshotTesting/{Plug-ins => Plugins}/ImageSerializer.swift | 0 .../SnapshotTesting/{Plug-ins => Plugins}/PluginRegistry.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Sources/SnapshotTesting/{Plug-ins => Plugins}/ImageSerializer.swift (100%) rename Sources/SnapshotTesting/{Plug-ins => Plugins}/PluginRegistry.swift (100%) diff --git a/Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift b/Sources/SnapshotTesting/Plugins/ImageSerializer.swift similarity index 100% rename from Sources/SnapshotTesting/Plug-ins/ImageSerializer.swift rename to Sources/SnapshotTesting/Plugins/ImageSerializer.swift diff --git a/Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift b/Sources/SnapshotTesting/Plugins/PluginRegistry.swift similarity index 100% rename from Sources/SnapshotTesting/Plug-ins/PluginRegistry.swift rename to Sources/SnapshotTesting/Plugins/PluginRegistry.swift From a7f218c94e304f2e324ec3f39c9048dc858a0c02 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Fri, 13 Sep 2024 14:28:00 +0200 Subject: [PATCH 19/22] feat: add documentation / improve naming --- Sources/SnapshotTesting/AssertSnapshot.swift | 7 +++ .../Plugins/ImageSerializer.swift | 2 +- .../Plugins/PluginRegistry.swift | 43 ++++++++++++++++--- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 3e28dca08..9761433cd 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -7,6 +7,13 @@ import ImageSerializationPlugin @_implementationOnly import Testing #endif +/// Whether or not to change the default output image format to something else. +@available( + *, + deprecated, + message: + "Use 'withSnapshotTesting' to customize the image output format. See the documentation for more information." +) public var imageFormat: ImageSerializationFormat { get { _imageFormat diff --git a/Sources/SnapshotTesting/Plugins/ImageSerializer.swift b/Sources/SnapshotTesting/Plugins/ImageSerializer.swift index 5704ed3b9..5250ccc13 100644 --- a/Sources/SnapshotTesting/Plugins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plugins/ImageSerializer.swift @@ -19,7 +19,7 @@ public class ImageSerializer { let plugins: [ImageSerialization] public init() { - self.plugins = PluginRegistry.shared.allPlugins() + self.plugins = PluginRegistry.allPlugins() } // TODO: async throws will be added later diff --git a/Sources/SnapshotTesting/Plugins/PluginRegistry.swift b/Sources/SnapshotTesting/Plugins/PluginRegistry.swift index be018edbb..f7c37734d 100644 --- a/Sources/SnapshotTesting/Plugins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plugins/PluginRegistry.swift @@ -11,7 +11,7 @@ import SnapshotTestingPlugin /// and filter plugins that conform to the `ImageSerialization` protocol. public class PluginRegistry { /// The shared instance of the `PluginRegistry`, providing a single point of access. - public static let shared = PluginRegistry() + private static let shared = PluginRegistry() /// A dictionary holding the registered plugins, keyed by their identifier. private var plugins: [String: AnyObject] = [:] @@ -20,13 +20,42 @@ public class PluginRegistry { /// /// Upon initialization, the registry automatically calls `registerAllPlugins()` to discover and register plugins. private init() { - defer { registerAllPlugins() } + defer { automaticPluginRegistration() } } /// Registers a given plugin in the registry. /// /// - Parameter plugin: An instance of a class conforming to `SnapshotTestingPlugin`. - public func registerPlugin(_ plugin: SnapshotTestingPlugin) { + public static func registerPlugin(_ plugin: SnapshotTestingPlugin) { + PluginRegistry.shared.registerPlugin(plugin) + } + + /// Retrieves a plugin from the registry by its identifier and casts it to the specified type. + /// + /// This method attempts to find a plugin in the registry that matches the given identifier and cast it to the specified generic type `Output`. + /// If the plugin exists and can be cast to the specified type, it is returned; otherwise, `nil` is returned. + /// + /// - Parameter identifier: A unique string identifier for the plugin. + /// - Returns: The plugin instance cast to the specified type `Output` if found and castable, otherwise `nil`. + public static func plugin(for identifier: String) -> Output? { + PluginRegistry.shared.plugin(for: identifier) + } + + /// Returns all registered plugins that can be cast to the specified type. + /// + /// This method retrieves all registered plugins and attempts to cast each one to the specified generic type `Output`. + /// Only the plugins that can be successfully cast to `Output` are included in the returned array. + /// + /// - Returns: An array of all registered plugins that can be cast to the specified type `Output`. + public static func allPlugins() -> [Output] { + PluginRegistry.shared.allPlugins() + } + + // MARK: - Internal Representation + /// Registers a given plugin in the registry. + /// + /// - Parameter plugin: An instance of a class conforming to `SnapshotTestingPlugin`. + private func registerPlugin(_ plugin: SnapshotTestingPlugin) { plugins[type(of: plugin).identifier] = plugin } @@ -37,7 +66,7 @@ public class PluginRegistry { /// /// - Parameter identifier: A unique string identifier for the plugin. /// - Returns: The plugin instance cast to the specified type `Output` if found and castable, otherwise `nil`. - public func plugin(for identifier: String) -> Output? { + private func plugin(for identifier: String) -> Output? { return plugins[identifier] as? Output } @@ -47,10 +76,10 @@ public class PluginRegistry { /// Only the plugins that can be successfully cast to `Output` are included in the returned array. /// /// - Returns: An array of all registered plugins that can be cast to the specified type `Output`. - public func allPlugins() -> [Output] { + private func allPlugins() -> [Output] { return Array(plugins.values.compactMap { $0 as? Output }) } - + /// Discovers and registers all classes that conform to the `SnapshotTestingPlugin` protocol. /// /// This method iterates over all classes in the Objective-C runtime, identifies those that conform to the `SnapshotTestingPlugin` @@ -62,7 +91,7 @@ public class PluginRegistry { /// 3. All class references are retrieved into the allocated memory. /// 4. Each class reference is checked for conformance to the `SnapshotTestingPlugin` protocol. /// 5. If a class conforms, it is instantiated and registered as a plugin using the `registerPlugin(_:)` method. - func registerAllPlugins() { + private func automaticPluginRegistration() { let classCount = objc_getClassList(nil, 0) guard classCount > 0 else { return } From 6b149c5142a821259e9ba7f4842cefe8898a34cc Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Fri, 13 Sep 2024 14:29:49 +0200 Subject: [PATCH 20/22] feat: update documentation --- .../Plugins/PluginRegistry.swift | 86 ++++++++----------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/Sources/SnapshotTesting/Plugins/PluginRegistry.swift b/Sources/SnapshotTesting/Plugins/PluginRegistry.swift index f7c37734d..63c64af4c 100644 --- a/Sources/SnapshotTesting/Plugins/PluginRegistry.swift +++ b/Sources/SnapshotTesting/Plugins/PluginRegistry.swift @@ -6,91 +6,76 @@ import SnapshotTestingPlugin /// A singleton class responsible for managing and registering plugins conforming to the `SnapshotTestingPlugin` protocol. /// -/// The `PluginRegistry` class automatically discovers and registers all classes that conform to the `SnapshotTestingPlugin` protocol -/// within the Objective-C runtime. It provides methods to retrieve specific plugins by their identifier, get all registered plugins, -/// and filter plugins that conform to the `ImageSerialization` protocol. +/// The `PluginRegistry` automatically discovers and registers classes conforming to the `SnapshotTestingPlugin` protocol +/// within the Objective-C runtime. It allows retrieval of specific plugins by identifier, access to all registered plugins, +/// and filtering of plugins that conform to the `ImageSerialization` protocol. public class PluginRegistry { - /// The shared instance of the `PluginRegistry`, providing a single point of access. + + /// Shared singleton instance of `PluginRegistry`. private static let shared = PluginRegistry() - - /// A dictionary holding the registered plugins, keyed by their identifier. + + /// Dictionary holding registered plugins, keyed by their identifier. private var plugins: [String: AnyObject] = [:] - - /// Private initializer to enforce the singleton pattern. + + /// Private initializer enforcing the singleton pattern. /// - /// Upon initialization, the registry automatically calls `registerAllPlugins()` to discover and register plugins. + /// Automatically triggers `automaticPluginRegistration()` to discover and register plugins. private init() { defer { automaticPluginRegistration() } } - /// Registers a given plugin in the registry. + // MARK: - Public Methods + + /// Registers a plugin. /// - /// - Parameter plugin: An instance of a class conforming to `SnapshotTestingPlugin`. + /// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`. public static func registerPlugin(_ plugin: SnapshotTestingPlugin) { PluginRegistry.shared.registerPlugin(plugin) } - - /// Retrieves a plugin from the registry by its identifier and casts it to the specified type. - /// - /// This method attempts to find a plugin in the registry that matches the given identifier and cast it to the specified generic type `Output`. - /// If the plugin exists and can be cast to the specified type, it is returned; otherwise, `nil` is returned. + + /// Retrieves a plugin by its identifier, casting it to the specified type. /// - /// - Parameter identifier: A unique string identifier for the plugin. - /// - Returns: The plugin instance cast to the specified type `Output` if found and castable, otherwise `nil`. + /// - Parameter identifier: The unique identifier for the plugin. + /// - Returns: The plugin instance cast to `Output` if found and castable, otherwise `nil`. public static func plugin(for identifier: String) -> Output? { PluginRegistry.shared.plugin(for: identifier) } - /// Returns all registered plugins that can be cast to the specified type. + /// Returns all registered plugins cast to the specified type. /// - /// This method retrieves all registered plugins and attempts to cast each one to the specified generic type `Output`. - /// Only the plugins that can be successfully cast to `Output` are included in the returned array. - /// - /// - Returns: An array of all registered plugins that can be cast to the specified type `Output`. + /// - Returns: An array of all registered plugins that can be cast to `Output`. public static func allPlugins() -> [Output] { PluginRegistry.shared.allPlugins() } - - // MARK: - Internal Representation - /// Registers a given plugin in the registry. + + // MARK: - Internal Methods + + /// Registers a plugin. /// - /// - Parameter plugin: An instance of a class conforming to `SnapshotTestingPlugin`. + /// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`. private func registerPlugin(_ plugin: SnapshotTestingPlugin) { plugins[type(of: plugin).identifier] = plugin } - - /// Retrieves a plugin from the registry by its identifier and casts it to the specified type. - /// - /// This method attempts to find a plugin in the registry that matches the given identifier and cast it to the specified generic type `Output`. - /// If the plugin exists and can be cast to the specified type, it is returned; otherwise, `nil` is returned. + + /// Retrieves a plugin by its identifier, casting it to the specified type. /// - /// - Parameter identifier: A unique string identifier for the plugin. - /// - Returns: The plugin instance cast to the specified type `Output` if found and castable, otherwise `nil`. + /// - Parameter identifier: The unique identifier for the plugin. + /// - Returns: The plugin instance cast to `Output` if found and castable, otherwise `nil`. private func plugin(for identifier: String) -> Output? { return plugins[identifier] as? Output } - /// Returns all registered plugins that can be cast to the specified type. + /// Returns all registered plugins cast to the specified type. /// - /// This method retrieves all registered plugins and attempts to cast each one to the specified generic type `Output`. - /// Only the plugins that can be successfully cast to `Output` are included in the returned array. - /// - /// - Returns: An array of all registered plugins that can be cast to the specified type `Output`. + /// - Returns: An array of all registered plugins that can be cast to `Output`. private func allPlugins() -> [Output] { return Array(plugins.values.compactMap { $0 as? Output }) } - - /// Discovers and registers all classes that conform to the `SnapshotTestingPlugin` protocol. - /// - /// This method iterates over all classes in the Objective-C runtime, identifies those that conform to the `SnapshotTestingPlugin` - /// protocol, and registers them as plugins. The plugins are expected to have a parameterless initializer. + + /// Discovers and registers all classes conforming to the `SnapshotTestingPlugin` protocol. /// - /// The process is as follows: - /// 1. The function queries the Objective-C runtime for the total number of classes. - /// 2. Memory is allocated to hold references to these classes. - /// 3. All class references are retrieved into the allocated memory. - /// 4. Each class reference is checked for conformance to the `SnapshotTestingPlugin` protocol. - /// 5. If a class conforms, it is instantiated and registered as a plugin using the `registerPlugin(_:)` method. + /// This method iterates over all Objective-C runtime classes, identifying those that conform to `SnapshotTestingPlugin`, + /// instantiating them, and registering them as plugins. private func automaticPluginRegistration() { let classCount = objc_getClassList(nil, 0) guard classCount > 0 else { return } @@ -110,6 +95,5 @@ public class PluginRegistry { self.registerPlugin(pluginType.init()) } } - } #endif From 106c297467aec6098557cd4c233a5f7fcee011d4 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Fri, 13 Sep 2024 15:05:18 +0200 Subject: [PATCH 21/22] feat: update documentation --- README.md | 14 +++++++++- .../Documentation.docc/Articles/Plugins.md | 27 +++++++++++++++++++ .../Documentation.docc/SnapshotTesting.md | 4 +++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md diff --git a/README.md b/README.md index 5782f945a..4bd214563 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ targets: [ [available-strategies]: https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting/snapshotting [defining-strategies]: https://swiftpackageindex.com/pointfreeco/swift-snapshot-testing/main/documentation/snapshottesting/customstrategies -## Plug-ins +## Strategies / Plug-ins - [AccessibilitySnapshot](https://github.com/cashapp/AccessibilitySnapshot) adds easy regression testing for iOS accessibility. @@ -273,6 +273,18 @@ targets: [ - [SnapshotVision](https://github.com/gregersson/swift-snapshot-testing-vision) adds snapshot strategy for text recognition on views and images. Uses Apples Vision framework. + - [ImageSerializer HEIC](https://github.com/mackoj/swift-snapshot-testing-plugin-heic) make all the + strategy that create image as output to store them in `.heic` storage format which reduces file sizes + in comparison to PNG. + + - [ImageSerializer WEBP](https://github.com/mackoj/swift-snapshot-testing-plugin-heic) make all the + strategy that create image as output to store them in `.webp` storage format which reduces file sizes + in comparison to PNG. + + - [ImageSerializer JXL](https://github.com/mackoj/swift-snapshot-testing-plugin-heic) make all the + strategy that create image as output to store them in `.jxl` storage format which reduces file sizes + in comparison to PNG. + Have you written your own SnapshotTesting plug-in? [Add it here](https://github.com/pointfreeco/swift-snapshot-testing/edit/master/README.md) and submit a pull request! diff --git a/Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md b/Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md new file mode 100644 index 000000000..42ef29e31 --- /dev/null +++ b/Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md @@ -0,0 +1,27 @@ +# Plugins + +SnapshotTesting offers a wide range of built-in snapshot strategies, and over the years, third-party developers have introduced new ones. However, when there’s a need for functionality that spans multiple strategies, plugins become essential. + +## Overview + +Plugins provide greater flexibility and extensibility by enabling shared behavior across different strategies without the need to duplicate code or modify each strategy individually. They can be dynamically discovered, registered, and executed at runtime, making them ideal for adding new functionality without altering the core system. This architecture promotes modularity and decoupling, allowing features to be easily added or swapped out without impacting existing functionality. + +### Plugin architecture + +The plugin architecture is designed around the concept of **dynamic discovery and registration**. Plugins conform to specific protocols, such as `SnapshotTestingPlugin`, and are registered automatically by the `PluginRegistry`. This registry manages plugin instances, allowing them to be retrieved by identifier or filtered by the protocols they conform to. + +The primary components of the plugin system include: + +- **Plugin Protocols**: Define the behavior that plugins must implement. +- **PluginRegistry**: Manages plugin discovery, registration, and retrieval. +- **Objective-C Runtime Integration**: Allows automatic discovery of plugins that conform to specific protocols. + +The `PluginRegistry` is a singleton that registers plugins during its initialization. Plugins can be retrieved by their identifier or cast to specific types, allowing flexible interaction. + +## ImageSerializer + +The `ImageSerializer` is a plugin-based system that provides support for encoding and decoding images. It leverages the plugin architecture to extend its support for different image formats without needing to modify the core system. + +Plugins that conform to the `ImageSerializationPlugin` protocol can be registered into the `PluginRegistry` and used to encode or decode images in different formats, such as PNG, JPEG, WebP, HEIC, and more. + +When a plugin supporting a specific image format is available, the `ImageSerializer` can dynamically choose the correct plugin based on the image format required, ensuring modularity and scalability in image handling. diff --git a/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md index 8704d920d..42ed0d4e2 100644 --- a/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md +++ b/Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md @@ -23,6 +23,10 @@ Powerfully flexible snapshot testing. - ``withSnapshotTesting(record:diffTool:operation:)-2kuyr`` - ``SnapshotTestingConfiguration`` +### Plugins + +- + ### Deprecations - From 649ce56273f7438f24bd6b3f60e865718643edbf Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Fri, 13 Sep 2024 15:08:53 +0200 Subject: [PATCH 22/22] feat: code cleaning --- Sources/SnapshotTesting/Plugins/ImageSerializer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SnapshotTesting/Plugins/ImageSerializer.swift b/Sources/SnapshotTesting/Plugins/ImageSerializer.swift index 5250ccc13..356b7f848 100644 --- a/Sources/SnapshotTesting/Plugins/ImageSerializer.swift +++ b/Sources/SnapshotTesting/Plugins/ImageSerializer.swift @@ -35,7 +35,7 @@ public class ImageSerializer { public func encodeImage(_ image: SnapImage, imageFormat: ImageSerializationFormat) /*async throws*/ -> Data? { for plugin in self.plugins { if type(of: plugin).imageFormat == imageFormat { - return /*try await*/ plugin.encodeImage(image) + return plugin.encodeImage(image) } } // Default to PNG @@ -55,7 +55,7 @@ public class ImageSerializer { public func decodeImage(_ data: Data, imageFormat: ImageSerializationFormat) /*async throws*/ -> SnapImage? { for plugin in self.plugins { if type(of: plugin).imageFormat == imageFormat { - return /*try await*/ plugin.decodeImage(data) + return plugin.decodeImage(data) } } // Default to PNG