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") + } + } + + } +}