Posted 6/27/2018.
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.
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).
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.
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.