From 62452be0d89a4bdbb8764511b158a47d9a55ef2d Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Fri, 27 Sep 2024 22:32:25 +0200 Subject: [PATCH 1/3] feat: add file serilizer --- Package.swift | 9 ++ .../FileSerializationPlugin.swift | 30 +++++++ .../AssertInlineSnapshot.swift | 1 + Sources/SnapshotTesting/AssertSnapshot.swift | 8 +- .../Plugins/FileSerializer.swift | 57 +++++++++++++ .../FileSerializationPluginTests.swift | 83 +++++++++++++++++++ 6 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 Sources/FileSerializationPlugin/FileSerializationPlugin.swift create mode 100644 Sources/SnapshotTesting/Plugins/FileSerializer.swift create mode 100644 Tests/SnapshotTestingTests/FileSerializationPluginTests.swift diff --git a/Package.swift b/Package.swift index f3ee95372..fb7e87748 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,10 @@ let package = Package( name: "ImageSerializationPlugin", targets: ["ImageSerializationPlugin"] ), + .library( + name: "FileSerializationPlugin", + targets: ["FileSerializationPlugin"] + ), .library( name: "InlineSnapshotTesting", targets: ["InlineSnapshotTesting"] @@ -35,11 +39,16 @@ let package = Package( .target( name: "SnapshotTesting", dependencies: [ + "FileSerializationPlugin", "ImageSerializationPlugin", "SnapshotTestingPlugin" ] ), .target(name: "SnapshotTestingPlugin"), + .target( + name: "FileSerializationPlugin", + dependencies: ["SnapshotTestingPlugin"] + ), .target( name: "ImageSerializationPlugin", dependencies: ["SnapshotTestingPlugin"] diff --git a/Sources/FileSerializationPlugin/FileSerializationPlugin.swift b/Sources/FileSerializationPlugin/FileSerializationPlugin.swift new file mode 100644 index 000000000..c24e1ac9e --- /dev/null +++ b/Sources/FileSerializationPlugin/FileSerializationPlugin.swift @@ -0,0 +1,30 @@ +import SnapshotTestingPlugin +import Foundation + +public typealias FileSerializationPlugin = FileSerialization & SnapshotTestingPlugin + +public protocol FileSerialization { + static var location: FileSerializationLocation { get } + func write(_ data: Data, to url: URL, options: Data.WritingOptions) async throws + func read(_ url: URL) async throws -> Data? +} + +public enum FileSerializationLocation: RawRepresentable, Sendable, Equatable { + + public static let defaultValue: FileSerializationLocation = .local + + case local + + case plugins(String) + + public init?(rawValue: String) { + self = rawValue == "local" ? .local : .plugins(rawValue) + } + + public var rawValue: String { + switch self { + case .local: return "local" + case let .plugins(value): return value + } + } +} diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift index 1ca6ddf8e..468ee34a5 100644 --- a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift +++ b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift @@ -6,6 +6,7 @@ import Foundation import SwiftSyntax import SwiftSyntaxBuilder import XCTest + import FileSerializationPlugin /// Asserts that a given value matches an inline string snapshot. /// diff --git a/Sources/SnapshotTesting/AssertSnapshot.swift b/Sources/SnapshotTesting/AssertSnapshot.swift index 55ebc0e94..d90cfc866 100644 --- a/Sources/SnapshotTesting/AssertSnapshot.swift +++ b/Sources/SnapshotTesting/AssertSnapshot.swift @@ -1,5 +1,6 @@ import XCTest import ImageSerializationPlugin +import FileSerializationPlugin #if canImport(Testing) // NB: We are importing only the implementation of Testing because that framework is not available @@ -7,6 +8,8 @@ import ImageSerializationPlugin @_implementationOnly import Testing #endif +public var fileLocation: FileSerializationLocation = .defaultValue + /// Whether or not to change the default output image format to something else. public var imageFormat: ImageSerializationFormat { get { @@ -387,7 +390,7 @@ public func verifySnapshot( } func recordSnapshot() throws { - try snapshotting.diffing.toData(diffable).write(to: snapshotFileUrl) + try FileSerializer().write(snapshotting.diffing.toData(diffable), to: snapshotFileUrl, location: fileLocation) #if !os(Linux) && !os(Windows) if !isSwiftTesting, ProcessInfo.processInfo.environment.keys.contains("__XCODE_BUILT_PRODUCTS_DIR_PATHS") @@ -424,7 +427,8 @@ public func verifySnapshot( """ } - let data = try Data(contentsOf: snapshotFileUrl) + guard let data = try FileSerializer().read(snapshotFileUrl, location: fileLocation) + else { return nil } let reference = snapshotting.diffing.fromData(data) #if os(iOS) || os(tvOS) diff --git a/Sources/SnapshotTesting/Plugins/FileSerializer.swift b/Sources/SnapshotTesting/Plugins/FileSerializer.swift new file mode 100644 index 000000000..e6f8cf413 --- /dev/null +++ b/Sources/SnapshotTesting/Plugins/FileSerializer.swift @@ -0,0 +1,57 @@ +#if canImport(SwiftUI) +import Foundation +import FileSerializationPlugin + +final class FileSerializer { + + /// A collection of plugins that conform to the `FileSerialization` protocol. + private let plugins: [FileSerialization] + + init() { + self.plugins = PluginRegistry.allPlugins() + } + + func write(_ data: Data, to url: URL, options: Data.WritingOptions = [], location: FileSerializationLocation = .defaultValue) throws { + if let plugin = self.plugins.first(where: { type(of: $0).location == location }) { + Task { + try await plugin.write(data, to: url, options: options) + } + return + } + + try data.write(to: url) + } + + + func read(_ url: URL, location: FileSerializationLocation = .defaultValue) throws -> Data? { + if let plugin = self.plugins.first(where: { type(of: $0).location == location }) { + let semaphore = DispatchSemaphore(value: 0) + var result: Result? + + Task { + do { + let data = try await plugin.read(url) + result = .success(data) + } catch { + result = .failure(error) + } + semaphore.signal() // Release the semaphore once async task is done + } + + semaphore.wait() // Wait for async task to complete + + switch result { + case .success(let data): + return data + case .failure(let error): + throw error + case .none: + fatalError("Unexpected error occurred") + } + } + + // Synchronous path for fallback + return try Data(contentsOf: url) + } +} +#endif diff --git a/Tests/SnapshotTestingTests/FileSerializationPluginTests.swift b/Tests/SnapshotTestingTests/FileSerializationPluginTests.swift new file mode 100644 index 000000000..979475713 --- /dev/null +++ b/Tests/SnapshotTestingTests/FileSerializationPluginTests.swift @@ -0,0 +1,83 @@ +#if canImport(SwiftUI) && canImport(ObjectiveC) +import XCTest +import SnapshotTestingPlugin +@testable import SnapshotTesting +import FileSerializationPlugin + +class InMemoryFileSerializationPlugin: FileSerializationPlugin { + static var location: FileSerializationLocation = .plugins("inMemory") + var inMemory: [String: Data] = [:] + + func write(_ data: Data, to url: URL, options: Data.WritingOptions) async throws { + inMemory[url.absoluteString] = data + } + + func read(_ url: URL) async throws -> Data? { + return inMemory[url.absoluteString] + } + + // MARK: - SnapshotTestingPlugin + static var identifier: String = "FileSerializationPlugin.InMemoryFileSerializationPlugin.mock" + required init() {} +} + +class FileSerializerTests: XCTestCase { + + var fileSerializer: FileSerializer! + let testData = "Test Data".data(using: .utf8)! + let testURL = URL(string: "file:///test.txt")! + + override func setUp() { + super.setUp() + PluginRegistry.reset() // Reset state before each test + + // Register the mock plugin in the PluginRegistry + PluginRegistry.registerPlugin(InMemoryFileSerializationPlugin() as SnapshotTestingPlugin) + + fileSerializer = FileSerializer() + } + + override func tearDown() { + fileSerializer = nil + PluginRegistry.reset() // Reset state after each test + super.tearDown() + } + + func testReadAndWriteUsingMockPlugin() async throws { + try fileSerializer.write( + testData, + to: testURL, + location: InMemoryFileSerializationPlugin.location + ) + + let storedData = try fileSerializer.read(testURL, location: InMemoryFileSerializationPlugin.location) + XCTAssertNotNil(storedData, "Data should be stored in the in-memory plugin.") + XCTAssertEqual(storedData, testData, "Stored data should match the test data.") + } + + func testReadAndWriteDefaultPlugin() async throws { + let tmpURL = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try fileSerializer.write( + testData, + to: tmpURL + ) + + let storedData = try fileSerializer.read(tmpURL) + XCTAssertNotNil(storedData, "Data should be stored in the in-memory plugin.") + XCTAssertEqual(storedData, testData, "Stored data should match the test data.") + } + + func testReadNonExistantFileUsingMockPlugin() async throws { + let data = try fileSerializer.read(URL(string: "https://www.pointfree.co")!, location: InMemoryFileSerializationPlugin.location) + XCTAssertNil(data, "This should be empty.") + } + + func testPluginRegistryShouldContainRegisteredPlugins() { + let plugins = PluginRegistry.allPlugins() as [FileSerialization] + + XCTAssertEqual(plugins.count, 1, "There should be one registered plugin.") + XCTAssertEqual(type(of: plugins[0]).location.rawValue, "inMemory", "The plugin should support the 'inMemory' location.") + } +} + +#endif From 6a88a08e69f3afa7a5926654407ba9722ad8761b Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Mon, 7 Oct 2024 02:02:50 +0200 Subject: [PATCH 2/3] feat: improve API --- .../FileSerializationPlugin/FileSerializationPlugin.swift | 7 ++++++- Sources/SnapshotTesting/Plugins/FileSerializer.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/FileSerializationPlugin/FileSerializationPlugin.swift b/Sources/FileSerializationPlugin/FileSerializationPlugin.swift index c24e1ac9e..9477bf1b8 100644 --- a/Sources/FileSerializationPlugin/FileSerializationPlugin.swift +++ b/Sources/FileSerializationPlugin/FileSerializationPlugin.swift @@ -3,10 +3,15 @@ import Foundation public typealias FileSerializationPlugin = FileSerialization & SnapshotTestingPlugin -public protocol FileSerialization { +@preconcurrency public protocol FileSerialization { + associatedtype Configuration static var location: FileSerializationLocation { get } func write(_ data: Data, to url: URL, options: Data.WritingOptions) async throws func read(_ url: URL) async throws -> Data? + + // This should not be called ofter + func start(_ configuration: Configuration) async throws + func stop() async throws } public enum FileSerializationLocation: RawRepresentable, Sendable, Equatable { diff --git a/Sources/SnapshotTesting/Plugins/FileSerializer.swift b/Sources/SnapshotTesting/Plugins/FileSerializer.swift index e6f8cf413..50ebfb06f 100644 --- a/Sources/SnapshotTesting/Plugins/FileSerializer.swift +++ b/Sources/SnapshotTesting/Plugins/FileSerializer.swift @@ -5,7 +5,7 @@ import FileSerializationPlugin final class FileSerializer { /// A collection of plugins that conform to the `FileSerialization` protocol. - private let plugins: [FileSerialization] + private let plugins: [any FileSerialization] init() { self.plugins = PluginRegistry.allPlugins() From 61c439c8eb96289334b44ba8334575450e791d81 Mon Sep 17 00:00:00 2001 From: Jeffrey Macko Date: Mon, 7 Oct 2024 02:04:39 +0200 Subject: [PATCH 3/3] feat: improve API --- Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift index 468ee34a5..1ca6ddf8e 100644 --- a/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift +++ b/Sources/InlineSnapshotTesting/AssertInlineSnapshot.swift @@ -6,7 +6,6 @@ import Foundation import SwiftSyntax import SwiftSyntaxBuilder import XCTest - import FileSerializationPlugin /// Asserts that a given value matches an inline string snapshot. ///