Observing Real-Time Ouput From Shell Commands In A Swift Script

In the last tutorial, we saw how to generate code coverage reports using the new xccov tool. Part of that process is using the xcodebuild command to build a project with code coverage enabled. We ran that command directly in Terminal, but we could also run xcodebuild in a Swift script. The problem is xcodebuild takes time, and we would want to see the output in real-time in Terminal as we run the Swift script. In this tutorial we'll sere how to do that.

Setup

If you followed the last tutorial on xccov, you have already gone through this setup.

On my Mac, I made a new directory called "CoverageScript" at the path "~/Documents/Dev/". I have an open source project that has unit tests at "~/Documents/Dev/CodableKeychain". To generate a code coverage report directly in Terminal, I would run:

xcodebuild -project ~/Documents/Dev/CodableKeychain/CodableKeychain.xcodeproj/ -scheme CodableKeychainMobile -derivedDataPath CodableKeychain/ -destination 'platform=iOS Simulator,OS=11.4,name=iPhone 7' -enableCodeCoverage YES clean build test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO

If you're following along with your own project, you would change the project path (-project) and scheme name (-scheme) to match your project. The xcodebuild command will put all of the derived data into a new directory in the present working directory (~/Documents/Dev/CoverageScript in my case) at the path you passed in for -derivedDataPath ("CodableKeychain" in my case).

Shell Commands From A Swift Script

We're now ready to move xcodebuild into a Swift script. First let's create the script and make it executable. In the directory you created above, run the following:

touch generate-coverage.swift
chmod +x generate-coverage.swift

Next we'll write a function in the script for running shell commands and observing the output in real-time in Terminal. But before we do that, let's review what a basic version looks like that simply executes commands:

struct ShellResult {

    let output: String
    let status: Int32

}

@discardableResult
func shell(_ arguments: String...) -> Int32 {
    let task = Process()
    task.launchPath = "/usr/bin/env"
    task.arguments = arguments
    task.launch()
    task.waitUntilExit()
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8) ?? ""
    return ShellResult(output: output, status: task.terminationStatus)
}

Here we have a struct (ShellResult) that captures the command's output and status. After launching the task, we just read the pipe's output and return it. The issue is that if you execute a shell command with this function and then run the Swift script from Terminal, you won't get output until the task has returned. With xcodebuild, we want to see real-time progress. Let's modify our shell function to do that:

let isVerbose = CommandLine.arguments.contains("--verbose")

@discardableResult
func shell(_ arguments: String...) -> ShellResult {
    let task = Process()
    task.launchPath = "/usr/bin/env"
    task.arguments = arguments
    let pipe = Pipe()
    task.standardOutput = pipe
    let outputHandler = pipe.fileHandleForReading
    outputHandler.waitForDataInBackgroundAndNotify()

    var output = ""
    var dataObserver: NSObjectProtocol!
    let notificationCenter = NotificationCenter.default
    let dataNotificationName = NSNotification.Name.NSFileHandleDataAvailable
    dataObserver = notificationCenter.addObserver(forName: dataNotificationName, object: outputHandler, queue: nil) {  notification in
        let data = outputHandler.availableData
        guard data.count > 0 else {
            notificationCenter.removeObserver(dataObserver)
            return
        }
        if let line = String(data: data, encoding: .utf8) {
            if isVerbose {
                print(line)
            }
            output = output + line + "\n"
        }
        outputHandler.waitForDataInBackgroundAndNotify()
    }

    task.launch()
    task.waitUntilExit()
    return ShellResult(output: output, status: task.terminationStatus)
}

The first thing to notice is that we call waitForDataInBackgroundAndNotify on the NSFileHandle used to read the output. This will send a NSFileHandleDataAvailable notification when new data becomes available from the file handle. You can view the documentation for that function here.

Then we use NotificationCenter to observe the notification as we normally would. In the observation block, we print the output as it comes in. We also keep a running output variable to return in a ShellResult.

Lastly, at the top of the script we check if the --verbose option was passed in from the command line and store it in a Bool. We only print the output in real-time if isVerbose is true.

Xcodebuild

The xcodebuild command takes several arguments specifying how to build an Xcode project (or workspace). Here we'll see how to pass in two arguments, for the project path and scheme, from the command line. The rest will be hard-coded. Below the shell function, add the following:

let projectPath = CommandLine.arguments[1]
guard projectPath.contains("xcodeproj") else {
    print("Error: First argument must be an Xcode project path.")
    exit(0)
}
let scheme = CommandLine.arguments[2]
shell("xcodebuild", "-project", projectPath, "-scheme", scheme, "-derivedDataPath", scheme + "/", "-destination", "platform=iOS Simulator,OS=11.4,name=iPhone 7", "-enableCodeCoverage", "YES", "clean", "build", "test", "CODE_SIGN_IDENTITY=\"\"", "CODE_SIGNING_REQUIRED=NO")

We start by reading the project path, which will be the argument at index 1 (the argument at index 0 is the script command itself). We check that it contains "xcodeproj" as a naive check that our path is in fact an Xcode project path.

Then we read the scheme argument and call shell, passing in the xcodebuild command and all of the arguments it needs.

You should now be able to run ./generate-coverage.swift ~/Documents/Dev/CodableKeychain/CodableKeychain.xcodeproj/ CodableKeychainMobile --verbose in Terminal, replacing the project path and scheme arguments with the values for your project, and observe the output in real-time.

Note that if you wanted you could pass in the other arguments xcodebuild takes from Terminal in order to customize the destination, whether code coverage is enabled, and code signing options. You could also build a workspace by replacing -project with -workspace and providing a workspace path.


In this tutorial we saw how to observe the output of shell commands executed from a Swift script in real-time. This allows us to use existing command line tools in Swift without losing the ability to monitor their progress from Terminal.

The full script is available here.