From 9f17bc8f0a1a023c13c05fd066939f439ca9bd4c Mon Sep 17 00:00:00 2001 From: Michael Link Date: Tue, 5 May 2026 19:10:14 -0500 Subject: [PATCH 1/3] AEGIS-10050 add swift-testing capabilities --- CHANGELOG.md | 17 +++ Package.swift | 13 ++- README.md | 84 ++++++++++++- .../SubprocessDependencyBuilder.swift | 16 +-- Sources/SubprocessMocks/MockProcess.swift | 14 +-- Sources/SubprocessMocks/MockShell.swift | 32 ++--- Sources/SubprocessMocks/MockSubprocess.swift | 12 +- .../MockSubprocessDependencyBuilder.swift | 42 ++++--- .../SubprocessTesting/SubprocessTesting.swift | 42 +++++++ Tests/SwiftTesting/SubprocessSwiftTests.swift | 110 ++++++++++++++++++ Tests/SystemTests/ShellSystemTests.swift | 6 +- Tests/SystemTests/SubprocessSystemTests.swift | 6 +- Tests/UnitTests/ShellTests.swift | 2 +- Tests/UnitTests/SubprocessTests.swift | 2 +- 14 files changed, 332 insertions(+), 66 deletions(-) create mode 100644 Sources/SubprocessTesting/SubprocessTesting.swift create mode 100644 Tests/SwiftTesting/SubprocessSwiftTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6405a2f..6ff446d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.0.0] - 2026-05-05 + +### Added +- New `SubprocessTesting` library target providing `SubprocessTrait` — a Swift Testing `TestTrait`/`SuiteTrait` that scopes subprocess mocking to individual tests via `@TaskLocal`, enabling safe parallel test execution. +- `SwiftTesting` test target with suite demonstrating parallel mock usage using Swift Testing. + +### Changed +- `SubprocessDependencyFactory` now conforms to `Sendable`. +- `MockSubprocessDependencyBuilder` is now `public final` and `Sendable`; its `shared` instance is `@TaskLocal` instead of a `nonisolated(unsafe)` static, removing the need to manually reset it between tests. +- `MockSubprocessDependencyBuilder.makeProcess`, `makeInputFileHandle`, and `makeInputPipe` are now `public` to support the new `SubprocessTesting` target. +- `MockProcess.Context`, `MockProcess.Context.State`, `ExpectationError`, and `MockSubprocessError` now conform to `Sendable`. +- `MockProcess.Context.runStub` closure is now `@Sendable`. +- `MockProcess.Context` standard I/O properties marked `nonisolated(unsafe)` for `Sendable` conformance. +- All `Shell.expect` and `Subprocess.expect` overloads now use `#filePath` instead of `#file` for the default `file:` argument. +- Unit tests import `SubprocessMocks` publicly instead of `@testable`. +- `swift-tools-version` bumped to `5.10`. + ## [3.0.5] - 2024-08-07 ### Changed diff --git a/Package.swift b/Package.swift index 28a3792..2366c13 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 import PackageDescription @@ -14,6 +14,10 @@ let package = Package( name: "SubprocessMocks", targets: [ "SubprocessMocks" ] ), + .library( + name: "SubprocessTesting", + targets: ["SubprocessTesting"] + ), .library( name: "libSubprocess", targets: [ "Subprocess" ] @@ -37,6 +41,7 @@ let package = Package( .target(name: "Subprocess") ] ), + .target(name: "SubprocessTesting", dependencies: ["Subprocess", "SubprocessMocks"]), .testTarget( name: "UnitTests", dependencies: [ @@ -49,6 +54,12 @@ let package = Package( dependencies: [ .target(name: "Subprocess") ] + ), + .testTarget( + name: "SwiftTesting", + dependencies: [ + .target(name: "SubprocessTesting") + ] ) ], swiftLanguageVersions: [.v5, .version("6")] diff --git a/README.md b/README.md index 59b8cca..89ee260 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,83 @@ let package = Package( ] ) ``` -### Cocoapods -```ruby -pod 'Subprocess' + +## Unit Testing with Swift Testing + +Add `SubprocessMocks` and `SubprocessTesting` as dependencies of your test target: + +```swift +.testTarget( + name: "MyTests", + dependencies: [ + .product(name: "SubprocessMocks", package: "Subprocess"), + .product(name: "SubprocessTesting", package: "Subprocess"), + ] +) +``` + +### SubprocessTrait + +`SubprocessTrait` is a Swift Testing `TestTrait` and `SuiteTrait` that automatically scopes subprocess mocking to each test. Each test gets its own isolated `MockSubprocessDependencyBuilder` via `@TaskLocal`, so tests can safely run in parallel without interfering with each other. + +Apply `.subprocessTesting` to any `@Test` or `@Suite`: + +```swift +import Testing +import Subprocess +import SubprocessMocks +import SubprocessTesting + +@Test(.subprocessTesting) +func testSoftwareVersion() async throws { + Subprocess.expect(["/usr/bin/sw_vers", "-productVersion"], standardOutput: "15.0\n".data(using: .utf8)) + + let version = try await Subprocess.string(for: ["/usr/bin/sw_vers", "-productVersion"]) + + #expect(version.trimmingCharacters(in: .whitespacesAndNewlines) == "15.0") + try Subprocess.verify() +} +``` + +Apply it to a whole suite to cover every test in the type: + +```swift +@Suite(.subprocessTesting) +struct MyCommandTests { + @Test + func testGrep() async throws { + Subprocess.expect(["/usr/bin/grep", "foo"], standardOutput: "foo bar\n".data(using: .utf8)) + + let result = try await Subprocess.string(for: ["/usr/bin/grep", "foo"]) + + #expect(result.contains("foo")) + try Subprocess.verify() + } + + @Test + func testMissingFile() async throws { + let error = NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + Subprocess.expect(["/bin/cat", "/no/such/file"], error: error) + + await #expect(throws: (any Error).self) { + try await Subprocess.data(for: ["/bin/cat", "/no/such/file"]) + } + } +} ``` -### Carthage -```ruby -github 'jamf/Subprocess' + +### Parallel tests + +Because each test's mocks are stored in a `@TaskLocal`, parameterised and parallel tests work without any extra setup: + +```swift +@Test(.subprocessTesting, arguments: ["foo", "bar", "baz"]) +func testEcho(_ word: String) async throws { + Subprocess.expect(["/bin/echo", word], standardOutput: "\(word)\n".data(using: .utf8)) + + let output = try await Subprocess.string(for: ["/bin/echo", word]) + + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == word) + try Subprocess.verify() +} ``` diff --git a/Sources/Subprocess/SubprocessDependencyBuilder.swift b/Sources/Subprocess/SubprocessDependencyBuilder.swift index 75cfe16..8a2b1a8 100644 --- a/Sources/Subprocess/SubprocessDependencyBuilder.swift +++ b/Sources/Subprocess/SubprocessDependencyBuilder.swift @@ -32,7 +32,7 @@ import Foundation #endif /// Protocol call used for dependency injection -public protocol SubprocessDependencyFactory { +public protocol SubprocessDependencyFactory: Sendable { /// Creates new Subprocess /// /// - Parameter command: Command represented as an array of strings @@ -56,17 +56,17 @@ public protocol SubprocessDependencyFactory { /// Default implementation of SubprocessDependencyFactory public struct SubprocessDependencyBuilder: SubprocessDependencyFactory { private static let queue = DispatchQueue(label: "\(Self.self)") - - #if compiler(<5.10) - private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder() - #else nonisolated(unsafe) private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder() - #endif + @TaskLocal public static var __shared: (any SubprocessDependencyFactory)? /// Shared instance used for dependency creation public static var shared: any SubprocessDependencyFactory { get { - queue.sync { - _shared + if let value = __shared { + value + } else { + queue.sync { + _shared + } } } set { diff --git a/Sources/SubprocessMocks/MockProcess.swift b/Sources/SubprocessMocks/MockProcess.swift index 22db9e9..d3c33d6 100644 --- a/Sources/SubprocessMocks/MockProcess.swift +++ b/Sources/SubprocessMocks/MockProcess.swift @@ -34,7 +34,6 @@ import Subprocess /// Interface used for mocking a process public struct MockProcess: Sendable { - /// The underlying `MockProcessReference` public var reference: MockProcessReference @@ -70,10 +69,9 @@ public struct MockProcess: Sendable { /// Subclass of `Process` used for mocking open class MockProcessReference: Process, @unchecked Sendable { /// Context information and values used for overriden properties - public struct Context { - + public struct Context: Sendable { /// State of the mock process - public enum State { + public enum State: Sendable { case initialized case running case uncaughtSignal @@ -84,11 +82,11 @@ open class MockProcessReference: Process, @unchecked Sendable { public var state: State = .initialized /// Block called to stub the call to launch - public var runStub: (MockProcess) throws -> Void + public var runStub: @Sendable (MockProcess) throws -> Void - var standardInput: Any? - var standardOutput: Any? - var standardError: Any? + nonisolated(unsafe) var standardInput: Any? + nonisolated(unsafe) var standardOutput: Any? + nonisolated(unsafe) var standardError: Any? var terminationHandler: (@Sendable (Process) -> Void)? } diff --git a/Sources/SubprocessMocks/MockShell.swift b/Sources/SubprocessMocks/MockShell.swift index 571fec1..79e8e43 100644 --- a/Sources/SubprocessMocks/MockShell.swift +++ b/Sources/SubprocessMocks/MockShell.swift @@ -172,12 +172,12 @@ public extension Shell { /// - command: The command to mock /// - input: The expected input of the process /// - error: Error thrown when `Process.run` is called - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) { Subprocess.expect(command, input: input, error: error, file: file, line: line) } @@ -191,14 +191,14 @@ public extension Shell { /// - standardOutput: Data written to stdout of the process /// - standardError: Data written to stderr of the process /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) static func expect(_ command: [String], input: Input? = nil, standardOutput: Data? = nil, standardError: Data? = nil, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) { Subprocess.expect(command, input: input, file: file, line: line) { process in if let data = standardOutput { @@ -220,14 +220,14 @@ public extension Shell { /// - stdout: String written to stdout of the process /// - stderr: String written to stderr of the process (Default: nil) /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) static func expect(_ command: [String], input: Input? = nil, stdout: String, stderr: String? = nil, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) { Subprocess.expect(command, input: input, file: file, line: line) { process in process.writeTo(stdout: stdout) @@ -246,13 +246,13 @@ public extension Shell { /// - stdout: String written to stdout of the process /// - stderr: String written to stderr of the process /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) static func expect(_ command: [String], input: Input? = nil, stderr: String, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) { Subprocess.expect(command, input: input, file: file, line: line) { process in process.writeTo(stderr: stderr) @@ -268,14 +268,14 @@ public extension Shell { /// - input: The expected input of the process /// - plist: Property list object serialized and written to stdout /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) /// - Throws: Error when serializing property list object static func expect(_ command: [String], input: Input? = nil, plist: Any, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) throws { let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) @@ -289,14 +289,14 @@ public extension Shell { /// - input: The expected input of the process /// - plist: JSON object serialized and written to stdout /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) /// - Throws: Error when serializing JSON object static func expect(_ command: [String], input: Input? = nil, json: Any, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) throws { let data = try JSONSerialization.data(withJSONObject: json, options: []) Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) @@ -310,14 +310,14 @@ public extension Shell { /// - input: The expected input of the process /// - plistObject: Encodable object written to stdout as a property list /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) /// - Throws: Error when encoding the provided object static func expect(_ command: [String], input: Input? = nil, plistObject: T, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) throws { let data = try PropertyListEncoder().encode(plistObject) Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) @@ -331,14 +331,14 @@ public extension Shell { /// - input: The expected input of the process /// - jsonObject: Encodable object written to stdout as JSON /// - exitCode: Exit code of the process (Default: 0) - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) /// - Throws: Error when encoding the provided object static func expect(_ command: [String], input: Input? = nil, jsonObject: T, exitCode: Int32 = 0, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) throws { let data = try JSONEncoder().encode(jsonObject) Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) diff --git a/Sources/SubprocessMocks/MockSubprocess.swift b/Sources/SubprocessMocks/MockSubprocess.swift index f3c989e..00c1602 100644 --- a/Sources/SubprocessMocks/MockSubprocess.swift +++ b/Sources/SubprocessMocks/MockSubprocess.swift @@ -104,9 +104,9 @@ public extension Subprocess { /// - command: The command to mock /// - input: The expected input of the process /// - error: Error thrown when `Process.run` is called - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) - static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, file: StaticString = #file, line: UInt = #line) { + static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, file: StaticString = #filePath, line: UInt = #line) { let mock = MockProcessReference(withRunError: error) MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) } @@ -117,10 +117,10 @@ public extension Subprocess { /// - Parameters: /// - command: The command to mock /// - input: The expected input of the process - /// - file: Source file where expect was called (Default: #file) + /// - file: Source file where expect was called (Default: #filePath) /// - line: Line number of source file where expect was called (Default: #line) /// - runBlock: Block called with a `MockProcess` to mock process execution - static func expect(_ command: [String], input: Input? = nil, file: StaticString = #file, line: UInt = #line, runBlock: (@Sendable (MockProcess) -> Void)? = nil) { + static func expect(_ command: [String], input: Input? = nil, file: StaticString = #filePath, line: UInt = #line, runBlock: (@Sendable (MockProcess) -> Void)? = nil) { let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() }) MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) } @@ -132,7 +132,7 @@ public extension Subprocess { /// - standardOutput: Data written to stdout of the process /// - standardError: Data written to stderr of the process /// - exitCode: Exit code of the process (Default: 0) - static func expect(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #file, line: UInt = #line) { + static func expect(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #filePath, line: UInt = #line) { expect(command, input: input, file: file, line: line) { process in if let data = standardOutput { process.writeTo(stdout: data) @@ -155,7 +155,7 @@ public extension Subprocess { /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`. /// - exitCode: Exit code of the process (Default: 0) /// - Throws: Error when encoding the provided object - static func expect(_ command: [String], content: Content, encoder: Encoder, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #file, line: UInt = #line) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data { + static func expect(_ command: [String], content: Content, encoder: Encoder, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #filePath, line: UInt = #line) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data { let data: Data = try encoder.encode(content) expect(command, standardOutput: data, input: input, exitCode: exitCode, file: file, line: line) diff --git a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift index 1a61b6a..e6ccafe 100644 --- a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift +++ b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift @@ -27,13 +27,14 @@ #if swift(>=6.0) public import Foundation +public import Subprocess #else import Foundation -#endif import Subprocess +#endif /// Error representing a failed call to Subprocess.expect or Shell.expect -public struct ExpectationError: Error { +public struct ExpectationError: Error, Sendable { /// Source file where expect was called public var file: StaticString /// Line number where expect was called @@ -43,7 +44,7 @@ public struct ExpectationError: Error { } /// Type representing possible errors thrown -public enum MockSubprocessError: Error { +public enum MockSubprocessError: Error, Sendable { /// Error containing command thrown when a process is launched that was not stubbed case missingMock([String]) /// List of expectations which failed @@ -89,8 +90,8 @@ public final class MockPipe: Pipe, @unchecked Sendable { } } -class MockSubprocessDependencyBuilder { - class MockItem { +public final class MockSubprocessDependencyBuilder: Sendable { + final class MockItem { var used = false var command: [String] var input: Input? @@ -107,18 +108,33 @@ class MockSubprocessDependencyBuilder { } } - var mocks: [MockItem] = [] + private let mocksLock = NSLock() + private nonisolated(unsafe) var _mocks: [MockItem] = [] + var mocks: [MockItem] { + get { + mocksLock.withLock { + _mocks + } + } + set { + mocksLock.withLock { + _mocks = newValue + } + } + } - nonisolated(unsafe) static let shared = MockSubprocessDependencyBuilder() + @TaskLocal public static var shared = MockSubprocessDependencyBuilder() - init() { SubprocessDependencyBuilder.shared = self } + public init() { + // public for SubprocessTesting target + } - func stub(_ command: [String], process: MockProcessReference) { + public func stub(_ command: [String], process: MockProcessReference) { let mock = MockItem(command: command, input: nil, process: process, file: nil, line: nil) mocks.append(mock) } - func expect(_ command: [String], input: Input?, process: MockProcessReference, file: StaticString, line: UInt) { + public func expect(_ command: [String], input: Input?, process: MockProcessReference, file: StaticString, line: UInt) { let mock = MockItem(command: command, input: input, process: process, file: file, line: line) mocks.append(mock) } @@ -229,7 +245,7 @@ class MockSubprocessDependencyBuilder { } extension MockSubprocessDependencyBuilder: SubprocessDependencyFactory { - func makeProcess(command: [String]) -> Process { + public func makeProcess(command: [String]) -> Process { if let item = mocks.first(where: { !$0.used && $0.command == command }) { item.used = true return item.process @@ -237,13 +253,13 @@ extension MockSubprocessDependencyBuilder: SubprocessDependencyFactory { return MockProcessReference(withRunError: MockSubprocessError.missingMock(command)) } - func makeInputFileHandle(url: URL) throws -> FileHandle { + public func makeInputFileHandle(url: URL) throws -> FileHandle { let handle = MockFileHandle() handle.url = url return handle } - func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 { + public func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 { let semaphore = DispatchSemaphore(value: 0) let pipe = MockPipe() diff --git a/Sources/SubprocessTesting/SubprocessTesting.swift b/Sources/SubprocessTesting/SubprocessTesting.swift new file mode 100644 index 0000000..185e80f --- /dev/null +++ b/Sources/SubprocessTesting/SubprocessTesting.swift @@ -0,0 +1,42 @@ +#if swift(>=6.0) +public import Testing +public import SubprocessMocks +#else +import Testing +import SubprocessMocks +#endif +import Subprocess + +#if swift(>=6.1) +public struct SubprocessTrait: TestTrait, SuiteTrait, TestScoping { + private let builder: MockSubprocessDependencyBuilder? + + public init(builder: MockSubprocessDependencyBuilder? = nil) { + self.builder = builder + } + + public func provideScope(for test: Test, testCase: Test.Case?, performing function: @Sendable () async throws -> Void) async throws { + let scopedBuilder = if let builder { + builder + } else { + MockSubprocessDependencyBuilder() + } + + try await SubprocessDependencyBuilder.$__shared.withValue(scopedBuilder) { + try await MockSubprocessDependencyBuilder.$shared.withValue(scopedBuilder) { + try await function() + } + } + } +} + +extension Trait where Self == SubprocessTrait { + public static var subprocessTesting: Self { + SubprocessTrait() + } + + public static func subprocessBuilder(_ builder: MockSubprocessDependencyBuilder) -> Self { + SubprocessTrait(builder: builder) + } +} +#endif diff --git a/Tests/SwiftTesting/SubprocessSwiftTests.swift b/Tests/SwiftTesting/SubprocessSwiftTests.swift new file mode 100644 index 0000000..9ab4921 --- /dev/null +++ b/Tests/SwiftTesting/SubprocessSwiftTests.swift @@ -0,0 +1,110 @@ +#if swift(>=6.1) +import Foundation +import Testing +import Subprocess +import SubprocessMocks +import SubprocessTesting + +@Suite +struct SubprocessSwiftTests: ~Copyable { + @Test(.subprocessTesting, arguments: 0..<100) + func `mocks can handle parallel testing`(_ count: Int) async throws { + let testFileURL = URL(fileURLWithPath: "/tmp/\(Self.self)-\(UUID().uuidString).txt") + + let commands = [ + ["/bin/cat", testFileURL.path], + ["/usr/bin/head", "-n", "\(count)", testFileURL.path], + ["/usr/bin/tail", "-r", "-n", "\(count)", testFileURL.path], + ] + + for command in commands.shuffled() { + Subprocess.expect(command) + } + + try await withThrowingTaskGroup(of: Void.self) { group in + for command in commands.shuffled() { + group.addTask { + _ = try await Subprocess.string(for: command) + } + } + + try await group.waitForAll() + } + + try Subprocess.verify() + } + + @Test(arguments: 0..<5) + func `other testing still works`(_ count: Int) async throws { + let testFileURL: URL = { + let url = URL(fileURLWithPath: "/tmp/\(Self.self)-\(UUID().uuidString).txt") + + try! Self.generateRandomASCIIFile(at: url) + return url + }() + + let fileContents = try await Subprocess.string(for: ["/bin/cat", testFileURL.path]) + try #expect(fileContents == String(contentsOfFile: testFileURL.path, encoding: .utf8)) + try FileManager.default.removeItem(at: testFileURL) + } + + @Test(.subprocessTesting, arguments: ["foo", "bar", "baz"]) + func testEcho(_ word: String) async throws { + Subprocess.expect(["/bin/echo", word], standardOutput: "\(word)\n".data(using: .utf8)) + + let output = try await Subprocess.string(for: ["/bin/echo", word]) + + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == word) + try Subprocess.verify() + } + + @Test(.subprocessTesting) + func testSoftwareVersion() async throws { + Subprocess.expect(["/usr/bin/sw_vers", "-productVersion"], standardOutput: "15.0\n".data(using: .utf8)) + + let version = try await Subprocess.string(for: ["/usr/bin/sw_vers", "-productVersion"]) + + #expect(version.trimmingCharacters(in: .whitespacesAndNewlines) == "15.0") + try Subprocess.verify() + } +} + +@Suite(.subprocessTesting) +struct MyCommandTests: ~Copyable { + @Test + func testGrep() async throws { + Subprocess.expect(["/usr/bin/grep", "foo"], standardOutput: "foo bar\n".data(using: .utf8)) + + let result = try await Subprocess.string(for: ["/usr/bin/grep", "foo"]) + + #expect(result.contains("foo")) + try Subprocess.verify() + } + + @Test + func testMissingFile() async throws { + let error = NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + Subprocess.expect(["/bin/cat", "/no/such/file"], error: error) + + await #expect(throws: (any Error).self) { + try await Subprocess.data(for: ["/bin/cat", "/no/such/file"]) + } + } +} + +private extension SubprocessSwiftTests { + static func generateRandomASCIIFile(at url: URL, lineCount: Int = 1000, maxLineLength: Int = 1000) throws { + let printableASCII: [Character] = (UInt8(0x21)...UInt8(0x7E)).map { Character(UnicodeScalar($0)) } + var contents = "" + contents.reserveCapacity(lineCount * maxLineLength / 2) + + for _ in 0.. Date: Tue, 5 May 2026 19:18:52 -0500 Subject: [PATCH 2/3] AEGIS-10050 update github workflows --- .github/workflows/build-and-test.yml | 31 ++++++---------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 5f07f02..6e4232c 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -9,36 +9,17 @@ on: branches: - main - jobs: spm: name: SwiftPM build and test - runs-on: macos-14 + runs-on: macos-latest steps: - - run: | - sudo xcode-select -s /Applications/Xcode_15.3.app - - uses: actions/checkout@v3 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - uses: actions/checkout@v6 - name: Build swift packages run: swift build -v - name: Run tests run: swift test -v - carthage: - name: Xcode project build and test - runs-on: macos-14 - steps: - - run: | - sudo xcode-select -s /Applications/Xcode_15.3.app - - uses: actions/checkout@v3 - - name: Build xcode project - run: xcodebuild build -scheme 'SubprocessMocks' -derivedDataPath .build - - name: Run tests - run: xcodebuild test -scheme 'Subprocess' -derivedDataPath .build - cocoapods: - name: Pod lib lint - runs-on: macos-14 - steps: - - run: | - sudo xcode-select -s /Applications/Xcode_15.3.app - - uses: actions/checkout@v3 - - name: Lib lint - run: pod lib lint --verbose Subprocess.podspec --allow-warnings From e8b34f3965541edf9c6a6cc48f032f50cd52d892 Mon Sep 17 00:00:00 2001 From: Michael Link Date: Tue, 5 May 2026 19:23:53 -0500 Subject: [PATCH 3/3] AEGIS-10050 fix unit test for older version of swift-testing --- .../MockSubprocessDependencyBuilder.swift | 33 +++++++++++++++++++ Tests/SwiftTesting/SubprocessSwiftTests.swift | 3 +- Tests/SystemTests/ShellSystemTests.swift | 6 ++-- Tests/SystemTests/SubprocessSystemTests.swift | 6 ++-- Tests/UnitTests/SubprocessTests.swift | 2 +- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift index e6ccafe..bd9bb6d 100644 --- a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift +++ b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift @@ -71,6 +71,39 @@ public extension SubprocessMockObject { public class MockFileHandle: FileHandle, @unchecked Sendable { public var url: URL? + + private let backing: FileHandle + + public override init(fileDescriptor fd: Int32, closeOnDealloc closeopt: Bool) { + backing = FileHandle(fileDescriptor: fd, closeOnDealloc: closeopt) + super.init(fileDescriptor: fd, closeOnDealloc: closeopt) + } + + init(backing: FileHandle = Pipe().fileHandleForReading) { + self.backing = backing + super.init(fileDescriptor: backing.fileDescriptor, closeOnDealloc: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override var fileDescriptor: Int32 { + backing.fileDescriptor + } + + // If your tests use these APIs, forward them as well: + public override func readDataToEndOfFile() -> Data { + backing.readDataToEndOfFile() + } + + public override func write(_ data: Data) { + backing.write(data) + } + + public override func closeFile() { + backing.closeFile() + } } public final class MockPipe: Pipe, @unchecked Sendable { diff --git a/Tests/SwiftTesting/SubprocessSwiftTests.swift b/Tests/SwiftTesting/SubprocessSwiftTests.swift index 9ab4921..5e8fc96 100644 --- a/Tests/SwiftTesting/SubprocessSwiftTests.swift +++ b/Tests/SwiftTesting/SubprocessSwiftTests.swift @@ -44,7 +44,8 @@ struct SubprocessSwiftTests: ~Copyable { }() let fileContents = try await Subprocess.string(for: ["/bin/cat", testFileURL.path]) - try #expect(fileContents == String(contentsOfFile: testFileURL.path, encoding: .utf8)) + let stringFileContents = try String(contentsOfFile: testFileURL.path, encoding: .utf8) + #expect(fileContents == stringFileContents) try FileManager.default.removeItem(at: testFileURL) } diff --git a/Tests/SystemTests/ShellSystemTests.swift b/Tests/SystemTests/ShellSystemTests.swift index 288ec8d..6d7a41a 100644 --- a/Tests/SystemTests/ShellSystemTests.swift +++ b/Tests/SystemTests/ShellSystemTests.swift @@ -4,9 +4,9 @@ import XCTest @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") final class ShellSystemTests: XCTestCase { -// override func setUp() { -// SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() -// } + override func setUp() { + SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() + } let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist" diff --git a/Tests/SystemTests/SubprocessSystemTests.swift b/Tests/SystemTests/SubprocessSystemTests.swift index 1b3c47d..1dc2702 100644 --- a/Tests/SystemTests/SubprocessSystemTests.swift +++ b/Tests/SystemTests/SubprocessSystemTests.swift @@ -4,9 +4,9 @@ import XCTest final class SubprocessSystemTests: XCTestCase { let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist" -// override func setUp() { -// SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() -// } + override func setUp() { + SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() + } @available(macOS 12.0, *) func testRunWithOutput() async throws { diff --git a/Tests/UnitTests/SubprocessTests.swift b/Tests/UnitTests/SubprocessTests.swift index df4af43..e45e703 100644 --- a/Tests/UnitTests/SubprocessTests.swift +++ b/Tests/UnitTests/SubprocessTests.swift @@ -8,7 +8,7 @@ import SubprocessMocks final class SubprocessTests: XCTestCase { let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ] - + override func setUp() { // This is only needed for SwiftPM since it runs all of the test suites as a single test run SubprocessDependencyBuilder.shared = MockSubprocessDependencyBuilder.shared