From b15244c20a0718ac85428b65a1653dd78c434e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 10:21:36 -0300 Subject: [PATCH 1/8] refactor: nest private helper types inside their owning types --- .../Cache/CacheStore.swift | 10 +++--- .../Infrastructure/ProcessLauncher.swift | 34 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftMutationTesting/Cache/CacheStore.swift b/Sources/SwiftMutationTesting/Cache/CacheStore.swift index 4597c40..bccafc3 100644 --- a/Sources/SwiftMutationTesting/Cache/CacheStore.swift +++ b/Sources/SwiftMutationTesting/Cache/CacheStore.swift @@ -1,11 +1,11 @@ import Foundation -private struct CacheEntry: Codable { - let key: MutantCacheKey - let status: ExecutionStatus -} - actor CacheStore { + private struct CacheEntry: Codable { + let key: MutantCacheKey + let status: ExecutionStatus + } + init(storePath: String) { self.storePath = storePath self.entries = [:] diff --git a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift index 432aabf..b0c97b6 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/ProcessLauncher.swift @@ -142,26 +142,26 @@ struct ProcessLauncher: Sendable, ProcessLaunching { kill(-pid, SIGKILL) } } -} -private struct CaptureTarget { - let fileHandle: FileHandle - let tempURL: URL -} + private struct CaptureTarget { + let fileHandle: FileHandle + let tempURL: URL + } -private final class KilledByUsFlag: @unchecked Sendable { - private let lock = NSLock() - private var flag = false + private final class KilledByUsFlag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false - var value: Bool { - lock.lock() - defer { lock.unlock() } - return flag - } + var value: Bool { + lock.lock() + defer { lock.unlock() } + return flag + } - func mark() { - lock.lock() - flag = true - lock.unlock() + func mark() { + lock.lock() + flag = true + lock.unlock() + } } } From accd7bbeaf8d930eff0d2ba9c5ecedc599945e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 10:21:50 -0300 Subject: [PATCH 2/8] test: split SwiftMutationTestingTests into three focused suites and move helpers to TestSupport --- .../TestSupport/IOSSimulatorMock.swift | 22 ++ .../TestSupport/RunnerSummaryFixtures.swift | 5 + ...iftMutationTestingExecutionPathTests.swift | 102 ++++++ .../CLI/SwiftMutationTestingRunTests.swift | 94 +++++ .../Unit/CLI/SwiftMutationTestingTests.swift | 336 ------------------ .../Unit/CLI/WriteReportsTests.swift | 124 +++++++ 6 files changed, 347 insertions(+), 336 deletions(-) create mode 100644 Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift create mode 100644 Tests/SwiftMutationTestingTests/TestSupport/RunnerSummaryFixtures.swift create mode 100644 Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift create mode 100644 Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingRunTests.swift delete mode 100644 Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift create mode 100644 Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift new file mode 100644 index 0000000..4a6571c --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/IOSSimulatorMock.swift @@ -0,0 +1,22 @@ +import Foundation + +@testable import SwiftMutationTesting + +struct IOSSimulatorMock: ProcessLaunching { + let listJSON: String + let cloneUDID: String + + func launch( + executableURL: URL, arguments: [String], workingDirectoryURL: URL, timeout: Double + ) async throws -> Int32 { + 1 + } + + func launchCapturing( + executableURL: URL, arguments: [String], environment: [String: String]?, + additionalEnvironment: [String: String], workingDirectoryURL: URL, timeout: Double + ) async throws -> (exitCode: Int32, output: String) { + if arguments.contains("clone") { return (0, cloneUDID + "\n") } + return (0, listJSON) + } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/RunnerSummaryFixtures.swift b/Tests/SwiftMutationTestingTests/TestSupport/RunnerSummaryFixtures.swift new file mode 100644 index 0000000..f272d88 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/RunnerSummaryFixtures.swift @@ -0,0 +1,5 @@ +@testable import SwiftMutationTesting + +func makeEmptySummary() -> RunnerSummary { + RunnerSummary(results: [], totalDuration: 0) +} diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift new file mode 100644 index 0000000..f0f97f6 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingExecutionPathTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("SwiftMutationTesting.run execution path") +struct SwiftMutationTestingExecutionPathTests { + @Test("Given valid config with macOS destination and no Swift files, when run called, then returns success") + func mainExecutionPathWithEmptyProjectReturnsSuccess() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\n" + try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) + + let result = await SwiftMutationTesting.run( + args: [dir.path], + launcher: MockProcessLauncher(exitCode: 1) + ) + + #expect(result == .success) + } + + @Test("Given valid config with quiet false and no Swift files, when run called, then returns success") + func quietFalseExecutionPathReturnsSuccess() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\nquiet: false\n" + try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) + + let result = await SwiftMutationTesting.run( + args: [dir.path], + launcher: MockProcessLauncher(exitCode: 1) + ) + + #expect(result == .success) + } + + @Test("Given iOS Simulator destination with invalid simctl output, when run called, then returns error") + func iOSSimulatorDestinationWithInvalidSimctlOutputReturnsError() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let yml = "scheme: NonExistentScheme\ndestination: \"platform=iOS Simulator,name=iPhone 15\"\n" + try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) + + let result = await SwiftMutationTesting.run( + args: [dir.path], + launcher: MockProcessLauncher(exitCode: 1, output: "not-valid-json") + ) + + #expect(result == .error) + } + + @Test("Given iOS Simulator destination with valid simctl output, when run called, then SimulatorPool is created") + func iOSSimulatorPoolIsCreatedWhenDestinationRequiresSimulator() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let cloneUDID = "CLONE-UDID" + let listJSON = """ + {"devices":{"com.apple.runtime.iOS":[ + {"udid":"BASE-UDID","name":"iPhone 15","state":"Booted"}, + {"udid":"\(cloneUDID)","name":"Clone","state":"Booted"} + ]}} + """ + let yml = "scheme: NonExistentScheme\ndestination: \"platform=iOS Simulator,name=iPhone 15\"\n" + try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) + + let result = await SwiftMutationTesting.run( + args: [dir.path], + launcher: IOSSimulatorMock(listJSON: listJSON, cloneUDID: cloneUDID) + ) + + #expect(result == .success) + } + + @Test("Given corrupted cache file at project path, when run called, then returns error") + func corruptedCacheFileReturnsError() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\n" + try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) + + let cacheDir = dir.appendingPathComponent(CacheStore.directoryName) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + try "not valid json at all!!!".write( + to: cacheDir.appendingPathComponent("results.json"), + atomically: true, + encoding: .utf8 + ) + + let result = await SwiftMutationTesting.run( + args: [dir.path], + launcher: MockProcessLauncher(exitCode: 1) + ) + + #expect(result == .error) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingRunTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingRunTests.swift new file mode 100644 index 0000000..06e950d --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingRunTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("SwiftMutationTesting.run") +struct SwiftMutationTestingRunTests { + @Test("Given --help flag, when run called, then returns success") + func helpFlagReturnsSuccess() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let result = await SwiftMutationTesting.run(args: ["--help"]) + + #expect(result == .success) + } + + @Test("Given --help flag, when run called, then prints usage to stdout") + func helpFlagPrintsUsage() async { + let output = await captureOutput { + _ = await SwiftMutationTesting.run(args: ["--help"]) + } + + #expect(output.contains("swift-mutation-testing")) + } + + @Test("Given --version flag, when run called, then returns success") + func versionFlagReturnsSuccess() async { + let result = await SwiftMutationTesting.run(args: ["--version"]) + + #expect(result == .success) + } + + @Test("Given --version flag, when run called, then prints version to stdout") + func versionFlagPrintsVersion() async { + let output = await captureOutput { + _ = await SwiftMutationTesting.run(args: ["--version"]) + } + + #expect(output.contains("swift-mutation-testing")) + } + + @Test("Given no scheme, when run called, then returns error") + func missingSchemeReturnsError() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let result = await SwiftMutationTesting.run(args: [dir.path]) + + #expect(result == .error) + } + + @Test("Given scheme but no destination, when run called, then returns error") + func missingDestinationReturnsError() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let result = await SwiftMutationTesting.run(args: [dir.path, "--scheme", "App"]) + + #expect(result == .error) + } + + @Test("Given unknown flag, when run called, then returns error") + func unknownFlagReturnsError() async { + let result = await SwiftMutationTesting.run(args: ["--unknown-flag-xyz"]) + + #expect(result == .error) + } + + @Test("Given init command on empty directory, when run called, then returns success and creates yml") + func initCommandCreatesConfigFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let result = await SwiftMutationTesting.run(args: ["init", dir.path]) + + #expect(result == .success) + let ymlPath = dir.appendingPathComponent(".swift-mutation-testing.yml").path + #expect(FileManager.default.fileExists(atPath: ymlPath)) + } + + @Test("Given init command when yml already exists, when run called, then returns error") + func initCommandFailsWhenYmlExists() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let ymlURL = dir.appendingPathComponent(".swift-mutation-testing.yml") + try "existing".write(to: ymlURL, atomically: true, encoding: .utf8) + + let result = await SwiftMutationTesting.run(args: ["init", dir.path]) + + #expect(result == .error) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift deleted file mode 100644 index ba46293..0000000 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/SwiftMutationTestingTests.swift +++ /dev/null @@ -1,336 +0,0 @@ -import Foundation -import Testing - -@testable import SwiftMutationTesting - -private struct IOSSimulatorMock: ProcessLaunching { - let listJSON: String - let cloneUDID: String - - func launch( - executableURL: URL, arguments: [String], workingDirectoryURL: URL, timeout: Double - ) async throws -> Int32 { - 1 - } - - func launchCapturing( - executableURL: URL, arguments: [String], environment: [String: String]?, - additionalEnvironment: [String: String], workingDirectoryURL: URL, timeout: Double - ) async throws -> (exitCode: Int32, output: String) { - if arguments.contains("clone") { return (0, cloneUDID + "\n") } - return (0, listJSON) - } -} - -@Suite("SwiftMutationTesting.run") -struct SwiftMutationTestingRunTests { - @Test("Given --help flag, when run called, then returns success") - func helpFlagReturnsSuccess() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let result = await SwiftMutationTesting.run(args: ["--help"]) - - #expect(result == .success) - } - - @Test("Given --help flag, when run called, then prints usage to stdout") - func helpFlagPrintsUsage() async { - let output = await captureOutput { - _ = await SwiftMutationTesting.run(args: ["--help"]) - } - - #expect(output.contains("swift-mutation-testing")) - } - - @Test("Given --version flag, when run called, then returns success") - func versionFlagReturnsSuccess() async { - let result = await SwiftMutationTesting.run(args: ["--version"]) - - #expect(result == .success) - } - - @Test("Given --version flag, when run called, then prints version to stdout") - func versionFlagPrintsVersion() async { - let output = await captureOutput { - _ = await SwiftMutationTesting.run(args: ["--version"]) - } - - #expect(output.contains("swift-mutation-testing")) - } - - @Test("Given no scheme, when run called, then returns error") - func missingSchemeReturnsError() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let result = await SwiftMutationTesting.run(args: [dir.path]) - - #expect(result == .error) - } - - @Test("Given scheme but no destination, when run called, then returns error") - func missingDestinationReturnsError() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let result = await SwiftMutationTesting.run(args: [dir.path, "--scheme", "App"]) - - #expect(result == .error) - } - - @Test("Given unknown flag, when run called, then returns error") - func unknownFlagReturnsError() async { - let result = await SwiftMutationTesting.run(args: ["--unknown-flag-xyz"]) - - #expect(result == .error) - } - - @Test("Given init command on empty directory, when run called, then returns success and creates yml") - func initCommandCreatesConfigFile() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let result = await SwiftMutationTesting.run(args: ["init", dir.path]) - - #expect(result == .success) - let ymlPath = dir.appendingPathComponent(".swift-mutation-testing.yml").path - #expect(FileManager.default.fileExists(atPath: ymlPath)) - } - - @Test("Given init command when yml already exists, when run called, then returns error") - func initCommandFailsWhenYmlExists() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let ymlURL = dir.appendingPathComponent(".swift-mutation-testing.yml") - try "existing".write(to: ymlURL, atomically: true, encoding: .utf8) - - let result = await SwiftMutationTesting.run(args: ["init", dir.path]) - - #expect(result == .error) - } -} - -@Suite("SwiftMutationTesting.writeReports", .serialized) -struct WriteReportsTests { - @Test("Given no output paths configured, when writeReports called, then no files are created") - func noOutputPathsProducesNoFiles() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let configuration = makeConfiguration(projectPath: dir.path) - let summary = makeEmptySummary() - - SwiftMutationTesting.writeReports(summary, configuration: configuration) - - let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) - #expect(files.isEmpty) - } - - @Test("Given json output path, when writeReports called, then json file is written") - func jsonOutputPathWritesFile() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let outputPath = dir.appendingPathComponent("report.json").path - let configuration = makeConfiguration(projectPath: dir.path, output: outputPath) - - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - - #expect(FileManager.default.fileExists(atPath: outputPath)) - } - - @Test("Given html output path, when writeReports called, then html file is written") - func htmlOutputPathWritesFile() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let outputPath = dir.appendingPathComponent("report.html").path - let configuration = makeConfiguration(projectPath: dir.path, htmlOutput: outputPath) - - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - - #expect(FileManager.default.fileExists(atPath: outputPath)) - } - - @Test("Given sonar output path, when writeReports called, then sonar file is written") - func sonarOutputPathWritesFile() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let outputPath = dir.appendingPathComponent("sonar.json").path - let configuration = makeConfiguration(projectPath: dir.path, sonarOutput: outputPath) - - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - - #expect(FileManager.default.fileExists(atPath: outputPath)) - } - - @Test("Given invalid json output path, when writeReports called, then does not crash") - func invalidJsonOutputPathDoesNotCrash() { - let configuration = makeConfiguration( - projectPath: "/tmp", - output: "/nonexistent/dir/report.json" - ) - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - } - - @Test("Given invalid html output path, when writeReports called, then does not crash") - func invalidHtmlOutputPathDoesNotCrash() { - let configuration = makeConfiguration( - projectPath: "/tmp", - htmlOutput: "/nonexistent/dir/report.html" - ) - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - } - - @Test("Given invalid sonar output path, when writeReports called, then does not crash") - func invalidSonarOutputPathDoesNotCrash() { - let configuration = makeConfiguration( - projectPath: "/tmp", - sonarOutput: "/nonexistent/dir/sonar.json" - ) - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - } - - @Test("Given all three output paths, when writeReports called, then all three files are written") - func allThreeOutputPathsWriteFiles() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let jsonPath = dir.appendingPathComponent("report.json").path - let htmlPath = dir.appendingPathComponent("report.html").path - let sonarPath = dir.appendingPathComponent("sonar.json").path - let configuration = makeConfiguration( - projectPath: dir.path, - output: jsonPath, - htmlOutput: htmlPath, - sonarOutput: sonarPath - ) - - SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) - - #expect(FileManager.default.fileExists(atPath: jsonPath)) - #expect(FileManager.default.fileExists(atPath: htmlPath)) - #expect(FileManager.default.fileExists(atPath: sonarPath)) - } - - private func makeEmptySummary() -> RunnerSummary { - RunnerSummary(results: [], totalDuration: 0) - } - - private func makeConfiguration( - projectPath: String, - output: String? = nil, - htmlOutput: String? = nil, - sonarOutput: String? = nil - ) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: false), - reporting: .init(output: output, htmlOutput: htmlOutput, sonarOutput: sonarOutput, quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) - } -} - -@Suite("SwiftMutationTesting.run execution path") -struct SwiftMutationTestingExecutionPathTests { - @Test("Given valid config with macOS destination and no Swift files, when run called, then returns success") - func mainExecutionPathWithEmptyProjectReturnsSuccess() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\n" - try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) - - let result = await SwiftMutationTesting.run( - args: [dir.path], - launcher: MockProcessLauncher(exitCode: 1) - ) - - #expect(result == .success) - } - - @Test("Given valid config with quiet false and no Swift files, when run called, then returns success") - func quietFalseExecutionPathReturnsSuccess() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\nquiet: false\n" - try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) - - let result = await SwiftMutationTesting.run( - args: [dir.path], - launcher: MockProcessLauncher(exitCode: 1) - ) - - #expect(result == .success) - } - - @Test("Given iOS Simulator destination with invalid simctl output, when run called, then returns error") - func iOSSimulatorDestinationWithInvalidSimctlOutputReturnsError() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let yml = "scheme: NonExistentScheme\ndestination: \"platform=iOS Simulator,name=iPhone 15\"\n" - try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) - - let result = await SwiftMutationTesting.run( - args: [dir.path], - launcher: MockProcessLauncher(exitCode: 1, output: "not-valid-json") - ) - - #expect(result == .error) - } - - @Test("Given iOS Simulator destination with valid simctl output, when run called, then SimulatorPool is created") - func iOSSimulatorPoolIsCreatedWhenDestinationRequiresSimulator() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let cloneUDID = "CLONE-UDID" - let listJSON = """ - {"devices":{"com.apple.runtime.iOS":[ - {"udid":"BASE-UDID","name":"iPhone 15","state":"Booted"}, - {"udid":"\(cloneUDID)","name":"Clone","state":"Booted"} - ]}} - """ - let yml = "scheme: NonExistentScheme\ndestination: \"platform=iOS Simulator,name=iPhone 15\"\n" - try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) - - let result = await SwiftMutationTesting.run( - args: [dir.path], - launcher: IOSSimulatorMock(listJSON: listJSON, cloneUDID: cloneUDID) - ) - - #expect(result == .success) - } - - @Test("Given corrupted cache file at project path, when run called, then returns error") - func corruptedCacheFileReturnsError() async throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let yml = "scheme: NonExistentScheme\ndestination: platform=macOS\n" - try yml.write(to: dir.appendingPathComponent(".swift-mutation-testing.yml"), atomically: true, encoding: .utf8) - - let cacheDir = dir.appendingPathComponent(CacheStore.directoryName) - try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - try "not valid json at all!!!".write( - to: cacheDir.appendingPathComponent("results.json"), - atomically: true, - encoding: .utf8 - ) - - let result = await SwiftMutationTesting.run( - args: [dir.path], - launcher: MockProcessLauncher(exitCode: 1) - ) - - #expect(result == .error) - } -} diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift new file mode 100644 index 0000000..26e7d2a --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("SwiftMutationTesting.writeReports", .serialized) +struct WriteReportsTests { + @Test("Given no output paths configured, when writeReports called, then no files are created") + func noOutputPathsProducesNoFiles() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let configuration = makeConfiguration(projectPath: dir.path) + + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + + let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) + #expect(files.isEmpty) + } + + @Test("Given json output path, when writeReports called, then json file is written") + func jsonOutputPathWritesFile() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let outputPath = dir.appendingPathComponent("report.json").path + let configuration = makeConfiguration(projectPath: dir.path, output: outputPath) + + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + + #expect(FileManager.default.fileExists(atPath: outputPath)) + } + + @Test("Given html output path, when writeReports called, then html file is written") + func htmlOutputPathWritesFile() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let outputPath = dir.appendingPathComponent("report.html").path + let configuration = makeConfiguration(projectPath: dir.path, htmlOutput: outputPath) + + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + + #expect(FileManager.default.fileExists(atPath: outputPath)) + } + + @Test("Given sonar output path, when writeReports called, then sonar file is written") + func sonarOutputPathWritesFile() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let outputPath = dir.appendingPathComponent("sonar.json").path + let configuration = makeConfiguration(projectPath: dir.path, sonarOutput: outputPath) + + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + + #expect(FileManager.default.fileExists(atPath: outputPath)) + } + + @Test("Given invalid json output path, when writeReports called, then does not crash") + func invalidJsonOutputPathDoesNotCrash() { + let configuration = makeConfiguration( + projectPath: "/tmp", + output: "/nonexistent/dir/report.json" + ) + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + } + + @Test("Given invalid html output path, when writeReports called, then does not crash") + func invalidHtmlOutputPathDoesNotCrash() { + let configuration = makeConfiguration( + projectPath: "/tmp", + htmlOutput: "/nonexistent/dir/report.html" + ) + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + } + + @Test("Given invalid sonar output path, when writeReports called, then does not crash") + func invalidSonarOutputPathDoesNotCrash() { + let configuration = makeConfiguration( + projectPath: "/tmp", + sonarOutput: "/nonexistent/dir/sonar.json" + ) + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + } + + @Test("Given all three output paths, when writeReports called, then all three files are written") + func allThreeOutputPathsWriteFiles() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let jsonPath = dir.appendingPathComponent("report.json").path + let htmlPath = dir.appendingPathComponent("report.html").path + let sonarPath = dir.appendingPathComponent("sonar.json").path + let configuration = makeConfiguration( + projectPath: dir.path, + output: jsonPath, + htmlOutput: htmlPath, + sonarOutput: sonarPath + ) + + SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) + + #expect(FileManager.default.fileExists(atPath: jsonPath)) + #expect(FileManager.default.fileExists(atPath: htmlPath)) + #expect(FileManager.default.fileExists(atPath: sonarPath)) + } +} + +private func makeConfiguration( + projectPath: String, + output: String? = nil, + htmlOutput: String? = nil, + sonarOutput: String? = nil +) -> RunnerConfiguration { + RunnerConfiguration( + projectPath: projectPath, + build: .init( + projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), + timeout: 60, concurrency: 1, noCache: false), + reporting: .init(output: output, htmlOutput: htmlOutput, sonarOutput: sonarOutput, quiet: true), + filter: .init(excludePatterns: [], operators: []) + ) +} From f2d7563d75f8d8dbb138b528f3c296d9b085c131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 10:21:57 -0300 Subject: [PATCH 3/8] test: replace bool literal comparisons with direct expect assertions --- .../DiscoveryPipelineIntegrationTests.swift | 2 +- .../Unit/CLI/CommandLineParserTests.swift | 20 +++++++++---------- .../ConfigurationResolverTests.swift | 4 ++-- .../Pipeline/DiscoveryPipelineTests.swift | 2 +- .../Pipeline/SchematizationStageTests.swift | 9 +++++---- .../Unit/Sandbox/SandboxFactoryTests.swift | 12 +++++------ .../Simulator/SimulatorManagerTests.swift | 6 +++--- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift b/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift index dab2674..5eb132f 100644 --- a/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift +++ b/Tests/SwiftMutationTestingTests/Integration/DiscoveryPipelineIntegrationTests.swift @@ -125,7 +125,7 @@ struct DiscoveryPipelineIntegrationTests { #expect(result.projectType == .xcode(scheme: "CalcApp", destination: "platform=macOS")) #expect(result.timeout == 60) #expect(result.concurrency == 1) - #expect(result.noCache == false) + #expect(!result.noCache) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift index 45920a1..43ebdee 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/CommandLineParserTests.swift @@ -14,8 +14,8 @@ struct CommandLineParserTests { #expect(result.projectPath == "/my/project") #expect(result.build.scheme == "MyApp") #expect(result.build.destination == "platform=macOS") - #expect(result.showHelp == false) - #expect(result.showVersion == false) + #expect(!result.showHelp) + #expect(!result.showVersion) } @Test("Given run command without explicit path, when parsed, then projectPath defaults to dot") @@ -29,21 +29,21 @@ struct CommandLineParserTests { func returnsShowHelpForHelpFlag() throws { let result = try parser.parse(["--help"]) - #expect(result.showHelp == true) + #expect(result.showHelp) } @Test("Given -h flag, when parsed, then showHelp is true") func returnsShowHelpForShortFlag() throws { let result = try parser.parse(["-h"]) - #expect(result.showHelp == true) + #expect(result.showHelp) } @Test("Given empty arguments, when parsed, then execution is attempted with default project path") func attemptsExecutionWhenEmpty() throws { let result = try parser.parse([]) - #expect(result.showHelp == false) + #expect(!result.showHelp) #expect(result.projectPath == ".") } @@ -51,15 +51,15 @@ struct CommandLineParserTests { func returnsShowVersion() throws { let result = try parser.parse(["--version"]) - #expect(result.showVersion == true) + #expect(result.showVersion) } @Test("Given --no-cache and --quiet flags, when parsed, then noCache and quiet are true") func parsesBooleanFlags() throws { let result = try parser.parse(["run", "--scheme", "App", "--destination", "d", "--no-cache", "--quiet"]) - #expect(result.build.noCache == true) - #expect(result.reporting.quiet == true) + #expect(result.build.noCache) + #expect(result.reporting.quiet) } @Test("Given optional string flags, when parsed, then all string values are set") @@ -94,7 +94,7 @@ struct CommandLineParserTests { func parsesInitWithDefaultPath() throws { let result = try parser.parse(["init"]) - #expect(result.showInit == true) + #expect(result.showInit) #expect(result.projectPath == ".") } @@ -102,7 +102,7 @@ struct CommandLineParserTests { func parsesInitWithExplicitPath() throws { let result = try parser.parse(["init", "/my/project"]) - #expect(result.showInit == true) + #expect(result.showInit) #expect(result.projectPath == "/my/project") } diff --git a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift index 9dbf521..5c3eb37 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Configuration/ConfigurationResolverTests.swift @@ -137,7 +137,7 @@ struct ConfigurationResolverTests { fileValues: ["noCache": "true"] ) - #expect(result.build.noCache == true) + #expect(result.build.noCache) } @Test("Given quiet true in file, when resolved, then quiet is true") @@ -147,7 +147,7 @@ struct ConfigurationResolverTests { fileValues: ["quiet": "true"] ) - #expect(result.reporting.quiet == true) + #expect(result.reporting.quiet) } @Test("Given concurrency of zero, when resolved, then throws UsageError") diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift index 32593b7..ab3db29 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift @@ -76,7 +76,7 @@ struct DiscoveryPipelineTests { #expect(result.projectType == .xcode(scheme: "MyScheme", destination: "platform=macOS")) #expect(result.timeout == 120) #expect(result.concurrency == 8) - #expect(result.noCache == true) + #expect(result.noCache) } @Test("Given empty operators list, when run, then all default operators are used") diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift index 12cfa24..750f432 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift @@ -20,7 +20,7 @@ struct SchematizationStageTests { let source = makeParsedSource("func f() { let x = true }", path: "a.swift") let mutations = BooleanLiteralReplacement().mutations(in: source) let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.descriptors[0].isSchematizable == true) + #expect(result.descriptors[0].isSchematizable) #expect(result.descriptors[0].mutatedSourceContent == nil) } @@ -29,17 +29,18 @@ struct SchematizationStageTests { let source = makeParsedSource("let x = true", path: "a.swift") let mutations = BooleanLiteralReplacement().mutations(in: source) let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.descriptors[0].isSchematizable == false) + #expect(!result.descriptors[0].isSchematizable) #expect(result.descriptors[0].mutatedSourceContent != nil) #expect(result.schematizedFiles.isEmpty) } @Test("Given incompatible mutation, when run, then mutatedSourceContent has mutation applied") - func incompatibleMutationContentHasMutationApplied() { + func incompatibleMutationContentHasMutationApplied() throws { let source = makeParsedSource("let x = true", path: "a.swift") let mutations = BooleanLiteralReplacement().mutations(in: source) let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.descriptors[0].mutatedSourceContent?.contains("false") == true) + let content = try #require(result.descriptors[0].mutatedSourceContent) + #expect(content.contains("false")) } @Test("Given multiple mutations across files, when run, then descriptors are sorted by global index") diff --git a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift index 1ccaa18..d00aa96 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift @@ -187,10 +187,10 @@ struct SandboxFactoryTests { var isDir: ObjCBool = false let exists = FileManager.default.fileExists(atPath: sandboxXcuserdata.path, isDirectory: &isDir) - #expect(exists == true) - #expect(isDir.boolValue == true) - #expect(isSymlink == false) - #expect(FileManager.default.fileExists(atPath: userDataFile.path) == false) + #expect(exists) + #expect(isDir.boolValue) + #expect(!isSymlink) + #expect(!FileManager.default.fileExists(atPath: userDataFile.path)) } @Test("Given file not in schematized list, when sandbox created, then file is a symlink to the original") @@ -211,7 +211,7 @@ struct SandboxFactoryTests { let isSymlink = (try? sandboxFile.resourceValues(forKeys: [.isSymbolicLinkKey]))?.isSymbolicLink ?? false let content = try String(contentsOf: sandboxFile, encoding: .utf8) - #expect(isSymlink == true) + #expect(isSymlink) #expect(content == "original content") } @@ -431,7 +431,7 @@ struct SandboxFactoryTests { try sandbox.cleanup() - #expect(FileManager.default.fileExists(atPath: sandbox.rootURL.path) == false) + #expect(!FileManager.default.fileExists(atPath: sandbox.rootURL.path)) } private func swiftLintPbxprojContent() -> String { diff --git a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift index 58b0bc8..0f76e91 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Simulator/SimulatorManagerTests.swift @@ -9,14 +9,14 @@ struct SimulatorManagerTests { func requiresSimulatorPoolReturnsTrueForIOSSimulator() { let result = SimulatorManager.requiresSimulatorPool(for: "platform=iOS Simulator,name=iPhone 15") - #expect(result == true) + #expect(result) } @Test("Given macOS destination, when requiresSimulatorPool called, then returns false") func requiresSimulatorPoolReturnsFalseForMacOS() { let result = SimulatorManager.requiresSimulatorPool(for: "platform=macOS,arch=arm64") - #expect(result == false) + #expect(!result) } @Test("Given destination with name, when resolveBaseUDID called, then returns matching UDID") @@ -66,7 +66,7 @@ struct SimulatorManagerTests { @Test("Given destination without platform= prefix, when requiresSimulatorPool called, then returns true") func requiresSimulatorPoolReturnsTrueForUnknownPlatform() { let result = SimulatorManager.requiresSimulatorPool(for: "name=My Device,OS=latest") - #expect(result == true) + #expect(result) } @Test("Given simulator never boots within max attempts, when waitForBooted called, then throws bootTimeout") From 801a614feeae8948d32150d10052816d01e75779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 20:05:22 -0300 Subject: [PATCH 4/8] refactor: extract discover duration and writeReport helper in SwiftMutationTesting --- .../SwiftMutationTesting.swift | 58 +++++++++---------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift index 151bf92..f8031a3 100644 --- a/Sources/SwiftMutationTesting/SwiftMutationTesting.swift +++ b/Sources/SwiftMutationTesting/SwiftMutationTesting.swift @@ -46,7 +46,20 @@ public struct SwiftMutationTesting { fileValues: fileValues ) - let input = try await discover(configuration: configuration) + let (input, discoveryDuration) = try await discover(configuration: configuration) + + if !configuration.reporting.quiet { + let schematizable = input.mutants.filter { $0.isSchematizable }.count + let incompatible = input.mutants.count - schematizable + await ConsoleProgressReporter().report( + .discoveryFinished( + mutantCount: input.mutants.count, + schematizableCount: schematizable, + incompatibleCount: incompatible, + duration: discoveryDuration + )) + } + let start = Date() let results = try await MutantExecutor(configuration: configuration, launcher: launcher).execute(input) let duration = Date().timeIntervalSince(start) @@ -58,7 +71,7 @@ public struct SwiftMutationTesting { return .success } - private static func discover(configuration: RunnerConfiguration) async throws -> RunnerInput { + private static func discover(configuration: RunnerConfiguration) async throws -> (RunnerInput, TimeInterval) { let start = Date() let discoveryInput = DiscoveryInput( projectPath: configuration.projectPath, @@ -71,20 +84,7 @@ public struct SwiftMutationTesting { operators: configuration.filter.operators ) let input = try await DiscoveryPipeline().run(input: discoveryInput) - - if !configuration.reporting.quiet { - let schematizable = input.mutants.filter { $0.isSchematizable }.count - let incompatible = input.mutants.count - schematizable - await ConsoleProgressReporter().report( - .discoveryFinished( - mutantCount: input.mutants.count, - schematizableCount: schematizable, - incompatibleCount: incompatible, - duration: Date().timeIntervalSince(start) - )) - } - - return input + return (input, Date().timeIntervalSince(start)) } static func writeReports(_ summary: RunnerSummary, configuration: RunnerConfiguration) { @@ -96,32 +96,30 @@ public struct SwiftMutationTesting { print("") if let output = configuration.reporting.output { - do { + writeReport(label: "JSON", to: output) { try JsonReporter(outputPath: output, projectRoot: configuration.projectPath).report(summary) - print(" ✓ JSON report: \(output)") - } catch { - fputs("Warning: could not write JSON report to '\(output)': \(error.localizedDescription)\n", stderr) } } if let htmlOutput = configuration.reporting.htmlOutput { - do { + writeReport(label: "HTML", to: htmlOutput) { try HtmlReporter(outputPath: htmlOutput, projectRoot: configuration.projectPath).report(summary) - print(" ✓ HTML report: \(htmlOutput)") - } catch { - let msg = "Warning: could not write HTML report to '\(htmlOutput)': \(error.localizedDescription)\n" - fputs(msg, stderr) } } if let sonarOutput = configuration.reporting.sonarOutput { - do { + writeReport(label: "Sonar", to: sonarOutput) { try SonarReporter(outputPath: sonarOutput, projectRoot: configuration.projectPath).report(summary) - print(" ✓ Sonar report: \(sonarOutput)") - } catch { - let msg = "Warning: could not write Sonar report to '\(sonarOutput)': \(error.localizedDescription)\n" - fputs(msg, stderr) } } } + + private static func writeReport(label: String, to path: String, _ write: () throws -> Void) { + do { + try write() + print(" ✓ \(label) report: \(path)") + } catch { + fputs("Warning: could not write \(label) report to '\(path)': \(error.localizedDescription)\n", stderr) + } + } } From a43fcac73bb2b4cef88466680ced4578ce49ddd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 20:05:29 -0300 Subject: [PATCH 5/8] refactor: consolidate execution stage deps into ExecutionDeps and extract FallbackExecutor --- .../Execution/FallbackExecutor.swift | 102 +++++++++++++++ .../IncompatibleMutantExecutor.swift | 35 +++-- .../Execution/MutantExecutor.swift | 122 +----------------- .../Execution/TestExecutionStage.swift | 31 ++--- 4 files changed, 137 insertions(+), 153 deletions(-) create mode 100644 Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift diff --git a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift new file mode 100644 index 0000000..86cf0ce --- /dev/null +++ b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift @@ -0,0 +1,102 @@ +import Foundation + +struct FallbackExecutor: Sendable { + let deps: ExecutionDeps + let configuration: RunnerConfiguration + + func execute(input: RunnerInput, pool: SimulatorPool, testFilesHash: String) async throws -> [ExecutionResult] { + var results: [ExecutionResult] = [] + + for file in input.schematizedFiles { + results += try await processFile(file: file, input: input, pool: pool, testFilesHash: testFilesHash) + } + + return results + } + + private func processFile( + file: SchematizedFile, + input: RunnerInput, + pool: SimulatorPool, + testFilesHash: String + ) async throws -> [ExecutionResult] { + let fileMutants = input.mutants.filter { $0.filePath == file.originalPath && $0.isSchematizable } + + guard !fileMutants.isEmpty else { return [] } + + if let cached = await cachedResults(for: fileMutants, testFilesHash: testFilesHash) { + return cached + } + + let sandbox = try await SandboxFactory().create( + projectPath: input.projectPath, + schematizedFiles: [file], + supportFileContent: input.supportFileContent + ) + + await deps.reporter.report(.fallbackBuildStarted(filePath: file.originalPath)) + + guard case .xcode(let scheme, let destination) = configuration.build.projectType else { + try? sandbox.cleanup() + return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + } + + let artifact: BuildArtifact + do { + artifact = try await BuildStage(launcher: deps.launcher).build( + sandbox: sandbox, + scheme: scheme, + destination: destination, + timeout: configuration.build.timeout + ) + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) + } catch { + await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) + try? sandbox.cleanup() + return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + } + + let context = TestExecutionContext( + artifact: artifact, sandbox: sandbox, pool: pool, + configuration: configuration, testFilesHash: testFilesHash + ) + + let stageResults = try await TestExecutionStage(deps: deps).execute(mutants: fileMutants, in: context) + try? sandbox.cleanup() + return stageResults + } + + private func cachedResults(for mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult]? { + guard !configuration.build.noCache else { return nil } + + var results: [ExecutionResult] = [] + for mutant in mutants { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + guard let status = await deps.cacheStore.result(for: key) else { return nil } + results.append(ExecutionResult(descriptor: mutant, status: status, testDuration: 0)) + } + + for result in results { + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished( + descriptor: result.descriptor, status: result.status, + index: index, total: deps.counter.total)) + } + + return results + } + + private func markUnviable(mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult] { + var results: [ExecutionResult] = [] + for mutant in mutants { + let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + await deps.cacheStore.store(status: .unviable, for: key) + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished(descriptor: mutant, status: .unviable, index: index, total: deps.counter.total)) + results.append(ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0)) + } + return results + } +} diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 9b4f568..c05f097 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -1,11 +1,8 @@ import Foundation struct IncompatibleMutantExecutor: Sendable { - let launcher: any ProcessLaunching + let deps: ExecutionDeps let sandboxFactory: SandboxFactory - let cacheStore: CacheStore - let reporter: any ProgressReporter - let counter: MutationCounter func execute( _ mutants: [MutantDescriptor], @@ -18,10 +15,10 @@ struct IncompatibleMutantExecutor: Sendable { for mutant in mutants { let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) - if !configuration.build.noCache, let cachedStatus = await cacheStore.result(for: key) { - let total = counter.total - let index = await counter.increment() - await reporter.report( + if !configuration.build.noCache, let cachedStatus = await deps.cacheStore.result(for: key) { + let total = deps.counter.total + let index = await deps.counter.increment() + await deps.reporter.report( .mutantFinished(descriptor: mutant, status: cachedStatus, index: index, total: total)) results.append(ExecutionResult(descriptor: mutant, status: cachedStatus, testDuration: 0)) continue @@ -66,7 +63,7 @@ struct IncompatibleMutantExecutor: Sendable { let outcome: TestRunOutcome switch configuration.build.projectType { case .xcode: - outcome = try await ResultParser(launcher: launcher).parse( + outcome = try await ResultParser(launcher: deps.launcher).parse( exitCode: launched.exitCode, output: launched.output, xcresultPath: launched.xcresultPath, @@ -80,10 +77,10 @@ struct IncompatibleMutantExecutor: Sendable { try? sandbox.cleanup() let status = outcome.asExecutionStatus - let total = counter.total - let index = await counter.increment() - await reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: total)) - await cacheStore.store(status: status, for: key) + let total = deps.counter.total + let index = await deps.counter.increment() + await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: total)) + await deps.cacheStore.store(status: status, for: key) return ExecutionResult(descriptor: mutant, status: status, testDuration: duration) } @@ -125,7 +122,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, @@ -153,7 +150,7 @@ struct IncompatibleMutantExecutor: Sendable { } let start = Date() - let captured = try await launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -176,10 +173,10 @@ struct IncompatibleMutantExecutor: Sendable { sandbox: Sandbox? ) async -> ExecutionResult { try? sandbox?.cleanup() - await cacheStore.store(status: .unviable, for: key) - let total = counter.total - let index = await counter.increment() - await reporter.report(.mutantFinished(descriptor: mutant, status: .unviable, index: index, total: total)) + await deps.cacheStore.store(status: .unviable, for: key) + let total = deps.counter.total + let index = await deps.counter.increment() + await deps.reporter.report(.mutantFinished(descriptor: mutant, status: .unviable, index: index, total: total)) return ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0) } } diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index 5e43e92..ecc8824 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -119,10 +119,7 @@ struct MutantExecutor: Sendable { context: TestExecutionContext, schematizable: [MutantDescriptor] ) async throws -> [ExecutionResult] { - try await TestExecutionStage( - launcher: deps.launcher, cacheStore: deps.cacheStore, - reporter: deps.reporter, counter: deps.counter - ).execute(mutants: schematizable, in: context) + try await TestExecutionStage(deps: deps).execute(mutants: schematizable, in: context) } private func runFallback( @@ -131,115 +128,8 @@ struct MutantExecutor: Sendable { pool: SimulatorPool, testFilesHash: String ) async throws -> [ExecutionResult] { - var results: [ExecutionResult] = [] - - for file in input.schematizedFiles { - results += try await processFallbackFile( - file: file, input: input, pool: pool, - testFilesHash: testFilesHash, deps: deps - ) - } - - return results - } - - private func processFallbackFile( - file: SchematizedFile, - input: RunnerInput, - pool: SimulatorPool, - testFilesHash: String, - deps: ExecutionDeps - ) async throws -> [ExecutionResult] { - let sandboxFactory = SandboxFactory() - let fileMutants = input.mutants.filter { $0.filePath == file.originalPath && $0.isSchematizable } - - guard !fileMutants.isEmpty else { return [] } - - if let cached = await cachedResultsForFile(fileMutants, testFilesHash: testFilesHash, deps: deps) { - return cached - } - - let sandbox = try await sandboxFactory.create( - projectPath: input.projectPath, - schematizedFiles: [file], - supportFileContent: input.supportFileContent - ) - - await deps.reporter.report(.fallbackBuildStarted(filePath: file.originalPath)) - - guard case .xcode(let scheme, let destination) = configuration.build.projectType else { - try? sandbox.cleanup() - return await markFallbackMutantsUnviable(mutants: fileMutants, testFilesHash: testFilesHash, deps: deps) - } - - let artifact: BuildArtifact - do { - artifact = try await BuildStage(launcher: deps.launcher).build( - sandbox: sandbox, - scheme: scheme, - destination: destination, - timeout: configuration.build.timeout - ) - await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: true)) - } catch { - await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) - try? sandbox.cleanup() - return await markFallbackMutantsUnviable(mutants: fileMutants, testFilesHash: testFilesHash, deps: deps) - } - - let context = TestExecutionContext( - artifact: artifact, sandbox: sandbox, pool: pool, - configuration: configuration, testFilesHash: testFilesHash - ) - - let stageResults = try await TestExecutionStage( - launcher: deps.launcher, cacheStore: deps.cacheStore, - reporter: deps.reporter, counter: deps.counter - ).execute(mutants: fileMutants, in: context) - try? sandbox.cleanup() - return stageResults - } - - private func cachedResultsForFile( - _ mutants: [MutantDescriptor], - testFilesHash: String, - deps: ExecutionDeps - ) async -> [ExecutionResult]? { - guard !configuration.build.noCache else { return nil } - - var results: [ExecutionResult] = [] - for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) - guard let status = await deps.cacheStore.result(for: key) else { return nil } - results.append(ExecutionResult(descriptor: mutant, status: status, testDuration: 0)) - } - - for result in results { - let index = await deps.counter.increment() - await deps.reporter.report( - .mutantFinished( - descriptor: result.descriptor, status: result.status, - index: index, total: deps.counter.total)) - } - - return results - } - - private func markFallbackMutantsUnviable( - mutants: [MutantDescriptor], - testFilesHash: String, - deps: ExecutionDeps - ) async -> [ExecutionResult] { - var results: [ExecutionResult] = [] - for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) - await deps.cacheStore.store(status: .unviable, for: key) - let index = await deps.counter.increment() - await deps.reporter.report( - .mutantFinished(descriptor: mutant, status: .unviable, index: index, total: deps.counter.total)) - results.append(ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0)) - } - return results + try await FallbackExecutor(deps: deps, configuration: configuration) + .execute(input: input, pool: pool, testFilesHash: testFilesHash) } private func runIncompatible( @@ -248,10 +138,8 @@ struct MutantExecutor: Sendable { pool: SimulatorPool, testFilesHash: String ) async throws -> [ExecutionResult] { - try await IncompatibleMutantExecutor( - launcher: deps.launcher, sandboxFactory: SandboxFactory(), - cacheStore: deps.cacheStore, reporter: deps.reporter, counter: deps.counter - ).execute(mutants, configuration: configuration, pool: pool, testFilesHash: testFilesHash) + try await IncompatibleMutantExecutor(deps: deps, sandboxFactory: SandboxFactory()) + .execute(mutants, configuration: configuration, pool: pool, testFilesHash: testFilesHash) } private func makePool(launcher: any ProcessLaunching) async throws -> SimulatorPool { diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 06b190a..1374205 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -1,10 +1,7 @@ import Foundation struct TestExecutionStage: Sendable { - let launcher: any ProcessLaunching - let cacheStore: CacheStore - let reporter: any ProgressReporter - let counter: MutationCounter + let deps: ExecutionDeps func execute( mutants: [MutantDescriptor], @@ -40,11 +37,11 @@ struct TestExecutionStage: Sendable { key: MutantCacheKey, in context: TestExecutionContext ) async throws -> ExecutionResult { - if !context.configuration.build.noCache, let cached = await cacheStore.result(for: key) { + if !context.configuration.build.noCache, let cached = await deps.cacheStore.result(for: key) { let result = ExecutionResult(descriptor: mutant, status: cached, testDuration: 0) - let index = await counter.increment() - await reporter.report( - .mutantFinished(descriptor: mutant, status: cached, index: index, total: counter.total)) + let index = await deps.counter.increment() + await deps.reporter.report( + .mutantFinished(descriptor: mutant, status: cached, index: index, total: deps.counter.total)) return result } @@ -63,7 +60,7 @@ struct TestExecutionStage: Sendable { } await context.pool.release(slot) - let outcome = try await ResultParser(launcher: launcher).parse( + let outcome = try await ResultParser(launcher: deps.launcher).parse( exitCode: launched.exitCode, output: launched.output, xcresultPath: launched.xcresultPath, @@ -73,9 +70,9 @@ struct TestExecutionStage: Sendable { 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)) + await deps.cacheStore.store(status: status, for: key) + let index = await deps.counter.increment() + await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) return result } @@ -97,9 +94,9 @@ struct TestExecutionStage: Sendable { let outcome = SPMResultParser().parse(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)) + await deps.cacheStore.store(status: status, for: key) + let index = await deps.counter.increment() + await deps.reporter.report(.mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) return result } @@ -114,7 +111,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/swift"), arguments: arguments, environment: nil, @@ -160,7 +157,7 @@ struct TestExecutionStage: Sendable { } let start = Date() - let captured = try await launcher.launchCapturing( + let captured = try await deps.launcher.launchCapturing( executableURL: URL(fileURLWithPath: "/usr/bin/xcodebuild"), arguments: arguments, environment: nil, From 609e4783efad53101bdc51a9efaf4868a5f38f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 20:05:34 -0300 Subject: [PATCH 6/8] test: update TestExecutionStage and IncompatibleMutantExecutor tests for ExecutionDeps --- .../IncompatibleMutantExecutorTests.swift | 96 ++++++++++-------- .../Execution/TestExecutionStageTests.swift | 99 +++++++++++-------- 2 files changed, 114 insertions(+), 81 deletions(-) diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift index 7775902..3ec79b8 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift @@ -81,11 +81,13 @@ struct IncompatibleMutantExecutorTests { let mutant = makeMutant(id: "m0", content: "let x = 1") let firstExecutor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 1), - sandboxFactory: SandboxFactory(), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) _ = try await firstExecutor.execute( [mutant], @@ -103,11 +105,13 @@ struct IncompatibleMutantExecutorTests { filter: .init(excludePatterns: [], operators: []) ) let secondExecutor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 1), - sandboxFactory: SandboxFactory(), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) let results = try await secondExecutor.execute( [mutant], @@ -135,11 +139,13 @@ struct IncompatibleMutantExecutorTests { filter: .init(excludePatterns: [], operators: []) ) let executor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 1), - sandboxFactory: SandboxFactory(), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) let results = try await executor.execute( @@ -163,11 +169,13 @@ struct IncompatibleMutantExecutorTests { let mutant = makeMutant(id: "m0", content: "let x = 1") let firstExecutor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 1), - sandboxFactory: SandboxFactory(), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) _ = try await firstExecutor.execute( [mutant], @@ -177,11 +185,13 @@ struct IncompatibleMutantExecutorTests { ) let secondExecutor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 1), - sandboxFactory: SandboxFactory(), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) let results = try await secondExecutor.execute( [mutant], @@ -199,11 +209,13 @@ struct IncompatibleMutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: 0, throwsOnCapture: true), - sandboxFactory: SandboxFactory(), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 0, throwsOnCapture: true), + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) let pool = makePool() try await pool.setUp() @@ -263,11 +275,13 @@ struct IncompatibleMutantExecutorTests { output: String = "" ) -> IncompatibleMutantExecutor { IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: exitCode, output: output), - sandboxFactory: SandboxFactory(), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: exitCode, output: output), + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ), + sandboxFactory: SandboxFactory() ) } @@ -282,11 +296,13 @@ struct IncompatibleMutantExecutorTests { private func makeExecutor(in dir: URL, exitCode: Int32) -> IncompatibleMutantExecutor { IncompatibleMutantExecutor( - launcher: MockProcessLauncher(exitCode: exitCode), - sandboxFactory: SandboxFactory(), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 3) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: exitCode), + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 3) + ), + sandboxFactory: SandboxFactory() ) } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift index 13103a2..9c2896c 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift @@ -41,18 +41,22 @@ struct TestExecutionStageTests { ) let successStage = TestExecutionStage( - launcher: MockProcessLauncher(exitCode: 0), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 0), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) _ = try await successStage.execute(mutants: [makeMutant(id: "m0")], in: context) let failStage = TestExecutionStage( - launcher: MockProcessLauncher(exitCode: 1), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 1), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) let results = try await failStage.execute(mutants: [makeMutant(id: "m0")], in: context) @@ -101,21 +105,25 @@ struct TestExecutionStageTests { ) let survivedStage = TestExecutionStage( - launcher: MockProcessLauncher(exitCode: 0), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher(exitCode: 0), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) _ = try await survivedStage.execute(mutants: [makeMutant(id: "m0")], in: context) let killedStage = TestExecutionStage( - launcher: MockProcessLauncher( - exitCode: 1, - output: "Test Case '-[S t]' failed (0.001 seconds)." - ), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: MockProcessLauncher( + exitCode: 1, + output: "Test Case '-[S t]' failed (0.001 seconds)." + ), + cacheStore: cacheStore, + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) let results = try await killedStage.execute(mutants: [makeMutant(id: "m0")], in: context) @@ -142,10 +150,12 @@ struct TestExecutionStageTests { filter: .init(excludePatterns: [], operators: []) ) let stage = TestExecutionStage( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), @@ -186,10 +196,12 @@ struct TestExecutionStageTests { try await pool.setUp() let stage = TestExecutionStage( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), @@ -243,17 +255,18 @@ struct TestExecutionStageTests { try await pool.setUp() let config = RunnerConfiguration( projectPath: "/tmp", - build: .init( - projectType: .spm, testTarget: "MyLibTests", + build: .init(projectType: .spm, testTarget: "MyLibTests", timeout: 60, concurrency: 1, noCache: false), reporting: .init(quiet: true), filter: .init(excludePatterns: [], operators: []) ) let stage = TestExecutionStage( - launcher: MockProcessLauncher(exitCode: 0), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + 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), @@ -280,10 +293,12 @@ struct TestExecutionStageTests { destination: "platform=macOS", launcher: launcher ) let stage = TestExecutionStage( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 3) + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 3) + ) ) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), @@ -306,10 +321,12 @@ struct TestExecutionStageTests { destination: "platform=macOS", launcher: launcher ) let stage = TestExecutionStage( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + deps: ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), + reporter: MockProgressReporter(), + counter: MutationCounter(total: 1) + ) ) let config = RunnerConfiguration( projectPath: "/tmp", From 19d66024c957cc7e28aec3ff763871b5a5772362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 20:05:40 -0300 Subject: [PATCH 7/8] refactor: decompose SchematizationStage into MutantIndexingStage and IncompatibleRewritingStage --- .../Discovery/DiscoveryPipeline.swift | 16 +++- .../Pipeline/IncompatibleRewritingStage.swift | 39 +++++++++ .../Pipeline/IndexedMutationPoint.swift | 5 ++ .../Pipeline/MutantIndexingStage.swift | 27 ++++++ .../Pipeline/SchematizationResult.swift | 5 -- .../Pipeline/SchematizationStage.swift | 82 ++++--------------- 6 files changed, 98 insertions(+), 76 deletions(-) create mode 100644 Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift create mode 100644 Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift create mode 100644 Sources/SwiftMutationTesting/Discovery/Pipeline/MutantIndexingStage.swift delete mode 100644 Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationResult.swift diff --git a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift index 5e9c5da..2f26b5d 100644 --- a/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift +++ b/Sources/SwiftMutationTesting/Discovery/DiscoveryPipeline.swift @@ -16,7 +16,11 @@ struct DiscoveryPipeline: Sendable { let parsedSources = await ParsingStage().run(sourceFiles: sourceFiles) let ops = resolvedOperators(from: input.operators) let mutationPoints = await MutantDiscoveryStage(operators: ops).run(sources: parsedSources) - let result = SchematizationStage().run(mutationPoints: mutationPoints, sources: parsedSources) + let indexed = MutantIndexingStage().run(mutationPoints: mutationPoints, sources: parsedSources) + let (schematizedFiles, schematizableDescriptors) = SchematizationStage().run(indexed: indexed, sources: parsedSources) + let incompatibleDescriptors = IncompatibleRewritingStage().run(indexed: indexed, sources: parsedSources) + let allDescriptors = (schematizableDescriptors + incompatibleDescriptors) + .sorted { indexFromID($0.id) < indexFromID($1.id) } return RunnerInput( projectPath: input.projectPath, @@ -24,12 +28,16 @@ struct DiscoveryPipeline: Sendable { timeout: input.timeout, concurrency: input.concurrency, noCache: input.noCache, - schematizedFiles: result.schematizedFiles, - supportFileContent: result.supportFileContent, - mutants: result.descriptors + schematizedFiles: schematizedFiles, + supportFileContent: SchematizationStage.supportFileContent, + mutants: allDescriptors ) } + private func indexFromID(_ id: String) -> Int { + Int(id.replacingOccurrences(of: "swift-mutation-testing_", with: "")) ?? 0 + } + private func resolvedOperators(from identifiers: [String]) -> [any MutationOperator] { if identifiers.isEmpty { return Self.registry.map(\.operator) diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift new file mode 100644 index 0000000..af43d39 --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/IncompatibleRewritingStage.swift @@ -0,0 +1,39 @@ +struct IncompatibleRewritingStage: Sendable { + func run(indexed: [IndexedMutationPoint], sources: [ParsedSource]) -> [MutantDescriptor] { + let incompatible = indexed.filter { !$0.isSchematizable } + let sourceByPath = Dictionary(uniqueKeysWithValues: sources.map { ($0.file.path, $0) }) + let rewriter = MutationRewriter() + + return incompatible.compactMap { entry in + guard let source = sourceByPath[entry.mutation.filePath] else { return nil } + let mutatedContent = rewriter.rewrite(source: source.file.content, applying: entry.mutation) + return makeDescriptor(from: entry.mutation, id: mutantID(entry.index), isSchematizable: false, mutatedContent: mutatedContent) + } + } + + private func mutantID(_ index: Int) -> String { + "swift-mutation-testing_\(index)" + } + + private func makeDescriptor( + from mutation: MutationPoint, + id: String, + isSchematizable: Bool, + mutatedContent: String? + ) -> MutantDescriptor { + MutantDescriptor( + id: id, + filePath: mutation.filePath, + line: mutation.line, + column: mutation.column, + utf8Offset: mutation.utf8Offset, + originalText: mutation.originalText, + mutatedText: mutation.mutatedText, + operatorIdentifier: mutation.operatorIdentifier, + replacementKind: mutation.replacement, + description: mutation.description, + isSchematizable: isSchematizable, + mutatedSourceContent: mutatedContent + ) + } +} diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift new file mode 100644 index 0000000..eaf37ca --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/IndexedMutationPoint.swift @@ -0,0 +1,5 @@ +struct IndexedMutationPoint: Sendable { + let index: Int + let mutation: MutationPoint + let isSchematizable: Bool +} diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantIndexingStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantIndexingStage.swift new file mode 100644 index 0000000..02c6f70 --- /dev/null +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/MutantIndexingStage.swift @@ -0,0 +1,27 @@ +import SwiftSyntax + +struct MutantIndexingStage: Sendable { + func run(mutationPoints: [MutationPoint], sources: [ParsedSource]) -> [IndexedMutationPoint] { + let sorted = mutationPoints.sorted { + if $0.filePath != $1.filePath { return $0.filePath < $1.filePath } + return $0.utf8Offset < $1.utf8Offset + } + + let visitors = buildVisitors(for: sources) + + return sorted.enumerated().map { index, mutation in + let schematizable = visitors[mutation.filePath]?.isSchematizable(utf8Offset: mutation.utf8Offset) ?? false + return IndexedMutationPoint(index: index, mutation: mutation, isSchematizable: schematizable) + } + } + + private func buildVisitors(for sources: [ParsedSource]) -> [String: TypeScopeVisitor] { + var visitors: [String: TypeScopeVisitor] = [:] + for source in sources { + let visitor = TypeScopeVisitor() + visitor.walk(source.syntax) + visitors[source.file.path] = visitor + } + return visitors + } +} diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationResult.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationResult.swift deleted file mode 100644 index 7d9ae1d..0000000 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationResult.swift +++ /dev/null @@ -1,5 +0,0 @@ -struct SchematizationResult: Sendable { - let schematizedFiles: [SchematizedFile] - let descriptors: [MutantDescriptor] - let supportFileContent: String -} diff --git a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift b/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift index 9058738..42d48f2 100644 --- a/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift +++ b/Sources/SwiftMutationTesting/Discovery/Pipeline/SchematizationStage.swift @@ -1,7 +1,7 @@ import Foundation struct SchematizationStage: Sendable { - private static let supportFileContent = """ + static let supportFileContent = """ import Foundation var __swiftMutationTestingID: String { @@ -9,89 +9,37 @@ struct SchematizationStage: Sendable { } """ - func run(mutationPoints: [MutationPoint], sources: [ParsedSource]) -> SchematizationResult { - let sorted = mutationPoints.sorted { - if $0.filePath != $1.filePath { return $0.filePath < $1.filePath } - return $0.utf8Offset < $1.utf8Offset - } - + func run(indexed: [IndexedMutationPoint], sources: [ParsedSource]) -> ([SchematizedFile], [MutantDescriptor]) { + let schematizable = indexed.filter { $0.isSchematizable } let sourceByPath = Dictionary(uniqueKeysWithValues: sources.map { ($0.file.path, $0) }) - let visitors = buildVisitors(for: sources) - let indexed = assignIndices(sorted: sorted, visitors: visitors) - let byFile = Dictionary(grouping: indexed) { $0.mutation.filePath } + let byFile = Dictionary(grouping: schematizable) { $0.mutation.filePath } let generator = SchemataGenerator() - let rewriter = MutationRewriter() - var descriptors: [MutantDescriptor] = [] var schematizedFiles: [SchematizedFile] = [] + var descriptors: [MutantDescriptor] = [] for (filePath, entries) in byFile { guard let source = sourceByPath[filePath] else { continue } - let schematizable = entries.filter { $0.isSchematizable } - let incompatible = entries.filter { !$0.isSchematizable } - - if !schematizable.isEmpty { - let mutations = schematizable.map { (index: $0.index, point: $0.mutation) } - let content = generator.generate(source: source, mutations: mutations) - schematizedFiles.append(SchematizedFile(originalPath: filePath, schematizedContent: content)) + let mutations = entries.map { (index: $0.index, point: $0.mutation) } + let content = generator.generate(source: source, mutations: mutations) + schematizedFiles.append(SchematizedFile(originalPath: filePath, schematizedContent: content)) - for entry in schematizable { - descriptors.append( - descriptor( - from: entry.mutation, id: mutantID(entry.index), - isSchematizable: true, mutatedContent: nil - )) - } + for entry in entries { + descriptors.append(makeDescriptor( + from: entry.mutation, id: mutantID(entry.index), + isSchematizable: true, mutatedContent: nil + )) } - - for entry in incompatible { - let mutatedContent = rewriter.rewrite(source: source.file.content, applying: entry.mutation) - descriptors.append( - descriptor( - from: entry.mutation, id: mutantID(entry.index), - isSchematizable: false, mutatedContent: mutatedContent - )) - } - } - - return SchematizationResult( - schematizedFiles: schematizedFiles, - descriptors: descriptors.sorted { indexFromID($0.id) < indexFromID($1.id) }, - supportFileContent: Self.supportFileContent - ) - } - - private func buildVisitors(for sources: [ParsedSource]) -> [String: TypeScopeVisitor] { - var result: [String: TypeScopeVisitor] = [:] - - for source in sources { - let visitor = TypeScopeVisitor() - visitor.walk(source.syntax) - result[source.file.path] = visitor } - return result - } - - private func assignIndices( - sorted: [MutationPoint], - visitors: [String: TypeScopeVisitor] - ) -> [(index: Int, mutation: MutationPoint, isSchematizable: Bool)] { - sorted.enumerated().map { index, mutation in - let schematizable = visitors[mutation.filePath]?.isSchematizable(utf8Offset: mutation.utf8Offset) ?? false - return (index: index, mutation: mutation, isSchematizable: schematizable) - } + return (schematizedFiles, descriptors) } private func mutantID(_ index: Int) -> String { "swift-mutation-testing_\(index)" } - private func indexFromID(_ id: String) -> Int { - Int(id.replacingOccurrences(of: "swift-mutation-testing_", with: "")) ?? 0 - } - - private func descriptor( + private func makeDescriptor( from mutation: MutationPoint, id: String, isSchematizable: Bool, From 4cb0955a62a89dcdefe843e5fc33f79bd4c0728c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89rico=20Perez=20Neto?= Date: Tue, 31 Mar 2026 20:05:46 -0300 Subject: [PATCH 8/8] test: add MutantIndexingStage and IncompatibleRewritingStage tests, update SchematizationStageTests --- .../IncompatibleRewritingStageTests.swift | 55 +++++++++ .../Pipeline/MutantIndexingStageTests.swift | 54 +++++++++ .../Pipeline/SchematizationStageTests.swift | 106 ++++++------------ 3 files changed, 141 insertions(+), 74 deletions(-) create mode 100644 Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift create mode 100644 Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift new file mode 100644 index 0000000..7ade64f --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift @@ -0,0 +1,55 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("IncompatibleRewritingStage") +struct IncompatibleRewritingStageTests { + private let stage = IncompatibleRewritingStage() + + @Test("Given mutation at file scope, when run, then descriptor is marked as incompatible") + func fileScopeMutationIsIncompatible() { + let source = makeParsedSource("let x = true", path: "a.swift") + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let descriptors = stage.run(indexed: indexed, sources: [source]) + #expect(!descriptors.isEmpty) + #expect(descriptors.allSatisfy { !$0.isSchematizable }) + #expect(descriptors.allSatisfy { $0.mutatedSourceContent != nil }) + } + + @Test("Given incompatible mutation, when run, then mutatedSourceContent contains the mutation applied") + func incompatibleMutationContentHasMutationApplied() throws { + let source = makeParsedSource("let x = true", path: "a.swift") + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let descriptors = stage.run(indexed: indexed, sources: [source]) + let content = try #require(descriptors.first?.mutatedSourceContent) + #expect(content.contains("false")) + } + + @Test("Given schematizable mutation, when run, then returns no descriptors") + func schematizableMutationProducesNoDescriptors() { + let source = makeParsedSource("func f() { let x = true }", path: "a.swift") + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let descriptors = stage.run(indexed: indexed, sources: [source]) + #expect(descriptors.isEmpty) + } + + @Test("Given mutation point for unknown file path, when run, then skips it") + func mutationForUnknownFilePathIsSkipped() { + let source = makeParsedSource("let x = true", path: "a.swift") + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let descriptors = stage.run(indexed: indexed, sources: []) + #expect(descriptors.isEmpty) + } + + @Test("Given empty indexed list, when run, then returns empty") + func emptyIndexedListReturnsEmpty() { + let source = makeParsedSource("let x = true", path: "a.swift") + let descriptors = stage.run(indexed: [], sources: [source]) + #expect(descriptors.isEmpty) + } + + private func makeIndexed(source: ParsedSource, operators: [any MutationOperator]) -> [IndexedMutationPoint] { + let points = operators.flatMap { $0.mutations(in: source) } + return MutantIndexingStage().run(mutationPoints: points, sources: [source]) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift new file mode 100644 index 0000000..c25191d --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/MutantIndexingStageTests.swift @@ -0,0 +1,54 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("MutantIndexingStage") +struct MutantIndexingStageTests { + private let stage = MutantIndexingStage() + + @Test("Given empty mutation points, when run, then returns empty") + func emptyMutationPointsReturnsEmpty() { + let source = makeParsedSource("func f() { let x = 1 }", path: "a.swift") + let result = stage.run(mutationPoints: [], sources: [source]) + #expect(result.isEmpty) + } + + @Test("Given mutation in function body, when run, then isSchematizable is true") + func mutationInFunctionBodyIsSchematizable() { + let source = makeParsedSource("func f() { let x = true }", path: "a.swift") + let points = BooleanLiteralReplacement().mutations(in: source) + let result = stage.run(mutationPoints: points, sources: [source]) + #expect(result.allSatisfy { $0.isSchematizable }) + } + + @Test("Given mutation at file scope, when run, then isSchematizable is false") + func mutationAtFileScopeIsNotSchematizable() { + let source = makeParsedSource("let x = true", path: "a.swift") + let points = BooleanLiteralReplacement().mutations(in: source) + let result = stage.run(mutationPoints: points, sources: [source]) + #expect(result.allSatisfy { !$0.isSchematizable }) + } + + @Test("Given mutation points, when run, then indices are zero-based and sequential") + func indicesAreZeroBasedAndSequential() { + let source = makeParsedSource("func f() { let x = true }", path: "a.swift") + let points = BooleanLiteralReplacement().mutations(in: source) + let result = stage.run(mutationPoints: points, sources: [source]) + for (pos, entry) in result.enumerated() { + #expect(entry.index == pos) + } + } + + @Test("Given mutations across two files, when run, then sorted by file path then offset") + func sortsByFilePathThenOffset() { + let sourceA = makeParsedSource("func f() { let x = true }", path: "b.swift") + let sourceB = makeParsedSource("func g() { let y = false }", path: "a.swift") + let pointsA = BooleanLiteralReplacement().mutations(in: sourceA) + let pointsB = BooleanLiteralReplacement().mutations(in: sourceB) + let result = stage.run(mutationPoints: pointsA + pointsB, sources: [sourceA, sourceB]) + + #expect(result.count == 2) + #expect(result[0].mutation.filePath == "a.swift") + #expect(result[1].mutation.filePath == "b.swift") + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift index 750f432..9133cd0 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift @@ -6,99 +6,57 @@ import Testing struct SchematizationStageTests { private let stage = SchematizationStage() - @Test("Given mutation in function body, when run, then produces one schematized file") + @Test("Given schematizable mutation, when run, then produces one schematized file") func schematizableMutationProducesSchematizedFile() { let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.schematizedFiles.count == 1) - #expect(result.schematizedFiles[0].originalPath == "a.swift") + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let (files, _) = stage.run(indexed: indexed, sources: [source]) + #expect(files.count == 1) + #expect(files[0].originalPath == "a.swift") } - @Test("Given schematizable mutation, when run, then descriptor has correct schematizability flags") + @Test("Given schematizable mutation, when run, then descriptor has correct flags") func schematizableMutationDescriptorHasCorrectFlags() { let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.descriptors[0].isSchematizable) - #expect(result.descriptors[0].mutatedSourceContent == nil) - } - - @Test("Given mutation at file scope, when run, then descriptor is marked as incompatible") - func fileScopeMutationIsIncompatible() { - let source = makeParsedSource("let x = true", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(!result.descriptors[0].isSchematizable) - #expect(result.descriptors[0].mutatedSourceContent != nil) - #expect(result.schematizedFiles.isEmpty) - } - - @Test("Given incompatible mutation, when run, then mutatedSourceContent has mutation applied") - func incompatibleMutationContentHasMutationApplied() throws { - let source = makeParsedSource("let x = true", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - let content = try #require(result.descriptors[0].mutatedSourceContent) - #expect(content.contains("false")) - } - - @Test("Given multiple mutations across files, when run, then descriptors are sorted by global index") - func descriptorsAreSortedByIndex() { - let sourceA = makeParsedSource("func f() { let x = true }", path: "a.swift") - let sourceB = makeParsedSource("func g() { let y = false }", path: "b.swift") - let mutationsA = BooleanLiteralReplacement().mutations(in: sourceA) - let mutationsB = BooleanLiteralReplacement().mutations(in: sourceB) - let result = stage.run( - mutationPoints: mutationsA + mutationsB, - sources: [sourceA, sourceB] - ) - #expect(result.descriptors.count == 2) - let ids = result.descriptors.map { $0.id } - #expect(ids[0] == "swift-mutation-testing_0") - #expect(ids[1] == "swift-mutation-testing_1") - } - - @Test("Given any input, when run, then supportFileContent declares __swiftMutationTestingID") - func supportFileContentDeclaresIDVariable() { - let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.supportFileContent.contains("__swiftMutationTestingID")) - #expect(result.supportFileContent.contains("__SWIFT_MUTATION_TESTING_ACTIVE")) + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let (_, descriptors) = stage.run(indexed: indexed, sources: [source]) + #expect(descriptors[0].isSchematizable) + #expect(descriptors[0].mutatedSourceContent == nil) } @Test("Given schematized content, when checked, then does not contain var __swiftMutationTestingID declaration") func schematizedContentDoesNotDeclareIDVariable() { let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - let schematizedContent = result.schematizedFiles[0].schematizedContent - #expect(!schematizedContent.contains("var __swiftMutationTestingID")) + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let (files, _) = stage.run(indexed: indexed, sources: [source]) + #expect(!files[0].schematizedContent.contains("var __swiftMutationTestingID")) } - @Test("Given mutation, when run, then descriptor ID uses correct format") - func descriptorIDUsesCorrectFormat() { - let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: [source]) - #expect(result.descriptors[0].id == "swift-mutation-testing_0") + @Test("Given any input, when checked, then supportFileContent declares __swiftMutationTestingID") + func supportFileContentDeclaresIDVariable() { + #expect(SchematizationStage.supportFileContent.contains("__swiftMutationTestingID")) + #expect(SchematizationStage.supportFileContent.contains("__SWIFT_MUTATION_TESTING_ACTIVE")) } - @Test("Given mutation point for unknown file path, when run, then skips it and returns empty result") + @Test("Given mutation point for unknown file path, when run, then skips it and returns empty") func mutationForUnknownFilePathIsSkipped() { let source = makeParsedSource("func f() { let x = true }", path: "a.swift") - let mutations = BooleanLiteralReplacement().mutations(in: source) - let result = stage.run(mutationPoints: mutations, sources: []) - #expect(result.schematizedFiles.isEmpty) - #expect(result.descriptors.isEmpty) + let indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let (files, descriptors) = stage.run(indexed: indexed, sources: []) + #expect(files.isEmpty) + #expect(descriptors.isEmpty) } - @Test("Given empty mutation points, when run, then returns empty result") - func emptyMutationPointsReturnsEmptyResult() { + @Test("Given empty indexed list, when run, then returns empty") + func emptyIndexedListReturnsEmpty() { let source = makeParsedSource("func f() { let x = 1 }", path: "a.swift") - let result = stage.run(mutationPoints: [], sources: [source]) - #expect(result.schematizedFiles.isEmpty) - #expect(result.descriptors.isEmpty) + let (files, descriptors) = stage.run(indexed: [], sources: [source]) + #expect(files.isEmpty) + #expect(descriptors.isEmpty) + } + + private func makeIndexed(source: ParsedSource, operators: [any MutationOperator]) -> [IndexedMutationPoint] { + let points = operators.flatMap { $0.mutations(in: source) } + return MutantIndexingStage().run(mutationPoints: points, sources: [source]) } }