[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:
parent
40535054f3
commit
f090784973
@ -35,6 +35,7 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
UITests/UITests.swift,
|
UITests/UITests.swift,
|
||||||
UITests/UITestsLaunchTests.swift,
|
UITests/UITestsLaunchTests.swift,
|
||||||
|
UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift,
|
||||||
UnitTests/UnitTests.swift,
|
UnitTests/UnitTests.swift,
|
||||||
);
|
);
|
||||||
target = 46D4BE762CB06ED300FCFB84 /* Piper */;
|
target = 46D4BE762CB06ED300FCFB84 /* Piper */;
|
||||||
@ -42,6 +43,7 @@
|
|||||||
46D4BEF72CB07CCB00FCFB84 /* Exceptions for "Tests" folder in "UnitTests" target */ = {
|
46D4BEF72CB07CCB00FCFB84 /* Exceptions for "Tests" folder in "UnitTests" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
|
UnitTests/Tests/UseCases/RunProcessUseCaseTests.swift,
|
||||||
UnitTests/UnitTests.swift,
|
UnitTests/UnitTests.swift,
|
||||||
);
|
);
|
||||||
target = 46D4BED32CB07C7A00FCFB84 /* UnitTests */;
|
target = 46D4BED32CB07C7A00FCFB84 /* UnitTests */;
|
||||||
@ -492,6 +494,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 7FMNM89WKG;
|
DEVELOPMENT_TEAM = 7FMNM89WKG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@ -509,6 +512,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = 7FMNM89WKG;
|
DEVELOPMENT_TEAM = 7FMNM89WKG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.rock-n-code.piper.tests.unit";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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