Skip to content

Commit a3eab77

Browse files
committed
feat: add the basic mechanics for plugin management
1 parent 7b0bbba commit a3eab77

File tree

6 files changed

+237
-1
lines changed

6 files changed

+237
-1
lines changed

Package.swift

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ let package = Package(
1515
name: "SnapshotTesting",
1616
targets: ["SnapshotTesting"]
1717
),
18+
.library(
19+
name: "SnapshotTestingPlugin",
20+
targets: ["SnapshotTestingPlugin"]
21+
),
1822
.library(
1923
name: "InlineSnapshotTesting",
2024
targets: ["InlineSnapshotTesting"]
@@ -25,8 +29,10 @@ let package = Package(
2529
],
2630
targets: [
2731
.target(
28-
name: "SnapshotTesting"
32+
name: "SnapshotTesting",
33+
dependencies: ["SnapshotTestingPlugin"]
2934
),
35+
.target(name: "SnapshotTestingPlugin"),
3036
.target(
3137
name: "InlineSnapshotTesting",
3238
dependencies: [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Plugins
2+
3+
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.
4+
5+
## Overview
6+
7+
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.
8+
9+
### Plugin architecture
10+
11+
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.
12+
13+
The primary components of the plugin system include:
14+
15+
- **Plugin Protocols**: Define the behavior that plugins must implement.
16+
- **PluginRegistry**: Manages plugin discovery, registration, and retrieval.
17+
- **Objective-C Runtime Integration**: Allows automatic discovery of plugins that conform to specific protocols.
18+
19+
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.

Sources/SnapshotTesting/Documentation.docc/SnapshotTesting.md

+4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ Powerfully flexible snapshot testing.
2323
- ``withSnapshotTesting(record:diffTool:operation:)-2kuyr``
2424
- ``SnapshotTestingConfiguration``
2525

26+
### Plugins
27+
28+
- <doc:Plugins>
29+
2630
### Deprecations
2731

2832
- <doc:SnapshotTestingDeprecations>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import Foundation
2+
import SnapshotTestingPlugin
3+
4+
#if canImport(SwiftUI) && canImport(ObjectiveC)
5+
import ObjectiveC.runtime
6+
#endif
7+
8+
/// A singleton class responsible for managing and registering plugins conforming to the `SnapshotTestingPlugin` protocol.
9+
///
10+
/// The `PluginRegistry` automatically discovers and registers classes conforming to the `SnapshotTestingPlugin` protocol
11+
/// within the Objective-C runtime. It allows retrieval of specific plugins by identifier, access to all registered plugins,
12+
/// and filtering of plugins that conform to the `ImageSerialization` protocol.
13+
class PluginRegistry {
14+
15+
/// Shared singleton instance of `PluginRegistry`.
16+
private static let shared = PluginRegistry()
17+
18+
/// Dictionary holding registered plugins, keyed by their identifier.
19+
private var plugins: [String: AnyObject] = [:]
20+
21+
/// Private initializer enforcing the singleton pattern.
22+
///
23+
/// Automatically triggers `automaticPluginRegistration()` to discover and register plugins.
24+
private init() {
25+
defer { automaticPluginRegistration() }
26+
}
27+
28+
// MARK: - Internal Methods
29+
30+
/// Registers a plugin.
31+
///
32+
/// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`.
33+
static func registerPlugin(_ plugin: SnapshotTestingPlugin) {
34+
PluginRegistry.shared.registerPlugin(plugin)
35+
}
36+
37+
/// Retrieves a plugin by its identifier, casting it to the specified type.
38+
///
39+
/// - Parameter identifier: The unique identifier for the plugin.
40+
/// - Returns: The plugin instance cast to `Output` if found and castable, otherwise `nil`.
41+
static func plugin<Output>(for identifier: String) -> Output? {
42+
PluginRegistry.shared.plugin(for: identifier)
43+
}
44+
45+
/// Returns all registered plugins cast to the specified type.
46+
///
47+
/// - Returns: An array of all registered plugins that can be cast to `Output`.
48+
static func allPlugins<Output>() -> [Output] {
49+
PluginRegistry.shared.allPlugins()
50+
}
51+
52+
// MARK: - Internal Methods
53+
54+
/// Registers a plugin.
55+
///
56+
/// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`.
57+
private func registerPlugin(_ plugin: SnapshotTestingPlugin) {
58+
plugins[type(of: plugin).identifier] = plugin
59+
}
60+
61+
/// Retrieves a plugin by its identifier, casting it to the specified type.
62+
///
63+
/// - Parameter identifier: The unique identifier for the plugin.
64+
/// - Returns: The plugin instance cast to `Output` if found and castable, otherwise `nil`.
65+
private func plugin<Output>(for identifier: String) -> Output? {
66+
return plugins[identifier] as? Output
67+
}
68+
69+
/// Returns all registered plugins cast to the specified type.
70+
///
71+
/// - Returns: An array of all registered plugins that can be cast to `Output`.
72+
private func allPlugins<Output>() -> [Output] {
73+
return Array(plugins.values.compactMap { $0 as? Output })
74+
}
75+
76+
#if canImport(SwiftUI) && canImport(ObjectiveC)
77+
/// Discovers and registers all classes conforming to the `SnapshotTestingPlugin` protocol.
78+
///
79+
/// This method iterates over all Objective-C runtime classes, identifying those that conform to `SnapshotTestingPlugin`,
80+
/// instantiating them, and registering them as plugins.
81+
private func automaticPluginRegistration() {
82+
let classCount = objc_getClassList(nil, 0)
83+
guard classCount > 0 else { return }
84+
85+
let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
86+
defer { classes.deallocate() }
87+
88+
let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
89+
objc_getClassList(autoreleasingClasses, classCount)
90+
91+
for i in 0..<Int(classCount) {
92+
guard
93+
let someClass = classes[i],
94+
class_conformsToProtocol(someClass, SnapshotTestingPlugin.self),
95+
let pluginType = someClass as? SnapshotTestingPlugin.Type
96+
else { continue }
97+
self.registerPlugin(pluginType.init())
98+
}
99+
}
100+
#endif
101+
102+
// TEST-ONLY Reset Method
103+
#if DEBUG
104+
internal static func reset() {
105+
shared.plugins.removeAll()
106+
}
107+
108+
internal static func automaticPluginRegistration() {
109+
shared.automaticPluginRegistration()
110+
}
111+
#endif
112+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Foundation
2+
3+
/// A protocol that defines a plugin for snapshot testing, designed to be used in environments that support Objective-C.
4+
///
5+
/// The `SnapshotTestingPlugin` protocol is intended to be adopted by classes that provide specific functionality for snapshot testing.
6+
/// It requires each conforming class to have a unique identifier and a parameterless initializer. This protocol is designed to be used in
7+
/// environments where both Foundation and Objective-C are available, making it compatible with Objective-C runtime features.
8+
#if canImport(SwiftUI) && canImport(ObjectiveC)
9+
///
10+
/// Conforming classes must be marked with `@objc` to ensure compatibility with Objective-C runtime mechanisms.
11+
@objc
12+
#endif
13+
public protocol SnapshotTestingPlugin: AnyObject {
14+
15+
/// A unique string identifier for the plugin.
16+
///
17+
/// Each plugin must provide a static identifier that uniquely distinguishes it from other plugins. This identifier is used
18+
/// to register and retrieve plugins within a registry, ensuring that each plugin can be easily identified and utilized.
19+
static var identifier: String { get }
20+
21+
/// Initializes a new instance of the plugin.
22+
///
23+
/// This initializer is required to allow the Objective-C runtime to create instances of the plugin class when registering
24+
/// and utilizing plugins. The initializer must not take any parameters.
25+
init()
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#if canImport(SwiftUI)
2+
import XCTest
3+
@testable import SnapshotTesting
4+
import SnapshotTestingPlugin
5+
6+
final class PluginRegistryTests: XCTestCase {
7+
8+
class MockPlugin: SnapshotTestingPlugin {
9+
static var identifier: String = "MockPlugin"
10+
11+
required init() {}
12+
}
13+
14+
class AnotherMockPlugin: SnapshotTestingPlugin {
15+
static var identifier: String = "AnotherMockPlugin"
16+
17+
required init() {}
18+
}
19+
20+
override func setUp() {
21+
super.setUp()
22+
PluginRegistry.reset() // Reset state before each test
23+
}
24+
25+
override func tearDown() {
26+
PluginRegistry.reset() // Reset state after each test
27+
super.tearDown()
28+
}
29+
30+
func testRegisterPlugin() {
31+
// Register a mock plugin
32+
PluginRegistry.registerPlugin(MockPlugin())
33+
34+
// Retrieve the plugin by identifier
35+
let retrievedPlugin: MockPlugin? = PluginRegistry.plugin(for: MockPlugin.identifier)
36+
XCTAssertNotNil(retrievedPlugin)
37+
}
38+
39+
func testRetrieveNonExistentPlugin() {
40+
// Try to retrieve a non-existent plugin
41+
let nonExistentPlugin: MockPlugin? = PluginRegistry.plugin(for: "NonExistentPlugin")
42+
XCTAssertNil(nonExistentPlugin)
43+
}
44+
45+
func testAllPlugins() {
46+
// Register two mock plugins
47+
PluginRegistry.registerPlugin(MockPlugin())
48+
PluginRegistry.registerPlugin(AnotherMockPlugin())
49+
50+
// Retrieve all plugins
51+
let allPlugins: [SnapshotTestingPlugin] = PluginRegistry.allPlugins()
52+
53+
XCTAssertEqual(allPlugins.count, 2)
54+
XCTAssertTrue(allPlugins.contains { $0 is MockPlugin })
55+
XCTAssertTrue(allPlugins.contains { $0 is AnotherMockPlugin })
56+
}
57+
58+
#if canImport(SwiftUI) && canImport(ObjectiveC)
59+
func testAutomaticPluginRegistration() {
60+
// Automatically register plugins using the Objective-C runtime
61+
PluginRegistry.automaticPluginRegistration()
62+
63+
// Verify if the mock plugin was automatically registered
64+
let registeredPlugin: MockPlugin? = PluginRegistry.plugin(for: MockPlugin.identifier)
65+
XCTAssertNotNil(registeredPlugin)
66+
}
67+
#endif
68+
}
69+
#endif

0 commit comments

Comments
 (0)