Skip to content

Commit a793448

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

File tree

6 files changed

+237
-1
lines changed

6 files changed

+237
-1
lines changed

Package.swift

Lines changed: 7 additions & 1 deletion
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: [
Lines changed: 19 additions & 0 deletions
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

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

0 commit comments

Comments
 (0)