diff --git a/.swiftpm/configuration/coverage.html.report.args.txt b/.swiftpm/configuration/coverage.html.report.args.txt
new file mode 100644
index 00000000000..bee3082c7db
--- /dev/null
+++ b/.swiftpm/configuration/coverage.html.report.args.txt
@@ -0,0 +1,12 @@
+--tab-size=10
+--coverage-watermark=80,20
+--enable-vtable-value-profiling
+--show-branch-summary
+--show-region-summary
+--show-branches=percent
+--show-mcdc-summary
+--show-expansions
+--show-instantiations
+--show-regions
+--show-directory-coverage
+--show-line-counts
\ No newline at end of file
diff --git a/Package.swift b/Package.swift
index aa32ba1e384..01562d69f27 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1108,7 +1108,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
// utils/update_checkout/update-checkout-config.json
// They are used to build the official swift toolchain.
.package(url: "https://github.com/swiftlang/swift-syntax.git", branch: relatedDependenciesBranch),
- .package(url: "https://github.com/apple/swift-argument-parser.git", revision: "1.5.1"),
+ .package(url: "https://github.com/apple/swift-argument-parser.git", revision: "1.6.1"),
.package(url: "https://github.com/apple/swift-crypto.git", revision: "3.12.5"),
.package(url: "https://github.com/apple/swift-system.git", revision: "1.5.0"),
.package(url: "https://github.com/apple/swift-collections.git", revision: "1.1.6"),
diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift
index 5c366090224..9d418baa94d 100644
--- a/Sources/Commands/SwiftBuildCommand.swift
+++ b/Sources/Commands/SwiftBuildCommand.swift
@@ -76,9 +76,15 @@ struct BuildCommandOptions: ParsableArguments {
var buildTests: Bool = false
/// Whether to enable code coverage.
- @Flag(name: .customLong("code-coverage"),
- inversion: .prefixedEnableDisable,
- help: "Enable code coverage.")
+ @Flag(
+ name: [
+ .customLong("codecov"),
+ .customLong("code-coverage"),
+ .customLong("coverage"),
+ ],
+ inversion: .prefixedEnableDisable,
+ help: "Enable code coverage.",
+ )
var enableCodeCoverage: Bool = false
/// If the binary output path should be printed.
diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift
index 10c3b280c29..9fe1bd6f5d3 100644
--- a/Sources/Commands/SwiftTestCommand.swift
+++ b/Sources/Commands/SwiftTestCommand.swift
@@ -10,6 +10,7 @@
//
//===----------------------------------------------------------------------===//
+import RegexBuilder
import ArgumentParser
@_spi(SwiftPMInternal)
@@ -173,11 +174,6 @@ struct TestCommandOptions: ParsableArguments {
help: "Lists test methods in specifier format.")
var _deprecated_shouldListTests: Bool = false
- /// If the path of the exported code coverage JSON should be printed.
- @Flag(name: [.customLong("show-codecov-path"), .customLong("show-code-coverage-path"), .customLong("show-coverage-path")],
- help: "Print the path of the exported code coverage JSON file.")
- var shouldPrintCodeCovPath: Bool = false
-
var testCaseSpecifier: TestCaseSpecifier {
if !filter.isEmpty {
return .regex(filter)
@@ -217,11 +213,10 @@ struct TestCommandOptions: ParsableArguments {
@Flag(name: .customLong("testable-imports"), inversion: .prefixedEnableDisable, help: "Enable or disable testable imports. Enabled by default.")
var enableTestableImports: Bool = true
- /// Whether to enable code coverage.
- @Flag(name: .customLong("code-coverage"),
- inversion: .prefixedEnableDisable,
- help: "Enable code coverage.")
- var enableCodeCoverage: Bool = false
+ @OptionGroup(
+ title: "Coverage Options",
+ )
+ var coverageOptions: CoverageOptions
/// Configure test output.
@Option(help: ArgumentHelp("", visibility: .hidden))
@@ -232,6 +227,95 @@ struct TestCommandOptions: ParsableArguments {
}
}
+
+package enum CoverageFormat: String, ExpressibleByArgument, CaseIterable, Comparable {
+ case json
+ case html
+
+ package var defaultValueDescription: String {
+ switch self {
+ case .json: "Produces a JSON coverage report."
+ case .html: "Produces an HTML report producd by llvm-cov. The HTML report can be configured using a reponse file stored in the project repository. See 'TODO' for more."
+ }
+ }
+
+ package static func < (lhs: CoverageFormat, rhs: CoverageFormat) -> Bool {
+ return lhs.rawValue < rhs.rawValue
+ }
+}
+
+extension CoverageFormat: Encodable {}
+
+package enum CoveragePrintPathMode: String, ExpressibleByArgument, CaseIterable {
+ case json
+ case text
+
+ package var defaultValueDescription: String {
+ switch self {
+ case .json: "Display the output in JSON format."
+ case .text: "Display the output as plain text."
+ }
+ }
+
+}
+
+
+
+public struct CoverageOptions: ParsableArguments {
+ public init() {}
+
+ /// If the path of the exported code coverage JSON should be printed.
+ @Flag(
+ name: [
+ .customLong("show-codecov-path"),
+ .customLong("show-code-coverage-path"),
+ .customLong("show-coverage-path"),
+ ],
+ help: "Print the path of the exported code coverage files.",
+ )
+ var shouldPrintPath: Bool = false
+
+ /// If the path of the exported code coverage JSON should be printed.
+ @Option(
+ name: [
+ .customLong("show-codecov-path-mode"),
+ .customLong("show-code-coverage-path-mode"),
+ .customLong("show-coverage-path-mode"),
+ ],
+ help: ArgumentHelp(
+ "How to display the paths of the selected code coverage file formats.",
+ )
+ )
+ var printPathMode: CoveragePrintPathMode = .text
+
+ /// Whether to enable code coverage.
+ @Flag(
+ name: [
+ .customLong("codecov"),
+ .customLong("code-coverage"),
+ .customLong("coverage"),
+ ],
+ inversion: .prefixedEnableDisable,
+ help: "Enable code coverage.",
+ )
+ var isEnabled: Bool = false
+
+ @Option(
+ name: [
+ .customLong("codecov-format"),
+ .customLong("code-coverage-format"),
+ .customLong("coverage-format"),
+ ],
+ help: ArgumentHelp(
+ "Format of the code coverage output. Can be specified multiple times.",
+ valueName: "format",
+ )
+ )
+ var formats: [CoverageFormat] = [.json]
+
+}
+
+
/// Tests filtering specifier, which is used to filter tests to run.
public enum TestCaseSpecifier {
/// No filtering
@@ -259,6 +343,163 @@ public enum TestOutput: String, ExpressibleByArgument {
case experimentalParseable
}
+package func getOutputDir(
+ from file: AbsolutePath,
+ fileSystem: FileSystem = localFileSystem,
+ workspacePath: AbsolutePath,
+) throws-> AbsolutePath? {
+ guard fileSystem.exists(file) else {
+ return nil
+ }
+ return try getOutputDir(from: try fileSystem.readFileContents(file), workspacePath: workspacePath)
+}
+
+package func getOutputDir(
+ from content: String,
+ workspacePath: AbsolutePath,
+) throws -> AbsolutePath? {
+ var returnValue : AbsolutePath? = nil
+ let commandArg = "--output-dir"
+ let lines = content.split(whereSeparator: \.isNewline)
+
+ let outputDir = Reference(String.self)
+ let outputDirRegex = Regex {
+ Optionally {
+ ZeroOrMore(.any, .reluctant)
+ OneOrMore(.whitespace)
+ }
+ "--output-dir"
+ ChoiceOf {
+ "="
+ OneOrMore(.whitespace)
+ }
+ Capture(as: outputDir) {
+ OneOrMore(.any)
+ } transform: {
+ "\($0)"
+ }
+ }
+
+ func convertStringToAbsolutePath(_ string: String) throws -> AbsolutePath {
+ let path: AbsolutePath
+ do {
+ // Need to check if `value` is an absolute or relative path
+ path = try AbsolutePath(validating: string)
+ } catch {
+ // Value must be a relative path
+ path = try workspacePath.appending(RelativePath(validating: string))
+ }
+ return path
+ }
+
+ // Loop on the contents.
+ for (index, line) in lines.enumerated() {
+ if !line.contains(commandArg) {
+ continue
+ }
+
+ if line == commandArg || line.hasSuffix(" \(commandArg)") {
+ // The argument value is on the next line
+ let value = "\(lines[index + 1])"
+ returnValue = try convertStringToAbsolutePath(value)
+ continue
+ }
+
+ // Let's parse via regular expression
+ if let match = line.wholeMatch(of: outputDirRegex) {
+ let (_, outputDir) = match.output
+ returnValue = try convertStringToAbsolutePath(outputDir)
+ }
+ }
+
+ return returnValue
+}
+package struct CoverageFormatOutput: Encodable {
+ private var _underlying: [CoverageFormat : AbsolutePath]
+
+ package init() {
+ self._underlying = [CoverageFormat : AbsolutePath]()
+ }
+
+ package init(data: [CoverageFormat : AbsolutePath]) {
+ self._underlying = data
+ }
+
+ // Custom encoding to ensure the dictionary is encoded as a JSON object, not an array
+ public func encode(to encoder: Encoder) throws {
+ // Use keyed container to encode each format and its path
+ // This will create proper JSON objects and proper plain text "key: value" format
+ var container = encoder.container(keyedBy: DynamicCodingKey.self)
+
+ // Sort entries for consistent output
+ let sortedEntries = _underlying.sorted { $0.key.rawValue < $1.key.rawValue }
+
+ for (format, path) in sortedEntries {
+ let key = DynamicCodingKey(stringValue: format.rawValue)!
+ try container.encode(path.pathString, forKey: key)
+ }
+ }
+
+ // Dynamic coding keys for the formats
+ private struct DynamicCodingKey: CodingKey {
+ var stringValue: String
+ var intValue: Int? { nil }
+
+ init?(stringValue: String) {
+ self.stringValue = stringValue
+ }
+
+ init?(intValue: Int) {
+ return nil
+ }
+ }
+
+ /// Adds a key/value pair to the underlying dictionary.
+ /// - Parameters:
+ /// - format: The coverage format key
+ /// - path: The absolute path value
+ /// - Throws: `StringError` if the key already exists
+ package mutating func addFormat(_ format: CoverageFormat, path: AbsolutePath) throws {
+ guard !_underlying.keys.contains(format) else {
+ throw StringError("Coverage format '\(format.rawValue)' already exists")
+ }
+ _underlying[format] = path
+ }
+
+ /// Access paths by format. Returns nil if format doesn't exist.
+ package subscript(format: CoverageFormat) -> AbsolutePath? {
+ return _underlying[format]
+ }
+
+ /// Gets the path for a format, throwing an error if it doesn't exist.
+ /// - Parameter format: The coverage format
+ /// - Returns: The absolute path for the format
+ /// - Throws: `StringError` if the format is not found
+ package func getPath(for format: CoverageFormat) throws -> AbsolutePath {
+ guard let path = _underlying[format] else {
+ throw StringError("Missing coverage format output path for '\(format.rawValue)'")
+ }
+ return path
+ }
+
+ /// Returns all formats currently stored
+ package var formats: [CoverageFormat] {
+ return Array(_underlying.keys).sorted()
+ }
+
+ /// Iterate over format/path pairs
+ package func forEach(_ body: (CoverageFormat, AbsolutePath) throws -> Void) rethrows {
+ try _underlying.forEach(body)
+ }
+
+}
+
+struct CodeCoverageConfiguration {
+ // let outputDirMap: CoverageFormatOutput
+ let outputDir: AbsolutePath
+ let htmlArgumentFile: AbsolutePath
+}
+
/// swift-test tool namespace
public struct SwiftTestCommand: AsyncSwiftCommand {
public static var configuration = CommandConfiguration(
@@ -321,7 +562,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
let testSuites = try TestingSupport.getTestSuites(
in: testProducts,
swiftCommandState: swiftCommandState,
- enableCodeCoverage: options.enableCodeCoverage,
+ enableCodeCoverage: options.coverageOptions.isEnabled,
shouldSkipBuilding: options.sharedOptions.shouldSkipBuilding,
experimentalTestOutput: options.enableExperimentalTestOutput,
sanitizers: globalOptions.build.sanitizers
@@ -412,7 +653,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
let testSuites = try TestingSupport.getTestSuites(
in: testProducts,
swiftCommandState: swiftCommandState,
- enableCodeCoverage: options.enableCodeCoverage,
+ enableCodeCoverage: options.coverageOptions.isEnabled,
shouldSkipBuilding: options.sharedOptions.shouldSkipBuilding,
experimentalTestOutput: options.enableExperimentalTestOutput,
sanitizers: globalOptions.build.sanitizers
@@ -447,6 +688,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
// MARK: - Common implementation
public func run(_ swiftCommandState: SwiftCommandState) async throws {
+ let uniqueCoverageFormats = Array(Set(self.options.coverageOptions.formats)).sorted( by: <)
do {
// Validate commands arguments
try self.validateArguments(swiftCommandState: swiftCommandState)
@@ -455,8 +697,12 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
throw ExitCode.failure
}
- if self.options.shouldPrintCodeCovPath {
- try await printCodeCovPath(swiftCommandState)
+ if self.options.coverageOptions.shouldPrintPath {
+ try await printCodeCovPath(
+ swiftCommandState,
+ formats: uniqueCoverageFormats,
+ printMode: options.coverageOptions.printPathMode,
+ )
} else if self.options._deprecated_shouldListTests {
// backward compatibility 6/2022 for deprecation of flag into a subcommand
let command = try List.parse()
@@ -467,7 +713,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
// Clean out the code coverage directory that may contain stale
// profraw files from a previous run of the code coverage tool.
- if self.options.enableCodeCoverage {
+ if self.options.coverageOptions.isEnabled {
try swiftCommandState.fileSystem.removeFileTree(productsBuildParameters.codeCovPath)
}
@@ -475,8 +721,12 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
// Process code coverage if requested. We do not process it if the test run failed.
// See https://github.com/swiftlang/swift-package-manager/pull/6894 for more info.
- if self.options.enableCodeCoverage, swiftCommandState.executionStatus != .failure {
- try await processCodeCoverage(testProducts, swiftCommandState: swiftCommandState)
+ if self.options.coverageOptions.isEnabled, swiftCommandState.executionStatus != .failure {
+ try await processCodeCoverage(
+ testProducts,
+ swiftCommandState: swiftCommandState,
+ formats: uniqueCoverageFormats,
+ )
}
}
}
@@ -585,8 +835,10 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
/// Processes the code coverage data and emits a json.
private func processCodeCoverage(
_ testProducts: [BuiltTestProduct],
- swiftCommandState: SwiftCommandState
+ swiftCommandState: SwiftCommandState,
+ formats: [CoverageFormat],
) async throws {
+ swiftCommandState.observabilityScope.emit(info: "Processing code coverage data...")
let workspace = try swiftCommandState.getActiveWorkspace()
let root = try swiftCommandState.getWorkspaceRoot()
let rootManifests = try await workspace.loadRootManifests(
@@ -598,50 +850,130 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
}
// Merge all the profraw files to produce a single profdata file.
- try await mergeCodeCovRawDataFiles(swiftCommandState: swiftCommandState)
+ let profData = try await mergeCodeCovRawDataFiles(swiftCommandState: swiftCommandState)
+ var coverageReportData = [CoverageFormat : AbsolutePath]()
+ defer {
+ swiftCommandState.outputStream.send("Code coverage report:\n")
+ for (format, path) in coverageReportData {
+ swiftCommandState.outputStream.send(" - \(format.rawValue.uppercased()): \(path.pathString)\n")
+ }
+ swiftCommandState.outputStream.flush()
+ }
+ for format in formats {
+ switch format {
+ case .json:
+ // let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(options: self.options)
+ for product in testProducts {
+ // Export the codecov data as JSON.
+ let jsonPath = try await self.getCodeCoverageConfiguration(swiftCommandState, format: .json).outputDir
+ coverageReportData[format] = try await exportCodeCovAsJSON(
+ to: jsonPath,
+ testBinary: product.binaryPath,
+ swiftCommandState: swiftCommandState,
+ )
+ }
+ case .html:
+ let toolchain = try swiftCommandState.getHostToolchain()
+ let llvmCov = try toolchain.getLLVMCov()
+
+ // Get all production source files from test targets
+ let buildSystem = try await swiftCommandState.createBuildSystem()
+ let packageGraph = try await buildSystem.getPackageGraph()
+
+ let sourceFiles = try await getProductionSourceFiles(
+ testProducts: testProducts,
+ packageGraph: packageGraph,
+ )
+ let configuration = try await self.getCodeCoverageConfiguration(swiftCommandState, format: .html)
+ for product in testProducts {
+ let coveragaHtmlReportPath = try await generateCoverageReport(
+ llvmCovPath: llvmCov,
+ fromFile: profData,
+ desiredOutputPath: configuration.outputDir,
+ testBinary: product.binaryPath,
+ sourceFiles: sourceFiles,
+ withTitle: rootManifest.displayName,
+ llvmCovShowArgumentFile: configuration.htmlArgumentFile,
+ // .appending("coverage.html.report.args.txt"),
+ observabilityScope: swiftCommandState.observabilityScope,
+ )
+ coverageReportData[format] = coveragaHtmlReportPath.appending("index.html")
+ }
+ }
+ }
+ }
- let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(options: self.options)
- for product in testProducts {
- // Export the codecov data as JSON.
- let jsonPath = productsBuildParameters.codeCovAsJSONPath(packageName: rootManifest.displayName)
- try await exportCodeCovAsJSON(
- to: jsonPath,
- testBinary: product.binaryPath,
- swiftCommandState: swiftCommandState,
- )
+ /// Gets all production source files from test targets and their dependencies.
+ private func getProductionSourceFiles(
+ testProducts: [BuiltTestProduct],
+ packageGraph: ModulesGraph,
+ ) async throws -> [AbsolutePath] {
+ var sourceFiles = Set()
+
+ // Get all modules from root packages that are not test modules
+ // These are the production modules that tests are covering
+ for package in packageGraph.rootPackages {
+ for module in package.modules {
+ // Include all non-test, non-plugin modules from root packages
+ if module.type != .test && module.type != .plugin {
+ sourceFiles.formUnion(module.sources.paths)
+ }
+ }
+ }
+
+ // If no source files found from root packages, fall back to all reachable modules
+ if sourceFiles.isEmpty {
+ for module in packageGraph.reachableModules {
+ if module.type != .test && module.type != .plugin {
+ sourceFiles.formUnion(module.sources.paths)
+ }
+ }
}
+
+ return Array(sourceFiles)
}
/// Merges all profraw profiles in codecoverage directory into default.profdata file.
- private func mergeCodeCovRawDataFiles(swiftCommandState: SwiftCommandState) async throws {
+ private func mergeCodeCovRawDataFiles(
+ swiftCommandState: SwiftCommandState,
+ ) async throws -> AbsolutePath {
// Get the llvm-prof tool.
let llvmProf = try swiftCommandState.getTargetToolchain().getLLVMProf()
// Get the profraw files.
let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(options: self.options)
- let codeCovFiles = try swiftCommandState.fileSystem.getDirectoryContents(productsBuildParameters.codeCovPath)
+ let covPath = productsBuildParameters.codeCovPath
+ let codeCovFiles: [String] = if swiftCommandState.fileSystem.exists(covPath) {
+ try swiftCommandState.fileSystem.getDirectoryContents(covPath)
+ } else {
+ []
+ }
// Construct arguments for invoking the llvm-prof tool.
var args = [llvmProf.pathString, "merge", "-sparse"]
for file in codeCovFiles {
- let filePath = productsBuildParameters.codeCovPath.appending(component: file)
+ let filePath = covPath.appending(component: file)
if filePath.extension == "profraw" {
args.append(filePath.pathString)
}
}
args += ["-o", productsBuildParameters.codeCovDataFile.pathString]
try await AsyncProcess.checkNonZeroExit(arguments: args)
+
+ return productsBuildParameters.codeCovDataFile
}
/// Exports profdata as a JSON file.
- private func exportCodeCovAsJSON(
+ func exportCodeCovAsJSON(
to path: AbsolutePath,
testBinary: AbsolutePath,
swiftCommandState: SwiftCommandState
- ) async throws {
+ ) async throws -> AbsolutePath{
// Export using the llvm-cov tool.
let llvmCov = try swiftCommandState.getTargetToolchain().getLLVMCov()
- let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(options: self.options)
+ let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(
+ options: self.options,
+ )
let archArgs: [String] = if let arch = productsBuildParameters.triple.llvmCovArchArgument {
["--arch", "\(arch)"]
} else {
@@ -661,6 +993,59 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
throw StringError("Unable to export code coverage:\n \(output)")
}
try swiftCommandState.fileSystem.writeFileContents(path, bytes: ByteString(result.output.get()))
+ return path
+ }
+
+ /// Generates a code coverage HTML report.
+ package func generateCoverageReport(
+ llvmCovPath: AbsolutePath,
+ fromFile profData: AbsolutePath,
+ desiredOutputPath outputPath: AbsolutePath,
+ testBinary: AbsolutePath,
+ sourceFiles: [AbsolutePath],
+ withTitle title: String,
+ llvmCovShowArgumentFile: AbsolutePath,
+ observabilityScope: ObservabilityScope,
+ ) async throws -> AbsolutePath {
+ // Generate the HTML report.
+ if localFileSystem.exists(outputPath) {
+ try localFileSystem.removeFileTree(outputPath)
+ } else {
+ try localFileSystem.createDirectory(outputPath, recursive: true)
+ }
+
+ let argumentFile: [String] = if localFileSystem.exists(llvmCovShowArgumentFile) {
+ ["@\(llvmCovShowArgumentFile)"]
+ } else {
+ []
+ }
+
+ var args = [
+ llvmCovPath.pathString,
+ "show",
+ "--project-title=\(title) Coverage Report",
+ "--instr-profile=\(profData.pathString)",
+ "--output-dir=\(outputPath.pathString)",
+ ] + argumentFile + [
+ // ensure we overdie the fomat to HTML as that's what the user specified via
+ // the Swift test command line argument
+ "--format=html",
+ testBinary.pathString,
+ ]
+
+ // Add all the production source files of the test targets
+ args.append(contentsOf: sourceFiles.map { $0.pathString })
+
+ observabilityScope.emit(debug: "Calling \(args.joined(separator: " "))")
+ let result = try await AsyncProcess.popen(arguments: args)
+
+ if result.exitStatus != .terminated(code: 0) {
+ let output = try result.utf8Output() + result.utf8stderrOutput()
+ throw StringError("Unable to generate HTML code coverage report:\n \(output)")
+ }
+
+ // the output put can be updated via the command arg file
+ return outputPath
}
/// Builds the "test" target if enabled in options.
@@ -710,7 +1095,29 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
}
extension SwiftTestCommand {
- func printCodeCovPath(_ swiftCommandState: SwiftCommandState) async throws {
+
+ func getCodeCoverageConfiguration(
+ _ swiftCommandState: SwiftCommandState,
+ format: CoverageFormat
+ ) async throws -> CodeCoverageConfiguration {
+ let htmlArgumentFile = try swiftCommandState.getActiveWorkspace().location.llvmCovShowArgumentFile
+ let outputDir = try await self.getCodeCovPath(
+ swiftCommandState,
+ format: format,
+ argumentFile: htmlArgumentFile,
+ )
+
+ return CodeCoverageConfiguration(
+ outputDir: outputDir,
+ htmlArgumentFile: htmlArgumentFile,
+ )
+ }
+
+ func getCodeCovPath(
+ _ swiftCommandState: SwiftCommandState,
+ format: CoverageFormat,
+ argumentFile: AbsolutePath,
+ ) async throws -> AbsolutePath {
let workspace = try swiftCommandState.getActiveWorkspace()
let root = try swiftCommandState.getWorkspaceRoot()
let rootManifests = try await workspace.loadRootManifests(
@@ -721,7 +1128,63 @@ extension SwiftTestCommand {
throw StringError("invalid manifests at \(root.packages)")
}
let (productsBuildParameters, _) = try swiftCommandState.buildParametersForTest(enableCodeCoverage: true)
- print(productsBuildParameters.codeCovAsJSONPath(packageName: rootManifest.displayName))
+
+ return switch format {
+ case .html:
+ try! getOutputDir(from: argumentFile, workspacePath: self.globalOptions.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory ?? AbsolutePath.root) ?? productsBuildParameters.codeCovAsHTMLPath(packageName: rootManifest.displayName)
+ case .json:
+ productsBuildParameters.codeCovAsJSONPath(packageName: rootManifest.displayName)
+ }
+ }
+
+ package func getCodeCovOutputPaths(
+ _ swiftCommandState: SwiftCommandState,
+ formats: [CoverageFormat],
+ printMode: CoveragePrintPathMode,
+ ) async throws -> String {
+ var coverageData = [CoverageFormat : AbsolutePath]()
+ for format in formats {
+ let config = try await self.getCodeCoverageConfiguration(swiftCommandState, format: format)
+ coverageData[format] = config.outputDir
+ }
+
+ let data: Data
+ switch printMode {
+ case .json:
+ let coverageOutput = CoverageFormatOutput(data: coverageData)
+ let encoder = JSONEncoder.makeWithDefaults()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ data = try encoder.encode(coverageOutput)
+ case .text:
+ // When there's only one format, don't show the key prefix
+ if formats.count == 1, let singlePath = coverageData.values.first {
+ swiftCommandState.observabilityScope.emit(
+ warning: """
+ The contents of this output are subject to change in the future. Use `--print-coverage-path-mode json` if the output is required in a script.
+ """,
+ )
+ data = Data("\(singlePath.pathString)".utf8)
+ } else {
+ let coverageOutput = CoverageFormatOutput(data: coverageData)
+ var encoder = PlainTextEncoder()
+ encoder.formattingOptions = [.prettyPrinted]
+ data = try encoder.encode(coverageOutput)
+ }
+ }
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ package func printCodeCovPath(
+ _ swiftCommandState: SwiftCommandState,
+ formats: [CoverageFormat],
+ printMode: CoveragePrintPathMode,
+ ) async throws {
+ let output = try await self.getCodeCovOutputPaths(
+ swiftCommandState,
+ formats: formats,
+ printMode: printMode,
+ )
+ print(output)
}
}
@@ -1503,7 +1966,7 @@ extension SwiftCommandState {
options: TestCommandOptions
) throws -> (productsBuildParameters: BuildParameters, toolsBuildParameters: BuildParameters) {
try self.buildParametersForTest(
- enableCodeCoverage: options.enableCodeCoverage,
+ enableCodeCoverage: options.coverageOptions.isEnabled,
enableTestability: options.enableTestableImports,
shouldSkipBuilding: options.sharedOptions.shouldSkipBuilding,
experimentalTestOutput: options.enableExperimentalTestOutput
@@ -1552,6 +2015,10 @@ extension BuildParameters {
fileprivate func codeCovAsJSONPath(packageName: String) -> AbsolutePath {
return self.codeCovPath.appending(component: packageName + ".json")
}
+
+ fileprivate func codeCovAsHTMLPath(packageName: String) -> AbsolutePath {
+ return self.codeCovPath.appending(component: "\(packageName)-html")
+ }
}
private extension Basics.Diagnostic {
diff --git a/Sources/Commands/Utilities/PlainTextEncoder.swift b/Sources/Commands/Utilities/PlainTextEncoder.swift
index 00ddc760c9c..6f0e7739696 100644
--- a/Sources/Commands/Utilities/PlainTextEncoder.swift
+++ b/Sources/Commands/Utilities/PlainTextEncoder.swift
@@ -14,31 +14,34 @@ import struct Foundation.Data
import class TSCBasic.BufferedOutputByteStream
import protocol TSCBasic.OutputByteStream
-struct PlainTextEncoder {
+package struct PlainTextEncoder {
/// The formatting of the output plain-text data.
- struct FormattingOptions: OptionSet {
- let rawValue: UInt
+ package struct FormattingOptions: OptionSet {
+ package let rawValue: UInt
- init(rawValue: UInt) {
+ package init(rawValue: UInt) {
self.rawValue = rawValue
}
/// Produce plain-text format with indented output.
- static let prettyPrinted = FormattingOptions(rawValue: 1 << 0)
+ package static let prettyPrinted = FormattingOptions(rawValue: 1 << 0)
}
/// The output format to produce. Defaults to `[]`.
- var formattingOptions: FormattingOptions = []
+ package var formattingOptions: FormattingOptions = []
/// Contextual user-provided information for use during encoding.
- var userInfo: [CodingUserInfoKey: Any] = [:]
+ package var userInfo: [CodingUserInfoKey: Any] = [:]
+
+ /// Initializes a new PlainTextEncoder.
+ package init() {}
/// Encodes the given top-level value and returns its plain text representation.
///
/// - parameter value: The value to encode.
/// - returns: A new `Data` value containing the encoded plan-text data.
/// - throws: An error if any value throws an error during encoding.
- func encode(_ value: T) throws -> Data {
+ package func encode(_ value: T) throws -> Data {
let outputStream = BufferedOutputByteStream()
let encoder = _PlainTextEncoder(
outputStream: outputStream,
diff --git a/Sources/Workspace/Workspace+Configuration.swift b/Sources/Workspace/Workspace+Configuration.swift
index 9fecb0a0888..63ff5a62798 100644
--- a/Sources/Workspace/Workspace+Configuration.swift
+++ b/Sources/Workspace/Workspace+Configuration.swift
@@ -28,6 +28,7 @@ import class TSCUtility.SimplePersistence
extension Workspace {
/// Workspace location configuration
public struct Location {
+ package static let coverageResponseFileName = "coverage.html.report.args.txt"
/// Path to scratch space (working) directory for this workspace (aka .build).
public var scratchDirectory: AbsolutePath
@@ -105,6 +106,11 @@ extension Workspace {
self.sharedConfigurationDirectory.map { DefaultLocations.mirrorsConfigurationFile(at: $0) }
}
+ /// Path to the LLVM Cov Show argument file
+ public var llvmCovShowArgumentFile: AbsolutePath {
+ self.localConfigurationDirectory.appending(Location.coverageResponseFileName)
+ }
+
/// Path to the local registries configuration.
public var localRegistriesConfigurationFile: AbsolutePath {
DefaultLocations.registriesConfigurationFile(at: self.localConfigurationDirectory)
diff --git a/Sources/_InternalTestSupport/BuildSystemProvider+Supported.swift b/Sources/_InternalTestSupport/BuildSystemProvider+Supported.swift
index 069182fd0bf..336503dcf9a 100644
--- a/Sources/_InternalTestSupport/BuildSystemProvider+Supported.swift
+++ b/Sources/_InternalTestSupport/BuildSystemProvider+Supported.swift
@@ -26,6 +26,11 @@ public var SupportedBuildSystemOnPlatform: [BuildSystemProvider.Kind] {
public struct BuildData {
public let buildSystem: BuildSystemProvider.Kind
public let config: BuildConfiguration
+
+ public init(buildSystem: BuildSystemProvider.Kind, config: BuildConfiguration) {
+ self.buildSystem = buildSystem
+ self.config = config
+ }
}
public func getBuildData(for buildSystems: [BuildSystemProvider.Kind]) -> [BuildData] {
diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift
index 1a9a2ffd677..f78dbb43673 100644
--- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift
+++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift
@@ -40,6 +40,7 @@ extension Tag.Feature {
@Tag public static var CodeCoverage: Tag
@Tag public static var CTargets: Tag
@Tag public static var DependencyResolution: Tag
+ @Tag public static var Encoding: Tag
@Tag public static var ModuleAliasing: Tag
@Tag public static var Mirror: Tag
@Tag public static var NetRc: Tag
diff --git a/Tests/CommandsTests/CoverageTests.swift b/Tests/CommandsTests/CoverageTests.swift
index 4872691725e..f6f882f17be 100644
--- a/Tests/CommandsTests/CoverageTests.swift
+++ b/Tests/CommandsTests/CoverageTests.swift
@@ -10,14 +10,19 @@
//
//===----------------------------------------------------------------------===//
-import Foundation
import Commands
+import CoreCommands
+import Foundation
+import RegexBuilder
+import Testing
import _InternalTestSupport
-import var Basics.localFileSystem
+
import struct Basics.AbsolutePath
+import var Basics.localFileSystem
+import func Basics.resolveSymlinks
import enum PackageModel.BuildConfiguration
import struct SPMBuildCore.BuildSystemProvider
-import Testing
+import class TSCBasic.BufferedOutputByteStream
@Suite(
.serializedIfOnWindows,
@@ -29,7 +34,9 @@ import Testing
)
struct CoverageTests {
@Test(
- .SWBINTTODO("Test failed because of missing plugin support in the PIF builder. This can be reinvestigated after the support is there."),
+ .SWBINTTODO(
+ "Test failed because of missing plugin support in the PIF builder. This can be reinvestigated after the support is there."
+ ),
.tags(
.Feature.Command.Build,
.Feature.Command.Test,
@@ -41,7 +48,9 @@ struct CoverageTests {
buildSystem: BuildSystemProvider.Kind,
) async throws {
let config = BuildConfiguration.debug
- try await withKnownIssue(isIntermittent: (ProcessInfo.hostOperatingSystem == .linux && buildSystem == .swiftbuild)) {
+ try await withKnownIssue(
+ isIntermittent: (ProcessInfo.hostOperatingSystem == .linux && buildSystem == .swiftbuild)
+ ) {
try await fixture(name: "Miscellaneous/TestDiscovery/Simple") { path in
_ = try await executeSwiftBuild(
path,
@@ -49,7 +58,7 @@ struct CoverageTests {
extraArgs: ["--build-tests"],
buildSystem: buildSystem,
)
- await #expect(throws: (any Error).self ) {
+ await #expect(throws: (any Error).self) {
try await executeSwiftTest(
path,
configuration: config,
@@ -68,111 +77,487 @@ struct CoverageTests {
}
@Test(
- .SWBINTTODO("Test failed because of missing plugin support in the PIF builder. This can be reinvestigated after the support is there."),
+ .SWBINTTODO(
+ "Test failed because of missing plugin support in the PIF builder. This can be reinvestigated after the support is there."
+ ),
.IssueWindowsCannotSaveAttachment,
.tags(
.Feature.Command.Test,
.Feature.CommandLineArguments.BuildTests,
),
- arguments: SupportedBuildSystemOnAllPlatforms,
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
)
func executingTestsWithCoverageWithCodeBuiltWithCoverageGeneratesCodeCoverage(
- buildSystem: BuildSystemProvider.Kind,
+ buildData: BuildData
) async throws {
- let config = BuildConfiguration.debug
+ let buildSystem = buildData.buildSystem
+ let config = buildData.config
// Test that enabling code coverage during building produces the expected folder.
try await withKnownIssue(isIntermittent: true) {
- try await fixture(name: "Miscellaneous/TestDiscovery/Simple") { path in
- let codeCovPathString = try await executeSwiftTest(
- path,
- configuration: config,
- extraArgs: [
- "--show-coverage-path",
- ],
- throwIfCommandFails: true,
- buildSystem: buildSystem,
- ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
-
- let codeCovPath = try AbsolutePath(validating: codeCovPathString)
-
- // WHEN we build with coverage enabled
- try await withKnownIssue {
- try await executeSwiftBuild(
- path,
- configuration: config,
- extraArgs: ["--build-tests", "--enable-code-coverage"],
- buildSystem: buildSystem,
- )
-
- // AND we test with coverag enabled and skip the build
- try await executeSwiftTest(
+ try await fixture(name: "Miscellaneous/TestDiscovery/Simple") { path in
+ let codeCovPathString = try await executeSwiftTest(
path,
configuration: config,
extraArgs: [
- "--skip-build",
- "--enable-code-coverage",
+ "--show-coverage-path"
],
+ throwIfCommandFails: true,
buildSystem: buildSystem,
- )
+ ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
- // THEN we expect the file to exists
- expectFileExists(at: codeCovPath)
+ let codeCovPath = try AbsolutePath(validating: codeCovPathString)
- // AND the parent directory is non empty
- let codeCovFiles = try localFileSystem.getDirectoryContents(codeCovPath.parentDirectory)
- #expect(codeCovFiles.count > 0)
- } when: {
- ProcessInfo.hostOperatingSystem == .linux && buildSystem == .swiftbuild
+ // WHEN we build with coverage enabled
+ try await withKnownIssue {
+ try await executeSwiftBuild(
+ path,
+ configuration: config,
+ extraArgs: ["--build-tests", "--enable-code-coverage"],
+ buildSystem: buildSystem,
+ )
+
+ // AND we test with coverag enabled and skip the build
+ try await executeSwiftTest(
+ path,
+ configuration: config,
+ extraArgs: [
+ "--skip-build",
+ "--enable-code-coverage",
+ ],
+ buildSystem: buildSystem,
+ )
+
+ // THEN we expect the file to exists
+ expectFileExists(at: codeCovPath)
+
+ // AND the parent directory is non empty
+ let codeCovFiles = try localFileSystem.getDirectoryContents(codeCovPath.parentDirectory)
+ #expect(codeCovFiles.count > 0)
+ } when: {
+ ProcessInfo.hostOperatingSystem == .linux && buildSystem == .swiftbuild
+ }
}
- }
} when: {
- ProcessInfo.hostOperatingSystem == .windows && buildSystem == .swiftbuild
+ (ProcessInfo.hostOperatingSystem == .windows && buildSystem == .swiftbuild)
+ || (
+ // error: failed to load coverage: '/arm64-apple-macosx/Products/Release/SimpleTests.xctest/Contents/MacOS/SimpleTests': `-arch` specifier is invalid or missing for universal binary
+ buildData.buildSystem == .swiftbuild && buildData.config == .release)
+ || (
+ // error: /private/var/folders/9j/994sp90x6y3232rzrl9h_z4w0000gn/T/Miscellaneous_TestDiscovery_Simple.3EzR9a/Miscellaneous_TestDiscovery_Simple/Tests/SimpleTests/SwiftTests.swift:2:18 Unable to find module dependency: 'Simple'
+ // @testable import Simple
+ // ^
+ // error: SwiftDriver SimpleTests normal arm64 com.apple.xcode.tools.swift.compiler failed with a nonzero exit code. Command line: cd /private/var/folders/9j/994sp90x6y3232rzrl9h_z4w0000gn/T/Miscellaneous
+ buildData.buildSystem == .native && buildData.config == .release)
}
}
+ struct GenerateCoverageReportTestData {
+ // let buildData: BuildData
+ let fixtureName: String
+ let coverageFormat: CoverageFormat
+ }
+
@Test(
.tags(
.Feature.Command.Test,
),
- arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), [
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ [
"Coverage/Simple",
"Miscellaneous/TestDiscovery/Simple",
- ],
+ ].flatMap { fixturePath in
+ // getBuildData(for: SupportedBuildSystemOnAllPlatforms).flatMap { buildData in
+ CoverageFormat.allCases.map { format in
+ GenerateCoverageReportTestData(
+ // buildData: buildData,
+ fixtureName: fixturePath,
+ coverageFormat: format,
+ )
+ }
+ // }
+ },
)
- func generateCoverageReport(
+ func generateSingleCoverageReport(
buildData: BuildData,
- fixtureName: String
+ testData: GenerateCoverageReportTestData,
+ ) async throws {
+ let fixtureName = testData.fixtureName
+ let coverageFormat = testData.coverageFormat
+ try await withKnownIssue(isIntermittent: true) {
+ try await fixture(name: fixtureName) { path in
+ let commonCoverageArgs = [
+ "--coverage-format",
+ "\(coverageFormat)",
+ ]
+ let coveragePathString = try await executeSwiftTest(
+ path,
+ configuration: buildData.config,
+ extraArgs: [
+ "--show-coverage-path"
+ ] + commonCoverageArgs,
+ throwIfCommandFails: true,
+ buildSystem: buildData.buildSystem,
+ ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+ let coveragePath = try AbsolutePath(validating: coveragePathString)
+ try #require(!localFileSystem.exists(coveragePath))
+
+ // WHEN we test with coverage enabled
+ try await withKnownIssue {
+ try await executeSwiftTest(
+ path,
+ configuration: buildData.config,
+ extraArgs: [
+ "--enable-code-coverage"
+ ] + commonCoverageArgs,
+ throwIfCommandFails: true,
+ buildSystem: buildData.buildSystem,
+ )
+
+ // THEN we expect the file to exists
+ #expect(localFileSystem.exists(coveragePath))
+ } when: {
+ (buildData.buildSystem == .swiftbuild
+ && [.windows, .linux].contains(ProcessInfo.hostOperatingSystem))
+ }
+ }
+ } when: {
+ // error: failed to load coverage: '/arm64-apple-macosx/Products/Release/SimpleTests.xctest/Contents/MacOS/SimpleTests': `-arch` specifier is invalid or missing for universal binary
+ buildData.buildSystem == .swiftbuild && buildData.config == .release
+ }
+
+ }
+
+ @Test(
+ .tags(
+ .Feature.Command.Test,
+ ),
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ )
+ func generateMultipleCoverageReports(
+ buildData: BuildData
) async throws {
- try await fixture(name: fixtureName) { path in
- let coveragePathString = try await executeSwiftTest(
- path,
- configuration: buildData.config,
- extraArgs: [
+ let configuration = buildData.config
+ let buildSystem = buildData.buildSystem
+ try await fixture(name: "Coverage/Simple") { fixturePath in
+ let commonCoverageArgs = [
+ "--coverage-format",
+ "html",
+ "--coverage-format",
+ "json",
+ ]
+ let coverateLocationJsonString = try await executeSwiftTest(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: commonCoverageArgs + [
"--show-coverage-path",
+ "--show-coverage-path-mode",
+ "json",
+ ],
+ buildSystem: buildSystem
+ ).stdout
+ struct ReportOutput: Codable {
+ let html: AbsolutePath?
+ let json: AbsolutePath?
+ }
+
+ let outputData = try #require(
+ coverateLocationJsonString.data(using: .utf8),
+ "Unable to parse stdout into Data"
+ )
+ let decoder = JSONDecoder()
+ let reportData = try decoder.decode(ReportOutput.self, from: outputData)
+
+ let (_, _) = try await executeSwiftTest(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: commonCoverageArgs + [
+ "--enable-coverage"
],
- throwIfCommandFails: true,
- buildSystem: buildData.buildSystem,
- ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
- let coveragePath = try AbsolutePath(validating: coveragePathString)
- try #require(!localFileSystem.exists(coveragePath))
+ buildSystem: buildSystem
+ )
+
+ // Ensure all paths in the data exists.
+ try withKnownIssue {
+ let html = try #require(reportData.html)
+ #expect(localFileSystem.exists(html))
+ let json = try #require(reportData.json)
+ #expect(localFileSystem.exists(json))
+ } when: {
+ // error: failed to load coverage: '/arm64-apple-macosx/Products/Release/SimpleTests.xctest/Contents/MacOS/SimpleTests': `-arch` specifier is invalid or missing for universal binary
+ (buildSystem == .swiftbuild && configuration == .release)
+ || (
+ ProcessInfo.hostOperatingSystem == .linux
+ && buildSystem == .swiftbuild && configuration == .debug
+ )
+ }
+ }
+ }
+
+ @Suite
+ struct htmlCoverageReportTests {
+ let commonHtmlCoverageArgs = [
+ "--enable-coverage",
+ "--coverage-format",
+ "html",
+ ]
+ let responseFilePathComponents = [
+ ".swiftpm",
+ "configuration",
+ "coverage.html.report.args.txt",
+ ]
+
+ @Test(
+ .tags(
+ .Feature.Command.Test,
+ ),
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ [
+ true, false,
+ ]
+ )
+ func htmlReportOutputDirectoryInResponseFileOverrideTheDefaultLocation(
+ buildData: BuildData,
+ isResponseFileOutputAbsolutePath: Bool,
+ ) async throws {
+ let buildSystem = buildData.buildSystem
+ let configuration = buildData.config
+ try await withKnownIssue(isIntermittent: true) {
+ // Verify the output directory argument specified in the response file override the default location.
+ try await fixture(name: "Coverage/Simple") { fixturePath in
+ let responseFilePath = fixturePath.appending(components: responseFilePathComponents)
+ let responseFileContent: String
+ let expectedOutputPath: String
+ if isResponseFileOutputAbsolutePath {
+ responseFileContent = "--output-dir /foo"
+ expectedOutputPath = AbsolutePath("/foo").pathString
+ } else {
+ responseFileContent = "--output-dir ./foo"
+ expectedOutputPath = fixturePath.appending("foo").pathString
+ }
+
+ try localFileSystem.createDirectory(responseFilePath.parentDirectory, recursive: true)
+ try localFileSystem.writeFileContents(responseFilePath, string: responseFileContent)
+
+ let (stdout, stderr) = try await executeSwiftTest(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: [
+ "--show-coverage-path",
+ "--coverage-format",
+ "html",
+ ],
+ buildSystem: buildSystem,
+ )
+ let actualOutput = stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ #expect(actualOutput == expectedOutputPath, "stderr: \(stderr)")
+ }
+ } when: {
+ // error: failed to load coverage: '/arm64-apple-macosx/Products/Release/SimpleTests.xctest/Contents/MacOS/SimpleTests': `-arch` specifier is invalid or missing for universal binary
+ buildSystem == .swiftbuild && configuration == .release
+ }
+ }
+
+ @Test(
+ .tags(
+ .Feature.Command.Test,
+ ),
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ )
+ func existenceOfResponseFileWithNotOutputDirectorySpecifiedUsedTheDefaultLocation(
+ buildData: BuildData,
+ ) async throws {
+ let buildSystem = buildData.buildSystem
+ let configuration = buildData.config
+ // Verify the output directory argument specified in the response file override the default location.
+ try await fixture(name: "Coverage/Simple") { fixturePath in
+ let responseFilePath = fixturePath.appending(components: responseFilePathComponents)
+ let responseFileContent = "--tab-size=10"
+
+ try localFileSystem.createDirectory(responseFilePath.parentDirectory, recursive: true)
+ try localFileSystem.writeFileContents(responseFilePath, string: responseFileContent)
+
+ let (stdout, _) = try await executeSwiftTest(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: [
+ "--show-coverage-path",
+ "--coverage-format",
+ "html",
+ ],
+ buildSystem: buildSystem,
+ )
+ let actualOutput = stdout.trimmingCharacters(in: .whitespacesAndNewlines)
- // WHEN we test with coverage enabled
+ #expect(actualOutput.contains(fixturePath.pathString))
+ }
+ }
+
+ @Test(
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ )
+ func htmlExistenceOfReportResponseFileHasFileOnTheCommandLine(
+ buildData: BuildData,
+ ) async throws {
+ // Verify the arguments specified in the response file are used.
try await withKnownIssue {
- try await executeSwiftTest(
- path,
+ try await fixture(name: "Coverage/Simple") { fixturePath in
+ let responseFilePath = fixturePath.appending(components: responseFilePathComponents)
+
+ try localFileSystem.writeFileContents(responseFilePath, string: "")
+ expectFileExists(at: responseFilePath)
+ let (_, stderr) = try await executeSwiftTest(
+ fixturePath,
configuration: buildData.config,
extraArgs: [
- "--enable-code-coverage",
- ],
- throwIfCommandFails: true,
+ "--very-verbose" // this emits the coverage commmands
+ ] + commonHtmlCoverageArgs,
+ throwIfCommandFails: false,
buildSystem: buildData.buildSystem,
)
-
- // THEN we expect the file to exists
- #expect(localFileSystem.exists(coveragePath))
+ let responseFileArgument = try Regex("@.*\(responseFilePath)")
+ let contains = stderr.components(separatedBy: .newlines).filter {
+ $0.contains("llvm-cov show") && $0.contains(responseFileArgument) //$0.contains("@\(responseFilePath)")
+ }
+ #expect(contains.count >= 1)
+ }
} when: {
- (buildData.buildSystem == .swiftbuild && [.windows, .linux].contains(ProcessInfo.hostOperatingSystem))
+ ProcessInfo.hostOperatingSystem == .linux && buildData.buildSystem == .swiftbuild // TO Fix before merge
+ }
+ }
+ }
+
+ @Suite
+ struct ShowCoveragePathTests {
+ let commonTestArgs = [
+ "--show-codecov-path"
+ ]
+ struct ShowCoveragePathTestData {
+ let formats: [CoverageFormat]
+ let printMode: CoveragePrintPathMode
+ let expected: String
+ }
+ @Test(
+ arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms),
+ [
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.html],
+ printMode: CoveragePrintPathMode.text,
+ expected: "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html",
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json],
+ printMode: CoveragePrintPathMode.text,
+ expected: "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json",
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.html, .json],
+ printMode: CoveragePrintPathMode.text,
+ expected: """
+ Html: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html
+ Json: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json, .html],
+ printMode: CoveragePrintPathMode.text,
+ expected: """
+ Html: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html
+ Json: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json, .html, .json],
+ printMode: CoveragePrintPathMode.text,
+ expected: """
+ Html: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html
+ Json: $(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json
+ """,
+ ),
+
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.html],
+ printMode: CoveragePrintPathMode.json,
+ expected: """
+ {
+ "html" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html"
+ }
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json],
+ printMode: CoveragePrintPathMode.json,
+ expected: """
+ {
+ "json" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json"
+ }
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.html, .json],
+ printMode: CoveragePrintPathMode.json,
+ expected: """
+ {
+ "html" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html",
+ "json" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json"
+ }
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json, .html],
+ printMode: CoveragePrintPathMode.json,
+ expected: """
+ {
+ "html" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html",
+ "json" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json"
+ }
+ """,
+ ),
+ ShowCoveragePathTestData(
+ formats: [CoverageFormat.json, .html, .json],
+ printMode: CoveragePrintPathMode.json,
+ expected: """
+ {
+ "html" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple-html",
+ "json" : "$(DEFAULT_BUILD_OUTPUT)/codecov/Simple.json"
+ }
+ """,
+ ),
+
+ ]
+ )
+ func specifiedFormatsFormatInTextModeOnlyDisplaysThePath(
+ buildData: BuildData,
+ testData: ShowCoveragePathTestData,
+ ) async throws {
+ let buildSystem = buildData.buildSystem
+ let configuration = buildData.config
+ try await fixture(name: "Coverage/Simple") { fixturePath in
+ let defaultBuildOUtput = try await executeSwiftBuild(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: ["--show-bin-path"],
+
+ buildSystem: buildSystem,
+ ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+ let updatedExpected = testData.expected.replacing(
+ "$(DEFAULT_BUILD_OUTPUT)",
+ with: defaultBuildOUtput
+ )
+
+ let actual = try await executeSwiftTest(
+ fixturePath,
+ configuration: configuration,
+ extraArgs: self.commonTestArgs + [
+ "--show-codecov-path-mode",
+ testData.printMode.rawValue,
+ ] + testData.formats.flatMap({ ["--coverage-format", $0.rawValue] }),
+ buildSystem: buildSystem,
+ ).stdout.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ #expect(actual == updatedExpected)
}
}
}
+
}
diff --git a/Tests/CommandsTests/TestCommandTests+Helpers.swift b/Tests/CommandsTests/TestCommandTests+Helpers.swift
new file mode 100644
index 00000000000..feff640db57
--- /dev/null
+++ b/Tests/CommandsTests/TestCommandTests+Helpers.swift
@@ -0,0 +1,620 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift open source project
+//
+// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors
+// Licensed under Apache License v2.0 with Runtime Library Exception
+//
+// See http://swift.org/LICENSE.txt for license information
+// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
+//
+//===----------------------------------------------------------------------===//
+
+import Foundation
+import Testing
+
+import struct Basics.AbsolutePath
+import struct Basics.RelativePath
+import func Commands.getOutputDir
+import enum Commands.CoverageFormat
+import struct Commands.CoverageFormatOutput
+import typealias Basics.StringError
+import struct Commands.PlainTextEncoder
+
+@Suite(
+ .tags(
+ .TestSize.small,
+ )
+)
+struct TestCommmandHelpersTests {
+
+ @Suite
+ struct getOutputDirTests {
+ @Test(
+ arguments: [
+ "",
+ """
+ line1
+ """,
+ """
+ line1
+ line2
+ """,
+ """
+ line1
+ line2
+ line3
+ """,
+ ]
+ )
+ func outputDirArgumentNotPresentReturnsNil(
+ content: String
+ ) async throws {
+ let actual = try getOutputDir(from: content, workspacePath: AbsolutePath.root,)
+
+ #expect(actual == nil)
+ }
+
+ struct GetOutputDirTestData: Identifiable {
+ let content: String
+ let expected: AbsolutePath?
+ let id: String
+ }
+
+ @Test(
+ arguments: [
+ "=",
+ "\n",
+ " ",
+ " ",
+ " ",
+ ].map { sep in
+ return [
+ GetOutputDirTestData(
+ content: """
+ --output-dir\(sep)/Bar/baz
+ """,
+ expected: AbsolutePath("/Bar/baz"),
+ id: "Single argument with seperator '\(sep)'",
+ ),
+ GetOutputDirTestData(
+ content: """
+ --output-dir\(sep)/Bar/baz
+ --output-dir\(sep)/this/should/win
+ """,
+ expected: AbsolutePath("/this/should/win"),
+ id: "Two output dir arguments with seperator '\(sep)' returns the last occurrence",
+ ),
+ GetOutputDirTestData(
+ content: """
+ --output-dir\(sep)/Bar/baz
+ --output-dir\(sep)/what
+ --output-dir\(sep)/this/should/win
+ """,
+ expected: AbsolutePath("/this/should/win"),
+ id: "three output dir arguments with seperator '\(sep)' returns the last occurrence",
+ ),
+ GetOutputDirTestData(
+ content: """
+ prefix
+ --output-dir\(sep)/Bar/baz
+ """,
+ expected: AbsolutePath("/Bar/baz"),
+ id: "seperator '\(sep)': with content prefix",
+ ),
+ GetOutputDirTestData(
+ content: """
+ --output-dir\(sep)/Bar/baz
+ suffix
+ """,
+ expected: AbsolutePath("/Bar/baz"),
+ id: "seperator '\(sep)': with content suffix",
+ ),
+ GetOutputDirTestData(
+ content: """
+ line_prefix
+ --output-dir\(sep)/Bar/baz
+ suffix
+ """,
+ expected: AbsolutePath("/Bar/baz"),
+ id: "seperator '\(sep)': with line content and suffix",
+ ),
+ GetOutputDirTestData(
+ content: """
+ prefix--output-dir\(sep)/Bar/baz
+ """,
+ expected: nil,
+ id: "seperator '\(sep)': with line prefix (no space)",
+ ),
+ GetOutputDirTestData(
+ content: """
+ prefix --output-dir\(sep)/Bar/baz
+ """,
+ expected: AbsolutePath("/Bar/baz"),
+ id: "seperator '\(sep)': with line prefix (which contains a space)",
+ ),
+ ]
+ }.flatMap { $0 },
+ )
+ func contentContainsOutputDirectoryReturnsCorrectPath(
+ data: GetOutputDirTestData,
+ ) async throws {
+ let actual = try getOutputDir(from: data.content, workspacePath: AbsolutePath.root,)
+
+ #expect(actual == data.expected)
+ }
+
+ @Test(
+ arguments: [
+ (
+ relativePathUnderTest: "./relative/path",
+ dir: AbsolutePath("/some/random/longish/path"),
+ expected: AbsolutePath("/some/random/longish/path/relative/path"),
+ ),
+ (
+ relativePathUnderTest: "relative/path",
+ dir: AbsolutePath("/some/random/longish/path"),
+ expected: AbsolutePath("/some/random/longish/path/relative/path"),
+ ),
+ (
+ relativePathUnderTest: "../relative/path",
+ dir: AbsolutePath("/some/random/longish/path"),
+ expected: AbsolutePath("/some/random/longish/relative/path"),
+ ),
+ (
+ relativePathUnderTest: "../../relative/path",
+ dir: AbsolutePath("/some/random/longish/path"),
+ expected: AbsolutePath("/some/random/relative/path"),
+ ),
+ ],
+ )
+ func contentContainsOutputDirectoryAsRelativePathReturnsCorrectPath(
+ relativePathUnderTest: String,
+ dir: AbsolutePath,
+ expected: AbsolutePath,
+ ) async throws {
+ let relativePathUnderTest = RelativePath(relativePathUnderTest)
+ let content = """
+ --output-dir \(relativePathUnderTest)
+ """
+
+ let actual = try getOutputDir(from: content, workspacePath: dir,)
+
+ #expect(actual == expected)
+ }
+
+ @Test func sample() async throws {
+ let logMessage = "ERROR: User 'john.doe' failed login attempt from IP 192.168.1.100."
+
+ // Create a Regex with named captue groups for user and ipAddress
+ let regex = try! Regex("User '(?[a-zA-Z0-9.]+)' failed login attempt from IP (?\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})")
+
+ // Find the first match in the log message
+ if let match = logMessage.firstMatch(of: regex) {
+ // Access the captured values using their named properties
+ // let username = match.user
+ // let ipAddress = match.ipAddress
+
+ #expect(Bool(true))
+ } else {
+ #expect(Bool(false))
+ }
+ }
+ }
+
+ @Suite
+ struct CoverageFormatOutputTests {
+
+ var validData: [CoverageFormat: AbsolutePath] {
+ [
+ CoverageFormat.json: AbsolutePath("/some/path/json"),
+ CoverageFormat.html: AbsolutePath("/some/path/html"),
+ ]
+ }
+
+ // MARK: - Initialization Tests
+
+ @Test
+ func initEmpty() async throws {
+ let output = CoverageFormatOutput()
+ #expect(output.formats.isEmpty)
+ }
+
+ @Test
+ func initWithData() async throws {
+ let output = CoverageFormatOutput(data: validData)
+ #expect(output.formats.count == 2)
+ #expect(output.formats.contains(CoverageFormat.json))
+ #expect(output.formats.contains(CoverageFormat.html))
+ }
+
+ // MARK: - addFormat Tests
+
+ @Test
+ func addFormatSuccess() async throws {
+ var output = CoverageFormatOutput()
+ let jsonPath = AbsolutePath("/path/to/json")
+
+ try output.addFormat(CoverageFormat.json, path: jsonPath)
+
+ #expect(output.formats.count == 1)
+ #expect(output.formats.contains(CoverageFormat.json))
+ #expect(output[CoverageFormat.json] == jsonPath)
+ }
+
+ @Test
+ func addFormatMultiple() async throws {
+ var output = CoverageFormatOutput()
+ let jsonPath = AbsolutePath("/path/to/json")
+ let htmlPath = AbsolutePath("/path/to/html")
+
+ try output.addFormat(CoverageFormat.json, path: jsonPath)
+ try output.addFormat(CoverageFormat.html, path: htmlPath)
+
+ #expect(output.formats.count == 2)
+ #expect(output.formats.contains(CoverageFormat.json))
+ #expect(output.formats.contains(CoverageFormat.html))
+ #expect(output[CoverageFormat.json] == jsonPath)
+ #expect(output[CoverageFormat.html] == htmlPath)
+ }
+
+ @Test
+ func addFormatDuplicateThrowsError() async throws {
+ var output = CoverageFormatOutput()
+ let jsonPath1 = AbsolutePath("/path/to/json1")
+ let jsonPath2 = AbsolutePath("/path/to/json2")
+
+ try output.addFormat(CoverageFormat.json, path: jsonPath1)
+
+ #expect(throws: StringError("Coverage format 'json' already exists")) {
+ try output.addFormat(CoverageFormat.json, path: jsonPath2)
+ }
+
+ // Verify original path is unchanged
+ #expect(output[CoverageFormat.json] == jsonPath1)
+ #expect(output.formats.count == 1)
+ }
+
+ // MARK: - Subscript Tests
+
+ @Test
+ func subscriptExistingFormat() async throws {
+ let output = CoverageFormatOutput(data: validData)
+
+ #expect(output[CoverageFormat.json] == AbsolutePath("/some/path/json"))
+ #expect(output[CoverageFormat.html] == AbsolutePath("/some/path/html"))
+ }
+
+ @Test
+ func subscriptNonExistentFormat() async throws {
+ let output = CoverageFormatOutput()
+
+ #expect(output[CoverageFormat.json] == nil)
+ #expect(output[CoverageFormat.html] == nil)
+ }
+
+ // MARK: - getPath Tests
+
+ @Test
+ func getPathExistingFormat() async throws {
+ let output = CoverageFormatOutput(data: validData)
+
+ let jsonPath = try output.getPath(for: CoverageFormat.json)
+ let htmlPath = try output.getPath(for: CoverageFormat.html)
+
+ #expect(jsonPath == AbsolutePath("/some/path/json"))
+ #expect(htmlPath == AbsolutePath("/some/path/html"))
+ }
+
+ @Test
+ func getPathNonExistentFormatThrowsError() async throws {
+ let output = CoverageFormatOutput()
+
+ #expect(throws: StringError("Missing coverage format output path for 'json'")) {
+ try output.getPath(for: CoverageFormat.json)
+ }
+
+ #expect(throws: StringError("Missing coverage format output path for 'html'")) {
+ try output.getPath(for: CoverageFormat.html)
+ }
+ }
+
+ // MARK: - formats Property Tests
+
+ @Test
+ func formatsEmptyWhenNoData() async throws {
+ let output = CoverageFormatOutput()
+ #expect(output.formats.isEmpty)
+ }
+
+ @Test
+ func formatsReturnsSortedFormats() async throws {
+ let output = CoverageFormatOutput(data: validData)
+ let formats = output.formats
+
+ #expect(formats.count == 2)
+ // Formats should be sorted alphabetically by raw value
+ #expect(formats == [CoverageFormat.html, CoverageFormat.json]) // html comes before json alphabetically
+ }
+
+ @Test
+ func formatsAfterAddingFormats() async throws {
+ var output = CoverageFormatOutput()
+
+ try output.addFormat(CoverageFormat.json, path: AbsolutePath("/json/path"))
+ #expect(output.formats == [CoverageFormat.json])
+
+ try output.addFormat(CoverageFormat.html, path: AbsolutePath("/html/path"))
+ #expect(output.formats == [CoverageFormat.html, CoverageFormat.json]) // sorted
+ }
+
+ // MARK: - forEach Tests
+
+ @Test
+ func forEachEmptyOutput() async throws {
+ let output = CoverageFormatOutput()
+ var iterationCount = 0
+
+ output.forEach { format, path in
+ iterationCount += 1
+ }
+
+ #expect(iterationCount == 0)
+ }
+
+ @Test
+ func forEachWithData() async throws {
+ let output = CoverageFormatOutput(data: validData)
+ var results: [CoverageFormat: AbsolutePath] = [:]
+
+ output.forEach { format, path in
+ results[format] = path
+ }
+
+ #expect(results.count == 2)
+ #expect(results[CoverageFormat.json] == AbsolutePath("/some/path/json"))
+ #expect(results[CoverageFormat.html] == AbsolutePath("/some/path/html"))
+ }
+
+ @Test
+ func forEachCanThrow() async throws {
+ let output = CoverageFormatOutput(data: validData)
+
+ struct TestError: Error, Equatable {
+ let message: String
+ }
+
+ #expect(throws: TestError(message: "test error")) {
+ try output.forEach { format, path in
+ if format == CoverageFormat.json {
+ throw TestError(message: "test error")
+ }
+ }
+ }
+ }
+
+ // MARK: - Integration Tests
+
+ @Test
+ func completeWorkflow() async throws {
+ // Start with empty output
+ var output = CoverageFormatOutput()
+ #expect(output.formats.isEmpty)
+
+ // Add first format
+ let jsonPath = AbsolutePath("/coverage/reports/coverage.json")
+ try output.addFormat(CoverageFormat.json, path: jsonPath)
+ #expect(output.formats == [CoverageFormat.json])
+ #expect(output[CoverageFormat.json] == jsonPath)
+ let actualJsonPath = try output.getPath(for: CoverageFormat.json)
+ #expect(actualJsonPath == jsonPath)
+
+ // Add second format
+ let htmlPath = AbsolutePath("/coverage/reports/html")
+ try output.addFormat(CoverageFormat.html, path: htmlPath)
+ #expect(output.formats == [CoverageFormat.html, CoverageFormat.json]) // sorted
+ #expect(output[CoverageFormat.html] == htmlPath)
+ let actualHmtlPath = try output.getPath(for: CoverageFormat.html)
+ #expect(actualHmtlPath == htmlPath)
+
+ // Verify forEach works
+ var collectedPaths: [CoverageFormat: AbsolutePath] = [:]
+ output.forEach { format, path in
+ collectedPaths[format] = path
+ }
+ #expect(collectedPaths.count == 2)
+ #expect(collectedPaths[CoverageFormat.json] == jsonPath)
+ #expect(collectedPaths[CoverageFormat.html] == htmlPath)
+
+ // Verify duplicate add fails
+ #expect(throws: StringError("Coverage format 'json' already exists")) {
+ try output.addFormat(CoverageFormat.json, path: AbsolutePath("/different/path"))
+ }
+
+ // Verify original data is preserved
+ #expect(output[CoverageFormat.json] == jsonPath)
+ #expect(output.formats.count == 2)
+ }
+
+ // MARK: - Encoding Tests
+
+ @Test("Encode as JSON with single format")
+ func encodeAsJSONSingle() throws {
+ let path = try AbsolutePath(validating: "/path/to/coverage.json")
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: path)
+
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ let jsonData = try encoder.encode(output)
+ let jsonString = String(decoding: jsonData, as: UTF8.self)
+ let decoded = try JSONSerialization.jsonObject(with: jsonData) as! [String: String]
+
+ #expect(decoded["json"] == "/path/to/coverage.json")
+ #expect(decoded.count == 1)
+ }
+
+ @Suite(
+ .tags(
+ .TestSize.small,
+ .Feature.Encoding,
+ ),
+ )
+ struct EncodingTests {
+ @Suite
+ struct JsonEncodingTests {
+ @Test("Encode as JSON with multiple formats")
+ func encodeAsJSONMultiple() throws {
+ let jsonPath = try AbsolutePath(validating: "/path/to/coverage.json")
+ let htmlPath = try AbsolutePath(validating: "/path/to/coverage-html")
+
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: jsonPath)
+ try output.addFormat(.html, path: htmlPath)
+
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ encoder.outputFormatting = [.prettyPrinted]
+ let jsonData = try encoder.encode(output)
+ let jsonString = String(decoding: jsonData, as: UTF8.self)
+ let decoded = try JSONSerialization.jsonObject(with: jsonData) as! [String: String]
+
+ #expect(decoded["json"] == "/path/to/coverage.json")
+ #expect(decoded["html"] == "/path/to/coverage-html")
+ #expect(decoded.count == 2)
+
+ // Verify it's properly formatted JSON
+ #expect(jsonString.contains("{\n"))
+ #expect(jsonString.contains("\n}"))
+ }
+
+ @Test("Encode as JSON with empty data")
+ func encodeAsJSONEmpty() throws {
+ let output = CoverageFormatOutput()
+
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ encoder.outputFormatting = [.prettyPrinted]
+ let jsonData = try encoder.encode(output)
+ let jsonString = String(decoding: jsonData, as: UTF8.self)
+ let decoded = try JSONSerialization.jsonObject(with: jsonData) as! [String: String]
+
+ #expect(decoded.isEmpty)
+ #expect(jsonString.contains("{\n\n}") || jsonString.contains("{}"))
+ }
+ }
+
+ @Suite
+ struct TextEncodingTests {
+ @Test(
+ "Encode as text with single format",
+ arguments: CoverageFormat.allCases
+ )
+ func encodeAsTextSingle(
+ format: CoverageFormat,
+ ) throws {
+ let path = try AbsolutePath(validating: "/path/to/coverage.json")
+ var output = CoverageFormatOutput()
+ try output.addFormat(format, path: path)
+
+ var encoder = PlainTextEncoder()
+ encoder.formattingOptions = [.prettyPrinted]
+ let textData = try encoder.encode(output)
+ let textString = String(decoding: textData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // PlainTextEncoder capitalizes first letter of keys
+ let expectedFormat = format.rawValue.prefix(1).uppercased() + format.rawValue.dropFirst()
+ #expect(textString == "\(expectedFormat): /path/to/coverage.json")
+ }
+
+ @Test("Encode as text with multiple formats")
+ func encodeAsTextMultiple() throws {
+ let jsonPath = try AbsolutePath(validating: "/path/to/coverage.json")
+ let htmlPath = try AbsolutePath(validating: "/path/to/coverage-html")
+
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: jsonPath)
+ try output.addFormat(.html, path: htmlPath)
+
+ var encoder = PlainTextEncoder()
+ encoder.formattingOptions = [.prettyPrinted]
+ let textData = try encoder.encode(output)
+ let textString = String(decoding: textData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Should be sorted by format name (html comes before json alphabetically)
+ // PlainTextEncoder capitalizes first letter of keys
+ #expect(textString == "Html: /path/to/coverage-html\nJson: /path/to/coverage.json")
+ }
+
+ @Test("Encode as text with empty data")
+ func encodeAsTextEmpty() throws {
+ let output = CoverageFormatOutput()
+
+ var encoder = PlainTextEncoder()
+ encoder.formattingOptions = [.prettyPrinted]
+ let textData = try encoder.encode(output)
+ let textString = String(decoding: textData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+
+ #expect(textString.isEmpty)
+ }
+ }
+
+ @Test("Encoding consistency - formats maintain sorting")
+ func encodingConsistency() throws {
+ // Add formats in reverse alphabetical order to test sorting
+ let jsonPath = try AbsolutePath(validating: "/json/path")
+ let htmlPath: AbsolutePath = try AbsolutePath(validating: "/html/path")
+
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: jsonPath) // Add json first
+ try output.addFormat(.html, path: htmlPath) // Add html second
+
+ // Text encoding should show html first (alphabetically)
+ var textEncoder = PlainTextEncoder()
+ textEncoder.formattingOptions = [.prettyPrinted]
+ let textData = try textEncoder.encode(output)
+ let textString = String(decoding: textData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+ #expect(textString.hasPrefix("Html:"))
+ #expect(textString.hasSuffix("Json: /json/path"))
+
+ // JSON encoding should also maintain consistent ordering
+ let jsonEncoder = JSONEncoder()
+ jsonEncoder.keyEncodingStrategy = .convertToSnakeCase
+ let jsonData = try jsonEncoder.encode(output)
+ let decoded = try JSONSerialization.jsonObject(with: jsonData) as! [String: String]
+
+ #expect(decoded["html"] == "/html/path")
+ #expect(decoded["json"] == "/json/path")
+ }
+
+ @Test("Text encoding handles special characters in paths")
+ func textEncodingSpecialCharacters() throws {
+ let specialPath = try AbsolutePath(validating: "/path with/spaces & symbols/coverage.json")
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: specialPath)
+
+ var encoder = PlainTextEncoder()
+ encoder.formattingOptions = [.prettyPrinted]
+ let textData = try encoder.encode(output)
+ let textString = String(decoding: textData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+
+ #expect(textString == "Json: /path with/spaces & symbols/coverage.json")
+ }
+
+ @Test("JSON encoding handles special characters in paths")
+ func jsonEncodingSpecialCharacters() throws {
+ let specialPath = try AbsolutePath(validating: "/path with/spaces & symbols/coverage.json")
+ var output = CoverageFormatOutput()
+ try output.addFormat(.json, path: specialPath)
+
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ let jsonData = try encoder.encode(output)
+ let decoded = try JSONSerialization.jsonObject(with: jsonData) as! [String: String]
+
+ #expect(decoded["json"] == "/path with/spaces & symbols/coverage.json")
+ }
+ }
+
+ }
+}