[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: #3
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
Javier Cicchelli 2024-10-05 08:34:34 +00:00 committed by Javier Cicchelli
parent 40535054f3
commit f090784973
5 changed files with 247 additions and 0 deletions

View File

@ -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)";

View File

@ -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<Data>.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()
}
}

View File

@ -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) }
}

View File

@ -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
}

View File

@ -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"
}