Skip to content

Commit 225ea84

Browse files
committed
feat: add Image Serialization Plugin
1 parent de77dba commit 225ea84

20 files changed

+304
-88
lines changed

Package.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ let package = Package(
1919
name: "SnapshotTestingPlugin",
2020
targets: ["SnapshotTestingPlugin"]
2121
),
22+
.library(
23+
name: "ImageSerializationPlugin",
24+
targets: ["ImageSerializationPlugin"]
25+
),
2226
.library(
2327
name: "InlineSnapshotTesting",
2428
targets: ["InlineSnapshotTesting"]
@@ -30,9 +34,16 @@ let package = Package(
3034
targets: [
3135
.target(
3236
name: "SnapshotTesting",
33-
dependencies: ["SnapshotTestingPlugin"]
37+
dependencies: [
38+
"ImageSerializationPlugin",
39+
"SnapshotTestingPlugin"
40+
]
3441
),
3542
.target(name: "SnapshotTestingPlugin"),
43+
.target(
44+
name: "ImageSerializationPlugin",
45+
dependencies: ["SnapshotTestingPlugin"]
46+
),
3647
.target(
3748
name: "InlineSnapshotTesting",
3849
dependencies: [
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#if canImport(SwiftUI)
2+
import Foundation
3+
import SnapshotTestingPlugin
4+
5+
#if canImport(UIKit)
6+
import UIKit.UIImage
7+
/// A type alias for `UIImage` when UIKit is available.
8+
public typealias SnapImage = UIImage
9+
#elseif canImport(AppKit)
10+
import AppKit.NSImage
11+
/// A type alias for `NSImage` when AppKit is available.
12+
public typealias SnapImage = NSImage
13+
#endif
14+
15+
/// A type alias that combines `ImageSerialization` and `SnapshotTestingPlugin` protocols.
16+
///
17+
/// `ImageSerializationPlugin` is a convenient alias used to conform to both `ImageSerialization` and `SnapshotTestingPlugin` protocols.
18+
/// This allows for image serialization plugins that also support snapshot testing, leveraging the Objective-C runtime while maintaining image serialization capabilities.
19+
public typealias ImageSerializationPlugin = ImageSerialization & SnapshotTestingPlugin
20+
21+
// TODO: async throws will be added later to encodeImage and decodeImage
22+
/// A protocol that defines methods for encoding and decoding images in various formats.
23+
///
24+
/// The `ImageSerialization` protocol is intended for classes that provide functionality to serialize (encode) and deserialize (decode) images.
25+
/// Implementing this protocol allows a class to specify the image format it supports and to handle image data conversions.
26+
/// This protocol is designed to be used in environments where SwiftUI is available and supports platform-specific image types via `SnapImage`.
27+
public protocol ImageSerialization {
28+
29+
/// The image format that the serialization plugin supports.
30+
///
31+
/// Each conforming class must specify the format it handles, using the `ImageSerializationFormat` enum. This property helps the `ImageSerializer`
32+
/// determine which plugin to use for a given format during image encoding and decoding.
33+
static var imageFormat: ImageSerializationFormat { get }
34+
35+
/// Encodes a `SnapImage` into a data representation.
36+
///
37+
/// This method converts the provided image into the appropriate data format. It may eventually support asynchronous operations and error handling using `async throws`.
38+
///
39+
/// - Parameter image: The image to be encoded.
40+
/// - Returns: The encoded image data, or `nil` if encoding fails.
41+
func encodeImage(_ image: SnapImage) -> Data?
42+
43+
/// Decodes image data into a `SnapImage`.
44+
///
45+
/// This method converts the provided data back into an image. It may eventually support asynchronous operations and error handling using `async throws`.
46+
///
47+
/// - Parameter data: The image data to be decoded.
48+
/// - Returns: The decoded image, or `nil` if decoding fails.
49+
func decodeImage(_ data: Data) -> SnapImage?
50+
}
51+
#endif
52+
53+
/// An enumeration that defines the image formats supported by the `ImageSerialization` protocol.
54+
///
55+
/// The `ImageSerializationFormat` enum is used to represent various image formats. It includes a predefined case for PNG images and a flexible case for plugins,
56+
/// allowing for the extension of formats via plugins identified by unique string values.
57+
public enum ImageSerializationFormat: RawRepresentable, Sendable, Equatable {
58+
/// Represents the default image format aka PNG.
59+
case png
60+
61+
/// Represents a custom image format provided by a plugin.
62+
///
63+
/// This case allows for the extension of image formats beyond the predefined ones by using a unique string identifier.
64+
case plugins(String)
65+
66+
/// Initializes an `ImageSerializationFormat` instance from a raw string value.
67+
///
68+
/// This initializer converts a string value into an appropriate `ImageSerializationFormat` case.
69+
///
70+
/// - Parameter rawValue: The string representation of the image format.
71+
public init?(rawValue: String) {
72+
switch rawValue {
73+
case "png": self = .png
74+
default: self = .plugins(rawValue)
75+
}
76+
}
77+
78+
/// The raw string value of the `ImageSerializationFormat`.
79+
///
80+
/// This computed property returns the string representation of the current image format.
81+
public var rawValue: String {
82+
switch self {
83+
case .png: return "png"
84+
case let .plugins(value): return value
85+
}
86+
}
87+
}

Sources/SnapshotTesting/AssertSnapshot.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,49 @@
11
import XCTest
2+
import ImageSerializationPlugin
23

34
#if canImport(Testing)
45
// NB: We are importing only the implementation of Testing because that framework is not available
56
// in Xcode UI test targets.
67
@_implementationOnly import Testing
78
#endif
89

10+
/// Whether or not to change the default output image format to something else.
11+
@available(
12+
*,
13+
deprecated,
14+
message:
15+
"Use 'withSnapshotTesting' to customize the image output format. See the documentation for more information."
16+
)
17+
public var imageFormat: ImageSerializationFormat {
18+
get {
19+
_imageFormat
20+
}
21+
set { _imageFormat = newValue }
22+
}
23+
24+
@_spi(Internals)
25+
public var _imageFormat: ImageSerializationFormat {
26+
get {
27+
#if canImport(Testing)
28+
if let test = Test.current {
29+
for trait in test.traits.reversed() {
30+
if let diffTool = (trait as? _SnapshotsTestTrait)?.configuration.imageFormat {
31+
return diffTool
32+
}
33+
}
34+
}
35+
#endif
36+
return __imageFormat
37+
}
38+
set {
39+
__imageFormat = newValue
40+
}
41+
}
42+
43+
@_spi(Internals)
44+
public var __imageFormat: ImageSerializationFormat = .png
45+
46+
947
/// Enhances failure messages with a command line diff tool expression that can be copied and pasted
1048
/// into a terminal.
1149
@available(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Image Serialization Plugin
2+
3+
Image Serialization Plugin is a plugin based on the PluginAPI, it 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.
4+
5+
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.
6+
7+
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.
8+
9+
10+
# Image Serialization Plugin
11+
12+
The **Image Serialization Plugin** extends the functionality of the SnapshotTesting library by enabling support for multiple image formats through a plugin architecture. This PluginAPI allows image encoding and decoding to be easily extended without modifying the core logic of the system.
13+
14+
## Overview
15+
16+
The **Image Serialization Plugin** provides an interface for encoding and decoding images in various formats. By conforming to both the `ImageSerialization` and `SnapshotTestingPlugin` protocols, it integrates with the broader plugin system, allowing for the seamless addition of new image formats. The default implementation supports PNG, but this architecture allows users to define custom plugins for other formats.
17+
18+
### Image Serialization Plugin Architecture
19+
20+
The **Image Serialization Plugin** relies on the PluginAPI that is a combination of protocols and a centralized registry to manage and discover plugins. The architecture allows for dynamic registration of image serialization plugins, which can be automatically discovered at runtime using the Objective-C runtime. This makes the system highly extensible, with plugins being automatically registered without the need for manual intervention.
21+
22+
#### Key Components:
23+
24+
1. **`ImageSerialization` Protocol**:
25+
- Defines the core methods for encoding and decoding images.
26+
- Requires plugins to specify the image format they support using the `ImageSerializationFormat` enum.
27+
- Provides methods for encoding (`encodeImage`) and decoding (`decodeImage`) images.
28+
29+
2. **`ImageSerializationFormat` Enum**:
30+
- Represents supported image formats.
31+
- Includes predefined formats such as `.png` and extensible formats through the `.plugins(String)` case, allowing for custom formats to be introduced via plugins.
32+
33+
3. **`ImageSerializer` Class**:
34+
- Responsible for encoding and decoding images using the registered plugins.
35+
- Retrieves available plugins from the `PluginRegistry` and uses the first matching plugin for the requested image format.
36+
- Provides default implementations for PNG encoding and decoding if no plugin is available for a given format.
37+
38+
#### Example Plugin Flow:
39+
40+
1. **Plugin Discovery**:
41+
- Plugins are automatically discovered at runtime through the Objective-C runtime, which identifies classes that conform to both the `ImageSerialization` and `SnapshotTestingPlugin` protocols.
42+
43+
2. **Plugin Registration**:
44+
- Each plugin registers itself with the `PluginRegistry`, allowing it to be retrieved when needed for image serialization.
45+
46+
3. **Image Encoding/Decoding**:
47+
- When an image needs to be serialized, the `ImageSerializer` checks the available plugins for one that supports the requested format.
48+
- If no plugin is found, it defaults to the built-in PNG encoding/decoding methods.
49+
50+
#### Extensibility:
51+
52+
The plugin architecture allows developers to introduce new image formats without modifying the core SnapshotTesting library. By creating a new plugin that conforms to `ImageSerialization` and `SnapshotTestingPlugin`, other formats.
53+

Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Powerfully flexible snapshot testing.
2626
### Plugins
2727

2828
- <doc:Plugins>
29+
- <doc:ImageSerializationPlugin>
2930

3031
### Deprecations
3132

Sources/SnapshotTesting/SnapshotTestingConfiguration.swift

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import ImageSerializationPlugin
2+
13
/// Customizes `assertSnapshot` for the duration of an operation.
24
///
35
/// Use this operation to customize how the `assertSnapshot` function behaves in a test. It is most
@@ -26,13 +28,14 @@
2628
public func withSnapshotTesting<R>(
2729
record: SnapshotTestingConfiguration.Record? = nil,
2830
diffTool: SnapshotTestingConfiguration.DiffTool? = nil,
31+
imageFormat: ImageSerializationFormat? = nil,
2932
operation: () throws -> R
3033
) rethrows -> R {
3134
try SnapshotTestingConfiguration.$current.withValue(
3235
SnapshotTestingConfiguration(
3336
record: record ?? SnapshotTestingConfiguration.current?.record ?? _record,
34-
diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool
35-
?? SnapshotTesting._diffTool
37+
diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? SnapshotTesting._diffTool,
38+
imageFormat: imageFormat ?? SnapshotTestingConfiguration.current?.imageFormat ?? _imageFormat
3639
)
3740
) {
3841
try operation()
@@ -45,12 +48,14 @@ public func withSnapshotTesting<R>(
4548
public func withSnapshotTesting<R>(
4649
record: SnapshotTestingConfiguration.Record? = nil,
4750
diffTool: SnapshotTestingConfiguration.DiffTool? = nil,
51+
imageFormat: ImageSerializationFormat? = nil,
4852
operation: () async throws -> R
4953
) async rethrows -> R {
5054
try await SnapshotTestingConfiguration.$current.withValue(
5155
SnapshotTestingConfiguration(
5256
record: record ?? SnapshotTestingConfiguration.current?.record ?? _record,
53-
diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? _diffTool
57+
diffTool: diffTool ?? SnapshotTestingConfiguration.current?.diffTool ?? _diffTool,
58+
imageFormat: imageFormat ?? SnapshotTestingConfiguration.current?.imageFormat ?? _imageFormat
5459
)
5560
) {
5661
try await operation()
@@ -71,13 +76,17 @@ public struct SnapshotTestingConfiguration: Sendable {
7176
///
7277
/// See ``Record-swift.struct`` for more information.
7378
public var record: Record?
79+
80+
public var imageFormat: ImageSerializationFormat?
7481

7582
public init(
7683
record: Record?,
77-
diffTool: DiffTool?
84+
diffTool: DiffTool?,
85+
imageFormat: ImageSerializationFormat?
7886
) {
7987
self.diffTool = diffTool
8088
self.record = record
89+
self.imageFormat = imageFormat
8190
}
8291

8392
/// The record mode of the snapshot test.

Sources/SnapshotTesting/SnapshotsTestTrait.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// NB: We are importing only the implementation of Testing because that framework is not available
33
// in Xcode UI test targets.
44
@_implementationOnly import Testing
5+
import ImageSerializationPlugin
56

67
@_spi(Experimental)
78
extension Trait where Self == _SnapshotsTestTrait {
@@ -12,12 +13,14 @@
1213
/// - diffTool: The diff tool to use in failure messages.
1314
public static func snapshots(
1415
record: SnapshotTestingConfiguration.Record? = nil,
15-
diffTool: SnapshotTestingConfiguration.DiffTool? = nil
16+
diffTool: SnapshotTestingConfiguration.DiffTool? = nil,
17+
imageFormat: ImageSerializationFormat? = nil
1618
) -> Self {
1719
_SnapshotsTestTrait(
1820
configuration: SnapshotTestingConfiguration(
1921
record: record,
20-
diffTool: diffTool
22+
diffTool: diffTool,
23+
imageFormat: imageFormat
2124
)
2225
)
2326
}

Sources/SnapshotTesting/Snapshotting/CALayer.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import ImageSerializationPlugin
12
#if os(macOS)
23
import AppKit
34
import Cocoa
@@ -14,7 +15,7 @@
1415
/// assertSnapshot(of: layer, as: .image(precision: 0.99))
1516
/// ```
1617
public static var image: Snapshotting {
17-
return .image(precision: 1)
18+
return .image(precision: 1, imageFormat: imageFormat)
1819
}
1920

2021
/// A snapshot strategy for comparing layers based on pixel equality.
@@ -25,9 +26,9 @@
2526
/// match. 98-99% mimics
2627
/// [the precision](http://zschuessler.github.io/DeltaE/learn/#toc-defining-delta-e) of the
2728
/// human eye.
28-
public static func image(precision: Float, perceptualPrecision: Float = 1) -> Snapshotting {
29+
public static func image(precision: Float, perceptualPrecision: Float = 1, imageFormat: ImageSerializationFormat) -> Snapshotting {
2930
return SimplySnapshotting.image(
30-
precision: precision, perceptualPrecision: perceptualPrecision
31+
precision: precision, perceptualPrecision: perceptualPrecision, imageFormat: imageFormat
3132
).pullback { layer in
3233
let image = NSImage(size: layer.bounds.size)
3334
image.lockFocus()
@@ -46,7 +47,7 @@
4647
extension Snapshotting where Value == CALayer, Format == UIImage {
4748
/// A snapshot strategy for comparing layers based on pixel equality.
4849
public static var image: Snapshotting {
49-
return .image()
50+
return .image(imageFormat: imageFormat)
5051
}
5152

5253
/// A snapshot strategy for comparing layers based on pixel equality.
@@ -59,12 +60,12 @@
5960
/// human eye.
6061
/// - traits: A trait collection override.
6162
public static func image(
62-
precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init()
63+
precision: Float = 1, perceptualPrecision: Float = 1, traits: UITraitCollection = .init(), imageFormat: ImageSerializationFormat
6364
)
6465
-> Snapshotting
6566
{
6667
return SimplySnapshotting.image(
67-
precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale
68+
precision: precision, perceptualPrecision: perceptualPrecision, scale: traits.displayScale, imageFormat: imageFormat
6869
).pullback { layer in
6970
renderer(bounds: layer.bounds, for: traits).image { ctx in
7071
layer.setNeedsLayout()

0 commit comments

Comments
 (0)