diff --git a/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift b/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift index 7edbe26..8deae4e 100644 --- a/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift +++ b/Sources/SwiftMutationTesting/CLI/CommandLineParser.swift @@ -6,6 +6,7 @@ struct CommandLineParser: Sendable { var timeout: Double? var concurrency: Int? var noCache = false + var testingFramework: String? var output: String? var htmlOutput: String? var sonarOutput: String? @@ -66,7 +67,8 @@ struct CommandLineParser: Sendable { testTarget: flags.testTarget, timeout: flags.timeout, concurrency: flags.concurrency, - noCache: flags.noCache + noCache: flags.noCache, + testingFramework: flags.testingFramework ), reporting: .init( output: flags.output, @@ -120,6 +122,9 @@ struct CommandLineParser: Sendable { case "--no-cache": values.noCache = true + case "--testing-framework": + values.testingFramework = try nextValue(for: flag, at: &index, in: arguments) + case "--output": values.output = try nextValue(for: flag, at: &index, in: arguments) diff --git a/Sources/SwiftMutationTesting/CLI/HelpText.swift b/Sources/SwiftMutationTesting/CLI/HelpText.swift index ee639ca..0317f8b 100644 --- a/Sources/SwiftMutationTesting/CLI/HelpText.swift +++ b/Sources/SwiftMutationTesting/CLI/HelpText.swift @@ -12,8 +12,9 @@ enum HelpText { OPTIONS: --scheme Xcode scheme to build and test (Xcode projects only) --destination xcodebuild destination specifier (Xcode projects only) + --testing-framework Testing framework: xctest or swift-testing (default: swift-testing) --target Test target name - --timeout Per-mutant test timeout in seconds (default: 120) + --timeout Per-mutant test timeout in seconds (default: 120 Xcode, 30 SPM) --concurrency Number of parallel test workers (default: CPUs - 1) --no-cache Disable the result cache --output Write mutation report JSON to path diff --git a/Sources/SwiftMutationTesting/CLI/ParsedArguments.swift b/Sources/SwiftMutationTesting/CLI/ParsedArguments.swift index 38027ab..06abd8b 100644 --- a/Sources/SwiftMutationTesting/CLI/ParsedArguments.swift +++ b/Sources/SwiftMutationTesting/CLI/ParsedArguments.swift @@ -32,7 +32,8 @@ struct ParsedArguments: Sendable { testTarget: String? = nil, timeout: Double? = nil, concurrency: Int? = nil, - noCache: Bool = false + noCache: Bool = false, + testingFramework: String? = nil ) { self.scheme = scheme self.destination = destination @@ -40,6 +41,7 @@ struct ParsedArguments: Sendable { self.timeout = timeout self.concurrency = concurrency self.noCache = noCache + self.testingFramework = testingFramework } var scheme: String? @@ -48,6 +50,7 @@ struct ParsedArguments: Sendable { var timeout: Double? var concurrency: Int? var noCache: Bool + var testingFramework: String? } struct ReportingOptions: Sendable { diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift index 0d4d910..a66da7e 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift @@ -20,7 +20,8 @@ struct ConfigurationFileWriter: Sendable { scheme: scheme, allSchemes: allSchemes, destination: destination, - testTarget: project.testTarget + testTarget: project.testTarget, + testingFramework: project.testingFramework ) case .spm(let testTargets): return generateSPMContent(testTargets: testTargets, testTarget: project.testTarget) @@ -31,7 +32,8 @@ struct ConfigurationFileWriter: Sendable { scheme: String?, allSchemes: [String], destination: String, - testTarget: String? + testTarget: String?, + testingFramework: TestingFramework ) -> String { var lines: [String] = [] @@ -51,6 +53,10 @@ struct ConfigurationFileWriter: Sendable { lines.append("destination: \(destination)") 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("") if let testTarget { lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)") @@ -65,7 +71,11 @@ struct ConfigurationFileWriter: Sendable { lines.append("timeout: 120") lines.append("") lines.append("# Number of parallel workers (default: max(1, CPU count - 1))") - lines.append("concurrency: 4") + if testingFramework == .xctest { + lines.append("concurrency: 1") + } else { + lines.append("concurrency: 4") + } lines.append("") lines.append("# Disable result cache (re-runs all mutants on every execution)") lines.append("# noCache: true") @@ -110,8 +120,8 @@ struct ConfigurationFileWriter: Sendable { } lines.append("") - lines.append("# Per-mutant test timeout in seconds (default: 120)") - lines.append("timeout: 120") + lines.append("# Per-mutant test timeout in seconds (default: 30 for SPM)") + lines.append("timeout: 30") lines.append("") lines.append("# Disable result cache (re-runs all mutants on every execution)") lines.append("# noCache: true") diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift index 154eea3..70ffa46 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift @@ -6,7 +6,6 @@ struct ConfigurationResolver: Sendable { fileValues: [String: String] ) throws -> RunnerConfiguration { let projectPath = resolvedPath(cliArguments.projectPath) - let timeout = resolvedTimeout(cli: cliArguments, fileValues: fileValues) let concurrency = resolvedConcurrency(cli: cliArguments, fileValues: fileValues) guard concurrency >= 1 else { @@ -19,14 +18,25 @@ struct ConfigurationResolver: Sendable { projectPath: projectPath ) + let testingFramework = try resolvedTestingFramework(cli: cliArguments, fileValues: fileValues) + let timeout = resolvedTimeout(cli: cliArguments, fileValues: fileValues, projectType: projectType) + + let effectiveConcurrency: Int + if case .xcode = projectType, testingFramework == .xctest { + effectiveConcurrency = 1 + } else { + effectiveConcurrency = concurrency + } + return RunnerConfiguration( projectPath: projectPath, build: .init( projectType: projectType, testTarget: cliArguments.build.testTarget ?? fileValues["testTarget"], timeout: timeout, - concurrency: concurrency, - noCache: cliArguments.build.noCache || fileValues["noCache"]?.lowercased() == "true" + concurrency: effectiveConcurrency, + noCache: cliArguments.build.noCache || fileValues["noCache"]?.lowercased() == "true", + testingFramework: testingFramework ), reporting: .init( output: cliArguments.reporting.output ?? fileValues["output"], @@ -75,10 +85,15 @@ struct ConfigurationResolver: Sendable { return FileManager.default.fileExists(atPath: packageURL.path) } - private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String]) -> Double { + private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String], projectType: ProjectType) -> Double + { if let timeout = cli.build.timeout { return timeout } if let timeout = fileValues["timeout"].flatMap(Double.init) { return timeout } - return RunnerConfiguration.defaultTimeout + + return switch projectType { + case .xcode: RunnerConfiguration.defaultXcodeTimeout + case .spm: RunnerConfiguration.defaultSPMTimeout + } } private func resolvedConcurrency(cli: ParsedArguments, fileValues: [String: String]) -> Int { @@ -87,6 +102,21 @@ struct ConfigurationResolver: Sendable { return RunnerConfiguration.defaultConcurrency } + private func resolvedTestingFramework(cli: ParsedArguments, fileValues: [String: String]) throws -> TestingFramework + { + let raw = cli.build.testingFramework ?? fileValues["testingFramework"] + + guard let raw else { + return .swiftTesting + } + + guard let framework = TestingFramework(rawValue: raw) else { + throw UsageError(message: "--testing-framework must be 'xctest' or 'swift-testing'") + } + + return framework + } + private func resolveOperators(cli: ParsedArguments, fileValues: [String: String]) -> [String] { if !cli.filter.operators.isEmpty { return cli.filter.operators diff --git a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift index 1e94b91..5f42c73 100644 --- a/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift +++ b/Sources/SwiftMutationTesting/Configuration/DetectedProject.swift @@ -6,11 +6,13 @@ struct DetectedProject: Sendable { static let empty = DetectedProject( kind: .xcode(scheme: nil, allSchemes: [], destination: "platform=macOS"), - testTarget: nil + testTarget: nil, + testingFramework: .swiftTesting ) let kind: Kind let testTarget: String? + var testingFramework: TestingFramework = .swiftTesting var scheme: String? { guard case .xcode(let xScheme, _, _) = kind else { return nil } diff --git a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift index 904a154..071ada6 100644 --- a/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift +++ b/Sources/SwiftMutationTesting/Configuration/ProjectDetector.swift @@ -10,13 +10,15 @@ struct ProjectDetector: Sendable { let (schemes, projectName, testTarget) = await listProject( container: container, workingDirectory: projectURL) let destination = await detectDestination(in: projectURL) + let framework = detectTestingFramework(at: projectURL, testTarget: testTarget) return DetectedProject( kind: .xcode( scheme: selectScheme(from: schemes, projectName: projectName), allSchemes: schemes, destination: destination ), - testTarget: testTarget + testTarget: testTarget, + testingFramework: framework ) } @@ -24,7 +26,8 @@ struct ProjectDetector: Sendable { let testTargets = await listSPMTestTargets(in: projectURL) return DetectedProject( kind: .spm(testTargets: testTargets), - testTarget: testTargets.first + testTarget: testTargets.first, + testingFramework: .swiftTesting ) } @@ -223,6 +226,40 @@ struct ProjectDetector: Sendable { return nil } + private func detectTestingFramework(at projectURL: URL, testTarget: String?) -> TestingFramework { + let searchURL: URL + if let testTarget { + let targetURL = projectURL.appendingPathComponent(testTarget) + searchURL = FileManager.default.fileExists(atPath: targetURL.path) ? targetURL : projectURL + } else { + searchURL = projectURL + } + + guard 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 { + guard url.pathExtension == "swift" else { continue } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue } + + if content.contains("import XCTest") { hasXCTest = true } + if content.contains("import Testing") { hasSwiftTesting = true } + + if hasXCTest && hasSwiftTesting { break } + } + + if hasXCTest && !hasSwiftTesting { return .xctest } + return .swiftTesting + } + private func runtimeVersion(from key: String) -> (Int, Int) { let parts = key.components(separatedBy: "-") guard parts.count >= 2, diff --git a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift index 949f1a1..876d6b6 100644 --- a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift +++ b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift @@ -1,7 +1,8 @@ import Foundation struct RunnerConfiguration: Sendable { - static let defaultTimeout: Double = 120.0 + static let defaultXcodeTimeout: Double = 120.0 + static let defaultSPMTimeout: Double = 30.0 static let defaultConcurrency: Int = max(1, ProcessInfo.processInfo.processorCount - 1) let projectPath: String @@ -15,6 +16,7 @@ struct RunnerConfiguration: Sendable { var timeout: Double var concurrency: Int var noCache: Bool + var testingFramework: TestingFramework = .swiftTesting } struct ReportingOptions: Sendable { diff --git a/Sources/SwiftMutationTesting/Configuration/TestingFramework.swift b/Sources/SwiftMutationTesting/Configuration/TestingFramework.swift new file mode 100644 index 0000000..5844c75 --- /dev/null +++ b/Sources/SwiftMutationTesting/Configuration/TestingFramework.swift @@ -0,0 +1,4 @@ +enum TestingFramework: String, Sendable { + case xctest + case swiftTesting = "swift-testing" +} diff --git a/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyExtractor.swift b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyExtractor.swift new file mode 100644 index 0000000..0eb5407 --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyExtractor.swift @@ -0,0 +1,9 @@ +import SwiftSyntax + +struct InfiniteLoopBodyExtractor: Sendable { + func extractLoopBodyRanges(from syntax: SourceFileSyntax) -> [Range] { + let visitor = InfiniteLoopBodyVisitor() + visitor.walk(syntax) + return visitor.loopBodyRanges + } +} diff --git a/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitor.swift b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitor.swift new file mode 100644 index 0000000..9c3949f --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitor.swift @@ -0,0 +1,20 @@ +import SwiftSyntax + +final class InfiniteLoopBodyVisitor: SyntaxVisitor { + + init() { + super.init(viewMode: .sourceAccurate) + } + + private(set) var loopBodyRanges: [Range] = [] + + override func visit(_ node: WhileStmtSyntax) -> SyntaxVisitorContinueKind { + loopBodyRanges.append(node.body.position ..< node.body.endPosition) + return .visitChildren + } + + override func visit(_ node: RepeatStmtSyntax) -> SyntaxVisitorContinueKind { + loopBodyRanges.append(node.body.position ..< node.body.endPosition) + return .visitChildren + } +} diff --git a/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopFilter.swift b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopFilter.swift new file mode 100644 index 0000000..67a08fb --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopFilter.swift @@ -0,0 +1,27 @@ +import SwiftSyntax + +struct InfiniteLoopFilter: Sendable { + + private static let riskyOperators: Set = [ + "ArithmeticOperatorReplacement", + "RemoveSideEffects", + ] + + func filter( + _ mutationPoints: [MutationPoint], + loopBodyRanges: [Range] + ) -> [MutationPoint] { + guard !loopBodyRanges.isEmpty else { + return mutationPoints + } + + return mutationPoints.filter { point in + guard Self.riskyOperators.contains(point.operatorIdentifier) else { + return true + } + + let position = AbsolutePosition(utf8Offset: point.utf8Offset) + return !loopBodyRanges.contains { $0.contains(position) } + } + } +} diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantDiscoveryStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantDiscoveryStage.swift index 764d0b3..975a639 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantDiscoveryStage.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantDiscoveryStage.swift @@ -4,10 +4,20 @@ struct MutantDiscoveryStage: Sendable { func run(sources: [ParsedSource]) async -> [MutationPoint] { let extractor = SuppressionAnnotationExtractor() let filter = SuppressionFilter() + let loopExtractor = InfiniteLoopBodyExtractor() + let loopFilter = InfiniteLoopFilter() let allMutations = await withTaskGroup(of: [MutationPoint].self) { group in for source in sources { - group.addTask { self.mutationPoints(for: source, extractor: extractor, filter: filter) } + group.addTask { + self.mutationPoints( + for: source, + extractor: extractor, + filter: filter, + loopExtractor: loopExtractor, + loopFilter: loopFilter + ) + } } var collected: [MutationPoint] = [] @@ -31,10 +41,14 @@ struct MutantDiscoveryStage: Sendable { private func mutationPoints( for source: ParsedSource, extractor: SuppressionAnnotationExtractor, - filter: SuppressionFilter + filter: SuppressionFilter, + loopExtractor: InfiniteLoopBodyExtractor, + loopFilter: InfiniteLoopFilter ) -> [MutationPoint] { let suppressedRanges = extractor.extractSuppressedRanges(from: source.syntax) + let loopBodyRanges = loopExtractor.extractLoopBodyRanges(from: source.syntax) let mutations = operators.flatMap { $0.mutations(in: source) } - return filter.filter(mutations, suppressedRanges: suppressedRanges) + let afterSuppression = filter.filter(mutations, suppressedRanges: suppressedRanges) + return loopFilter.filter(afterSuppression, loopBodyRanges: loopBodyRanges) } } diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 40906c3..80f8c7c 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -152,7 +152,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let test = try await deps.launcher.launchCapturingDeferred( + let test = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: testArgs, environment: nil, @@ -163,7 +163,6 @@ struct IncompatibleMutantExecutor: Sendable { let duration = Date().timeIntervalSince(start) let outcome = SPMResultParser().parse(exitCode: test.exitCode, output: test.output) - test.cleanup() let status = outcome.asExecutionStatus let index = await deps.counter.increment() @@ -200,14 +199,14 @@ struct IncompatibleMutantExecutor: Sendable { throw error } + await pool.release(slot) + let outcome = try await TestResultResolver(launcher: deps.launcher).resolve( launch: launched, projectType: configuration.build.projectType, timeout: configuration.build.timeout ) - launched.cleanup() - await pool.release(slot) try? sandbox.cleanup() let status = outcome.asExecutionStatus @@ -256,7 +255,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturingDeferred( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, @@ -269,8 +268,7 @@ struct IncompatibleMutantExecutor: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: xcresultPath, - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } @@ -285,7 +283,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturingDeferred( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -298,8 +296,7 @@ struct IncompatibleMutantExecutor: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: "", - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 8d8038c..6b6f024 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -2,7 +2,7 @@ import Foundation struct MutantExecutor: Sendable { - init(configuration: RunnerConfiguration, launcher: any ProcessLaunching = ProcessLauncher()) { + init(configuration: RunnerConfiguration, launcher: any ProcessLaunching) { self.configuration = configuration self.launcher = launcher } @@ -99,10 +99,6 @@ struct MutantExecutor: Sendable { } } - if !reroutedToIncompatible.isEmpty { - fputs("[xmr] rerouted \(reroutedToIncompatible.count) schema-excluded mutants to incompatible executor\n", stderr) - } - let excludedIDs = Set(schemaBuildExcluded.map(\.id)) let testableSchematizable = schematizable.filter { !excludedIDs.contains($0.id) } @@ -177,7 +173,6 @@ struct MutantExecutor: Sendable { await deps.reporter.report(.buildFinished(duration: Date().timeIntervalSince(start))) return (artifact, []) } catch BuildError.compilationFailed(let output) { - fputs("[xmr] schematized build failed — starting retry\n", stderr) let (artifact, excluded) = try await retryExcludingErrors( output: output, sandbox: sandbox, @@ -187,7 +182,6 @@ struct MutantExecutor: Sendable { start: start, alreadyExcluded: [] ) - fputs("[xmr] retry done: artifact=\(artifact != nil ? "ok" : "nil") excluded=\(excluded.count)\n", stderr) return (artifact, excluded) } } @@ -205,8 +199,6 @@ struct MutantExecutor: Sendable { let sandboxRoot = canonicalPath(sandbox.rootURL.path) let projectRoot = URL(fileURLWithPath: input.projectPath).resolvingSymlinksInPath().path - fputs("[xmr] sandboxRoot=\(sandboxRoot)\n", stderr) - let errorSandboxPaths = Set( output.components(separatedBy: "\n").compactMap { line -> String? in guard line.hasPrefix(sandboxRoot) else { return nil } @@ -241,10 +233,7 @@ struct MutantExecutor: Sendable { ) } - fputs("[xmr] error files=\(errorSandboxPaths.count) newly excluded=\(newlyExcluded.count)\n", stderr) - guard !newlyExcluded.isEmpty else { - fputs("[xmr] no files matched sandboxRoot — skipping retry\n", stderr) return (nil, alreadyExcluded) } @@ -305,26 +294,14 @@ struct MutantExecutor: Sendable { arguments += ["--filter", testTarget] } - guard let result = try? await deps.launcher.launchCapturing( + _ = try? await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, additionalEnvironment: [:], workingDirectoryURL: sandbox.rootURL, timeout: configuration.build.timeout - ) else { - fputs("[xmr] baseline check failed to launch\n", stderr) - return - } - - if result.exitCode == 0 { - fputs("[xmr] baseline passed — schematized binary is healthy\n", stderr) - } else { - let lines = result.output.components(separatedBy: "\n") - let failLines = lines.filter { $0.contains("failed") || $0.contains("Issue") || $0.contains("✗") || $0.contains("error:") || $0.contains("FAILED") } - let snippet = failLines.prefix(20).joined(separator: "↵") - fputs("[xmr] baseline FAILED exitCode=\(result.exitCode) failures=\(snippet)\n", stderr) - } + ) } private func excludeProblematicMutants( @@ -377,7 +354,6 @@ struct MutantExecutor: Sendable { try? narrowed.write(toFile: sandboxPath, atomically: true, encoding: .utf8) let excluded = mutantsInFile.filter { problematicIDs.contains($0.id) } - fputs("[xmr] narrow exclusion: file=\(URL(fileURLWithPath: originalPath).lastPathComponent) total=\(mutantsInFile.count) excluded=\(excluded.count) remaining=\(mutantsInFile.count - excluded.count)\n", stderr) return excluded } diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 0ff4cc1..2fea394 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -59,14 +59,14 @@ struct TestExecutionStage: Sendable { throw error } + await context.pool.release(slot) + let outcome = try await ResultParser(launcher: deps.launcher).parse( exitCode: launched.exitCode, output: launched.output, xcresultPath: launched.xcresultPath, timeout: context.configuration.build.timeout ) - launched.cleanup() - await context.pool.release(slot) try? FileManager.default.removeItem(atPath: launched.xcresultPath) let status = outcome.asExecutionStatus @@ -92,13 +92,8 @@ struct TestExecutionStage: Sendable { } let outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) - launched.cleanup() await context.pool.release(slot) let status = outcome.asExecutionStatus - if status == .unviable { - let snippet = String(launched.output.prefix(300)).replacingOccurrences(of: "\n", with: "↵") - fputs("[xmr] unviable mutant=\(mutant.id) file=\(URL(fileURLWithPath: mutant.filePath).lastPathComponent) exitCode=\(launched.exitCode) output=\(snippet)\n", stderr) - } let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) await deps.cacheStore.store(status: status, for: key) let index = await deps.counter.increment() @@ -117,7 +112,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturingDeferred( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -130,8 +125,7 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: "", - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } @@ -164,7 +158,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await deps.launcher.launchCapturingDeferred( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, @@ -177,8 +171,7 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: xcresultPath, - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } } diff --git a/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift b/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift index c221d49..eb6fc13 100644 --- a/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift +++ b/Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift @@ -3,5 +3,4 @@ struct TestLaunchResult: Sendable { let output: String let xcresultPath: String let duration: Double - let cleanup: @Sendable () -> Void } diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift index 14e8983..042634c 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLaunching.swift @@ -1,11 +1,5 @@ import Foundation -struct CapturedOutput: Sendable { - let exitCode: Int32 - let output: String - let cleanup: @Sendable () -> Void -} - protocol ProcessLaunching: Sendable { func launch( executableURL: URL, @@ -22,34 +16,4 @@ protocol ProcessLaunching: Sendable { workingDirectoryURL: URL, timeout: Double ) async throws -> (exitCode: Int32, output: String) - - func launchCapturingDeferred( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> CapturedOutput -} - -extension ProcessLaunching { - func launchCapturingDeferred( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> CapturedOutput { - let result = try await launchCapturing( - executableURL: executableURL, - arguments: arguments, - environment: environment, - additionalEnvironment: additionalEnvironment, - workingDirectoryURL: workingDirectoryURL, - timeout: timeout - ) - return CapturedOutput(exitCode: result.exitCode, output: result.output, cleanup: {}) - } } diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift similarity index 66% rename from Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift rename to Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift index 41321f9..5f6534a 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift @@ -1,6 +1,6 @@ import Foundation -struct ProcessLauncher: Sendable, ProcessLaunching { +struct SPMProcessLauncher: Sendable, ProcessLaunching { func launch( executableURL: URL, arguments: [String], @@ -107,97 +107,6 @@ struct ProcessLauncher: Sendable, ProcessLaunching { } } - func launchCapturingDeferred( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> CapturedOutput { - 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 result = try await withTaskCancellationHandler { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - self.startDeferredCapturingProcess( - process, killedByUs: killedByUs, timeout: timeout, - sandboxPath: sandboxPath, - capture: CaptureTarget(fileHandle: fileHandle, tempURL: tempURL), - continuation: continuation - ) - } - } onCancel: { - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } - - return result - } - - private func startDeferredCapturingProcess( - _ process: Process, - killedByUs: KilledByUsFlag, - timeout: Double, - sandboxPath: String, - capture: CaptureTarget, - continuation: CheckedContinuation - ) { - let timeoutTask = Task { - try await Task.sleep(for: .seconds(timeout)) - killedByUs.mark() - terminateProcessGroup(pid: process.processIdentifier, sandboxPath: sandboxPath) - } - - 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 - let pid = terminated.processIdentifier - let cleanup: @Sendable () -> Void = { - kill(-pid, SIGKILL) - killEscapedChildren(sandboxPath: sandboxPath) - } - continuation.resume(returning: CapturedOutput(exitCode: exitCode, output: output, cleanup: cleanup)) - } - - 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 startCapturingProcess( _ process: Process, killedByUs: KilledByUsFlag, @@ -234,7 +143,7 @@ struct ProcessLauncher: Sendable, ProcessLaunching { } } - private func terminateProcessGroup(pid: Int32, sandboxPath: String = "") { + private func terminateProcessGroup(pid: Int32, sandboxPath: String) { guard pid > 0 else { return } kill(-pid, SIGTERM) Task { @@ -257,8 +166,8 @@ struct ProcessLauncher: Sendable, ProcessLaunching { var procs = [kinfo_proc](repeating: kinfo_proc(), count: size / procSize) guard sysctl(&mib, 4, &procs, &size, nil, 0) == 0 else { return } - for i in 0..<(size / procSize) { - let pid = procs[i].kp_proc.p_pid + for index in 0..<(size / procSize) { + let pid = procs[index].kp_proc.p_pid guard pid > 1 else { continue } var argSize = 0 diff --git a/Sources/SwiftMutationTesting/Infrastructure/XcodeProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/XcodeProcessLauncher.swift new file mode 100644 index 0000000..cb557e2 --- /dev/null +++ b/Sources/SwiftMutationTesting/Infrastructure/XcodeProcessLauncher.swift @@ -0,0 +1,167 @@ +import Foundation + +struct XcodeProcessLauncher: Sendable, ProcessLaunching { + 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() + terminateProcessGroup(pid: process.processIdentifier) + } + } + + func launchCapturing( + executableURL: URL, + arguments: [String], + environment: [String: String]?, + additionalEnvironment: [String: String], + workingDirectoryURL: URL, + timeout: Double + ) 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) + } + } + + 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() + } + } +} diff --git a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift index 1e9d3d1..3cd1a6c 100644 --- a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift +++ b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift @@ -6,7 +6,7 @@ public struct SwiftMutationTesting { exit(await run(args: Array(CommandLine.arguments.dropFirst())).rawValue) } - static func run(args: [String], launcher: any ProcessLaunching = ProcessLauncher()) async -> ExitCode { + static func run(args: [String], launcher: (any ProcessLaunching)? = nil) async -> ExitCode { do { return try await execute(args: args, launcher: launcher) } catch let error as UsageError { @@ -25,7 +25,7 @@ public struct SwiftMutationTesting { } } - private static func execute(args: [String], launcher: any ProcessLaunching) async throws -> ExitCode { + private static func execute(args: [String], launcher: (any ProcessLaunching)?) async throws -> ExitCode { let parsed = try CommandLineParser().parse(args) if parsed.showHelp { @@ -39,7 +39,8 @@ public struct SwiftMutationTesting { } if parsed.showInit { - let detected = await ProjectDetector(launcher: launcher).detect(at: parsed.projectPath) + let initLauncher = launcher ?? XcodeProcessLauncher() + let detected = await ProjectDetector(launcher: initLauncher).detect(at: parsed.projectPath) try ConfigurationFileWriter().write(to: parsed.projectPath, project: detected) return .success } @@ -64,8 +65,18 @@ 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 start = Date() - let results = try await MutantExecutor(configuration: configuration, launcher: launcher).execute(input) + let results = try await MutantExecutor(configuration: configuration, launcher: executionLauncher).execute(input) let duration = Date().timeIntervalSince(start) let summary = RunnerSummary(results: results, totalDuration: duration) diff --git a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift index eb8a406..2ae2626 100644 --- a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorIntegrationTests.swift @@ -16,7 +16,7 @@ struct MutantExecutorIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - let results = try await MutantExecutor(configuration: configuration).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 +39,7 @@ struct MutantExecutorIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - _ = try await MutantExecutor(configuration: configuration).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 8c12c68..9441c84 100644 --- a/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/MutantExecutorSPMIntegrationTests.swift @@ -12,7 +12,7 @@ struct MutantExecutorSPMIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - let results = try await MutantExecutor(configuration: configuration).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 +35,7 @@ struct MutantExecutorSPMIntegrationTests { let configuration = makeConfiguration(fixtureURL: fixtureURL) let input = makeInput(fixtureURL: fixtureURL) - _ = try await MutantExecutor(configuration: configuration).execute(input) + _ = try await MutantExecutor(configuration: configuration, launcher: SPMProcessLauncher()).execute(input) let after = try String(contentsOf: calculatorURL, encoding: .utf8) diff --git a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift index f7b0466..cc4d02b 100644 --- a/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift +++ b/Tests/SwiftMutationTestingTests/TestSupport/MockProcessLauncher.swift @@ -43,17 +43,4 @@ struct MockProcessLauncher: ProcessLaunching { return responses[key] ?? (exitCode: exitCode, output: output) } - func launchCapturingDeferred( - executableURL: URL, - arguments: [String], - environment: [String: String]?, - additionalEnvironment: [String: String], - workingDirectoryURL: URL, - timeout: Double - ) async throws -> CapturedOutput { - if throwsOnCapture { throw CocoaError(.fileReadNoSuchFile) } - let key = executableURL.lastPathComponent - let response = responses[key] ?? (exitCode: exitCode, output: output) - return CapturedOutput(exitCode: response.exitCode, output: response.output, cleanup: {}) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift index 43ebdee..d7a92ee 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift @@ -190,6 +190,33 @@ struct CommandLineParserTests { #expect(result.filter.operators.isEmpty) } + @Test("Given --testing-framework xctest, when parsed, then testingFramework is xctest") + func parsesTestingFrameworkXCTest() throws { + let result = try parser.parse([ + "run", "--scheme", "App", "--destination", "d", + "--testing-framework", "xctest", + ]) + + #expect(result.build.testingFramework == "xctest") + } + + @Test("Given --testing-framework swift-testing, when parsed, then testingFramework is swift-testing") + func parsesTestingFrameworkSwiftTesting() throws { + let result = try parser.parse([ + "run", "--scheme", "App", "--destination", "d", + "--testing-framework", "swift-testing", + ]) + + #expect(result.build.testingFramework == "swift-testing") + } + + @Test("Given no --testing-framework flag, when parsed, then testingFramework is nil") + func testingFrameworkDefaultsToNil() throws { + let result = try parser.parse(["run", "--scheme", "App", "--destination", "d"]) + + #expect(result.build.testingFramework == nil) + } + @Test("Given repeated --disable-mutator flags, when parsed, then all disabled mutators are collected") func disabledMutatorsAreCollected() throws { let result = try parser.parse([ diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift index 5046262..14fab69 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationFileWriterTests.swift @@ -184,6 +184,60 @@ struct ConfigurationFileWriterTests { } } + @Test("Given Xcode project, when write called, then testingFramework option is included") + func testingFrameworkOptionIncludedForXcode() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try writer.write( + to: dir.path, + project: DetectedProject( + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp"], destination: "platform=macOS"), + testTarget: nil + ) + ) + + let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) + #expect(content.contains("testingFramework")) + #expect(content.contains("swift-testing")) + } + + @Test("Given Xcode project with xctest, when write called, then concurrency is 1") + func xcTestConcurrencyIsOneInTemplate() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try writer.write( + to: dir.path, + project: DetectedProject( + kind: .xcode(scheme: "MyApp", allSchemes: ["MyApp"], destination: "platform=macOS"), + testTarget: nil, + testingFramework: .xctest + ) + ) + + let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) + #expect(content.contains("testingFramework: xctest")) + #expect(content.contains("concurrency: 1")) + } + + @Test("Given SPM project, when write called, then testingFramework option is not included") + func testingFrameworkOptionNotIncludedForSPM() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try writer.write( + to: dir.path, + project: DetectedProject( + kind: .spm(testTargets: ["MyTests"]), + testTarget: nil + ) + ) + + let content = try String(contentsOf: dir.appendingPathComponent(".swift-mutation-testing.yml"), encoding: .utf8) + #expect(!content.contains("testingFramework")) + } + @Test("Given existing config file, when write called, then throws UsageError") func throwsWhenFileAlreadyExists() throws { let dir = try FileHelpers.makeTemporaryDirectory() diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index 5c3eb37..31d052f 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift @@ -110,14 +110,29 @@ struct ConfigurationResolverTests { #expect(result.build.timeout == 30) } - @Test("Given no timeout in CLI or file, when resolved, then default timeout is applied") - func appliesDefaultTimeout() throws { + @Test("Given no timeout in CLI or file for Xcode, when resolved, then default Xcode timeout is applied") + func appliesDefaultXcodeTimeout() throws { let result = try resolver.resolve( cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), fileValues: [:] ) - #expect(result.build.timeout == RunnerConfiguration.defaultTimeout) + #expect(result.build.timeout == RunnerConfiguration.defaultXcodeTimeout) + } + + @Test("Given no timeout in CLI or file for SPM, when resolved, then default SPM timeout is applied") + func appliesDefaultSPMTimeout() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try FileHelpers.write("// Package.swift", named: "Package.swift", in: dir) + + let result = try resolver.resolve( + cliArguments: ParsedArguments(projectPath: dir.path), + fileValues: [:] + ) + + #expect(result.build.timeout == RunnerConfiguration.defaultSPMTimeout) } @Test("Given no concurrency in CLI or file, when resolved, then default concurrency is applied") @@ -354,6 +369,98 @@ struct ConfigurationResolverTests { #expect(result.reporting.output == "/tmp/report.txt") } + @Test("Given no testingFramework anywhere, when resolved, then defaults to swiftTesting") + func testingFrameworkDefaultsToSwiftTesting() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), + fileValues: [:] + ) + + #expect(result.build.testingFramework == .swiftTesting) + } + + @Test("Given testingFramework xctest via CLI, when resolved, then testingFramework is xctest") + func testingFrameworkFromCLI() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d", testingFramework: "xctest")), + fileValues: [:] + ) + + #expect(result.build.testingFramework == .xctest) + } + + @Test("Given testingFramework xctest in file, when resolved, then testingFramework is xctest") + func testingFrameworkFromFile() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d")), + fileValues: ["testingFramework": "xctest"] + ) + + #expect(result.build.testingFramework == .xctest) + } + + @Test("Given testingFramework in both CLI and file, when resolved, then CLI takes priority") + func testingFrameworkCLIOverridesFile() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d", testingFramework: "xctest")), + fileValues: ["testingFramework": "swift-testing"] + ) + + #expect(result.build.testingFramework == .xctest) + } + + @Test("Given invalid testingFramework value, when resolved, then throws UsageError") + func testingFrameworkThrowsForInvalidValue() { + #expect(throws: UsageError.self) { + try resolver.resolve( + cliArguments: ParsedArguments(build: .init(scheme: "App", destination: "d", testingFramework: "junit")), + fileValues: [:] + ) + } + } + + @Test("Given xctest and xcode project, when resolved, then concurrency is forced to 1") + func xcTestForcesConcurrencyToOne() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments( + build: .init(scheme: "App", destination: "d", concurrency: 8, testingFramework: "xctest") + ), + fileValues: [:] + ) + + #expect(result.build.concurrency == 1) + } + + @Test("Given swift-testing and xcode project, when resolved, then concurrency is preserved") + func swiftTestingPreservesConcurrency() throws { + let result = try resolver.resolve( + cliArguments: ParsedArguments( + build: .init(scheme: "App", destination: "d", concurrency: 8, testingFramework: "swift-testing") + ), + fileValues: [:] + ) + + #expect(result.build.concurrency == 8) + } + + @Test("Given xctest and spm project, when resolved, then concurrency is not forced to 1") + func xcTestWithSPMDoesNotForceConcurrency() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + try FileHelpers.write("// Package.swift", named: "Package.swift", in: dir) + + let result = try resolver.resolve( + cliArguments: ParsedArguments( + projectPath: dir.path, + build: .init(concurrency: 4, testingFramework: "xctest") + ), + fileValues: [:] + ) + + #expect(result.build.concurrency == 4) + } + @Test("Given testTarget via CLI, when resolved, then testTarget is set") func testTargetFromCLI() throws { let result = try resolver.resolve( diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitorTests.swift new file mode 100644 index 0000000..030db64 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitorTests.swift @@ -0,0 +1,86 @@ +import SwiftSyntax +import Testing + +@testable import SwiftMutationTesting + +@Suite("InfiniteLoopBodyVisitor") +struct InfiniteLoopBodyVisitorTests { + private let extractor = InfiniteLoopBodyExtractor() + + @Test("Given while loop, when extracted, then returns one body range") + func whileLoopProducesOneBodyRange() { + let source = makeParsedSource("func f() { while true { x += 1 } }") + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.count == 1) + } + + @Test("Given repeat-while loop, when extracted, then returns one body range") + func repeatWhileLoopProducesOneBodyRange() { + let source = makeParsedSource("func f() { repeat { x += 1 } while true }") + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.count == 1) + } + + @Test("Given for-in loop, when extracted, then returns no ranges") + func forInLoopProducesNoRanges() { + let source = makeParsedSource("func f() { for i in 0..<10 { x += 1 } }") + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.isEmpty) + } + + @Test("Given nested while loops, when extracted, then returns two ranges") + func nestedWhileLoopsProduceTwoRanges() { + let code = """ + func f() { + while true { + while false { + x += 1 + } + } + } + """ + let source = makeParsedSource(code) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.count == 2) + } + + @Test("Given while inside for-in, when extracted, then returns one range for while body only") + func whileInsideForInProducesOneRange() { + let code = """ + func f() { + for i in 0..<10 { + while true { + x += 1 + } + } + } + """ + let source = makeParsedSource(code) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.count == 1) + } + + @Test("Given no loops, when extracted, then returns no ranges") + func noLoopsProducesNoRanges() { + let source = makeParsedSource("func f() { let x = 1 + 2 }") + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + #expect(ranges.isEmpty) + } + + @Test("Given while loop, when extracted, then condition is not included in body range") + func whileConditionIsNotInBodyRange() { + let code = "func f() { while i < 10 { i = i + 1 } }" + let source = makeParsedSource(code) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + let op = RelationalOperatorReplacement() + let conditionMutations = op.mutations(in: source) + #expect(!conditionMutations.isEmpty) + + let bodyRange = ranges[0] + for mutation in conditionMutations { + let position = AbsolutePosition(utf8Offset: mutation.utf8Offset) + #expect(!bodyRange.contains(position)) + } + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopFilterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopFilterTests.swift new file mode 100644 index 0000000..873d0f5 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopFilterTests.swift @@ -0,0 +1,140 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("InfiniteLoopFilter") +struct InfiniteLoopFilterTests { + private let filter = InfiniteLoopFilter() + private let extractor = InfiniteLoopBodyExtractor() + + @Test("Given ArithmeticOperator inside while body, when filtered, then mutation is removed") + func arithmeticInsideWhileBodyIsFiltered() { + let code = "func f() { while true { let x = 1 + 2 } }" + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(!mutations.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.isEmpty) + } + + @Test("Given ArithmeticOperator outside loop, when filtered, then mutation is kept") + func arithmeticOutsideLoopIsNotFiltered() { + let code = "func f() { let x = 1 + 2 }" + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(ranges.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.count == mutations.count) + } + + @Test("Given RemoveSideEffects inside while body, when filtered, then mutation is removed") + func removeSideEffectsInsideWhileBodyIsFiltered() { + let code = "func f() { while true { doWork() } }" + let source = makeParsedSource(code) + let mutations = RemoveSideEffects().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(!mutations.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.isEmpty) + } + + @Test("Given BooleanLiteral inside while body, when filtered, then mutation is kept") + func booleanLiteralInsideWhileBodyIsNotFiltered() { + let code = "func f() { while true { let x = false } }" + let source = makeParsedSource(code) + let mutations = BooleanLiteralReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(!mutations.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.count == mutations.count) + } + + @Test("Given RelationalOperator in while condition, when filtered, then mutation is kept") + func relationalInWhileConditionIsNotFiltered() { + let code = "func f() { while i < 10 { i = i + 1 } }" + let source = makeParsedSource(code) + let conditionMutations = RelationalOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(!conditionMutations.isEmpty) + + let result = filter.filter(conditionMutations, loopBodyRanges: ranges) + #expect(result.count == conditionMutations.count) + } + + @Test("Given no loop body ranges, when filtered, then returns all mutations unchanged") + func noLoopRangesReturnsAllMutations() { + let code = "func f() { let x = 1 + 2 }" + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + + let result = filter.filter(mutations, loopBodyRanges: []) + #expect(result.count == mutations.count) + } + + @Test("Given nested while loops, when filtered, then mutation in inner body is removed") + func nestedWhileMutationIsFiltered() { + let code = """ + func f() { + while true { + while false { + let x = 1 + 2 + } + } + } + """ + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(!mutations.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.isEmpty) + } + + @Test("Given for-in loop with arithmetic, when filtered, then mutation is kept") + func forInArithmeticIsNotFiltered() { + let code = "func f() { for i in 0..<10 { let x = 1 + 2 } }" + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(ranges.isEmpty) + #expect(!mutations.isEmpty) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.count == mutations.count) + } + + @Test("Given mixed code, when filtered, then only risky mutations inside loop body are removed") + func mixedCodeFiltersOnlyRiskyInsideLoop() { + let code = """ + func f() { + let a = 1 + 2 + while true { + let b = 3 + 4 + } + } + """ + let source = makeParsedSource(code) + let mutations = ArithmeticOperatorReplacement().mutations(in: source) + let ranges = extractor.extractLoopBodyRanges(from: source.syntax) + + #expect(mutations.count >= 2) + + let result = filter.filter(mutations, loopBodyRanges: ranges) + #expect(result.count == 1) + #expect(result[0].originalText == "+") + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift index d9f8912..36477a1 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/ProcessLauncherTests.swift @@ -3,9 +3,187 @@ import Testing @testable import SwiftMutationTesting -@Suite("ProcessLauncher") -struct ProcessLauncherTests { - private let launcher = ProcessLauncher() +@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 {