From 07e8012faf017366dcae6e41272b35e7abccd034 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 8 Feb 2025 10:22:52 +0100 Subject: [PATCH] Implemented the TerminalService service for the library target --- .../Public/Protocols/TerminalServicing.swift | 19 +++++ .../Public/Services/TerminalService.swift | 60 +++++++++++++++ .../Services/TemplateServiceTests.swift | 1 + .../Services/TerminalServiceTests.swift | 51 +++++++++++++ .../Helpers/Mocks/TerminalServiceMock.swift | 73 +++++++++++++++++++ .../Helpers/Spies/TerminalServiceSpy.swift | 39 ++++++++++ 6 files changed, 243 insertions(+) create mode 100644 Library/Sources/Public/Protocols/TerminalServicing.swift create mode 100644 Library/Sources/Public/Services/TerminalService.swift create mode 100644 Test/Sources/Cases/Public/Services/TerminalServiceTests.swift create mode 100644 Test/Sources/Helpers/Mocks/TerminalServiceMock.swift create mode 100644 Test/Sources/Helpers/Spies/TerminalServiceSpy.swift diff --git a/Library/Sources/Public/Protocols/TerminalServicing.swift b/Library/Sources/Public/Protocols/TerminalServicing.swift new file mode 100644 index 0000000..b9f63e3 --- /dev/null +++ b/Library/Sources/Public/Protocols/TerminalServicing.swift @@ -0,0 +1,19 @@ +import Foundation + +public protocol TerminalServicing { + + // MARK: Functions + + @discardableResult + func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String + +} + +// MARK: - Errors + +public enum TerminalServiceError: Error, Equatable { + case captured(_ output: String) + case output(_ output: String) + case unexpected +} + diff --git a/Library/Sources/Public/Services/TerminalService.swift b/Library/Sources/Public/Services/TerminalService.swift new file mode 100644 index 0000000..78105e1 --- /dev/null +++ b/Library/Sources/Public/Services/TerminalService.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct TerminalService { + + // MARK: Initialisers + + public init() {} + +} + +// MARK: - TerminalServicing + +extension TerminalService: TerminalServicing { + + // MARK: Functions + + public func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String { + let process = Process() + let standardError = Pipe() + let standardOutput = Pipe() + + process.executableURL = executableURL + process.arguments = arguments + process.standardError = standardError + process.standardOutput = standardOutput + + async let streamOutput = standardOutput.availableData.append() + async let streamError = standardError.availableData.append() + + do { + try process.run() + + let dataOutput = await streamOutput + let dataError = await streamError + + guard dataError.isEmpty else { + guard let errorOutput = String(data: dataError, encoding: .utf8) else { + throw TerminalServiceError.unexpected + } + + throw TerminalServiceError.output(errorOutput) + } + + guard let output = String(data: dataOutput, encoding: .utf8) else { + throw TerminalServiceError.unexpected + } + + return await withCheckedContinuation { continuation in + process.terminationHandler = { _ in + continuation.resume(returning: output) + } + } + } catch let error as TerminalServiceError { + throw error + } catch { + throw .captured(error.localizedDescription) + } + } + +} diff --git a/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift b/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift index 5da5fa3..3bd7852 100644 --- a/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift +++ b/Test/Sources/Cases/Public/Services/TemplateServiceTests.swift @@ -26,6 +26,7 @@ struct TemplateServiceTests { @Test(arguments: [TemplateServiceError.serviceNotInitialized, .resourcePathNotFound, .templateNotFound, .contentNotRendered]) func render(throws error: TemplateServiceError) async throws { + // GIVEN let service = TemplateServiceMock(action: .error(error), spy: spy) // WHEN diff --git a/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift b/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift new file mode 100644 index 0000000..f644c2b --- /dev/null +++ b/Test/Sources/Cases/Public/Services/TerminalServiceTests.swift @@ -0,0 +1,51 @@ +import ColibriLibrary +import Foundation +import Testing + +struct TerminalServiceTests { + + // MARK: Properties + + private let spy = TerminalServiceSpy() + + // MARK: Functions + + @Test(arguments: [URL.someNewFile], [[], ["--example"], ["--example", "--more", "--etc"]]) + func run(with executableURL: URL, and arguments: [String]) async throws { + // GIVEN + let service = TerminalServiceMock(action: .run(executableURL, arguments), spy: spy) + + // WHEN + let result = try await service.run(executableURL, arguments: arguments) + + // THEN + #expect(result == .content) + + #expect(spy.actions.isEmpty == false) + + let action = try #require(spy.actions.last) + + #expect(action == .ran(executableURL, arguments)) + } + + @Test(arguments: [TerminalServiceError.unexpected, .captured("Some captured error"), .output("Some output error")]) + func run(throws error: TerminalServiceError) async throws { + // GIVEN + let service = TerminalServiceMock(action: .error(error), spy: spy) + + // WHEN + // THEN + await #expect(throws: error) { + try await service.run(URL.someNewFile, arguments: []) + } + + #expect(spy.actions.isEmpty == true) + } + +} + +// MARK: - String+Constants + +private extension String { + static let content = "" +} diff --git a/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift b/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift new file mode 100644 index 0000000..3a1344d --- /dev/null +++ b/Test/Sources/Helpers/Mocks/TerminalServiceMock.swift @@ -0,0 +1,73 @@ +import ColibriLibrary +import Foundation + +final class TerminalServiceMock { + + // MARK: Properties + + private var actions: [Action] = [] + + private weak var spy: TerminalServiceSpy? + + // MARK: Initialisers + + init( + action: Action, + spy: TerminalServiceSpy? = nil + ) { + self.actions.append(action) + self.spy = spy + } + +} + +// MARK: - TerminalServicing + +extension TerminalServiceMock: TerminalServicing { + + // MARK: Functions + + func run(_ executableURL: URL, arguments: [String]) async throws (TerminalServiceError) -> String { + guard let nextAction else { return .empty } + + switch nextAction { + case .error(let error): + throw error + case let .run(executableURL, arguments): + try await spy?.run(executableURL, arguments: arguments) + return .empty + } + } + +} + +// MARK: - Helpers + +private extension TerminalServiceMock { + + // MARK: Computed + + var nextAction: Action? { + guard !actions.isEmpty else { + return nil + } + + return actions.removeFirst() + } + +} + +// MARK: - Actions + +extension TerminalServiceMock { + enum Action { + case error(TerminalServiceError) + case run(URL, [String]) + } +} + +// MARK: - String+Constants + +private extension String { + static let empty = "" +} diff --git a/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift b/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift new file mode 100644 index 0000000..0c9a621 --- /dev/null +++ b/Test/Sources/Helpers/Spies/TerminalServiceSpy.swift @@ -0,0 +1,39 @@ +import ColibriLibrary +import Foundation + +final class TerminalServiceSpy { + + // MARK: Properties + + private(set) var actions: [Action] = [] + +} + +// MARK: - TerminalServicing + +extension TerminalServiceSpy: TerminalServicing { + + // MARK: Functions + + @discardableResult + func run(_ executableURL: URL, arguments: [String]) async throws(TerminalServiceError) -> String { + actions.append(.ran(executableURL, arguments)) + + return .content + } + +} + +// MARK: - Actions + +extension TerminalServiceSpy { + enum Action: Equatable { + case ran(_ executableURL: URL, _ arguments: [String]) + } +} + +// MARK: - String+Constants + +private extension String { + static let content = "" +}