diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 819d3bc..366832b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: uses: actions/checkout@v3 - name: Build and test shell: bash - env: + env: NSUnbufferedIO: YES run: | swift test --enable-code-coverage --disable-automatic-resolution @@ -27,7 +27,7 @@ jobs: name: Test Linux runs-on: ubuntu-latest container: - image: swift:5.6-focal + image: swift:6.3.3 steps: - uses: actions/checkout@v3 - run: swift test diff --git a/Package.swift b/Package.swift index e455cfb..ece785e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.3 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -25,5 +25,6 @@ let package = Package( resources: [ .copy("Fixtures") ]), - ] + ], + swiftLanguageModes: [.v6], ) diff --git a/README.md b/README.md index 1731896..a426bca 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Test](https://github.com/Alexander-Ignition/XSTooling/actions/workflows/test.yml/badge.svg)](https://github.com/Alexander-Ignition/XSTooling/actions/workflows/test.yml) [![SPM compatible](https://img.shields.io/badge/spm-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager) -[![Swift 5.3](https://img.shields.io/badge/swift-5.3-brightgreen.svg?style=flat)](https://developer.apple.com/swift) +[![Swift 6.3](https://img.shields.io/badge/swift-6.3-brightgreen.svg?style=flat)](https://developer.apple.com/swift) [![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://github.com/Alexander-Ignition/XSTooling/blob/master/LICENSE) Xcode and Swift toolset @@ -34,7 +34,7 @@ Finally, add `import XSTooling` to your source code. ```swift import XSTooling -let sh = Shell.default +let sh = Shell.current try await sh("swift build").run() ``` @@ -61,31 +61,18 @@ let url = URL(fileURLWithPath: "logs.txt", isDirectory: false) FileManager.default.createFile(atPath: url.path, contents: nil) let file = try FileHandle(forWritingTo: url) -try await sh("swift build").run(.output(file).error(file)) +try await sh("swift build").run(standardOutput: file, standardError: file) ``` `Shell` has predefined instances. ```swift -Shell.default +Shell.current Shell.sh Shell.bash Shell.zsh ``` -Conceptually, a `Shell` is a wrapper over a `ProcessCommand`. - -- `sh.command` contains common parameters for all commands. -- `sh("ls")` each call to this method returned a copy of the `ProcessCommand` with additional arguments - -```swift -let sh = Shell.default -sh.command // ProcessCommand -sh.command.environment // [String: String]? -sh.command.currentDirectoryURL // URL? -sh("ls") // ProcessCommand -``` - ## ProcessCommand The main component is `ProcessCommand`. Which can configure and run a subprocess. The `read` and `run` methods are called on the `ProcessCommand`. @@ -126,7 +113,7 @@ for info in devices where info.state == "Booted" { try await simulator.device(info.udid).shutdown.run() } -let udid = devices.first!.udid +let udid = devices.first!.udid try await simulator.device(udid).boot.run() try await simulator.device(udid).app("com.example.app").launch.run() ``` diff --git a/Sources/XSTooling/Core/AsyncProcess.swift b/Sources/XSTooling/Core/AsyncProcess.swift deleted file mode 100644 index c550d49..0000000 --- a/Sources/XSTooling/Core/AsyncProcess.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -extension Process { - var async: AsyncProcess { - AsyncProcess(process: self) - } -} - -final class AsyncProcess { - private let _process: Process - private let _lock = NSLock() - - fileprivate init(process: Process) { - self._process = process - } - - /// Runs the process with the current environment. - func run() async throws { - try await withTaskCancellationHandler { - try Task.checkCancellation() // can be canceled before running - try await _run() - } onCancel: { - _terminate() - } - } - - private func _run() async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - var runningError: Error? - _lock.lock() - _process.terminationHandler = { _ in - continuation.resume() - } - do { - try _process.run() // waiting for the `terminationHandler` call - } catch { - // `terminationHandler` is not called if if an error occurred - runningError = error - } - _lock.unlock() - - if let runningError = runningError { - continuation.resume(throwing: runningError) - } - } - } - - private func _terminate() { - _lock.lock() - if _process.isRunning { // can be canceled without starting - _process.terminate() // crash if not running - } - _lock.unlock() - } -} diff --git a/Sources/XSTooling/Core/FileHandleReader.swift b/Sources/XSTooling/Core/FileHandleReader.swift deleted file mode 100644 index f441a42..0000000 --- a/Sources/XSTooling/Core/FileHandleReader.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -final class FileHandleReader { - fileprivate(set) var data = Data() - - fileprivate init() {} -} - -extension FileHandle { - func reader() -> FileHandleReader { - assert(self.readabilityHandler == nil) - - let reader = FileHandleReader() - - self.readabilityHandler = { fileHandle in - // invoke on serial queue - let data = fileHandle.availableData - - if data.isEmpty { - // stop - fileHandle.readabilityHandler = nil - } else { - reader.data.append(data) - } - } - return reader - } -} diff --git a/Sources/XSTooling/Core/ProcessCommand.swift b/Sources/XSTooling/Core/ProcessCommand.swift deleted file mode 100644 index 470e59d..0000000 --- a/Sources/XSTooling/Core/ProcessCommand.swift +++ /dev/null @@ -1,165 +0,0 @@ -import Foundation - -public struct ProcessCommand: Hashable, @unchecked Sendable { - public static func find(_ name: String) -> ProcessCommand? { - guard let string = ProcessInfo.processInfo.environment["PATH"] else { - return nil - } - let paths = string.split(separator: ":") - for substring in paths { - let directory = URL(fileURLWithPath: String(substring), isDirectory: true) - let url = directory.appendingPathComponent(name) - if FileManager.default.isExecutableFile(atPath: url.path) { - return ProcessCommand(executableURL: url) - } - } - return nil - } - - /// The receiver’s executable. - public var executableURL: URL - - /// The command arguments that the system uses to launch the executable. - public var arguments: [String] - - /// The environment for the executable. - public var environment: [String: String]? - - /// The current directory for the receiver. - public var currentDirectoryURL: URL? - - /// Successful exit code. Default 0. - /// - /// Set `nil` if you don't want to check the exit code. - public var successCode: Int32? - - public init( - executableURL: URL, - arguments: [String] = [], - environment: [String: String]? = nil, - currentDirectoryURL: URL? = nil, - successCode: Int32? = 0 - ) { - self.executableURL = executableURL - self.environment = environment - self.arguments = arguments - self.currentDirectoryURL = currentDirectoryURL - self.successCode = successCode - } - - public init( - path: String, - arguments: [String] = [], - environment: [String: String]? = nil, - currentDirectoryPath: String? = nil, - successCode: Int32? = 0 - ) { - self.init( - executableURL: URL(fileURLWithPath: path, isDirectory: false), - arguments: arguments, - environment: environment, - currentDirectoryURL: currentDirectoryPath.map { - URL(fileURLWithPath: $0, isDirectory: true) - }, - successCode: successCode) - } - - // MARK: - Arguments - - public func appending(argument: String) -> ProcessCommand { - var copy = self - copy.arguments.append(argument) - return copy - } - - public func appending(arguments: String...) -> ProcessCommand { - appending(arguments: arguments) - } - - public func appending(arguments: [String]) -> ProcessCommand { - var copy = self - copy.arguments.append(contentsOf: arguments) - return copy - } - - // MARK: - Running - - /// Run and read from standard output and standard error streams. - /// - /// - Throws: `CancellationError` if cancelled before running. - /// - Throws: `ProcessOutputError` if the exit code is not equal to `successCode`. - /// - Returns: The command output with collected bytes from standard output and standard error streams. - public func read() async throws -> ProcessOutput { - try await _check(_read()) - } - - /// Runs the command with the current environment. - /// - /// By default, during command execution, reads from stdout and stderr occur. - /// This behavior can be changed by passing `redirection`. - /// - /// - Throws: `CancellationError` if cancelled before running. - /// - Throws: `ProcessOutputError` if the exit code is not equal to `successCode`. - /// - Parameter redirection: A subprocess input / output. Default `nil`. - /// - Returns: The command output. - @discardableResult - public func run(_ redirection: ProcessRedirection? = nil) async throws -> ProcessOutput { - try await _check(_run(redirection)) - } - - // MARK: - Private - - private func _check(_ output: ProcessOutput) throws -> ProcessOutput { - if let success = successCode { - try output.check(code: success) - } - return output - } - - private func _read() async throws -> ProcessOutput { - let outputPipe = Pipe() - let errorPipe = Pipe() - - let outputReader = outputPipe.fileHandleForReading.reader() - let errorReader = errorPipe.fileHandleForReading.reader() - - defer { - outputPipe.fileHandleForReading.readabilityHandler = nil - errorPipe.fileHandleForReading.readabilityHandler = nil - } - - var result = try await _run(.output(outputPipe).error(errorPipe)) - - result.standardOutput = outputReader.data - result.standardError = errorReader.data - - return result - } - - private func _run(_ redirection: ProcessRedirection?) async throws -> ProcessOutput { - let process = _process(redirection: redirection) - - try await process.async.run() - - let code = process.terminationStatus - let reason = process.terminationReason - return ProcessOutput(code: code, reason: reason, command: self) - } - - private func _process(redirection: ProcessRedirection?) -> Process { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - if let environment = environment { - process.environment = environment - } - process.currentDirectoryURL = currentDirectoryURL - if let standardOutput = redirection?.standardOutput?.object { - process.standardOutput = standardOutput - } - if let standardError = redirection?.standardError?.object { - process.standardError = standardError - } - return process - } -} diff --git a/Sources/XSTooling/Core/ProcessOutput.swift b/Sources/XSTooling/Core/ProcessOutput.swift deleted file mode 100644 index ffe0cb1..0000000 --- a/Sources/XSTooling/Core/ProcessOutput.swift +++ /dev/null @@ -1,91 +0,0 @@ -import Foundation - -/// Output of the process execution. -public struct ProcessOutput: Hashable, @unchecked Sendable { - /// Constants that specify the termination reason values that the system returns. - public typealias TerminationReason = Process.TerminationReason - - /// The exit status the receiver’s executable returns. - public var code: Int32 - - /// The reason the system terminated the task. - public var reason: TerminationReason - - /// Failed executable command. - public var command: ProcessCommand - - /// Bytes from standard output stream. - public var standardOutput: Data - - /// Bytes from standard error stream. - public var standardError: Data - - public init( - code: Int32, - reason: TerminationReason, - command: ProcessCommand, - standardOutput: Data = Data(), - standardError: Data = Data() - ) { - self.code = code - self.reason = reason - self.command = command - self.standardOutput = standardOutput - self.standardError = standardError - } - - // MARK: - Check - - /// Check the success status of the exit code. - /// - /// - Parameter code: success exit code. Default 0. - /// - Returns: same output. - /// - Throws: `ProcessOutputError`. - @discardableResult - public func check(code: Int32 = 0) throws -> ProcessOutput { - if self.code != code { - throw ProcessOutputError(output: self) - } - return self - } - - // MARK: - String - - /// UTF8 string from standard output stream. - public var string: String { - standardOutput.string(strippingNewline: true) - } - - /// Error description from standard error stream. - public var errorDescription: String? { - guard !standardError.isEmpty else { - return nil - } - return standardError.string(strippingNewline: true) - } - - // MARK: - JSON - - /// Default JSON decoder. - private static let decoder = JSONDecoder() - - public func decode( - _ type: T.Type, - using decoder: JSONDecoder? = nil - ) throws -> T where T: Decodable { - - try (decoder ?? Self.decoder).decode(type, from: standardOutput) - } -} - -extension Data { - private static let newLine = UInt8(ascii: "\n") - - fileprivate func string(strippingNewline: Bool) -> String { - var buffer = self - if strippingNewline, buffer.last == Self.newLine { // TODO: \r\n or \n - buffer = buffer.dropLast() - } - return String(decoding: buffer, as: UTF8.self) - } -} diff --git a/Sources/XSTooling/Core/ProcessOutputError.swift b/Sources/XSTooling/Core/ProcessOutputError.swift deleted file mode 100644 index 2e5150a..0000000 --- a/Sources/XSTooling/Core/ProcessOutputError.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -/// Process execution error. -public struct ProcessOutputError: Error, Equatable { - /// Output of the process that ended with an error. - public let output: ProcessOutput - - public init(output: ProcessOutput) { - self.output = output - } -} - -extension ProcessOutputError: LocalizedError { - /// Error description from standard error stream. - public var errorDescription: String? { - output.errorDescription - } -} - -extension ProcessOutputError: CustomNSError { - public var errorCode: Int { Int(output.code) } - - public var errorUserInfo: [String: Any] { - var userInfo: [String: Any] = [:] - userInfo[NSLocalizedDescriptionKey] = errorDescription - return userInfo - } -} diff --git a/Sources/XSTooling/Core/ProcessRedirection.swift b/Sources/XSTooling/Core/ProcessRedirection.swift deleted file mode 100644 index b69e599..0000000 --- a/Sources/XSTooling/Core/ProcessRedirection.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation - -/// Redirecting the I/O of the process.. -public struct ProcessRedirection: Equatable { - enum Target: Equatable { - /// A wrapper for a file descriptor. - case file(FileHandle) - - /// A one-way communications channel between related processes. - case pipe(Pipe) - } - - /// The standard output for the receiver. - var standardOutput: Target? - - /// The standard error for the receiver. - var standardError: Target? - - /// A new empty redirection. - public init() {} - - // MARK: - Output - - public static func output(_ file: FileHandle) -> ProcessRedirection { - ProcessRedirection().output(file) - } - - public static func output(_ pipe: Pipe) -> ProcessRedirection { - ProcessRedirection().output(pipe) - } - - public func output(_ file: FileHandle) -> ProcessRedirection { - copy(standardOutput: .file(file)) - } - - public func output(_ pipe: Pipe) -> ProcessRedirection { - copy(standardOutput: .pipe(pipe)) - } - - // MARK: - Error - - public static func error(_ file: FileHandle) -> ProcessRedirection { - ProcessRedirection().error(file) - } - - public static func error(_ pipe: Pipe) -> ProcessRedirection { - ProcessRedirection().error(pipe) - } - - public func error(_ file: FileHandle) -> ProcessRedirection { - copy(standardError: .file(file)) - } - - public func error(_ pipe: Pipe) -> ProcessRedirection { - copy(standardError: .pipe(pipe)) - } - - // MARK: - Copy - - private func copy(standardOutput: Target?) -> ProcessRedirection { - var redirection = self - redirection.standardOutput = standardOutput - return redirection - } - - private func copy(standardError: Target?) -> ProcessRedirection { - var redirection = self - redirection.standardError = standardError - return redirection - } -} - -// MARK: - Target properties - -extension ProcessRedirection.Target { - var object: Any { - switch self { - case .file(let fileHandle): - return fileHandle - case .pipe(let pipe): - return pipe - } - } -} - diff --git a/Sources/XSTooling/ProcessCommand.swift b/Sources/XSTooling/ProcessCommand.swift new file mode 100644 index 0000000..be3d79f --- /dev/null +++ b/Sources/XSTooling/ProcessCommand.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct ProcessCommand: Hashable, Sendable { + public static func find(_ name: String) -> ProcessCommand? { + ProcessInfo + .processInfo + .environment["PATH"]? + .split(separator: ":") + .lazy + .map { URL(fileURLWithPath: String($0), isDirectory: true).appendingPathComponent(name) } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + .map { ProcessCommand(executableURL: $0) } + } + + /// The receiver’s executable. + public var executableURL: URL + + /// The command arguments that the system uses to launch the executable. + public var arguments: [String] + + /// The environment for the executable. + public var environment: [String: String]? + + /// The current directory for the receiver. + public var currentDirectoryURL: URL? + + public init( + executableURL: URL, + arguments: [String] = [], + environment: [String: String]? = nil, + currentDirectoryURL: URL? = nil, + ) { + self.executableURL = executableURL + self.environment = environment + self.arguments = arguments + self.currentDirectoryURL = currentDirectoryURL + } + + public init( + path: String, + arguments: [String] = [], + environment: [String: String]? = nil, + currentDirectoryPath: String? = nil, + ) { + self.init( + executableURL: URL(fileURLWithPath: path, isDirectory: false), + arguments: arguments, + environment: environment, + currentDirectoryURL: currentDirectoryPath.map { + URL(fileURLWithPath: $0, isDirectory: true) + }) + } + + // MARK: - Arguments + + public func appending(argument: String) -> ProcessCommand { + var copy = self + copy.arguments.append(argument) + return copy + } + + public func appending(arguments: String...) -> ProcessCommand { + appending(arguments: arguments) + } + + public func appending(arguments: [String]) -> ProcessCommand { + var copy = self + copy.arguments.append(contentsOf: arguments) + return copy + } + + // MARK: - Running + + public func read(standardError: FileHandle? = nil) async throws -> ProcessOutput { + let pipe = Pipe() + let standardError: Any? = (standardError == FileHandle.standardOutput) ? pipe : standardError + async let output = pipe.fileHandleForReading.stream().reduce(into: Data()) { $0.append($1) } + try await runProcess(standardOutput: pipe, standardError: standardError) + let data = await output + return ProcessOutput(data: data) + } + + public func run(standardOutput: FileHandle? = nil, standardError: FileHandle? = nil) async throws { + try await runProcess(standardOutput: standardOutput, standardError: standardError) + } + + // MARK: - Private + + private func runProcess(standardOutput: Any?, standardError: Any?) async throws { + let process = try makeProcess(standardOutput: standardOutput, standardError: standardError) + try await process.execute() + if process.terminationStatus != 0 { + throw ProcessError( + executableURL: executableURL, + arguments: arguments, + terminationStatus: process.terminationStatus, + terminationReason: process.terminationReason + ) + } + } + + private func makeProcess(standardOutput: Any?, standardError: Any?) throws -> Process { + let process = Process() + process.executableURL = executableURL + process.currentDirectoryURL = currentDirectoryURL + process.arguments = arguments + if let environment = environment { + process.environment = environment + } + if let standardOutput { + process.standardOutput = standardOutput + } + if let standardError { + process.standardError = standardError + } + return process + } +} + +public struct ProcessOutput: Sendable { + public let data: Data + + public init(data: Data) { + self.data = data + } + + public var string: String { + string(strippingNewline: true) + } + + public func string(strippingNewline: Bool) -> String { + var string = String(decoding: data, as: UTF8.self) + if strippingNewline, string.last?.isNewline == true { + string.removeLast() + } + return string + } + + @usableFromInline static let decoder = JSONDecoder() + + @inlinable public func decode( + _ type: T.Type, + using decoder: JSONDecoder? = nil + ) throws -> T where T: Decodable { + try (decoder ?? Self.decoder).decode(type, from: data) + } +} + +public struct ProcessError: Error, Equatable { + public let executableURL: URL + public let arguments: [String] + public let terminationStatus: Int32 + public let terminationReason: Process.TerminationReason + + public init( + executableURL: URL, + arguments: [String], + terminationStatus: Int32, + terminationReason: Process.TerminationReason + ) { + self.executableURL = executableURL + self.arguments = arguments + self.terminationStatus = terminationStatus + self.terminationReason = terminationReason + } +} + +// MARK: - Process + +extension Process { + fileprivate func execute() async throws { + try Task.checkCancellation() + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.terminationHandler = { process in + process.terminationHandler = nil + continuation.resume() + } + do { + try self.run() + } catch { + self.terminationHandler = nil + continuation.resume(throwing: error) + } + } + } onCancel: { // can be canceled without starting + if self.isRunning { + self.terminate() // crash if not running + } + } + try Task.checkCancellation() + } +} + +// MARK: - FileHandle + AsyncStream + +extension FileHandle { + fileprivate func stream() -> AsyncStream { + AsyncStream { continuation in + self.readabilityHandler = { fileHandle in + continuation.onTermination = { _ in + fileHandle.readabilityHandler = nil // stop + } + let data = fileHandle.availableData + if data.isEmpty { + fileHandle.readabilityHandler = nil // stop + continuation.finish() + } else { + continuation.yield(data) + } + } + } + } +} diff --git a/Sources/XSTooling/Shell.swift b/Sources/XSTooling/Shell.swift index 9071035..11d8c7b 100644 --- a/Sources/XSTooling/Shell.swift +++ b/Sources/XSTooling/Shell.swift @@ -1,31 +1,33 @@ import Foundation public struct Shell: Equatable, Sendable { - /// The default shell. - public static var `default`: Shell { + @TaskLocal + public static var current: Shell = { let path = ProcessInfo.processInfo.environment["SHELL"] return path.map { Shell(path: $0) } ?? bash - } - + }() + /// POSIX-compliant command interpreter. - public static let sh = Shell(path: "/bin/sh") + public static var sh: Shell { + Shell(path: "/bin/sh") + } /// GNU Bourne-Again SHell. - public static let bash = Shell(path: "/bin/bash") + public static var bash: Shell { + Shell(path: "/bin/bash") + } /// The Z shell. - public static let zsh = Shell(path: "/bin/zsh") + public static var zsh: Shell { + Shell(path: "/bin/zsh") + } - /// Basic command. - /// - /// Contains common parameters for all commands in the tool. - public var command: ProcessCommand + public var path: String + public var arguments: [String] - /// A new shell. - /// - /// - Parameter path: executable file location. - public init(path: String) { - self.command = ProcessCommand(path: path) + public init(path: String, arguments: [String] = []) { + self.path = path + self.arguments = arguments } // MARK: - Options @@ -36,7 +38,7 @@ public struct Shell: Equatable, Sendable { private func option(_ value: String) -> Shell { var shell = self - shell.command.arguments.append(value) + shell.arguments.append(value) return shell } @@ -44,7 +46,7 @@ public struct Shell: Equatable, Sendable { /// Show version information for this instance of bash on the standard output and exit successfully. public var version: ProcessCommand { - command.appending(argument: "--version") + ProcessCommand(path: path, arguments: arguments).appending(argument: "--version") } public func callAsFunction(_ string: String) -> ProcessCommand { @@ -52,11 +54,9 @@ public struct Shell: Equatable, Sendable { } public func command(string: String) -> ProcessCommand { - command.appending(arguments: "-c", string) - } - - /// Locate a program file in the user's path. - public func which(_ name: String) -> ProcessCommand { - command(string: "which \(name)") + var arguments = self.arguments + arguments.append("-c") + arguments.append(string) + return ProcessCommand(path: path, arguments: arguments) } } diff --git a/Sources/XSTooling/Simctl.swift b/Sources/XSTooling/Simctl.swift index 0a77b7d..c850ee5 100644 --- a/Sources/XSTooling/Simctl.swift +++ b/Sources/XSTooling/Simctl.swift @@ -1,9 +1,13 @@ /// Command line utility to control the Simulator. public struct Simctl: Equatable, Sendable { - public var command: ProcessCommand + public var path: String - public init(command: ProcessCommand) { - self.command = command + public func callAsFunction(_ arguments: String...) -> ProcessCommand { + command(arguments: arguments) + } + + public func command(arguments: [String]) -> ProcessCommand { + ProcessCommand(path: path, arguments: arguments) } public var booted: DeviceControl { @@ -20,17 +24,17 @@ public struct Simctl: Equatable, Sendable { /// Boot a device or device pair. public var boot: ProcessCommand { - simulator.command.appending(arguments: "boot", udid) + simulator("boot", udid) } /// Shutdown a device. public var shutdown: ProcessCommand { - simulator.command.appending(arguments: "shutdown", udid) + simulator("shutdown", udid) } /// Open a URL in a device. public func open(url: String) -> ProcessCommand { - simulator.command.appending(arguments: "openurl", udid, url) + simulator("openurl", udid, url) } public func app(_ appBundleIdentifier: String) -> ApplicationControl { @@ -49,12 +53,12 @@ public struct Simctl: Equatable, Sendable { /// Launch an application by identifier on a device. public var launch: ProcessCommand { - device.simulator.command.appending(arguments: "launch", device.udid, bundleIdentifier) + device.simulator("launch", device.udid, bundleIdentifier) } /// Terminate an application by identifier on a device. public var terminate: ProcessCommand { - device.simulator.command.appending(arguments: "terminate", device.udid, bundleIdentifier) + device.simulator("terminate", device.udid, bundleIdentifier) } } @@ -63,26 +67,32 @@ public struct Simctl: Equatable, Sendable { let application: ApplicationControl /// The .app bundle. - public var app: ProcessCommand { _path("app") } + public var app: ProcessCommand { + container("app") + } /// The application's data container. - public var data: ProcessCommand { _path("data") } + public var data: ProcessCommand { + container("data") + } /// The App Group containers. - public var groups: ProcessCommand { _path("groups") } + public var groups: ProcessCommand { + container("groups") + } /// A specific App Group container. public func group(_ identifier: String) -> ProcessCommand { - _path(identifier) + container(identifier) } - private func _path(_ container: String) -> ProcessCommand { + private func container(_ name: String) -> ProcessCommand { // Usage: simctl get_app_container [] - application.device.simulator.command.appending(arguments: + application.device.simulator( "get_app_container", application.device.udid, application.bundleIdentifier, - container) + name) } } } @@ -92,7 +102,7 @@ public struct Simctl: Equatable, Sendable { extension Simctl { /// List available devices, device types, runtimes, and device pairs. public var list: ListQuery { - ListQuery(command: command.appending(argument: "list")) + ListQuery(command: command(arguments: ["list"])) } /// List available devices, device types, runtimes, and device pairs. @@ -112,7 +122,7 @@ extension Simctl { if available { arguments.append("available") } - return ListQuery(command: command.appending(arguments: arguments)) + return ListQuery(command: command(arguments: arguments)) } public struct ListQuery: Sendable { @@ -121,11 +131,6 @@ extension Simctl { public func read() async throws -> ProcessOutput { try await command.read() } - - @discardableResult - public func run(_ redirection: ProcessRedirection? = nil) async throws -> ProcessOutput { - try await command.run() - } } } diff --git a/Sources/XSTooling/XCRun.swift b/Sources/XSTooling/XCRun.swift index 8f11017..d9775df 100644 --- a/Sources/XSTooling/XCRun.swift +++ b/Sources/XSTooling/XCRun.swift @@ -1,30 +1,36 @@ /// Run or locate development tools and properties. public struct XCRun: Equatable, Sendable { - public var command: ProcessCommand + @TaskLocal + public static var current = XCRun(path: "/usr/bin/xcrun") - public init(path: String = "/usr/bin/xcrun") { - self.command = ProcessCommand(path: path) - } - - public init(command: ProcessCommand) { - self.command = command - } + public var path: String /// Show the xcrun version. public var version: ProcessCommand { - command.appending(argument: "--version") + command(arguments: ["--version"]) + } + + /// A new simulator control. + public var simctl: Simctl { + get async throws { + let path = try await find("simctl") + return Simctl(path: path) + } } /// Only find and return the tool path. /// /// - Parameter tool: The tool name. /// - Returns: The tool path. - public func find(_ tool: String) -> ProcessCommand { - command.appending(arguments: "--find", tool) + public func find(_ tool: String) async throws -> String { + try await command(arguments: ["--find", tool]).read().string(strippingNewline: true) } - /// A new simulator control. - public var simctl: Simctl { - Simctl(command: command.appending(argument: "simctl")) + public func callAsFunction(_ arguments: String...) -> ProcessCommand { + command(arguments: arguments) + } + + public func command(arguments: [String]) -> ProcessCommand { + ProcessCommand(path: path, arguments: arguments) } } diff --git a/Tests/XSToolingTests/Asserts.swift b/Tests/XSToolingTests/Asserts.swift deleted file mode 100644 index 2fe03f5..0000000 --- a/Tests/XSToolingTests/Asserts.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest -import XSTooling - -extension ProcessCommand { - var assert: Assertion { Assertion(command: self) } - - struct Assertion { - private let command: ProcessCommand - - fileprivate init(command: ProcessCommand) { - self.command = command - } - - func equal(path: String, arguments: String..., file: StaticString = #filePath, line: UInt = #line) { - let other = ProcessCommand(path: path, arguments: arguments) - equal(to: other, file: file, line: line) - } - - func equal(to other: ProcessCommand, file: StaticString = #filePath, line: UInt = #line) { - XCTAssertEqual(command.executableURL, other.executableURL, "executableURL", file: file, line: line) - XCTAssertEqual(command.arguments, other.arguments, "arguments", file: file, line: line) - XCTAssertEqual(command.environment, other.environment, "environment", file: file, line: line) - XCTAssertEqual(command.currentDirectoryURL, other.currentDirectoryURL, "currentDirectoryURL", file: file, line: line) - XCTAssertEqual(command.successCode, other.successCode, "successCode", file: file, line: line) - } - } -} diff --git a/Tests/XSToolingTests/Core/ProcessCommandTests.swift b/Tests/XSToolingTests/Core/ProcessCommandTests.swift index c7f1a28..282c590 100644 --- a/Tests/XSToolingTests/Core/ProcessCommandTests.swift +++ b/Tests/XSToolingTests/Core/ProcessCommandTests.swift @@ -3,7 +3,7 @@ import XSTooling extension ProcessCommand { static func bash(_ command: String, successCode: Int32? = 0) -> ProcessCommand { - ProcessCommand(path: "/bin/bash", arguments: ["-c", command], successCode: successCode) + ProcessCommand(path: "/bin/bash", arguments: ["-c", command]) } } @@ -16,7 +16,6 @@ final class ProcessCommandTests: GHTestCase { XCTAssertEqual(command.arguments, []) XCTAssertNil(command.environment) XCTAssertNil(command.currentDirectoryURL) - XCTAssertEqual(command.successCode, 0) } func testRead() async throws { @@ -24,23 +23,21 @@ final class ProcessCommandTests: GHTestCase { let output = try await command.read() - XCTAssertEqual(output.code, 0) - XCTAssertEqual(output.reason, .exit) - XCTAssertEqual(output.command, command) - XCTAssertEqual(output.standardOutput, Data("hello\n".utf8)) - XCTAssertEqual(output.standardError, Data()) + XCTAssertEqual(output.data, Data("hello\n".utf8)) + } + + func testReadStandardError() async throws { + let command = ProcessCommand.bash("echo 'hello'; echo 'world!' >&2;") + + let output = try await command.read(standardError: .standardOutput) + + XCTAssertEqual(output.string, "hello\nworld!") } func testRunWithRedirection() async throws { let command = ProcessCommand.bash("echo 'test'") - let output = try await command.run(.output(.standardOutput).error(.standardOutput)) - - XCTAssertEqual(output.code, 0) - XCTAssertEqual(output.reason, .exit) - XCTAssertEqual(output.command, command) - XCTAssertEqual(output.standardOutput, Data()) - XCTAssertEqual(output.standardError, Data()) + try await command.run(standardOutput: .standardOutput, standardError: .standardOutput) } func testEnvironment() async throws { @@ -67,25 +64,29 @@ final class ProcessCommandTests: GHTestCase { func testSuccessCodeCheck() async { let command = ProcessCommand.bash("exit 1") - let output = ProcessOutput(code: 1, reason: .exit, command: command) - let expectedError = ProcessOutputError(output: output) + let expectedError = ProcessError( + executableURL: command.executableURL, + arguments: command.arguments, + terminationStatus: 1, + terminationReason: .exit + ) do { - let output = try await command.run() - XCTFail("The exit code has not been checked: \(output)") - } catch let error as ProcessOutputError { + try await command.run() + XCTFail("The exit code has not been checked") + } catch let error as ProcessError { XCTAssertEqual(error, expectedError) } catch { XCTFail("Unexpected error: \(error)") } } - func testReadWithError() async throws { + func testRunWithError() async throws { let command = ProcessCommand(path: "/usr/local/bin/not/found") do { - let output = try await command.run() - XCTFail("\(output)") + try await command.run() + XCTFail("The exit code has not been checked") } catch { - XCTAssertFalse(error is ProcessOutputError) + XCTAssertFalse(error is ProcessError) } } @@ -95,8 +96,8 @@ final class ProcessCommandTests: GHTestCase { } task.cancel() do { - let result = try await task.value - XCTFail("Task not cancelled. Exit code: \(result.code)") + let output = try await task.value + XCTFail("Task not cancelled. Output: \(output.string)") } catch { XCTAssert(error is CancellationError, "Unexpected error: \(error)") } @@ -108,8 +109,8 @@ final class ProcessCommandTests: GHTestCase { } task.cancel() do { - let result = try await task.value - XCTFail("Task not cancelled. Exit code: \(result.code)") + _ = try await task.value + XCTFail("Task not cancelled") } catch { XCTAssert(error is CancellationError, "Unexpected error: \(error)") } @@ -125,8 +126,10 @@ final class ProcessCommandTests: GHTestCase { try await Task.sleep(nanoseconds: 1_000_000) task.cancel() } - let result = try await task.value - XCTAssertEqual(result.code, 15, "The process was not terminated") - XCTAssertEqual(result.string, "") + do { + _ = try await task.value + } catch { + XCTAssert(error is CancellationError, "Unexpected error: \(error)") + } } } diff --git a/Tests/XSToolingTests/Core/ProcessOutputErrorTests.swift b/Tests/XSToolingTests/Core/ProcessOutputErrorTests.swift deleted file mode 100644 index eaa5370..0000000 --- a/Tests/XSToolingTests/Core/ProcessOutputErrorTests.swift +++ /dev/null @@ -1,36 +0,0 @@ -import XCTest -import XSTooling - -final class ProcessOutputErrorTests: GHTestCase { - - private var error: ProcessOutputError! - private var result: ProcessOutput! { - didSet { error = ProcessOutputError(output: result) } - } - - override func setUp() { - super.setUp() - let command = ProcessCommand(path: "/usr/bin/bash") - result = ProcessOutput(code: 0, reason: .exit, command: command) - } - - func testErrorDescription() { - result.standardError = Data("file not found\n".utf8) - XCTAssertEqual(error.errorDescription, "file not found") - } - - func testErrorCode() { - result.code = 2 - XCTAssertEqual(error.errorCode, 2) - } - - func testErrorUserInfo() { - result.standardError = Data() - XCTAssertEqual(error.errorUserInfo as NSDictionary, [:]) - - result.standardError = Data("not found".utf8) - XCTAssertEqual(error.errorUserInfo as NSDictionary, [ - NSLocalizedDescriptionKey: "not found" - ]) - } -} diff --git a/Tests/XSToolingTests/Core/ProcessOutputTests.swift b/Tests/XSToolingTests/Core/ProcessOutputTests.swift index 8d23b1c..529455e 100644 --- a/Tests/XSToolingTests/Core/ProcessOutputTests.swift +++ b/Tests/XSToolingTests/Core/ProcessOutputTests.swift @@ -3,51 +3,19 @@ import XSTooling final class ProcessOutputTests: GHTestCase { - private var output: ProcessOutput! - - override func setUp() { - super.setUp() - let command = ProcessCommand(path: "/usr/bin/ls") - output = ProcessOutput(code: 0, reason: .exit, command: command) - } - - func testChecked() { - output.code = 0 - XCTAssertEqual(try output.check(), output) - - output.code = 1 - XCTAssertEqual(try output.check(code: 1), output) - - output.code = 1 - XCTAssertThrowsError(try output.check()) { error in - XCTAssertNotNil(error as? ProcessOutputError) - } - } - func testString() { - output.standardOutput = Data("output\n".utf8) - XCTAssertEqual(output.string, "output") - - output.standardOutput = Data("output".utf8) - XCTAssertEqual(output.string, "output") - } - - func testErrorDescription() { - output.standardError = Data() - XCTAssertNil(output.errorDescription) - - output.standardError = Data("error\n".utf8) - XCTAssertEqual(output.errorDescription, "error") + let output1 = ProcessOutput(data: Data("output\n".utf8)) + XCTAssertEqual(output1.string, "output") - output.standardError = Data("error".utf8) - XCTAssertEqual(output.errorDescription, "error") + let output2 = ProcessOutput(data: Data("output\n".utf8)) + XCTAssertEqual(output2.string, "output") } func testDecode() { struct Status: Decodable { let code: Int } - output.standardOutput = Data(#"{ "code": 2} "#.utf8) + let output = ProcessOutput(data: Data(#"{ "code": 2} "#.utf8)) XCTAssertEqual(try output.decode(Status.self).code, 2) } } diff --git a/Tests/XSToolingTests/ShellTests.swift b/Tests/XSToolingTests/ShellTests.swift index d5da14c..fec2a4c 100644 --- a/Tests/XSToolingTests/ShellTests.swift +++ b/Tests/XSToolingTests/ShellTests.swift @@ -13,7 +13,7 @@ final class ShellTests: GHTestCase { func testSh() async throws { shell = Shell.sh - shell.command.assert.equal(path: "/bin/sh") + XCTAssertEqual(shell.path, "/bin/sh") let string = try await shell("echo 'hello world'").read().string XCTAssertEqual(string, "hello world") @@ -21,7 +21,7 @@ final class ShellTests: GHTestCase { func testBash() async throws { shell = Shell.bash - shell.command.assert.equal(path: "/bin/bash") + XCTAssertEqual(shell.path, "/bin/bash") let string = try await shell("echo 'hello world'").read().string XCTAssertEqual(string, "hello world") @@ -31,45 +31,42 @@ final class ShellTests: GHTestCase { try XCTSkipIf(isLinux) shell = Shell.zsh - shell.command.assert.equal(path: "/bin/zsh") + XCTAssertEqual(shell.path, "/bin/zsh") let string = try await shell("echo 'hello world'").read().string XCTAssertEqual(string, "hello world") } func testVerbose() { - let command = shell.verbose.command - - command.assert.equal(path: path, arguments: "--verbose") + XCTAssertEqual(shell.verbose, Shell(path: path, arguments: ["--verbose"])) } func testLogin() { - let command = shell.login.command - - command.assert.equal(path: path, arguments: "--login") + XCTAssertEqual(shell.login, Shell(path: path, arguments: ["--login"])) } func testVersion() { - let command = shell.version - - command.assert.equal(path: path, arguments: "--version") + XCTAssertEqual(shell.version, ProcessCommand(path: path, arguments: ["--version"])) } func testVerboseLoginVersion() { let command = shell.verbose.login.version - command.assert.equal(path: path, arguments: "--verbose", "--login", "--version") - } - - func testWhich() { - let command = shell.which("ls") + let expected = ProcessCommand( + path: path, + arguments: ["--verbose", "--login", "--version"] + ) + XCTAssertEqual(command, expected) - command.assert.equal(path: path, arguments: "-c", "which ls") } func testCallAsFunction() { let command = shell("xcrun xcodebuild -version") - command.assert.equal(path: path, arguments: "-c", "xcrun xcodebuild -version") + let expected = ProcessCommand( + path: path, + arguments: ["-c", "xcrun xcodebuild -version"] + ) + XCTAssertEqual(command, expected) } } diff --git a/Tests/XSToolingTests/SimctlTests.swift b/Tests/XSToolingTests/SimctlTests.swift index b9ea658..f309d48 100644 --- a/Tests/XSToolingTests/SimctlTests.swift +++ b/Tests/XSToolingTests/SimctlTests.swift @@ -1,8 +1,8 @@ -import XCTest -import XSTooling - #if os(macOS) +import XCTest +@testable import XSTooling + final class SimctlTests: GHTestCase { private var simctl: Simctl! private var path: String! @@ -10,7 +10,7 @@ final class SimctlTests: GHTestCase { override func setUp() { super.setUp() path = "/usr/bin/simctl/\(name)" - simctl = Simctl(command: ProcessCommand(path: path)) + simctl = Simctl(path: path) } // MARK: - Device control @@ -18,25 +18,35 @@ final class SimctlTests: GHTestCase { func testDeviceBoot() { let command = simctl.device("2").boot - command.assert.equal(path: path, arguments: "boot", "2") + let expected = ProcessCommand(path: path, arguments: ["boot", "2"]) + XCTAssertEqual(command, expected) } func testDeviceShutdown() { let command = simctl.device("3").shutdown - command.assert.equal(path: path, arguments: "shutdown", "3") + let expected = ProcessCommand(path: path, arguments: ["shutdown", "3"]) + XCTAssertEqual(command, expected) } func testDeviceOpenURL() { - let command = simctl.device("device-udid").open(url: "https://example.com") + let command = simctl.device("4").open(url: "https://example.com") - command.assert.equal(path: path, arguments: "openurl", "device-udid", "https://example.com") + let expected = ProcessCommand( + path: path, + arguments: ["openurl", "4", "https://example.com"] + ) + XCTAssertEqual(command, expected) } func testBootedDeviceOpenURL() { let command = simctl.booted.open(url: "https://test.com") - command.assert.equal(path: path, arguments: "openurl", "booted", "https://test.com") + let expected = ProcessCommand( + path: path, + arguments: ["openurl", "booted", "https://test.com"] + ) + XCTAssertEqual(command, expected) } // MARK: - App control @@ -44,13 +54,21 @@ final class SimctlTests: GHTestCase { func testDeviceAppLaunch() { let command = simctl.device("4").app("com.bundle.app").launch - command.assert.equal(path: path, arguments: "launch", "4", "com.bundle.app") + let expected = ProcessCommand( + path: path, + arguments: ["launch", "4", "com.bundle.app"] + ) + XCTAssertEqual(command, expected) } func testDeviceAppTerminate() { let command = simctl.device("5").app("com.bundle.app2").terminate - command.assert.equal(path: path, arguments: "terminate", "5", "com.bundle.app2") + let expected = ProcessCommand( + path: path, + arguments: ["terminate", "5", "com.bundle.app2"] + ) + XCTAssertEqual(command, expected) } // MARK: - App container @@ -58,25 +76,41 @@ final class SimctlTests: GHTestCase { func testDeviceAppContainerApp() { let command = simctl.device("6").app("com.bundle.app3").container.app - command.assert.equal(path: path, arguments: "get_app_container", "6", "com.bundle.app3", "app") + let expected = ProcessCommand( + path: path, + arguments: ["get_app_container", "6", "com.bundle.app3", "app"] + ) + XCTAssertEqual(command, expected) } func testDeviceAppContainerData() { let command = simctl.device("7").app("com.bundle.app4").container.data - command.assert.equal(path: path, arguments: "get_app_container", "7", "com.bundle.app4", "data") + let expected = ProcessCommand( + path: path, + arguments: ["get_app_container", "7", "com.bundle.app4", "data"] + ) + XCTAssertEqual(command, expected) } func testDeviceAppContainerGroups() { let command = simctl.device("8").app("com.bundle.app5").container.groups - command.assert.equal(path: path, arguments: "get_app_container", "8", "com.bundle.app5", "groups") + let expected = ProcessCommand( + path: path, + arguments: ["get_app_container", "8", "com.bundle.app5", "groups"] + ) + XCTAssertEqual(command, expected) } func testDeviceAppContainerGroup() { let command = simctl.device("9").app("com.bundle.app6").container.group("g") - command.assert.equal(path: path, arguments: "get_app_container", "9", "com.bundle.app6", "g") + let expected = ProcessCommand( + path: path, + arguments: ["get_app_container", "9", "com.bundle.app6", "g"] + ) + XCTAssertEqual(command, expected) } // MARK: - Device list @@ -84,34 +118,32 @@ final class SimctlTests: GHTestCase { func testDeviceList() { let command = simctl.list.command - command.assert.equal(path: path, arguments: "list") + let expected = ProcessCommand(path: path, arguments: ["list"]) + XCTAssertEqual(command, expected) } func testDeviceListJson() { let command = simctl.list.json.command - command.assert.equal(path: path, arguments: "list", "--json") + let expected = ProcessCommand(path: path, arguments: ["list", "--json"]) + XCTAssertEqual(command, expected) } - func testDeviceListJsonDecode() async { - simctl = XCRun().simctl - do { - let deviceList = try await simctl.list.json.decode() - XCTAssertFalse(deviceList.devices.isEmpty) - } catch { - XCTFail("\(error)") - } + func testDeviceListJsonDecode() async throws { + simctl = try await XCRun.current.simctl + let deviceList = try await simctl.list.json.decode() + XCTAssertFalse(deviceList.devices.isEmpty) } func testDeviceListFilter() { var command = simctl.list(.devices).command - command.assert.equal(path: path, arguments: "list", "devices") + XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices"])) command = simctl.list(.devices, "iPhone 8").command - command.assert.equal(path: path, arguments: "list", "devices", "iPhone 8") + XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices", "iPhone 8"])) command = simctl.list(.devices, available: true).command - command.assert.equal(path: path, arguments: "list", "devices", "available") + XCTAssertEqual(command, ProcessCommand(path: path, arguments: ["list", "devices", "available"])) } func testDeviceListBooted() throws { diff --git a/Tests/XSToolingTests/XCRunTests.swift b/Tests/XSToolingTests/XCRunTests.swift index 09cc4f7..a8044e0 100644 --- a/Tests/XSToolingTests/XCRunTests.swift +++ b/Tests/XSToolingTests/XCRunTests.swift @@ -1,37 +1,23 @@ +#if os(macOS) + import XCTest import XSTooling -#if os(macOS) - final class XCRunTests: GHTestCase { - private var xcrun: XCRun! - private var path: String! + private let xcrun = XCRun.current - override func setUp() { - super.setUp() - path = "/usr/bin/xcrun/\(name)" - xcrun = XCRun(path: path) + func testExecute() async throws { + try await xcrun("xcodebuild", "-version").run() } - func testExecute() async { - do { - xcrun = XCRun() - try await xcrun.command.appending(arguments: "xcodebuild", "-version").run() - } catch { - XCTFail("\(error)") - } + func testFind() async throws { + let path = try await xcrun.find("xcodebuild") + XCTAssertTrue(path.hasSuffix("/usr/bin/xcodebuild")) } - func testFind() { - let command = xcrun.find("swift") - - command.assert.equal(path: path, arguments: "--find", "swift") - } - - func testSimctl() { - let simctl = xcrun.simctl - - simctl.command.assert.equal(path: path, arguments: "simctl") + func testSimctl() async throws { + let simulator = try await xcrun.simctl + XCTAssertTrue(simulator.path.hasSuffix("/usr/bin/simctl")) } }