Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 61 additions & 4 deletions Tests/CLIIntegrationTests/AppleDocsCommand.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import Dispatch
import Foundation

#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif

enum AppleDocsCommandError: Error, LocalizedError {
case failed(status: Int32, stderr: String)
case invalidUTF8(stream: String)
case missingExecutable
case readFailed(code: Int32)

var errorDescription: String? {
switch self {
Expand All @@ -13,12 +21,18 @@ enum AppleDocsCommandError: Error, LocalizedError {
return "apple-docs returned invalid UTF-8 on \(stream)."
case .missingExecutable:
return "APPLE_DOCS_EXECUTABLE is not set. Run the tests with make test-integration."
case .readFailed(let code):
return "Could not read apple-docs output: errno \(code)."
}
}
}

func runAppleDocs(_ arguments: [String]) throws -> String {
guard let executablePath = ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"] else {
func runAppleDocs(
_ arguments: [String],
executablePath: String? = ProcessInfo.processInfo.environment["APPLE_DOCS_EXECUTABLE"],
captureStandardError: (String) -> Void = { _ in }
) throws -> String {
guard let executablePath else {
throw AppleDocsCommandError.missingExecutable
}

Expand All @@ -27,22 +41,65 @@ func runAppleDocs(_ arguments: [String]) throws -> String {
let standardError = Pipe()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
var environment = ProcessInfo.processInfo.environment
environment["TELEMETRY_DISABLED"] = "true"
process.environment = environment
process.standardOutput = standardOutput
process.standardError = standardError

try process.run()
let outputData = standardOutput.fileHandleForReading.readDataToEndOfFile()
let errorData = standardError.fileHandleForReading.readDataToEndOfFile()
let errorBuffer = CommandErrorBuffer()
let draining = DispatchGroup()
draining.enter()
// Synchronous callers can occupy every cooperative worker. The pipe reader must not
// depend on that same dispatch pool to unblock them.
Thread.detachNewThread {
let result = Result { try readCommandOutput(standardError.fileHandleForReading) }
errorBuffer.store(result)
draining.leave()
}
let outputResult = Result { try readCommandOutput(standardOutput.fileHandleForReading) }
process.waitUntilExit()
draining.wait()
let outputData = try outputResult.get()
let errorData = try errorBuffer.load().get()

guard let output = String(data: outputData, encoding: .utf8) else {
throw AppleDocsCommandError.invalidUTF8(stream: "standard output")
}
guard let error = String(data: errorData, encoding: .utf8) else {
throw AppleDocsCommandError.invalidUTF8(stream: "standard error")
}
captureStandardError(error)
guard process.terminationStatus == 0 else {
throw AppleDocsCommandError.failed(status: process.terminationStatus, stderr: error)
}
return output
}

// NSLock keeps the integration harness usable on the package's macOS 13 deployment target.
private final class CommandErrorBuffer: @unchecked Sendable {
private let lock = NSLock()
private var result: Result<Data, any Error> = .success(Data())

func store(_ result: Result<Data, any Error>) {
lock.withLock { self.result = result }
}

func load() -> Result<Data, any Error> {
lock.withLock { result }
}
}

private func readCommandOutput(_ handle: FileHandle) throws -> Data {
var result = Data()
var buffer = [UInt8](repeating: 0, count: 16384)
while true {
let count = read(handle.fileDescriptor, &buffer, buffer.count)
if count < 0 && errno == EINTR { continue }
guard count >= 0 else { throw AppleDocsCommandError.readFailed(code: errno) }
if count == 0 { return result }
result.append(contentsOf: buffer.prefix(count))
}
}
59 changes: 59 additions & 0 deletions Tests/CLIIntegrationTests/AppleDocsCommandTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Foundation
import Testing

@Suite("CLI subprocess capture")
struct AppleDocsCommandTests {
@Test("captures stdout and stderr separately and disables telemetry")
func capturesSeparateStreams() throws {
// -- Arrange --
let script = "printf '%s' \"$TELEMETRY_DISABLED\"; printf '%s' diagnostics >&2"
var diagnostics = ""

// -- Act --
let output = try runAppleDocs(
["-c", script], executablePath: "/bin/sh", captureStandardError: { diagnostics = $0 })

// -- Assert --
#expect(output == "true")
#expect(diagnostics == "diagnostics")
}

@Test("drains large stderr while stdout is still open", arguments: 0..<32)
func drainsBothPipes(iteration _: Int) throws {
// -- Arrange --
// The watchdog turns a pipe deadlock into an ordinary subprocess failure.
let script = """
parent=$$
(sleep 10; kill -TERM "$parent") >/dev/null 2>&1 &
watchdog=$!
trap 'kill "$watchdog" 2>/dev/null' EXIT
dd if=/dev/zero bs=1024 count=256 >&2 2>/dev/null
printf complete
"""
var diagnostics = ""

// -- Act --
let output = try runAppleDocs(
["-c", script], executablePath: "/bin/sh", captureStandardError: { diagnostics = $0 })

// -- Assert --
#expect(output == "complete")
#expect(diagnostics.utf8.count == 262_144)
}

@Test("reports nonzero exit status and stderr")
func reportsFailure() throws {
// -- Arrange --
let script = "printf '%s' failure >&2; exit 7"

// -- Act --
do {
_ = try runAppleDocs(["-c", script], executablePath: "/bin/sh")
Issue.record("Expected a subprocess failure")
} catch AppleDocsCommandError.failed(let status, let stderr) {
// -- Assert --
#expect(status == 7)
#expect(stderr == "failure")
}
}
}
Loading