diff --git a/.gitignore b/.gitignore index 581fc26..ff38c9a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .swift-marshal-cache/ .swift-cpd-cache/ USP/ +Makefile # Generated by swift-mutation-testing .swift-mutation-testing-cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ba4262..a42915c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -109,25 +109,33 @@ repos: args: ["format", "--in-place", "--parallel"] types: [swift] - # Swift Clone & Pattern Detector + # Swift Code Duplication Detector - repo: https://github.com/ericodx/swift-cpd - rev: v1.1.0 + rev: v1.2.0 hooks: - id: swift-cpd - name: ✓ Swift Clone & Pattern Detector - description: Detect duplicated code in Swift and Objective-C/C codebases. + name: ✓ Swift Code Duplication Detector + description: Detect and eliminate duplicated logic in Swift and Objective-C/C codebases to improve maintainability and code quality. types: [swift] # Swift Marshal - repo: https://github.com/ericodx/swift-marshal - rev: v1.0.0 + rev: v1.1.0 hooks: - id: swift-marshal name: ✓ Swift Marshal - description: Reorder Swift type members without rewriting code. + description: Ensure consistent member ordering in Swift types to improve readability and maintainability. types: [swift] args: ["--path", "Sources"] + # Gitleaks + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks + name: ✓ Gitleaks + description: Detect hardcoded secrets and credentials before they are committed. + # Local hooks - repo: local hooks: diff --git a/Sources/SwiftMutationTesting/CLI/HelpText.swift b/Sources/SwiftMutationTesting/CLI/HelpText.swift index a1835fe..ee639ca 100644 --- a/Sources/SwiftMutationTesting/CLI/HelpText.swift +++ b/Sources/SwiftMutationTesting/CLI/HelpText.swift @@ -10,8 +10,8 @@ enum HelpText { Path to the Xcode project root (default: .) OPTIONS: - --scheme Xcode scheme to build and test (required) - --destination xcodebuild destination specifier (required) + --scheme Xcode scheme to build and test (Xcode projects only) + --destination xcodebuild destination specifier (Xcode projects only) --target Test target name --timeout Per-mutant test timeout in seconds (default: 120) --concurrency Number of parallel test workers (default: CPUs - 1) diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift index 867d48d..0d4d910 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift @@ -14,26 +14,45 @@ struct ConfigurationFileWriter: Sendable { } private func generateContent(project: DetectedProject) -> String { + switch project.kind { + case .xcode(let scheme, let allSchemes, let destination): + return generateXcodeContent( + scheme: scheme, + allSchemes: allSchemes, + destination: destination, + testTarget: project.testTarget + ) + case .spm(let testTargets): + return generateSPMContent(testTargets: testTargets, testTarget: project.testTarget) + } + } + + private func generateXcodeContent( + scheme: String?, + allSchemes: [String], + destination: String, + testTarget: String? + ) -> String { var lines: [String] = [] lines.append("# swift-mutation-testing configuration") lines.append("# All settings are optional. CLI flags override file values.") lines.append("") - if project.allSchemes.count > 1 { - lines.append("# Available schemes: \(project.allSchemes.joined(separator: ", "))") + if allSchemes.count > 1 { + lines.append("# Available schemes: \(allSchemes.joined(separator: ", "))") } - if let scheme = project.scheme { + if let scheme { lines.append("scheme: \(scheme)") } else { lines.append("# scheme: MyApp") } - lines.append("destination: \(project.destination)") + lines.append("destination: \(destination)") lines.append("") - if let testTarget = project.testTarget { + if let testTarget { lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)") lines.append("testTarget: \(testTarget)") } else { @@ -58,7 +77,7 @@ struct ConfigurationFileWriter: Sendable { lines.append("") lines.append("# Source file glob patterns to exclude from mutation") - if let testTarget = project.testTarget { + if let testTarget { lines.append("exclude:") lines.append(" - \"/\(testTarget)/\"") } else { @@ -71,6 +90,52 @@ struct ConfigurationFileWriter: Sendable { return lines.joined(separator: "\n") + "\n" } + private func generateSPMContent(testTargets: [String], testTarget: String?) -> String { + var lines: [String] = [] + + lines.append("# swift-mutation-testing configuration") + lines.append("# All settings are optional. CLI flags override file values.") + lines.append("") + + if testTargets.count > 1 { + lines.append("# Available test targets: \(testTargets.joined(separator: ", "))") + } + + if let testTarget { + lines.append("# Limit test execution to a specific target") + lines.append("testTarget: \(testTarget)") + } else { + lines.append("# Limit test execution to a specific target") + lines.append("# testTarget: MyPackageTests") + } + + lines.append("") + lines.append("# Per-mutant test timeout in seconds (default: 120)") + lines.append("timeout: 120") + lines.append("") + lines.append("# Disable result cache (re-runs all mutants on every execution)") + lines.append("# noCache: true") + lines.append("") + lines.append("# Report output paths") + lines.append("# output: mutation-report.json") + lines.append("# htmlOutput: mutation-report.html") + lines.append("sonarOutput: sonar-mutation-report.json") + lines.append("") + lines.append("# Source file glob patterns to exclude from mutation") + + if let testTarget { + lines.append("exclude:") + lines.append(" - \"/\(testTarget)/\"") + } else { + lines.append("# exclude:") + lines.append("# - \"**/Tests/**\"") + } + + lines.append(contentsOf: mutatorsSection()) + + return lines.joined(separator: "\n") + "\n" + } + private func mutatorsSection() -> [String] { var lines = ["", "# Mutation operators — set active: false to disable", "mutators:"] for name in DiscoveryPipeline.allOperatorNames { diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift index 51a3994..154eea3 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift @@ -13,19 +13,16 @@ struct ConfigurationResolver: Sendable { throw UsageError(message: "--concurrency must be >= 1") } - guard cliArguments.build.scheme != nil || fileValues["scheme"] != nil else { - throw UsageError(message: "--scheme is required") - } - - guard cliArguments.build.destination != nil || fileValues["destination"] != nil else { - throw UsageError(message: "--destination is required") - } + let projectType = try resolveProjectType( + cliArguments: cliArguments, + fileValues: fileValues, + projectPath: projectPath + ) return RunnerConfiguration( projectPath: projectPath, build: .init( - scheme: cliArguments.build.scheme ?? fileValues["scheme"] ?? "", - destination: cliArguments.build.destination ?? fileValues["destination"] ?? "", + projectType: projectType, testTarget: cliArguments.build.testTarget ?? fileValues["testTarget"], timeout: timeout, concurrency: concurrency, @@ -49,6 +46,35 @@ struct ConfigurationResolver: Sendable { ) } + private func resolveProjectType( + cliArguments: ParsedArguments, + fileValues: [String: String], + projectPath: String + ) throws -> ProjectType { + let scheme = cliArguments.build.scheme ?? fileValues["scheme"] + let destination = cliArguments.build.destination ?? fileValues["destination"] + + if scheme == nil && destination == nil && hasSPMPackage(at: projectPath) { + return .spm + } + + guard let scheme else { + throw UsageError(message: "--scheme is required") + } + + guard let destination else { + throw UsageError(message: "--destination is required") + } + + return .xcode(scheme: scheme, destination: destination) + } + + private func hasSPMPackage(at projectPath: String) -> Bool { + let packageURL = URL(fileURLWithPath: projectPath) + .appendingPathComponent("Package.swift") + return FileManager.default.fileExists(atPath: packageURL.path) + } + private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String]) -> Double { if let timeout = cli.build.timeout { return timeout } if let timeout = fileValues["timeout"].flatMap(Double.init) { return timeout } diff --git a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift index eb89f8b..1e94b91 100644 --- a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift +++ b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift @@ -1,11 +1,29 @@ struct DetectedProject: Sendable { + enum Kind: Sendable { + case xcode(scheme: String?, allSchemes: [String], destination: String) + case spm(testTargets: [String]) + } static let empty = DetectedProject( - scheme: nil, allSchemes: [], testTarget: nil, destination: "platform=macOS" + kind: .xcode(scheme: nil, allSchemes: [], destination: "platform=macOS"), + testTarget: nil ) - let scheme: String? - let allSchemes: [String] + let kind: Kind let testTarget: String? - let destination: String + + var scheme: String? { + guard case .xcode(let xScheme, _, _) = kind else { return nil } + return xScheme + } + + var allSchemes: [String] { + guard case .xcode(_, let all, _) = kind else { return [] } + return all + } + + var destination: String { + guard case .xcode(_, _, let dest) = kind else { return "platform=macOS" } + return dest + } } diff --git a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift index eebcbf5..918e65f 100644 --- a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift +++ b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift @@ -6,19 +6,29 @@ struct ProjectDetector: Sendable { func detect(at projectPath: String) async -> DetectedProject { let projectURL = resolvedURL(for: projectPath) - guard let container = findContainer(in: projectURL) else { - return .empty + if let container = findContainer(in: projectURL) { + let (schemes, projectName, testTarget) = await listProject( + container: container, workingDirectory: projectURL) + let destination = await detectDestination(in: projectURL) + return DetectedProject( + kind: .xcode( + scheme: selectScheme(from: schemes, projectName: projectName), + allSchemes: schemes, + destination: destination + ), + testTarget: testTarget + ) } - let (schemes, projectName, testTarget) = await listProject(container: container, workingDirectory: projectURL) - let destination = await detectDestination(in: projectURL) + if FileManager.default.fileExists(atPath: projectURL.appendingPathComponent("Package.swift").path) { + let testTargets = await listSPMTestTargets(in: projectURL) + return DetectedProject( + kind: .spm(testTargets: testTargets), + testTarget: testTargets.first + ) + } - return DetectedProject( - scheme: selectScheme(from: schemes, projectName: projectName), - allSchemes: schemes, - testTarget: testTarget, - destination: destination - ) + return .empty } private func resolvedURL(for path: String) -> URL { @@ -70,6 +80,29 @@ struct ProjectDetector: Sendable { return parseListOutput(result.output) } + private func listSPMTestTargets(in projectURL: URL) async -> [String] { + guard + let result = try? await launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: ["package", "dump-package"], + environment: nil, + workingDirectoryURL: projectURL, + timeout: 30 + ), + result.exitCode == 0, + let data = result.output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let targets = json["targets"] as? [[String: Any]] + else { + return [] + } + + return + targets + .filter { ($0["type"] as? String) == "test" } + .compactMap { $0["name"] as? String } + } + private func parseListOutput(_ output: String) -> (schemes: [String], projectName: String?, testTarget: String?) { guard let data = output.data(using: .utf8), diff --git a/Sources/SwiftMutationTesting/Configuration/ProjectType.swift b/Sources/SwiftMutationTesting/Configuration/ProjectType.swift new file mode 100644 index 0000000..4e91399 --- /dev/null +++ b/Sources/SwiftMutationTesting/Configuration/ProjectType.swift @@ -0,0 +1,4 @@ +enum ProjectType: Sendable, Equatable { + case xcode(scheme: String, destination: String) + case spm +} diff --git a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift index 0381901..949f1a1 100644 --- a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift +++ b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift @@ -10,8 +10,7 @@ struct RunnerConfiguration: Sendable { let filter: FilterOptions struct BuildOptions: Sendable { - var scheme: String - var destination: String + var projectType: ProjectType var testTarget: String? var timeout: Double var concurrency: Int diff --git a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift index ed9b19b..5e9c5da 100644 --- a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift +++ b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift @@ -20,8 +20,7 @@ struct DiscoveryPipeline: Sendable { return RunnerInput( projectPath: input.projectPath, - scheme: input.scheme, - destination: input.destination, + projectType: input.projectType, timeout: input.timeout, concurrency: input.concurrency, noCache: input.noCache, diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/DiscoveryInput.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/DiscoveryInput.swift index 0dcade8..00116b4 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/DiscoveryInput.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/DiscoveryInput.swift @@ -1,7 +1,6 @@ struct DiscoveryInput: Sendable { let projectPath: String - let scheme: String - let destination: String + let projectType: ProjectType let timeout: Double let concurrency: Int let noCache: Bool diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index a770688..57861bf 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -90,9 +90,15 @@ struct IncompatibleMutantExecutor: Sendable { let xcresultPath = sandbox.rootURL .appendingPathComponent("\(UUID().uuidString).xcresult").path + guard case .xcode(let scheme, _) = configuration.build.projectType else { + return IncompatibleTestLaunchResult( + exitCode: 0, output: "", xcresultPath: xcresultPath, duration: 0 + ) + } + var arguments = [ "test", - "-scheme", configuration.build.scheme, + "-scheme", scheme, "-destination", slot.destination, "-derivedDataPath", derivedDataPath, "-resultBundlePath", xcresultPath, diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 9582943..d7a2d18 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -87,11 +87,15 @@ struct MutantExecutor: Sendable { await deps.reporter.report(.buildStarted) let start = Date() + guard case .xcode(let scheme, let destination) = configuration.build.projectType else { + return nil + } + do { let artifact = try await BuildStage(launcher: deps.launcher).build( sandbox: sandbox, - scheme: configuration.build.scheme, - destination: configuration.build.destination, + scheme: scheme, + destination: destination, timeout: configuration.build.timeout ) await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) @@ -154,12 +158,17 @@ struct MutantExecutor: Sendable { await deps.reporter.report(.fallbackBuildStarted(filePath: file.originalPath)) + guard case .xcode(let scheme, let destination) = configuration.build.projectType else { + try? sandbox.cleanup() + return await markFallbackMutantsUnviable(mutants: fileMutants, testFilesHash: testFilesHash, deps: deps) + } + let artifact: BuildArtifact do { artifact = try await BuildStage(launcher: deps.launcher).build( sandbox: sandbox, - scheme: configuration.build.scheme, - destination: configuration.build.destination, + scheme: scheme, + destination: destination, timeout: configuration.build.timeout ) await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) @@ -237,19 +246,26 @@ struct MutantExecutor: Sendable { } private func makePool(launcher: any ProcessLaunching) async throws -> SimulatorPool { - guard SimulatorManager.requiresSimulatorPool(for: configuration.build.destination) else { + let destination: String + if case .xcode(_, let dest) = configuration.build.projectType { + destination = dest + } else { + destination = "platform=macOS" + } + + guard SimulatorManager.requiresSimulatorPool(for: destination) else { return SimulatorPool( - baseUDID: nil, size: 1, - destination: configuration.build.destination, launcher: launcher + baseUDID: nil, size: configuration.build.concurrency, + destination: destination, launcher: launcher ) } let baseUDID = try await SimulatorManager(launcher: launcher) - .resolveBaseUDID(for: configuration.build.destination) + .resolveBaseUDID(for: destination) return SimulatorPool( baseUDID: baseUDID, size: configuration.build.concurrency, - destination: configuration.build.destination, launcher: launcher + destination: destination, launcher: launcher ) } } diff --git a/Sources/SwiftMutationTesting/Execution/RunnerInput.swift b/Sources/SwiftMutationTesting/Execution/RunnerInput.swift index 0cf98cd..4dfa4b9 100644 --- a/Sources/SwiftMutationTesting/Execution/RunnerInput.swift +++ b/Sources/SwiftMutationTesting/Execution/RunnerInput.swift @@ -1,7 +1,6 @@ struct RunnerInput: Sendable { let projectPath: String - let scheme: String - let destination: String + let projectType: ProjectType let timeout: Double let concurrency: Int let noCache: Bool diff --git a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift index d4266d3..151bf92 100644 --- a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift +++ b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift @@ -62,8 +62,7 @@ public struct SwiftMutationTesting { let start = Date() let discoveryInput = DiscoveryInput( projectPath: configuration.projectPath, - scheme: configuration.build.scheme, - destination: configuration.build.destination, + projectType: configuration.build.projectType, timeout: configuration.build.timeout, concurrency: configuration.build.concurrency, noCache: configuration.build.noCache, diff --git a/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift index fc7f20a..dab2674 100644 --- a/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift @@ -80,8 +80,7 @@ struct DiscoveryPipelineIntegrationTests { let input = DiscoveryInput( projectPath: dir.path, - scheme: "Scheme", - destination: "platform=macOS", + projectType: .xcode(scheme: "Scheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false, @@ -123,8 +122,7 @@ struct DiscoveryPipelineIntegrationTests { let result = try await pipeline.run(input: input) #expect(result.projectPath == calcAppURL().path) - #expect(result.scheme == "CalcApp") - #expect(result.destination == "platform=macOS") + #expect(result.projectType == .xcode(scheme: "CalcApp", destination: "platform=macOS")) #expect(result.timeout == 60) #expect(result.concurrency == 1) #expect(result.noCache == false) @@ -148,8 +146,7 @@ extension DiscoveryPipelineIntegrationTests { let root = calcAppURL() return DiscoveryInput( projectPath: root.path, - scheme: "CalcApp", - destination: "platform=macOS", + projectType: .xcode(scheme: "CalcApp", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false, diff --git a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift index eac3b2d..eb8a406 100644 --- a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift @@ -59,7 +59,9 @@ private func fixtureProjectURL() -> URL { private func makeConfiguration(fixtureURL: URL) -> RunnerConfiguration { RunnerConfiguration( projectPath: fixtureURL.path, - build: .init(scheme: "CalcApp", destination: "platform=macOS", timeout: 120.0, concurrency: 1, noCache: true), + build: .init( + projectType: .xcode(scheme: "CalcApp", destination: "platform=macOS"), + timeout: 120.0, concurrency: 1, noCache: true), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) ) @@ -68,8 +70,7 @@ private func makeConfiguration(fixtureURL: URL) -> RunnerConfiguration { private func makeInput(fixtureURL: URL) -> RunnerInput { RunnerInput( projectPath: fixtureURL.path, - scheme: "CalcApp", - destination: "platform=macOS", + projectType: .xcode(scheme: "CalcApp", destination: "platform=macOS"), timeout: 120.0, concurrency: 1, noCache: true, diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift index c3fa3d4..8e1c968 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift @@ -229,7 +229,7 @@ struct WriteReportsTests { RunnerConfiguration( projectPath: projectPath, build: .init( - scheme: "MyScheme", destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false), reporting: .init(output: output, htmlOutput: htmlOutput, sonarOutput: sonarOutput, quiet: true), filter: .init(excludePatterns: [], operators: []) diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift index 54024ae..5046262 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift @@ -38,7 +38,8 @@ struct ConfigurationFileWriterTests { try writer.write( to: dir.path, project: DetectedProject( - scheme: "MyApp", allSchemes: ["MyApp"], testTarget: nil, destination: "platform=macOS" + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp"], destination: "platform=macOS"), + testTarget: nil ) ) @@ -55,7 +56,8 @@ struct ConfigurationFileWriterTests { try writer.write( to: dir.path, project: DetectedProject( - scheme: "MyApp", allSchemes: ["MyApp"], testTarget: "MyAppTests", destination: "platform=macOS" + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp"], destination: "platform=macOS"), + testTarget: "MyAppTests" ) ) @@ -72,8 +74,11 @@ struct ConfigurationFileWriterTests { try writer.write( to: dir.path, project: DetectedProject( - scheme: "MyApp", allSchemes: ["MyApp"], testTarget: nil, - destination: "platform=iOS Simulator,OS=latest,name=iPhone 16 Pro" + kind: .xcode( + scheme: "MyApp", allSchemes: ["MyApp"], + destination: "platform=iOS Simulator,OS=latest,name=iPhone 16 Pro" + ), + testTarget: nil ) ) @@ -101,7 +106,8 @@ struct ConfigurationFileWriterTests { try writer.write( to: dir.path, project: DetectedProject( - scheme: "MyApp", allSchemes: ["MyApp", "MyAppTests"], testTarget: nil, destination: "platform=macOS" + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp", "MyAppTests"], destination: "platform=macOS"), + testTarget: nil ) ) @@ -117,7 +123,8 @@ struct ConfigurationFileWriterTests { try writer.write( to: dir.path, project: DetectedProject( - scheme: "MyApp", allSchemes: ["MyApp"], testTarget: "MyAppTests", destination: "platform=macOS" + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp"], destination: "platform=macOS"), + testTarget: "MyAppTests" ) ) diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index d4a6fd7..9dbf521 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import SwiftMutationTesting @@ -6,26 +7,24 @@ import Testing struct ConfigurationResolverTests { private let resolver = ConfigurationResolver() - @Test("Given CLI scheme and destination, when resolved, then configuration uses CLI values") + @Test("Given CLI scheme and destination, when resolved, then projectType is xcode with CLI values") func usesCLIValues() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "MyApp", destination: "platform=macOS")), fileValues: [:] ) - #expect(result.build.scheme == "MyApp") - #expect(result.build.destination == "platform=macOS") + #expect(result.build.projectType == .xcode(scheme: "MyApp", destination: "platform=macOS")) } - @Test("Given scheme and destination only in file, when resolved, then configuration uses file values") + @Test("Given scheme and destination only in file, when resolved, then projectType is xcode with file values") func fallsBackToFileValues() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(), fileValues: ["scheme": "FileApp", "destination": "platform=macOS"] ) - #expect(result.build.scheme == "FileApp") - #expect(result.build.destination == "platform=macOS") + #expect(result.build.projectType == .xcode(scheme: "FileApp", destination: "platform=macOS")) } @Test("Given scheme in both CLI and file, when resolved, then CLI scheme takes priority") @@ -35,7 +34,60 @@ struct ConfigurationResolverTests { fileValues: ["scheme": "FileApp"] ) - #expect(result.build.scheme == "CLIApp") + #expect(result.build.projectType == .xcode(scheme: "CLIApp", destination: "platform=macOS")) + } + + @Test("Given Package.swift present and no scheme or destination, when resolved, then projectType is spm") + func detectsSPMWhenPackageSwiftExists() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try FileHelpers.write("// Package.swift", named: "Package.swift", in: dir) + + let result = try resolver.resolve( + cliArguments: ParsedArguments(projectPath: dir.path), + fileValues: [:] + ) + + #expect(result.build.projectType == .spm) + } + + @Test("Given Package.swift present but scheme provided, when resolved, then projectType is xcode") + func xcodeWhenSchemeProvidedEvenWithPackageSwift() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try FileHelpers.write("// Package.swift", named: "Package.swift", in: dir) + + let result = try resolver.resolve( + cliArguments: ParsedArguments( + projectPath: dir.path, + build: .init(scheme: "MyApp", destination: "platform=macOS") + ), + fileValues: [:] + ) + + #expect(result.build.projectType == .xcode(scheme: "MyApp", destination: "platform=macOS")) + } + + @Test("Given no scheme and no Package.swift, when resolved, then throws UsageError") + func throwsWhenSchemeMissingAndNotSPM() { + #expect(throws: UsageError.self) { + try resolver.resolve( + cliArguments: ParsedArguments(build: .init(destination: "platform=macOS")), + fileValues: [:] + ) + } + } + + @Test("Given no destination and scheme provided, when resolved, then throws UsageError") + func throwsWhenDestinationMissing() { + #expect(throws: UsageError.self) { + try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "MyApp")), + fileValues: [:] + ) + } } @Test("Given timeout only in file, when resolved, then configuration uses file timeout") @@ -78,26 +130,6 @@ struct ConfigurationResolverTests { #expect(result.build.concurrency == RunnerConfiguration.defaultConcurrency) } - @Test("Given no scheme in standalone mode, when resolved, then throws UsageError") - func throwsWhenSchemeMissing() { - #expect(throws: UsageError.self) { - try resolver.resolve( - cliArguments: ParsedArguments(build: .init(destination: "platform=macOS")), - fileValues: [:] - ) - } - } - - @Test("Given no destination in standalone mode, when resolved, then throws UsageError") - func throwsWhenDestinationMissing() { - #expect(throws: UsageError.self) { - try resolver.resolve( - cliArguments: ParsedArguments(build: .init(scheme: "MyApp")), - fileValues: [:] - ) - } - } - @Test("Given noCache true in file, when resolved, then noCache is true") func noCacheFromFile() throws { let result = try resolver.resolve( diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift index 1a9f26c..32593b7 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift @@ -62,8 +62,7 @@ struct DiscoveryPipelineTests { let input = DiscoveryInput( projectPath: "/project", - scheme: "MyScheme", - destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 120, concurrency: 8, noCache: true, @@ -74,8 +73,7 @@ struct DiscoveryPipelineTests { let result = try await pipeline.run(input: input) #expect(result.projectPath == "/project") - #expect(result.scheme == "MyScheme") - #expect(result.destination == "platform=macOS") + #expect(result.projectType == .xcode(scheme: "MyScheme", destination: "platform=macOS")) #expect(result.timeout == 120) #expect(result.concurrency == 8) #expect(result.noCache == true) @@ -121,8 +119,7 @@ extension DiscoveryPipelineTests { ) -> DiscoveryInput { DiscoveryInput( projectPath: projectPath, - scheme: "Scheme", - destination: "platform=macOS", + projectType: .xcode(scheme: "Scheme", destination: "platform=macOS"), timeout: 60, concurrency: 4, noCache: false, diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift index 9b48e62..c1c5444 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift @@ -13,8 +13,7 @@ struct FileDiscoveryStageTests { ) -> DiscoveryInput { DiscoveryInput( projectPath: sourcesPath, - scheme: "Scheme", - destination: "platform=macOS", + projectType: .xcode(scheme: "Scheme", destination: "platform=macOS"), timeout: 60, concurrency: 4, noCache: false, diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift index 9cf1820..d3929b3 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift @@ -97,7 +97,7 @@ struct IncompatibleMutantExecutorTests { let noCacheConfig = RunnerConfiguration( projectPath: dir.path, build: .init( - scheme: "MyScheme", destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: true), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) @@ -129,8 +129,8 @@ struct IncompatibleMutantExecutorTests { let config = RunnerConfiguration( projectPath: dir.path, build: .init( - scheme: "MyScheme", destination: "platform=macOS", testTarget: "AppTests", - timeout: 60, concurrency: 1, noCache: false), + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), + testTarget: "AppTests", timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) ) @@ -239,7 +239,7 @@ struct IncompatibleMutantExecutorTests { RunnerConfiguration( projectPath: projectPath, build: .init( - scheme: "MyScheme", destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index 51ef7ba..5d535e1 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -230,7 +230,7 @@ struct MutantExecutorTests { RunnerConfiguration( projectPath: projectPath, build: .init( - scheme: "MyScheme", destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: noCache), reporting: .init(quiet: quiet), filter: .init(excludePatterns: [], operators: []) @@ -244,8 +244,7 @@ struct MutantExecutorTests { ) -> RunnerInput { RunnerInput( projectPath: projectPath, - scheme: "MyScheme", - destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false, diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift index 5229de9..46f32c0 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift @@ -86,7 +86,9 @@ struct TestExecutionStageTests { let noCacheConfig = RunnerConfiguration( projectPath: "/tmp", - build: .init(scheme: "S", destination: "platform=macOS", timeout: 60, concurrency: 1, noCache: true), + build: .init( + projectType: .xcode(scheme: "S", destination: "platform=macOS"), + timeout: 60, concurrency: 1, noCache: true), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) ) @@ -134,8 +136,8 @@ struct TestExecutionStageTests { let config = RunnerConfiguration( projectPath: "/tmp", build: .init( - scheme: "S", destination: "platform=macOS", testTarget: "AppTests", - timeout: 60, concurrency: 1, noCache: false), + projectType: .xcode(scheme: "S", destination: "platform=macOS"), + testTarget: "AppTests", timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) ) @@ -241,7 +243,7 @@ struct TestExecutionStageTests { RunnerConfiguration( projectPath: "/tmp", build: .init( - scheme: "MyScheme", destination: "platform=macOS", + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: [])