From f09078497332c970c5b115c7c9eb1269a64fc5e8 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 5 Oct 2024 08:34:34 +0000 Subject: [PATCH] [App] Run terminal processes (#3) This PR contains the work done to run terminal processes within the context of the app, conforming to Swift concurrency. Reviewed-on: https://repo.rock-n-code.com/rock-n-code/piper-app/pulls/3 Co-authored-by: Javier Cicchelli Co-committed-by: Javier Cicchelli --- Piper.xcodeproj/project.pbxproj | 4 + .../Extensions/Pipe+AsyncAvailableData.swift | 70 +++++++++++++++ .../Logic/Extensions/Pipe+Computed.swift | 17 ++++ .../Logic/UseCases/RunProcessUseCase.swift | 87 +++++++++++++++++++ .../UseCases/RunProcessUseCaseTests.swift | 69 +++++++++++++++ 5 files changed, 247 insertions(+) create mode 100644 Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift create mode 100644 Piper/Sources/Logic/Extensions/Pipe+Computed.swift create mode 100644 Piper/Sources/Logic/UseCases/RunProcessUseCase.swift create mode 100644 Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift diff --git a/Piper.xcodeproj/project.pbxproj b/Piper.xcodeproj/project.pbxproj index c7de080..55a9a07 100644 --- a/Piper.xcodeproj/project.pbxproj +++ b/Piper.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ membershipExceptions = ( UITests/UITests.swift, UITests/UITestsLaunchTests.swift, + UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift, UnitTests/UnitTests.swift, ); target = 46D4BE762CB06ED300FCFB84 /* Piper */; @@ -42,6 +43,7 @@ 46D4BEF72CB07CCB00FCFB84 /* Exceptions for "Tests" folder in "UnitTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift, UnitTests/UnitTests.swift, ); target = 46D4BED32CB07C7A00FCFB84 /* UnitTests */; @@ -492,6 +494,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 7FMNM89WKG; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -509,6 +512,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 7FMNM89WKG; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit"; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift b/Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift new file mode 100644 index 0000000..853c653 --- /dev/null +++ b/Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift @@ -0,0 +1,70 @@ +// +// Pipe+AsyncAvailableData.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation + +extension Pipe { + struct AsyncAvailableData { + + // MARK: Properties + + private let pipe: Pipe + + // MARK: Initialisers + + init(_ pipe: Pipe) { + self.pipe = pipe + } + + // MARK: Functions + + func append() async -> Data { + var data = Data() + + for await availableData in self { + data.append(availableData) + } + + return data + } + + } +} + +// MARK: - AsyncSequence + +extension Pipe.AsyncAvailableData: AsyncSequence { + + // MARK: Type aliases + + typealias AsyncIterator = AsyncStream.Iterator + typealias Element = Data + + // MARK: Functions + + func makeAsyncIterator() -> AsyncIterator { + AsyncStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { @Sendable handler in + let data = handler.availableData + + guard !data.isEmpty else { + continuation.finish() + return + } + + continuation.yield(data) + } + + continuation.onTermination = { _ in + pipe.fileHandleForReading.readabilityHandler = nil + } + } + .makeAsyncIterator() + } + +} diff --git a/Piper/Sources/Logic/Extensions/Pipe+Computed.swift b/Piper/Sources/Logic/Extensions/Pipe+Computed.swift new file mode 100644 index 0000000..2622d39 --- /dev/null +++ b/Piper/Sources/Logic/Extensions/Pipe+Computed.swift @@ -0,0 +1,17 @@ +// +// Pipe+Computed.swift +// Piper +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation + +extension Pipe { + + // MARK: Computed + + var availableData: AsyncAvailableData { .init(self) } + +} diff --git a/Piper/Sources/Logic/UseCases/RunProcessUseCase.swift b/Piper/Sources/Logic/UseCases/RunProcessUseCase.swift new file mode 100644 index 0000000..29e70eb --- /dev/null +++ b/Piper/Sources/Logic/UseCases/RunProcessUseCase.swift @@ -0,0 +1,87 @@ +// +// RunProcessUseCase.swift +// Piper ~ App +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Foundation + +struct RunProcessUseCase { + + // MARK: Type aliases + + typealias Output = String + + // MARK: Properties + + private let path: String + private let arguments: [String]? + + // MARK: Initialisers + + init( + path: String, + arguments: [String] = [] + ) { + self.path = path + self.arguments = arguments + } + + // MARK: Functions + + func callAsFunction() async throws -> Output { + let process = Process() + + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + + let pipeError = Pipe() + let pipeOutput = Pipe() + + process.standardError = pipeError + process.standardOutput = pipeOutput + + async let streamOutput = pipeOutput.availableData.append() + async let streamError = pipeError.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 RunProcessError.unexpected + } + + throw RunProcessError.output(errorOutput) + } + + guard let output = String(data: dataOutput, encoding: .utf8) else { + throw RunProcessError.unexpected + } + + return await withCheckedContinuation { continuation in + process.terminationHandler = { _ in + continuation.resume(returning: output) + } + } + } catch let error as RunProcessError { + throw error + } catch { + throw RunProcessError.captured(error) + } + } + +} + +// MARK: - Errors + +enum RunProcessError: Error { + case captured(_ error: Error) + case output(_ output: String) + case unexpected +} diff --git a/Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift b/Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift new file mode 100644 index 0000000..320ad4b --- /dev/null +++ b/Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift @@ -0,0 +1,69 @@ +// +// RunProcessUseCaseTests.swift +// UnitTests +// +// Created by Javier Cicchelli on 05/10/2024. +// Copyright © 2024 Röck+Cöde. All rights reserved. +// + +import Testing + +@testable import Piper + +struct RunProcessUseCaseTests { + + // MARK: Functions tests + + @Test("Run a simple process without arguments") + func simpleCommand_withoutArguments() async throws { + // GIVEN + let useCase = RunProcessUseCase(path: .ls) + + // WHEN + let output = try await useCase() + + // THEN + #expect(output.isEmpty == false) + } + + @Test("Run a simple process with arguments") + func simpleCommand_withArguments() async throws { + // GIVEN + let useCase = RunProcessUseCase( + path: .ls, + arguments: ["-la", "."] + ) + + // WHEN + let output = try await useCase() + + // THEN + #expect(output.isEmpty == false) + } + + @Test("Run a simple command with arguments that throws an error") + func simpleCommand_withArguments_throwsError() async throws { + // GIVEN + let useCase = RunProcessUseCase( + path: .ls, + arguments: ["-la", "~"] + ) + + // WHEN + // THEN + await #expect(throws: RunProcessError.self) { + try await useCase() + } + } + +} + +// MARK: - String+Constants + +private extension String { + + // MARK: Constants + + static let ls = "/bin/ls" + +}