├── .github
└── workflows
│ ├── build-and-test.yml
│ └── publish-docs.yml
├── .gitignore
├── .swiftlint.yml
├── .swiftpm
├── configuration
│ └── Package.resolved
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── CHANGELOG.md
├── CODEOWNERS
├── LICENSE
├── Package.resolved
├── Package.swift
├── PrivacyInfo.xcprivacy
├── README.md
├── Sources
├── Subprocess
│ ├── AsyncSequence+Additions.swift
│ ├── AsyncStream+Yield.swift
│ ├── Errors.swift
│ ├── Info.plist
│ ├── Input.swift
│ ├── Pipe+AsyncBytes.swift
│ ├── Shell.swift
│ ├── Subprocess.h
│ ├── Subprocess.swift
│ ├── SubprocessDependencyBuilder.swift
│ └── UnsafeData.swift
└── SubprocessMocks
│ ├── Info.plist
│ ├── MockOutput.swift
│ ├── MockProcess.swift
│ ├── MockShell.swift
│ ├── MockSubprocess.swift
│ ├── MockSubprocessDependencyBuilder.swift
│ └── SubprocessMocks.h
├── Subprocess.podspec
├── Subprocess.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── xcshareddata
│ └── xcschemes
│ ├── Subprocess.xcscheme
│ └── SubprocessMocks.xcscheme
└── Tests
├── SystemTests
├── Info.plist
├── ShellSystemTests.swift
└── SubprocessSystemTests.swift
└── UnitTests
├── Info.plist
├── ShellTests.swift
└── SubprocessTests.swift
/.github/workflows/build-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Build & Test
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 | pull_request:
9 | branches:
10 | - main
11 |
12 |
13 | jobs:
14 | spm:
15 | name: SwiftPM build and test
16 | runs-on: macos-14
17 | steps:
18 | - run: |
19 | sudo xcode-select -s /Applications/Xcode_15.3.app
20 | - uses: actions/checkout@v3
21 | - name: Build swift packages
22 | run: swift build -v
23 | - name: Run tests
24 | run: swift test -v
25 | carthage:
26 | name: Xcode project build and test
27 | runs-on: macos-14
28 | steps:
29 | - run: |
30 | sudo xcode-select -s /Applications/Xcode_15.3.app
31 | - uses: actions/checkout@v3
32 | - name: Build xcode project
33 | run: xcodebuild build -scheme 'SubprocessMocks' -derivedDataPath .build
34 | - name: Run tests
35 | run: xcodebuild test -scheme 'Subprocess' -derivedDataPath .build
36 | cocoapods:
37 | name: Pod lib lint
38 | runs-on: macos-14
39 | steps:
40 | - run: |
41 | sudo xcode-select -s /Applications/Xcode_15.3.app
42 | - uses: actions/checkout@v3
43 | - name: Lib lint
44 | run: pod lib lint --verbose Subprocess.podspec --allow-warnings
45 |
--------------------------------------------------------------------------------
/.github/workflows/publish-docs.yml:
--------------------------------------------------------------------------------
1 | name: Build and Publish Docs
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - 'main'
8 |
9 | # Kill any previous run still executing
10 | concurrency:
11 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
12 | cancel-in-progress: true
13 |
14 | jobs:
15 | build_docs:
16 | name: Build and Archive Docs
17 | runs-on: macos-13
18 | steps:
19 | - run: |
20 | sudo xcode-select -s /Applications/Xcode_15.0.app
21 | - name: Checkout
22 | uses: actions/checkout@v3
23 |
24 | - name: Generate docs
25 | run: |
26 | swift package \
27 | --allow-writing-to-directory github-pages \
28 | generate-documentation \
29 | --target Subprocess \
30 | --disable-indexing \
31 | --transform-for-static-hosting \
32 | --hosting-base-path Subprocess/ \
33 | --output-path github-pages
34 |
35 | - name: Upload docs archive
36 | uses: actions/upload-pages-artifact@main
37 | with:
38 | path: github-pages
39 |
40 | deploy:
41 | name: Deploy Docs
42 | needs: build_docs
43 |
44 | permissions:
45 | pages: write
46 | id-token: write
47 |
48 | environment:
49 | name: github-pages
50 | url: ${{ steps.deployment.outputs.page_url }}
51 |
52 | runs-on: ubuntu-latest
53 | steps:
54 | - name: Deploy
55 | id: deployment
56 | uses: actions/deploy-pages@v1
57 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
91 | .DS_Store
92 |
--------------------------------------------------------------------------------
/.swiftlint.yml:
--------------------------------------------------------------------------------
1 | disabled_rules:
2 | - trailing_whitespace # Xcode automatically adds space for new lines
3 | - line_length # IDE is good at wrapping long lines
4 | - function_body_length
5 | - file_length # doesn't play nice when you need to have private in the same file
6 | - nesting
7 | - large_tuple
8 | - colon # doesn't follow Swift formatting
9 | - type_body_length # XCTest subclasses and arbitrary depending on type
10 |
--------------------------------------------------------------------------------
/.swiftpm/configuration/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "db0939bfcff17f77ea9eb9dc37e55caa295f4878b93f9eaf156720c118c15c0f",
3 | "pins" : [
4 | {
5 | "identity" : "swift-docc-plugin",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/apple/swift-docc-plugin",
8 | "state" : {
9 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247",
10 | "version" : "1.3.0"
11 | }
12 | },
13 | {
14 | "identity" : "swift-docc-symbolkit",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/apple/swift-docc-symbolkit",
17 | "state" : {
18 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
19 | "version" : "1.0.0"
20 | }
21 | }
22 | ],
23 | "version" : 3
24 | }
25 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Subprocess
2 |
3 | Subprocess is a Swift library for macOS providing interfaces for external process execution.
4 |
5 | All notable changes to this project will be documented in this file.
6 |
7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
9 |
10 | ## [3.0.5] - 2024-08-07
11 |
12 | ### Changed
13 | - Non-breaking changes to public imports when compiling under Swift 6.
14 |
15 | ## [3.0.4] - 2024-07-01
16 |
17 | ### Changed
18 | - Swift 6 compatibility updates.
19 |
20 | ## 3.0.3 - 2024-04-15
21 |
22 | ### Changed
23 | - Correctly turned on `StrictConcurrency` in Swift 5.10 and earlier and added non-breaking conformance to `Sendable`.
24 | - Updated documentation for closure based usage where `nonisolated(unsafe)` is required to avoid an error in projects that use `StrictConcurrency`.
25 |
26 | ## 3.0.2 - 2024-02-07
27 |
28 | ### Added
29 | - Additional `Sendable` conformance where it can't be implicitly determined outside of this package.
30 |
31 | ### Removed
32 | - `open` scope from `Subprocess` since none of its members were `open`.
33 |
34 | ## 3.0.1 - 2023-11-27
35 |
36 | ### Added
37 | - Explicit `Sendable` conformance for some types which silences warnings in consuming projects where Xcode can't determine implicit conformance.
38 |
39 | ## 3.0.0 - 2023-10-13
40 |
41 | ### Added
42 | - Methods to `Subprocess` that support Swift Concurrency.
43 | - `Subprocess.run(standardInput:options:)` can run interactive commands.
44 |
45 | ### Changed
46 | - Breaking: `Subprocess.init` no longer accepts an argument for a dispatch queue's quality of service since the underlying implementation now uses Swift Concurrency and not GCD.
47 | - Breaking: `Input`s `text` case no longer accepts an encoding as utf8 is overwhelmingly common. Instead convert the string to data explicitly if an alternate encoding is required.
48 | - `Shell` and `SubprocessError` have been deprecated in favor of using new replacement methods that support Swift Concurrency and that no longer have a synchronized wait.
49 | - Swift 5.9 (Xcode 15) is now the package minimum required to build.
50 |
51 | ## 2.0.0 - 2021-07-01
52 |
53 | ### Changed
54 | - Breaking: added the output of the command to Shell's exception exitedWithNonZeroStatus error to better conform to objc interop and NSError
55 | - Updated minimum deployment target to macOS 10.13.
56 |
57 | ## 1.1.0 - 2020-05-15
58 |
59 | ### Added
60 | - Added dynamic library targets to SPM
61 |
62 | ### Fixed
63 | - Fixed naming convention to match Jamf internal convention
64 |
65 | ## 1.0.1 - 2020-03-13
66 |
67 | ### Added
68 | - Added Cocoapods support
69 |
70 |
71 | ## 1.0.0 - 2020-03-13
72 |
73 | ### Added
74 | - All support for the initial release
75 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @jamf/apple-natives-write
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Jamf
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-docc-plugin",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-docc-plugin",
7 | "state" : {
8 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247",
9 | "version" : "1.3.0"
10 | }
11 | },
12 | {
13 | "identity" : "swift-docc-symbolkit",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/apple/swift-docc-symbolkit",
16 | "state" : {
17 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
18 | "version" : "1.0.0"
19 | }
20 | }
21 | ],
22 | "version" : 2
23 | }
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "Subprocess",
7 | platforms: [ .macOS("10.15.4") ],
8 | products: [
9 | .library(
10 | name: "Subprocess",
11 | targets: [ "Subprocess" ]
12 | ),
13 | .library(
14 | name: "SubprocessMocks",
15 | targets: [ "SubprocessMocks" ]
16 | ),
17 | .library(
18 | name: "libSubprocess",
19 | targets: [ "Subprocess" ]
20 | ),
21 | .library(
22 | name: "libSubprocessMocks",
23 | targets: [ "SubprocessMocks" ]
24 | )
25 | ],
26 | dependencies: [
27 | .package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.0.0"))
28 | ],
29 | targets: [
30 | .target(
31 | name: "Subprocess",
32 | dependencies: []
33 | ),
34 | .target(
35 | name: "SubprocessMocks",
36 | dependencies: [
37 | .target(name: "Subprocess")
38 | ]
39 | ),
40 | .testTarget(
41 | name: "UnitTests",
42 | dependencies: [
43 | .target(name: "Subprocess"),
44 | .target(name: "SubprocessMocks")
45 | ]
46 | ),
47 | .testTarget(
48 | name: "SystemTests",
49 | dependencies: [
50 | .target(name: "Subprocess")
51 | ]
52 | )
53 | ],
54 | swiftLanguageVersions: [.v5, .version("6")]
55 | )
56 |
57 | for target in package.targets {
58 | var swiftSettings = target.swiftSettings ?? []
59 |
60 | // According to Swift's piecemeal adoption plan features that were
61 | // upcoming features that become language defaults and are still enabled
62 | // as upcoming features will result in a compiler error. Currently in the
63 | // latest 5.10 compiler this doesn't happen, the compiler ignores it.
64 | //
65 | // The Swift 6 compiler on the other hand does emit errors when features
66 | // are enabled. Unfortunately it appears that the preprocessor
67 | // !hasFeature(xxx) cannot be used to test for this situation nor does
68 | // #if swift(<6) guard against this. There must be some sort of magic
69 | // used that is special for compiling the Package.swift manifest.
70 | // Instead a versioned Package.swift can be used (e.g. Package@swift-5.10.swift)
71 | // and the implemented now default features can be removed in Package.swift.
72 | //
73 | // Or you can just delete the Swift 6 features that are enabled instead of
74 | // creating another manifest file and test to see if building under Swift 5
75 | // still works (it should almost always work).
76 | //
77 | // It's still safe to enable features that don't exist in older compiler
78 | // versions as the compiler will ignore features it doesn't have implemented.
79 |
80 | // swift 7
81 | swiftSettings.append(.enableUpcomingFeature("ExistentialAny"))
82 | swiftSettings.append(.enableUpcomingFeature("InternalImportsByDefault"))
83 |
84 | target.swiftSettings = swiftSettings
85 | }
86 |
--------------------------------------------------------------------------------
/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyCollectedDataTypes
6 |
7 | NSPrivacyAccessedAPITypes
8 |
9 | NSPrivacyTrackingDomains
10 |
11 | NSPrivacyTracking
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Subprocess
2 | [](http://mit-license.org)
3 | 
4 | [](https://cocoapods.org/pods/Subprocess)
5 | [](https://developer.apple.com/macos)
6 | [](https://developer.apple.com/swift)
7 | [](https://github.com/Carthage/Carthage)
8 | [](https://swift.org/package-manager)
9 | [](https://engineering.jamf.com/Subprocess/documentation/subprocess/)
10 |
11 | Subprocess is a Swift library for macOS providing interfaces for both synchronous and asynchronous process execution.
12 | SubprocessMocks can be used in unit tests for quick and highly customizable mocking and verification of Subprocess usage.
13 |
14 | - [Usage](#usage)
15 | - [Subprocess Class](#subprocess-class)
16 | - [Command Input](#command-input) - [Data](#input-for-data), [Text](#input-for-text), [File](#input-for-file-url)
17 | - [Command Output](#command-output) - [Data](#output-as-data), [Text](#output-as-string), [Decodable JSON object](#output-as-decodable-object-from-json), [Decodable property list object](#output-as-decodable-object-from-property-list)
18 | - [Installation](#installation)
19 | - [SwiftPM](#swiftpm)
20 | - [Cocoapods](#cocoapods)
21 | - [Carthage](#carthage)
22 |
23 | [Full Documentation](./docs/index.html)
24 |
25 | # Usage
26 | ### Subprocess Class
27 | The `Subprocess` class can be used for command execution.
28 |
29 | #### Command Input
30 |
31 | ###### Input for data
32 | ```swift
33 | let inputData = Data("hello world".utf8)
34 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: inputData)
35 | ```
36 | ###### Input for text
37 | ```swift
38 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: "hello world")
39 | ```
40 | ###### Input for file URL
41 | ```swift
42 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "foo"], standardInput: URL(filePath: "/path/to/input/file"))
43 | ```
44 |
45 | #### Command Output
46 |
47 | ###### Output as Data
48 | ```swift
49 | let data = try await Subprocess.data(for: ["/usr/bin/sw_vers"])
50 | ```
51 | ###### Output as String
52 | ```swift
53 | let string = try await Subprocess.string(for: ["/usr/bin/sw_vers"])
54 | ```
55 | ###### Output as decodable object from JSON
56 | ```swift
57 | struct LogMessage: Codable {
58 | var subsystem: String
59 | var category: String
60 | var machTimestamp: UInt64
61 | }
62 |
63 | let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder())
64 | ```
65 | ###### Output as decodable object from Property List
66 | ```swift
67 | struct SystemVersion: Codable {
68 | enum CodingKeys: String, CodingKey {
69 | case version = "ProductVersion"
70 | }
71 | var version: String
72 | }
73 |
74 | let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"], decoder: PropertyListDecoder())
75 | ```
76 | ###### Output mapped to other type
77 | ```swift
78 | let enabled = try await Subprocess(["/usr/bin/csrutil", "status"]).run().standardOutput.lines.first(where: { $0.contains("enabled") } ) != nil
79 | ```
80 | ###### Output options
81 | ```swift
82 | let errorText = try await Subprocess.string(for: ["/usr/bin/cat", "/non/existent/file.txt"], options: .returnStandardError)
83 | let outputText = try await Subprocess.string(for: ["/usr/bin/sw_vers"])
84 |
85 | async let (standardOutput, standardError, _) = try Subprocess(["/usr/bin/csrutil", "status"]).run()
86 | let combinedOutput = try await [standardOutput.string(), standardError.string()]
87 | ```
88 | ###### Handling output as it is read
89 | ```swift
90 | let (stream, input) = {
91 | var input: AsyncStream.Continuation!
92 | let stream: AsyncStream = AsyncStream { continuation in
93 | input = continuation
94 | }
95 |
96 | return (stream, input!)
97 | }()
98 |
99 | let subprocess = Subprocess(["/bin/cat"])
100 | let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream)
101 |
102 | input.yield("hello\n")
103 |
104 | Task {
105 | for await line in standardOutput.lines {
106 | switch line {
107 | case "hello":
108 | input.yield("world\n")
109 | case "world":
110 | input.yield("and\nuniverse")
111 | input.finish()
112 | case "universe":
113 | await waitForExit()
114 | break
115 | default:
116 | continue
117 | }
118 | }
119 | }
120 | ```
121 | ###### Handling output on termination
122 | ```swift
123 | let process = Subprocess(["/usr/bin/csrutil", "status"])
124 | let (standardOutput, standardError, waitForExit) = try process.run()
125 | async let (stdout, stderr) = (standardOutput, standardError)
126 | let combinedOutput = await [stdout.data(), stderr.data()]
127 |
128 | await waitForExit()
129 |
130 | if process.exitCode == 0 {
131 | // Do something with output data
132 | } else {
133 | // Handle failure
134 | }
135 | ```
136 | ###### Closure based callbacks
137 | ```swift
138 | let command: [String] = ...
139 | let process = Subprocess(command)
140 | nonisolated(unsafe) var outputData: Data?
141 | nonisolated(unsafe) var errorData: Data?
142 |
143 | // The outputHandler and errorHandler are invoked serially
144 | try process.launch(outputHandler: { data in
145 | // Handle new data read from stdout
146 | outputData = data
147 | }, errorHandler: { data in
148 | // Handle new data read from stderr
149 | errorData = data
150 | }, terminationHandler: { process in
151 | // Handle process termination, all scheduled calls to
152 | // the outputHandler and errorHandler are guaranteed to
153 | // have completed.
154 | })
155 | ```
156 | ###### Handing output on termination with a closure
157 | ```swift
158 | let command: [String] = ...
159 | let process = Subprocess(command)
160 |
161 | try process.launch { (process, outputData, errorData) in
162 | if process.exitCode == 0 {
163 | // Do something with output data
164 | } else {
165 | // Handle failure
166 | }
167 | ```
168 |
169 | ## Installation
170 | ### SwiftPM
171 | ```swift
172 | let package = Package(
173 | // name, platforms, products, etc.
174 | dependencies: [
175 | // other dependencies
176 | .package(url: "https://github.com/jamf/Subprocess.git", .upToNextMajor(from: "3.0.0")),
177 | ],
178 | targets: [
179 | .target(name: "",
180 | dependencies: [
181 | // other dependencies
182 | .product(name: "Subprocess"),
183 | ]),
184 | // other targets
185 | ]
186 | )
187 | ```
188 | ### Cocoapods
189 | ```ruby
190 | pod 'Subprocess'
191 | ```
192 | ### Carthage
193 | ```ruby
194 | github 'jamf/Subprocess'
195 | ```
196 |
--------------------------------------------------------------------------------
/Sources/Subprocess/AsyncSequence+Additions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AsyncSequence+Additions.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | extension AsyncSequence {
35 | /// Returns a sequence of all the elements.
36 | public func sequence() async rethrows -> [Element] {
37 | try await reduce(into: [Element]()) { $0.append($1) }
38 | }
39 | }
40 |
41 | extension AsyncSequence where Element == UInt8 {
42 | /// Returns a `Data` representation.
43 | public func data() async rethrows -> Data {
44 | Data(try await sequence())
45 | }
46 |
47 | public func string() async rethrows -> String {
48 | if #available(macOS 12.0, *) {
49 | String(try await characters.sequence())
50 | } else {
51 | String(decoding: try await data(), as: UTF8.self)
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/Subprocess/AsyncStream+Yield.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AsyncStream+Yield.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | extension AsyncStream.Continuation where Element == UInt8 {
35 | /// Resume the task awaiting the next iteration point by having it return
36 | /// normally from its suspension point with the given data.
37 | ///
38 | /// - Parameter value: The value to yield from the continuation.
39 | /// - Returns: A `YieldResult` that indicates the success or failure of the
40 | /// yield operation from the last byte of the `Data`.
41 | ///
42 | /// If nothing is awaiting the next value, this method attempts to buffer the
43 | /// result's element.
44 | ///
45 | /// This can be called more than once and returns to the caller immediately
46 | /// without blocking for any awaiting consumption from the iteration.
47 | @discardableResult public func yield(_ value: Data) -> AsyncStream.Continuation.YieldResult? {
48 | var yieldResult: AsyncStream.Continuation.YieldResult?
49 |
50 | for byte in value {
51 | yieldResult = yield(byte)
52 | }
53 |
54 | return yieldResult
55 | }
56 |
57 | /// Resume the task awaiting the next iteration point by having it return
58 | /// normally from its suspension point with the given string.
59 | ///
60 | /// - Parameter value: The value to yield from the continuation.
61 | /// - Returns: A `YieldResult` that indicates the success or failure of the
62 | /// yield operation from the last byte of the string after being converted to `Data`.
63 | ///
64 | /// If nothing is awaiting the next value, this method attempts to buffer the
65 | /// result's element.
66 | ///
67 | /// This can be called more than once and returns to the caller immediately
68 | /// without blocking for any awaiting consumption from the iteration.
69 | @discardableResult public func yield(_ value: String) -> AsyncStream.Continuation.YieldResult? {
70 | // unicode encodings are safe to explicity unwrap
71 | yield(value.data(using: .utf8)!)
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Errors.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Errors.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | /// Type representing possible errors
35 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods")
36 | public enum SubprocessError: Error {
37 |
38 | /// The process completed with a non-zero exit code
39 | /// and a custom error message.
40 | case exitedWithNonZeroStatus(Int32, String)
41 |
42 | /// The property list object could not be cast to expected type
43 | case unexpectedPropertyListObject(String)
44 |
45 | /// The JSON object could not be cast to expected type
46 | case unexpectedJSONObject(String)
47 |
48 | /// Input string could not be encoded
49 | case inputStringEncodingError
50 |
51 | /// Output string could not be encoded
52 | case outputStringEncodingError
53 | }
54 |
55 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods")
56 | extension SubprocessError: LocalizedError {
57 | public var errorDescription: String? {
58 | switch self {
59 | case .exitedWithNonZeroStatus(_, let errorMessage):
60 | return "\(errorMessage)"
61 | case .unexpectedPropertyListObject:
62 | // Ignoring the plist contents parameter as we don't want that in the error message
63 | return "The property list object could not be cast to expected type"
64 | case .unexpectedJSONObject:
65 | // Ignoring the json contents parameter as we don't want that in the error message
66 | return "The JSON object could not be cast to expected type"
67 | case .inputStringEncodingError:
68 | return "Input string could not be encoded"
69 | case .outputStringEncodingError:
70 | return "Output string could not be encoded"
71 | }
72 | }
73 | }
74 |
75 | /// Common NSError methods for better interop with Objective-C
76 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods")
77 | extension SubprocessError: CustomNSError {
78 | public var errorCode: Int {
79 | switch self {
80 | case .exitedWithNonZeroStatus(let errorCode, _):
81 | return Int(errorCode)
82 | case .unexpectedPropertyListObject: return 10
83 | case .unexpectedJSONObject: return 20
84 | case .inputStringEncodingError: return 30
85 | case .outputStringEncodingError: return 40
86 | }
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 2.0.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Input.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Input.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | /// Interface representing input to the process
35 | public struct Input {
36 | /// Reference to the input value
37 | public enum Value {
38 |
39 | /// Data to be written to stdin of the child process
40 | case data(Data)
41 |
42 | /// Text to be written to stdin of the child process
43 | case text(String)
44 |
45 | /// File to be written to stdin of the child process
46 | case file(URL)
47 | }
48 |
49 | /// Reference to the input value
50 | public let value: Value
51 |
52 | /// Creates input for writing data to stdin of the child process
53 | /// - Parameter data: Data written to stdin of the child process
54 | /// - Returns: New Input instance
55 | public static func data(_ data: Data) -> Input {
56 | return Input(value: .data(data))
57 | }
58 |
59 | /// Creates input for writing text to stdin of the child process
60 | /// - Parameter text: Text written to stdin of the child process
61 | /// - Returns: New Input instance
62 | public static func text(_ text: String) -> Input {
63 | return Input(value: .text(text))
64 | }
65 |
66 | /// Creates input for writing contents of file at path to stdin of the child process
67 | /// - Parameter path: Path to file written to stdin of the child process
68 | /// - Returns: New Input instance
69 | public static func file(path: String) -> Input {
70 | return Input(value: .file(URL(fileURLWithPath: path)))
71 | }
72 |
73 | /// Creates input for writing contents of file URL to stdin of the child process
74 | /// - Parameter url: URL for file written to stdin of the child process
75 | /// - Returns: New Input instance
76 | public static func file(url: URL) -> Input {
77 | return Input(value: .file(url))
78 | }
79 |
80 | /// Creates file handle or pipe for given input
81 | /// - Returns: New FileHandle or Pipe
82 | func createPipeOrFileHandle() throws -> Any {
83 | switch value {
84 | case .data(let data):
85 | return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in
86 | continuation.yield(data)
87 | continuation.finish()
88 | }))
89 | case .text(let text):
90 | return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in
91 | continuation.yield(Data(text.utf8))
92 | continuation.finish()
93 | }))
94 | case .file(let url):
95 | return try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: url)
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Pipe+AsyncBytes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Pipe+AsyncBytes.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | // `FileHandle.AsyncBytes` has a bug that can block reading of stdout when also reading stderr.
35 | // We can avoid this problem if we create independent handlers.
36 | extension Pipe {
37 | /// Convenience for reading bytes from the pipe's file handle.
38 | public struct AsyncBytes: AsyncSequence, Sendable {
39 | public typealias Element = UInt8
40 |
41 | let pipe: Pipe
42 |
43 | public func makeAsyncIterator() -> AsyncStream.Iterator {
44 | AsyncStream { continuation in
45 | pipe.fileHandleForReading.readabilityHandler = { @Sendable handle in
46 | let availableData = handle.availableData
47 |
48 | guard !availableData.isEmpty else {
49 | handle.readabilityHandler = nil
50 | continuation.finish()
51 | return
52 | }
53 |
54 | for byte in availableData {
55 | if case .terminated = continuation.yield(byte) {
56 | break
57 | }
58 | }
59 | }
60 |
61 | continuation.onTermination = { _ in
62 | pipe.fileHandleForReading.readabilityHandler = nil
63 | }
64 | }.makeAsyncIterator()
65 | }
66 | }
67 |
68 | public var bytes: AsyncBytes {
69 | AsyncBytes(pipe: self)
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Shell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Shell.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | /// Class used for synchronous process execution
35 | @available(*, deprecated, message: "Use Swift Concurrency methods instead which are part of the Subprocess class")
36 | public class Shell {
37 |
38 | /// OptionSet representing output handling
39 | public struct OutputOptions: OptionSet, Sendable {
40 | public let rawValue: Int
41 |
42 | /// Processes data written to stdout
43 | public static let stdout = OutputOptions(rawValue: 1 << 0)
44 |
45 | /// Processes data written to stderr
46 | public static let stderr = OutputOptions(rawValue: 1 << 1)
47 |
48 | /// Processes data written to both stdout and stderr
49 | public static let combined: OutputOptions = [ .stdout, .stderr ]
50 | public init(rawValue: Int) { self.rawValue = rawValue }
51 | }
52 |
53 | /// Reference to subprocess
54 | public let process: Subprocess
55 |
56 | /// Creates new Shell instance
57 | ///
58 | /// - Parameter command: Command represented as an array of strings
59 | public init(_ command: [String]) {
60 | process = Subprocess(command)
61 | }
62 |
63 | /// Executes shell command using a supplied block to tranform the process output into whatever type you would like
64 | ///
65 | /// - Parameters:
66 | /// - input: File or data to write to standard input of the process (Default: nil)
67 | /// - options: Output options defining the output to process (Default: .stdout)
68 | /// - transformBlock: Block executed given a reference to the completed process and the output
69 | /// - Returns: Process output as output type of the `transformBlock`
70 | /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output
71 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
72 | public func exec(input: Input? = nil,
73 | options: OutputOptions = .stdout,
74 | transformBlock: (_ process: Subprocess, _ data: Data) throws -> T) throws -> T {
75 | let stdoutData = UnsafeData()
76 | let stderrData = UnsafeData()
77 | let outputHandler: (@Sendable (Data) -> Void)? = if options.contains(.stdout) {
78 | { data in
79 | stdoutData.append(data)
80 | }
81 | } else {
82 | nil
83 | }
84 | let errorHandler: (@Sendable (Data) -> Void)? = if options.contains(.stderr) {
85 | { data in
86 | stderrData.append(data)
87 | }
88 | } else {
89 | nil
90 | }
91 |
92 | try process.launch(input: input, outputHandler: outputHandler, errorHandler: errorHandler)
93 | process.waitForTermination()
94 | // doing this so we can consistently get stdout before stderr when using the combined option
95 | var combinedBuffer = Data()
96 | combinedBuffer.append(stdoutData.value())
97 | combinedBuffer.append(stderrData.value())
98 | return try transformBlock(process, combinedBuffer)
99 | }
100 |
101 | /// Executes shell command expecting exit code of zero and returning the output data
102 | ///
103 | /// - Parameters:
104 | /// - input: File or data to write to standard input of the process (Default: nil)
105 | /// - options: Output options defining the output to process (Default: .stdout)
106 | /// - Returns: Process output data
107 | /// - Throws: Error from process launch or if termination code is none-zero
108 | @available(*, deprecated, message: "Use Subprocess.data(for:standardInput:options:)")
109 | public func exec(input: Input? = nil, options: OutputOptions = .stdout) throws -> Data {
110 | return try exec(input: input, options: options) { process, data in
111 | let exitCode = process.exitCode
112 | guard exitCode == 0 else {
113 | let message = String(data: data, encoding: .utf8)
114 | throw SubprocessError.exitedWithNonZeroStatus(exitCode, message ?? "")
115 | }
116 | return data
117 | }
118 | }
119 |
120 | /// Executes shell command using a supplied block to tranform the process output as a String
121 | /// into whatever type you would like
122 | ///
123 | /// - Parameters:
124 | /// - input: File or data to write to standard input of the process (Default: nil)
125 | /// - options: Output options defining the output to process (Default: .stdout)
126 | /// - encoding: Encoding to use for the output
127 | /// - transformBlock: Block executed given a reference to the completed process and the output as a string
128 | /// - Returns: Process output as output type of the `transformBlock`
129 | /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output
130 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
131 | public func exec(input: Input? = nil,
132 | options: OutputOptions = .stdout,
133 | encoding: String.Encoding,
134 | transformBlock: (_ process: Subprocess, _ string: String) throws -> T) throws -> T {
135 | return try exec(input: input, options: options) { process, data in
136 | guard let text = String(data: data, encoding: encoding) else {
137 | throw SubprocessError.outputStringEncodingError
138 | }
139 | return try transformBlock(process, text)
140 | }
141 | }
142 |
143 | /// Executes shell command expecting exit code of zero and returning the output as a string
144 | ///
145 | /// - Parameters:
146 | /// - input: File or data to write to standard input of the process (Default: nil)
147 | /// - options: Output options defining the output to process (Default: .stdout)
148 | /// - encoding: Encoding to use for the output
149 | /// - Returns: Process output as a String
150 | /// - Throws: Error from process launch, if termination code is none-zero or failing create a string from the output
151 | @available(*, deprecated, message: "Use Subprocess.string(for:standardInput:options:)")
152 | public func exec(input: Input? = nil,
153 | options: OutputOptions = .stdout,
154 | encoding: String.Encoding) throws -> String {
155 | return try exec(input: input, options: options, encoding: encoding) { process, text in
156 | let exitCode = process.exitCode
157 | guard exitCode == 0 else {
158 | throw SubprocessError.exitedWithNonZeroStatus(exitCode, text)
159 | }
160 | return text
161 | }
162 | }
163 |
164 | /// Executes shell command expecting JSON
165 | ///
166 | /// - Parameters:
167 | /// - input: File or data to write to standard input of the process (Default: nil)
168 | /// - options: Output options defining the output to process (Default: .stdout)
169 | /// - Returns: Process output as an Array or Dictionary
170 | /// - Throws: Error from process launch, JSONSerialization or failing to cast to expected type
171 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
172 | public func execJSON(input: Input? = nil, options: OutputOptions = .stdout) throws -> T {
173 | return try exec(input: input, options: options) { _, data in
174 | let object = try JSONSerialization.jsonObject(with: data, options: [])
175 | guard let value = object as? T else {
176 | throw SubprocessError.unexpectedJSONObject(String(describing: type(of: object)))
177 | }
178 | return value
179 | }
180 | }
181 |
182 | /// Executes shell command expecting a property list
183 | ///
184 | /// - Parameters:
185 | /// - input: File or data to write to standard input of the process (Default: nil)
186 | /// - options: Output options defining the output to process (Default: .stdout)
187 | /// - Returns: Process output as an Array or Dictionary
188 | /// - Throws: Error from process launch, PropertyListSerialization or failing to cast to expected type
189 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
190 | public func execPropertyList(input: Input? = nil, options: OutputOptions = .stdout) throws -> T {
191 | return try exec(input: input, options: options) { _, data in
192 | let object = try PropertyListSerialization.propertyList(from: data, options: [], format: .none)
193 | guard let value = object as? T else {
194 | throw SubprocessError.unexpectedPropertyListObject(String(describing: type(of: object)))
195 | }
196 | return value
197 | }
198 | }
199 |
200 | /// Executes shell command expecting JSON and decodes object conforming to Decodable
201 | ///
202 | /// - Parameters:
203 | /// - input: File or data to write to standard input of the process (Default: nil)
204 | /// - options: Output options defining the output to process (Default: .stdout)
205 | /// - decoder: JSONDecoder instance used for decoding the output object
206 | /// - Returns: Process output as the decodable object type
207 | /// - Throws: Error from process launch or JSONDecoder
208 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
209 | public func exec(input: Input? = nil,
210 | options: OutputOptions = .stdout,
211 | decoder: JSONDecoder) throws -> T {
212 | return try exec(input: input, options: options) { _, data in try decoder.decode(T.self, from: data) }
213 | }
214 |
215 | /// Executes shell command expecting property list and decodes object conforming to Decodable
216 | ///
217 | /// - Parameters:
218 | /// - input: File or data to write to standard input of the process (Default: nil)
219 | /// - options: Output options defining the output to process (Default: .stdout)
220 | /// - decoder: PropertyListDecoder instance used for decoding the output object
221 | /// - Returns: Process output as the decodable object type
222 | /// - Throws: Error from process launch or PropertyListDecoder
223 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)")
224 | public func exec(input: Input? = nil,
225 | options: OutputOptions = .stdout,
226 | decoder: PropertyListDecoder) throws -> T {
227 | return try exec(input: input, options: options) { _, data in try decoder.decode(T.self, from: data) }
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Subprocess.h:
--------------------------------------------------------------------------------
1 | //
2 | // Subprocess.h
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #import
29 |
30 | //! Project version number for Subprocess.
31 | FOUNDATION_EXPORT double SubprocessVersionNumber;
32 |
33 | //! Project version string for Subprocess.
34 | FOUNDATION_EXPORT const unsigned char SubprocessVersionString[];
35 |
--------------------------------------------------------------------------------
/Sources/Subprocess/Subprocess.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Subprocess.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | public import Combine
31 | #else
32 | import Foundation
33 | import Combine
34 | #endif
35 |
36 | /// Class used for asynchronous process execution
37 | public class Subprocess: @unchecked Sendable {
38 | /// Output options.
39 | public struct OutputOptions: OptionSet, Sendable {
40 | public let rawValue: Int
41 |
42 | /// Buffer standard output.
43 | public static let standardOutput = Self(rawValue: 1 << 0)
44 |
45 | /// Buffer standard error which may include useful error messages.
46 | public static let standardError = Self(rawValue: 1 << 1)
47 |
48 | public init(rawValue: Int) {
49 | self.rawValue = rawValue
50 | }
51 | }
52 |
53 | /// Process reference
54 | let process: Process
55 |
56 | /// Process identifier
57 | public var pid: Int32 { process.processIdentifier }
58 |
59 | /// Exit code of the process
60 | public var exitCode: Int32 { process.terminationStatus }
61 |
62 | /// Returns whether the process is still running.
63 | public var isRunning: Bool { process.isRunning }
64 |
65 | /// Reason for process termination
66 | public var terminationReason: Process.TerminationReason { process.terminationReason }
67 |
68 | /// Reference environment property
69 | public var environment: [String: String]? {
70 | get {
71 | process.environment
72 | }
73 | set {
74 | process.environment = newValue
75 | }
76 | }
77 |
78 | private lazy var group = DispatchGroup()
79 |
80 | /// Creates new Subprocess
81 | ///
82 | /// - Parameter command: Command represented as an array of strings
83 | public required init(_ command: [String]) {
84 | process = SubprocessDependencyBuilder.shared.makeProcess(command: command)
85 | }
86 |
87 | // You may ask yourself, "Well, how did I get here?"
88 | // It would be nice if we could write something like:
89 | //
90 | // public func run(standardInput: Input? = nil, options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: () async -> Void) where Input : AsyncSequence, Input.Element == UInt8 {}
91 | //
92 | // Then the equivelent convenience methods below could take an AsyncSequence as well in the same style.
93 | //
94 | // The problem with this is that AsyncSequence is a rethrowing protocol that has no primary associated type. So it's always up to the caller of the method to specify its type and if the default nil is used then it can't determine the type thus causing a compile time error.
95 | // There are a few Swift Forum threads discussing the problems with AsyncSequence in interfaces:
96 | // https://forums.swift.org/t/anyasyncsequence/50828/33
97 | // https://forums.swift.org/t/type-erasure-of-asyncsequences/66547/23
98 | //
99 | // The solution used here is to unfortunately have an extra method that just omits the standard input when its not going to be used. I believe the interface is well defined this way and easier to use in the end without strange hacks or conversions to AsyncStream.
100 |
101 | /// Run a command.
102 | ///
103 | /// - Parameters:
104 | /// - options: Options to control which output should be returned.
105 | /// - Returns: The standard output and standard error as `Pipe.AsyncBytes` sequences and an optional closure that can be used to `await` the process until it has completed.
106 | ///
107 | /// Run a command and optionally read its output.
108 | ///
109 | /// let subprocess = Subprocess(["/bin/cat somefile"])
110 | /// let (standardOutput, _, waitForExit) = try subprocess.run()
111 | ///
112 | /// Task {
113 | /// for await line in standardOutput.lines {
114 | /// switch line {
115 | /// case "hello":
116 | /// await waitForExit()
117 | /// break
118 | /// default:
119 | /// continue
120 | /// }
121 | /// }
122 | /// }
123 | ///
124 | /// It is the callers responsibility to ensure that any reads occur if waiting for the process to exit otherwise a deadlock can happen if the process is waiting to write to its output buffer.
125 | /// A task group can be used to wait for exit while reading the output. If the output is discardable consider passing (`[]`) an empty set for the options which effectively flushes output to null.
126 | public func run(options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: @Sendable () async -> Void) {
127 | let standardOutput: Pipe.AsyncBytes = {
128 | if options.contains(.standardOutput) {
129 | let pipe = Pipe()
130 |
131 | process.standardOutput = pipe
132 | return pipe.bytes
133 | } else {
134 | let pipe = Pipe()
135 |
136 | defer {
137 | try? pipe.fileHandleForReading.close()
138 | }
139 |
140 | process.standardOutput = FileHandle.nullDevice
141 | return pipe.bytes
142 | }
143 | }()
144 | let standardError: Pipe.AsyncBytes = {
145 | if options.contains(.standardError) {
146 | let pipe = Pipe()
147 |
148 | process.standardError = pipe
149 | return pipe.bytes
150 | } else {
151 | let pipe = Pipe()
152 |
153 | defer {
154 | try? pipe.fileHandleForReading.close()
155 | }
156 |
157 | process.standardError = FileHandle.nullDevice
158 | return pipe.bytes
159 | }
160 | }()
161 |
162 | let terminationContinuation = TerminationContinuation()
163 | let task: Task = Task.detached {
164 | await withUnsafeContinuation { continuation in
165 | Task {
166 | await terminationContinuation.setContinuation(continuation)
167 | }
168 | }
169 | }
170 | let waitUntilExit = { @Sendable in
171 | await task.value
172 | }
173 |
174 | process.terminationHandler = { _ in
175 | Task {
176 | await terminationContinuation.resume()
177 | }
178 | }
179 |
180 | try process.run()
181 | return (standardOutput, standardError, waitUntilExit)
182 | }
183 |
184 | /// Run an interactive command.
185 | ///
186 | /// - Parameters:
187 | /// - standardInput: An `AsyncSequence` that is used to supply input to the underlying process.
188 | /// - options: Options to control which output should be returned.
189 | /// - Returns: The standard output and standard error as `Pipe.AsyncBytes` sequences and an optional closure that can be used to `await` the process until it has completed.
190 | ///
191 | /// Run a command and interactively respond to output.
192 | ///
193 | /// let (stream, input) = {
194 | /// var input: AsyncStream.Continuation!
195 | /// let stream: AsyncStream = AsyncStream { continuation in
196 | /// input = continuation
197 | /// }
198 | ///
199 | /// return (stream, input!)
200 | /// }()
201 | ///
202 | /// let subprocess = Subprocess(["/bin/cat"])
203 | /// let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream)
204 | ///
205 | /// input.yield("hello\n")
206 | ///
207 | /// Task {
208 | /// for await line in standardOutput.lines {
209 | /// switch line {
210 | /// case "hello":
211 | /// input.yield("world\n")
212 | /// case "world":
213 | /// input.yield("and\nuniverse")
214 | /// input.finish()
215 | /// case "universe":
216 | /// await waitForExit()
217 | /// break
218 | /// default:
219 | /// continue
220 | /// }
221 | /// }
222 | /// }
223 | ///
224 | public func run(standardInput: Input, options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: @Sendable () async -> Void) where Input : AsyncSequence, Input.Element == UInt8 {
225 | process.standardInput = try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: standardInput)
226 | return try run(options: options)
227 | }
228 |
229 | /// Suspends the command
230 | public func suspend() -> Bool {
231 | process.suspend()
232 | }
233 |
234 | /// Resumes the command which was suspended
235 | public func resume() -> Bool {
236 | process.resume()
237 | }
238 |
239 | /// Sends the command the term signal
240 | public func kill() {
241 | process.terminate()
242 | }
243 | }
244 |
245 | // Methods for typical one-off acquisition of output from running some command.
246 | extension Subprocess {
247 | /// Additional configuration options.
248 | public struct RunOptions: OptionSet, Sendable {
249 | public let rawValue: Int
250 |
251 | /// Throw an error if the process exited with a non-zero exit code.
252 | public static let throwErrorOnNonZeroExit = Self(rawValue: 1 << 0)
253 |
254 | /// Return the output from standard error instead of standard output.
255 | public static let returnStandardError = Self(rawValue: 1 << 1)
256 |
257 | public init(rawValue: Int) {
258 | self.rawValue = rawValue
259 | }
260 | }
261 |
262 | /// Retreive output as `Data` from running an external command.
263 | /// - Parameters:
264 | /// - command: An external command to run with optional arguments.
265 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command.
266 | /// - options: Options used to specify runtime behavior.
267 | public static func data(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data {
268 | let subprocess = Self(command)
269 | let (standardOutput, standardError, waitForExit) = if let standardInput {
270 | try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in
271 | for byte in standardInput {
272 | if case .terminated = continuation.yield(byte) {
273 | break
274 | }
275 | }
276 |
277 | continuation.finish()
278 | }))
279 | } else {
280 | try subprocess.run()
281 | }
282 |
283 | // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer
284 | let result = await withTaskGroup(of: Void.self) { group in
285 | let stdoutData = UnsafeData()
286 | let stderrData = UnsafeData()
287 |
288 | group.addTask {
289 | await withTaskCancellationHandler(operation: {
290 | await waitForExit()
291 | }, onCancel: {
292 | subprocess.kill()
293 | })
294 | }
295 | group.addTask {
296 | var bytes = [UInt8]()
297 |
298 | for await byte in standardOutput {
299 | bytes.append(byte)
300 | }
301 |
302 | stdoutData.set(Data(bytes))
303 | }
304 | group.addTask {
305 | var bytes = [UInt8]()
306 |
307 | for await byte in standardError {
308 | bytes.append(byte)
309 | }
310 |
311 | stderrData.set(Data(bytes))
312 | }
313 |
314 | for await _ in group {
315 | // nothing to collect here
316 | }
317 |
318 | return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value())
319 | }
320 | try Task.checkCancellation()
321 |
322 | if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 {
323 | throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self))
324 | }
325 |
326 | let data = if options.contains(.returnStandardError) {
327 | result.standardErrorData
328 | } else {
329 | result.standardOutputData
330 | }
331 |
332 | return data
333 | }
334 |
335 | // MARK: Data convenience methods
336 |
337 | /// Retreive output as `Data` from running an external command.
338 | /// - Parameters:
339 | /// - command: An external command to run with optional arguments.
340 | /// - standardInput: A `String` from which to send input to the external command.
341 | /// - options: Options used to specify runtime behavior.
342 | @inlinable
343 | public static func data(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data {
344 | try await data(for: command, standardInput: standardInput.data(using: .utf8)!, options: options)
345 | }
346 |
347 | /// Retreive output as `Data` from running an external command.
348 | /// - Parameters:
349 | /// - command: An external command to run with optional arguments.
350 | /// - standardInput: A file `URL` from which to read input to the external command.
351 | /// - options: Options used to specify runtime behavior.
352 | public static func data(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data {
353 | let subprocess = Self(command)
354 | let (standardOutput, standardError, waitForExit) = if #available(macOS 12.0, *) {
355 | try subprocess.run(standardInput: SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).bytes)
356 | } else if let fileData = try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).readToEnd(), !fileData.isEmpty {
357 | try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in
358 | for byte in fileData {
359 | if case .terminated = continuation.yield(byte) {
360 | break
361 | }
362 | }
363 |
364 | continuation.finish()
365 | }))
366 | } else {
367 | try subprocess.run()
368 | }
369 |
370 | // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer
371 | let result = await withTaskGroup(of: Void.self) { group in
372 | let stdoutData = UnsafeData()
373 | let stderrData = UnsafeData()
374 |
375 | group.addTask {
376 | await withTaskCancellationHandler(operation: {
377 | await waitForExit()
378 | }, onCancel: {
379 | subprocess.kill()
380 | })
381 | }
382 | group.addTask {
383 | var bytes = [UInt8]()
384 |
385 | for await byte in standardOutput {
386 | bytes.append(byte)
387 | }
388 |
389 | stdoutData.set(Data(bytes))
390 | }
391 | group.addTask {
392 | var bytes = [UInt8]()
393 |
394 | for await byte in standardError {
395 | bytes.append(byte)
396 | }
397 |
398 | stderrData.set(Data(bytes))
399 | }
400 |
401 | for await _ in group {
402 | // nothing to collect
403 | }
404 |
405 | return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value())
406 | }
407 | try Task.checkCancellation()
408 |
409 | if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 {
410 | throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self))
411 | }
412 |
413 | let data = if options.contains(.returnStandardError) {
414 | result.standardErrorData
415 | } else {
416 | result.standardOutputData
417 | }
418 |
419 | return data
420 | }
421 |
422 | // MARK: String convenience methods
423 |
424 | /// Retreive output as a UTF8 `String` from running an external command.
425 | /// - Parameters:
426 | /// - command: An external command to run with optional arguments.
427 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command.
428 | /// - options: Options used to specify runtime behavior.
429 | @inlinable
430 | public static func string(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String {
431 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self)
432 | }
433 |
434 | /// Retreive output as `String` from running an external command.
435 | /// - Parameters:
436 | /// - command: An external command to run with optional arguments.
437 | /// - standardInput: A `String` from which to send input to the external command.
438 | /// - options: Options used to specify runtime behavior.
439 | @inlinable
440 | public static func string(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String {
441 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self)
442 | }
443 |
444 | /// Retreive output as `String` from running an external command.
445 | /// - Parameters:
446 | /// - command: An external command to run with optional arguments.
447 | /// - standardInput: A file `URL` from which to read input to the external command.
448 | /// - options: Options used to specify runtime behavior.
449 | @inlinable
450 | public static func string(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String {
451 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self)
452 | }
453 |
454 | // MARK: Decodable types convenience methods
455 |
456 | /// Retreive output from from running an external command.
457 | /// - Parameters:
458 | /// - command: An external command to run with optional arguments.
459 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command.
460 | /// - options: Options used to specify runtime behavior.
461 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data.
462 | @inlinable
463 | public static func value(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data {
464 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options))
465 | }
466 |
467 | /// Retreive output from from running an external command.
468 | /// - Parameters:
469 | /// - command: An external command to run with optional arguments.
470 | /// - standardInput: A `String` from which to send input to the external command.
471 | /// - options: Options used to specify runtime behavior.
472 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data.
473 | @inlinable
474 | public static func value(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data {
475 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options))
476 | }
477 |
478 | /// Retreive output from from running an external command.
479 | /// - Parameters:
480 | /// - command: An external command to run with optional arguments.
481 | /// - standardInput: A file `URL` from which to read input to the external command.
482 | /// - options: Options used to specify runtime behavior.
483 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data.
484 | @inlinable
485 | public static func value(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data {
486 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options))
487 | }
488 | }
489 |
490 | // closure based methods
491 | extension Subprocess {
492 | /// Launches command with read handlers and termination handler
493 | ///
494 | /// - Parameters:
495 | /// - input: File or data to write to standard input of the process
496 | /// - outputHandler: Block called whenever new data is read from standard output of the process
497 | /// - errorHandler: Block called whenever new data is read from standard error of the process
498 | /// - terminationHandler: Block called when process has terminated and all output handlers have returned
499 | public func launch(input: Input? = nil, outputHandler: (@Sendable (Data) -> Void)? = nil, errorHandler: (@Sendable (Data) -> Void)? = nil, terminationHandler: (@Sendable (Subprocess) -> Void)? = nil) throws {
500 | process.standardInput = try input?.createPipeOrFileHandle()
501 |
502 | process.standardOutput = if let outputHandler {
503 | createPipeWithReadabilityHandler(outputHandler)
504 | } else {
505 | FileHandle.nullDevice
506 | }
507 |
508 | process.standardError = if let errorHandler {
509 | createPipeWithReadabilityHandler(errorHandler)
510 | } else {
511 | FileHandle.nullDevice
512 | }
513 |
514 | group.enter()
515 | process.terminationHandler = { [unowned self] _ in
516 | group.leave()
517 | }
518 |
519 | group.notify(queue: .main) {
520 | terminationHandler?(self)
521 | }
522 |
523 | try process.run()
524 | }
525 |
526 | /// Block type called for executing process returning data from standard out and standard error
527 | public typealias DataTerminationHandler = @Sendable (_ process: Subprocess, _ stdout: Data, _ stderr: Data) -> Void
528 |
529 | /// Launches command calling a block when process terminates
530 | ///
531 | /// - Parameters:
532 | /// - input: File or data to write to standard input of the process
533 | /// - terminationHandler: Block called with Subprocess, stdout Data, stderr Data
534 | public func launch(input: Input? = nil, terminationHandler: @escaping DataTerminationHandler) throws {
535 | let stdoutData = UnsafeData()
536 | let stderrData = UnsafeData()
537 |
538 | try launch(input: input, outputHandler: { data in
539 | stdoutData.append(data)
540 | }, errorHandler: { data in
541 | stderrData.append(data)
542 | }, terminationHandler: { selfRef in
543 | let standardOutput = stdoutData.value()
544 | let standardError = stderrData.value()
545 |
546 | terminationHandler(selfRef, standardOutput, standardError)
547 | })
548 | }
549 |
550 | /// Waits for process to complete and all handlers to be called. Not to be
551 | /// confused with `Process.waitUntilExit()` which can return before its
552 | /// `terminationHandler` is called.
553 | /// Calling this method when using the non-deprecated methods will return immediately and not wait for the process to exit.
554 | public func waitForTermination() {
555 | group.wait()
556 | }
557 |
558 | private func createPipeWithReadabilityHandler(_ handler: @escaping @Sendable (Data) -> Void) -> Pipe {
559 | let pipe = Pipe()
560 |
561 | group.enter()
562 |
563 | let stream: AsyncStream = AsyncStream { continuation in
564 | pipe.fileHandleForReading.readabilityHandler = { handle in
565 | let data = handle.availableData
566 |
567 | guard !data.isEmpty else {
568 | handle.readabilityHandler = nil
569 | continuation.finish()
570 | return
571 | }
572 |
573 | continuation.yield(data)
574 | }
575 |
576 | continuation.onTermination = { _ in
577 | pipe.fileHandleForReading.readabilityHandler = nil
578 | }
579 | }
580 |
581 | Task {
582 | for await data in stream {
583 | handler(data)
584 | }
585 |
586 | group.leave()
587 | }
588 |
589 | return pipe
590 | }
591 | }
592 |
593 | extension Subprocess {
594 | /// Errors specific to `Subprocess`.
595 | public enum Error: LocalizedError {
596 | case nonZeroExit(status: Int32, reason: Process.TerminationReason, standardOutput: Data, standardError: String)
597 |
598 | public var errorDescription: String? {
599 | switch self {
600 | case let .nonZeroExit(status: terminationStatus, reason: _, standardOutput: _, standardError: errorString):
601 | return "Process exited with status \(terminationStatus): \(errorString)"
602 | }
603 | }
604 | }
605 | }
606 |
607 | private actor TerminationContinuation {
608 | private var continuation: UnsafeContinuation?
609 | private var didResume = false
610 |
611 | deinit {
612 | continuation?.resume()
613 | }
614 |
615 | func setContinuation(_ continuation: UnsafeContinuation) {
616 | self.continuation = continuation
617 |
618 | // in case the termination happened before the task was able to set the continuation
619 | if didResume {
620 | resume()
621 | }
622 | }
623 |
624 | func resume() {
625 | continuation?.resume()
626 | continuation = nil
627 | didResume = true
628 | }
629 | }
630 |
--------------------------------------------------------------------------------
/Sources/Subprocess/SubprocessDependencyBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SubprocessDependencyBuilder.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | /// Protocol call used for dependency injection
35 | public protocol SubprocessDependencyFactory {
36 | /// Creates new Subprocess
37 | ///
38 | /// - Parameter command: Command represented as an array of strings
39 | /// - Returns: New Subprocess instance
40 | func makeProcess(command: [String]) -> Process
41 |
42 | /// Creates a FileHandle for reading
43 | ///
44 | /// - Parameter url: File URL
45 | /// - Returns: New FileHandle for reading
46 | /// - Throws: When unable to open file for reading
47 | func makeInputFileHandle(url: URL) throws -> FileHandle
48 |
49 | /// Creates a `Pipe` and writes the sequence.
50 | ///
51 | /// - Parameter sequence: An `AsyncSequence` that supplies data to be written.
52 | /// - Returns: New `Pipe` instance.
53 | func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence, Input.Element == UInt8
54 | }
55 |
56 | /// Default implementation of SubprocessDependencyFactory
57 | public struct SubprocessDependencyBuilder: SubprocessDependencyFactory {
58 | private static let queue = DispatchQueue(label: "\(Self.self)")
59 |
60 | #if compiler(<5.10)
61 | private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder()
62 | #else
63 | nonisolated(unsafe) private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder()
64 | #endif
65 | /// Shared instance used for dependency creation
66 | public static var shared: any SubprocessDependencyFactory {
67 | get {
68 | queue.sync {
69 | _shared
70 | }
71 | }
72 | set {
73 | queue.sync {
74 | _shared = newValue
75 | }
76 | }
77 | }
78 |
79 | public func makeProcess(command: [String]) -> Process {
80 | var tmp = command
81 | let process = Process()
82 |
83 | process.executableURL = URL(fileURLWithPath: tmp.removeFirst())
84 | process.arguments = tmp
85 | return process
86 | }
87 |
88 | public func makeInputFileHandle(url: URL) throws -> FileHandle {
89 | return try FileHandle(forReadingFrom: url)
90 | }
91 |
92 | public func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 {
93 | let pipe = Pipe()
94 | // see here: https://developer.apple.com/forums/thread/690382
95 | let result = fcntl(pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1)
96 |
97 | guard result >= 0 else {
98 | throw NSError(domain: NSPOSIXErrorDomain, code: Int(result), userInfo: nil)
99 | }
100 |
101 | pipe.fileHandleForWriting.writeabilityHandler = { handle in
102 | handle.writeabilityHandler = nil
103 |
104 | Task {
105 | defer {
106 | try? handle.close()
107 | }
108 |
109 | // `DispatchIO` seems like an interesting solution but doesn't seem to mesh well with async/await, perhaps there will be updates in this area in the future.
110 | // https://developer.apple.com/forums/thread/690310
111 | // According to Swift forum talk byte by byte reads _could_ be optimized by the compiler depending on how much visibility it has into methods.
112 | for try await byte in sequence {
113 | try handle.write(contentsOf: [byte])
114 | }
115 | }
116 | }
117 |
118 | return pipe
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/Sources/Subprocess/UnsafeData.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UnsafeData.swift
3 | // Subprocess
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | import Foundation
29 |
30 | // Avoids errors for modifying data in concurrent contexts when we know it's safe to do so.
31 | final class UnsafeData: @unchecked Sendable {
32 | private lazy var data = Data()
33 |
34 | func set(_ data: Data) {
35 | self.data = data
36 | }
37 |
38 | func append(_ other: Data) {
39 | data.append(other)
40 | }
41 |
42 | func value() -> Data {
43 | data
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 2.0.0
19 | CFBundleVersion
20 | $(CURRENT_PROJECT_VERSION)
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/MockOutput.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockOutput.swift
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 |
34 | /// A way to supply data to mock methods
35 | public protocol MockOutput: Sendable {
36 | var data: Data { get }
37 | }
38 |
39 | extension Data: MockOutput {
40 | public var data: Data {
41 | self
42 | }
43 | }
44 |
45 | extension String: MockOutput {
46 | public var data: Data {
47 | Data(self.utf8)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/MockProcess.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockProcess.swift
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 | import Subprocess
34 |
35 | /// Interface used for mocking a process
36 | public struct MockProcess: Sendable {
37 |
38 | /// The underlying `MockProcessReference`
39 | public var reference: MockProcessReference
40 |
41 | /// Writes given data to standard out of the mock child process
42 | /// - Parameter data: Data to write to standard out of the mock child process
43 | public func writeTo(stdout: some MockOutput) {
44 | do {
45 | try reference.standardOutputPipe?.fileHandleForWriting.write(contentsOf: stdout.data)
46 | } catch {
47 | fatalError("unexpected write failure: \(error)")
48 | }
49 | }
50 |
51 | /// Writes given data to standard error of the mock child process
52 | /// - Parameter data: Data to write to standard error of the mock child process
53 | public func writeTo(stderr: some MockOutput) {
54 | do {
55 | try reference.standardErrorPipe?.fileHandleForWriting.write(contentsOf: stderr.data)
56 | } catch {
57 | fatalError("unexpected write failure: \(error)")
58 | }
59 | }
60 |
61 | /// Completes the mock process execution
62 | /// - Parameters:
63 | /// - statusCode: Exit code of the process (Default: 0)
64 | /// - reason: Termination reason of the process (Default: .exit)
65 | public func exit(withStatus statusCode: Int32 = 0, reason: Process.TerminationReason = .exit) {
66 | reference.exit(withStatus: statusCode, reason: reason)
67 | }
68 | }
69 |
70 | /// Subclass of `Process` used for mocking
71 | open class MockProcessReference: Process, @unchecked Sendable {
72 | /// Context information and values used for overriden properties
73 | public struct Context {
74 |
75 | /// State of the mock process
76 | public enum State {
77 | case initialized
78 | case running
79 | case uncaughtSignal
80 | case exited
81 | }
82 | public var terminationStatus: Int32 = 0
83 | public var processIdentifier: Int32 = -1
84 | public var state: State = .initialized
85 |
86 | /// Block called to stub the call to launch
87 | public var runStub: (MockProcess) throws -> Void
88 |
89 | var standardInput: Any?
90 | var standardOutput: Any?
91 | var standardError: Any?
92 | var terminationHandler: (@Sendable (Process) -> Void)?
93 | }
94 |
95 | public var context: Context
96 |
97 | /// Creates a new `MockProcessReference` which throws an error on launch
98 | /// - Parameter error: Error thrown when `Process.run` is called
99 | public init(withRunError error: any Error) {
100 | context = Context(runStub: { _ in throw error })
101 | }
102 |
103 | /// Creates a new `MockProcessReference` calling run stub block
104 | /// - Parameter block: Block used to stub `Process.run`
105 | public init(withRunBlock block: @escaping @Sendable (MockProcess) -> Void) {
106 | context = Context(runStub: { mock in
107 | Task(priority: .userInitiated) {
108 | block(mock)
109 | }
110 | })
111 | }
112 |
113 | /// Block called when `Process.terminate` is called
114 | public var stubTerminate: ((MockProcessReference) -> Void)?
115 |
116 | /// Block called when `Process.resume` is called
117 | public var stubResume: (() -> Bool)?
118 |
119 | /// Block called when `Process.suspend` is called
120 | public var stubSuspend: (() -> Bool)?
121 |
122 | /// Block called when `Process.waitUntilExit` is called
123 | public var stubWaitUntilExit: (() -> Void)?
124 |
125 | /// standardOutput object as a Pipe
126 | public var standardOutputPipe: Pipe? { standardOutput as? Pipe }
127 |
128 | /// standardError object as a Pipe
129 | public var standardErrorPipe: Pipe? { standardError as? Pipe }
130 |
131 | /// Environment property
132 | private var _environment: [String: String]?
133 | open override var environment: [String: String]? {
134 | get {
135 | return _environment
136 | }
137 | set {
138 | _environment = newValue
139 | }
140 | }
141 |
142 | /// Completes the mock process execution
143 | /// - Parameters:
144 | /// - statusCode: Exit code of the process (Default: 0)
145 | /// - reason: Termination reason of the process (Default: .exit)
146 | public func exit(withStatus statusCode: Int32 = 0, reason: Process.TerminationReason = .exit) {
147 | guard context.state == .running else { return }
148 | context.state = (reason == .exit) ? .exited : .uncaughtSignal
149 | context.terminationStatus = statusCode
150 | try? standardOutputPipe?.fileHandleForWriting.close()
151 | try? standardErrorPipe?.fileHandleForWriting.close()
152 |
153 | guard let handler = terminationHandler else { return }
154 | terminationHandler = nil
155 | handler(self)
156 | }
157 |
158 | open override var terminationReason: TerminationReason {
159 | context.state == .uncaughtSignal ? .uncaughtSignal : .exit
160 | }
161 |
162 | open override var terminationStatus: Int32 { context.terminationStatus }
163 |
164 | open override var processIdentifier: Int32 { context.processIdentifier }
165 |
166 | open override var isRunning: Bool { context.state == .running }
167 |
168 | open override func run() throws {
169 | guard context.state == .initialized else { return }
170 | context.state = .running
171 | context.processIdentifier = .random(in: 0...Int32.max)
172 | let mock = MockProcess(reference: self)
173 | try context.runStub(mock)
174 | }
175 |
176 | open override func terminate() {
177 | if let stub = stubTerminate {
178 | stub(self)
179 | context.state = .exited
180 | } else {
181 | context.state = .uncaughtSignal
182 | }
183 | }
184 |
185 | open override var standardInput: Any? {
186 | get { context.standardInput }
187 | set { context.standardInput = newValue }
188 | }
189 |
190 | open override var standardOutput: Any? {
191 | get { context.standardOutput }
192 | set { context.standardOutput = newValue }
193 | }
194 |
195 | open override var standardError: Any? {
196 | get { context.standardError }
197 | set { context.standardError = newValue }
198 | }
199 |
200 | open override var terminationHandler: (@Sendable (Process) -> Void)? {
201 | get { context.terminationHandler }
202 | set { context.terminationHandler = newValue }
203 | }
204 |
205 | open override func resume() -> Bool { stubResume?() ?? false }
206 |
207 | open override func suspend() -> Bool { stubSuspend?() ?? false }
208 |
209 | open override func waitUntilExit() {
210 | stubWaitUntilExit?()
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/MockShell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockShell.swift
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | public import Subprocess
31 | #else
32 | import Foundation
33 | import Subprocess
34 | #endif
35 |
36 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell")
37 | extension Shell: SubprocessMockObject {}
38 |
39 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell")
40 | public extension Shell {
41 |
42 | /// Adds a mock for a command which throws an error when `Process.run` is called
43 | ///
44 | /// - Parameters:
45 | /// - command: The command to mock
46 | /// - error: Error thrown when `Process.run` is called
47 | static func stub(_ command: [String], error: any Error) {
48 | Subprocess.stub(command, error: error)
49 | }
50 |
51 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code
52 | ///
53 | /// - Parameters:
54 | /// - command: The command to mock
55 | /// - standardOutput: Data written to stdout of the process
56 | /// - standardError: Data written to stderr of the process
57 | /// - exitCode: Exit code of the process (Default: 0)
58 | static func stub(_ command: [String],
59 | standardOutput: Data? = nil,
60 | standardError: Data? = nil,
61 | exitCode: Int32 = 0) {
62 | Subprocess.stub(command) { process in
63 | if let data = standardOutput {
64 | process.writeTo(stdout: data)
65 | }
66 | if let data = standardError {
67 | process.writeTo(stderr: data)
68 | }
69 | process.exit(withStatus: exitCode)
70 | }
71 | }
72 |
73 | /// Adds a mock for a command which writes the given text to the outputs and exits with the provided exit code
74 | ///
75 | /// - Parameters:
76 | /// - command: The command to mock
77 | /// - stdout: String written to stdout of the process
78 | /// - stderr: String written to stderr of the process (Default: nil)
79 | /// - exitCode: Exit code of the process (Default: 0)
80 | static func stub(_ command: [String],
81 | stdout: String,
82 | stderr: String? = nil,
83 | exitCode: Int32 = 0) {
84 | Subprocess.stub(command) { process in
85 | process.writeTo(stdout: stdout)
86 | if let text = stderr {
87 | process.writeTo(stderr: text)
88 | }
89 | process.exit(withStatus: exitCode)
90 | }
91 | }
92 |
93 | /// Adds a mock for a command which writes the given text to stderr and exits with the provided exit code
94 | ///
95 | /// - Parameters:
96 | /// - command: The command to mock
97 | /// - stdout: String written to stdout of the process
98 | /// - stderr: String written to stderr of the process
99 | /// - exitCode: Exit code of the process (Default: 0)
100 | static func stub(_ command: [String],
101 | stderr: String,
102 | exitCode: Int32 = 0) {
103 | Subprocess.stub(command) { process in
104 | process.writeTo(stderr: stderr)
105 | process.exit(withStatus: exitCode)
106 | }
107 | }
108 |
109 | /// Adds a mock for a command which writes the given property list to stdout and exits with the provided exit code
110 | ///
111 | /// - Parameters:
112 | /// - command: The command to mock
113 | /// - plist: Property list object serialized and written to stdout
114 | /// - exitCode: Exit code of the process (Default: 0)
115 | /// - Throws: Error when serializing property list object
116 | static func stub(_ command: [String],
117 | plist: Any,
118 | exitCode: Int32 = 0) throws {
119 | let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
120 | Shell.stub(command, standardOutput: data, exitCode: exitCode)
121 | }
122 |
123 | /// Adds a mock for a command which writes the given JSON object to stdout and exits with the provided exit code
124 | ///
125 | /// - Parameters:
126 | /// - command: The command to mock
127 | /// - plist: JSON object serialized and written to stdout
128 | /// - exitCode: Exit code of the process (Default: 0)
129 | /// - Throws: Error when serializing JSON object
130 | static func stub(_ command: [String],
131 | json: Any,
132 | exitCode: Int32 = 0) throws {
133 | let data = try JSONSerialization.data(withJSONObject: json, options: [])
134 | Shell.stub(command, standardOutput: data, exitCode: exitCode)
135 | }
136 |
137 | /// Adds a mock for a command which writes the given encodable object as a property list to stdout
138 | /// and exits with the provided exit code
139 | ///
140 | /// - Parameters:
141 | /// - command: The command to mock
142 | /// - plistObject: Encodable object written to stdout as a property list
143 | /// - exitCode: Exit code of the process (Default: 0)
144 | /// - Throws: Error when encoding the provided object
145 | static func stub(_ command: [String],
146 | plistObject: T,
147 | exitCode: Int32 = 0) throws {
148 | let data = try PropertyListEncoder().encode(plistObject)
149 | Shell.stub(command, standardOutput: data, exitCode: exitCode)
150 | }
151 |
152 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout
153 | /// and exits with the provided exit code
154 | ///
155 | /// - Parameters:
156 | /// - command: The command to mock
157 | /// - jsonObject: Encodable object written to stdout as JSON
158 | /// - exitCode: Exit code of the process (Default: 0)
159 | /// - Throws: Error when encoding the provided object
160 | static func stub(_ command: [String],
161 | jsonObject: T,
162 | exitCode: Int32 = 0) throws {
163 | let data = try JSONEncoder().encode(jsonObject)
164 | Shell.stub(command, standardOutput: data, exitCode: exitCode)
165 | }
166 |
167 | // MARK: -
168 |
169 | /// Adds an expected mock for a command which throws an error when `Process.run` is called
170 | ///
171 | /// - Parameters:
172 | /// - command: The command to mock
173 | /// - input: The expected input of the process
174 | /// - error: Error thrown when `Process.run` is called
175 | /// - file: Source file where expect was called (Default: #file)
176 | /// - line: Line number of source file where expect was called (Default: #line)
177 | static func expect(_ command: [String],
178 | input: Input? = nil,
179 | error: any Swift.Error,
180 | file: StaticString = #file,
181 | line: UInt = #line) {
182 | Subprocess.expect(command, input: input, error: error, file: file, line: line)
183 | }
184 |
185 | /// Adds an expected mock for a command which writes the given data to the outputs
186 | /// and exits with the provided exit code
187 | ///
188 | /// - Parameters:
189 | /// - command: The command to mock
190 | /// - input: The expected input of the process
191 | /// - standardOutput: Data written to stdout of the process
192 | /// - standardError: Data written to stderr of the process
193 | /// - exitCode: Exit code of the process (Default: 0)
194 | /// - file: Source file where expect was called (Default: #file)
195 | /// - line: Line number of source file where expect was called (Default: #line)
196 | static func expect(_ command: [String],
197 | input: Input? = nil,
198 | standardOutput: Data? = nil,
199 | standardError: Data? = nil,
200 | exitCode: Int32 = 0,
201 | file: StaticString = #file,
202 | line: UInt = #line) {
203 | Subprocess.expect(command, input: input, file: file, line: line) { process in
204 | if let data = standardOutput {
205 | process.writeTo(stdout: data)
206 | }
207 | if let data = standardError {
208 | process.writeTo(stderr: data)
209 | }
210 | process.exit(withStatus: exitCode)
211 | }
212 | }
213 |
214 | /// Adds an expected mock for a command which writes the given text to the outputs
215 | /// and exits with the provided exit code
216 | ///
217 | /// - Parameters:
218 | /// - command: The command to mock
219 | /// - input: The expected input of the process
220 | /// - stdout: String written to stdout of the process
221 | /// - stderr: String written to stderr of the process (Default: nil)
222 | /// - exitCode: Exit code of the process (Default: 0)
223 | /// - file: Source file where expect was called (Default: #file)
224 | /// - line: Line number of source file where expect was called (Default: #line)
225 | static func expect(_ command: [String],
226 | input: Input? = nil,
227 | stdout: String,
228 | stderr: String? = nil,
229 | exitCode: Int32 = 0,
230 | file: StaticString = #file,
231 | line: UInt = #line) {
232 | Subprocess.expect(command, input: input, file: file, line: line) { process in
233 | process.writeTo(stdout: stdout)
234 | if let text = stderr {
235 | process.writeTo(stderr: text)
236 | }
237 | process.exit(withStatus: exitCode)
238 | }
239 | }
240 |
241 | /// Adds an expected mock for a command which writes the given text to stderr and exits with the provided exit code
242 | ///
243 | /// - Parameters:
244 | /// - command: The command to mock
245 | /// - input: The expected input of the process
246 | /// - stdout: String written to stdout of the process
247 | /// - stderr: String written to stderr of the process
248 | /// - exitCode: Exit code of the process (Default: 0)
249 | /// - file: Source file where expect was called (Default: #file)
250 | /// - line: Line number of source file where expect was called (Default: #line)
251 | static func expect(_ command: [String],
252 | input: Input? = nil,
253 | stderr: String,
254 | exitCode: Int32 = 0,
255 | file: StaticString = #file,
256 | line: UInt = #line) {
257 | Subprocess.expect(command, input: input, file: file, line: line) { process in
258 | process.writeTo(stderr: stderr)
259 | process.exit(withStatus: exitCode)
260 | }
261 | }
262 |
263 | /// Adds an expected mock for a command which writes the given property list to stdout
264 | /// and exits with the provided exit code
265 | ///
266 | /// - Parameters:
267 | /// - command: The command to mock
268 | /// - input: The expected input of the process
269 | /// - plist: Property list object serialized and written to stdout
270 | /// - exitCode: Exit code of the process (Default: 0)
271 | /// - file: Source file where expect was called (Default: #file)
272 | /// - line: Line number of source file where expect was called (Default: #line)
273 | /// - Throws: Error when serializing property list object
274 | static func expect(_ command: [String],
275 | input: Input? = nil,
276 | plist: Any,
277 | exitCode: Int32 = 0,
278 | file: StaticString = #file,
279 | line: UInt = #line) throws {
280 | let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
281 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
282 | }
283 |
284 | /// Adds an expected mock for a command which writes the given JSON object to stdout
285 | /// and exits with the provided exit code
286 | ///
287 | /// - Parameters:
288 | /// - command: The command to mock
289 | /// - input: The expected input of the process
290 | /// - plist: JSON object serialized and written to stdout
291 | /// - exitCode: Exit code of the process (Default: 0)
292 | /// - file: Source file where expect was called (Default: #file)
293 | /// - line: Line number of source file where expect was called (Default: #line)
294 | /// - Throws: Error when serializing JSON object
295 | static func expect(_ command: [String],
296 | input: Input? = nil,
297 | json: Any,
298 | exitCode: Int32 = 0,
299 | file: StaticString = #file,
300 | line: UInt = #line) throws {
301 | let data = try JSONSerialization.data(withJSONObject: json, options: [])
302 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
303 | }
304 |
305 | /// Adds an expected mock for a command which writes the given encodable object as a property list to stdout
306 | /// and exits with the provided exit code
307 | ///
308 | /// - Parameters:
309 | /// - command: The command to mock
310 | /// - input: The expected input of the process
311 | /// - plistObject: Encodable object written to stdout as a property list
312 | /// - exitCode: Exit code of the process (Default: 0)
313 | /// - file: Source file where expect was called (Default: #file)
314 | /// - line: Line number of source file where expect was called (Default: #line)
315 | /// - Throws: Error when encoding the provided object
316 | static func expect(_ command: [String],
317 | input: Input? = nil,
318 | plistObject: T,
319 | exitCode: Int32 = 0,
320 | file: StaticString = #file,
321 | line: UInt = #line) throws {
322 | let data = try PropertyListEncoder().encode(plistObject)
323 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
324 | }
325 |
326 | /// Adds an expected mock for a command which writes the given encodable object as JSON to stdout
327 | /// and exits with the provided exit code
328 | ///
329 | /// - Parameters:
330 | /// - command: The command to mock
331 | /// - input: The expected input of the process
332 | /// - jsonObject: Encodable object written to stdout as JSON
333 | /// - exitCode: Exit code of the process (Default: 0)
334 | /// - file: Source file where expect was called (Default: #file)
335 | /// - line: Line number of source file where expect was called (Default: #line)
336 | /// - Throws: Error when encoding the provided object
337 | static func expect(_ command: [String],
338 | input: Input? = nil,
339 | jsonObject: T,
340 | exitCode: Int32 = 0,
341 | file: StaticString = #file,
342 | line: UInt = #line) throws {
343 | let data = try JSONEncoder().encode(jsonObject)
344 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line)
345 | }
346 | }
347 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/MockSubprocess.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockSubprocess.swift
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | public import Combine
31 | public import Subprocess
32 | #else
33 | import Foundation
34 | import Combine
35 | import Subprocess
36 | #endif
37 |
38 | extension Subprocess: SubprocessMockObject {}
39 |
40 | public extension Subprocess {
41 |
42 | /// Adds a mock for a given command which throws an error when `Process.run` is called
43 | ///
44 | /// - Parameters:
45 | /// - command: The command to mock
46 | /// - error: Error thrown when `Process.run` is called
47 | static func stub(_ command: [String], error: any Swift.Error) {
48 | let mock = MockProcessReference(withRunError: error)
49 | MockSubprocessDependencyBuilder.shared.stub(command, process: mock)
50 | }
51 |
52 | /// Adds a mock for a given command which calls the run block to mock process execution
53 | ///
54 | /// - Important: You must call`MockProcess.exit` for the process to complete
55 | /// - Parameters:
56 | /// - command: The command to mock
57 | /// - runBlock: Block called with a `MockProcess` to mock process execution.
58 | static func stub(_ command: [String], runBlock: (@Sendable (MockProcess) -> Void)? = nil) {
59 | let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() })
60 | MockSubprocessDependencyBuilder.shared.stub(command, process: mock)
61 | }
62 |
63 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code
64 | ///
65 | /// - Parameters:
66 | /// - command: The command to mock
67 | /// - standardOutput: Data written to stdout of the process
68 | /// - standardError: Data written to stderr of the process
69 | /// - exitCode: Exit code of the process (Default: 0)
70 | static func stub(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, exitCode: Int32 = 0) {
71 | stub(command) { process in
72 | if let data = standardOutput {
73 | process.writeTo(stdout: data)
74 | }
75 |
76 | if let data = standardError {
77 | process.writeTo(stderr: data)
78 | }
79 |
80 | process.exit(withStatus: exitCode)
81 | }
82 | }
83 |
84 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout
85 | /// and exits with the provided exit code
86 | ///
87 | /// - Parameters:
88 | /// - command: The command to mock
89 | /// - content: Encodable object written to stdout
90 | /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`.
91 | /// - exitCode: Exit code of the process (Default: 0)
92 | /// - Throws: Error when encoding the provided object
93 | static func stub(_ command: [String], content: Content, encoder: Encoder, exitCode: Int32 = 0) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data {
94 | let data: Data = try encoder.encode(content)
95 |
96 | stub(command, standardOutput: data, exitCode: exitCode)
97 | }
98 |
99 | // MARK: -
100 |
101 | /// Adds an expected mock for a given command which throws an error when `Process.run` is called
102 | ///
103 | /// - Parameters:
104 | /// - command: The command to mock
105 | /// - input: The expected input of the process
106 | /// - error: Error thrown when `Process.run` is called
107 | /// - file: Source file where expect was called (Default: #file)
108 | /// - line: Line number of source file where expect was called (Default: #line)
109 | static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, file: StaticString = #file, line: UInt = #line) {
110 | let mock = MockProcessReference(withRunError: error)
111 | MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line)
112 | }
113 |
114 | /// Adds an expected mock for a given command which calls the run block to mock process execution
115 | ///
116 | /// - Important: You must call`MockProcess.exit` for the process to complete
117 | /// - Parameters:
118 | /// - command: The command to mock
119 | /// - input: The expected input of the process
120 | /// - file: Source file where expect was called (Default: #file)
121 | /// - line: Line number of source file where expect was called (Default: #line)
122 | /// - runBlock: Block called with a `MockProcess` to mock process execution
123 | static func expect(_ command: [String], input: Input? = nil, file: StaticString = #file, line: UInt = #line, runBlock: (@Sendable (MockProcess) -> Void)? = nil) {
124 | let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() })
125 | MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line)
126 | }
127 |
128 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code
129 | ///
130 | /// - Parameters:
131 | /// - command: The command to mock
132 | /// - standardOutput: Data written to stdout of the process
133 | /// - standardError: Data written to stderr of the process
134 | /// - exitCode: Exit code of the process (Default: 0)
135 | 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) {
136 | expect(command, input: input, file: file, line: line) { process in
137 | if let data = standardOutput {
138 | process.writeTo(stdout: data)
139 | }
140 |
141 | if let data = standardError {
142 | process.writeTo(stderr: data)
143 | }
144 |
145 | process.exit(withStatus: exitCode)
146 | }
147 | }
148 |
149 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout
150 | /// and exits with the provided exit code
151 | ///
152 | /// - Parameters:
153 | /// - command: The command to mock
154 | /// - content: Encodable object written to stdout
155 | /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`.
156 | /// - exitCode: Exit code of the process (Default: 0)
157 | /// - Throws: Error when encoding the provided object
158 | 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 {
159 | let data: Data = try encoder.encode(content)
160 |
161 | expect(command, standardOutput: data, input: input, exitCode: exitCode, file: file, line: line)
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockSubprocessDependencyBuilder.swift
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #if swift(>=6.0)
29 | public import Foundation
30 | #else
31 | import Foundation
32 | #endif
33 | import Subprocess
34 |
35 | /// Error representing a failed call to Subprocess.expect or Shell.expect
36 | public struct ExpectationError: Error {
37 | /// Source file where expect was called
38 | public var file: StaticString
39 | /// Line number where expect was called
40 | public var line: UInt
41 | /// Error message
42 | public var message: String
43 | }
44 |
45 | /// Type representing possible errors thrown
46 | public enum MockSubprocessError: Error {
47 | /// Error containing command thrown when a process is launched that was not stubbed
48 | case missingMock([String])
49 | /// List of expectations which failed
50 | case missedExpectations([ExpectationError])
51 | }
52 |
53 | public protocol SubprocessMockObject {}
54 |
55 | public extension SubprocessMockObject {
56 |
57 | /// Verifies expected stubs and resets mocking
58 | /// - Throws: A `MockSubprocessError.missedExpectations` containing failed expectations
59 | static func verify() throws { try MockSubprocessDependencyBuilder.shared.verify() }
60 |
61 | /// Verifies expected stubs and resets mocking
62 | /// - Parameter missedBlock: Block called for each failed expectation
63 | static func verify(missedBlock: (ExpectationError) -> Void) {
64 | MockSubprocessDependencyBuilder.shared.verify(missedBlock: missedBlock)
65 | }
66 |
67 | /// Resets mocking
68 | static func reset() { MockSubprocessDependencyBuilder.shared.reset() }
69 | }
70 |
71 | public class MockFileHandle: FileHandle, @unchecked Sendable {
72 | public var url: URL?
73 | }
74 |
75 | public final class MockPipe: Pipe, @unchecked Sendable {
76 | private static let queue = DispatchQueue(label: "\(MockPipe.self)")
77 | private var _data: Data?
78 | public var data: Data? {
79 | get {
80 | Self.queue.sync {
81 | _data
82 | }
83 | }
84 | set {
85 | Self.queue.sync {
86 | _data = newValue
87 | }
88 | }
89 | }
90 | }
91 |
92 | class MockSubprocessDependencyBuilder {
93 | class MockItem {
94 | var used = false
95 | var command: [String]
96 | var input: Input?
97 | var process: MockProcessReference
98 | var file: StaticString?
99 | var line: UInt?
100 |
101 | init(command: [String], input: Input?, process: MockProcessReference, file: StaticString?, line: UInt?) {
102 | self.command = command
103 | self.input = input
104 | self.process = process
105 | self.file = file
106 | self.line = line
107 | }
108 | }
109 |
110 | var mocks: [MockItem] = []
111 |
112 | nonisolated(unsafe) static let shared = MockSubprocessDependencyBuilder()
113 |
114 | init() { SubprocessDependencyBuilder.shared = self }
115 |
116 | func stub(_ command: [String], process: MockProcessReference) {
117 | let mock = MockItem(command: command, input: nil, process: process, file: nil, line: nil)
118 | mocks.append(mock)
119 | }
120 |
121 | func expect(_ command: [String], input: Input?, process: MockProcessReference, file: StaticString, line: UInt) {
122 | let mock = MockItem(command: command, input: input, process: process, file: file, line: line)
123 | mocks.append(mock)
124 | }
125 |
126 | func verify(missedBlock block: (ExpectationError) -> Void) {
127 | defer { reset() }
128 | // All of the mocks for errors
129 | mocks.forEach {
130 | // Check if the file and line properties were set, this indicates it was an expected mock
131 | guard let file = $0.file, let line = $0.line else { return }
132 |
133 | // Check if the mock was used
134 | guard $0.used else { return block(ExpectationError(file: file, line: line, message: "Command not called")) }
135 |
136 | // Check the expected input
137 | let expectedData: Data?
138 | let expectedFile: URL?
139 |
140 | switch $0.input?.value {
141 | case .data(let data):
142 | expectedData = data
143 | expectedFile = nil
144 | case .text(let string):
145 | expectedData = Data(string.utf8)
146 | expectedFile = nil
147 | case .file(let url):
148 | expectedData = nil
149 | expectedFile = url
150 | default:
151 | expectedData = nil
152 | expectedFile = nil
153 | }
154 |
155 | let inputFile: URL? = ($0.process.standardInput as? MockFileHandle)?.url
156 | let inputData: Data? = ($0.process.standardInput as? MockPipe)?.data
157 |
158 | let fileError = verifyInputFile(inputURL: inputFile, expectedURL: expectedFile, file: file, line: line)
159 | if let error = fileError { return block(error) }
160 |
161 | let dataError = verifyInputData(inputData: inputData, expectedData: expectedData, file: file, line: line)
162 | guard let error = dataError else { return }
163 | block(error)
164 | }
165 | }
166 |
167 | private func verifyInputFile(inputURL: URL?,
168 | expectedURL: URL?,
169 | file: StaticString,
170 | line: UInt) -> ExpectationError? {
171 | if let expected = expectedURL {
172 | if let url = inputURL {
173 | if expected != url {
174 | return ExpectationError(file: file,
175 | line: line,
176 | message: "Input file URLs do not match \(expected) != \(url)")
177 | }
178 | } else {
179 | return ExpectationError(file: file, line: line, message: "Missing file input")
180 | }
181 | } else if let url = inputURL {
182 | return ExpectationError(file: file, line: line, message: "Unexpected input file \(url)")
183 | }
184 | return nil
185 | }
186 |
187 | private func verifyInputData(inputData: Data?,
188 | expectedData: Data?,
189 | file: StaticString,
190 | line: UInt) -> ExpectationError? {
191 | if let expectedData = expectedData {
192 | if let inputData = inputData {
193 | if inputData != expectedData {
194 | if let input = String(data: inputData, encoding: .utf8),
195 | let expected = String(data: inputData, encoding: .utf8) {
196 | let errorText = "Input text does not match expected input text \(input) != \(expected)"
197 | return ExpectationError(file: file,
198 | line: line,
199 | message: errorText)
200 | } else {
201 | return ExpectationError(file: file,
202 | line: line,
203 | message: "Input data does not match expected input data")
204 | }
205 | }
206 | } else {
207 | return ExpectationError(file: file, line: line, message: "Missing data input")
208 | }
209 | } else if let unexpectedData = inputData {
210 | if let input = String(data: unexpectedData, encoding: .utf8) {
211 | return ExpectationError(file: file, line: line, message: "Unexpected input text: \(input)")
212 | } else {
213 | return ExpectationError(file: file, line: line, message: "Unexpected input data")
214 | }
215 | }
216 | return nil
217 | }
218 |
219 | func verify() throws {
220 | var errors: [ExpectationError] = []
221 | verify { errors.append($0) }
222 | if errors.isEmpty { return }
223 | throw MockSubprocessError.missedExpectations(errors)
224 | }
225 |
226 | func reset() {
227 | mocks = []
228 | }
229 | }
230 |
231 | extension MockSubprocessDependencyBuilder: SubprocessDependencyFactory {
232 | func makeProcess(command: [String]) -> Process {
233 | if let item = mocks.first(where: { !$0.used && $0.command == command }) {
234 | item.used = true
235 | return item.process
236 | }
237 | return MockProcessReference(withRunError: MockSubprocessError.missingMock(command))
238 | }
239 |
240 | func makeInputFileHandle(url: URL) throws -> FileHandle {
241 | let handle = MockFileHandle()
242 | handle.url = url
243 | return handle
244 | }
245 |
246 | func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 {
247 | let semaphore = DispatchSemaphore(value: 0)
248 | let pipe = MockPipe()
249 |
250 | Task {
251 | pipe.data = try await sequence.data()
252 | semaphore.signal()
253 | }
254 |
255 | semaphore.wait()
256 | return pipe
257 | }
258 | }
259 |
--------------------------------------------------------------------------------
/Sources/SubprocessMocks/SubprocessMocks.h:
--------------------------------------------------------------------------------
1 | //
2 | // SubprocessMocks.h
3 | // SubprocessMocks
4 | //
5 | // MIT License
6 | //
7 | // Copyright (c) 2023 Jamf
8 | //
9 | // Permission is hereby granted, free of charge, to any person obtaining a copy
10 | // of this software and associated documentation files (the "Software"), to deal
11 | // in the Software without restriction, including without limitation the rights
12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | // copies of the Software, and to permit persons to whom the Software is
14 | // furnished to do so, subject to the following conditions:
15 | //
16 | // The above copyright notice and this permission notice shall be included in all
17 | // copies or substantial portions of the Software.
18 | //
19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | // SOFTWARE.
26 | //
27 |
28 | #import
29 |
30 | //! Project version number for SubprocessMocks.
31 | FOUNDATION_EXPORT double SubprocessMocksVersionNumber;
32 |
33 | //! Project version string for SubprocessMocks.
34 | FOUNDATION_EXPORT const unsigned char SubprocessMocksVersionString[];
35 |
--------------------------------------------------------------------------------
/Subprocess.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'Subprocess'
3 | s.version = '3.0.1'
4 | s.summary = 'Wrapper for NSTask used for running processes and shell commands on macOS.'
5 | s.license = { :type => 'MIT', :text => "" }
6 | s.description = <<-DESC
7 | Everything related to creating processes and running shell commands on macOS.
8 | DESC
9 | s.homepage = 'https://github.com/jamf/Subprocess'
10 | s.authors = { 'Michael Link' => 'michael.link@jamf.com' }
11 | s.source = { :git => "https://github.com/jamf/Subprocess.git", :tag => s.version.to_s }
12 | s.platform = :osx, '10.15.4'
13 | s.osx.deployment_target = '10.15.4'
14 | s.swift_version = '5.9'
15 | s.default_subspec = 'Core'
16 |
17 | s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-DCOCOA_PODS' }
18 |
19 | s.subspec 'Core' do |ss|
20 | ss.source_files = 'Sources/Subprocess/*.swift'
21 | end
22 |
23 | s.subspec 'Mocks' do |ss|
24 | ss.source_files = 'Sources/SubprocessMocks/*.swift'
25 | ss.dependency 'Subprocess/Core'
26 | end
27 |
28 | s.test_spec 'UnitTests' do |test_spec|
29 | test_spec.source_files = [ 'Tests/UnitTests/*.swift' ]
30 | test_spec.dependency 'Subprocess/Core'
31 | test_spec.dependency 'Subprocess/Mocks'
32 | end
33 |
34 | s.test_spec 'SystemTests' do |test_spec|
35 | test_spec.source_files = [ 'Tests/SystemTests/*.swift' ]
36 | test_spec.dependency 'Subprocess/Core'
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/Subprocess.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Subprocess.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
39 |
40 |
41 |
42 |
44 |
50 |
51 |
52 |
54 |
60 |
61 |
62 |
63 |
64 |
74 |
75 |
81 |
82 |
88 |
89 |
90 |
91 |
93 |
94 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/Subprocess.xcodeproj/xcshareddata/xcschemes/SubprocessMocks.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
44 |
50 |
51 |
57 |
58 |
59 |
60 |
62 |
63 |
66 |
67 |
68 |
--------------------------------------------------------------------------------
/Tests/SystemTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Tests/SystemTests/ShellSystemTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Subprocess
3 |
4 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell")
5 | final class ShellSystemTests: XCTestCase {
6 |
7 | override func setUp() {
8 | SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder()
9 | }
10 |
11 | let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist"
12 |
13 | // MARK: Input values
14 |
15 | func testExecWithDataInput() {
16 | // Given
17 | let inputText = "This is a text\nabc123\nHello World"
18 | var result: String?
19 |
20 | // When
21 | XCTAssertNoThrow(result = try Shell(["/usr/bin/grep", "abc123"]).exec(input: .text(inputText), encoding: .utf8))
22 |
23 | // Then
24 | XCTAssertFalse(result?.isEmpty ?? true)
25 | }
26 |
27 | func testExecWithFileInput() {
28 | // Given
29 | let cmd = ["/usr/bin/grep", "ProductVersion"]
30 | var result: String?
31 |
32 | // When
33 | XCTAssertNoThrow(result = try Shell(cmd).exec(input: .file(path: softwareVersionFilePath), encoding: .utf8))
34 |
35 | // Then
36 | XCTAssertFalse(result?.isEmpty ?? true)
37 | }
38 |
39 | // MARK: String transform
40 |
41 | func testExecReturningBoolFromString() {
42 | // Given
43 | var result = false
44 | let username = NSUserName()
45 |
46 | // When
47 | XCTAssertNoThrow(result = try Shell(["/usr/bin/dscl", ".", "list", "/Users"]).exec(encoding: .utf8,
48 | transformBlock: { _, txt in
49 | return txt.contains(username)
50 | }))
51 |
52 | // Then
53 | XCTAssertTrue(result)
54 | }
55 |
56 | // MARK: String
57 |
58 | func testExecReturningString() {
59 | // Given
60 | var result: String?
61 | let username = NSUserName()
62 |
63 | // When
64 | XCTAssertNoThrow(result = try Shell(["/usr/bin/dscl", ".", "list", "/Users"]).exec(encoding: .utf8))
65 |
66 | // Then
67 | XCTAssertTrue(result?.contains(username) ?? false)
68 | }
69 |
70 | // MARK: JSON object
71 |
72 | func testExecReturningJSONObject() {
73 | // Given
74 | var result: [[String: Any]]?
75 |
76 | // When
77 | XCTAssertNoThrow(result = try Shell(["/usr/bin/log", "show", "--style", "json", "--last", "1m"]).execJSON())
78 |
79 | // Then
80 | XCTAssertFalse(result?.isEmpty ?? true)
81 | }
82 |
83 | // MARK: Property list object
84 |
85 | func testExecReturningPropertyList() {
86 | // Given
87 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString
88 | var result: [String: Any]?
89 |
90 | // When
91 | XCTAssertNoThrow(result = try Shell(["/bin/cat", softwareVersionFilePath]).execPropertyList())
92 |
93 | // Then
94 | guard let versionNumber = result?["ProductVersion"] as? String else { return XCTFail("Unable to find version") }
95 | XCTAssertTrue(fullVersionString.contains(versionNumber))
96 | }
97 |
98 | // MARK: Decodable object from JSON
99 |
100 | // swiftlint:disable nesting
101 |
102 | func testExecReturningDecodableObjectFromJSON() {
103 | // Given
104 | struct LogMessage: Codable {
105 | var subsystem: String
106 | var category: String
107 | var machTimestamp: UInt64
108 | }
109 | var result: [LogMessage]?
110 | let cmd = ["/usr/bin/log", "show", "--style", "json", "--last", "1m"]
111 | // When
112 | XCTAssertNoThrow(result = try Shell(cmd).exec(decoder: JSONDecoder()))
113 |
114 | // Then
115 | XCTAssertFalse(result?.isEmpty ?? true)
116 | }
117 |
118 | // MARK: Decodable object from property list
119 |
120 | func testExecReturningDecodableObjectFromPropertyList() {
121 | struct SystemVersion: Codable {
122 | enum CodingKeys: String, CodingKey {
123 | case version = "ProductVersion"
124 | }
125 | var version: String
126 | }
127 |
128 | // Given
129 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString
130 | var result: SystemVersion?
131 |
132 | // When
133 | XCTAssertNoThrow(result = try Shell(["/bin/cat", softwareVersionFilePath]).exec(decoder: PropertyListDecoder()))
134 |
135 | // Then
136 | guard let versionNumber = result?.version else { return XCTFail("Result is nil") }
137 | XCTAssertTrue(fullVersionString.contains(versionNumber))
138 | }
139 | // swiftlint:enable nesting
140 | }
141 |
--------------------------------------------------------------------------------
/Tests/SystemTests/SubprocessSystemTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Subprocess
3 |
4 | final class SubprocessSystemTests: XCTestCase {
5 | let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist"
6 |
7 | override func setUp() {
8 | SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder()
9 | }
10 |
11 | @available(macOS 12.0, *)
12 | func testRunWithOutput() async throws {
13 | let result = try await Subprocess(["/bin/cat", softwareVersionFilePath]).run().standardOutput.lines.first(where: { $0.contains("ProductName") }) != nil
14 |
15 | XCTAssertTrue(result)
16 | }
17 |
18 | @available(macOS 12.0, *)
19 | func testRunWithStandardOutput() async throws {
20 | let result = try await Subprocess(["/bin/cat", softwareVersionFilePath]).run(options: .standardOutput).standardOutput.lines.first(where: { $0.contains("ProductName") }) != nil
21 |
22 | XCTAssertTrue(result)
23 | }
24 |
25 | @available(macOS 12.0, *)
26 | func testRunWithStandardError() async throws {
27 | let result = try await Subprocess(["/bin/cat", "/non/existent/path/file.txt"]).run(options: .standardError).standardError.lines.first(where: { $0.contains("No such file or directory") }) != nil
28 |
29 | XCTAssertTrue(result)
30 | }
31 |
32 | func testRunWithCombinedOutput() async throws {
33 | let process = Subprocess(["/bin/cat", softwareVersionFilePath])
34 | let (standardOutput, standardError, waitForExit) = try process.run()
35 | async let (stdout, stderr) = (standardOutput, standardError)
36 | let combinedOutput = await [stdout.string(), stderr.string()]
37 |
38 | await waitForExit()
39 | XCTAssertTrue(combinedOutput[0].contains("ProductName"))
40 | }
41 |
42 | @available(macOS 12.0, *)
43 | func testInteractiveRun() async throws {
44 | var input: AsyncStream.Continuation!
45 | let stream: AsyncStream = AsyncStream { continuation in
46 | input = continuation
47 | }
48 | let subprocess = Subprocess(["/bin/cat"])
49 | let (standardOutput, _, _) = try subprocess.run(standardInput: stream)
50 |
51 | input.yield("hello\n")
52 |
53 | for await line in standardOutput.lines {
54 | XCTAssertEqual("hello", line)
55 | break
56 | }
57 |
58 | input.yield("world\n")
59 | input.finish()
60 |
61 | for await line in standardOutput.lines {
62 | XCTAssertEqual("world", line)
63 | break
64 | }
65 | }
66 |
67 | @available(macOS 12.0, *)
68 | func testInteractiveAsyncRun() throws {
69 | let exp = expectation(description: "\(#file):\(#line)")
70 | let (stream, input) = {
71 | var input: AsyncStream.Continuation!
72 | let stream: AsyncStream = AsyncStream { continuation in
73 | input = continuation
74 | }
75 |
76 | return (stream, input!)
77 | }()
78 |
79 | let subprocess = Subprocess(["/bin/cat"])
80 | let (standardOutput, _, _) = try subprocess.run(standardInput: stream)
81 |
82 | input.yield("hello\n")
83 |
84 | Task {
85 | for await line in standardOutput.lines {
86 | switch line {
87 | case "hello":
88 | Task {
89 | _ = input.yield("world\n")
90 | }
91 | case "world":
92 | input.yield("and\nuniverse")
93 | input.finish()
94 | case "universe":
95 | break
96 | default:
97 | continue
98 | }
99 | }
100 |
101 | exp.fulfill()
102 | }
103 |
104 | wait(for: [exp])
105 | }
106 |
107 | func testData() async throws {
108 | let data = try await Subprocess.data(for: ["/bin/cat", softwareVersionFilePath])
109 |
110 | XCTAssert(!data.isEmpty)
111 | }
112 |
113 | func testDataWithInput() async throws {
114 | let data = try await Subprocess.data(for: ["/bin/cat"], standardInput: Data("hello".utf8))
115 |
116 | XCTAssertEqual(String(decoding: data, as: UTF8.self), "hello")
117 | }
118 |
119 | @available(macOS 13.0, *)
120 | func testDataCancel() async throws {
121 | let exp = expectation(description: "\(#file):\(#line)")
122 | let task = Task {
123 | do {
124 | _ = try await Subprocess.data(for: ["/bin/cat"], standardInput: URL(filePath: "/dev/random"))
125 |
126 | XCTFail("expected task to be canceled")
127 | } catch {
128 | exp.fulfill()
129 | }
130 | }
131 |
132 | try await Task.sleep(nanoseconds: 1_000_000_000)
133 | task.cancel()
134 | await fulfillment(of: [exp])
135 | }
136 |
137 | func testDataCancelWithoutInput() async throws {
138 | let exp = expectation(description: "\(#file):\(#line)")
139 | let task = Task {
140 | do {
141 | _ = try await Subprocess.data(for: ["/bin/cat", "/dev/random"])
142 |
143 | XCTFail("expected task to be canceled")
144 | } catch {
145 | exp.fulfill()
146 | }
147 | }
148 |
149 | try await Task.sleep(nanoseconds: 1_000_000_000)
150 | task.cancel()
151 | await fulfillment(of: [exp])
152 | }
153 |
154 | func testString() async throws {
155 | let username = NSUserName()
156 | let result = try await Subprocess.string(for: ["/usr/bin/dscl", ".", "list", "/Users"])
157 |
158 | XCTAssertTrue(result.contains(username))
159 | }
160 |
161 | func testStringWithStringInput() async throws {
162 | let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: "hello")
163 |
164 | XCTAssertEqual("hello", result)
165 | }
166 |
167 | @available(macOS 13.0, *)
168 | func testStringWithFileInput() async throws {
169 | let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: URL(filePath: softwareVersionFilePath))
170 |
171 | XCTAssertEqual(try String(contentsOf: URL(filePath: softwareVersionFilePath)), result)
172 | }
173 |
174 | func testReturningJSON() async throws {
175 | struct LogMessage: Codable {
176 | var subsystem: String
177 | var category: String
178 | var machTimestamp: UInt64
179 | }
180 |
181 | let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder())
182 |
183 | XCTAssertTrue(!result.isEmpty)
184 | }
185 |
186 | func testReturningPropertyList() async throws {
187 | struct SystemVersion: Codable {
188 | enum CodingKeys: String, CodingKey {
189 | case version = "ProductVersion"
190 | }
191 | var version: String
192 | }
193 |
194 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString
195 | let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", softwareVersionFilePath], decoder: PropertyListDecoder())
196 | let versionNumber = result.version
197 |
198 | XCTAssertTrue(fullVersionString.contains(versionNumber))
199 | }
200 |
201 | func testNonZeroExit() async {
202 | do {
203 | _ = try await Subprocess.string(for: ["/bin/cat", "/non/existent/path/file.txt"])
204 | XCTFail("expected failure")
205 | } catch Subprocess.Error.nonZeroExit {
206 | // expected
207 | } catch {
208 | XCTFail("unexpected error: \(error)")
209 | }
210 | }
211 | }
212 |
--------------------------------------------------------------------------------
/Tests/UnitTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/Tests/UnitTests/ShellTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Subprocess
3 | #if !COCOA_PODS
4 | @testable import SubprocessMocks
5 | #endif
6 |
7 | struct TestCodableObject: Codable, Equatable {
8 | let uuid: UUID
9 | init() { uuid = UUID() }
10 | }
11 |
12 | // swiftlint:disable control_statement duplicated_key_in_dictionary_literal
13 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell")
14 | final class ShellTests: XCTestCase {
15 |
16 | let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ]
17 |
18 | override func setUp() {
19 | // This is only needed for SwiftPM since it runs all of the test suites as a single test run
20 | SubprocessDependencyBuilder.shared = MockSubprocessDependencyBuilder.shared
21 | }
22 |
23 | override func tearDown() {
24 | Shell.reset()
25 | }
26 |
27 | // MARK: Data
28 |
29 | func testExecReturningDataWhenExitCodeIsNoneZero() {
30 | // Given
31 | let exitCode = Int32.random(in: 1...Int32.max)
32 | let stdoutData = "stdout example".data(using: .utf8)
33 | let stderrData = "stderr example".data(using: .utf8)
34 | Shell.expect(command, input: nil, standardOutput: stdoutData, standardError: stderrData, exitCode: exitCode)
35 |
36 | // When
37 | XCTAssertThrowsError(_ = try Shell(command).exec()) { error in
38 | switch (error as? SubprocessError) {
39 | case .exitedWithNonZeroStatus(let status, let errorMessage):
40 | XCTAssertEqual(status, exitCode)
41 | let failMsg = "error message should have contained the results from only stdout but was \(errorMessage)"
42 | XCTAssertTrue(errorMessage.contains("stdout example"), failMsg)
43 | XCTAssertEqual(errorMessage, error.localizedDescription, "should have also set localizedDescription")
44 | let nsError = error as NSError
45 | XCTAssertEqual(Int(status), nsError.code, "should have also set the NSError exit code")
46 | default: XCTFail("Unexpected error type: \(error)")
47 | }
48 | }
49 |
50 | // Then
51 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
52 | }
53 |
54 | func testExecReturningDataFromStandardOutput() {
55 | // Given
56 | var result: Data?
57 | let expected = Data([ UInt8.random(in: 0...UInt8.max),
58 | UInt8.random(in: 0...UInt8.max),
59 | UInt8.random(in: 0...UInt8.max) ])
60 | let errorData = Data([ UInt8.random(in: 0...UInt8.max) ])
61 | Shell.expect(command, input: nil, standardOutput: expected, standardError: errorData)
62 |
63 | // When
64 | XCTAssertNoThrow(result = try Shell(command).exec())
65 |
66 | // Then
67 | XCTAssertEqual(expected, result)
68 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
69 | }
70 |
71 | func testExecReturningDataFromStandardError() {
72 | // Given
73 | var result: Data?
74 | let expected = Data([ UInt8.random(in: 0...UInt8.max),
75 | UInt8.random(in: 0...UInt8.max),
76 | UInt8.random(in: 0...UInt8.max) ])
77 | let stdOutData = Data([ UInt8.random(in: 0...UInt8.max) ])
78 | Shell.expect(command, input: nil, standardOutput: stdOutData, standardError: expected)
79 |
80 | // When
81 | XCTAssertNoThrow(result = try Shell(command).exec(options: .stderr))
82 |
83 | // Then
84 | XCTAssertEqual(expected, result)
85 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
86 | }
87 |
88 | func testExecReturningDataFromBothOutputs() {
89 | // Given
90 | var result = Data()
91 | let expectedStdout = UUID().uuidString
92 | let expectedStderr = UUID().uuidString
93 | Shell.expect(command,
94 | input: nil,
95 | standardOutput: expectedStdout.data(using: .utf8) ?? Data(),
96 | standardError: expectedStderr.data(using: .utf8) ?? Data())
97 |
98 | // When
99 | XCTAssertNoThrow(result = try Shell(command).exec(options: .combined))
100 |
101 | // Then
102 | let text = String(data: result, encoding: .utf8)
103 | XCTAssertEqual("\(expectedStdout)\(expectedStderr)", text, "should have combined stdout and stderror")
104 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
105 | }
106 |
107 | // MARK: String
108 |
109 | func testExecReturningStringWhenExitCodeIsNoneZero() {
110 | // Given
111 | let exitCode = Int32.random(in: 1...Int32.max)
112 | let stdoutText = "should not show up"
113 | let stderrText = "should show up"
114 | Shell.expect(command, input: nil, stdout: stdoutText, stderr: stderrText, exitCode: exitCode)
115 | // When
116 | XCTAssertThrowsError(_ = try Shell(command).exec(options: .stderr, encoding: .utf8)) { error in
117 | switch (error as? SubprocessError) {
118 | case .exitedWithNonZeroStatus(let status, let errorMessage):
119 | XCTAssertEqual(status, exitCode)
120 | let failMsg = "should have put just stderr in the error: \(errorMessage)"
121 | XCTAssertEqual("should show up", errorMessage, failMsg)
122 | XCTAssertEqual(errorMessage, error.localizedDescription, "also should have set localizedDescription")
123 | default: XCTFail("Unexpected error type: \(error)")
124 | }
125 | }
126 |
127 | // Then
128 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
129 | }
130 |
131 | func testExecReturningStringWhenOutputEncodingErrorOccurs() {
132 | // Given
133 | let invalidData = Data([ 0xFF, 0xFF, 0xFF, 0xFF ])
134 | Shell.expect(command, input: nil, standardOutput: invalidData)
135 |
136 | // When
137 | XCTAssertThrowsError(_ = try Shell(command).exec(encoding: .utf8)) {
138 | switch ($0 as? SubprocessError) {
139 | case .outputStringEncodingError: break
140 | default: XCTFail("Unexpected error type: \($0)")
141 | }
142 | }
143 |
144 | // Then
145 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
146 | }
147 |
148 | func testExecReturningStringFromStandardOutput() {
149 | // Given
150 | var result: String?
151 | let expected = UUID().uuidString
152 | Shell.expect(command, input: nil, stdout: expected, stderr: UUID().uuidString)
153 |
154 | // When
155 | XCTAssertNoThrow(result = try Shell(command).exec(encoding: .utf8))
156 |
157 | // Then
158 | XCTAssertEqual(expected, result)
159 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
160 | }
161 |
162 | func testExecReturningStringFromStandardError() {
163 | // Given
164 | var result: String?
165 | let expected = UUID().uuidString
166 | Shell.expect(command, input: nil, stdout: UUID().uuidString, stderr: expected)
167 |
168 | // When
169 | XCTAssertNoThrow(result = try Shell(command).exec(options: .stderr, encoding: .utf8))
170 |
171 | // Then
172 | XCTAssertEqual(expected, result)
173 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
174 | }
175 |
176 | func testExecReturningStringFromBothOutputs() {
177 | // Given
178 | var result: String?
179 | let expectedStdout = UUID().uuidString
180 | let expectedStderr = UUID().uuidString
181 | Shell.expect(command, input: nil, stdout: expectedStdout, stderr: expectedStderr)
182 |
183 | // When
184 | XCTAssertNoThrow(result = try Shell(command).exec(options: .combined, encoding: .utf8))
185 |
186 | // Then
187 | XCTAssertTrue(result?.contains(expectedStdout) ?? false)
188 | XCTAssertTrue(result?.contains(expectedStderr) ?? false)
189 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
190 | }
191 |
192 | // MARK: JSON object
193 |
194 | func testExecReturningJSONArray() {
195 | // Given
196 | var result: [String]?
197 | let expected: [String] = [
198 | UUID().uuidString,
199 | UUID().uuidString
200 | ]
201 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected))
202 |
203 | // When
204 | XCTAssertNoThrow(result = try Shell(command).execJSON())
205 |
206 | // Then
207 | XCTAssertEqual(expected, result)
208 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
209 | }
210 |
211 | func testExecReturningJSONDictionary() {
212 | // Given
213 | var result: [String: [String: String]]?
214 | let expected: [String: [String: String]] = [
215 | UUID().uuidString: [
216 | UUID().uuidString: UUID().uuidString
217 | ],
218 | UUID().uuidString: [
219 | UUID().uuidString: UUID().uuidString
220 | ]
221 | ]
222 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected))
223 |
224 | // When
225 | XCTAssertNoThrow(result = try Shell(command).execJSON())
226 |
227 | // Then
228 | XCTAssertEqual(expected, result)
229 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
230 | }
231 |
232 | func testExecReturningJSONWithInvalidCast() {
233 | // Given
234 | var result: [String]?
235 | let expected: [String: [String: String]] = [
236 | UUID().uuidString: [
237 | UUID().uuidString: UUID().uuidString
238 | ],
239 | UUID().uuidString: [
240 | UUID().uuidString: UUID().uuidString
241 | ]
242 | ]
243 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected))
244 |
245 | // When
246 | XCTAssertThrowsError(result = try Shell(command).execJSON()) {
247 | switch ($0 as? SubprocessError) {
248 | case .unexpectedJSONObject(let type):
249 | XCTAssertEqual(type, "__NSDictionaryI")
250 | default: XCTFail("Unexpected error type: \($0)")
251 | }
252 | }
253 |
254 | // Then
255 | XCTAssertNil(result)
256 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
257 | }
258 |
259 | // MARK: Property list object
260 |
261 | func testExecReturningPropertyListArray() {
262 | // Given
263 | var result: [String]?
264 | let expected: [String] = [
265 | UUID().uuidString,
266 | UUID().uuidString
267 | ]
268 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected))
269 |
270 | // When
271 | XCTAssertNoThrow(result = try Shell(command).execPropertyList())
272 |
273 | // Then
274 | XCTAssertEqual(expected, result)
275 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
276 | }
277 |
278 | func testExecReturningPropertyListDictionary() {
279 | // Given
280 | var result: [String: [String: String]]?
281 | let expected: [String: [String: String]] = [
282 | UUID().uuidString: [
283 | UUID().uuidString: UUID().uuidString
284 | ],
285 | UUID().uuidString: [
286 | UUID().uuidString: UUID().uuidString
287 | ]
288 | ]
289 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected))
290 |
291 | // When
292 | XCTAssertNoThrow(result = try Shell(command).execPropertyList())
293 |
294 | // Then
295 | XCTAssertEqual(expected, result)
296 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
297 | }
298 |
299 | func testExecReturningPropertyListWithInvalidCast() {
300 | // Given
301 | var result: [String]?
302 | let expected: [String: [String: String]] = [
303 | UUID().uuidString: [
304 | UUID().uuidString: UUID().uuidString
305 | ],
306 | UUID().uuidString: [
307 | UUID().uuidString: UUID().uuidString
308 | ]
309 | ]
310 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected))
311 |
312 | // When
313 | XCTAssertThrowsError(result = try Shell(command).execPropertyList()) {
314 | switch ($0 as? SubprocessError) {
315 | case .unexpectedPropertyListObject(let type):
316 | XCTAssertEqual(type, "__NSDictionaryM")
317 | default: XCTFail("Unexpected error type: \($0)")
318 | }
319 | }
320 |
321 | // Then
322 | XCTAssertNil(result)
323 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
324 | }
325 |
326 | // MARK: Decodable object from JSON
327 |
328 | func testExecReturningDecodableObjectFromJSON() {
329 | // Given
330 | var result: TestCodableObject?
331 | let expectObject: TestCodableObject = TestCodableObject()
332 | XCTAssertNoThrow(try Shell.expect(command, input: nil, jsonObject: expectObject))
333 |
334 | // When
335 | XCTAssertNoThrow(result = try Shell(command).exec(decoder: JSONDecoder()))
336 |
337 | // Then
338 | XCTAssertEqual(expectObject, result)
339 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
340 | }
341 |
342 | // MARK: Decodable object from property list
343 |
344 | func testExecReturningDecodableObjectFromPropertyList() {
345 | // Given
346 | var result: TestCodableObject?
347 | let expectObject: TestCodableObject = TestCodableObject()
348 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plistObject: expectObject))
349 |
350 | // When
351 | XCTAssertNoThrow(result = try Shell(command).exec(decoder: PropertyListDecoder()))
352 |
353 | // Then
354 | XCTAssertEqual(expectObject, result)
355 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
356 | }
357 | }
358 | // swiftlint:enable control_statement duplicated_key_in_dictionary_literal
359 |
--------------------------------------------------------------------------------
/Tests/UnitTests/SubprocessTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Subprocess
3 | #if !COCOA_PODS
4 | @testable import SubprocessMocks
5 | #endif
6 |
7 | // swiftlint:disable duplicated_key_in_dictionary_literal
8 | final class SubprocessTests: XCTestCase {
9 |
10 | let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ]
11 |
12 | override func setUp() {
13 | // This is only needed for SwiftPM since it runs all of the test suites as a single test run
14 | SubprocessDependencyBuilder.shared = MockSubprocessDependencyBuilder.shared
15 | }
16 |
17 | override func tearDown() {
18 | Subprocess.reset()
19 | }
20 |
21 | // MARK: Input
22 |
23 | func testInputData() {
24 | // Given
25 | var pipeOrFileHandle: Any?
26 | let expected = Data([ UInt8.random(in: 0...UInt8.max),
27 | UInt8.random(in: 0...UInt8.max),
28 | UInt8.random(in: 0...UInt8.max) ])
29 |
30 | // When
31 | let input = Input.data(expected)
32 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle())
33 |
34 | // Then
35 | switch input.value {
36 | case .data(let data): XCTAssertEqual(data, expected)
37 | default: XCTFail("Unexpected type")
38 | }
39 | guard let pipe = pipeOrFileHandle as? MockPipe else { return XCTFail("Unable to cast MockPipe") }
40 | XCTAssertEqual(pipe.data, expected)
41 | }
42 |
43 | func testInputText() {
44 | // Given
45 | var pipeOrFileHandle: Any?
46 | let expected = UUID().uuidString
47 |
48 | // When
49 | let input = Input.text(expected)
50 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle())
51 |
52 | // Then
53 | switch input.value {
54 | case .text(let text):
55 | XCTAssertEqual(text, expected)
56 | default: XCTFail("Unexpected type")
57 | }
58 | guard let pipe = pipeOrFileHandle as? MockPipe else { return XCTFail("Unable to cast MockPipe") }
59 | guard let data = pipe.data, let text = String(data: data, encoding: .utf8) else {
60 | return XCTFail("Failed to convert pipe data to string")
61 | }
62 | XCTAssertEqual(text, expected)
63 | }
64 |
65 | func testInputFilePath() {
66 | // Given
67 | var pipeOrFileHandle: Any?
68 | let expected = "/some/fake/path/\(UUID().uuidString)"
69 |
70 | // When
71 | let input = Input.file(path: expected)
72 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle())
73 |
74 | // Then
75 | switch input.value {
76 | case .file(let url): XCTAssertEqual(url.path, expected)
77 | default: XCTFail("Unexpected type")
78 | }
79 | guard let pipe = pipeOrFileHandle as? MockFileHandle else { return XCTFail("Unable to cast MockFileHandle") }
80 | XCTAssertEqual(pipe.url?.path, expected)
81 | }
82 |
83 | func testInputFileURL() {
84 | // Given
85 | var pipeOrFileHandle: Any?
86 | let expected = URL(fileURLWithPath: "/some/fake/path/\(UUID().uuidString)")
87 |
88 | // When
89 | let input = Input.file(url: expected)
90 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle())
91 |
92 | // Then
93 | switch input.value {
94 | case .file(let url): XCTAssertEqual(url, expected)
95 | default: XCTFail("Unexpected type")
96 | }
97 | guard let pipe = pipeOrFileHandle as? MockFileHandle else { return XCTFail("Unable to cast MockFileHandle") }
98 | XCTAssertEqual(pipe.url, expected)
99 | }
100 |
101 | // MARK: PID
102 |
103 | func testGetPID() throws {
104 | // Given
105 | let mockCalled = expectation(description: "Mock setup called")
106 | nonisolated(unsafe) var expectedPID: Int32?
107 | Subprocess.expect(command) { mock in
108 | expectedPID = mock.reference.processIdentifier
109 | mockCalled.fulfill()
110 | }
111 |
112 | // When
113 | let subprocess = Subprocess(command)
114 | _ = try subprocess.run()
115 |
116 | // Then
117 | wait(for: [mockCalled], timeout: 5.0)
118 | XCTAssertEqual(subprocess.pid, expectedPID)
119 | }
120 |
121 | // MARK: launch with termination handler
122 |
123 | func testLaunchWithTerminationHandler() {
124 | // Given
125 | let terminationExpectation = expectation(description: "Termination block called")
126 | let expectedExitCode = Int32.random(in: Int32.min...Int32.max)
127 | let expectedStdout = Data([ UInt8.random(in: 0...UInt8.max),
128 | UInt8.random(in: 0...UInt8.max),
129 | UInt8.random(in: 0...UInt8.max) ])
130 | let expectedStderr = Data([ UInt8.random(in: 0...UInt8.max),
131 | UInt8.random(in: 0...UInt8.max),
132 | UInt8.random(in: 0...UInt8.max) ])
133 | Subprocess.expect(command) { mock in
134 | mock.writeTo(stdout: expectedStdout)
135 | mock.writeTo(stderr: expectedStderr)
136 | mock.exit(withStatus: expectedExitCode, reason: .uncaughtSignal)
137 | }
138 |
139 | // When
140 | let subprocess = Subprocess(command)
141 | XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (process, standardOutput, standardError) in
142 | XCTAssertEqual(standardOutput, expectedStdout)
143 | XCTAssertEqual(standardError, expectedStderr)
144 | XCTAssertEqual(process.terminationReason, .uncaughtSignal)
145 | XCTAssertEqual(process.exitCode, expectedExitCode)
146 | terminationExpectation.fulfill()
147 | }))
148 |
149 | // Then
150 | wait(for: [terminationExpectation], timeout: 5.0)
151 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
152 | }
153 |
154 | func testRunhWithWaitUntilExit() async throws {
155 | // Given
156 | let expectedExitCode = Int32.random(in: Int32.min...Int32.max)
157 | let expectedStdout = Data([ UInt8.random(in: 0...UInt8.max),
158 | UInt8.random(in: 0...UInt8.max),
159 | UInt8.random(in: 0...UInt8.max) ])
160 | let expectedStderr = Data([ UInt8.random(in: 0...UInt8.max),
161 | UInt8.random(in: 0...UInt8.max),
162 | UInt8.random(in: 0...UInt8.max) ])
163 | Subprocess.expect(command) { mock in
164 | mock.writeTo(stdout: expectedStdout)
165 | mock.writeTo(stderr: expectedStderr)
166 | mock.exit(withStatus: expectedExitCode, reason: .uncaughtSignal)
167 | }
168 |
169 | // When
170 | let subprocess = Subprocess(command)
171 | let (standardOutput, standardError, waitUntilExit) = try subprocess.run()
172 | async let (stdout, stderr) = (standardOutput, standardError)
173 | let combinedOutput = await [stdout.data(), stderr.data()]
174 |
175 | await waitUntilExit()
176 |
177 | XCTAssertEqual(combinedOutput[0], expectedStdout)
178 | XCTAssertEqual(combinedOutput[1], expectedStderr)
179 | XCTAssertEqual(subprocess.terminationReason, .uncaughtSignal)
180 | XCTAssertEqual(subprocess.exitCode, expectedExitCode)
181 |
182 | // Then
183 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
184 | }
185 |
186 | // MARK: suspend
187 |
188 | @MainActor func testSuspend() throws {
189 | // Given
190 | let semaphore = DispatchSemaphore(value: 0)
191 | let suspendCalled = expectation(description: "Suspend called")
192 | Subprocess.expect(command) { mock in
193 | mock.reference.stubSuspend = {
194 | suspendCalled.fulfill()
195 | return true
196 | }
197 | semaphore.signal()
198 | }
199 | let subprocess = Subprocess(command)
200 | _ = try subprocess.run()
201 | semaphore.wait()
202 |
203 | // When
204 | XCTAssertTrue(subprocess.suspend())
205 |
206 | // Then
207 | waitForExpectations(timeout: 5.0)
208 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
209 | }
210 |
211 | // MARK: resume
212 |
213 | @MainActor func testResume() throws {
214 | // Given
215 | let semaphore = DispatchSemaphore(value: 0)
216 | let resumeCalled = expectation(description: "Resume called")
217 | Subprocess.expect(command) { mock in
218 | mock.reference.stubResume = {
219 | resumeCalled.fulfill()
220 | return true
221 | }
222 | semaphore.signal()
223 | }
224 | let subprocess = Subprocess(command)
225 | _ = try subprocess.run()
226 | semaphore.wait()
227 |
228 | // When
229 | XCTAssertTrue(subprocess.resume())
230 |
231 | // Then
232 | waitForExpectations(timeout: 5.0)
233 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
234 | }
235 |
236 | // MARK: kill
237 |
238 | @MainActor func testKill() throws {
239 | // Given
240 | let semaphore = DispatchSemaphore(value: 0)
241 | let terminateCalled = expectation(description: "Terminate called")
242 | Subprocess.expect(command) { mock in
243 | mock.reference.stubTerminate = { _ in
244 | terminateCalled.fulfill()
245 | }
246 | semaphore.signal()
247 | }
248 | let subprocess = Subprocess(command)
249 | _ = try subprocess.run()
250 | semaphore.wait()
251 |
252 | // When
253 | XCTAssertTrue(subprocess.isRunning)
254 | subprocess.kill()
255 |
256 | // Then
257 | waitForExpectations(timeout: 5.0)
258 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
259 | }
260 |
261 | func testEnvironmentProperty() {
262 | // Given
263 | let subprocess = Subprocess(["/bin/echo"])
264 | let environmentVariableName = "FOO"
265 | let environmentVariableValue = "BAR"
266 |
267 | // When
268 | subprocess.environment = [environmentVariableName: environmentVariableValue]
269 |
270 | // Then
271 | XCTAssertEqual(subprocess.environment?[environmentVariableName], environmentVariableValue,
272 | "The environment property did not store the value correctly.")
273 | }
274 |
275 | // MARK: Data
276 |
277 | func testReturningDataWhenExitCodeIsNoneZero() async {
278 | // Given
279 | let exitCode = Int32.random(in: 1...Int32.max)
280 | let stdoutData = Data("stdout example".utf8)
281 | let stderrData = Data("stderr example".utf8)
282 | Subprocess.expect(command, standardOutput: stdoutData, standardError: stderrData, exitCode: exitCode)
283 |
284 | // When
285 | do {
286 | _ = try await Subprocess.data(for: command)
287 | } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) {
288 | XCTAssertEqual(status, exitCode)
289 | XCTAssertTrue(stderr.contains("stderr example"))
290 | XCTAssertEqual(stdoutData, stdout)
291 | } catch {
292 | XCTFail("Unexpected error type: \(error)")
293 | }
294 |
295 | // Then
296 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
297 | }
298 |
299 | func testReturningDataFromStandardOutput() async throws {
300 | // Given
301 | let expected = Data([ UInt8.random(in: 0...UInt8.max),
302 | UInt8.random(in: 0...UInt8.max),
303 | UInt8.random(in: 0...UInt8.max) ])
304 | let errorData = Data([ UInt8.random(in: 0...UInt8.max) ])
305 | Subprocess.expect(command, standardOutput: expected, standardError: errorData)
306 |
307 | // When
308 | let result = try await Subprocess.data(for: command)
309 |
310 | // Then
311 | XCTAssertEqual(expected, result)
312 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
313 | }
314 |
315 | func testReturningDataFromStandardError() async throws {
316 | // Given
317 | let expected = Data([ UInt8.random(in: 0...UInt8.max),
318 | UInt8.random(in: 0...UInt8.max),
319 | UInt8.random(in: 0...UInt8.max) ])
320 | let stdOutData = Data([ UInt8.random(in: 0...UInt8.max) ])
321 | Subprocess.expect(command, standardOutput: stdOutData, standardError: expected)
322 |
323 | // When
324 | let result = try await Subprocess.data(for: command, options: .returnStandardError)
325 |
326 | // Then
327 | XCTAssertEqual(expected, result)
328 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
329 | }
330 |
331 | // MARK: String
332 |
333 | func testReturningStringWhenExitCodeIsNoneZero() async throws {
334 | // Given
335 | let exitCode = Int32.random(in: 1...Int32.max)
336 | let stdoutText = "should not show up"
337 | let stderrText = "should show up"
338 | Subprocess.expect(command, standardOutput: stdoutText, standardError: stderrText, exitCode: exitCode)
339 |
340 | // When
341 | do {
342 | _ = try await Subprocess.string(for: command)
343 | } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) {
344 | XCTAssertEqual(status, exitCode)
345 | XCTAssertTrue(stderr.contains("should show up"))
346 | XCTAssertEqual(stdoutText, String(decoding: stdout, as: UTF8.self))
347 | } catch {
348 | XCTFail("Unexpected error type: \(error)")
349 | }
350 |
351 | // Then
352 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
353 | }
354 |
355 | func testReturningStringFromStandardOutput() async throws {
356 | // Given
357 | let expected = UUID().uuidString
358 | Subprocess.expect(command, standardOutput: expected, standardError: UUID().uuidString)
359 |
360 | // When
361 | let result = try await Subprocess.string(for: command)
362 |
363 | // Then
364 | XCTAssertEqual(expected, result)
365 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
366 | }
367 |
368 | func testReturningStringFromStandardError() async throws {
369 | // Given
370 | let expected = UUID().uuidString
371 | Subprocess.expect(command, standardOutput: UUID().uuidString, standardError: expected)
372 |
373 | // When
374 | let result = try await Subprocess.string(for: command, options: .returnStandardError)
375 |
376 | // Then
377 | XCTAssertEqual(expected, result)
378 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
379 | }
380 |
381 | // MARK: JSON object
382 |
383 | func testReturningJSONArray() async throws {
384 | // Given
385 | let expected: [String] = [
386 | UUID().uuidString,
387 | UUID().uuidString
388 | ]
389 |
390 | XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder()))
391 |
392 | // When
393 | let result: [String] = try await Subprocess.value(for: command, decoder: JSONDecoder())
394 |
395 | // Then
396 | XCTAssertEqual(expected, result)
397 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
398 | }
399 |
400 | func testExecReturningJSONDictionary() async throws {
401 | // Given
402 | let expected: [String: [String: String]] = [
403 | UUID().uuidString: [
404 | UUID().uuidString: UUID().uuidString
405 | ],
406 | UUID().uuidString: [
407 | UUID().uuidString: UUID().uuidString
408 | ]
409 | ]
410 | XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder()))
411 |
412 | // When
413 | let result: [String: [String: String]] = try await Subprocess.value(for: command, decoder: JSONDecoder())
414 |
415 | // Then
416 | XCTAssertEqual(expected, result)
417 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) }
418 | }
419 | }
420 | // swiftlint:enable duplicated_key_in_dictionary_literal
421 |
--------------------------------------------------------------------------------