From b79d43bf379ac94b8f05aafca05cae94a10caa5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:19:57 -0300 Subject: [PATCH 01/25] feat: add XcodeProcessLauncher with v1.0.0 behavior --- .../Infrastructure/XcodeProcessLauncher.swift | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 Sources/SwiftMutationTesting/Infrastructure/XcodeProcessLauncher.swift 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() + } + } +} From 0446f4e05c6f1bdf2a6960c7f3912b3e7493b260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:22:56 -0300 Subject: [PATCH 02/25] feat: add SPMProcessLauncher with aggressive child cleanup --- .../Infrastructure/SPMProcessLauncher.swift | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift diff --git a/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift new file mode 100644 index 0000000..5f6534a --- /dev/null +++ b/Sources/SwiftMutationTesting/Infrastructure/SPMProcessLauncher.swift @@ -0,0 +1,208 @@ +import Foundation + +struct SPMProcessLauncher: 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() + 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) + } + } + + 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() + let sandboxPath = workingDirectoryURL.path + + 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 + ) + } + } 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) + } + + 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) + } + + 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, + 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) + } + + 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)) + } + + 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, sandboxPath: String) { + guard pid > 0 else { return } + kill(-pid, SIGTERM) + Task { + try? await Task.sleep(for: .seconds(5)) + kill(-pid, SIGKILL) + killEscapedChildren(sandboxPath: sandboxPath) + } + } + + private 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 } + + 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 } + + 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[.. Date: Fri, 3 Apr 2026 15:27:20 -0300 Subject: [PATCH 03/25] refactor: remove launchCapturingDeferred and CapturedOutput from ProcessLaunching --- .../Infrastructure/ProcessLauncher.swift | 91 ------------------- .../Infrastructure/ProcessLaunching.swift | 36 -------- 2 files changed, 127 deletions(-) diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift index 41321f9..539c7c6 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift @@ -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, 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: {}) - } } From 96f9ef4f5b4508584b48f311458e4d62b2749290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:27:27 -0300 Subject: [PATCH 04/25] test: remove launchCapturingDeferred from MockProcessLauncher --- .../TestSupport/MockProcessLauncher.swift | 13 ------------- 1 file changed, 13 deletions(-) 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: {}) - } } From 12e128848754a980ffaed9964cb6cb0afad0b9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:27:32 -0300 Subject: [PATCH 05/25] refactor: remove cleanup field from TestLaunchResult --- Sources/SwiftMutationTesting/Execution/TestLaunchResult.swift | 1 - 1 file changed, 1 deletion(-) 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 } From 5d945c3c1d416dbe3c53fc6cfe7fbc1cfaeb0881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:27:40 -0300 Subject: [PATCH 06/25] refactor: revert to launchCapturing in execution stages --- .../Execution/IncompatibleMutantExecutor.swift | 14 +++++--------- .../Execution/TestExecutionStage.swift | 12 ++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 40906c3..d9dde45 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() @@ -206,7 +205,6 @@ struct IncompatibleMutantExecutor: Sendable { timeout: configuration.build.timeout ) - launched.cleanup() await pool.release(slot) try? sandbox.cleanup() @@ -256,7 +254,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 +267,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 +282,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 +295,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/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 0ff4cc1..b9f8169 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -65,7 +65,6 @@ struct TestExecutionStage: Sendable { xcresultPath: launched.xcresultPath, timeout: context.configuration.build.timeout ) - launched.cleanup() await context.pool.release(slot) try? FileManager.default.removeItem(atPath: launched.xcresultPath) @@ -92,7 +91,6 @@ 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 { @@ -117,7 +115,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 +128,7 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: "", - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } @@ -164,7 +161,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 +174,7 @@ struct TestExecutionStage: Sendable { exitCode: captured.exitCode, output: captured.output, xcresultPath: xcresultPath, - duration: Date().timeIntervalSince(start), - cleanup: captured.cleanup + duration: Date().timeIntervalSince(start) ) } } From ea3aa91ff12c2a4f0d6b5ec65a271d4111b98301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:35:41 -0300 Subject: [PATCH 07/25] refactor: inject launcher by ProjectType in composition root --- .../Execution/MutantExecutor.swift | 2 +- .../SwiftMutationTesting.swift | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 8d8038c..6bb142e 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 } 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) From 4991e3a80f2182eec958e344152fd822a20ec9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:35:52 -0300 Subject: [PATCH 08/25] test: use explicit launchers in integration tests --- .../Integration/MutantExecutorIntegrationTests.swift | 4 ++-- .../Integration/MutantExecutorSPMIntegrationTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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) From d3d1d247713997952b1604f4e52108a4ac7e2167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Fri, 3 Apr 2026 15:38:43 -0300 Subject: [PATCH 09/25] refactor: remove generic ProcessLauncher --- .../Infrastructure/ProcessLauncher.swift | 208 ------------------ 1 file changed, 208 deletions(-) delete mode 100644 Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift deleted file mode 100644 index 539c7c6..0000000 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ /dev/null @@ -1,208 +0,0 @@ -import Foundation - -struct ProcessLauncher: 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() - 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) - } - } - - 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() - let sandboxPath = workingDirectoryURL.path - - 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 - ) - } - } 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) - } - - 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) - } - - 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, - 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) - } - - 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)) - } - - 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, sandboxPath: String = "") { - guard pid > 0 else { return } - kill(-pid, SIGTERM) - Task { - try? await Task.sleep(for: .seconds(5)) - kill(-pid, SIGKILL) - killEscapedChildren(sandboxPath: sandboxPath) - } - } - - private 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 } - - 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 } - - 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 i in 0..<(size / procSize) { - let pid = procs[i].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[.. Date: Fri, 3 Apr 2026 15:38:48 -0300 Subject: [PATCH 10/25] test: split ProcessLauncherTests into XcodeProcessLauncher and SPMProcessLauncher suites --- .../Infrastructure/ProcessLauncherTests.swift | 184 +++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) 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 { From 93a174e0247a98b04d483c8e6aa9b8b114167905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:27 -0300 Subject: [PATCH 11/25] fix: release simulator slot before parsing results --- .../Execution/IncompatibleMutantExecutor.swift | 3 ++- .../SwiftMutationTesting/Execution/TestExecutionStage.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index d9dde45..80f8c7c 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -199,13 +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 ) - await pool.release(slot) try? sandbox.cleanup() let status = outcome.asExecutionStatus diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index b9f8169..f7dea12 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -59,13 +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 ) - await context.pool.release(slot) try? FileManager.default.removeItem(atPath: launched.xcresultPath) let status = outcome.asExecutionStatus From 0891c77c03a948bc288eb9938798fc9168ef9292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:31 -0300 Subject: [PATCH 12/25] feat: add TestingFramework enum --- .../SwiftMutationTesting/Configuration/TestingFramework.swift | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Sources/SwiftMutationTesting/Configuration/TestingFramework.swift 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" +} From 4212c287f190a3af9bbb35a5dc8cd6e6f2f89ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:36 -0300 Subject: [PATCH 13/25] feat: add testingFramework to BuildOptions --- .../SwiftMutationTesting/Configuration/RunnerConfiguration.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift index 949f1a1..34f08bb 100644 --- a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift +++ b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift @@ -15,6 +15,7 @@ struct RunnerConfiguration: Sendable { var timeout: Double var concurrency: Int var noCache: Bool + var testingFramework: TestingFramework = .swiftTesting } struct ReportingOptions: Sendable { From b307ebadf8696ab39680b53d11e9ff9155a75e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:43 -0300 Subject: [PATCH 14/25] feat: parse --testing-framework CLI flag --- Sources/SwiftMutationTesting/CLI/CommandLineParser.swift | 7 ++++++- Sources/SwiftMutationTesting/CLI/HelpText.swift | 1 + Sources/SwiftMutationTesting/CLI/ParsedArguments.swift | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) 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..3e934da 100644 --- a/Sources/SwiftMutationTesting/CLI/HelpText.swift +++ b/Sources/SwiftMutationTesting/CLI/HelpText.swift @@ -12,6 +12,7 @@ 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) --concurrency Number of parallel test workers (default: CPUs - 1) 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 { From d35f3b87b6e415209b05dbbd75010549b2d60aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:48 -0300 Subject: [PATCH 15/25] test: parse --testing-framework CLI flag --- .../Unit/CLI/CommandLineParserTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) 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([ From 4883c8e39fd01c2f52fd6278d5d26c841682888c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:53 -0300 Subject: [PATCH 16/25] feat: resolve testingFramework and force concurrency=1 for xctest --- .../Configuration/ConfigurationResolver.swift | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift index 154eea3..2f86421 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationResolver.swift @@ -19,14 +19,24 @@ struct ConfigurationResolver: Sendable { projectPath: projectPath ) + let testingFramework = try resolvedTestingFramework(cli: cliArguments, fileValues: fileValues) + + 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"], @@ -87,6 +97,20 @@ 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 From 54ac10100dd7db94b8596d6eaa991265e71fbd01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:44:58 -0300 Subject: [PATCH 17/25] test: resolve testingFramework and force concurrency=1 for xctest --- .../ConfigurationResolverTests.swift | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index 5c3eb37..e542811 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift @@ -354,6 +354,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( From 1429d37b517de78c45740891f7d0662ad97249f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:45:03 -0300 Subject: [PATCH 18/25] feat: auto-detect testing framework in ProjectDetector --- .../Configuration/DetectedProject.swift | 4 +- .../Configuration/ProjectDetector.swift | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) 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, From ebfd24f1c4e488a5aa1d0b8af2da48fccc51d673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:45:10 -0300 Subject: [PATCH 19/25] feat: include testingFramework in init template --- .../Configuration/ConfigurationFileWriter.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift index 0d4d910..04dbbb6 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") From 162992f95894322c28beb62457fe1894b028c53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 17:45:15 -0300 Subject: [PATCH 20/25] test: include testingFramework in init template --- .../ConfigurationFileWriterTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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() From ec4c052629499affc33cc374bffe1a4ab142c76d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 22:12:00 -0300 Subject: [PATCH 21/25] feat: split default timeout by platform (120s Xcode, 30s SPM) --- Sources/SwiftMutationTesting/CLI/HelpText.swift | 2 +- .../Configuration/ConfigurationFileWriter.swift | 4 ++-- .../Configuration/ConfigurationResolver.swift | 14 ++++++++++---- .../Configuration/RunnerConfiguration.swift | 3 ++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftMutationTesting/CLI/HelpText.swift b/Sources/SwiftMutationTesting/CLI/HelpText.swift index 3e934da..0317f8b 100644 --- a/Sources/SwiftMutationTesting/CLI/HelpText.swift +++ b/Sources/SwiftMutationTesting/CLI/HelpText.swift @@ -14,7 +14,7 @@ enum HelpText { --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/Configuration/ConfigurationFileWriter.swift b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift index 04dbbb6..a66da7e 100644 --- a/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift +++ b/Sources/SwiftMutationTesting/Configuration/ConfigurationFileWriter.swift @@ -120,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 2f86421..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 { @@ -20,6 +19,7 @@ struct ConfigurationResolver: Sendable { ) 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 { @@ -85,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 { @@ -97,7 +102,8 @@ struct ConfigurationResolver: Sendable { return RunnerConfiguration.defaultConcurrency } - private func resolvedTestingFramework(cli: ParsedArguments, fileValues: [String: String]) throws -> TestingFramework { + private func resolvedTestingFramework(cli: ParsedArguments, fileValues: [String: String]) throws -> TestingFramework + { let raw = cli.build.testingFramework ?? fileValues["testingFramework"] guard let raw else { diff --git a/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift b/Sources/SwiftMutationTesting/Configuration/RunnerConfiguration.swift index 34f08bb..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 From b449d6fdfeaea4299c1e2b2d807f52c1d8e0334e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 22:12:08 -0300 Subject: [PATCH 22/25] test: split default timeout by platform (120s Xcode, 30s SPM) --- .../ConfigurationResolverTests.swift | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index e542811..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") From e1acd968a4c0b5ea8371284a842bde9492f7cc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 22:12:14 -0300 Subject: [PATCH 23/25] feat: add InfiniteLoopFilter for while/repeat-while bodies --- .../InfiniteLoopBodyExtractor.swift | 9 +++++++ .../InfiniteLoopBodyVisitor.swift | 20 ++++++++++++++ .../InfiniteLoopFilter.swift | 27 +++++++++++++++++++ .../Pipeline/MutantDiscoveryStage.swift | 20 +++++++++++--- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyExtractor.swift create mode 100644 Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitor.swift create mode 100644 Sources/SwiftMutationTesting/Discovery/InfiniteLoopPrevention/InfiniteLoopFilter.swift 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) } } From a3bfd4250755af290fae2410cc78d94d483ef927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 22:12:18 -0300 Subject: [PATCH 24/25] test: add InfiniteLoopFilter for while/repeat-while bodies --- .../InfiniteLoopBodyVisitorTests.swift | 86 +++++++++++ .../InfiniteLoopFilterTests.swift | 140 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopBodyVisitorTests.swift create mode 100644 Tests/SwiftMutationTestingTests/Unit/Discovery/InfiniteLoopPrevention/InfiniteLoopFilterTests.swift 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 == "+") + } +} From 62b8301cac9480f7433e7b932109367f4abba6ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Sat, 4 Apr 2026 22:12:24 -0300 Subject: [PATCH 25/25] fix: remove debug prints from execution stages --- .../Execution/MutantExecutor.swift | 28 ++----------------- .../Execution/TestExecutionStage.swift | 4 --- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 6bb142e..6b6f024 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -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 f7dea12..2fea394 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -94,10 +94,6 @@ struct TestExecutionStage: Sendable { let outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) 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()