Skip to content
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

Introducing a Plugin API #905

Closed
wants to merge 1 commit 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
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ let package = Package(
name: "SnapshotTesting",
targets: ["SnapshotTesting"]
),
.library(
name: "SnapshotTestingPlugin",
targets: ["SnapshotTestingPlugin"]
),
.library(
name: "InlineSnapshotTesting",
targets: ["InlineSnapshotTesting"]
Expand All @@ -25,8 +29,10 @@ let package = Package(
],
targets: [
.target(
name: "SnapshotTesting"
name: "SnapshotTesting",
dependencies: ["SnapshotTestingPlugin"]
),
.target(name: "SnapshotTestingPlugin"),
.target(
name: "InlineSnapshotTesting",
dependencies: [
Expand Down
19 changes: 19 additions & 0 deletions Sources/SnapshotTesting/Documentation.docc/Articles/Plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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.
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>
109 changes: 109 additions & 0 deletions Sources/SnapshotTesting/Plugins/PluginRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#if canImport(SwiftUI) && canImport(ObjectiveC)
import Foundation
import ObjectiveC.runtime
import SnapshotTestingPlugin

/// A singleton class responsible for managing and registering plugins conforming to the `SnapshotTestingPlugin` 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 {

/// Shared singleton instance of `PluginRegistry`.
private static let shared = PluginRegistry()

/// Dictionary holding registered plugins, keyed by their identifier.
private var plugins: [String: AnyObject] = [:]

/// Private initializer enforcing the singleton pattern.
///
/// Automatically triggers `automaticPluginRegistration()` to discover and register plugins.
private init() {
defer { automaticPluginRegistration() }
}

// MARK: - Public Methods

/// Registers a plugin.
///
/// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`.
public static func registerPlugin(_ plugin: SnapshotTestingPlugin) {
PluginRegistry.shared.registerPlugin(plugin)
}

/// Retrieves a plugin by its identifier, casting it to the specified type.
///
/// - 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<Output>(for identifier: String) -> Output? {
PluginRegistry.shared.plugin(for: identifier)
}

/// Returns all registered plugins cast to the specified type.
///
/// - Returns: An array of all registered plugins that can be cast to `Output`.
public static func allPlugins<Output>() -> [Output] {
PluginRegistry.shared.allPlugins()
}

// MARK: - Internal Methods

/// Registers a plugin.
///
/// - Parameter plugin: An instance conforming to `SnapshotTestingPlugin`.
private func registerPlugin(_ plugin: SnapshotTestingPlugin) {
plugins[type(of: plugin).identifier] = plugin
}

/// Retrieves a plugin by its identifier, casting it to the specified type.
///
/// - Parameter identifier: The unique identifier for the plugin.
/// - Returns: The plugin instance cast to `Output` if found and castable, otherwise `nil`.
private func plugin<Output>(for identifier: String) -> Output? {
return plugins[identifier] as? Output
}

/// Returns all registered plugins cast to the specified type.
///
/// - Returns: An array of all registered plugins that can be cast to `Output`.
private func allPlugins<Output>() -> [Output] {
return Array(plugins.values.compactMap { $0 as? Output })
}

/// Discovers and registers all classes conforming to the `SnapshotTestingPlugin` protocol.
///
/// 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 }

let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
defer { classes.deallocate() }

let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
objc_getClassList(autoreleasingClasses, classCount)

for i in 0..<Int(classCount) {
guard
let someClass = classes[i],
class_conformsToProtocol(someClass, SnapshotTestingPlugin.self),
let pluginType = someClass as? SnapshotTestingPlugin.Type
else { continue }
self.registerPlugin(pluginType.init())
}
}

// TEST-ONLY Reset Method
#if DEBUG
internal static func reset() {
shared.plugins.removeAll()
}

internal static func automaticPluginRegistration() {
shared.automaticPluginRegistration()
}
#endif
}
#endif
25 changes: 25 additions & 0 deletions Sources/SnapshotTestingPlugin/SnapshotTestingPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#if canImport(Foundation) && canImport(ObjectiveC)
import Foundation

/// 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
73 changes: 73 additions & 0 deletions Tests/SnapshotTestingTests/PluginRegistryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#if canImport(SwiftUI) && canImport(ObjectiveC)
import XCTest
import ObjectiveC
@testable import SnapshotTesting
import SnapshotTestingPlugin

class MockPlugin: NSObject, SnapshotTestingPlugin {
static var identifier: String = "MockPlugin"

required override init() {
super.init()
}
}

class AnotherMockPlugin: NSObject, SnapshotTestingPlugin {
static var identifier: String = "AnotherMockPlugin"

required override init() {
super.init()
}
}

final class PluginRegistryTests: XCTestCase {

override func setUp() {
super.setUp()
PluginRegistry.reset() // Reset state before each test
}

override func tearDown() {
PluginRegistry.reset() // Reset state after each test
super.tearDown()
}

func testRegisterPlugin() {
// Register a mock plugin
PluginRegistry.registerPlugin(MockPlugin())

// Retrieve the plugin by identifier
let retrievedPlugin: MockPlugin? = PluginRegistry.plugin(for: MockPlugin.identifier)
XCTAssertNotNil(retrievedPlugin)
}

func testRetrieveNonExistentPlugin() {
// Try to retrieve a non-existent plugin
let nonExistentPlugin: MockPlugin? = PluginRegistry.plugin(for: "NonExistentPlugin")
XCTAssertNil(nonExistentPlugin)
}

func testAllPlugins() {
// Register two mock plugins
PluginRegistry.registerPlugin(MockPlugin())
PluginRegistry.registerPlugin(AnotherMockPlugin())

// Retrieve all plugins
let allPlugins: [SnapshotTestingPlugin] = PluginRegistry.allPlugins()

XCTAssertEqual(allPlugins.count, 2)
XCTAssertTrue(allPlugins.contains { $0 is MockPlugin })
XCTAssertTrue(allPlugins.contains { $0 is AnotherMockPlugin })
}

func testAutomaticPluginRegistration() {
// Automatically register plugins using the Objective-C runtime
PluginRegistry.automaticPluginRegistration() // Reset state before each test

// Verify if the mock plugin was automatically registered
let registeredPlugin: MockPlugin? = PluginRegistry.plugin(for: MockPlugin.identifier)
XCTAssertNotNil(registeredPlugin)
}
}

#endif
Loading