diff --git a/.periphery.yml b/.periphery.yml index 028b7c3..eaa7d4f 100644 --- a/.periphery.yml +++ b/.periphery.yml @@ -18,6 +18,7 @@ index_exclude: - "**/checkouts/**" - "**/.github/**" - "**/Docs/**" + - "**/Tests/**" # Exclude test files from reports report_exclude: diff --git a/.swiftlint.yml b/.swiftlint.yml index 1dd13f5..0565c38 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,7 +1,6 @@ # Project paths included: - Sources - - Tests # Analyzer exclusions excluded: diff --git a/Sources/SwiftMutationTesting/Build/BuildError.swift b/Sources/SwiftMutationTesting/Build/BuildError.swift index a99f0d8..9ed132b 100644 --- a/Sources/SwiftMutationTesting/Build/BuildError.swift +++ b/Sources/SwiftMutationTesting/Build/BuildError.swift @@ -1,7 +1,21 @@ -enum BuildError: Error, Equatable { +import Foundation + +enum BuildError: Error, Equatable, LocalizedError { case compilationFailed(output: String) case xctestrunNotFound + var errorDescription: String? { + switch self { + case .compilationFailed(let output): + var message = "Build failed. The schematized source could not be compiled." + if !output.isEmpty { message = output + "\n" + message } + return message + + case .xctestrunNotFound: + return "xctestrun file not found after build." + } + } + static func == (lhs: BuildError, rhs: BuildError) -> Bool { switch (lhs, rhs) { case (.compilationFailed, .compilationFailed): return true diff --git a/Sources/SwiftMutationTesting/Build/BuildStage.swift b/Sources/SwiftMutationTesting/Build/BuildStage.swift index 6cf24c9..5115e14 100644 --- a/Sources/SwiftMutationTesting/Build/BuildStage.swift +++ b/Sources/SwiftMutationTesting/Build/BuildStage.swift @@ -25,12 +25,14 @@ struct BuildStage: Sendable { } let (exitCode, buildOutput) = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: timeout + ) ) guard exitCode == 0 else { @@ -58,18 +60,19 @@ struct BuildStage: Sendable { func buildSPM( sandbox: Sandbox, - testTarget: String?, timeout: Double ) async throws -> BuildArtifact { let arguments = ["build", "--build-tests"] let (exitCode, buildOutput) = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: timeout + ) ) guard exitCode == 0 else { throw BuildError.compilationFailed(output: buildOutput) } diff --git a/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift b/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift index 8deae4e..79e13b7 100644 --- a/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift +++ b/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift @@ -97,6 +97,7 @@ struct CommandLineParser: Sendable { return values } + // swiftlint:disable:next cyclomatic_complexity private func applyFlag( _ flag: String, to values: inout FlagValues, diff --git a/Sources/SwiftMutationTesting/Cache/CacheStore.swift b/Sources/SwiftMutationTesting/Cache/CacheStore.swift index bccafc3..67d9a2a 100644 --- a/Sources/SwiftMutationTesting/Cache/CacheStore.swift +++ b/Sources/SwiftMutationTesting/Cache/CacheStore.swift @@ -1,10 +1,6 @@ import Foundation actor CacheStore { - private struct CacheEntry: Codable { - let key: MutantCacheKey - let status: ExecutionStatus - } init(storePath: String) { self.storePath = storePath @@ -16,6 +12,11 @@ actor CacheStore { private let storePath: String private var entries: [MutantCacheKey: ExecutionStatus] + private struct CacheEntry: Codable { + let key: MutantCacheKey + let status: ExecutionStatus + } + func result(for key: MutantCacheKey) -> ExecutionStatus? { entries[key] } diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileParser.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileParser.swift index 87e3f3a..b298e62 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileParser.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileParser.swift @@ -46,7 +46,7 @@ struct ConfigurationFileParser: Sendable { } if !disabledMutators.isEmpty { - result["disabledMutators"] = disabledMutators.joined(separator: ",") + result["disabled-mutators"] = disabledMutators.joined(separator: ",") } return result diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift index a66da7e..c777842 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift @@ -55,17 +55,24 @@ struct ConfigurationFileWriter: Sendable { lines.append("") lines.append("# Testing framework: xctest or swift-testing (default: swift-testing)") lines.append("# When xctest is selected, concurrency is forced to 1 for deterministic results") - lines.append("testingFramework: \(testingFramework.rawValue)") + lines.append("testing-framework: \(testingFramework.rawValue)") lines.append("") if let testTarget { lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)") - lines.append("testTarget: \(testTarget)") + lines.append("test-target: \(testTarget)") } else { lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)") - lines.append("# testTarget: MyAppTests") + lines.append("# test-target: MyAppTests") } + lines.append(contentsOf: xcodeRunSection(testingFramework: testingFramework, testTarget: testTarget)) + + return lines.joined(separator: "\n") + "\n" + } + + private func xcodeRunSection(testingFramework: TestingFramework, testTarget: String?) -> [String] { + var lines: [String] = [] lines.append("") lines.append("# Per-mutant test timeout in seconds (default: 120)") lines.append("timeout: 120") @@ -76,28 +83,9 @@ struct ConfigurationFileWriter: Sendable { } else { lines.append("concurrency: 4") } - 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("# - \"**/Generated/**\"") - } - + lines.append(contentsOf: reportSection(testTarget: testTarget, excludeExample: "**/Generated/**")) lines.append(contentsOf: mutatorsSection()) - - return lines.joined(separator: "\n") + "\n" + return lines } private func generateSPMContent(testTargets: [String], testTarget: String?) -> String { @@ -113,37 +101,41 @@ struct ConfigurationFileWriter: Sendable { if let testTarget { lines.append("# Limit test execution to a specific target") - lines.append("testTarget: \(testTarget)") + lines.append("test-target: \(testTarget)") } else { lines.append("# Limit test execution to a specific target") - lines.append("# testTarget: MyPackageTests") + lines.append("# test-target: MyPackageTests") } lines.append("") lines.append("# Per-mutant test timeout in seconds (default: 30 for SPM)") lines.append("timeout: 30") + lines.append(contentsOf: reportSection(testTarget: testTarget, excludeExample: "**/Tests/**")) + lines.append(contentsOf: mutatorsSection()) + + return lines.joined(separator: "\n") + "\n" + } + + private func reportSection(testTarget: String?, excludeExample: String) -> [String] { + var lines: [String] = [] lines.append("") lines.append("# Disable result cache (re-runs all mutants on every execution)") - lines.append("# noCache: true") + lines.append("# no-cache: 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("output: mutation-report.json") + lines.append("# html-output: mutation-report.html") + lines.append("# sonar-output: 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("# - \"\(excludeExample)\"") } - - lines.append(contentsOf: mutatorsSection()) - - return lines.joined(separator: "\n") + "\n" + return lines } private func mutatorsSection() -> [String] { diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift index 70ffa46..87d86d5 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift @@ -32,23 +32,23 @@ struct ConfigurationResolver: Sendable { projectPath: projectPath, build: .init( projectType: projectType, - testTarget: cliArguments.build.testTarget ?? fileValues["testTarget"], + testTarget: cliArguments.build.testTarget ?? fileValues["test-target"], timeout: timeout, concurrency: effectiveConcurrency, - noCache: cliArguments.build.noCache || fileValues["noCache"]?.lowercased() == "true", + noCache: cliArguments.build.noCache || fileValues["no-cache"]?.lowercased() == "true", testingFramework: testingFramework ), reporting: .init( output: cliArguments.reporting.output ?? fileValues["output"], - htmlOutput: cliArguments.reporting.htmlOutput ?? fileValues["htmlOutput"], - sonarOutput: cliArguments.reporting.sonarOutput ?? fileValues["sonarOutput"], + htmlOutput: cliArguments.reporting.htmlOutput ?? fileValues["html-output"], + sonarOutput: cliArguments.reporting.sonarOutput ?? fileValues["sonar-output"], quiet: cliArguments.reporting.quiet || fileValues["quiet"]?.lowercased() == "true" ), filter: .init( - sourcesPath: cliArguments.filter.sourcesPath ?? fileValues["sourcesPath"], + sourcesPath: cliArguments.filter.sourcesPath ?? fileValues["sources-path"], excludePatterns: resolveList( cli: cliArguments.filter.excludePatterns, - keys: ["exclude", "excludePatterns"], + keys: ["exclude", "exclude-patterns"], from: fileValues ), operators: resolveOperators(cli: cliArguments, fileValues: fileValues) @@ -104,7 +104,7 @@ struct ConfigurationResolver: Sendable { private func resolvedTestingFramework(cli: ParsedArguments, fileValues: [String: String]) throws -> TestingFramework { - let raw = cli.build.testingFramework ?? fileValues["testingFramework"] + let raw = cli.build.testingFramework ?? fileValues["testing-framework"] guard let raw else { return .swiftTesting @@ -127,7 +127,7 @@ struct ConfigurationResolver: Sendable { return DiscoveryPipeline.allOperatorNames.filter { !disabled.contains($0) } } - let fileDisabled = resolveList(cli: [], keys: ["disabledMutators"], from: fileValues) + let fileDisabled = resolveList(cli: [], keys: ["disabled-mutators"], from: fileValues) if !fileDisabled.isEmpty { let disabled = Set(fileDisabled) return DiscoveryPipeline.allOperatorNames.filter { !disabled.contains($0) } diff --git a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift index 5f42c73..c406f3b 100644 --- a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift +++ b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift @@ -1,8 +1,4 @@ struct DetectedProject: Sendable { - enum Kind: Sendable { - case xcode(scheme: String?, allSchemes: [String], destination: String) - case spm(testTargets: [String]) - } static let empty = DetectedProject( kind: .xcode(scheme: nil, allSchemes: [], destination: "platform=macOS"), @@ -14,18 +10,26 @@ struct DetectedProject: Sendable { let testTarget: String? var testingFramework: TestingFramework = .swiftTesting + // periphery:ignore var scheme: String? { guard case .xcode(let xScheme, _, _) = kind else { return nil } return xScheme } + // periphery:ignore var allSchemes: [String] { guard case .xcode(_, let all, _) = kind else { return [] } return all } + // periphery:ignore var destination: String { guard case .xcode(_, _, let dest) = kind else { return "platform=macOS" } return dest } + + enum Kind: Sendable { + case xcode(scheme: String?, allSchemes: [String], destination: String) + case spm(testTargets: [String]) + } } diff --git a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift index 071ada6..224124d 100644 --- a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift +++ b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift @@ -69,12 +69,14 @@ struct ProjectDetector: Sendable { ) async -> (schemes: [String], projectName: String?, testTarget: String?) { guard let result = try? await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), - arguments: [container.flag, container.path, "-list", "-json"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: workingDirectory, - timeout: 30 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), + arguments: [container.flag, container.path, "-list", "-json"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: workingDirectory, + timeout: 30 + ) ), result.exitCode == 0 else { @@ -87,12 +89,14 @@ struct ProjectDetector: Sendable { 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, - additionalEnvironment: [:], - workingDirectoryURL: projectURL, - timeout: 30 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: ["package", "dump-package"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: projectURL, + timeout: 30 + ) ), result.exitCode == 0, let data = result.output.data(using: .utf8), @@ -197,12 +201,14 @@ struct ProjectDetector: Sendable { private func queryBestDevice(for platform: String, selecting: ([String]) -> String?) async -> String? { guard let result = try? await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: ["simctl", "list", "devices", "available", "--json"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "."), - timeout: 10 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: ["simctl", "list", "devices", "available", "--json"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "."), + timeout: 10 + ) ), result.exitCode == 0, let data = result.output.data(using: .utf8), @@ -235,18 +241,16 @@ struct ProjectDetector: Sendable { searchURL = projectURL } - guard let enumerator = FileManager.default.enumerator( + let enumerator = FileManager.default.enumerator( at: searchURL, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] - ) else { - return .swiftTesting - } + ) var hasXCTest = false var hasSwiftTesting = false - while let url = enumerator.nextObject() as? URL { + while let url = enumerator?.nextObject() as? URL { guard url.pathExtension == "swift" else { continue } guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue } diff --git a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift index 2f26b5d..5eadfc0 100644 --- a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift +++ b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift @@ -17,7 +17,8 @@ struct DiscoveryPipeline: Sendable { let ops = resolvedOperators(from: input.operators) let mutationPoints = await MutantDiscoveryStage(operators: ops).run(sources: parsedSources) let indexed = MutantIndexingStage().run(mutationPoints: mutationPoints, sources: parsedSources) - let (schematizedFiles, schematizableDescriptors) = SchematizationStage().run(indexed: indexed, sources: parsedSources) + let (schematizedFiles, schematizableDescriptors) = SchematizationStage() + .run(indexed: indexed, sources: parsedSources) let incompatibleDescriptors = IncompatibleRewritingStage().run(indexed: indexed, sources: parsedSources) let allDescriptors = (schematizableDescriptors + incompatibleDescriptors) .sorted { indexFromID($0.id) < indexFromID($1.id) } @@ -35,7 +36,7 @@ struct DiscoveryPipeline: Sendable { } private func indexFromID(_ id: String) -> Int { - Int(id.replacingOccurrences(of: "swift-mutation-testing_", with: "")) ?? 0 + Int(id.replacingOccurrences(of: "swift-mutation-testing_", with: ""))! } private func resolvedOperators(from identifiers: [String]) -> [any MutationOperator] { diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift index af43d39..045624e 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift @@ -6,34 +6,10 @@ struct IncompatibleRewritingStage: Sendable { return incompatible.compactMap { entry in guard let source = sourceByPath[entry.mutation.filePath] else { return nil } - let mutatedContent = rewriter.rewrite(source: source.file.content, applying: entry.mutation) - return makeDescriptor(from: entry.mutation, id: mutantID(entry.index), isSchematizable: false, mutatedContent: mutatedContent) + let mutatedContent = rewriter.rewrite( + source: source.file.content, applying: entry.mutation + ) + return entry.toDescriptor(mutatedContent: mutatedContent) } } - - private func mutantID(_ index: Int) -> String { - "swift-mutation-testing_\(index)" - } - - private func makeDescriptor( - from mutation: MutationPoint, - id: String, - isSchematizable: Bool, - mutatedContent: String? - ) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: mutation.filePath, - line: mutation.line, - column: mutation.column, - utf8Offset: mutation.utf8Offset, - originalText: mutation.originalText, - mutatedText: mutation.mutatedText, - operatorIdentifier: mutation.operatorIdentifier, - replacementKind: mutation.replacement, - description: mutation.description, - isSchematizable: isSchematizable, - mutatedSourceContent: mutatedContent - ) - } } diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift index eaf37ca..e95bf17 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift @@ -2,4 +2,25 @@ struct IndexedMutationPoint: Sendable { let index: Int let mutation: MutationPoint let isSchematizable: Bool + + var mutantID: String { + "swift-mutation-testing_\(index)" + } + + func toDescriptor(mutatedContent: String?) -> MutantDescriptor { + MutantDescriptor( + id: mutantID, + filePath: mutation.filePath, + line: mutation.line, + column: mutation.column, + utf8Offset: mutation.utf8Offset, + originalText: mutation.originalText, + mutatedText: mutation.mutatedText, + operatorIdentifier: mutation.operatorIdentifier, + replacementKind: mutation.replacement, + description: mutation.description, + isSchematizable: isSchematizable, + mutatedSourceContent: mutatedContent + ) + } } diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift index 42d48f2..a780959 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift @@ -1,5 +1,3 @@ -import Foundation - struct SchematizationStage: Sendable { static let supportFileContent = """ import Foundation @@ -25,39 +23,10 @@ struct SchematizationStage: Sendable { schematizedFiles.append(SchematizedFile(originalPath: filePath, schematizedContent: content)) for entry in entries { - descriptors.append(makeDescriptor( - from: entry.mutation, id: mutantID(entry.index), - isSchematizable: true, mutatedContent: nil - )) + descriptors.append(entry.toDescriptor(mutatedContent: nil)) } } return (schematizedFiles, descriptors) } - - private func mutantID(_ index: Int) -> String { - "swift-mutation-testing_\(index)" - } - - private func makeDescriptor( - from mutation: MutationPoint, - id: String, - isSchematizable: Bool, - mutatedContent: String? - ) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: mutation.filePath, - line: mutation.line, - column: mutation.column, - utf8Offset: mutation.utf8Offset, - originalText: mutation.originalText, - mutatedText: mutation.mutatedText, - operatorIdentifier: mutation.operatorIdentifier, - replacementKind: mutation.replacement, - description: mutation.description, - isSchematizable: isSchematizable, - mutatedSourceContent: mutatedContent - ) - } } diff --git a/Sources/SwiftMutationTesting/Discovery/Schematization/MutationRewriter.swift b/Sources/SwiftMutationTesting/Discovery/Schematization/MutationRewriter.swift index 0667ff7..f9c1de7 100644 --- a/Sources/SwiftMutationTesting/Discovery/Schematization/MutationRewriter.swift +++ b/Sources/SwiftMutationTesting/Discovery/Schematization/MutationRewriter.swift @@ -1,9 +1,8 @@ struct MutationRewriter: Sendable { func rewrite(source: String, applying mutation: MutationPoint) -> String { - guard let sourceData = source.data(using: .utf8), - let originalData = mutation.originalText.data(using: .utf8), - let mutatedData = mutation.mutatedText.data(using: .utf8) - else { return source } + let sourceData = source.data(using: .utf8)! + let originalData = mutation.originalText.data(using: .utf8)! + let mutatedData = mutation.mutatedText.data(using: .utf8)! let offset = mutation.utf8Offset @@ -12,6 +11,6 @@ struct MutationRewriter: Sendable { var result = sourceData result.replaceSubrange(offset ..< offset + originalData.count, with: mutatedData) - return String(data: result, encoding: .utf8) ?? source + return String(data: result, encoding: .utf8)! } } diff --git a/Sources/SwiftMutationTesting/Discovery/Schematization/SchemataGenerator.swift b/Sources/SwiftMutationTesting/Discovery/Schematization/SchemataGenerator.swift index 89bca67..6b17c0a 100644 --- a/Sources/SwiftMutationTesting/Discovery/Schematization/SchemataGenerator.swift +++ b/Sources/SwiftMutationTesting/Discovery/Schematization/SchemataGenerator.swift @@ -60,17 +60,16 @@ struct SchemataGenerator: Sendable { } private func extract(from content: String, start: Int, end: Int) -> String? { - guard let data = content.data(using: .utf8), - start >= 0, end <= data.count, start <= end + let data = content.data(using: .utf8)! + guard start >= 0, end <= data.count, start <= end else { return nil } - return String(data: data.subdata(in: start ..< end), encoding: .utf8) + return String(data: data.subdata(in: start ..< end), encoding: .utf8)! } private func apply(_ mutation: MutationPoint, to statementsText: String, startOffset: Int) -> String { - guard let statementsData = statementsText.data(using: .utf8), - let originalData = mutation.originalText.data(using: .utf8), - let mutatedData = mutation.mutatedText.data(using: .utf8) - else { return statementsText } + let statementsData = statementsText.data(using: .utf8)! + let originalData = mutation.originalText.data(using: .utf8)! + let mutatedData = mutation.mutatedText.data(using: .utf8)! let relativeOffset = mutation.utf8Offset - startOffset @@ -79,7 +78,7 @@ struct SchemataGenerator: Sendable { var result = statementsData result.replaceSubrange(relativeOffset ..< relativeOffset + originalData.count, with: mutatedData) - return String(data: result, encoding: .utf8) ?? statementsText + return String(data: result, encoding: .utf8)! } private func buildSwitchBody( @@ -104,13 +103,13 @@ struct SchemataGenerator: Sendable { ) -> String { - guard let contentData = content.data(using: .utf8), - let replacementData = replacement.data(using: .utf8), - start >= 0, end <= contentData.count + let contentData = content.data(using: .utf8)! + let replacementData = replacement.data(using: .utf8)! + guard start >= 0, end <= contentData.count else { return content } var result = contentData result.replaceSubrange(start ..< end, with: replacementData) - return String(data: result, encoding: .utf8) ?? content + return String(data: result, encoding: .utf8)! } } diff --git a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift index 48a43c1..c8604da 100644 --- a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift @@ -1,5 +1,3 @@ -import Foundation - struct FallbackExecutor: Sendable { let deps: ExecutionDeps let configuration: RunnerConfiguration @@ -57,7 +55,6 @@ struct FallbackExecutor: Sendable { do { artifact = try await BuildStage(launcher: deps.launcher).buildSPM( sandbox: sandbox, - testTarget: configuration.build.testTarget, timeout: configuration.build.timeout ) await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 80f8c7c..3df3e03 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -31,10 +31,13 @@ struct IncompatibleMutantExecutor: Sendable { if case .spm = configuration.build.projectType { results += try await runSPMShared( mutants: pending, configuration: configuration, testFilesHash: testFilesHash) - } else { + } else if case .xcode(let scheme, _) = configuration.build.projectType { for mutant in pending { let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) - results.append(try await run(mutant: mutant, key: key, configuration: configuration, pool: pool)) + results.append( + try await run( + mutant: mutant, key: key, scheme: scheme, + configuration: configuration, pool: pool)) } } @@ -60,12 +63,14 @@ struct IncompatibleMutantExecutor: Sendable { let sandbox = try await sandboxFactory.createClean(projectPath: configuration.projectPath) let initialBuild = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: spmBuildArguments(configuration: configuration), - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: spmBuildArguments(), + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) ) guard initialBuild.exitCode == 0 else { @@ -77,18 +82,13 @@ struct IncompatibleMutantExecutor: Sendable { return results } - let projectRoot = URL(fileURLWithPath: configuration.projectPath).resolvingSymlinksInPath().path - let sandboxRoot = sandbox.rootURL.resolvingSymlinksInPath().path - for mutant in viable { let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) let result = try await runInSharedSandbox( mutant: mutant, key: key, configuration: configuration, - sandbox: sandbox, - projectRoot: projectRoot, - sandboxRoot: sandboxRoot + sandbox: sandbox ) results.append(result) } @@ -97,7 +97,7 @@ struct IncompatibleMutantExecutor: Sendable { return results } - private func spmBuildArguments(configuration: RunnerConfiguration) -> [String] { + private func spmBuildArguments() -> [String] { ["build", "--build-tests"] } @@ -105,24 +105,18 @@ struct IncompatibleMutantExecutor: Sendable { mutant: MutantDescriptor, key: MutantCacheKey, configuration: RunnerConfiguration, - sandbox: Sandbox, - projectRoot: String, - sandboxRoot: String + sandbox: Sandbox ) async throws -> ExecutionResult { - let originalCanonical = URL(fileURLWithPath: mutant.filePath).resolvingSymlinksInPath().path - - guard originalCanonical.hasPrefix(projectRoot), let content = mutant.mutatedSourceContent else { - return await storeAndReport(mutant: mutant, key: key, sandbox: nil) - } + let projectRoot = URL(fileURLWithPath: configuration.projectPath) + .resolvingSymlinksInPath().path + let sandboxRoot = sandbox.rootURL.resolvingSymlinksInPath().path + let originalCanonical = URL(fileURLWithPath: mutant.filePath).resolvingSymlinksInPath().path + let content = mutant.mutatedSourceContent! let relative = String(originalCanonical.dropFirst(projectRoot.count)) let sandboxFilePath = sandboxRoot + relative - do { - try content.write(toFile: sandboxFilePath, atomically: true, encoding: .utf8) - } catch { - return await storeAndReport(mutant: mutant, key: key, sandbox: nil) - } + try content.write(toFile: sandboxFilePath, atomically: true, encoding: .utf8) defer { try? FileManager.default.removeItem(atPath: sandboxFilePath) @@ -134,18 +128,31 @@ struct IncompatibleMutantExecutor: Sendable { ) let build = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: spmBuildArguments(configuration: configuration), - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: spmBuildArguments(), + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) ) guard build.exitCode == 0 else { return await storeAndReport(mutant: mutant, key: key, sandbox: nil) } + return try await runSPMTest( + mutant: mutant, key: key, configuration: configuration, sandbox: sandbox + ) + } + + private func runSPMTest( + mutant: MutantDescriptor, + key: MutantCacheKey, + configuration: RunnerConfiguration, + sandbox: Sandbox + ) async throws -> ExecutionResult { var testArgs = ["test", "--skip-build"] if let testTarget = configuration.build.testTarget { testArgs += ["--filter", testTarget] @@ -153,12 +160,14 @@ struct IncompatibleMutantExecutor: Sendable { let start = Date() let test = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: testArgs, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: testArgs, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) ) let duration = Date().timeIntervalSince(start) @@ -176,6 +185,7 @@ struct IncompatibleMutantExecutor: Sendable { private func run( mutant: MutantDescriptor, key: MutantCacheKey, + scheme: String, configuration: RunnerConfiguration, pool: SimulatorPool ) async throws -> ExecutionResult { @@ -192,7 +202,8 @@ struct IncompatibleMutantExecutor: Sendable { let slot = try await pool.acquire() let launched: TestLaunchResult do { - launched = try await launch(slot: slot, sandbox: sandbox, configuration: configuration) + launched = try await launchXcode( + scheme: scheme, slot: slot, sandbox: sandbox, configuration: configuration) } catch { await pool.release(slot) try? sandbox.cleanup() @@ -217,20 +228,6 @@ struct IncompatibleMutantExecutor: Sendable { return ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) } - private func launch( - slot: SimulatorSlot, - sandbox: Sandbox, - configuration: RunnerConfiguration - ) async throws -> TestLaunchResult { - switch configuration.build.projectType { - case .xcode(let scheme, _): - return try await launchXcode( - scheme: scheme, slot: slot, sandbox: sandbox, configuration: configuration) - case .spm: - return try await launchSPM(sandbox: sandbox, configuration: configuration) - } - } - private func launchXcode( scheme: String, slot: SimulatorSlot, @@ -256,12 +253,14 @@ struct IncompatibleMutantExecutor: Sendable { let start = Date() let captured = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) ) return TestLaunchResult( @@ -272,34 +271,6 @@ struct IncompatibleMutantExecutor: Sendable { ) } - private func launchSPM( - sandbox: Sandbox, - configuration: RunnerConfiguration - ) async throws -> TestLaunchResult { - var arguments = ["test"] - - if let testTarget = configuration.build.testTarget { - arguments += ["--filter", testTarget] - } - - let start = Date() - let captured = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout - ) - - return TestLaunchResult( - exitCode: captured.exitCode, - output: captured.output, - xcresultPath: "", - duration: Date().timeIntervalSince(start) - ) - } - private func storeAndReport( mutant: MutantDescriptor, key: MutantCacheKey, diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 6b6f024..cded4d1 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -10,6 +10,24 @@ struct MutantExecutor: Sendable { private let configuration: RunnerConfiguration private let launcher: any ProcessLaunching + private struct MutantRunContext { + let deps: ExecutionDeps + let input: RunnerInput + let sandbox: Sandbox + let pool: SimulatorPool + let artifact: BuildArtifact? + let schemaBuildExcluded: [MutantDescriptor] + let testFilesHash: String + } + + private struct RetryContext { + let sandbox: Sandbox + let input: RunnerInput + let stage: BuildStage + let deps: ExecutionDeps + let start: Date + } + func execute(_ input: RunnerInput) async throws -> [ExecutionResult] { let reporter: any ProgressReporter = configuration.reporting.quiet @@ -47,13 +65,15 @@ struct MutantExecutor: Sendable { let results: [ExecutionResult] do { results = try await runAllMutants( - deps: deps, - input: input, - sandbox: sandbox, - pool: pool, - artifact: artifact, - schemaBuildExcluded: schemaBuildExcluded, - testFilesHash: testFilesHash + MutantRunContext( + deps: deps, + input: input, + sandbox: sandbox, + pool: pool, + artifact: artifact, + schemaBuildExcluded: schemaBuildExcluded, + testFilesHash: testFilesHash + ) ) } catch { await pool.tearDown() @@ -69,14 +89,15 @@ struct MutantExecutor: Sendable { } private func runAllMutants( - deps: ExecutionDeps, - input: RunnerInput, - sandbox: Sandbox, - pool: SimulatorPool, - artifact: BuildArtifact?, - schemaBuildExcluded: [MutantDescriptor], - testFilesHash: String + _ context: MutantRunContext ) async throws -> [ExecutionResult] { + let deps = context.deps + let input = context.input + let sandbox = context.sandbox + let pool = context.pool + let artifact = context.artifact + let schemaBuildExcluded = context.schemaBuildExcluded + let testFilesHash = context.testFilesHash let schematizable = input.mutants.filter { $0.isSchematizable } let incompatible = input.mutants.filter { !$0.isSchematizable } @@ -167,19 +188,18 @@ struct MutantExecutor: Sendable { do { let artifact = try await stage.buildSPM( sandbox: sandbox, - testTarget: configuration.build.testTarget, timeout: configuration.build.timeout ) await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) return (artifact, []) } catch BuildError.compilationFailed(let output) { + let retryCtx = RetryContext( + sandbox: sandbox, input: input, + stage: stage, deps: deps, start: start + ) let (artifact, excluded) = try await retryExcludingErrors( output: output, - sandbox: sandbox, - input: input, - stage: stage, - deps: deps, - start: start, + context: retryCtx, alreadyExcluded: [] ) return (artifact, excluded) @@ -189,24 +209,14 @@ struct MutantExecutor: Sendable { private func retryExcludingErrors( output: String, - sandbox: Sandbox, - input: RunnerInput, - stage: BuildStage, - deps: ExecutionDeps, - start: Date, + context: RetryContext, alreadyExcluded: [MutantDescriptor] ) async throws -> (BuildArtifact?, [MutantDescriptor]) { + let sandbox = context.sandbox + let input = context.input let sandboxRoot = canonicalPath(sandbox.rootURL.path) let projectRoot = URL(fileURLWithPath: input.projectPath).resolvingSymlinksInPath().path - - let errorSandboxPaths = Set( - output.components(separatedBy: "\n").compactMap { line -> String? in - guard line.hasPrefix(sandboxRoot) else { return nil } - let path = line.components(separatedBy: ":").first ?? "" - return path.hasSuffix(".swift") ? path : nil - } - ) - + let errorSandboxPaths = extractErrorPaths(from: output, sandboxRoot: sandboxRoot) let alreadyExcludedIDs = Set(alreadyExcluded.map(\.id)) var newlyExcluded: [MutantDescriptor] = [] @@ -217,10 +227,10 @@ struct MutantExecutor: Sendable { guard FileManager.default.fileExists(atPath: originalPath) else { continue } - let mutantsInFile = input.mutants.filter { m in - m.isSchematizable - && !alreadyExcludedIDs.contains(m.id) - && URL(fileURLWithPath: m.filePath).resolvingSymlinksInPath().path == originalPath + let mutantsInFile = input.mutants.filter { mutant in + mutant.isSchematizable + && !alreadyExcludedIDs.contains(mutant.id) + && URL(fileURLWithPath: mutant.filePath).resolvingSymlinksInPath().path == originalPath } guard !mutantsInFile.isEmpty else { continue } @@ -240,26 +250,33 @@ struct MutantExecutor: Sendable { let allExcluded = alreadyExcluded + newlyExcluded do { - let artifact = try await stage.buildSPM( + let artifact = try await context.stage.buildSPM( sandbox: sandbox, - testTarget: configuration.build.testTarget, timeout: configuration.build.timeout ) - await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) + await context.deps.reporter.report( + .buildFinished(duration: Date().timeIntervalSince(context.start)) + ) return (artifact, allExcluded) } catch BuildError.compilationFailed(let newOutput) { return try await retryExcludingErrors( output: newOutput, - sandbox: sandbox, - input: input, - stage: stage, - deps: deps, - start: start, + context: context, alreadyExcluded: allExcluded ) } } + private func extractErrorPaths(from output: String, sandboxRoot: String) -> Set { + Set( + output.components(separatedBy: "\n").compactMap { line -> String? in + guard line.hasPrefix(sandboxRoot) else { return nil } + let path = line.components(separatedBy: ":").first ?? "" + return path.hasSuffix(".swift") ? path : nil + } + ) + } + private func runNormal( deps: ExecutionDeps, context: TestExecutionContext, @@ -295,12 +312,14 @@ struct MutantExecutor: Sendable { } _ = try? await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: sandbox.rootURL, - timeout: configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: sandbox.rootURL, + timeout: configuration.build.timeout + ) ) } @@ -333,15 +352,15 @@ struct MutantExecutor: Sendable { for errorLine in errorLines { let lineIndex = errorLine - 1 guard lineIndex >= 0, lineIndex < lines.count else { continue } - var i = lineIndex - while i >= 0 { - let trimmed = lines[i].trimmingCharacters(in: .whitespaces) + var searchIndex = lineIndex + while searchIndex >= 0 { + let trimmed = lines[searchIndex].trimmingCharacters(in: .whitespaces) if let id = mutantCaseID(from: trimmed), mutantIDs.contains(id) { problematicIDs.insert(id) break } if trimmed == "default:" || trimmed.hasPrefix("switch ") { break } - i -= 1 + searchIndex -= 1 } } @@ -400,11 +419,12 @@ struct MutantExecutor: Sendable { let source: String if let cached = sourceCache[mutant.filePath] { source = cached - } else if let loaded = try? String(contentsOfFile: mutant.filePath, encoding: .utf8) { + } else { + guard let loaded = try? String(contentsOfFile: mutant.filePath, encoding: .utf8) else { + return nil + } sourceCache[mutant.filePath] = loaded source = loaded - } else { - return nil } let point = MutationPoint( diff --git a/Sources/SwiftMutationTesting/Execution/Parsing/ResultParser.swift b/Sources/SwiftMutationTesting/Execution/Parsing/ResultParser.swift index 7a3de1a..c3f42c8 100644 --- a/Sources/SwiftMutationTesting/Execution/Parsing/ResultParser.swift +++ b/Sources/SwiftMutationTesting/Execution/Parsing/ResultParser.swift @@ -13,12 +13,17 @@ struct ResultParser: Sendable { if exitCode == 0 { return .testsSucceeded } let xcresultRaw = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: ["xcresulttool", "get", "test-results", "tests", "--path", xcresultPath], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: [ + "xcresulttool", "get", "test-results", + "tests", "--path", xcresultPath, + ], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: timeout + ) ) if xcresultRaw.exitCode == 0 { diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 2fea394..6601b0b 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -73,7 +73,12 @@ struct TestExecutionStage: Sendable { let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) await deps.cacheStore.store(status: status, for: key) let index = await deps.counter.increment() - await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) + await deps.reporter.report( + .mutantFinished( + descriptor: mutant, status: status, + index: index, total: deps.counter.total + ) + ) return result } @@ -97,7 +102,12 @@ struct TestExecutionStage: Sendable { let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) await deps.cacheStore.store(status: status, for: key) let index = await deps.counter.increment() - await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) + await deps.reporter.report( + .mutantFinished( + descriptor: mutant, status: status, + index: index, total: deps.counter.total + ) + ) return result } @@ -113,12 +123,16 @@ struct TestExecutionStage: Sendable { let start = Date() let captured = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/swift"), - arguments: arguments, - environment: nil, - additionalEnvironment: ["__SWIFT_MUTATION_TESTING_ACTIVE": mutant.id], - workingDirectoryURL: context.sandbox.rootURL, - timeout: context.configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + environment: nil, + additionalEnvironment: [ + "__SWIFT_MUTATION_TESTING_ACTIVE": mutant.id + ], + workingDirectoryURL: context.sandbox.rootURL, + timeout: context.configuration.build.timeout + ) ) return TestLaunchResult( @@ -159,12 +173,14 @@ struct TestExecutionStage: Sendable { let start = Date() let captured = try await deps.launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), - arguments: arguments, - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: context.sandbox.rootURL, - timeout: context.configuration.build.timeout + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), + arguments: arguments, + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: context.sandbox.rootURL, + timeout: context.configuration.build.timeout + ) ) return TestLaunchResult( diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift index 042634c..b9d9634 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift @@ -9,11 +9,6 @@ protocol ProcessLaunching: Sendable { ) async throws -> Int32 func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) } diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessRequest.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessRequest.swift new file mode 100644 index 0000000..7df243e --- /dev/null +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessRequest.swift @@ -0,0 +1,10 @@ +import Foundation + +struct ProcessRequest: Sendable { + let executableURL: URL + let arguments: [String] + let environment: [String: String]? + let additionalEnvironment: [String: String] + let workingDirectoryURL: URL + let timeout: Double +} diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessRunner.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessRunner.swift new file mode 100644 index 0000000..93c420e --- /dev/null +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessRunner.swift @@ -0,0 +1,162 @@ +import Foundation + +struct ProcessRunner: Sendable { + var postTerminationCleanup: (@Sendable (Int32) -> Void)? + let onTimeout: @Sendable (Int32) -> Void + + private struct CaptureTarget { + let fileHandle: FileHandle + let tempURL: URL + } + + final class KilledByUsFlag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return flag + } + + func mark() { + lock.lock() + flag = true + lock.unlock() + } + } + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.currentDirectoryURL = workingDirectoryURL + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + let killedByUs = KilledByUsFlag() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.startProcess( + process, killedByUs: killedByUs, timeout: timeout, + continuation: continuation + ) + } + } onCancel: { + killedByUs.mark() + onTimeout(process.processIdentifier) + } + } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + let process = Process() + process.executableURL = request.executableURL + process.arguments = request.arguments + process.currentDirectoryURL = request.workingDirectoryURL + + if let environment = request.environment { + process.environment = environment + } + + if !request.additionalEnvironment.isEmpty { + var env = process.environment ?? ProcessInfo.processInfo.environment + for (key, value) in request.additionalEnvironment { + env[key] = value + } + process.environment = env + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + FileManager.default.createFile(atPath: tempURL.path, contents: nil) + let fileHandle = try FileHandle(forWritingTo: tempURL) + process.standardOutput = fileHandle + process.standardError = fileHandle + + let killedByUs = KilledByUsFlag() + + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.startCapturingProcess( + process, killedByUs: killedByUs, timeout: request.timeout, + capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), + continuation: continuation + ) + } + } onCancel: { + killedByUs.mark() + onTimeout(process.processIdentifier) + } + } + + private func startProcess( + _ process: Process, + killedByUs: KilledByUsFlag, + timeout: Double, + continuation: CheckedContinuation + ) { + let timeoutTask = Task { + try await Task.sleep(for: .seconds(timeout)) + killedByUs.mark() + onTimeout(process.processIdentifier) + } + + process.terminationHandler = { proc in + timeoutTask.cancel() + postTerminationCleanup?(proc.processIdentifier) + let exitCode: Int32 = killedByUs.value ? -1 : proc.terminationStatus + continuation.resume(returning: exitCode) + } + + do { + try process.run() + setpgid(process.processIdentifier, process.processIdentifier) + } catch { + timeoutTask.cancel() + continuation.resume(throwing: error) + } + } + + private func startCapturingProcess( + _ process: Process, + killedByUs: KilledByUsFlag, + timeout: Double, + capture: CaptureTarget, + continuation: CheckedContinuation<(exitCode: Int32, output: String), any Error> + ) { + let timeoutTask = Task { + try await Task.sleep(for: .seconds(timeout)) + killedByUs.mark() + onTimeout(process.processIdentifier) + } + + process.terminationHandler = { terminated in + timeoutTask.cancel() + postTerminationCleanup?(terminated.processIdentifier) + capture.fileHandle.closeFile() + let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" + try? FileManager.default.removeItem(at: capture.tempURL) + let exitCode: Int32 = killedByUs.value ? -1 : terminated.terminationStatus + continuation.resume(returning: (exitCode: exitCode, output: output)) + } + + do { + try process.run() + setpgid(process.processIdentifier, process.processIdentifier) + } catch { + timeoutTask.cancel() + capture.fileHandle.closeFile() + try? FileManager.default.removeItem(at: capture.tempURL) + continuation.resume(throwing: error) + } + } + +} diff --git a/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift index 5f6534a..bad92af 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift @@ -7,202 +7,68 @@ struct SPMProcessLauncher: Sendable, ProcessLaunching { workingDirectoryURL: URL, timeout: Double ) async throws -> Int32 { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.currentDirectoryURL = workingDirectoryURL - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - - let killedByUs = KilledByUsFlag() let sandboxPath = workingDirectoryURL.path - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.startProcess(process, killedByUs: killedByUs, timeout: timeout, sandboxPath: sandboxPath, continuation: continuation) - } - } onCancel: { - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } + return try await makeRunner(sandboxPath: sandboxPath).launch( + executableURL: executableURL, + arguments: arguments, + workingDirectoryURL: workingDirectoryURL, + timeout: timeout + ) } func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.currentDirectoryURL = workingDirectoryURL - - if let environment { - process.environment = environment - } - - if !additionalEnvironment.isEmpty { - var env = process.environment ?? ProcessInfo.processInfo.environment - for (key, value) in additionalEnvironment { - env[key] = value - } - process.environment = env - } - - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - FileManager.default.createFile(atPath: tempURL.path, contents: nil) - let fileHandle = try FileHandle(forWritingTo: tempURL) - process.standardOutput = fileHandle - process.standardError = fileHandle - - let killedByUs = KilledByUsFlag() - let sandboxPath = workingDirectoryURL.path + let sandboxPath = request.workingDirectoryURL.path + return try await makeRunner(sandboxPath: sandboxPath).launchCapturing(request) + } - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.startCapturingProcess( - process, killedByUs: killedByUs, timeout: timeout, - sandboxPath: sandboxPath, - capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), - continuation: continuation - ) + private func makeRunner(sandboxPath: String) -> ProcessRunner { + ProcessRunner( + postTerminationCleanup: { pid in + kill(-pid, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) + }, + onTimeout: { pid in + guard pid > 0 else { return } + kill(-pid, SIGTERM) + Task { + try? await Task.sleep(for: .seconds(5)) + kill(-pid, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) + } } - } onCancel: { - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } + ) } +} - private func startProcess( - _ process: Process, - killedByUs: KilledByUsFlag, - timeout: Double, - sandboxPath: String, - continuation: CheckedContinuation - ) { - let timeoutTask = Task { - try await Task.sleep(for: .seconds(timeout)) - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } +func killEscapedChildren(sandboxPath: String) { + let sandboxName = URL(fileURLWithPath: sandboxPath).lastPathComponent + guard sandboxName.hasPrefix("xmr-") else { return } + guard let pathData = sandboxName.data(using: .utf8) else { return } - process.terminationHandler = { proc in - timeoutTask.cancel() - kill(-proc.processIdentifier, SIGKILL) - killEscapedChildren(sandboxPath: sandboxPath) - let exitCode: Int32 = killedByUs.value ? -1 : proc.terminationStatus - continuation.resume(returning: exitCode) - } + var size = 0 + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] + guard sysctl(&mib, 4, nil, &size, nil, 0) == 0, size > 0 else { return } - do { - try process.run() - setpgid(process.processIdentifier, process.processIdentifier) - } catch { - timeoutTask.cancel() - continuation.resume(throwing: error) - } - } + let procSize = MemoryLayout.stride + var procs = [kinfo_proc](repeating: kinfo_proc(), count: size / procSize) + guard sysctl(&mib, 4, &procs, &size, nil, 0) == 0 else { return } - private func startCapturingProcess( - _ process: Process, - killedByUs: KilledByUsFlag, - timeout: Double, - sandboxPath: String, - capture: CaptureTarget, - continuation: CheckedContinuation<(exitCode: Int32, output: String), any Error> - ) { - let timeoutTask = Task { - try await Task.sleep(for: .seconds(timeout)) - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } + for index in 0 ..< (size / procSize) { + let pid = procs[index].kp_proc.p_pid + guard pid > 1 else { continue } - process.terminationHandler = { terminated in - timeoutTask.cancel() - kill(-terminated.processIdentifier, SIGKILL) - killEscapedChildren(sandboxPath: sandboxPath) - capture.fileHandle.closeFile() - let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" - try? FileManager.default.removeItem(at: capture.tempURL) - let exitCode: Int32 = killedByUs.value ? -1 : terminated.terminationStatus - continuation.resume(returning: (exitCode: exitCode, output: output)) - } + var argSize = 0 + var argMib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] + guard sysctl(&argMib, 3, nil, &argSize, nil, 0) == 0, argSize > 0 else { continue } - do { - try process.run() - setpgid(process.processIdentifier, process.processIdentifier) - } catch { - timeoutTask.cancel() - capture.fileHandle.closeFile() - try? FileManager.default.removeItem(at: capture.tempURL) - continuation.resume(throwing: error) - } - } + var argBuf = [UInt8](repeating: 0, count: argSize) + guard sysctl(&argMib, 3, &argBuf, &argSize, nil, 0) == 0 else { continue } - private func terminateProcessGroup(pid: Int32, sandboxPath: String) { - guard pid > 0 else { return } - kill(-pid, SIGTERM) - Task { - try? await Task.sleep(for: .seconds(5)) + if Data(argBuf[.. 0 else { return } - - let procSize = MemoryLayout.stride - var procs = [kinfo_proc](repeating: kinfo_proc(), count: size / procSize) - guard sysctl(&mib, 4, &procs, &size, nil, 0) == 0 else { return } - - for index in 0..<(size / procSize) { - let pid = procs[index].kp_proc.p_pid - guard pid > 1 else { continue } - - var argSize = 0 - var argMib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid] - guard sysctl(&argMib, 3, nil, &argSize, nil, 0) == 0, argSize > 0 else { continue } - - var argBuf = [UInt8](repeating: 0, count: argSize) - guard sysctl(&argMib, 3, &argBuf, &argSize, nil, 0) == 0 else { continue } - - if Data(argBuf[.. Int32 { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.currentDirectoryURL = workingDirectoryURL - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - - let killedByUs = KilledByUsFlag() - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.startProcess(process, killedByUs: killedByUs, timeout: timeout, continuation: continuation) - } - } onCancel: { - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) - } + try await makeRunner().launch( + executableURL: executableURL, + arguments: arguments, + workingDirectoryURL: workingDirectoryURL, + timeout: timeout + ) } func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) { - let process = Process() - process.executableURL = executableURL - process.arguments = arguments - process.currentDirectoryURL = workingDirectoryURL - - if let environment { - process.environment = environment - } - - if !additionalEnvironment.isEmpty { - var env = process.environment ?? ProcessInfo.processInfo.environment - for (key, value) in additionalEnvironment { - env[key] = value - } - process.environment = env - } - - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - FileManager.default.createFile(atPath: tempURL.path, contents: nil) - let fileHandle = try FileHandle(forWritingTo: tempURL) - process.standardOutput = fileHandle - process.standardError = fileHandle - - let killedByUs = KilledByUsFlag() - - return try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { continuation in - self.startCapturingProcess( - process, killedByUs: killedByUs, timeout: timeout, - capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), - continuation: continuation - ) - } - } onCancel: { - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) - } + try await makeRunner().launchCapturing(request) } - private func startProcess( - _ process: Process, - killedByUs: KilledByUsFlag, - timeout: Double, - continuation: CheckedContinuation - ) { - let timeoutTask = Task { - try await Task.sleep(for: .seconds(timeout)) - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) - } - - process.terminationHandler = { proc in - timeoutTask.cancel() - let exitCode: Int32 = killedByUs.value ? -1 : proc.terminationStatus - continuation.resume(returning: exitCode) - } - - do { - try process.run() - setpgid(process.processIdentifier, process.processIdentifier) - } catch { - timeoutTask.cancel() - continuation.resume(throwing: error) - } - } - - private func startCapturingProcess( - _ process: Process, - killedByUs: KilledByUsFlag, - timeout: Double, - capture: CaptureTarget, - continuation: CheckedContinuation<(exitCode: Int32, output: String), any Error> - ) { - let timeoutTask = Task { - try await Task.sleep(for: .seconds(timeout)) - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier) - } - - process.terminationHandler = { terminated in - timeoutTask.cancel() - capture.fileHandle.closeFile() - let output = (try? String(contentsOf: capture.tempURL, encoding: .utf8)) ?? "" - try? FileManager.default.removeItem(at: capture.tempURL) - let exitCode: Int32 = killedByUs.value ? -1 : terminated.terminationStatus - continuation.resume(returning: (exitCode: exitCode, output: output)) - } - - do { - try process.run() - setpgid(process.processIdentifier, process.processIdentifier) - } catch { - timeoutTask.cancel() - capture.fileHandle.closeFile() - try? FileManager.default.removeItem(at: capture.tempURL) - continuation.resume(throwing: error) - } - } - - private func terminateProcessGroup(pid: Int32) { - guard pid > 0 else { return } - kill(-pid, SIGTERM) - Task { - try? await Task.sleep(for: .seconds(5)) - kill(-pid, SIGKILL) - } - } - - private struct CaptureTarget { - let fileHandle: FileHandle - let tempURL: URL - } - - private final class KilledByUsFlag: @unchecked Sendable { - private let lock = NSLock() - private var flag = false - - var value: Bool { - lock.lock() - defer { lock.unlock() } - return flag - } - - func mark() { - lock.lock() - flag = true - lock.unlock() - } + private func makeRunner() -> ProcessRunner { + ProcessRunner( + onTimeout: { pid in + guard pid > 0 else { return } + kill(-pid, SIGTERM) + Task { + try? await Task.sleep(for: .seconds(5)) + kill(-pid, SIGKILL) + } + } + ) } } diff --git a/Sources/SwiftMutationTesting/Reporting/TextReporter.swift b/Sources/SwiftMutationTesting/Reporting/TextReporter.swift index 18df205..0c34267 100644 --- a/Sources/SwiftMutationTesting/Reporting/TextReporter.swift +++ b/Sources/SwiftMutationTesting/Reporting/TextReporter.swift @@ -56,11 +56,21 @@ struct TextReporter: Sendable { + " / Unviable: \(summary.unviable.count)" + " / NoCoverage: \(summary.noCoverage.count)" ) - lines.append("Total duration: \(String(format: "%.1f", summary.totalDuration))s") + lines.append("Total duration: \(formattedDuration(summary.totalDuration))") return lines.joined(separator: "\n") } + private func formattedDuration(_ seconds: Double) -> String { + let totalSeconds = Int(seconds) + let minutes = totalSeconds / 60 + let remainingSeconds = totalSeconds % 60 + if minutes > 0 { + return "\(minutes)m \(remainingSeconds)s" + } + return String(format: "%.1fs", seconds) + } + private func relative(_ path: String) -> String { guard !resolvedRoot.isEmpty else { return path } let resolvedPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path diff --git a/Sources/SwiftMutationTesting/Simulator/SimulatorError.swift b/Sources/SwiftMutationTesting/Simulator/SimulatorError.swift index 48b58da..e96f3f3 100644 --- a/Sources/SwiftMutationTesting/Simulator/SimulatorError.swift +++ b/Sources/SwiftMutationTesting/Simulator/SimulatorError.swift @@ -1,5 +1,20 @@ -enum SimulatorError: Error { +import Foundation + +enum SimulatorError: Error, LocalizedError { case deviceNotFound(destination: String) case bootTimeout(udid: String) case cloneFailed(udid: String) + + var errorDescription: String? { + switch self { + case .deviceNotFound(let destination): + return "No simulator found matching destination: \(destination)" + + case .bootTimeout(let udid): + return "Simulator \(udid) did not boot within the expected time." + + case .cloneFailed(let udid): + return "Failed to clone simulator \(udid)." + } + } } diff --git a/Sources/SwiftMutationTesting/Simulator/SimulatorManager.swift b/Sources/SwiftMutationTesting/Simulator/SimulatorManager.swift index 507be59..e2e4e9d 100644 --- a/Sources/SwiftMutationTesting/Simulator/SimulatorManager.swift +++ b/Sources/SwiftMutationTesting/Simulator/SimulatorManager.swift @@ -29,12 +29,14 @@ struct SimulatorManager: Sendable { ) async throws { for _ in 0 ..< maxAttempts { let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: ["simctl", "list", "devices", "--json"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: ["simctl", "list", "devices", "--json"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) ) if isBooted(udid: udid, in: result.output) { return } @@ -55,12 +57,14 @@ struct SimulatorManager: Sendable { private func findUDID(named name: String, destination: String) async throws -> String { let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: ["simctl", "list", "devices", "--json"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: ["simctl", "list", "devices", "--json"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) ) guard diff --git a/Sources/SwiftMutationTesting/Simulator/SimulatorPool.swift b/Sources/SwiftMutationTesting/Simulator/SimulatorPool.swift index 4fad837..504ad98 100644 --- a/Sources/SwiftMutationTesting/Simulator/SimulatorPool.swift +++ b/Sources/SwiftMutationTesting/Simulator/SimulatorPool.swift @@ -51,12 +51,14 @@ actor SimulatorPool { for index in 0 ..< size { group.addTask { let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), - arguments: ["simctl", "clone", base, "XMR-\(session)-\(index)"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 60 + ProcessRequest( + executableURL: URL(fileURLWithPath: "/usr/bin/xcrun"), + arguments: ["simctl", "clone", base, "XMR-\(session)-\(index)"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 60 + ) ) guard result.exitCode == 0 else { diff --git a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift index 3cd1a6c..5c3ee36 100644 --- a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift +++ b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift @@ -12,13 +12,6 @@ public struct SwiftMutationTesting { } catch let error as UsageError { fputs(error.message + "\n", stderr) return .error - } catch BuildError.compilationFailed(let output) { - if !output.isEmpty { fputs(output + "\n", stderr) } - fputs("Build failed. The schematized source could not be compiled.\n", stderr) - return .error - } catch SimulatorError.deviceNotFound(let dest) { - fputs("Simulator not found for destination: \(dest)\n", stderr) - return .error } catch { fputs("Error: \(error.localizedDescription)\n", stderr) return .error @@ -65,15 +58,7 @@ public struct SwiftMutationTesting { )) } - let executionLauncher: any ProcessLaunching - if let launcher { - executionLauncher = launcher - } else { - executionLauncher = switch configuration.build.projectType { - case .xcode: XcodeProcessLauncher() - case .spm: SPMProcessLauncher() - } - } + let executionLauncher: any ProcessLaunching = launcher ?? defaultLauncher(for: configuration.build.projectType) let start = Date() let results = try await MutantExecutor(configuration: configuration, launcher: executionLauncher).execute(input) @@ -129,6 +114,13 @@ public struct SwiftMutationTesting { } } + static func defaultLauncher(for projectType: ProjectType) -> any ProcessLaunching { + switch projectType { + case .xcode: XcodeProcessLauncher() + case .spm: SPMProcessLauncher() + } + } + private static func writeReport(label: String, to path: String, _ write: () throws -> Void) { do { try write() diff --git a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift index 2ae2626..e584101 100644 --- a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift @@ -3,10 +3,6 @@ import Testing @testable import SwiftMutationTesting -extension Tag { - @Tag static var integration: Self -} - @Suite(.tags(.integration)) struct MutantExecutorIntegrationTests { @@ -16,7 +12,10 @@ struct MutantExecutorIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - let results = try await MutantExecutor(configuration: configuration, launcher: XcodeProcessLauncher()).execute(input) + let results = try await MutantExecutor( + configuration: configuration, + launcher: XcodeProcessLauncher() + ).execute(input) let killed = results.filter { if case .killed = $0.status { return true } @@ -39,7 +38,10 @@ struct MutantExecutorIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - _ = try await MutantExecutor(configuration: configuration, launcher: XcodeProcessLauncher()).execute(input) + _ = try await MutantExecutor( + configuration: configuration, + launcher: XcodeProcessLauncher() + ).execute(input) let after = try String(contentsOf: calculatorURL, encoding: .utf8) diff --git a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift index 9441c84..709564c 100644 --- a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift @@ -12,7 +12,10 @@ struct MutantExecutorSPMIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - let results = try await MutantExecutor(configuration: configuration, launcher: SPMProcessLauncher()).execute(input) + let results = try await MutantExecutor( + configuration: configuration, + launcher: SPMProcessLauncher() + ).execute(input) let killed = results.filter { if case .killed = $0.status { return true } @@ -35,7 +38,10 @@ struct MutantExecutorSPMIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - _ = try await MutantExecutor(configuration: configuration, launcher: SPMProcessLauncher()).execute(input) + _ = try await MutantExecutor( + configuration: configuration, + launcher: SPMProcessLauncher() + ).execute(input) let after = try String(contentsOf: calculatorURL, encoding: .utf8) @@ -178,7 +184,7 @@ private func incompatibleMutants(path: String) -> [MutantDescriptor] { func isNonNegative(_ n: Int) -> Bool { n > 0 } } """ - ), + ) ] } diff --git a/Tests/SwiftMutationTestingTests/TestSupport/FallbackBuildSucceedingMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/FallbackBuildSucceedingMock.swift new file mode 100644 index 0000000..2b742c9 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/FallbackBuildSucceedingMock.swift @@ -0,0 +1,34 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor FallbackBuildSucceedingMock: ProcessLaunching { + private var captureCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { + 0 + } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + captureCount += 1 + if captureCount == 1 { return (1, "") } + if let idx = request.arguments.firstIndex(of: "-derivedDataPath"), + idx + 1 < request.arguments.count + { + let productsURL = URL(fileURLWithPath: request.arguments[idx + 1]) + .appendingPathComponent("Build/Products") + try? FileManager.default.createDirectory(at: productsURL, withIntermediateDirectories: true) + let plist: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] + let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + try? data?.write(to: productsURL.appendingPathComponent("fake.xctestrun")) + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift index d7993bf..1c642a1 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift @@ -13,11 +13,10 @@ struct IOSSimulatorMock: ProcessLaunching { } func launchCapturing( - executableURL: URL, arguments: [String], environment: [String: String]?, - additionalEnvironment: [String: String], workingDirectoryURL: URL, timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) { - if arguments.contains("clone") { return (0, cloneUDID + "\n") } - if executableURL.lastPathComponent == "xcodebuild" { return (1, "") } + if request.arguments.contains("clone") { return (0, cloneUDID + "\n") } + if request.executableURL.lastPathComponent == "xcodebuild" { return (1, "") } return (0, listJSON) } } diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IntegrationTag.swift b/Tests/SwiftMutationTestingTests/TestSupport/IntegrationTag.swift new file mode 100644 index 0000000..11bbb2d --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/IntegrationTag.swift @@ -0,0 +1,5 @@ +import Testing + +extension Tag { + @Tag static var integration: Self +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift index cc4d02b..10f07a3 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift @@ -31,15 +31,10 @@ struct MockProcessLauncher: ProcessLaunching { } func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) { if throwsOnCapture { throw CocoaError(.fileReadNoSuchFile) } - let key = executableURL.lastPathComponent + let key = request.executableURL.lastPathComponent return responses[key] ?? (exitCode: exitCode, output: output) } diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMBuildSuccessTestFailureMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMBuildSuccessTestFailureMock.swift new file mode 100644 index 0000000..bbf1e65 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMBuildSuccessTestFailureMock.swift @@ -0,0 +1,25 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMBuildSuccessTestFailureMock: ProcessLaunching { + private let failureOutput: String + + init(failureOutput: String) { + self.failureOutput = failureOutput + } + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + if request.arguments.first == "test" { return (1, failureOutput) } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMDoubleFailMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMDoubleFailMock.swift new file mode 100644 index 0000000..4e4aeb8 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMDoubleFailMock.swift @@ -0,0 +1,40 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMDoubleFailMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + guard request.arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + + let root = request.workingDirectoryURL.path + let resolvedRoot = root.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return root } + defer { free(resolved) } + return String(cString: resolved) + } + + if buildCallCount == 1 { + let fooPath = resolvedRoot + "/Foo.swift" + return (1, "\(fooPath):1:5: error: cannot convert value") + } + + if buildCallCount == 2 { + let barPath = resolvedRoot + "/Bar.swift" + return (1, "\(barPath):1:5: error: cannot convert value") + } + + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMErrorWithoutLineNumberMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMErrorWithoutLineNumberMock.swift new file mode 100644 index 0000000..cac03b7 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMErrorWithoutLineNumberMock.swift @@ -0,0 +1,31 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMErrorWithoutLineNumberMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + guard request.arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = request.workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical): error: module 'Missing' not found") + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMInitialBuildSuccessThenFailMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMInitialBuildSuccessThenFailMock.swift new file mode 100644 index 0000000..74012ce --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMInitialBuildSuccessThenFailMock.swift @@ -0,0 +1,25 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMInitialBuildSuccessThenFailMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + if request.arguments.contains("--build-tests") || request.arguments.first == "build" { + buildCallCount += 1 + if buildCallCount == 1 { return (0, "") } + return (1, "error: build failed") + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMNarrowExclusionMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMNarrowExclusionMock.swift new file mode 100644 index 0000000..761fc0a --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMNarrowExclusionMock.swift @@ -0,0 +1,31 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMNarrowExclusionMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + guard request.arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = request.workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical):4:1: error: type mismatch") + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMRetryExcludingErrorsMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMRetryExcludingErrorsMock.swift new file mode 100644 index 0000000..684556c --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMRetryExcludingErrorsMock.swift @@ -0,0 +1,31 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMRetryExcludingErrorsMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + guard request.arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = request.workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical):1:5: error: cannot convert value") + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SPMSingleCaseExclusionMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SPMSingleCaseExclusionMock.swift new file mode 100644 index 0000000..ffb849f --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SPMSingleCaseExclusionMock.swift @@ -0,0 +1,31 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SPMSingleCaseExclusionMock: ProcessLaunching { + private var buildCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + guard request.arguments.first == "build" else { return (0, "") } + buildCallCount += 1 + if buildCallCount == 1 { + let fooPath = request.workingDirectoryURL.appendingPathComponent("Foo.swift").path + let canonical = fooPath.withCString { ptr in + guard let resolved = realpath(ptr, nil) else { return fooPath } + defer { free(resolved) } + return String(cString: resolved) + } + return (1, "\(canonical):4:1: error: type mismatch") + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SequentialOutputMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SequentialOutputMock.swift new file mode 100644 index 0000000..16a8e64 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SequentialOutputMock.swift @@ -0,0 +1,29 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor SequentialOutputMock: ProcessLaunching { + private let outputs: [String] + private var callIndex = 0 + + init(outputs: [String]) { + self.outputs = outputs + } + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { + 0 + } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + let output = outputs[min(callIndex, outputs.count - 1)] + callIndex += 1 + return (0, output) + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SimulatorCommandMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/SimulatorCommandMock.swift index 491bba5..c9b4a4d 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/SimulatorCommandMock.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/SimulatorCommandMock.swift @@ -23,18 +23,13 @@ struct SimulatorCommandMock: ProcessLaunching { } func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double + _ request: ProcessRequest ) async throws -> (exitCode: Int32, output: String) { - if arguments.contains("clone") { + if request.arguments.contains("clone") { return (0, cloneUDID + "\n") } - if arguments.contains("list") { + if request.arguments.contains("list") { return (0, listOutput) } diff --git a/Tests/SwiftMutationTestingTests/TestSupport/ThrowingDuringTestMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/ThrowingDuringTestMock.swift new file mode 100644 index 0000000..9257faa --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/ThrowingDuringTestMock.swift @@ -0,0 +1,26 @@ +import Foundation + +@testable import SwiftMutationTesting + +actor ThrowingDuringTestMock: ProcessLaunching { + private var testCallCount = 0 + + func launch( + executableURL: URL, + arguments: [String], + workingDirectoryURL: URL, + timeout: Double + ) async throws -> Int32 { 0 } + + func launchCapturing( + _ request: ProcessRequest + ) async throws -> (exitCode: Int32, output: String) { + if request.arguments.first == "test" { + testCallCount += 1 + if testCallCount > 1 { + throw CocoaError(.fileReadNoSuchFile) + } + } + return (0, "") + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Build/BuildErrorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Build/BuildErrorTests.swift new file mode 100644 index 0000000..0a0b640 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Build/BuildErrorTests.swift @@ -0,0 +1,39 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("BuildError") +struct BuildErrorTests { + @Test("Given compilationFailed with output, when errorDescription accessed, then includes output and message") + func compilationFailedWithOutput() { + let error = BuildError.compilationFailed(output: "error: missing semicolon") + #expect(error.errorDescription?.contains("missing semicolon") == true) + #expect(error.errorDescription?.contains("Build failed") == true) + } + + @Test("Given compilationFailed with empty output, when errorDescription accessed, then returns build failed message") + func compilationFailedEmptyOutput() { + let error = BuildError.compilationFailed(output: "") + #expect(error.errorDescription == "Build failed. The schematized source could not be compiled.") + } + + @Test("Given xctestrunNotFound, when errorDescription accessed, then returns expected message") + func xctestrunNotFound() { + let error = BuildError.xctestrunNotFound + #expect(error.errorDescription == "xctestrun file not found after build.") + } + + @Test("Given two compilationFailed errors, when compared, then they are equal") + func compilationFailedEquality() { + let lhs = BuildError.compilationFailed(output: "a") + let rhs = BuildError.compilationFailed(output: "b") + #expect(lhs == rhs) + } + + @Test("Given compilationFailed and xctestrunNotFound, when compared, then they are not equal") + func differentCasesNotEqual() { + let lhs = BuildError.compilationFailed(output: "a") + let rhs = BuildError.xctestrunNotFound + #expect(lhs != rhs) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift index deb4651..d17ec43 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift @@ -159,7 +159,7 @@ struct BuildStageTests { let sandbox = Sandbox(rootURL: projectDir) let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 0)) - let artifact = try await stage.buildSPM(sandbox: sandbox, testTarget: nil, timeout: 60) + let artifact = try await stage.buildSPM(sandbox: sandbox, timeout: 60) #expect(artifact.derivedDataPath == projectDir.appendingPathComponent(".build").path) #expect(artifact.xctestrunURL == nil) @@ -175,25 +175,11 @@ struct BuildStageTests { let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 1)) await #expect { - try await stage.buildSPM(sandbox: sandbox, testTarget: nil, timeout: 60) + try await stage.buildSPM(sandbox: sandbox, timeout: 60) } throws: { error in guard case BuildError.compilationFailed = error else { return false } return true } } - @Test("Given SPM build with test target, when buildSPM called, then target flag is included in arguments") - func spmBuildWithTestTargetIncludesTargetFlag() async throws { - let projectDir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(projectDir) } - - let launcher = MockProcessLauncher(exitCode: 0) - let sandbox = Sandbox(rootURL: projectDir) - let stage = BuildStage(launcher: launcher) - - let artifact = try await stage.buildSPM(sandbox: sandbox, testTarget: "MyLibTests", timeout: 60) - - #expect(artifact.xctestrunURL == nil) - #expect(artifact.plist == nil) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift index f0f97f6..5f1d850 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift @@ -76,6 +76,18 @@ struct SwiftMutationTestingExecutionPathTests { #expect(result == .success) } + @Test("Given xcode project type, when defaultLauncher called, then returns XcodeProcessLauncher") + func defaultLauncherForXcodeReturnsXcodeProcessLauncher() { + let launcher = SwiftMutationTesting.defaultLauncher(for: .xcode(scheme: "S", destination: "d")) + #expect(launcher is XcodeProcessLauncher) + } + + @Test("Given spm project type, when defaultLauncher called, then returns SPMProcessLauncher") + func defaultLauncherForSPMReturnsSPMProcessLauncher() { + let launcher = SwiftMutationTesting.defaultLauncher(for: .spm) + #expect(launcher is SPMProcessLauncher) + } + @Test("Given corrupted cache file at project path, when run called, then returns error") func corruptedCacheFileReturnsError() async throws { let dir = try FileHelpers.makeTemporaryDirectory() diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift index 26e7d2a..395ef9e 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift @@ -105,20 +105,20 @@ struct WriteReportsTests { #expect(FileManager.default.fileExists(atPath: htmlPath)) #expect(FileManager.default.fileExists(atPath: sonarPath)) } -} -private func makeConfiguration( - projectPath: String, - output: String? = nil, - htmlOutput: String? = nil, - sonarOutput: String? = nil -) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init( - 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: []) - ) + private func makeConfiguration( + projectPath: String, + output: String? = nil, + htmlOutput: String? = nil, + sonarOutput: String? = nil + ) -> RunnerConfiguration { + RunnerConfiguration( + projectPath: projectPath, + build: .init( + 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/ConfigurationFileParserTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileParserTests.swift index bdcba62..304a1e6 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileParserTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileParserTests.swift @@ -107,14 +107,14 @@ struct ConfigurationFileParserTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write( - "excludePatterns:\n - /Generated/\n - /Pods/\n", + "exclude-patterns:\n - /Generated/\n - /Pods/\n", named: ".swift-mutation-testing.yml", in: dir ) let result = try parser.parse(at: dir.path) - #expect(result["excludePatterns"] == "/Generated/,/Pods/") + #expect(result["exclude-patterns"] == "/Generated/,/Pods/") } @Test("Given mutators block with all active true, when parsed, then disabledMutators is absent") @@ -133,7 +133,7 @@ struct ConfigurationFileParserTests { let result = try parser.parse(at: dir.path) - #expect(result["disabledMutators"] == nil) + #expect(result["disabled-mutators"] == nil) } @Test("Given mutators block with some active false, when parsed, then disabledMutators lists them") @@ -154,7 +154,7 @@ struct ConfigurationFileParserTests { let result = try parser.parse(at: dir.path) - #expect(result["disabledMutators"] == "RemoveSideEffects,SwapTernary") + #expect(result["disabled-mutators"] == "RemoveSideEffects,SwapTernary") } @Test("Given a config file with sourcesPath key, when parsed, then value is returned") @@ -163,13 +163,13 @@ struct ConfigurationFileParserTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write( - "sourcesPath: /my/sources\n", + "sources-path: /my/sources\n", named: ".swift-mutation-testing.yml", in: dir ) let result = try parser.parse(at: dir.path) - #expect(result["sourcesPath"] == "/my/sources") + #expect(result["sources-path"] == "/my/sources") } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift index 14fab69..1d68c8e 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift @@ -62,8 +62,8 @@ struct ConfigurationFileWriterTests { ) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(content.contains("testTarget: MyAppTests")) - #expect(!content.contains("# testTarget:")) + #expect(content.contains("test-target: MyAppTests")) + #expect(!content.contains("# test-target:")) } @Test("Given detected destination, when write called, then destination is filled") @@ -154,8 +154,8 @@ struct ConfigurationFileWriterTests { try writer.write(to: dir.path, project: .empty) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(content.contains("# noCache: true")) - #expect(!content.contains("\nnoCache:")) + #expect(content.contains("# no-cache: true")) + #expect(!content.contains("\nno-cache:")) } @Test("Given any project, when write called, then output is set to mutation-report.json") @@ -166,7 +166,8 @@ struct ConfigurationFileWriterTests { try writer.write(to: dir.path, project: .empty) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(content.contains("# output: mutation-report.json")) + #expect(content.contains("output: mutation-report.json")) + #expect(!content.contains("# output:")) } @Test("Given any project, when write called, then mutators section lists all operators as active") @@ -198,7 +199,7 @@ struct ConfigurationFileWriterTests { ) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(content.contains("testingFramework")) + #expect(content.contains("testing-framework")) #expect(content.contains("swift-testing")) } @@ -217,7 +218,7 @@ struct ConfigurationFileWriterTests { ) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(content.contains("testingFramework: xctest")) + #expect(content.contains("testing-framework: xctest")) #expect(content.contains("concurrency: 1")) } @@ -235,7 +236,28 @@ struct ConfigurationFileWriterTests { ) let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) - #expect(!content.contains("testingFramework")) + #expect(!content.contains("testing-framework")) + } + + @Test( + "Given SPM with multiple test targets and testTarget, when write called, then config is correct" + ) + func spmMultipleTestTargetsAndTestTarget() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try writer.write( + to: dir.path, + project: DetectedProject( + kind: .spm(testTargets: ["FooTests", "BarTests"]), + testTarget: "FooTests" + ) + ) + + let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) + #expect(content.contains("# Available test targets: FooTests, BarTests")) + #expect(content.contains("test-target: FooTests")) + #expect(!content.contains("# test-target:")) } @Test("Given existing config file, when write called, then throws UsageError") diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index 31d052f..843bfe2 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift @@ -149,7 +149,7 @@ struct ConfigurationResolverTests { func noCacheFromFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), - fileValues: ["noCache": "true"] + fileValues: ["no-cache": "true"] ) #expect(result.build.noCache) @@ -192,7 +192,7 @@ struct ConfigurationResolverTests { func sourcesPathFromFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), - fileValues: ["sourcesPath": "/file/sources"] + fileValues: ["sources-path": "/file/sources"] ) #expect(result.filter.sourcesPath == "/file/sources") @@ -225,7 +225,7 @@ struct ConfigurationResolverTests { func excludePatternsFromFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), - fileValues: ["excludePatterns": "/Generated/,/Pods/"] + fileValues: ["exclude-patterns": "/Generated/,/Pods/"] ) #expect(result.filter.excludePatterns == ["/Generated/", "/Pods/"]) @@ -268,7 +268,7 @@ struct ConfigurationResolverTests { func disabledMutatorsFromFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), - fileValues: ["disabledMutators": "RemoveSideEffects,SwapTernary"] + fileValues: ["disabled-mutators": "RemoveSideEffects,SwapTernary"] ) #expect(!result.filter.operators.contains("RemoveSideEffects")) @@ -297,7 +297,7 @@ struct ConfigurationResolverTests { build: .init(scheme: "App", destination: "d"), filter: .init(operators: ["NegateConditional"]) ), - fileValues: ["disabledMutators": "NegateConditional"] + fileValues: ["disabled-mutators": "NegateConditional"] ) #expect(result.filter.operators == ["NegateConditional"]) @@ -393,7 +393,7 @@ struct ConfigurationResolverTests { func testingFrameworkFromFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), - fileValues: ["testingFramework": "xctest"] + fileValues: ["testing-framework": "xctest"] ) #expect(result.build.testingFramework == .xctest) @@ -403,7 +403,7 @@ struct ConfigurationResolverTests { func testingFrameworkCLIOverridesFile() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d", testingFramework: "xctest")), - fileValues: ["testingFramework": "swift-testing"] + fileValues: ["testing-framework": "swift-testing"] ) #expect(result.build.testingFramework == .xctest) diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorCoverageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorCoverageTests.swift new file mode 100644 index 0000000..d994cec --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorCoverageTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("ProjectDetector Coverage") +struct ProjectDetectorCoverageTests { + private let projectJSON = """ + { + "project": { + "name": "MyApp", + "schemes": ["MyApp", "MyAppTests"], + "targets": ["MyApp", "MyAppTests", "MyAppUITests"] + } + } + """ + + @Test("Given SPM project with test targets, when detect called, then test targets are returned") + func detectsSPMTestTargets() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try "// swift-tools-version: 6.0".write( + to: dir.appendingPathComponent("Package.swift"), + atomically: true, encoding: .utf8 + ) + + let dumpJSON = """ + { + "targets": [ + { "name": "MyLib", "type": "regular" }, + { "name": "MyLibTests", "type": "test" } + ] + } + """ + let launcher = MockProcessLauncher(exitCode: 0, output: dumpJSON) + let result = await ProjectDetector(launcher: launcher).detect(at: dir.path) + + #expect(result.testTarget == "MyLibTests") + } + + @Test("Given SPM project and dump-package fails, when detect called, then empty test targets") + func spmDumpFailureReturnsEmptyTestTargets() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try "// swift-tools-version: 6.0".write( + to: dir.appendingPathComponent("Package.swift"), + atomically: true, encoding: .utf8 + ) + + let launcher = MockProcessLauncher(exitCode: 1, output: "") + let result = await ProjectDetector(launcher: launcher).detect(at: dir.path) + + #expect(result.testTarget == nil) + } + + @Test("Given Xcode project with test files containing import XCTest, when detect called, then xctest framework") + func detectsXCTestFrameworkFromSourceFiles() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let xcodeprojURL = dir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeprojURL, withIntermediateDirectories: true) + + let testDir = dir.appendingPathComponent("MyAppTests") + try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) + try "import XCTest\nclass Tests: XCTestCase {}".write( + to: testDir.appendingPathComponent("Tests.swift"), + atomically: true, encoding: .utf8 + ) + + let detector = ProjectDetector(launcher: MockProcessLauncher(exitCode: 0, output: projectJSON)) + let result = await detector.detect(at: dir.path) + + #expect(result.testingFramework == .xctest) + } + + @Test("Given Xcode project with both import XCTest and import Testing, when detect called, then swiftTesting") + func detectsBothFrameworksReturnsSwiftTesting() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let xcodeprojURL = dir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeprojURL, withIntermediateDirectories: true) + + let testDir = dir.appendingPathComponent("MyAppTests") + try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) + try "import XCTest\nclass OldTests: XCTestCase {}".write( + to: testDir.appendingPathComponent("OldTests.swift"), + atomically: true, encoding: .utf8 + ) + try "import Testing\n@Suite struct NewTests {}".write( + to: testDir.appendingPathComponent("NewTests.swift"), + atomically: true, encoding: .utf8 + ) + + let detector = ProjectDetector(launcher: MockProcessLauncher(exitCode: 0, output: projectJSON)) + let result = await detector.detect(at: dir.path) + + #expect(result.testingFramework == .swiftTesting) + } + + @Test("Given Xcode project with no test files, when detect called, then swiftTesting default") + func noTestFilesDefaultsToSwiftTesting() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let xcodeprojURL = dir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeprojURL, withIntermediateDirectories: true) + + let detector = ProjectDetector(launcher: MockProcessLauncher(exitCode: 0, output: projectJSON)) + let result = await detector.detect(at: dir.path) + + #expect(result.testingFramework == .swiftTesting) + } + + @Test("Given tvOS project with Apple TV but no 4K, when detect called, then uses Apple TV") + func tvOSFallsBackToAppleTVWhenNo4K() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let xcodeprojURL = dir.appendingPathComponent("MyApp.xcodeproj") + try FileManager.default.createDirectory(at: xcodeprojURL, withIntermediateDirectories: true) + try "SDKROOT = appletvos;".write( + to: xcodeprojURL.appendingPathComponent("project.pbxproj"), + atomically: true, encoding: .utf8 + ) + + let simctlJSON = """ + { + "devices": { + "com.apple.CoreSimulator.SimRuntime.tvOS-17-0": [ + { "name": "Apple TV", "isAvailable": true } + ] + } + } + """ + let launcher = MockProcessLauncher( + exitCode: 0, + output: projectJSON, + responses: ["xcrun": (exitCode: 0, output: simctlJSON)] + ) + let result = await ProjectDetector(launcher: launcher).detect(at: dir.path) + + #expect(result.destination.contains("Apple TV")) + #expect(result.destination.contains("tvOS Simulator")) + } + + @Test("Given workspace JSON without schemes key, when detect called, then schemes default to empty") + func missingSchemeKeyDefaultsToEmpty() async throws { + let json = #"{"workspace":{"name":"App"}}"# + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + try FileManager.default.createDirectory( + at: dir.appendingPathComponent("App.xcworkspace"), + withIntermediateDirectories: true + ) + + let result = await ProjectDetector(launcher: MockProcessLauncher(exitCode: 0, output: json)) + .detect(at: dir.path) + + #expect(result.scheme == nil) + #expect(result.allSchemes.isEmpty) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorTests.swift index b88683e..44d20d5 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ProjectDetectorTests.swift @@ -544,4 +544,5 @@ struct ProjectDetectorTests { #expect(result.destination == "platform=macOS") } + } diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift index c25191d..79ae03a 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift @@ -51,4 +51,12 @@ struct MutantIndexingStageTests { #expect(result[0].mutation.filePath == "a.swift") #expect(result[1].mutation.filePath == "b.swift") } + + @Test("Given mutation with filePath not in sources, when run, then isSchematizable defaults to false") + func missingSourceDefaultsToNotSchematizable() { + let source = makeParsedSource("func f() { let x = true }", path: "a.swift") + let points = BooleanLiteralReplacement().mutations(in: source) + let result = stage.run(mutationPoints: points, sources: []) + #expect(result.allSatisfy { !$0.isSchematizable }) + } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift new file mode 100644 index 0000000..bd9d420 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("FallbackExecutor") +struct FallbackExecutorTests { + @Test("Given SPM project type with successful build, when execute called, then results are returned") + func spmFallbackBuildSuccessReturnsResults() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let config = RunnerConfiguration( + projectPath: dir.path, + build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + + let launcher = MockProcessLauncher(exitCode: 0) + let counter = MutationCounter(total: 1) + let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let deps = ExecutionDeps( + launcher: launcher, + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: counter + ) + + let pool = SimulatorPool( + baseUDID: nil, size: 1, + destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) + ) + try await pool.setUp() + + let mutant = MutantDescriptor( + id: "m0", + filePath: sourceFile.path, + line: 1, + column: 1, + utf8Offset: 0, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: nil + ) + + let input = RunnerInput( + projectPath: dir.path, + projectType: .spm, + timeout: 60, + concurrency: 1, + noCache: false, + schematizedFiles: [ + SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false") + ], + supportFileContent: "", + mutants: [mutant] + ) + + let executor = FallbackExecutor(deps: deps, configuration: config) + let results = try await executor.execute(input: input, pool: pool, testFilesHash: "hash") + + #expect(results.count == 1) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift index 88a19bf..60f921e 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift @@ -3,33 +3,6 @@ import Testing @testable import SwiftMutationTesting -private actor SPMBuildSuccessTestFailureMock: ProcessLaunching { - private let failureOutput: String - - init(failureOutput: String) { - self.failureOutput = failureOutput - } - - func launch( - executableURL: URL, - arguments: [String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> Int32 { 0 } - - func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - if arguments.first == "test" { return (1, failureOutput) } - return (0, "") - } -} - @Suite("IncompatibleMutantExecutor") struct IncompatibleMutantExecutorTests { @Test("Given 3 mutants with content, when execute called, then 3 results are returned in order") @@ -303,6 +276,88 @@ struct IncompatibleMutantExecutorTests { #expect(results.first?.status == .killed(by: "myTest")) } + @Test("Given SPM project type and initial build failure, when execute called, then all viable mutants are unviable") + func spmInitialBuildFailureMarksAllUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let executor = makeExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 1)) + let pool = makePool() + try await pool.setUp() + + let mutants = [ + makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = false"), + makeMutant(id: "m1", filePath: sourceFile.path, content: "let x = 0"), + ] + + let results = try await executor.execute( + mutants, + configuration: makeConfigurationSPM(projectPath: dir.path), + pool: pool, + testFilesHash: "hash" + ) + + #expect(results.count == 2) + #expect(results.allSatisfy { $0.status == .unviable }) + } + + @Test("Given SPM project type with testTarget, when execute called, then filter is applied") + func spmTestTargetFilterIsApplied() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let output = #"Test "myTest" failed after 0.001 seconds."# + let executor = makeExecutorSPM( + in: dir, launcher: SPMBuildSuccessTestFailureMock(failureOutput: output)) + let pool = makePool() + try await pool.setUp() + + let config = RunnerConfiguration( + projectPath: dir.path, + build: .init(projectType: .spm, testTarget: "FooTests", timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + + let results = try await executor.execute( + [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], + configuration: config, + pool: pool, + testFilesHash: "hash" + ) + + #expect(results.count == 1) + } + + @Test("Given SPM project type and per-mutant build failure, when execute called, then mutant is unviable") + func spmPerMutantBuildFailureReturnsUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let executor = makeExecutorSPM( + in: dir, launcher: SPMInitialBuildSuccessThenFailMock()) + let pool = makePool() + try await pool.setUp() + + let results = try await executor.execute( + [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = INVALID")], + configuration: makeConfigurationSPM(projectPath: dir.path), + pool: pool, + testFilesHash: "hash" + ) + + #expect(results.first?.status == .unviable) + } + private func makeExecutorSPM( in dir: URL, launcher: any ProcessLaunching diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift new file mode 100644 index 0000000..95b8f26 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("MutantExecutor Coverage") +struct MutantExecutorCoverageTests { + @Test("Given test execution throws, when execute called, then error propagates after cleanup") + func testExecutionThrowingTriggersCleanup() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: ThrowingDuringTestMock() + ) + let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], + mutants: [mutant] + ) + + await #expect(throws: Error.self) { + _ = try await executor.execute(input) + } + } + + @Test("Given build error without line numbers, when retry called, then all mutants in file are excluded") + func buildErrorWithoutLineNumbersExcludesAllMutants() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMErrorWithoutLineNumberMock() + ) + let mutant = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], + mutants: [mutant] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 1) + } + + @Test("Given single case before default in schema, when that case excluded, then default line is preserved") + func singleCaseExclusionPreservesDefault() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let original = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let schematized = + "func foo() {\n" + + "switch __swiftMutationTestingID {\n" + + "case \"swift-mutation-testing_0\":\n" + + "return true\n" + + "default:\n" + + "return nil\n" + + "}\n" + + "}" + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMSingleCaseExclusionMock() + ) + let mutant = makeMutant( + id: "swift-mutation-testing_0", + filePath: fooFile.path, + isSchematizable: true + ) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized) + ], + mutants: [mutant] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 1) + } + + private func makeConfigurationSPM(projectPath: String) -> RunnerConfiguration { + RunnerConfiguration( + projectPath: projectPath, + build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + } + + private func makeInputSPM( + projectPath: String, + schematizedFiles: [SchematizedFile] = [], + mutants: [MutantDescriptor] = [] + ) -> RunnerInput { + RunnerInput( + projectPath: projectPath, + projectType: .spm, + timeout: 60, + concurrency: 1, + noCache: false, + schematizedFiles: schematizedFiles, + supportFileContent: "", + mutants: mutants + ) + } + + private func makeMutant( + id: String, + filePath: String, + isSchematizable: Bool, + mutatedContent: String? = "let x = false" + ) -> MutantDescriptor { + MutantDescriptor( + id: id, + filePath: filePath, + line: 1, + column: 1, + utf8Offset: 0, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: isSchematizable, + mutatedSourceContent: mutatedContent + ) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index e61cf1f..3628858 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -3,109 +3,8 @@ import Testing @testable import SwiftMutationTesting -private actor SPMRetryExcludingErrorsMock: ProcessLaunching { - private var buildCallCount = 0 - - func launch( - executableURL: URL, - arguments: [String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> Int32 { 0 } - - func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - guard arguments.first == "build" else { return (0, "") } - buildCallCount += 1 - if buildCallCount == 1 { - let fooPath = workingDirectoryURL.appendingPathComponent("Foo.swift").path - let canonical = fooPath.withCString { ptr in - guard let resolved = realpath(ptr, nil) else { return fooPath } - defer { free(resolved) } - return String(cString: resolved) - } - return (1, "\(canonical):1:5: error: cannot convert value") - } - return (0, "") - } -} - -private actor SPMNarrowExclusionMock: ProcessLaunching { - private var buildCallCount = 0 - - func launch( - executableURL: URL, - arguments: [String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> Int32 { 0 } - - func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - guard arguments.first == "build" else { return (0, "") } - buildCallCount += 1 - if buildCallCount == 1 { - let fooPath = workingDirectoryURL.appendingPathComponent("Foo.swift").path - let canonical = fooPath.withCString { ptr in - guard let resolved = realpath(ptr, nil) else { return fooPath } - defer { free(resolved) } - return String(cString: resolved) - } - return (1, "\(canonical):4:1: error: type mismatch") - } - return (0, "") - } -} - -private actor FallbackBuildSucceedingMock: ProcessLaunching { - private var captureCount = 0 - - func launch( - executableURL: URL, - arguments: [String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> Int32 { - 0 - } - - func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - captureCount += 1 - if captureCount == 1 { return (1, "") } - if let idx = arguments.firstIndex(of: "-derivedDataPath"), idx + 1 < arguments.count { - let productsURL = URL(fileURLWithPath: arguments[idx + 1]) - .appendingPathComponent("Build/Products") - try? FileManager.default.createDirectory(at: productsURL, withIntermediateDirectories: true) - let plist: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] - let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) - try? data?.write(to: productsURL.appendingPathComponent("fake.xctestrun")) - } - return (0, "") - } -} - @Suite("MutantExecutor") struct MutantExecutorTests { - @Test("Given empty mutant list, when execute called, then returns empty results") func emptyMutantsReturnsEmpty() async throws { let dir = try FileHelpers.makeTemporaryDirectory() @@ -339,7 +238,7 @@ struct MutantExecutorTests { #expect(results[0].status == .unviable) } - @Test("Given SPM build fails with canonical /private/var path in error, when retry succeeds, then only failing file is unviable") + @Test("Given SPM build fails with canonical path in error, when retry succeeds, then only failing file is unviable") func spmRetryExcludingErrorsMatchesCanonicalPaths() async throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } @@ -354,7 +253,10 @@ struct MutantExecutorTests { launcher: SPMRetryExcludingErrorsMock() ) let mutantFoo = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) - let mutantBar = makeMutant(id: "m1", filePath: barFile.path, isSchematizable: true, mutatedContent: "let y = false") + let mutantBar = makeMutant( + id: "m1", filePath: barFile.path, + isSchematizable: true, mutatedContent: "let y = false" + ) let input = makeInputSPM( projectPath: dir.path, schematizedFiles: [ @@ -398,21 +300,219 @@ struct MutantExecutorTests { configuration: makeConfigurationSPM(projectPath: dir.path), launcher: SPMNarrowExclusionMock() ) - let m0 = makeMutant(id: "swift-mutation-testing_0", filePath: fooFile.path, isSchematizable: true) - let m1 = makeMutant(id: "swift-mutation-testing_1", filePath: fooFile.path, isSchematizable: true, mutatedContent: "let y = false") + let mutantFoo = makeMutant( + id: "swift-mutation-testing_0", + filePath: fooFile.path, + isSchematizable: true + ) + let mutantBar = makeMutant( + id: "swift-mutation-testing_1", + filePath: fooFile.path, + isSchematizable: true, + mutatedContent: "let y = false" + ) let input = makeInputSPM( projectPath: dir.path, - schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized)], - mutants: [m0, m1] + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized) + ], + mutants: [mutantFoo, mutantBar] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 2) + let resultFirst = results.first { $0.descriptor.id == "swift-mutation-testing_0" } + let resultSecond = results.first { $0.descriptor.id == "swift-mutation-testing_1" } + #expect(resultFirst?.status == .survived) + #expect(resultSecond?.status == .survived) + } + + @Test("Given excluded mutants with same file, when rewrite attempted, then source is cached for second mutant") + func excludedMutantsFromSameFileUsesSourceCache() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMRetryExcludingErrorsMock() + ) + + let mutantA = MutantDescriptor( + id: "swift-mutation-testing_0", + filePath: fooFile.path, + line: 1, + column: 9, + utf8Offset: 8, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: nil + ) + + let mutantB = MutantDescriptor( + id: "swift-mutation-testing_1", + filePath: fooFile.path, + line: 1, + column: 9, + utf8Offset: 8, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: nil + ) + + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], + mutants: [mutantA, mutantB] + ) + + let results = try await executor.execute(input) + #expect(results.count == 2) + } + + @Test("Given excluded mutant with mismatched offset, when rewrite attempted, then marked unviable") + func excludedMutantWithMismatchedOffsetIsUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMRetryExcludingErrorsMock() + ) + + let badMutant = MutantDescriptor( + id: "swift-mutation-testing_0", + filePath: fooFile.path, + line: 1, + column: 1, + utf8Offset: 9999, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: nil + ) + + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], + mutants: [badMutant] + ) + + let results = try await executor.execute(input) + #expect(results.count == 1) + #expect(results.first?.status == .unviable) + } + + @Test("Given SPM project type with testTarget, when execute called, then testTarget is used in baseline validation") + func spmWithTestTargetAppliesFilter() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let config = RunnerConfiguration( + projectPath: dir.path, + build: .init(projectType: .spm, testTarget: "FooTests", timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + + let executor = MutantExecutor( + configuration: config, + launcher: MockProcessLauncher(exitCode: 0) + ) + let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], + mutants: [mutant] + ) + let results = try await executor.execute(input) + #expect(results.count == 1) + } + + @Test("Given SPM build fails twice with error paths, when retry recurses, then second retry excludes more mutants") + func spmRecursiveRetryExcludesAdditionalMutants() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + let barFile = dir.appendingPathComponent("Bar.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + try "let y = true".write(to: barFile, atomically: true, encoding: .utf8) + + let executor = MutantExecutor( + configuration: makeConfigurationSPM(projectPath: dir.path), + launcher: SPMDoubleFailMock() + ) + let mutantFoo = makeMutant(id: "swift-mutation-testing_0", filePath: fooFile.path, isSchematizable: true) + let mutantBar = makeMutant( + id: "swift-mutation-testing_1", filePath: barFile.path, + isSchematizable: true, mutatedContent: "let y = false" + ) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false"), + SchematizedFile(originalPath: barFile.path, schematizedContent: "let y = false"), + ], + mutants: [mutantFoo, mutantBar] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 2) + } + + @Test("Given excluded mutant with non-existent source file, when rewrite attempted, then marked unviable") + func excludedMutantWithMissingSourceIsUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let config = makeConfigurationSPM(projectPath: dir.path) + let executor = MutantExecutor( + configuration: config, + launcher: SPMRetryExcludingErrorsMock() + ) + + let validMutant = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) + let missingMutant = makeMutant( + id: "m1", + filePath: dir.appendingPathComponent("NonExistent.swift").path, + isSchematizable: true + ) + let input = makeInputSPM( + projectPath: dir.path, + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false") + ], + mutants: [validMutant, missingMutant] ) let results = try await executor.execute(input) #expect(results.count == 2) - let r0 = results.first { $0.descriptor.id == "swift-mutation-testing_0" } - let r1 = results.first { $0.descriptor.id == "swift-mutation-testing_1" } - #expect(r0?.status == .survived) - #expect(r1?.status == .survived) } private func makeConfiguration( diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift index e0561a2..d245382 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift @@ -107,14 +107,14 @@ struct TestOutputParserTests { #expect(result == .crashed) } -} -private func loadFixture(_ name: String) throws -> String { - let fixturesURL = URL(filePath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "TestSupport/Fixtures/\(name).txt") - return try String(contentsOf: fixturesURL, encoding: .utf8) + private func loadFixture(_ name: String) throws -> String { + let fixturesURL = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "TestSupport/Fixtures/\(name).txt") + return try String(contentsOf: fixturesURL, encoding: .utf8) + } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift index 9c2896c..cadc9d8 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift @@ -255,7 +255,8 @@ struct TestExecutionStageTests { try await pool.setUp() let config = RunnerConfiguration( projectPath: "/tmp", - build: .init(projectType: .spm, testTarget: "MyLibTests", + build: .init( + projectType: .spm, testTarget: "MyLibTests", timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) @@ -282,6 +283,84 @@ struct TestExecutionStageTests { #expect(results.first?.status == .survived) } + @Test( + "Given SPM launcher throws, when execute called, then pool slot is released and error propagated" + ) + func spmLaunchThrowsReleasesSlotAndPropagates() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let launcher = MockProcessLauncher(exitCode: 0, throwsOnCapture: true) + let pool = SimulatorPool( + baseUDID: nil, size: 1, + destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) + ) + try await pool.setUp() + + let stage = TestExecutionStage( + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) + ) + let config = RunnerConfiguration( + projectPath: "/tmp", + build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + let context = TestExecutionContext( + artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: config, + testFilesHash: "hash" + ) + + await #expect(throws: (any Error).self) { + try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + } + } + + @Test("Given Xcode artifact with nil xctestrunURL, when execute called, then sandbox root is used as base") + func xcodeNilXctestrunURLUsesSandboxRoot() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let launcher = MockProcessLauncher(exitCode: 0) + let pool = SimulatorPool( + baseUDID: nil, size: 1, + destination: "platform=macOS", launcher: launcher + ) + try await pool.setUp() + + let plistDict: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] + let data = try PropertyListSerialization.data(fromPropertyList: plistDict, format: .xml, options: 0) + let plist = try #require(XCTestRunPlist(data)) + + let stage = TestExecutionStage( + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) + ) + let context = TestExecutionContext( + artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: plist), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: makeConfiguration(), + testFilesHash: "hash" + ) + + let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + + #expect(results.count == 1) + } + private func makeFixture( in dir: URL, exitCode: Int32, diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestResultResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestResultResolverTests.swift new file mode 100644 index 0000000..e172558 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestResultResolverTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("TestResultResolver") +struct TestResultResolverTests { + @Test("Given SPM project type and exit code 0, when resolved, then outcome is passed") + func spmExitCodeZeroResolvesToPassed() async throws { + let resolver = TestResultResolver(launcher: MockProcessLauncher(exitCode: 0)) + let launch = TestLaunchResult(exitCode: 0, output: "", xcresultPath: "", duration: 1) + + let outcome = try await resolver.resolve(launch: launch, projectType: .spm, timeout: 60) + + #expect(outcome == .testsSucceeded) + } + + @Test("Given SPM project type and exit code 1 with failure, when resolved, then outcome is failed") + func spmExitCodeOneWithFailureResolvesToFailed() async throws { + let resolver = TestResultResolver(launcher: MockProcessLauncher(exitCode: 0)) + let output = #"Test "myTest" failed after 0.001 seconds."# + let launch = TestLaunchResult(exitCode: 1, output: output, xcresultPath: "", duration: 1) + + let outcome = try await resolver.resolve(launch: launch, projectType: .spm, timeout: 60) + + #expect(outcome == .testsFailed(failingTest: "myTest")) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/KillEscapedChildrenTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/KillEscapedChildrenTests.swift new file mode 100644 index 0000000..e1aeab2 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/KillEscapedChildrenTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("killEscapedChildren") +struct KillEscapedChildrenTests { + @Test("Given process with sandbox name in arguments, when called, then process is killed") + func killsProcessMatchingSandboxName() async throws { + let marker = "xmr-test-\(UUID().uuidString.prefix(8))" + let markerFile = "/tmp/\(marker)" + FileManager.default.createFile(atPath: markerFile, contents: nil) + defer { try? FileManager.default.removeItem(atPath: markerFile) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tail") + process.arguments = ["-f", markerFile] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + + try await Task.sleep(for: .milliseconds(100)) + #expect(process.isRunning) + + killEscapedChildren(sandboxPath: "/tmp/\(marker)") + + try await Task.sleep(for: .milliseconds(200)) + #expect(!process.isRunning) + } + + @Test("Given sandbox name without xmr prefix, when called, then no processes are killed") + func doesNotKillWhenSandboxNameLacksPrefix() async throws { + let marker = "other-test-\(UUID().uuidString.prefix(8))" + let markerFile = "/tmp/\(marker)" + FileManager.default.createFile(atPath: markerFile, contents: nil) + defer { try? FileManager.default.removeItem(atPath: markerFile) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tail") + process.arguments = ["-f", markerFile] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + defer { if process.isRunning { process.terminate() } } + + try await Task.sleep(for: .milliseconds(100)) + #expect(process.isRunning) + + killEscapedChildren(sandboxPath: "/tmp/\(marker)") + + try await Task.sleep(for: .milliseconds(200)) + #expect(process.isRunning) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift deleted file mode 100644 index 36477a1..0000000 --- a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift +++ /dev/null @@ -1,360 +0,0 @@ -import Foundation -import Testing - -@testable import SwiftMutationTesting - -@Suite("XcodeProcessLauncher") -struct XcodeProcessLauncherTests { - private let launcher = XcodeProcessLauncher() - - @Test("Given a successful executable, when launched, then returns zero exit code") - func launchReturnsSuccessExitCode() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/usr/bin/true"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(exitCode == 0) - } - - @Test("Given a failing executable, when launched, then returns non-zero exit code") - func launchReturnsFailureExitCode() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/usr/bin/false"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(exitCode != 0) - } - - @Test("Given echo command, when launched capturing, then output contains the argument") - func launchCapturingReturnsStdout() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/echo"), - arguments: ["hello world"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("hello world")) - } - - @Test("Given environment variables, when launched capturing, then process receives the variables") - func launchCapturingPassesEnvironment() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo $TEST_VAR"], - environment: ["TEST_VAR": "expected_value"], - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("expected_value")) - } - - @Test("Given stderr output, when launched capturing, then stderr is included in output") - func launchCapturingCapturesStderr() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo error_text >&2"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.output.contains("error_text")) - } - - @Test("Given long-running process and short timeout, when timeout expires, then returns minus one exit code") - func launchTimesOutAndReturnsMinus1() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 0.5 - ) - - #expect(exitCode == -1) - } - - @Test("Given non-existent executable, when launched, then throws") - func launchThrowsForNonExistentExecutable() async { - await #expect(throws: (any Error).self) { - try await launcher.launch( - executableURL: URL(fileURLWithPath: "/nonexistent/binary"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - } - } - - @Test("Given non-existent executable, when launchCapturing called, then throws") - func launchCapturingThrowsForNonExistentExecutable() async { - await #expect(throws: (any Error).self) { - try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/nonexistent/binary"), - arguments: [], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - } - } - - @Test("Given long-running process and short timeout, when launchCapturing times out, then returns minus one") - func launchCapturingTimesOut() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 0.5 - ) - - #expect(result.exitCode == -1) - } - - @Test("Given task is cancelled while launch running, when cancelled, then process is terminated") - func cancelledLaunchTerminatesProcess() async throws { - let task = Task { - try await launcher.launch( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 60 - ) - } - - try await Task.sleep(for: .milliseconds(100)) - task.cancel() - - let exitCode = try await task.value - #expect(exitCode == -1) - } - - @Test("Given additionalEnvironment, when launched capturing, then process receives merged variable") - func launchCapturingMergesAdditionalEnvironment() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo $EXTRA_VAR"], - environment: nil, - additionalEnvironment: ["EXTRA_VAR": "merged_value"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("merged_value")) - } - - @Test("Given task is cancelled while launchCapturing running, when cancelled, then process is terminated") - func cancelledLaunchCapturingTerminatesProcess() async throws { - let task = Task { - try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 60 - ) - } - - try await Task.sleep(for: .milliseconds(100)) - task.cancel() - - let result = try await task.value - #expect(result.exitCode == -1) - } -} - -@Suite("SPMProcessLauncher") -struct SPMProcessLauncherTests { - private let launcher = SPMProcessLauncher() - - @Test("Given a successful executable, when launched, then returns zero exit code") - func launchReturnsSuccessExitCode() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/usr/bin/true"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(exitCode == 0) - } - - @Test("Given a failing executable, when launched, then returns non-zero exit code") - func launchReturnsFailureExitCode() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/usr/bin/false"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(exitCode != 0) - } - - @Test("Given echo command, when launched capturing, then output contains the argument") - func launchCapturingReturnsStdout() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/echo"), - arguments: ["hello world"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("hello world")) - } - - @Test("Given environment variables, when launched capturing, then process receives the variables") - func launchCapturingPassesEnvironment() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo $TEST_VAR"], - environment: ["TEST_VAR": "expected_value"], - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("expected_value")) - } - - @Test("Given stderr output, when launched capturing, then stderr is included in output") - func launchCapturingCapturesStderr() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo error_text >&2"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.output.contains("error_text")) - } - - @Test("Given long-running process and short timeout, when timeout expires, then returns minus one exit code") - func launchTimesOutAndReturnsMinus1() async throws { - let exitCode = try await launcher.launch( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 0.5 - ) - - #expect(exitCode == -1) - } - - @Test("Given non-existent executable, when launched, then throws") - func launchThrowsForNonExistentExecutable() async { - await #expect(throws: (any Error).self) { - try await launcher.launch( - executableURL: URL(fileURLWithPath: "/nonexistent/binary"), - arguments: [], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - } - } - - @Test("Given non-existent executable, when launchCapturing called, then throws") - func launchCapturingThrowsForNonExistentExecutable() async { - await #expect(throws: (any Error).self) { - try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/nonexistent/binary"), - arguments: [], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - } - } - - @Test("Given long-running process and short timeout, when launchCapturing times out, then returns minus one") - func launchCapturingTimesOut() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 0.5 - ) - - #expect(result.exitCode == -1) - } - - @Test("Given task is cancelled while launch running, when cancelled, then process is terminated") - func cancelledLaunchTerminatesProcess() async throws { - let task = Task { - try await launcher.launch( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 60 - ) - } - - try await Task.sleep(for: .milliseconds(100)) - task.cancel() - - let exitCode = try await task.value - #expect(exitCode == -1) - } - - @Test("Given additionalEnvironment, when launched capturing, then process receives merged variable") - func launchCapturingMergesAdditionalEnvironment() async throws { - let result = try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sh"), - arguments: ["-c", "echo $EXTRA_VAR"], - environment: nil, - additionalEnvironment: ["EXTRA_VAR": "merged_value"], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 10 - ) - - #expect(result.exitCode == 0) - #expect(result.output.contains("merged_value")) - } - - @Test("Given task is cancelled while launchCapturing running, when cancelled, then process is terminated") - func cancelledLaunchCapturingTerminatesProcess() async throws { - let task = Task { - try await launcher.launchCapturing( - executableURL: URL(fileURLWithPath: "/bin/sleep"), - arguments: ["60"], - environment: nil, - additionalEnvironment: [:], - workingDirectoryURL: URL(fileURLWithPath: "/tmp"), - timeout: 60 - ) - } - - try await Task.sleep(for: .milliseconds(100)) - task.cancel() - - let result = try await task.value - #expect(result.exitCode == -1) - } -} diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/SPMProcessLauncherTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/SPMProcessLauncherTests.swift new file mode 100644 index 0000000..c4bef37 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/SPMProcessLauncherTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("SPMProcessLauncher") +struct SPMProcessLauncherTests { + private let launcher = SPMProcessLauncher() + + @Test("Given a successful executable, when launched, then returns zero exit code") + func launchReturnsSuccessExitCode() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + + #expect(exitCode == 0) + } + + @Test("Given a failing executable, when launched, then returns non-zero exit code") + func launchReturnsFailureExitCode() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + + #expect(exitCode != 0) + } + + @Test("Given echo command, when launched capturing, then output contains the argument") + func launchCapturingReturnsStdout() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/echo"), + arguments: ["hello world"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("hello world")) + } + + @Test("Given environment variables, when launched capturing, then process receives the variables") + func launchCapturingPassesEnvironment() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo $TEST_VAR"], + environment: ["TEST_VAR": "expected_value"], + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("expected_value")) + } + + @Test("Given stderr output, when launched capturing, then stderr is included in output") + func launchCapturingCapturesStderr() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo error_text >&2"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.output.contains("error_text")) + } + + @Test("Given long-running process and short timeout, when timeout expires, then returns minus one exit code") + func launchTimesOutAndReturnsMinus1() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 0.5 + ) + + #expect(exitCode == -1) + } + + @Test("Given non-existent executable, when launched, then throws") + func launchThrowsForNonExistentExecutable() async { + await #expect(throws: (any Error).self) { + try await launcher.launch( + executableURL: URL(fileURLWithPath: "/nonexistent/binary"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + } + } + + @Test("Given non-existent executable, when launchCapturing called, then throws") + func launchCapturingThrowsForNonExistentExecutable() async { + await #expect(throws: (any Error).self) { + try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/nonexistent/binary"), + arguments: [], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + } + } + + @Test("Given long-running process and short timeout, when launchCapturing times out, then returns minus one") + func launchCapturingTimesOut() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 0.5 + ) + ) + + #expect(result.exitCode == -1) + } + + @Test("Given task is cancelled while launch running, when cancelled, then process is terminated") + func cancelledLaunchTerminatesProcess() async throws { + let task = Task { + try await launcher.launch( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 60 + ) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + let exitCode = try await task.value + #expect(exitCode == -1) + } + + @Test("Given additionalEnvironment, when launched capturing, then process receives merged variable") + func launchCapturingMergesAdditionalEnvironment() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo $EXTRA_VAR"], + environment: nil, + additionalEnvironment: ["EXTRA_VAR": "merged_value"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("merged_value")) + } + + @Test("Given task is cancelled while launchCapturing running, when cancelled, then process is terminated") + func cancelledLaunchCapturingTerminatesProcess() async throws { + let task = Task { + try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 60 + ) + ) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + let result = try await task.value + #expect(result.exitCode == -1) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XCTestRunPlistTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XCTestRunPlistTests.swift index 81d0810..cac42f2 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XCTestRunPlistTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XCTestRunPlistTests.swift @@ -91,6 +91,51 @@ struct XCTestRunPlistTests { #expect(envVars["__SWIFT_MUTATION_TESTING_ACTIVE"] == "id_x") } + @Test("Given new-format plist without EnvironmentVariables, when activating, then env var is created") + func activatingCreatesEnvVarsInNewFormatWhenMissing() throws { + let plistDict: [String: Any] = [ + "TestConfigurations": [ + [ + "Name": "Config", + "TestTargets": [ + ["BlueprintName": "AppTests"] as [String: Any] + ], + ] as [String: Any] + ] + ] + let data = try PropertyListSerialization.data(fromPropertyList: plistDict, format: .xml, options: 0) + let plist = try #require(XCTestRunPlist(data)) + + let result = plist.activating("id_new") + let resultDict = try #require( + PropertyListSerialization.propertyList(from: result, options: [], format: nil) as? [String: Any] + ) + let configs = try #require(resultDict["TestConfigurations"] as? [[String: Any]]) + let targets = try #require(configs[0]["TestTargets"] as? [[String: Any]]) + let envVars = try #require(targets[0]["EnvironmentVariables"] as? [String: String]) + + #expect(envVars["__SWIFT_MUTATION_TESTING_ACTIVE"] == "id_new") + } + + @Test("Given legacy-format plist without EnvironmentVariables, when activating, then env var is created") + func activatingCreatesEnvVarsInLegacyFormatWhenMissing() throws { + let plistDict: [String: Any] = [ + "__xctestrun_metadata__": ["FormatVersion": 1], + "AppTests": ["BlueprintName": "AppTests"] as [String: Any], + ] + let data = try PropertyListSerialization.data(fromPropertyList: plistDict, format: .xml, options: 0) + let plist = try #require(XCTestRunPlist(data)) + + let result = plist.activating("id_leg") + let resultDict = try #require( + PropertyListSerialization.propertyList(from: result, options: [], format: nil) as? [String: Any] + ) + let targetDict = try #require(resultDict["AppTests"] as? [String: Any]) + let envVars = try #require(targetDict["EnvironmentVariables"] as? [String: String]) + + #expect(envVars["__SWIFT_MUTATION_TESTING_ACTIVE"] == "id_leg") + } + @Test("Given legacy-format plist, when activating mutant, then metadata key is not modified") func activatingDoesNotModifyMetadataKey() throws { let plistDict: [String: Any] = [ diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XcodeProcessLauncherTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XcodeProcessLauncherTests.swift new file mode 100644 index 0000000..550d5c0 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/XcodeProcessLauncherTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("XcodeProcessLauncher") +struct XcodeProcessLauncherTests { + private let launcher = XcodeProcessLauncher() + + @Test("Given a successful executable, when launched, then returns zero exit code") + func launchReturnsSuccessExitCode() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + + #expect(exitCode == 0) + } + + @Test("Given a failing executable, when launched, then returns non-zero exit code") + func launchReturnsFailureExitCode() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/false"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + + #expect(exitCode != 0) + } + + @Test("Given echo command, when launched capturing, then output contains the argument") + func launchCapturingReturnsStdout() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/echo"), + arguments: ["hello world"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("hello world")) + } + + @Test("Given environment variables, when launched capturing, then process receives the variables") + func launchCapturingPassesEnvironment() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo $TEST_VAR"], + environment: ["TEST_VAR": "expected_value"], + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("expected_value")) + } + + @Test("Given stderr output, when launched capturing, then stderr is included in output") + func launchCapturingCapturesStderr() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo error_text >&2"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.output.contains("error_text")) + } + + @Test("Given long-running process and short timeout, when timeout expires, then returns minus one exit code") + func launchTimesOutAndReturnsMinus1() async throws { + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 0.5 + ) + + #expect(exitCode == -1) + } + + @Test("Given non-existent executable, when launched, then throws") + func launchThrowsForNonExistentExecutable() async { + await #expect(throws: (any Error).self) { + try await launcher.launch( + executableURL: URL(fileURLWithPath: "/nonexistent/binary"), + arguments: [], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + } + } + + @Test("Given non-existent executable, when launchCapturing called, then throws") + func launchCapturingThrowsForNonExistentExecutable() async { + await #expect(throws: (any Error).self) { + try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/nonexistent/binary"), + arguments: [], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + } + } + + @Test("Given long-running process and short timeout, when launchCapturing times out, then returns minus one") + func launchCapturingTimesOut() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 0.5 + ) + ) + + #expect(result.exitCode == -1) + } + + @Test("Given task is cancelled while launch running, when cancelled, then process is terminated") + func cancelledLaunchTerminatesProcess() async throws { + let task = Task { + try await launcher.launch( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 60 + ) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + let exitCode = try await task.value + #expect(exitCode == -1) + } + + @Test("Given additionalEnvironment, when launched capturing, then process receives merged variable") + func launchCapturingMergesAdditionalEnvironment() async throws { + let result = try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "echo $EXTRA_VAR"], + environment: nil, + additionalEnvironment: ["EXTRA_VAR": "merged_value"], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 10 + ) + ) + + #expect(result.exitCode == 0) + #expect(result.output.contains("merged_value")) + } + + @Test("Given task is cancelled while launchCapturing running, when cancelled, then process is terminated") + func cancelledLaunchCapturingTerminatesProcess() async throws { + let task = Task { + try await launcher.launchCapturing( + ProcessRequest( + executableURL: URL(fileURLWithPath: "/bin/sleep"), + arguments: ["60"], + environment: nil, + additionalEnvironment: [:], + workingDirectoryURL: URL(fileURLWithPath: "/tmp"), + timeout: 60 + ) + ) + } + + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + let result = try await task.value + #expect(result.exitCode == -1) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift index adfcf77..abe1bd0 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift @@ -144,6 +144,18 @@ struct TextReporterTests { #expect(aIndex! < zIndex!) } + @Test("Given duration over 60 seconds, when format called, then duration is shown in minutes and seconds") + func formatShowsDurationInMinutes() { + let summary = RunnerSummary( + results: [makeResult(status: .killed(by: "t"))], + totalDuration: 1825.4 + ) + + let output = TextReporter().format(summary) + + #expect(output.contains("30m 25s")) + } + @Test("Given noCoverage mutant, when format called, then it appears in survived section") func noCoverageAppearsInSurvivedSection() { let summary = RunnerSummary( diff --git a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorErrorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorErrorTests.swift new file mode 100644 index 0000000..829e6c6 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorErrorTests.swift @@ -0,0 +1,24 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("SimulatorError") +struct SimulatorErrorTests { + @Test("Given deviceNotFound, when errorDescription accessed, then includes destination") + func deviceNotFound() { + let error = SimulatorError.deviceNotFound(destination: "platform=iOS Simulator") + #expect(error.errorDescription?.contains("platform=iOS Simulator") == true) + } + + @Test("Given bootTimeout, when errorDescription accessed, then includes udid") + func bootTimeout() { + let error = SimulatorError.bootTimeout(udid: "ABC-123") + #expect(error.errorDescription?.contains("ABC-123") == true) + } + + @Test("Given cloneFailed, when errorDescription accessed, then includes udid") + func cloneFailed() { + let error = SimulatorError.cloneFailed(udid: "DEF-456") + #expect(error.errorDescription?.contains("DEF-456") == true) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift index 5df730c..161e1ef 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift @@ -136,35 +136,3 @@ struct SimulatorManagerTests { #expect(threwBootTimeout) } } - -private actor SequentialOutputMock: ProcessLaunching { - private let outputs: [String] - private var callIndex = 0 - - init(outputs: [String]) { - self.outputs = outputs - } - - func launch( - executableURL: URL, - arguments: [String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> Int32 { - 0 - } - - func launchCapturing( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - let output = outputs[min(callIndex, outputs.count - 1)] - callIndex += 1 - return (0, output) - } - -}