Skip to content

Add a PluginAPI #904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ let package = Package(
name: "SnapshotTesting",
targets: ["SnapshotTesting"]
),
.library(
name: "SnapshotTestingPlugin",
targets: ["SnapshotTestingPlugin"]
),
.library(
name: "ImageSerializationPlugin",
targets: ["ImageSerializationPlugin"]
),
.library(
name: "InlineSnapshotTesting",
targets: ["InlineSnapshotTesting"]
Expand All @@ -25,7 +33,13 @@ let package = Package(
],
targets: [
.target(
name: "SnapshotTesting"
name: "SnapshotTesting",
dependencies: ["ImageSerializationPlugin"]
),
.target(name: "SnapshotTestingPlugin"),
.target(
name: "ImageSerializationPlugin",
dependencies: ["SnapshotTestingPlugin"]
),
.target(
name: "InlineSnapshotTesting",
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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!
Expand Down
87 changes: 87 additions & 0 deletions Sources/ImageSerializationPlugin/ImageSerializationPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#if canImport(SwiftUI)
import Foundation
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

/// 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 }

/// 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
default: self = .plugins(rawValue)
}
}

/// 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"
case let .plugins(value): return value
}
}
}
38 changes: 38 additions & 0 deletions Sources/SnapshotTesting/AssertSnapshot.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
import XCTest
import ImageSerializationPlugin

#if canImport(Testing)
// NB: We are importing only the implementation of Testing because that framework is not available
// in Xcode UI test targets.
@_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
}
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.
@available(
Expand Down
27 changes: 27 additions & 0 deletions Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Powerfully flexible snapshot testing.
- ``withSnapshotTesting(record:diffTool:operation:)-2kuyr``
- ``SnapshotTestingConfiguration``

### Plugins

- <doc:Plugins>

### Deprecations

- <doc:SnapshotTestingDeprecations>
99 changes: 99 additions & 0 deletions Sources/SnapshotTesting/Plugins/ImageSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#if canImport(SwiftUI)
import Foundation
import ImageSerializationPlugin

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
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 {

/// A collection of plugins that conform to the `ImageSerialization` protocol.
let plugins: [ImageSerialization]

public init() {
self.plugins = PluginRegistry.allPlugins()
}

// 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 self.plugins {
if type(of: plugin).imageFormat == imageFormat {
return plugin.encodeImage(image)
}
}
// Default to PNG
return encodePNG(image)
}

// 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 self.plugins {
if type(of: plugin).imageFormat == imageFormat {
return plugin.decodeImage(data)
}
}
// Default to PNG
return decodePNG(data)
}

// 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()
#elseif canImport(AppKit)
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
}

/// 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)
#elseif canImport(AppKit)
return NSImage(data: data)
#endif
}
}
#endif
Loading
Loading