[App] Run terminal processes #3
@ -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)";
|
||||
|
70
Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift
Normal file
70
Piper/Sources/Logic/Extensions/Pipe+AsyncAvailableData.swift
Normal 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()
|
||||
}
|
||||
|
||||
}
|
17
Piper/Sources/Logic/Extensions/Pipe+Computed.swift
Normal file
17
Piper/Sources/Logic/Extensions/Pipe+Computed.swift
Normal 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) }
|
||||
|
||||
}
|
87
Piper/Sources/Logic/UseCases/RunProcessUseCase.swift
Normal file
87
Piper/Sources/Logic/UseCases/RunProcessUseCase.swift
Normal 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
|
||||
}
|
69
Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift
Normal file
69
Tests/UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift
Normal 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"
|
||||
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user