From 019c6d30e5cfa236570f35412b7d38fadfdd0721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Mon, 30 Mar 2026 23:29:19 -0300 Subject: [PATCH 1/2] feat: add SPM execution path to TestExecutionStage --- .../Execution/TestExecutionStage.swift | 64 ++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 9ee6b36..f208d3e 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -49,7 +49,7 @@ struct TestExecutionStage: Sendable { } guard let plist = context.artifact.plist else { - return ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0) + return try await runSPM(mutant: mutant, key: key, in: context) } let plistData = plist.activating(mutant.id) @@ -79,6 +79,68 @@ struct TestExecutionStage: Sendable { return result } + private func runSPM( + mutant: MutantDescriptor, + key: MutantCacheKey, + in context: TestExecutionContext + ) async throws -> ExecutionResult { + let slot = try await context.pool.acquire() + let launched: TestLaunchResult + do { + launched = try await launchSPM(mutant: mutant, in: context) + } catch { + await context.pool.release(slot) + throw error + } + await context.pool.release(slot) + + let outcome = spmOutcome(exitCode: launched.exitCode, output: launched.output) + let status = outcome.asExecutionStatus + let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) + await cacheStore.store(status: status, for: key) + let index = await counter.increment() + await reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: counter.total)) + return result + } + + private func launchSPM( + mutant: MutantDescriptor, + in context: TestExecutionContext + ) async throws -> TestLaunchResult { + var arguments = ["test", "--skip-build"] + + if let testTarget = context.configuration.build.testTarget { + arguments += ["--filter", testTarget] + } + + let start = Date() + let captured = try await launcher.launchCapturing( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + environment: ["__SWIFT_MUTATION_TESTING_ACTIVE": mutant.id], + workingDirectoryURL: context.sandbox.rootURL, + timeout: context.configuration.build.timeout + ) + + return TestLaunchResult( + exitCode: captured.exitCode, + output: captured.output, + xcresultPath: "", + duration: Date().timeIntervalSince(start) + ) + } + + private func spmOutcome(exitCode: Int32, output: String) -> TestRunOutcome { + if exitCode == -1 { return .timedOut } + if exitCode == 0 { return .testsSucceeded } + + switch TestOutputParser().parse(output) { + case .killed(let name): return .testsFailed(failingTest: name) + case .crashed: return .crashed + case .unviable: return .unviable + } + } + private func launch( plistData: Data, slot: SimulatorSlot, From 2e2a1609e3c8c886dcd6582699de0aa57204dac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Mon, 30 Mar 2026 23:29:24 -0300 Subject: [PATCH 2/2] test: add SPM execution tests to TestExecutionStageTests and MutantExecutorTests --- .../Unit/Execution/MutantExecutorTests.swift | 6 +- .../Execution/TestExecutionStageTests.swift | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index dd213d4..107b31f 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -222,8 +222,8 @@ struct MutantExecutorTests { #expect(incompatibleResult?.status == .unviable) } - @Test("Given SPM project type and schematizable mutants, when execute called, then returns unviable") - func spmSchematizableMutantsAreUnviable() async throws { + @Test("Given SPM project type and schematizable mutants with exit code 0, when execute called, then survived") + func spmSchematizableMutantsSurviveOnExitCodeZero() async throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } @@ -244,7 +244,7 @@ struct MutantExecutorTests { let results = try await executor.execute(input) #expect(results.count == 1) - #expect(results[0].status == .unviable) + #expect(results[0].status == .survived) } @Test("Given SPM project type and build failure, when execute called, then throws compilationFailed") diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift index 46f32c0..13103a2 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift @@ -204,6 +204,71 @@ struct TestExecutionStageTests { } } + @Test("Given SPM artifact and exit code 0, when execute called, then mutant survived") + func spmExitCodeZeroProducesSurvivedStatus() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let (stage, context) = makeSPMFixture(in: dir, exitCode: 0) + try await context.pool.setUp() + + let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + + #expect(results.first?.status == .survived) + } + + @Test("Given SPM artifact and exit code 1 with failure output, when execute called, then mutant is killed") + func spmExitCodeOneWithFailureOutputProducesKilledStatus() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let output = #"Test "myTest" failed after 0.001 seconds."# + let (stage, context) = makeSPMFixture(in: dir, exitCode: 1, output: output) + try await context.pool.setUp() + + let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + + #expect(results.first?.status == .killed(by: "myTest")) + } + + @Test("Given SPM artifact with testTarget, when execute called, then returns result") + func spmWithTestTargetReturnsResult() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let pool = SimulatorPool( + baseUDID: nil, size: 1, + destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) + ) + try await pool.setUp() + let config = RunnerConfiguration( + projectPath: "/tmp", + build: .init( + projectType: .spm, testTarget: "MyLibTests", + timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + let stage = TestExecutionStage( + launcher: MockProcessLauncher(exitCode: 0), + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) + let context = TestExecutionContext( + artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: config, + testFilesHash: "hash" + ) + + let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + + #expect(results.count == 1) + #expect(results.first?.status == .survived) + } + private func makeFixture( in dir: URL, exitCode: Int32, @@ -230,6 +295,38 @@ struct TestExecutionStageTests { return (stage, context) } + private func makeSPMFixture( + in dir: URL, + exitCode: Int32, + output: String = "" + ) -> (TestExecutionStage, TestExecutionContext) { + let launcher = MockProcessLauncher(exitCode: exitCode, output: output) + let pool = SimulatorPool( + baseUDID: nil, size: 1, + destination: "platform=macOS", launcher: launcher + ) + let stage = TestExecutionStage( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) + let config = RunnerConfiguration( + projectPath: "/tmp", + build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), + reporting: .init(quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) + let context = TestExecutionContext( + artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: config, + testFilesHash: "hash" + ) + return (stage, context) + } + private func makeBuildArtifact(in dir: URL) -> BuildArtifact { let plistDict: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] let data = try! PropertyListSerialization.data(