diff --git a/README.md b/README.md index b1e7f6c..9fc8674 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/ericodx/swift-mutation-testing/main-analysis.yml?branch=main&style=flat-square&logo=github&logoColor=white&label=CI&color=4CAF50)](https://github.com/ericodx/swift-mutation-testing/actions) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-mutation-testing&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-mutation-testing) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=deploy-on-friday-swift-mutation-testing&metric=coverage)](https://sonarcloud.io/summary/new_code?id=deploy-on-friday-swift-mutation-testing) +![mutation score](https://img.shields.io/badge/mutation%20score-85%25-lightgray?logo=jest&logoColor=white) **Measure and improve test effectiveness in Swift codebases using mutation testing.** diff --git a/Sources/SwiftMutationTesting/Cache/CacheStore.swift b/Sources/SwiftMutationTesting/Cache/CacheStore.swift index 67d9a2a..1c7f3dc 100644 --- a/Sources/SwiftMutationTesting/Cache/CacheStore.swift +++ b/Sources/SwiftMutationTesting/Cache/CacheStore.swift @@ -5,35 +5,65 @@ actor CacheStore { init(storePath: String) { self.storePath = storePath self.entries = [:] + self.killerTestFiles = [:] } static let directoryName = ".swift-mutation-testing-cache" private let storePath: String private var entries: [MutantCacheKey: ExecutionStatus] + private var killerTestFiles: [MutantCacheKey: String] + + private var metadataPath: String { + let url = URL(fileURLWithPath: storePath) + return url.deletingLastPathComponent().appendingPathComponent("metadata.json").path + } private struct CacheEntry: Codable { let key: MutantCacheKey let status: ExecutionStatus + let killerTestFile: String? + } + + struct CacheMetadata: Codable, Sendable { + let testFileHashes: [String: String] } func result(for key: MutantCacheKey) -> ExecutionStatus? { entries[key] } - func store(status: ExecutionStatus, for key: MutantCacheKey) { + func killerTestFile(for key: MutantCacheKey) -> String? { + killerTestFiles[key] + } + + func store(status: ExecutionStatus, for key: MutantCacheKey, killerTestFile: String? = nil) { entries[key] = status + if let killerTestFile { + killerTestFiles[key] = killerTestFile + } } func load() throws { guard FileManager.default.fileExists(atPath: storePath) else { return } let data = try Data(contentsOf: URL(fileURLWithPath: storePath)) let loaded = try JSONDecoder().decode([CacheEntry].self, from: data) - entries = Dictionary(uniqueKeysWithValues: loaded.map { ($0.key, $0.status) }) + entries = [:] + for entry in loaded { + entries[entry.key] = entry.status + } + killerTestFiles = [:] + for entry in loaded { + if let file = entry.killerTestFile { + killerTestFiles[entry.key] = file + } + } } func persist() throws { - let cacheEntries = entries.map { CacheEntry(key: $0.key, status: $0.value) } + let cacheEntries = entries.map { + CacheEntry(key: $0.key, status: $0.value, killerTestFile: killerTestFiles[$0.key]) + } let data = try JSONEncoder().encode(cacheEntries) let url = URL(fileURLWithPath: storePath) try FileManager.default.createDirectory( @@ -42,4 +72,74 @@ actor CacheStore { ) try data.write(to: url, options: .atomic) } + + func loadMetadata() throws -> CacheMetadata? { + let url = URL(fileURLWithPath: metadataPath) + guard FileManager.default.fileExists(atPath: metadataPath) else { return nil } + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(CacheMetadata.self, from: data) + } + + func persistMetadata(_ metadata: CacheMetadata) throws { + let data = try JSONEncoder().encode(metadata) + let url = URL(fileURLWithPath: metadataPath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } + + func invalidate(diff: TestFileDiff) { + guard diff.hasChanges else { return } + + let changedFiles = diff.modified.union(diff.removed) + + for (key, status) in entries { + switch status { + case .unviable, .killedByCrash: + continue + + case .killed: + guard let file = killerTestFiles[key] else { + entries.removeValue(forKey: key) + killerTestFiles.removeValue(forKey: key) + continue + } + + if changedFiles.contains(file) { + entries.removeValue(forKey: key) + killerTestFiles.removeValue(forKey: key) + } + + case .survived, .noCoverage, .timeout: + entries.removeValue(forKey: key) + killerTestFiles.removeValue(forKey: key) + } + } + } + + func changedTestFiles(current: [String: String]) throws -> TestFileDiff { + guard let stored = try loadMetadata() else { + return TestFileDiff( + added: Set(current.keys), + modified: [], + removed: [] + ) + } + + let storedKeys = Set(stored.testFileHashes.keys) + let currentKeys = Set(current.keys) + + let added = currentKeys.subtracting(storedKeys) + let removed = storedKeys.subtracting(currentKeys) + + var modified: Set = [] + for key in storedKeys.intersection(currentKeys) where stored.testFileHashes[key] != current[key] { + modified.insert(key) + } + + return TestFileDiff(added: added, modified: modified, removed: removed) + } + } diff --git a/Sources/SwiftMutationTesting/Cache/KillerTestFileResolver.swift b/Sources/SwiftMutationTesting/Cache/KillerTestFileResolver.swift new file mode 100644 index 0000000..ee6cf2c --- /dev/null +++ b/Sources/SwiftMutationTesting/Cache/KillerTestFileResolver.swift @@ -0,0 +1,51 @@ +import Foundation + +struct KillerTestFileResolver: Sendable { + let testFilePaths: [String] + + func resolve(testName: String) -> String? { + if let path = resolveXCTestClassName(testName) { + return path + } + + if let path = resolveSwiftTestingFunctionName(testName) { + return path + } + + return nil + } + + private func resolveXCTestClassName(_ testName: String) -> String? { + let className: String + let components = testName.split(separator: ".") + guard components.count >= 2 else { return nil } + + if components.count == 3 { + className = String(components[1]) + } else { + className = String(components[0]) + } + + let fileName = "\(className).swift" + return testFilePaths.first { $0.hasSuffix("/\(fileName)") || $0 == fileName } + } + + private func resolveSwiftTestingFunctionName(_ testName: String) -> String? { + let components = testName.split(separator: "/") + guard let lastComponent = components.last else { return nil } + + let functionName = String(lastComponent) + + for path in testFilePaths { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + + if content.contains("func \(functionName)") + || content.contains("@Test") && content.contains(functionName) + { + return path + } + } + + return nil + } +} diff --git a/Sources/SwiftMutationTesting/Cache/MutantCacheKey.swift b/Sources/SwiftMutationTesting/Cache/MutantCacheKey.swift index f5db7f8..57eb597 100644 --- a/Sources/SwiftMutationTesting/Cache/MutantCacheKey.swift +++ b/Sources/SwiftMutationTesting/Cache/MutantCacheKey.swift @@ -3,7 +3,6 @@ import Foundation struct MutantCacheKey: Hashable, Sendable, Codable { let fileContentHash: String - let testFilesHash: String let operatorIdentifier: String let utf8Offset: Int let originalText: String @@ -14,11 +13,10 @@ struct MutantCacheKey: Hashable, Sendable, Codable { return digest.map { String(format: "%02x", $0) }.joined() } - static func make(for mutant: MutantDescriptor, testFilesHash: String) -> MutantCacheKey { + static func make(for mutant: MutantDescriptor) -> MutantCacheKey { let content = mutant.mutatedSourceContent ?? mutant.filePath return MutantCacheKey( fileContentHash: hash(of: content), - testFilesHash: testFilesHash, operatorIdentifier: mutant.operatorIdentifier, utf8Offset: mutant.utf8Offset, originalText: mutant.originalText, diff --git a/Sources/SwiftMutationTesting/Cache/TestFileDiff.swift b/Sources/SwiftMutationTesting/Cache/TestFileDiff.swift new file mode 100644 index 0000000..75b7d80 --- /dev/null +++ b/Sources/SwiftMutationTesting/Cache/TestFileDiff.swift @@ -0,0 +1,9 @@ +struct TestFileDiff: Sendable { + let added: Set + let modified: Set + let removed: Set + + var hasChanges: Bool { + !added.isEmpty || !modified.isEmpty || !removed.isEmpty + } +} diff --git a/Sources/SwiftMutationTesting/Execution/ExecutionDeps.swift b/Sources/SwiftMutationTesting/Execution/ExecutionDeps.swift index 09df423..da562bf 100644 --- a/Sources/SwiftMutationTesting/Execution/ExecutionDeps.swift +++ b/Sources/SwiftMutationTesting/Execution/ExecutionDeps.swift @@ -3,4 +3,5 @@ struct ExecutionDeps: Sendable { let cacheStore: CacheStore let reporter: any ProgressReporter let counter: MutationCounter + let killerTestFileResolver: KillerTestFileResolver } diff --git a/Sources/SwiftMutationTesting/Execution/ExecutionResult.swift b/Sources/SwiftMutationTesting/Execution/ExecutionResult.swift index d56b38d..23f739c 100644 --- a/Sources/SwiftMutationTesting/Execution/ExecutionResult.swift +++ b/Sources/SwiftMutationTesting/Execution/ExecutionResult.swift @@ -1,5 +1,14 @@ struct ExecutionResult: Sendable, Codable { + + init(descriptor: MutantDescriptor, status: ExecutionStatus, testDuration: Double, killerTestFile: String? = nil) { + self.descriptor = descriptor + self.status = status + self.testDuration = testDuration + self.killerTestFile = killerTestFile + } + let descriptor: MutantDescriptor let status: ExecutionStatus let testDuration: Double + let killerTestFile: String? } diff --git a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift index c8604da..aa25659 100644 --- a/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/FallbackExecutor.swift @@ -2,11 +2,11 @@ struct FallbackExecutor: Sendable { let deps: ExecutionDeps let configuration: RunnerConfiguration - func execute(input: RunnerInput, pool: SimulatorPool, testFilesHash: String) async throws -> [ExecutionResult] { + func execute(input: RunnerInput, pool: SimulatorPool) async throws -> [ExecutionResult] { var results: [ExecutionResult] = [] for file in input.schematizedFiles { - results += try await processFile(file: file, input: input, pool: pool, testFilesHash: testFilesHash) + results += try await processFile(file: file, input: input, pool: pool) } return results @@ -15,14 +15,13 @@ struct FallbackExecutor: Sendable { private func processFile( file: SchematizedFile, input: RunnerInput, - pool: SimulatorPool, - testFilesHash: String + pool: SimulatorPool ) 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) { + if let cached = await cachedResults(for: fileMutants) { return cached } @@ -48,7 +47,7 @@ struct FallbackExecutor: Sendable { } catch { await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) try? sandbox.cleanup() - return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + return await markUnviable(mutants: fileMutants) } case .spm: @@ -61,13 +60,13 @@ struct FallbackExecutor: Sendable { } catch { await deps.reporter.report(.fallbackBuildFinished(filePath: file.originalPath, success: false)) try? sandbox.cleanup() - return await markUnviable(mutants: fileMutants, testFilesHash: testFilesHash) + return await markUnviable(mutants: fileMutants) } } let context = TestExecutionContext( artifact: artifact, sandbox: sandbox, pool: pool, - configuration: configuration, testFilesHash: testFilesHash + configuration: configuration ) let stageResults = try await TestExecutionStage(deps: deps).execute(mutants: fileMutants, in: context) @@ -75,14 +74,18 @@ struct FallbackExecutor: Sendable { return stageResults } - private func cachedResults(for mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult]? { + private func cachedResults(for mutants: [MutantDescriptor]) async -> [ExecutionResult]? { guard !configuration.build.noCache else { return nil } var results: [ExecutionResult] = [] for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) guard let status = await deps.cacheStore.result(for: key) else { return nil } - results.append(ExecutionResult(descriptor: mutant, status: status, testDuration: 0)) + let killerTestFile = await deps.cacheStore.killerTestFile(for: key) + results.append( + ExecutionResult( + descriptor: mutant, status: status, testDuration: 0, killerTestFile: killerTestFile + )) } for result in results { @@ -96,10 +99,10 @@ struct FallbackExecutor: Sendable { return results } - private func markUnviable(mutants: [MutantDescriptor], testFilesHash: String) async -> [ExecutionResult] { + private func markUnviable(mutants: [MutantDescriptor]) async -> [ExecutionResult] { var results: [ExecutionResult] = [] for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) await deps.cacheStore.store(status: .unviable, for: key) let index = await deps.counter.increment() await deps.reporter.report( diff --git a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift index 3df3e03..c5b07c3 100644 --- a/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/IncompatibleMutantExecutor.swift @@ -7,21 +7,24 @@ struct IncompatibleMutantExecutor: Sendable { func execute( _ mutants: [MutantDescriptor], configuration: RunnerConfiguration, - pool: SimulatorPool, - testFilesHash: String + pool: SimulatorPool ) async throws -> [ExecutionResult] { var results: [ExecutionResult] = [] var pending: [MutantDescriptor] = [] for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) if !configuration.build.noCache, let cachedStatus = await deps.cacheStore.result(for: key) { + let killerTestFile = await deps.cacheStore.killerTestFile(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)) + results.append( + ExecutionResult( + descriptor: mutant, status: cachedStatus, testDuration: 0, killerTestFile: killerTestFile + )) continue } @@ -30,10 +33,10 @@ struct IncompatibleMutantExecutor: Sendable { if case .spm = configuration.build.projectType { results += try await runSPMShared( - mutants: pending, configuration: configuration, testFilesHash: testFilesHash) + mutants: pending, configuration: configuration) } else if case .xcode(let scheme, _) = configuration.build.projectType { for mutant in pending { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) results.append( try await run( mutant: mutant, key: key, scheme: scheme, @@ -46,15 +49,14 @@ struct IncompatibleMutantExecutor: Sendable { private func runSPMShared( mutants: [MutantDescriptor], - configuration: RunnerConfiguration, - testFilesHash: String + configuration: RunnerConfiguration ) async throws -> [ExecutionResult] { var results: [ExecutionResult] = [] let viable = mutants.filter { $0.mutatedSourceContent != nil } for mutant in mutants where mutant.mutatedSourceContent == nil { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) results.append(await storeAndReport(mutant: mutant, key: key, sandbox: nil)) } @@ -75,7 +77,7 @@ struct IncompatibleMutantExecutor: Sendable { guard initialBuild.exitCode == 0 else { for mutant in viable { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) results.append(await storeAndReport(mutant: mutant, key: key, sandbox: nil)) } try? sandbox.cleanup() @@ -83,7 +85,7 @@ struct IncompatibleMutantExecutor: Sendable { } for mutant in viable { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) let result = try await runInSharedSandbox( mutant: mutant, key: key, @@ -173,13 +175,16 @@ struct IncompatibleMutantExecutor: Sendable { let outcome = SPMResultParser().parse(exitCode: test.exitCode, output: test.output) let status = outcome.asExecutionStatus + let killerTestFile = resolveKillerTestFile(status: status) let index = await deps.counter.increment() await deps.reporter.report( .mutantFinished(descriptor: mutant, status: status, index: index, total: deps.counter.total)) - await deps.cacheStore.store(status: status, for: key) + await deps.cacheStore.store(status: status, for: key, killerTestFile: killerTestFile) - return ExecutionResult(descriptor: mutant, status: status, testDuration: duration) + return ExecutionResult( + descriptor: mutant, status: status, testDuration: duration, killerTestFile: killerTestFile + ) } private func run( @@ -221,11 +226,14 @@ struct IncompatibleMutantExecutor: Sendable { try? sandbox.cleanup() let status = outcome.asExecutionStatus + let killerTestFile = resolveKillerTestFile(status: status) 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: launched.duration) + await deps.cacheStore.store(status: status, for: key, killerTestFile: killerTestFile) + return ExecutionResult( + descriptor: mutant, status: status, testDuration: launched.duration, killerTestFile: killerTestFile + ) } private func launchXcode( @@ -271,6 +279,11 @@ struct IncompatibleMutantExecutor: Sendable { ) } + private func resolveKillerTestFile(status: ExecutionStatus) -> String? { + guard case .killed(let testName) = status else { return nil } + return deps.killerTestFileResolver.resolve(testName: testName) + } + private func storeAndReport( mutant: MutantDescriptor, key: MutantCacheKey, diff --git a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift index cded4d1..c773666 100644 --- a/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift +++ b/Sources/SwiftMutationTesting/Execution/MutantExecutor.swift @@ -17,7 +17,6 @@ struct MutantExecutor: Sendable { let pool: SimulatorPool let artifact: BuildArtifact? let schemaBuildExcluded: [MutantDescriptor] - let testFilesHash: String } private struct RetryContext { @@ -34,22 +33,23 @@ struct MutantExecutor: Sendable { ? SilentProgressReporter() : ConsoleProgressReporter() - let cachePath = URL(fileURLWithPath: configuration.projectPath) - .appendingPathComponent("\(CacheStore.directoryName)/results.json").path - let cacheStore = CacheStore(storePath: cachePath) - try await cacheStore.load() + let (cacheStore, metadata, hasher) = try await prepareCacheStore(input: input) - let testFilesHash = TestFilesHasher().hash(projectPath: input.projectPath) - - if let cached = await allCached(mutants: input.mutants, cacheStore: cacheStore, testFilesHash: testFilesHash) { + if let cached = await allCached(mutants: input.mutants, cacheStore: cacheStore) { await reporter.report(.loadedFromCache(mutantCount: cached.count)) + try await cacheStore.persist() + try await cacheStore.persistMetadata(metadata) return cached } let schematizable = input.mutants.filter { $0.isSchematizable } let incompatible = input.mutants.filter { !$0.isSchematizable } let counter = MutationCounter(total: schematizable.count + incompatible.count) - let deps = ExecutionDeps(launcher: launcher, cacheStore: cacheStore, reporter: reporter, counter: counter) + let resolver = KillerTestFileResolver(testFilePaths: hasher.testFilePaths(projectPath: input.projectPath)) + let deps = ExecutionDeps( + launcher: launcher, cacheStore: cacheStore, reporter: reporter, + counter: counter, killerTestFileResolver: resolver + ) let sandbox = try await SandboxFactory().create( projectPath: input.projectPath, @@ -71,8 +71,7 @@ struct MutantExecutor: Sendable { sandbox: sandbox, pool: pool, artifact: artifact, - schemaBuildExcluded: schemaBuildExcluded, - testFilesHash: testFilesHash + schemaBuildExcluded: schemaBuildExcluded ) ) } catch { @@ -84,10 +83,28 @@ struct MutantExecutor: Sendable { await pool.tearDown() try? sandbox.cleanup() try await cacheStore.persist() + try await cacheStore.persistMetadata(metadata) return results } + private func prepareCacheStore( + input: RunnerInput + ) async throws -> (CacheStore, CacheStore.CacheMetadata, TestFilesHasher) { + let cachePath = URL(fileURLWithPath: configuration.projectPath) + .appendingPathComponent("\(CacheStore.directoryName)/results.json").path + let cacheStore = CacheStore(storePath: cachePath) + try await cacheStore.load() + + let hasher = TestFilesHasher() + let currentTestHashes = hasher.hashPerFile(projectPath: input.projectPath) + let diff = try await cacheStore.changedTestFiles(current: currentTestHashes) + await cacheStore.invalidate(diff: diff) + + let metadata = CacheStore.CacheMetadata(testFileHashes: currentTestHashes) + return (cacheStore, metadata, hasher) + } + private func runAllMutants( _ context: MutantRunContext ) async throws -> [ExecutionResult] { @@ -97,7 +114,6 @@ struct MutantExecutor: Sendable { let pool = context.pool let artifact = context.artifact let schemaBuildExcluded = context.schemaBuildExcluded - let testFilesHash = context.testFilesHash let schematizable = input.mutants.filter { $0.isSchematizable } let incompatible = input.mutants.filter { !$0.isSchematizable } @@ -111,7 +127,7 @@ struct MutantExecutor: Sendable { if let rerouted = rewriteForIncompatible(mutant, rewriter: rewriter, sourceCache: &sourceCache) { reroutedToIncompatible.append(rerouted) } else { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) await deps.cacheStore.store(status: .unviable, for: key) let index = await deps.counter.increment() await deps.reporter.report( @@ -129,15 +145,15 @@ struct MutantExecutor: Sendable { } let context = TestExecutionContext( artifact: artifact, sandbox: sandbox, pool: pool, - configuration: configuration, testFilesHash: testFilesHash + configuration: configuration ) results += try await runNormal(deps: deps, context: context, schematizable: testableSchematizable) } else if !testableSchematizable.isEmpty { - results += try await runFallback(deps: deps, input: input, pool: pool, testFilesHash: testFilesHash) + results += try await runFallback(deps: deps, input: input, pool: pool) } results += try await runIncompatible( - deps: deps, mutants: incompatible + reroutedToIncompatible, pool: pool, testFilesHash: testFilesHash + deps: deps, mutants: incompatible + reroutedToIncompatible, pool: pool ) return results @@ -145,16 +161,19 @@ struct MutantExecutor: Sendable { private func allCached( mutants: [MutantDescriptor], - cacheStore: CacheStore, - testFilesHash: String + cacheStore: CacheStore ) async -> [ExecutionResult]? { guard !configuration.build.noCache, !mutants.isEmpty else { return nil } var results: [ExecutionResult] = [] for mutant in mutants { - let key = MutantCacheKey.make(for: mutant, testFilesHash: testFilesHash) + let key = MutantCacheKey.make(for: mutant) guard let status = await cacheStore.result(for: key) else { return nil } - results.append(ExecutionResult(descriptor: mutant, status: status, testDuration: 0)) + let killerTestFile = await cacheStore.killerTestFile(for: key) + results.append( + ExecutionResult( + descriptor: mutant, status: status, testDuration: 0, killerTestFile: killerTestFile + )) } return results @@ -288,21 +307,19 @@ struct MutantExecutor: Sendable { private func runFallback( deps: ExecutionDeps, input: RunnerInput, - pool: SimulatorPool, - testFilesHash: String + pool: SimulatorPool ) async throws -> [ExecutionResult] { try await FallbackExecutor(deps: deps, configuration: configuration) - .execute(input: input, pool: pool, testFilesHash: testFilesHash) + .execute(input: input, pool: pool) } private func runIncompatible( deps: ExecutionDeps, mutants: [MutantDescriptor], - pool: SimulatorPool, - testFilesHash: String + pool: SimulatorPool ) async throws -> [ExecutionResult] { try await IncompatibleMutantExecutor(deps: deps, sandboxFactory: SandboxFactory()) - .execute(mutants, configuration: configuration, pool: pool, testFilesHash: testFilesHash) + .execute(mutants, configuration: configuration, pool: pool) } private func validateSPMBaseline(sandbox: Sandbox, deps: ExecutionDeps) async { diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionContext.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionContext.swift index 29ac6d6..56a479d 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionContext.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionContext.swift @@ -3,5 +3,4 @@ struct TestExecutionContext: Sendable { let sandbox: Sandbox let pool: SimulatorPool let configuration: RunnerConfiguration - let testFilesHash: String } diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index 6601b0b..e6ecbcd 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -15,7 +15,7 @@ struct TestExecutionStage: Sendable { var iterator = mutants.makeIterator() while activeTasks < concurrency, let mutant = iterator.next() { - let key = MutantCacheKey.make(for: mutant, testFilesHash: context.testFilesHash) + let key = MutantCacheKey.make(for: mutant) group.addTask { try await self.run(mutant: mutant, key: key, in: context) } activeTasks += 1 } @@ -23,7 +23,7 @@ struct TestExecutionStage: Sendable { for try await result in group { results.append(result) if let next = iterator.next() { - let key = MutantCacheKey.make(for: next, testFilesHash: context.testFilesHash) + let key = MutantCacheKey.make(for: next) group.addTask { try await self.run(mutant: next, key: key, in: context) } } } @@ -38,7 +38,10 @@ struct TestExecutionStage: Sendable { in context: TestExecutionContext ) async throws -> ExecutionResult { if !context.configuration.build.noCache, let cached = await deps.cacheStore.result(for: key) { - let result = ExecutionResult(descriptor: mutant, status: cached, testDuration: 0) + let killerTestFile = await deps.cacheStore.killerTestFile(for: key) + let result = ExecutionResult( + descriptor: mutant, status: cached, testDuration: 0, killerTestFile: killerTestFile + ) let index = await deps.counter.increment() await deps.reporter.report( .mutantFinished(descriptor: mutant, status: cached, index: index, total: deps.counter.total)) @@ -70,8 +73,12 @@ struct TestExecutionStage: Sendable { try? FileManager.default.removeItem(atPath: launched.xcresultPath) let status = outcome.asExecutionStatus - let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) - await deps.cacheStore.store(status: status, for: key) + let killerTestFile = resolveKillerTestFile(status: status) + let result = ExecutionResult( + descriptor: mutant, status: status, testDuration: launched.duration, + killerTestFile: killerTestFile + ) + await deps.cacheStore.store(status: status, for: key, killerTestFile: killerTestFile) let index = await deps.counter.increment() await deps.reporter.report( .mutantFinished( @@ -99,8 +106,12 @@ struct TestExecutionStage: Sendable { let outcome = SPMResultParser().parse(exitCode: launched.exitCode, output: launched.output) await context.pool.release(slot) let status = outcome.asExecutionStatus - let result = ExecutionResult(descriptor: mutant, status: status, testDuration: launched.duration) - await deps.cacheStore.store(status: status, for: key) + let killerTestFile = resolveKillerTestFile(status: status) + let result = ExecutionResult( + descriptor: mutant, status: status, testDuration: launched.duration, + killerTestFile: killerTestFile + ) + await deps.cacheStore.store(status: status, for: key, killerTestFile: killerTestFile) let index = await deps.counter.increment() await deps.reporter.report( .mutantFinished( @@ -111,6 +122,11 @@ struct TestExecutionStage: Sendable { return result } + private func resolveKillerTestFile(status: ExecutionStatus) -> String? { + guard case .killed(let testName) = status else { return nil } + return deps.killerTestFileResolver.resolve(testName: testName) + } + private func launchSPM( mutant: MutantDescriptor, in context: TestExecutionContext diff --git a/Sources/SwiftMutationTesting/Infrastructure/TestFilesHasher.swift b/Sources/SwiftMutationTesting/Infrastructure/TestFilesHasher.swift index b50248f..6e6c247 100644 --- a/Sources/SwiftMutationTesting/Infrastructure/TestFilesHasher.swift +++ b/Sources/SwiftMutationTesting/Infrastructure/TestFilesHasher.swift @@ -1,11 +1,33 @@ import Foundation struct TestFilesHasher: Sendable { - func hash(projectPath: String) -> String { + + func hashPerFile(projectPath: String) -> [String: String] { let projectURL = URL(fileURLWithPath: projectPath) + let resolvedPrefix = projectURL.resolvingSymlinksInPath().path let paths = collectTestFilePaths(under: projectURL) - let combined = paths.sorted().compactMap { try? String(contentsOfFile: $0, encoding: .utf8) }.joined() - return MutantCacheKey.hash(of: combined) + var result: [String: String] = [:] + + for path in paths.sorted() { + guard let content = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + + let resolvedPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path + + let relativePath: String + if resolvedPath.hasPrefix(resolvedPrefix) { + relativePath = String(resolvedPath.dropFirst(resolvedPrefix.count).drop(while: { $0 == "/" })) + } else { + relativePath = path + } + + result[relativePath] = MutantCacheKey.hash(of: content) + } + + return result + } + + func testFilePaths(projectPath: String) -> [String] { + collectTestFilePaths(under: URL(fileURLWithPath: projectPath)) } private func collectTestFilePaths(under directory: URL) -> [String] { diff --git a/Tests/SwiftMutationTestingTests/TestSupport/BuildArtifactFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/BuildArtifactFixture.swift new file mode 100644 index 0000000..4cafdaf --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/BuildArtifactFixture.swift @@ -0,0 +1,12 @@ +import Foundation + +@testable import SwiftMutationTesting + +func makeBuildArtifact(in dir: URL) -> BuildArtifact { + let plistDict: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] + let data = try! PropertyListSerialization.data( + fromPropertyList: plistDict, format: .xml, options: 0 + ) + let plist = XCTestRunPlist(data)! + return BuildArtifact(derivedDataPath: dir.path, xctestrunURL: dir, plist: plist) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/DiscoveryInputFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/DiscoveryInputFixture.swift new file mode 100644 index 0000000..a7aa19a --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/DiscoveryInputFixture.swift @@ -0,0 +1,23 @@ +@testable import SwiftMutationTesting + +func makeDiscoveryInput( + projectPath: String = "/project", + projectType: ProjectType = .xcode(scheme: "Scheme", destination: "platform=macOS"), + timeout: Double = 60, + concurrency: Int = 4, + noCache: Bool = false, + sourcesPath: String, + excludePatterns: [String] = [], + operators: [String] = [] +) -> DiscoveryInput { + DiscoveryInput( + projectPath: projectPath, + projectType: projectType, + timeout: timeout, + concurrency: concurrency, + noCache: noCache, + sourcesPath: sourcesPath, + excludePatterns: excludePatterns, + operators: operators + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/ExecutionDepsFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/ExecutionDepsFixture.swift new file mode 100644 index 0000000..4f22e98 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/ExecutionDepsFixture.swift @@ -0,0 +1,19 @@ +import Foundation + +@testable import SwiftMutationTesting + +func makeExecutionDeps( + launcher: any ProcessLaunching = MockProcessLauncher(exitCode: 0), + cacheStorePath: String = "/tmp/cache.json", + reporter: any ProgressReporter = MockProgressReporter(), + total: Int = 1, + testFilePaths: [String] = [] +) -> ExecutionDeps { + ExecutionDeps( + launcher: launcher, + cacheStore: CacheStore(storePath: cacheStorePath), + reporter: reporter, + counter: MutationCounter(total: total), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: testFilePaths) + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/ExecutionResultFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/ExecutionResultFixture.swift new file mode 100644 index 0000000..5a95866 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/ExecutionResultFixture.swift @@ -0,0 +1,25 @@ +@testable import SwiftMutationTesting + +func makeExecutionResult( + id: String = "m0", + filePath: String = "/tmp/Foo.swift", + line: Int = 1, + column: Int = 1, + utf8Offset: Int = 0, + status: ExecutionStatus, + testDuration: Double = 0, + killerTestFile: String? = nil +) -> ExecutionResult { + ExecutionResult( + descriptor: makeMutantDescriptor( + id: id, + filePath: filePath, + line: line, + column: column, + utf8Offset: utf8Offset + ), + status: status, + testDuration: testDuration, + killerTestFile: killerTestFile + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/FixtureLoader.swift b/Tests/SwiftMutationTestingTests/TestSupport/FixtureLoader.swift new file mode 100644 index 0000000..4332a99 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/FixtureLoader.swift @@ -0,0 +1,8 @@ +import Foundation + +func loadTestFixture(_ name: String, extension ext: String = "txt") throws -> String { + let fixturesURL = URL(filePath: #filePath) + .deletingLastPathComponent() + .appending(path: "Fixtures/\(name).\(ext)") + return try String(contentsOf: fixturesURL, encoding: .utf8) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IncompatibleMutantExecutorFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/IncompatibleMutantExecutorFixture.swift new file mode 100644 index 0000000..bb5b839 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/IncompatibleMutantExecutorFixture.swift @@ -0,0 +1,30 @@ +import Foundation + +@testable import SwiftMutationTesting + +func makeIncompatibleMutantExecutor( + in dir: URL, + exitCode: Int32 +) -> IncompatibleMutantExecutor { + IncompatibleMutantExecutor( + deps: makeExecutionDeps( + launcher: MockProcessLauncher(exitCode: exitCode), + cacheStorePath: dir.appendingPathComponent("cache.json").path, + total: 3 + ), + sandboxFactory: SandboxFactory() + ) +} + +func makeIncompatibleMutantExecutorSPM( + in dir: URL, + launcher: any ProcessLaunching +) -> IncompatibleMutantExecutor { + IncompatibleMutantExecutor( + deps: makeExecutionDeps( + launcher: launcher, + cacheStorePath: dir.appendingPathComponent("cache.json").path + ), + sandboxFactory: SandboxFactory() + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/IndexedMutationPointFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/IndexedMutationPointFixture.swift new file mode 100644 index 0000000..e655e7a --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/IndexedMutationPointFixture.swift @@ -0,0 +1,9 @@ +@testable import SwiftMutationTesting + +func makeIndexedMutationPoints( + 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/TestSupport/MutantCacheKeyFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/MutantCacheKeyFixture.swift new file mode 100644 index 0000000..b82a403 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/MutantCacheKeyFixture.swift @@ -0,0 +1,17 @@ +@testable import SwiftMutationTesting + +func makeMutantCacheKey( + fileContentHash: String = "abc", + operatorIdentifier: String = "binaryOperator", + utf8Offset: Int = 0, + originalText: String = "a + b", + mutatedText: String = "a - b" +) -> MutantCacheKey { + MutantCacheKey( + fileContentHash: fileContentHash, + operatorIdentifier: operatorIdentifier, + utf8Offset: utf8Offset, + originalText: originalText, + mutatedText: mutatedText + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/MutantDescriptorFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/MutantDescriptorFixture.swift new file mode 100644 index 0000000..ec95bd6 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/MutantDescriptorFixture.swift @@ -0,0 +1,31 @@ +@testable import SwiftMutationTesting + +func makeMutantDescriptor( + id: String = "m0", + filePath: String = "/tmp/Foo.swift", + line: Int = 1, + column: Int = 1, + utf8Offset: Int = 0, + originalText: String = "+", + mutatedText: String = "-", + operatorIdentifier: String = "ArithmeticOperatorReplacement", + replacementKind: ReplacementKind = .binaryOperator, + description: String = "+ → -", + isSchematizable: Bool = false, + mutatedSourceContent: String? = nil +) -> MutantDescriptor { + MutantDescriptor( + id: id, + filePath: filePath, + line: line, + column: column, + utf8Offset: utf8Offset, + originalText: originalText, + mutatedText: mutatedText, + operatorIdentifier: operatorIdentifier, + replacementKind: replacementKind, + description: description, + isSchematizable: isSchematizable, + mutatedSourceContent: mutatedSourceContent + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/RunnerConfigurationFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/RunnerConfigurationFixture.swift new file mode 100644 index 0000000..d7def87 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/RunnerConfigurationFixture.swift @@ -0,0 +1,34 @@ +@testable import SwiftMutationTesting + +func makeRunnerConfiguration( + projectPath: String = "/tmp", + projectType: ProjectType = .xcode(scheme: "MyScheme", destination: "platform=macOS"), + testTarget: String? = nil, + timeout: Double = 60, + concurrency: Int = 1, + noCache: Bool = false, + output: String? = nil, + htmlOutput: String? = nil, + sonarOutput: String? = nil, + quiet: Bool = true, + excludePatterns: [String] = [], + operators: [String] = [] +) -> RunnerConfiguration { + RunnerConfiguration( + projectPath: projectPath, + build: .init( + projectType: projectType, + testTarget: testTarget, + timeout: timeout, + concurrency: concurrency, + noCache: noCache + ), + reporting: .init( + output: output, + htmlOutput: htmlOutput, + sonarOutput: sonarOutput, + quiet: quiet + ), + filter: .init(excludePatterns: excludePatterns, operators: operators) + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/RunnerInputFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/RunnerInputFixture.swift new file mode 100644 index 0000000..dce8b8d --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/RunnerInputFixture.swift @@ -0,0 +1,23 @@ +@testable import SwiftMutationTesting + +func makeRunnerInput( + projectPath: String = "/tmp", + projectType: ProjectType = .xcode(scheme: "MyScheme", destination: "platform=macOS"), + timeout: Double = 60, + concurrency: Int = 1, + noCache: Bool = false, + schematizedFiles: [SchematizedFile] = [], + supportFileContent: String = "", + mutants: [MutantDescriptor] = [] +) -> RunnerInput { + RunnerInput( + projectPath: projectPath, + projectType: projectType, + timeout: timeout, + concurrency: concurrency, + noCache: noCache, + schematizedFiles: schematizedFiles, + supportFileContent: supportFileContent, + mutants: mutants + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SandboxFactoryFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/SandboxFactoryFixture.swift new file mode 100644 index 0000000..0fef95b --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SandboxFactoryFixture.swift @@ -0,0 +1,29 @@ +func swiftLintPbxprojContent() -> String { + """ + + + + + archiveVersion + 1 + objects + + AABBCC + + isa + PBXShellScriptBuildPhase + shellScript + swiftlint lint --strict + + DDEEFF + + isa + PBXShellScriptBuildPhase + shellScript + echo hello + + + + + """ +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SchemataGeneratorFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/SchemataGeneratorFixture.swift new file mode 100644 index 0000000..8086395 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SchemataGeneratorFixture.swift @@ -0,0 +1,9 @@ +@testable import SwiftMutationTesting + +func mutationsWithIndices( + _ source: ParsedSource, + op: any MutationOperator = BooleanLiteralReplacement(), + startIndex: Int = 0 +) -> [(index: Int, point: MutationPoint)] { + op.mutations(in: source).enumerated().map { (index: startIndex + $0.offset, point: $0.element) } +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/SimulatorPoolFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/SimulatorPoolFixture.swift new file mode 100644 index 0000000..1a74b04 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/SimulatorPoolFixture.swift @@ -0,0 +1,10 @@ +@testable import SwiftMutationTesting + +func makeSimulatorPool(launcher: any ProcessLaunching = MockProcessLauncher(exitCode: 0)) -> SimulatorPool { + SimulatorPool( + baseUDID: nil, + size: 1, + destination: "platform=macOS", + launcher: launcher + ) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/TestExecutionStageFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/TestExecutionStageFixture.swift new file mode 100644 index 0000000..c537c74 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/TestExecutionStageFixture.swift @@ -0,0 +1,49 @@ +import Foundation + +@testable import SwiftMutationTesting + +func makeTestExecutionFixture( + in dir: URL, + exitCode: Int32, + output: String = "" +) -> (TestExecutionStage, TestExecutionContext) { + let launcher = MockProcessLauncher(exitCode: exitCode, output: output) + let pool = makeSimulatorPool(launcher: launcher) + let stage = TestExecutionStage( + deps: makeExecutionDeps( + launcher: launcher, + cacheStorePath: dir.appendingPathComponent("cache.json").path, + total: 3 + ) + ) + let context = TestExecutionContext( + artifact: makeBuildArtifact(in: dir), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: makeRunnerConfiguration() + ) + return (stage, context) +} + +func makeTestExecutionSPMFixture( + in dir: URL, + exitCode: Int32, + output: String = "" +) -> (TestExecutionStage, TestExecutionContext) { + let launcher = MockProcessLauncher(exitCode: exitCode, output: output) + let pool = makeSimulatorPool(launcher: launcher) + let stage = TestExecutionStage( + deps: makeExecutionDeps( + launcher: launcher, + cacheStorePath: dir.appendingPathComponent("cache.json").path + ) + ) + let config = makeRunnerConfiguration(projectType: .spm) + let context = TestExecutionContext( + artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), + sandbox: Sandbox(rootURL: dir), + pool: pool, + configuration: config + ) + return (stage, context) +} diff --git a/Tests/SwiftMutationTestingTests/TestSupport/TypeScopeVisitorFixture.swift b/Tests/SwiftMutationTestingTests/TestSupport/TypeScopeVisitorFixture.swift new file mode 100644 index 0000000..8a9fcb2 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/TestSupport/TypeScopeVisitorFixture.swift @@ -0,0 +1,10 @@ +import SwiftSyntax + +@testable import SwiftMutationTesting + +func makeTypeScopeVisitor(_ code: String) -> TypeScopeVisitor { + let source = makeParsedSource(code) + let visitor = TypeScopeVisitor() + visitor.walk(source.syntax) + return visitor +} diff --git a/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift index 395ef9e..c62cc2b 100644 --- a/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/CLI/WriteReportsTests.swift @@ -10,7 +10,7 @@ struct WriteReportsTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let configuration = makeConfiguration(projectPath: dir.path) + let configuration = makeRunnerConfiguration(projectPath: dir.path) SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) @@ -24,7 +24,7 @@ struct WriteReportsTests { defer { FileHelpers.cleanup(dir) } let outputPath = dir.appendingPathComponent("report.json").path - let configuration = makeConfiguration(projectPath: dir.path, output: outputPath) + let configuration = makeRunnerConfiguration(projectPath: dir.path, output: outputPath) SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) @@ -37,7 +37,7 @@ struct WriteReportsTests { defer { FileHelpers.cleanup(dir) } let outputPath = dir.appendingPathComponent("report.html").path - let configuration = makeConfiguration(projectPath: dir.path, htmlOutput: outputPath) + let configuration = makeRunnerConfiguration(projectPath: dir.path, htmlOutput: outputPath) SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) @@ -50,7 +50,7 @@ struct WriteReportsTests { defer { FileHelpers.cleanup(dir) } let outputPath = dir.appendingPathComponent("sonar.json").path - let configuration = makeConfiguration(projectPath: dir.path, sonarOutput: outputPath) + let configuration = makeRunnerConfiguration(projectPath: dir.path, sonarOutput: outputPath) SwiftMutationTesting.writeReports(makeEmptySummary(), configuration: configuration) @@ -59,7 +59,7 @@ struct WriteReportsTests { @Test("Given invalid json output path, when writeReports called, then does not crash") func invalidJsonOutputPathDoesNotCrash() { - let configuration = makeConfiguration( + let configuration = makeRunnerConfiguration( projectPath: "/tmp", output: "/nonexistent/dir/report.json" ) @@ -68,7 +68,7 @@ struct WriteReportsTests { @Test("Given invalid html output path, when writeReports called, then does not crash") func invalidHtmlOutputPathDoesNotCrash() { - let configuration = makeConfiguration( + let configuration = makeRunnerConfiguration( projectPath: "/tmp", htmlOutput: "/nonexistent/dir/report.html" ) @@ -77,7 +77,7 @@ struct WriteReportsTests { @Test("Given invalid sonar output path, when writeReports called, then does not crash") func invalidSonarOutputPathDoesNotCrash() { - let configuration = makeConfiguration( + let configuration = makeRunnerConfiguration( projectPath: "/tmp", sonarOutput: "/nonexistent/dir/sonar.json" ) @@ -92,7 +92,7 @@ struct WriteReportsTests { let jsonPath = dir.appendingPathComponent("report.json").path let htmlPath = dir.appendingPathComponent("report.html").path let sonarPath = dir.appendingPathComponent("sonar.json").path - let configuration = makeConfiguration( + let configuration = makeRunnerConfiguration( projectPath: dir.path, output: jsonPath, htmlOutput: htmlPath, @@ -106,19 +106,4 @@ struct WriteReportsTests { #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: []) - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Cache/CacheStoreTests.swift b/Tests/SwiftMutationTestingTests/Unit/Cache/CacheStoreTests.swift index 9022fa3..6e42de9 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Cache/CacheStoreTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Cache/CacheStoreTests.swift @@ -11,7 +11,7 @@ struct CacheStoreTests { defer { FileHelpers.cleanup(dir) } let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let key = makeKey(utf8Offset: 0) + let key = makeMutantCacheKey(utf8Offset: 0) await store.store(status: .survived, for: key) @@ -25,7 +25,7 @@ struct CacheStoreTests { let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - #expect(await store.result(for: makeKey(utf8Offset: 0)) == nil) + #expect(await store.result(for: makeMutantCacheKey(utf8Offset: 0)) == nil) } @Test("Given entries stored and persisted, when new store loads same path, then same entries are returned") @@ -34,7 +34,7 @@ struct CacheStoreTests { defer { FileHelpers.cleanup(dir) } let storePath = dir.appendingPathComponent("cache.json").path - let key = makeKey(utf8Offset: 5) + let key = makeMutantCacheKey(utf8Offset: 5) let first = CacheStore(storePath: storePath) await first.store(status: .killed(by: "Suite.test"), for: key) @@ -52,17 +52,409 @@ struct CacheStoreTests { try await store.load() - #expect(await store.result(for: makeKey(utf8Offset: 0)) == nil) + #expect(await store.result(for: makeMutantCacheKey(utf8Offset: 0)) == nil) } - private func makeKey(utf8Offset: Int) -> MutantCacheKey { - MutantCacheKey( - fileContentHash: "abc", - testFilesHash: "def", - operatorIdentifier: "binaryOperator", - utf8Offset: utf8Offset, - originalText: "a + b", - mutatedText: "a - b" + @Test("Given entry with killerTestFile persisted, when loaded, then killerTestFile is preserved") + func persistAndLoadRoundtripWithKillerTestFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let storePath = dir.appendingPathComponent("cache.json").path + let key = makeMutantCacheKey(utf8Offset: 10) + + let first = CacheStore(storePath: storePath) + await first.store(status: .killed(by: "Suite.test"), for: key, killerTestFile: "Tests/SuiteTests.swift") + try await first.persist() + + let second = CacheStore(storePath: storePath) + try await second.load() + + #expect(await second.result(for: key) == .killed(by: "Suite.test")) + #expect(await second.killerTestFile(for: key) == "Tests/SuiteTests.swift") + } + + @Test("Given entry without killerTestFile, when loaded, then killerTestFile is nil") + func killerTestFileIsNilWhenNotStored() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let storePath = dir.appendingPathComponent("cache.json").path + let key = makeMutantCacheKey(utf8Offset: 11) + + let store = CacheStore(storePath: storePath) + await store.store(status: .killed(by: "Suite.test"), for: key) + try await store.persist() + + let loaded = CacheStore(storePath: storePath) + try await loaded.load() + + #expect(await loaded.result(for: key) == .killed(by: "Suite.test")) + #expect(await loaded.killerTestFile(for: key) == nil) + } + + @Test("Given stored metadata, when changedTestFiles called with same hashes, then no changes reported") + func changedTestFilesReportsNoChangesWhenUnchanged() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let hashes = ["Tests/FooTests.swift": "aaa", "Tests/BarTests.swift": "bbb"] + try await store.persistMetadata(CacheStore.CacheMetadata(testFileHashes: hashes)) + + let diff = try await store.changedTestFiles(current: hashes) + + #expect(!diff.hasChanges) + #expect(diff.added.isEmpty) + #expect(diff.modified.isEmpty) + #expect(diff.removed.isEmpty) + } + + @Test("Given stored metadata, when changedTestFiles called with new file, then added set contains it") + func changedTestFilesClassifiesAddedFiles() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let stored = ["Tests/FooTests.swift": "aaa"] + try await store.persistMetadata(CacheStore.CacheMetadata(testFileHashes: stored)) + + let current = ["Tests/FooTests.swift": "aaa", "Tests/NewTests.swift": "ccc"] + let diff = try await store.changedTestFiles(current: current) + + #expect(diff.added == ["Tests/NewTests.swift"]) + #expect(diff.modified.isEmpty) + #expect(diff.removed.isEmpty) + } + + @Test("Given stored metadata, when changedTestFiles called with different hash, then modified set contains it") + func changedTestFilesClassifiesModifiedFiles() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let stored = ["Tests/FooTests.swift": "aaa"] + try await store.persistMetadata(CacheStore.CacheMetadata(testFileHashes: stored)) + + let current = ["Tests/FooTests.swift": "zzz"] + let diff = try await store.changedTestFiles(current: current) + + #expect(diff.added.isEmpty) + #expect(diff.modified == ["Tests/FooTests.swift"]) + #expect(diff.removed.isEmpty) + } + + @Test("Given stored metadata, when changedTestFiles called without a file, then removed set contains it") + func changedTestFilesClassifiesRemovedFiles() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let stored = ["Tests/FooTests.swift": "aaa", "Tests/OldTests.swift": "bbb"] + try await store.persistMetadata(CacheStore.CacheMetadata(testFileHashes: stored)) + + let current = ["Tests/FooTests.swift": "aaa"] + let diff = try await store.changedTestFiles(current: current) + + #expect(diff.added.isEmpty) + #expect(diff.modified.isEmpty) + #expect(diff.removed == ["Tests/OldTests.swift"]) + } + + @Test("Given no metadata exists, when changedTestFiles called, then all files reported as added") + func changedTestFilesTreatsAllAsAddedWhenNoMetadata() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let current = ["Tests/FooTests.swift": "aaa"] + + let diff = try await store.changedTestFiles(current: current) + + #expect(diff.added == ["Tests/FooTests.swift"]) + #expect(diff.modified.isEmpty) + #expect(diff.removed.isEmpty) + } + + @Test("Given metadata persisted and loaded, when round-tripped, then hashes match") + func metadataPersistAndLoadRoundtrip() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let hashes = ["Tests/A.swift": "abc", "Tests/B.swift": "def"] + try await store.persistMetadata(CacheStore.CacheMetadata(testFileHashes: hashes)) + + let loaded = try await store.loadMetadata() + + #expect(loaded?.testFileHashes == hashes) + } + + @Test("Given killed entry with unchanged killer file, when invalidated, then entry is kept") + func invalidateKeepsKilledByUnchangedFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 20) + await store.store(status: .killed(by: "FooTests.test"), for: key, killerTestFile: "Tests/FooTests.swift") + + let diff = TestFileDiff(added: [], modified: ["Tests/BarTests.swift"], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == .killed(by: "FooTests.test")) + } + + @Test("Given killed entry with modified killer file, when invalidated, then entry is removed") + func invalidateRemovesKilledByModifiedFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 21) + await store.store(status: .killed(by: "FooTests.test"), for: key, killerTestFile: "Tests/FooTests.swift") + + let diff = TestFileDiff(added: [], modified: ["Tests/FooTests.swift"], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given killed entry with nil killer file, when invalidated, then entry is removed conservatively") + func invalidateRemovesKilledWithNilKillerFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 22) + await store.store(status: .killed(by: "UnknownTest"), for: key) + + let diff = TestFileDiff(added: ["Tests/NewTests.swift"], modified: [], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given survived entry, when diff has changes, then entry is removed") + func invalidateRemovesSurvivedWhenDiffHasChanges() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 23) + await store.store(status: .survived, for: key) + + let diff = TestFileDiff(added: ["Tests/NewTests.swift"], modified: [], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given survived entry, when no changes, then entry is kept") + func invalidateKeepsSurvivedWhenNoChanges() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 24) + await store.store(status: .survived, for: key) + + let diff = TestFileDiff(added: [], modified: [], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == .survived) + } + + @Test("Given unviable entry, when invalidated, then entry is always kept") + func invalidateAlwaysKeepsUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 25) + await store.store(status: .unviable, for: key) + + let diff = TestFileDiff( + added: ["Tests/New.swift"], modified: ["Tests/Old.swift"], removed: ["Tests/Gone.swift"]) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == .unviable) + } + + @Test("Given killedByCrash entry, when invalidated, then entry is always kept") + func invalidateAlwaysKeepsKilledByCrash() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 26) + await store.store(status: .killedByCrash, for: key) + + let diff = TestFileDiff( + added: ["Tests/New.swift"], modified: ["Tests/Old.swift"], removed: ["Tests/Gone.swift"]) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == .killedByCrash) + } + + @Test("Given noCoverage entry, when diff has changes, then entry is removed") + func invalidateRemovesNoCoverageWhenDiffHasChanges() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 27) + await store.store(status: .noCoverage, for: key) + + let diff = TestFileDiff(added: ["Tests/New.swift"], modified: [], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given timeout entry, when diff has changes, then entry is removed") + func invalidateRemovesTimeoutWhenDiffHasChanges() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 28) + await store.store(status: .timeout, for: key) + + let diff = TestFileDiff(added: [], modified: ["Tests/Changed.swift"], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given killed entry with removed killer file, when invalidated, then entry is removed") + func invalidateRemovesKilledByRemovedFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let key = makeMutantCacheKey(utf8Offset: 29) + await store.store(status: .killed(by: "OldTests.test"), for: key, killerTestFile: "Tests/OldTests.swift") + + let diff = TestFileDiff(added: [], modified: [], removed: ["Tests/OldTests.swift"]) + await store.invalidate(diff: diff) + + #expect(await store.result(for: key) == nil) + } + + @Test("Given renamed test file, when invalidated, then killed pointing to old path removed and survived removed") + func invalidateHandlesRenamedTestFile() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let killedKey = makeMutantCacheKey(utf8Offset: 30) + let survivedKey = makeMutantCacheKey(utf8Offset: 31) + let unrelatedKilledKey = makeMutantCacheKey(utf8Offset: 32) + await store.store( + status: .killed(by: "OldTests.test"), for: killedKey, killerTestFile: "Tests/OldTests.swift") + await store.store(status: .survived, for: survivedKey) + await store.store( + status: .killed(by: "Other.test"), for: unrelatedKilledKey, killerTestFile: "Tests/OtherTests.swift") + + let diff = TestFileDiff( + added: ["Tests/RenamedTests.swift"], + modified: [], + removed: ["Tests/OldTests.swift"] ) + await store.invalidate(diff: diff) + + #expect(await store.result(for: killedKey) == nil) + #expect(await store.result(for: survivedKey) == nil) + #expect(await store.result(for: unrelatedKilledKey) == .killed(by: "Other.test")) } + + @Test( + "Given old cache format without killerTestFile, when loaded and invalidated, then entries treated conservatively" + ) + func oldCacheFormatWithoutKillerTestFileTreatedConservatively() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let storePath = dir.appendingPathComponent("cache.json").path + let key = makeMutantCacheKey(utf8Offset: 33) + + let store = CacheStore(storePath: storePath) + await store.store(status: .killed(by: "SomeTest.test"), for: key) + try await store.persist() + + let reloaded = CacheStore(storePath: storePath) + try await reloaded.load() + + #expect(await reloaded.killerTestFile(for: key) == nil) + + let diff = TestFileDiff(added: ["Tests/New.swift"], modified: [], removed: []) + await reloaded.invalidate(diff: diff) + + #expect(await reloaded.result(for: key) == nil) + } + + @Test("Given added test file, when invalidated, then killed entries with known files are kept") + func invalidateKeepsKilledEntriesWhenTestFileAdded() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let killedKey = makeMutantCacheKey(utf8Offset: 34) + let survivedKey = makeMutantCacheKey(utf8Offset: 35) + let noCoverageKey = makeMutantCacheKey(utf8Offset: 36) + await store.store( + status: .killed(by: "FooTests.test"), for: killedKey, killerTestFile: "Tests/FooTests.swift") + await store.store(status: .survived, for: survivedKey) + await store.store(status: .noCoverage, for: noCoverageKey) + + let diff = TestFileDiff(added: ["Tests/NewTests.swift"], modified: [], removed: []) + await store.invalidate(diff: diff) + + #expect(await store.result(for: killedKey) == .killed(by: "FooTests.test")) + #expect(await store.result(for: survivedKey) == nil) + #expect(await store.result(for: noCoverageKey) == nil) + } + + @Test("Given deleted test file, when invalidated, then killed entries pointing to it are removed") + func invalidateRemovesKilledEntriesWhenTestFileDeleted() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let store = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) + let affectedKey = makeMutantCacheKey(utf8Offset: 37) + let unaffectedKey = makeMutantCacheKey(utf8Offset: 38) + let survivedKey = makeMutantCacheKey(utf8Offset: 39) + await store.store( + status: .killed(by: "Gone.test"), for: affectedKey, killerTestFile: "Tests/GoneTests.swift") + await store.store( + status: .killed(by: "Still.test"), for: unaffectedKey, killerTestFile: "Tests/StillTests.swift") + await store.store(status: .survived, for: survivedKey) + + let diff = TestFileDiff(added: [], modified: [], removed: ["Tests/GoneTests.swift"]) + await store.invalidate(diff: diff) + + #expect(await store.result(for: affectedKey) == nil) + #expect(await store.result(for: unaffectedKey) == .killed(by: "Still.test")) + #expect(await store.result(for: survivedKey) == nil) + } + + @Test( + "Given metadata persisted, when loaded by new store, then changedTestFiles detects no-cache metadata correctly") + func metadataPersistedAndLoadedForNextRun() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let storePath = dir.appendingPathComponent("cache.json").path + + let first = CacheStore(storePath: storePath) + let hashes = ["Tests/A.swift": "hash1", "Tests/B.swift": "hash2"] + try await first.persistMetadata(CacheStore.CacheMetadata(testFileHashes: hashes)) + + let second = CacheStore(storePath: storePath) + let diff = try await second.changedTestFiles(current: hashes) + + #expect(!diff.hasChanges) + } + } diff --git a/Tests/SwiftMutationTestingTests/Unit/Cache/KillerTestFileResolverTests.swift b/Tests/SwiftMutationTestingTests/Unit/Cache/KillerTestFileResolverTests.swift new file mode 100644 index 0000000..cc9f741 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Cache/KillerTestFileResolverTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing + +@testable import SwiftMutationTesting + +@Suite("KillerTestFileResolver") +struct KillerTestFileResolverTests { + @Test("Given XCTest class name, when resolved, then returns file matching class name") + func resolvesXCTestClassName() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let testsDir = dir.appendingPathComponent("Tests") + try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) + let filePath = testsDir.appendingPathComponent("CalculatorTests.swift").path + try "import XCTest".write(toFile: filePath, atomically: true, encoding: .utf8) + + let resolver = KillerTestFileResolver(testFilePaths: [filePath]) + + let result = resolver.resolve(testName: "CalculatorTests.testAddition") + + #expect(result == filePath) + } + + @Test("Given XCTest three-part name, when resolved, then returns file matching middle component") + func resolvesXCTestThreePartName() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let testsDir = dir.appendingPathComponent("Tests") + try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) + let filePath = testsDir.appendingPathComponent("CalculatorTests.swift").path + try "import XCTest".write(toFile: filePath, atomically: true, encoding: .utf8) + + let resolver = KillerTestFileResolver(testFilePaths: [filePath]) + + let result = resolver.resolve(testName: "MyModule.CalculatorTests.testAddition") + + #expect(result == filePath) + } + + @Test("Given Swift Testing function name, when resolved, then returns file containing function") + func resolvesSwiftTestingFunctionName() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let testsDir = dir.appendingPathComponent("Tests") + try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) + let filePath = testsDir.appendingPathComponent("MathTests.swift").path + try "func testAddition() { }".write(toFile: filePath, atomically: true, encoding: .utf8) + + let resolver = KillerTestFileResolver(testFilePaths: [filePath]) + + let result = resolver.resolve(testName: "MyModule/MathTests/testAddition") + + #expect(result == filePath) + } + + @Test("Given unknown test name, when resolved, then returns nil") + func returnsNilForUnknownTestName() { + let resolver = KillerTestFileResolver(testFilePaths: ["/some/path/FooTests.swift"]) + + let result = resolver.resolve(testName: "UnknownTests.testSomething") + + #expect(result == nil) + } + + @Test("Given empty test file paths, when resolved, then returns nil") + func returnsNilWhenNoTestFiles() { + let resolver = KillerTestFileResolver(testFilePaths: []) + + let result = resolver.resolve(testName: "SomeTests.testMethod") + + #expect(result == nil) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Cache/MutantCacheKeyTests.swift b/Tests/SwiftMutationTestingTests/Unit/Cache/MutantCacheKeyTests.swift index 3e9b488..fa58cdd 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Cache/MutantCacheKeyTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Cache/MutantCacheKeyTests.swift @@ -21,9 +21,8 @@ struct MutantCacheKeyTests { mutatedSourceContent: nil ) - let key = MutantCacheKey.make(for: mutant, testFilesHash: "testhash") + let key = MutantCacheKey.make(for: mutant) - #expect(key.testFilesHash == "testhash") #expect(key.operatorIdentifier == "binaryOperator") #expect(key.utf8Offset == 42) #expect(key.originalText == "a + b") @@ -56,8 +55,8 @@ struct MutantCacheKeyTests { mutatedSourceContent: nil ) - let keyA = MutantCacheKey.make(for: mutant, testFilesHash: "h") - let keyB = MutantCacheKey.make(for: mutant, testFilesHash: "h") + let keyA = MutantCacheKey.make(for: mutant) + let keyB = MutantCacheKey.make(for: mutant) #expect(keyA == keyB) } @@ -93,8 +92,31 @@ struct MutantCacheKeyTests { mutatedSourceContent: nil ) - let keyBase = MutantCacheKey.make(for: base, testFilesHash: "h") - let keyShifted = MutantCacheKey.make(for: shifted, testFilesHash: "h") + let keyBase = MutantCacheKey.make(for: base) + let keyShifted = MutantCacheKey.make(for: shifted) #expect(keyBase != keyShifted) } + + @Test("Given same source unchanged, when make called across runs, then keys match") + func keysMatchAcrossRunsWhenSourceUnchanged() { + let mutant = MutantDescriptor( + id: "m0", + filePath: "/tmp/Foo.swift", + line: 1, + column: 1, + utf8Offset: 5, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "op", + replacementKind: .binaryOperator, + description: "desc", + isSchematizable: false, + mutatedSourceContent: "let x = a - b" + ) + + let key1 = MutantCacheKey.make(for: mutant) + let key2 = MutantCacheKey.make(for: mutant) + + #expect(key1 == key2) + } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Cache/TestFileDiffTests.swift b/Tests/SwiftMutationTestingTests/Unit/Cache/TestFileDiffTests.swift new file mode 100644 index 0000000..78005b4 --- /dev/null +++ b/Tests/SwiftMutationTestingTests/Unit/Cache/TestFileDiffTests.swift @@ -0,0 +1,34 @@ +import Testing + +@testable import SwiftMutationTesting + +@Suite("TestFileDiff") +struct TestFileDiffTests { + @Test("Given empty sets, when hasChanges checked, then returns false") + func hasChangesReturnsFalseWhenAllSetsEmpty() { + let diff = TestFileDiff(added: [], modified: [], removed: []) + + #expect(!diff.hasChanges) + } + + @Test("Given added files, when hasChanges checked, then returns true") + func hasChangesReturnsTrueWhenFilesAdded() { + let diff = TestFileDiff(added: ["NewTests.swift"], modified: [], removed: []) + + #expect(diff.hasChanges) + } + + @Test("Given modified files, when hasChanges checked, then returns true") + func hasChangesReturnsTrueWhenFilesModified() { + let diff = TestFileDiff(added: [], modified: ["FooTests.swift"], removed: []) + + #expect(diff.hasChanges) + } + + @Test("Given removed files, when hasChanges checked, then returns true") + func hasChangesReturnsTrueWhenFilesRemoved() { + let diff = TestFileDiff(added: [], modified: [], removed: ["OldTests.swift"]) + + #expect(diff.hasChanges) + } +} diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift index ab3db29..120a8bf 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/DiscoveryPipelineTests.swift @@ -12,7 +12,7 @@ struct DiscoveryPipelineTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write("func f() { let x = true }", named: "Source.swift", in: dir) - let input = makeInput(projectPath: dir.path, sourcesPath: dir.path) + let input = makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path) let result = try await pipeline.run(input: input) #expect(result.projectPath == dir.path) @@ -21,7 +21,7 @@ struct DiscoveryPipelineTests { @Test("Given non-existent sources path, when run, then throws") func nonExistentSourcesPathThrows() async { - let input = makeInput(projectPath: "/nonexistent", sourcesPath: "/nonexistent/does/not/exist") + let input = makeDiscoveryInput(projectPath: "/nonexistent", sourcesPath: "/nonexistent/does/not/exist") await #expect(throws: (any Error).self) { _ = try await pipeline.run(input: input) @@ -34,7 +34,7 @@ struct DiscoveryPipelineTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write("func f() { let x = true }", named: "Source.swift", in: dir) - let input = makeInput(sourcesPath: dir.path, operators: ["BooleanLiteralReplacement"]) + let input = makeDiscoveryInput(sourcesPath: dir.path, operators: ["BooleanLiteralReplacement"]) let result = try await pipeline.run(input: input) #expect(result.mutants.allSatisfy { $0.operatorIdentifier == "BooleanLiteralReplacement" }) @@ -46,7 +46,7 @@ struct DiscoveryPipelineTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write("func f() { let x = true }", named: "Source.swift", in: dir) - let input = makeInput(sourcesPath: dir.path, operators: ["BooleanLiteralReplacement"]) + let input = makeDiscoveryInput(sourcesPath: dir.path, operators: ["BooleanLiteralReplacement"]) let result = try await pipeline.run(input: input) #expect(!result.schematizedFiles.isEmpty) @@ -89,7 +89,7 @@ struct DiscoveryPipelineTests { in: dir ) - let input = makeInput(sourcesPath: dir.path, operators: []) + let input = makeDiscoveryInput(sourcesPath: dir.path, operators: []) let result = try await pipeline.run(input: input) let identifiers = Set(result.mutants.map { $0.operatorIdentifier }) @@ -102,30 +102,10 @@ struct DiscoveryPipelineTests { defer { FileHelpers.cleanup(dir) } try FileHelpers.write("func f() { let x = true }", named: "Generated.swift", in: dir) - let input = makeInput(sourcesPath: dir.path, excludePatterns: ["Generated.swift"]) + let input = makeDiscoveryInput(sourcesPath: dir.path, excludePatterns: ["Generated.swift"]) let result = try await pipeline.run(input: input) #expect(result.mutants.isEmpty) #expect(result.schematizedFiles.isEmpty) } } - -extension DiscoveryPipelineTests { - private func makeInput( - projectPath: String = "/project", - sourcesPath: String, - excludePatterns: [String] = [], - operators: [String] = [] - ) -> DiscoveryInput { - DiscoveryInput( - projectPath: projectPath, - projectType: .xcode(scheme: "Scheme", destination: "platform=macOS"), - timeout: 60, - concurrency: 4, - noCache: false, - sourcesPath: sourcesPath, - excludePatterns: excludePatterns, - operators: operators - ) - } -} diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift index c1c5444..57960d8 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/FileDiscoveryStageTests.swift @@ -7,22 +7,6 @@ import Testing struct FileDiscoveryStageTests { private let stage = FileDiscoveryStage() - private func makeInput( - sourcesPath: String, - excludePatterns: [String] = [] - ) -> DiscoveryInput { - DiscoveryInput( - projectPath: sourcesPath, - projectType: .xcode(scheme: "Scheme", destination: "platform=macOS"), - timeout: 60, - concurrency: 4, - noCache: false, - sourcesPath: sourcesPath, - excludePatterns: excludePatterns, - operators: [] - ) - } - @Test("Given swift file in directory, when run, then returns it as SourceFile") func findsSwiftFile() throws { let dir = try FileHelpers.makeTemporaryDirectory() @@ -30,7 +14,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "Foo.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Foo.swift")) @@ -44,7 +28,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("hello", named: "README.md", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.isEmpty) } @@ -57,7 +41,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class T {}", named: "FooTests.swift", in: dir) try FileHelpers.write("class S {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -73,7 +57,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class T {}", named: "Foo.swift", in: testsDir) try FileHelpers.write("class S {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -89,7 +73,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "Artifact.swift", in: buildDir) try FileHelpers.write("let y = 2", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -108,7 +92,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "GeneratedAssetSymbols.swift", in: derivedDir) try FileHelpers.write("let y = 2", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -124,7 +108,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "Cached.swift", in: cacheDir) try FileHelpers.write("let y = 2", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -140,7 +124,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "Generated.swift", in: derivedData) try FileHelpers.write("let y = 2", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -154,7 +138,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class M {}", named: "UserMock.swift", in: dir) try FileHelpers.write("class S {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -168,7 +152,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class S {}", named: "UserSpec.swift", in: dir) try FileHelpers.write("class P {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -184,7 +168,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class M {}", named: "UserMock.swift", in: mocksDir) try FileHelpers.write("class S {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -200,7 +184,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class S {}", named: "NetworkStub.swift", in: stubsDir) try FileHelpers.write("class P {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -216,7 +200,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class F {}", named: "ServiceFake.swift", in: fakesDir) try FileHelpers.write("class P {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -232,7 +216,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class H {}", named: "Helper.swift", in: helpersDir) try FileHelpers.write("class P {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -248,7 +232,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("class H {}", named: "Support.swift", in: supportDir) try FileHelpers.write("class P {}", named: "Source.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Source.swift")) @@ -264,7 +248,7 @@ struct FileDiscoveryStageTests { try FileHelpers.write("let x = 1", named: "Model.swift", in: generatedDir) try FileHelpers.write("let y = 2", named: "Source.swift", in: dir) - let input = makeInput(sourcesPath: dir.path, excludePatterns: ["/Generated/"]) + let input = makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path, excludePatterns: ["/Generated/"]) let result = try stage.run(input: input) #expect(result.count == 1) @@ -281,7 +265,7 @@ struct FileDiscoveryStageTests { try Data(invalidBytes).write(to: invalidFile) try FileHelpers.write("let x = 1", named: "Valid.swift", in: dir) - let result = try stage.run(input: makeInput(sourcesPath: dir.path)) + let result = try stage.run(input: makeDiscoveryInput(projectPath: dir.path, sourcesPath: dir.path)) #expect(result.count == 1) #expect(result[0].path.hasSuffix("Valid.swift")) @@ -289,7 +273,8 @@ struct FileDiscoveryStageTests { @Test("Given non-existent sources path, when run, then throws sourcesPathNotFound") func throwsWhenPathNotFound() { - let input = makeInput(sourcesPath: "/nonexistent/path/that/does/not/exist") + let input = makeDiscoveryInput( + projectPath: "/nonexistent/path/that/does/not/exist", sourcesPath: "/nonexistent/path/that/does/not/exist") #expect(throws: FileDiscoveryError.sourcesPathNotFound("/nonexistent/path/that/does/not/exist")) { try stage.run(input: input) } diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift index 7ade64f..b57a582 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/IncompatibleRewritingStageTests.swift @@ -9,7 +9,7 @@ struct IncompatibleRewritingStageTests { @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 indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let descriptors = stage.run(indexed: indexed, sources: [source]) #expect(!descriptors.isEmpty) #expect(descriptors.allSatisfy { !$0.isSchematizable }) @@ -19,7 +19,7 @@ struct IncompatibleRewritingStageTests { @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 indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let descriptors = stage.run(indexed: indexed, sources: [source]) let content = try #require(descriptors.first?.mutatedSourceContent) #expect(content.contains("false")) @@ -28,7 +28,7 @@ struct IncompatibleRewritingStageTests { @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 indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let descriptors = stage.run(indexed: indexed, sources: [source]) #expect(descriptors.isEmpty) } @@ -36,7 +36,7 @@ struct IncompatibleRewritingStageTests { @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 indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let descriptors = stage.run(indexed: indexed, sources: []) #expect(descriptors.isEmpty) } @@ -48,8 +48,4 @@ struct IncompatibleRewritingStageTests { #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/SchematizationStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift index 9133cd0..07433eb 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Pipeline/SchematizationStageTests.swift @@ -9,7 +9,7 @@ struct SchematizationStageTests { @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 indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let (files, _) = stage.run(indexed: indexed, sources: [source]) #expect(files.count == 1) #expect(files[0].originalPath == "a.swift") @@ -18,7 +18,7 @@ struct SchematizationStageTests { @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 indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let (_, descriptors) = stage.run(indexed: indexed, sources: [source]) #expect(descriptors[0].isSchematizable) #expect(descriptors[0].mutatedSourceContent == nil) @@ -27,7 +27,7 @@ struct SchematizationStageTests { @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 indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let (files, _) = stage.run(indexed: indexed, sources: [source]) #expect(!files[0].schematizedContent.contains("var __swiftMutationTestingID")) } @@ -41,7 +41,7 @@ struct SchematizationStageTests { @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 indexed = makeIndexed(source: source, operators: [BooleanLiteralReplacement()]) + let indexed = makeIndexedMutationPoints(source: source, operators: [BooleanLiteralReplacement()]) let (files, descriptors) = stage.run(indexed: indexed, sources: []) #expect(files.isEmpty) #expect(descriptors.isEmpty) @@ -55,8 +55,4 @@ struct SchematizationStageTests { #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/Schematization/SchemataGeneratorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/SchemataGeneratorTests.swift index faab3f6..bc00db9 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/SchemataGeneratorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/SchemataGeneratorTests.swift @@ -7,14 +7,6 @@ import Testing struct SchemataGeneratorTests { private let generator = SchemataGenerator() - private func mutationsWithIndices( - _ source: ParsedSource, - op: any MutationOperator = BooleanLiteralReplacement(), - startIndex: Int = 0 - ) -> [(index: Int, point: MutationPoint)] { - op.mutations(in: source).enumerated().map { (index: startIndex + $0.offset, point: $0.element) } - } - @Test("Given one mutation, when generated, then produces switch with one case and default") func oneMutationProducesSwitchWithOneCaseAndDefault() { let source = makeParsedSource("func f() { let x = true }") diff --git a/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/TypeScopeVisitorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/TypeScopeVisitorTests.swift index c4b9090..e8cdcdf 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/TypeScopeVisitorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Discovery/Schematization/TypeScopeVisitorTests.swift @@ -4,41 +4,34 @@ import Testing @Suite("TypeScopeVisitor") struct TypeScopeVisitorTests { - private func makeVisitor(_ code: String) -> TypeScopeVisitor { - let source = makeParsedSource(code) - let visitor = TypeScopeVisitor() - visitor.walk(source.syntax) - return visitor - } - @Test("Given function with body, when walked, then records one scope") func functionWithBodyRecordsOneScope() { - let visitor = makeVisitor("func f() { let x = 1 }") + let visitor = makeTypeScopeVisitor("func f() { let x = 1 }") #expect(visitor.scopes.count == 1) } @Test("Given protocol function requirement without body, when walked, then records no scope") func functionWithoutBodyRecordsNoScope() { - let visitor = makeVisitor("protocol P { func f() }") + let visitor = makeTypeScopeVisitor("protocol P { func f() }") #expect(visitor.scopes.isEmpty) } @Test("Given two functions, when walked, then records two scopes") func twoFunctionsRecordTwoScopes() { - let visitor = makeVisitor("func f() { } func g() { }") + let visitor = makeTypeScopeVisitor("func f() { } func g() { }") #expect(visitor.scopes.count == 2) } @Test("Given nested function, when walked, then records two scopes") func nestedFunctionRecordsTwoScopes() { let code = "func outer() { func inner() { let x = 1 } }" - let visitor = makeVisitor(code) + let visitor = makeTypeScopeVisitor(code) #expect(visitor.scopes.count == 2) } @Test("Given initializer with body, when walked, then records one scope") func initializerRecordsScope() { - let visitor = makeVisitor("struct S { init() { let x = 1 } }") + let visitor = makeTypeScopeVisitor("struct S { init() { let x = 1 } }") #expect(visitor.scopes.count == 1) } @@ -99,7 +92,7 @@ struct TypeScopeVisitorTests { @Test("Given deinitializer with body, when walked, then records one scope") func deinitializerRecordsScope() { - let visitor = makeVisitor("class C { deinit { let x = 1 } }") + let visitor = makeTypeScopeVisitor("class C { deinit { let x = 1 } }") #expect(visitor.scopes.count == 1) } @@ -117,7 +110,7 @@ struct TypeScopeVisitorTests { @Test("Given offset outside all scopes, when innermostScope queried, then returns nil") func innermostScopeReturnsNilForOffsetOutsideAllScopes() { - let visitor = makeVisitor("func f() { let x = 1 }") + let visitor = makeTypeScopeVisitor("func f() { let x = 1 }") #expect(visitor.innermostScope(containing: 99999) == nil) } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift index bd9d420..430051c 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/FallbackExecutorTests.swift @@ -13,59 +13,39 @@ struct FallbackExecutorTests { let sourceFile = dir.appendingPathComponent("Foo.swift") try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) - let config = RunnerConfiguration( - projectPath: dir.path, - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let config = makeRunnerConfiguration(projectPath: dir.path, projectType: .spm) let launcher = MockProcessLauncher(exitCode: 0) - let counter = MutationCounter(total: 1) - let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let deps = ExecutionDeps( + let deps = makeExecutionDeps( launcher: launcher, - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: counter + cacheStorePath: dir.appendingPathComponent("cache.json").path ) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() - let mutant = MutantDescriptor( + let mutant = makeMutantDescriptor( id: "m0", filePath: sourceFile.path, - line: 1, - column: 1, - utf8Offset: 0, originalText: "true", mutatedText: "false", operatorIdentifier: "BooleanLiteralReplacement", replacementKind: .booleanLiteral, description: "true → false", - isSchematizable: true, - mutatedSourceContent: nil + isSchematizable: true ) - let input = RunnerInput( + let input = makeRunnerInput( projectPath: dir.path, projectType: .spm, - timeout: 60, - concurrency: 1, - noCache: false, schematizedFiles: [ SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false") ], - supportFileContent: "", mutants: [mutant] ) let executor = FallbackExecutor(deps: deps, configuration: config) - let results = try await executor.execute(input: input, pool: pool, testFilesHash: "hash") + let results = try await executor.execute(input: input, pool: pool) #expect(results.count == 1) } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift index 60f921e..8a1042b 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/IncompatibleMutantExecutorTests.swift @@ -10,17 +10,25 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let executor = makeExecutor(in: dir, exitCode: 1) - let pool = makePool() + let executor = makeIncompatibleMutantExecutor(in: dir, exitCode: 1) + let pool = makeSimulatorPool() try await pool.setUp() - let mutants = (0 ..< 3).map { makeMutant(id: "m\($0)", content: "let x = \($0)") } + let mutants = (0 ..< 3).map { + makeMutantDescriptor( + id: "m\($0)", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = \($0)" + ) + } let results = try await executor.execute( mutants, - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) #expect(results.count == 3) @@ -32,17 +40,23 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let executor = makeExecutor(in: dir, exitCode: 0) - let pool = makePool() + let executor = makeIncompatibleMutantExecutor(in: dir, exitCode: 0) + let pool = makeSimulatorPool() try await pool.setUp() - let mutant = makeMutant(id: "m0", content: nil) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: nil + ) let results = try await executor.execute( [mutant], - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) #expect(results.first?.status == .unviable) @@ -53,17 +67,23 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let executor = makeExecutor(in: dir, exitCode: 1) - let pool = makePool() + let executor = makeIncompatibleMutantExecutor(in: dir, exitCode: 1) + let pool = makeSimulatorPool() try await pool.setUp() - let mutant = makeMutant(id: "m0", content: "let x = 1") + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) let results = try await executor.execute( [mutant], - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) #expect(results.first?.status == .unviable) @@ -75,49 +95,49 @@ struct IncompatibleMutantExecutorTests { defer { FileHelpers.cleanup(dir) } let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() - let mutant = makeMutant(id: "m0", content: "let x = 1") + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) let firstExecutor = IncompatibleMutantExecutor( deps: ExecutionDeps( launcher: MockProcessLauncher(exitCode: 1), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ), sandboxFactory: SandboxFactory() ) _ = try await firstExecutor.execute( [mutant], - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) - let noCacheConfig = RunnerConfiguration( - projectPath: dir.path, - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: true), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let noCacheConfig = makeRunnerConfiguration(projectPath: dir.path, noCache: true) let secondExecutor = IncompatibleMutantExecutor( deps: ExecutionDeps( launcher: MockProcessLauncher(exitCode: 1), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ), sandboxFactory: SandboxFactory() ) let results = try await secondExecutor.execute( [mutant], configuration: noCacheConfig, - pool: pool, - testFilesHash: "hash" + pool: pool ) #expect(results.first?.status == .unviable) @@ -128,31 +148,30 @@ struct IncompatibleMutantExecutorTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() - let config = RunnerConfiguration( - projectPath: dir.path, - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - testTarget: "AppTests", timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let config = makeRunnerConfiguration(projectPath: dir.path, testTarget: "AppTests") let executor = IncompatibleMutantExecutor( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: MockProcessLauncher(exitCode: 1), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ), sandboxFactory: SandboxFactory() ) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) + let results = try await executor.execute( - [makeMutant(id: "m0", content: "let x = 1")], + [mutant], configuration: config, - pool: pool, - testFilesHash: "hash" + pool: pool ) #expect(results.count == 1) } @@ -163,25 +182,32 @@ struct IncompatibleMutantExecutorTests { defer { FileHelpers.cleanup(dir) } let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() - let mutant = makeMutant(id: "m0", content: "let x = 1") + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) let firstExecutor = IncompatibleMutantExecutor( deps: ExecutionDeps( launcher: MockProcessLauncher(exitCode: 1), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ), sandboxFactory: SandboxFactory() ) _ = try await firstExecutor.execute( [mutant], - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) let secondExecutor = IncompatibleMutantExecutor( @@ -189,15 +215,15 @@ struct IncompatibleMutantExecutorTests { launcher: MockProcessLauncher(exitCode: 1), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ), sandboxFactory: SandboxFactory() ) let results = try await secondExecutor.execute( [mutant], - configuration: makeConfiguration(projectPath: "/non/existent/path"), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: "/non/existent/path"), + pool: pool ) #expect(results.first?.status == .unviable) @@ -209,23 +235,29 @@ struct IncompatibleMutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = IncompatibleMutantExecutor( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: MockProcessLauncher(exitCode: 0, throwsOnCapture: true), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ), sandboxFactory: SandboxFactory() ) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) + await #expect(throws: (any Error).self) { try await executor.execute( - [makeMutant(id: "m0", content: "let x = 1")], - configuration: makeConfiguration(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + [mutant], + configuration: makeRunnerConfiguration(projectPath: dir.path), + pool: pool ) } } @@ -238,15 +270,24 @@ struct IncompatibleMutantExecutorTests { let sourceFile = dir.appendingPathComponent("Foo.swift") try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) - let executor = makeExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 0)) - let pool = makePool() + let executor = makeIncompatibleMutantExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 0)) + let pool = makeSimulatorPool() try await pool.setUp() + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) + let results = try await executor.execute( - [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], - configuration: makeConfigurationSPM(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + [mutant], + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), + pool: pool ) #expect(results.first?.status == .survived) @@ -261,16 +302,25 @@ struct IncompatibleMutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let output = #"Test "myTest" failed after 0.001 seconds."# - let executor = makeExecutorSPM( + let executor = makeIncompatibleMutantExecutorSPM( in: dir, launcher: SPMBuildSuccessTestFailureMock(failureOutput: output)) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" + ) + let results = try await executor.execute( - [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], - configuration: makeConfigurationSPM(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + [mutant], + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), + pool: pool ) #expect(results.first?.status == .killed(by: "myTest")) @@ -284,20 +334,35 @@ struct IncompatibleMutantExecutorTests { let sourceFile = dir.appendingPathComponent("Foo.swift") try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) - let executor = makeExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 1)) - let pool = makePool() + let executor = makeIncompatibleMutantExecutorSPM(in: dir, launcher: MockProcessLauncher(exitCode: 1)) + let pool = makeSimulatorPool() try await pool.setUp() let mutants = [ - makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = false"), - makeMutant(id: "m1", filePath: sourceFile.path, content: "let x = 0"), + makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = false" + ), + makeMutantDescriptor( + id: "m1", + filePath: sourceFile.path, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 0" + ), ] let results = try await executor.execute( mutants, - configuration: makeConfigurationSPM(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), + pool: pool ) #expect(results.count == 2) @@ -313,23 +378,31 @@ struct IncompatibleMutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let output = #"Test "myTest" failed after 0.001 seconds."# - let executor = makeExecutorSPM( + let executor = makeIncompatibleMutantExecutorSPM( in: dir, launcher: SPMBuildSuccessTestFailureMock(failureOutput: output)) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() - let config = RunnerConfiguration( + let config = makeRunnerConfiguration( projectPath: dir.path, - build: .init(projectType: .spm, testTarget: "FooTests", timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) + projectType: .spm, + testTarget: "FooTests" + ) + + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + mutatedSourceContent: "let x = 1" ) let results = try await executor.execute( - [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = 1")], + [mutant], configuration: config, - pool: pool, - testFilesHash: "hash" + pool: pool ) #expect(results.count == 1) @@ -343,89 +416,27 @@ struct IncompatibleMutantExecutorTests { let sourceFile = dir.appendingPathComponent("Foo.swift") try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) - let executor = makeExecutorSPM( + let executor = makeIncompatibleMutantExecutorSPM( in: dir, launcher: SPMInitialBuildSuccessThenFailMock()) - let pool = makePool() + let pool = makeSimulatorPool() try await pool.setUp() - let results = try await executor.execute( - [makeMutant(id: "m0", filePath: sourceFile.path, content: "let x = INVALID")], - configuration: makeConfigurationSPM(projectPath: dir.path), - pool: pool, - testFilesHash: "hash" - ) - - #expect(results.first?.status == .unviable) - } - - private func makeExecutorSPM( - in dir: URL, - launcher: any ProcessLaunching - ) -> IncompatibleMutantExecutor { - IncompatibleMutantExecutor( - deps: ExecutionDeps( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) - ), - sandboxFactory: SandboxFactory() - ) - } - - private func makeConfigurationSPM(projectPath: String) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) - } - - private func makeExecutor(in dir: URL, exitCode: Int32) -> IncompatibleMutantExecutor { - IncompatibleMutantExecutor( - deps: ExecutionDeps( - launcher: MockProcessLauncher(exitCode: exitCode), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 3) - ), - sandboxFactory: SandboxFactory() - ) - } - - private func makePool() -> SimulatorPool { - SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) - } - - private func makeConfiguration(projectPath: String) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) - } - - private func makeMutant(id: String, filePath: String = "/tmp/Foo.swift", content: String?) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: filePath, - line: 1, - column: 1, - utf8Offset: 0, + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, originalText: "a + b", mutatedText: "a - b", operatorIdentifier: "binaryOperator", - replacementKind: .binaryOperator, description: "Replace + with -", - isSchematizable: false, - mutatedSourceContent: content + mutatedSourceContent: "let x = INVALID" ) + + let results = try await executor.execute( + [mutant], + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), + pool: pool + ) + + #expect(results.first?.status == .unviable) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift index 95b8f26..a176c43 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorCoverageTests.swift @@ -14,12 +14,23 @@ struct MutantExecutorCoverageTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: ThrowingDuringTestMock() ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInputSPM( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] ) @@ -38,12 +49,23 @@ struct MutantExecutorCoverageTests { try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMErrorWithoutLineNumberMock() ) - let mutant = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) - let input = makeInputSPM( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: fooFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], mutants: [mutant] ) @@ -72,16 +94,23 @@ struct MutantExecutorCoverageTests { + "}" let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMSingleCaseExclusionMock() ) - let mutant = makeMutant( + let mutant = makeMutantDescriptor( id: "swift-mutation-testing_0", filePath: fooFile.path, - isSchematizable: true + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [ SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized) ], @@ -92,52 +121,4 @@ struct MutantExecutorCoverageTests { #expect(results.count == 1) } - - private func makeConfigurationSPM(projectPath: String) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) - } - - private func makeInputSPM( - projectPath: String, - schematizedFiles: [SchematizedFile] = [], - mutants: [MutantDescriptor] = [] - ) -> RunnerInput { - RunnerInput( - projectPath: projectPath, - projectType: .spm, - timeout: 60, - concurrency: 1, - noCache: false, - schematizedFiles: schematizedFiles, - supportFileContent: "", - mutants: mutants - ) - } - - private func makeMutant( - id: String, - filePath: String, - isSchematizable: Bool, - mutatedContent: String? = "let x = false" - ) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: filePath, - line: 1, - column: 1, - utf8Offset: 0, - originalText: "true", - mutatedText: "false", - operatorIdentifier: "BooleanLiteralReplacement", - replacementKind: .booleanLiteral, - description: "true → false", - isSchematizable: isSchematizable, - mutatedSourceContent: mutatedContent - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift index 3628858..910fd97 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/MutantExecutorTests.swift @@ -11,10 +11,10 @@ struct MutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let input = makeInput(projectPath: dir.path) + let input = makeRunnerInput(projectPath: dir.path) let results = try await executor.execute(input) @@ -30,11 +30,21 @@ struct MutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInput( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] @@ -52,11 +62,20 @@ struct MutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let mutant = makeMutant(id: "m0", filePath: "/tmp/Foo.swift", isSchematizable: false, mutatedContent: nil) - let input = makeInput(projectPath: dir.path, mutants: [mutant]) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: false, + mutatedSourceContent: nil + ) + let input = makeRunnerInput(projectPath: dir.path, mutants: [mutant]) let results = try await executor.execute(input) @@ -72,17 +91,26 @@ struct MutantExecutorTests { let cacheDir = URL(fileURLWithPath: dir.path).appendingPathComponent(CacheStore.directoryName) try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - let mutant = makeMutant(id: "m0", filePath: "/tmp/Foo.swift", isSchematizable: true) - let cacheKey = MutantCacheKey.make(for: mutant, testFilesHash: TestFilesHasher().hash(projectPath: dir.path)) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let cacheKey = MutantCacheKey.make(for: mutant) let cacheStore = CacheStore(storePath: cacheDir.appendingPathComponent("results.json").path) await cacheStore.store(status: .survived, for: cacheKey) try await cacheStore.persist() let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let input = makeInput(projectPath: dir.path, mutants: [mutant]) + let input = makeRunnerInput(projectPath: dir.path, mutants: [mutant]) let results = try await executor.execute(input) @@ -96,10 +124,10 @@ struct MutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path, noCache: true), + configuration: makeRunnerConfiguration(projectPath: dir.path, noCache: true), launcher: MockProcessLauncher(exitCode: 1) ) - let input = makeInput(projectPath: dir.path) + let input = makeRunnerInput(projectPath: dir.path) let results = try await executor.execute(input) @@ -112,12 +140,12 @@ struct MutantExecutorTests { defer { FileHelpers.cleanup(dir) } let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path, quiet: false), + configuration: makeRunnerConfiguration(projectPath: dir.path, quiet: false), launcher: MockProcessLauncher(exitCode: 1) ) let output = await captureOutput { - _ = try? await executor.execute(makeInput(projectPath: dir.path)) + _ = try? await executor.execute(makeRunnerInput(projectPath: dir.path)) } #expect(output.contains("simulators ready")) @@ -132,11 +160,21 @@ struct MutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: FallbackBuildSucceedingMock() ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInput( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] @@ -159,21 +197,39 @@ struct MutantExecutorTests { let cacheDir = URL(fileURLWithPath: dir.path).appendingPathComponent(CacheStore.directoryName) try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - let schematizableMutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let incompatibleMutant = makeMutant( - id: "m1", filePath: "/tmp/Other.swift", isSchematizable: false, mutatedContent: nil) + let schematizableMutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let incompatibleMutant = makeMutantDescriptor( + id: "m1", + filePath: "/tmp/Other.swift", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: false, + mutatedSourceContent: nil + ) - let testFilesHash = TestFilesHasher().hash(projectPath: dir.path) - let cacheKey = MutantCacheKey.make(for: schematizableMutant, testFilesHash: testFilesHash) + let cacheKey = MutantCacheKey.make(for: schematizableMutant) let cacheStore = CacheStore(storePath: cacheDir.appendingPathComponent("results.json").path) await cacheStore.store(status: .survived, for: cacheKey) try await cacheStore.persist() let executor = MutantExecutor( - configuration: makeConfiguration(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path), launcher: MockProcessLauncher(exitCode: 1) ) - let input = makeInput( + let input = makeRunnerInput( projectPath: dir.path, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [schematizableMutant, incompatibleMutant] @@ -197,12 +253,23 @@ struct MutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: MockProcessLauncher(exitCode: 0) ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInputSPM( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] ) @@ -222,12 +289,23 @@ struct MutantExecutorTests { try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: MockProcessLauncher(exitCode: 1) ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInputSPM( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] ) @@ -249,16 +327,34 @@ struct MutantExecutorTests { try "let y = true".write(to: barFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMRetryExcludingErrorsMock() ) - let mutantFoo = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) - let mutantBar = makeMutant( - id: "m1", filePath: barFile.path, - isSchematizable: true, mutatedContent: "let y = false" + let mutantFoo = makeMutantDescriptor( + id: "m0", + filePath: fooFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let mutantBar = makeMutantDescriptor( + id: "m1", + filePath: barFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let y = false" ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [ SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false"), SchematizedFile(originalPath: barFile.path, schematizedContent: "let y = false"), @@ -283,7 +379,6 @@ struct MutantExecutorTests { let fooFile = dir.appendingPathComponent("Foo.swift") try "let original = true".write(to: fooFile, atomically: true, encoding: .utf8) - // Line 4 is the body of case "swift-mutation-testing_0"; line 6 is case "swift-mutation-testing_1" let schematized = "func foo() {\n" + "switch __swiftMutationTestingID {\n" @@ -297,22 +392,34 @@ struct MutantExecutorTests { + "}" let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMNarrowExclusionMock() ) - let mutantFoo = makeMutant( + let mutantFoo = makeMutantDescriptor( id: "swift-mutation-testing_0", filePath: fooFile.path, - isSchematizable: true + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" ) - let mutantBar = makeMutant( + let mutantBar = makeMutantDescriptor( id: "swift-mutation-testing_1", filePath: fooFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", isSchematizable: true, - mutatedContent: "let y = false" + mutatedSourceContent: "let y = false" ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [ SchematizedFile(originalPath: fooFile.path, schematizedContent: schematized) ], @@ -337,14 +444,13 @@ struct MutantExecutorTests { try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMRetryExcludingErrorsMock() ) - let mutantA = MutantDescriptor( + let mutantA = makeMutantDescriptor( id: "swift-mutation-testing_0", filePath: fooFile.path, - line: 1, column: 9, utf8Offset: 8, originalText: "true", @@ -356,10 +462,9 @@ struct MutantExecutorTests { mutatedSourceContent: nil ) - let mutantB = MutantDescriptor( + let mutantB = makeMutantDescriptor( id: "swift-mutation-testing_1", filePath: fooFile.path, - line: 1, column: 9, utf8Offset: 8, originalText: "true", @@ -371,8 +476,9 @@ struct MutantExecutorTests { mutatedSourceContent: nil ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], mutants: [mutantA, mutantB] ) @@ -390,15 +496,13 @@ struct MutantExecutorTests { try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMRetryExcludingErrorsMock() ) - let badMutant = MutantDescriptor( + let badMutant = makeMutantDescriptor( id: "swift-mutation-testing_0", filePath: fooFile.path, - line: 1, - column: 1, utf8Offset: 9999, originalText: "true", mutatedText: "false", @@ -409,8 +513,9 @@ struct MutantExecutorTests { mutatedSourceContent: nil ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false")], mutants: [badMutant] ) @@ -428,20 +533,30 @@ struct MutantExecutorTests { let sourceFile = dir.appendingPathComponent("Foo.swift") try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) - let config = RunnerConfiguration( + let config = makeRunnerConfiguration( projectPath: dir.path, - build: .init(projectType: .spm, testTarget: "FooTests", timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) + projectType: .spm, + testTarget: "FooTests" ) let executor = MutantExecutor( configuration: config, launcher: MockProcessLauncher(exitCode: 0) ) - let mutant = makeMutant(id: "m0", filePath: sourceFile.path, isSchematizable: true) - let input = makeInputSPM( + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], mutants: [mutant] ) @@ -460,16 +575,34 @@ struct MutantExecutorTests { try "let y = true".write(to: barFile, atomically: true, encoding: .utf8) let executor = MutantExecutor( - configuration: makeConfigurationSPM(projectPath: dir.path), + configuration: makeRunnerConfiguration(projectPath: dir.path, projectType: .spm), launcher: SPMDoubleFailMock() ) - let mutantFoo = makeMutant(id: "swift-mutation-testing_0", filePath: fooFile.path, isSchematizable: true) - let mutantBar = makeMutant( - id: "swift-mutation-testing_1", filePath: barFile.path, - isSchematizable: true, mutatedContent: "let y = false" + let mutantFoo = makeMutantDescriptor( + id: "swift-mutation-testing_0", + filePath: fooFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let mutantBar = makeMutantDescriptor( + id: "swift-mutation-testing_1", + filePath: barFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let y = false" ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, + projectType: .spm, schematizedFiles: [ SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false"), SchematizedFile(originalPath: barFile.path, schematizedContent: "let y = false"), @@ -482,116 +615,191 @@ struct MutantExecutorTests { #expect(results.count == 2) } - @Test("Given excluded mutant with non-existent source file, when rewrite attempted, then marked unviable") - func excludedMutantWithMissingSourceIsUnviable() async throws { + @Test( + "Given all mutants cached and no test files changed, when execute called, then returns from cache without building" + ) + func allCachedWithUnchangedTestFilesReturnsCachedResults() async throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let fooFile = dir.appendingPathComponent("Foo.swift") - try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + let cacheDir = URL(fileURLWithPath: dir.path).appendingPathComponent(CacheStore.directoryName) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let cacheKey = MutantCacheKey.make(for: mutant) + let cacheStore = CacheStore(storePath: cacheDir.appendingPathComponent("results.json").path) + await cacheStore.store(status: .killed(by: "SomeTest"), for: cacheKey, killerTestFile: "Tests/SomeTest.swift") + try await cacheStore.persist() + + let metadata = CacheStore.CacheMetadata(testFileHashes: [:]) + try await cacheStore.persistMetadata(metadata) - let config = makeConfigurationSPM(projectPath: dir.path) let executor = MutantExecutor( - configuration: config, - launcher: SPMRetryExcludingErrorsMock() + configuration: makeRunnerConfiguration(projectPath: dir.path), + launcher: MockProcessLauncher(exitCode: 1) ) + let input = makeRunnerInput(projectPath: dir.path, mutants: [mutant]) - let validMutant = makeMutant(id: "m0", filePath: fooFile.path, isSchematizable: true) - let missingMutant = makeMutant( - id: "m1", - filePath: dir.appendingPathComponent("NonExistent.swift").path, - isSchematizable: true + let results = try await executor.execute(input) + + #expect(results.count == 1) + #expect(results[0].status == .killed(by: "SomeTest")) + #expect(results[0].killerTestFile == "Tests/SomeTest.swift") + } + + @Test( + "Given cached survived mutant and test file changed, when execute called, then invalidation removes entry and falls through" + ) + func invalidationRemovesSurvivedEntryWhenTestFileChanged() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let sourceFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: sourceFile, atomically: true, encoding: .utf8) + + let cacheDir = URL(fileURLWithPath: dir.path).appendingPathComponent(CacheStore.directoryName) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + + let mutant = makeMutantDescriptor( + id: "m0", + filePath: sourceFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let cacheKey = MutantCacheKey.make(for: mutant) + let cacheStore = CacheStore(storePath: cacheDir.appendingPathComponent("results.json").path) + await cacheStore.store(status: .survived, for: cacheKey) + try await cacheStore.persist() + + let metadata = CacheStore.CacheMetadata(testFileHashes: ["Tests/FooTests.swift": "old-hash"]) + try await cacheStore.persistMetadata(metadata) + + let executor = MutantExecutor( + configuration: makeRunnerConfiguration(projectPath: dir.path), + launcher: MockProcessLauncher(exitCode: 1) ) - let input = makeInputSPM( + let input = makeRunnerInput( projectPath: dir.path, - schematizedFiles: [ - SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false") - ], - mutants: [validMutant, missingMutant] + schematizedFiles: [SchematizedFile(originalPath: sourceFile.path, schematizedContent: "let x = false")], + mutants: [mutant] ) let results = try await executor.execute(input) - #expect(results.count == 2) + #expect(results.count == 1) + #expect(results[0].status == .unviable) } - private func makeConfiguration( - projectPath: String, - noCache: Bool = false, - quiet: Bool = true - ) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: noCache), - reporting: .init(quiet: quiet), - filter: .init(excludePatterns: [], operators: []) - ) - } + @Test("Given all mutants cached, when execute called with quiet false, then reporter shows loaded from cache count") + func allCachedReportsCorrectCount() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let cacheDir = URL(fileURLWithPath: dir.path).appendingPathComponent(CacheStore.directoryName) + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) - private func makeConfigurationSPM(projectPath: String) -> RunnerConfiguration { - RunnerConfiguration( - projectPath: projectPath, - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) + let mutantA = makeMutantDescriptor( + id: "m0", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" ) - } + let mutantB = makeMutantDescriptor( + id: "m1", + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let y = false" + ) + let cacheStore = CacheStore(storePath: cacheDir.appendingPathComponent("results.json").path) + await cacheStore.store(status: .survived, for: MutantCacheKey.make(for: mutantA)) + await cacheStore.store(status: .killed(by: "T"), for: MutantCacheKey.make(for: mutantB)) + try await cacheStore.persist() + + let metadata = CacheStore.CacheMetadata(testFileHashes: [:]) + try await cacheStore.persistMetadata(metadata) - private func makeInput( - projectPath: String, - schematizedFiles: [SchematizedFile] = [], - mutants: [MutantDescriptor] = [] - ) -> RunnerInput { - RunnerInput( - projectPath: projectPath, - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, - concurrency: 1, - noCache: false, - schematizedFiles: schematizedFiles, - supportFileContent: "", - mutants: mutants + let executor = MutantExecutor( + configuration: makeRunnerConfiguration(projectPath: dir.path, quiet: false), + launcher: MockProcessLauncher(exitCode: 1) ) + let input = makeRunnerInput(projectPath: dir.path, mutants: [mutantA, mutantB]) + + let output = await captureOutput { + _ = try? await executor.execute(input) + } + + #expect(output.contains("Loaded 2 mutants from cache")) } - private func makeInputSPM( - projectPath: String, - schematizedFiles: [SchematizedFile] = [], - mutants: [MutantDescriptor] = [] - ) -> RunnerInput { - RunnerInput( - projectPath: projectPath, - projectType: .spm, - timeout: 60, - concurrency: 1, - noCache: false, - schematizedFiles: schematizedFiles, - supportFileContent: "", - mutants: mutants + @Test("Given excluded mutant with non-existent source file, when rewrite attempted, then marked unviable") + func excludedMutantWithMissingSourceIsUnviable() async throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } + + let fooFile = dir.appendingPathComponent("Foo.swift") + try "let x = true".write(to: fooFile, atomically: true, encoding: .utf8) + + let config = makeRunnerConfiguration(projectPath: dir.path, projectType: .spm) + let executor = MutantExecutor( + configuration: config, + launcher: SPMRetryExcludingErrorsMock() ) - } - private func makeMutant( - id: String, - filePath: String, - isSchematizable: Bool, - mutatedContent: String? = "let x = false" - ) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: filePath, - line: 1, - column: 1, - utf8Offset: 0, + let validMutant = makeMutantDescriptor( + id: "m0", + filePath: fooFile.path, + originalText: "true", + mutatedText: "false", + operatorIdentifier: "BooleanLiteralReplacement", + replacementKind: .booleanLiteral, + description: "true → false", + isSchematizable: true, + mutatedSourceContent: "let x = false" + ) + let missingMutant = makeMutantDescriptor( + id: "m1", + filePath: dir.appendingPathComponent("NonExistent.swift").path, originalText: "true", mutatedText: "false", operatorIdentifier: "BooleanLiteralReplacement", replacementKind: .booleanLiteral, description: "true → false", - isSchematizable: isSchematizable, - mutatedSourceContent: mutatedContent + isSchematizable: true, + mutatedSourceContent: "let x = false" ) + let input = makeRunnerInput( + projectPath: dir.path, + projectType: .spm, + schematizedFiles: [ + SchematizedFile(originalPath: fooFile.path, schematizedContent: "let x = false") + ], + mutants: [validMutant, missingMutant] + ) + + let results = try await executor.execute(input) + + #expect(results.count == 2) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift index d245382..926c2eb 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/Parsing/TestOutputParserTests.swift @@ -94,7 +94,7 @@ struct TestOutputParserTests { @Test("Given SPM swift test output with fatal error, when parsed, then returns crashed") func parsesSPMFatalErrorCrash() throws { - let output = try loadFixture("spm_xctest_fatal_error") + let output = try loadTestFixture("spm_xctest_fatal_error") let result = TestOutputParser().parse(output) #expect(result == .crashed) @@ -102,19 +102,9 @@ struct TestOutputParserTests { @Test("Given SPM swift test output with EXC_BAD_INSTRUCTION, when parsed, then returns crashed") func parsesSPMEXCBadInstructionCrash() throws { - let output = try loadFixture("spm_xctest_exc_bad_instruction") + let output = try loadTestFixture("spm_xctest_exc_bad_instruction") let result = TestOutputParser().parse(output) #expect(result == .crashed) } - - private func loadFixture(_ name: String) throws -> String { - let fixturesURL = URL(filePath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "TestSupport/Fixtures/\(name).txt") - return try String(contentsOf: fixturesURL, encoding: .utf8) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift index cadc9d8..187f5d7 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Execution/TestExecutionStageTests.swift @@ -10,10 +10,19 @@ struct TestExecutionStageTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let (stage, context) = makeFixture(in: dir, exitCode: 0) + let (stage, context) = makeTestExecutionFixture(in: dir, exitCode: 0) try await context.pool.setUp() - let mutants = (0 ..< 3).map { makeMutant(id: "m\($0)") } + let mutants = (0 ..< 3).map { + makeMutantDescriptor( + id: "m\($0)", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + } let results = try await stage.execute(mutants: mutants, in: context) @@ -26,18 +35,23 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: makeConfiguration(), - testFilesHash: "hash" + configuration: makeRunnerConfiguration() + ) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true ) let successStage = TestExecutionStage( @@ -45,20 +59,22 @@ struct TestExecutionStageTests { launcher: MockProcessLauncher(exitCode: 0), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ) ) - _ = try await successStage.execute(mutants: [makeMutant(id: "m0")], in: context) + _ = try await successStage.execute(mutants: [mutant], in: context) let failStage = TestExecutionStage( deps: ExecutionDeps( launcher: MockProcessLauncher(exitCode: 1), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ) ) - let results = try await failStage.execute(mutants: [makeMutant(id: "m0")], in: context) + let results = try await failStage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .survived) } @@ -68,10 +84,19 @@ struct TestExecutionStageTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let (stage, context) = makeFixture(in: dir, exitCode: 0) + let (stage, context) = makeTestExecutionFixture(in: dir, exitCode: 0) try await context.pool.setUp() - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .survived) } @@ -82,37 +107,33 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let cacheStore = CacheStore(storePath: dir.appendingPathComponent("cache.json").path) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() - let noCacheConfig = RunnerConfiguration( - projectPath: "/tmp", - build: .init( - projectType: .xcode(scheme: "S", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: true), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let noCacheConfig = makeRunnerConfiguration(noCache: true) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: noCacheConfig, - testFilesHash: "hash" + configuration: noCacheConfig + ) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true ) let survivedStage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: MockProcessLauncher(exitCode: 0), - cacheStore: cacheStore, - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) - _ = try await survivedStage.execute(mutants: [makeMutant(id: "m0")], in: context) + _ = try await survivedStage.execute(mutants: [mutant], in: context) let killedStage = TestExecutionStage( deps: ExecutionDeps( @@ -122,10 +143,11 @@ struct TestExecutionStageTests { ), cacheStore: cacheStore, reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + counter: MutationCounter(total: 1), + killerTestFileResolver: KillerTestFileResolver(testFilePaths: []) ) ) - let results = try await killedStage.execute(mutants: [makeMutant(id: "m0")], in: context) + let results = try await killedStage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .killed(by: "S.t")) } @@ -136,36 +158,32 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let launcher = MockProcessLauncher(exitCode: 0) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: launcher - ) + let pool = makeSimulatorPool(launcher: launcher) try await pool.setUp() - let config = RunnerConfiguration( - projectPath: "/tmp", - build: .init( - projectType: .xcode(scheme: "S", destination: "platform=macOS"), - testTarget: "AppTests", timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let config = makeRunnerConfiguration(testTarget: "AppTests") let stage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: config, - testFilesHash: "hash" + configuration: config ) - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.count == 1) } @@ -175,10 +193,19 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let output = "Test Case '-[MySuite myTest]' failed (0.001 seconds)." - let (stage, context) = makeFixture(in: dir, exitCode: 1, output: output) + let (stage, context) = makeTestExecutionFixture(in: dir, exitCode: 1, output: output) try await context.pool.setUp() - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .killed(by: "MySuite.myTest")) } @@ -189,30 +216,33 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let launcher = MockProcessLauncher(exitCode: 0, throwsOnCapture: true) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() let stage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) let context = TestExecutionContext( artifact: makeBuildArtifact(in: dir), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: makeConfiguration(), - testFilesHash: "hash" + configuration: makeRunnerConfiguration() + ) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true ) await #expect(throws: (any Error).self) { - try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + try await stage.execute(mutants: [mutant], in: context) } } @@ -221,10 +251,19 @@ struct TestExecutionStageTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let (stage, context) = makeSPMFixture(in: dir, exitCode: 0) + let (stage, context) = makeTestExecutionSPMFixture(in: dir, exitCode: 0) try await context.pool.setUp() - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .survived) } @@ -235,10 +274,19 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let output = #"Test "myTest" failed after 0.001 seconds."# - let (stage, context) = makeSPMFixture(in: dir, exitCode: 1, output: output) + let (stage, context) = makeTestExecutionSPMFixture(in: dir, exitCode: 1, output: output) try await context.pool.setUp() - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true + ) + + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.first?.status == .killed(by: "myTest")) } @@ -248,36 +296,32 @@ struct TestExecutionStageTests { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() - let config = RunnerConfiguration( - projectPath: "/tmp", - build: .init( - projectType: .spm, testTarget: "MyLibTests", - timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let config = makeRunnerConfiguration(projectType: .spm, testTarget: "MyLibTests") let stage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: MockProcessLauncher(exitCode: 0), - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) let context = TestExecutionContext( artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: config, - testFilesHash: "hash" + configuration: config + ) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true ) - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + let results = try await stage.execute(mutants: [mutant], in: context) #expect(results.count == 1) #expect(results.first?.status == .survived) @@ -291,36 +335,34 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let launcher = MockProcessLauncher(exitCode: 0, throwsOnCapture: true) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: MockProcessLauncher(exitCode: 0) - ) + let pool = makeSimulatorPool() try await pool.setUp() let stage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) - let config = RunnerConfiguration( - projectPath: "/tmp", - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) + let config = makeRunnerConfiguration(projectType: .spm) let context = TestExecutionContext( artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: config, - testFilesHash: "hash" + configuration: config + ) + + let mutant = makeMutantDescriptor( + id: "m0", + originalText: "a + b", + mutatedText: "a - b", + operatorIdentifier: "binaryOperator", + description: "Replace + with -", + isSchematizable: true ) await #expect(throws: (any Error).self) { - try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) + try await stage.execute(mutants: [mutant], in: context) } } @@ -330,10 +372,7 @@ struct TestExecutionStageTests { defer { FileHelpers.cleanup(dir) } let launcher = MockProcessLauncher(exitCode: 0) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: launcher - ) + let pool = makeSimulatorPool(launcher: launcher) try await pool.setUp() let plistDict: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] @@ -341,122 +380,29 @@ struct TestExecutionStageTests { let plist = try #require(XCTestRunPlist(data)) let stage = TestExecutionStage( - deps: ExecutionDeps( + deps: makeExecutionDeps( launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) + cacheStorePath: dir.appendingPathComponent("cache.json").path ) ) let context = TestExecutionContext( artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: plist), sandbox: Sandbox(rootURL: dir), pool: pool, - configuration: makeConfiguration(), - testFilesHash: "hash" - ) - - let results = try await stage.execute(mutants: [makeMutant(id: "m0")], in: context) - - #expect(results.count == 1) - } - - private func makeFixture( - in dir: URL, - exitCode: Int32, - output: String = "" - ) -> (TestExecutionStage, TestExecutionContext) { - let launcher = MockProcessLauncher(exitCode: exitCode, output: output) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: launcher - ) - let stage = TestExecutionStage( - 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), - sandbox: Sandbox(rootURL: dir), - pool: pool, - configuration: makeConfiguration(), - testFilesHash: "hash" - ) - return (stage, context) - } - - private func makeSPMFixture( - in dir: URL, - exitCode: Int32, - output: String = "" - ) -> (TestExecutionStage, TestExecutionContext) { - let launcher = MockProcessLauncher(exitCode: exitCode, output: output) - let pool = SimulatorPool( - baseUDID: nil, size: 1, - destination: "platform=macOS", launcher: launcher - ) - let stage = TestExecutionStage( - deps: ExecutionDeps( - launcher: launcher, - cacheStore: CacheStore(storePath: dir.appendingPathComponent("cache.json").path), - reporter: MockProgressReporter(), - counter: MutationCounter(total: 1) - ) - ) - let config = RunnerConfiguration( - projectPath: "/tmp", - build: .init(projectType: .spm, timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) - ) - let context = TestExecutionContext( - artifact: BuildArtifact(derivedDataPath: dir.path, xctestrunURL: nil, plist: nil), - sandbox: Sandbox(rootURL: dir), - pool: pool, - configuration: config, - testFilesHash: "hash" - ) - return (stage, context) - } - - private func makeBuildArtifact(in dir: URL) -> BuildArtifact { - let plistDict: [String: Any] = ["MyTarget": ["EnvironmentVariables": [String: String]()]] - let data = try! PropertyListSerialization.data( - fromPropertyList: plistDict, format: .xml, options: 0 - ) - let plist = XCTestRunPlist(data)! - return BuildArtifact(derivedDataPath: dir.path, xctestrunURL: dir, plist: plist) - } - - private func makeConfiguration() -> RunnerConfiguration { - RunnerConfiguration( - projectPath: "/tmp", - build: .init( - projectType: .xcode(scheme: "MyScheme", destination: "platform=macOS"), - timeout: 60, concurrency: 1, noCache: false), - reporting: .init(quiet: true), - filter: .init(excludePatterns: [], operators: []) + configuration: makeRunnerConfiguration() ) - } - private func makeMutant(id: String) -> MutantDescriptor { - MutantDescriptor( - id: id, - filePath: "/tmp/Foo.swift", - line: 1, - column: 1, - utf8Offset: 0, + let mutant = makeMutantDescriptor( + id: "m0", originalText: "a + b", mutatedText: "a - b", operatorIdentifier: "binaryOperator", - replacementKind: .binaryOperator, description: "Replace + with -", - isSchematizable: true, - mutatedSourceContent: nil + isSchematizable: true ) + + let results = try await stage.execute(mutants: [mutant], in: context) + + #expect(results.count == 1) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/TestFilesHasherTests.swift b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/TestFilesHasherTests.swift index 5fc6131..c6c4a51 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Infrastructure/TestFilesHasherTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Infrastructure/TestFilesHasherTests.swift @@ -5,68 +5,67 @@ import Testing @Suite("TestFilesHasher") struct TestFilesHasherTests { - @Test("Given a project directory, when hash called, then a 64-character hex string is returned") - func hashReturnsHexString() throws { - let dir = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(dir) } - - let result = TestFilesHasher().hash(projectPath: dir.path) - - #expect(result.count == 64) - #expect(result.allSatisfy { $0.isHexDigit }) - } - @Test("Given the same project directory, when hash called twice, then identical hashes are returned") - func hashIsDeterministic() throws { + @Test("Given multiple test files, when hashPerFile called, then one entry per test file is returned") + func hashPerFileReturnsOneEntryPerTestFile() throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } let testsDir = dir.appendingPathComponent("Tests") try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) - try FileHelpers.write("let x = 1", named: "FooTests.swift", in: testsDir) + try FileHelpers.write("let a = 1", named: "FooTests.swift", in: testsDir) + try FileHelpers.write("let b = 2", named: "BarTests.swift", in: testsDir) - let hasher = TestFilesHasher() - #expect(hasher.hash(projectPath: dir.path) == hasher.hash(projectPath: dir.path)) + let result = TestFilesHasher().hashPerFile(projectPath: dir.path) + + #expect(result.count == 2) + #expect(result.keys.contains("Tests/FooTests.swift")) + #expect(result.keys.contains("Tests/BarTests.swift")) } - @Test("Given a test file is modified, when hash called, then a different hash is returned") - func hashChangesWhenTestFileContentChanges() throws { + @Test("Given a test file is modified, when hashPerFile called, then only that file's hash changes") + func hashPerFileChangesOnlyForModifiedFile() throws { let dir = try FileHelpers.makeTemporaryDirectory() defer { FileHelpers.cleanup(dir) } let testsDir = dir.appendingPathComponent("Tests") try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) - let testFile = testsDir.appendingPathComponent("FooTests.swift") + try FileHelpers.write("let a = 1", named: "FooTests.swift", in: testsDir) + try FileHelpers.write("let b = 2", named: "BarTests.swift", in: testsDir) - try "let x = 1".write(to: testFile, atomically: true, encoding: .utf8) - let hashBefore = TestFilesHasher().hash(projectPath: dir.path) + let before = TestFilesHasher().hashPerFile(projectPath: dir.path) - try "let x = 2".write(to: testFile, atomically: true, encoding: .utf8) - let hashAfter = TestFilesHasher().hash(projectPath: dir.path) + try FileHelpers.write("let a = 999", named: "FooTests.swift", in: testsDir) - #expect(hashBefore != hashAfter) - } + let after = TestFilesHasher().hashPerFile(projectPath: dir.path) - @Test("Given non-existent project path, when hash called, then returns a valid hash for empty content") - func hashReturnsValidHashForNonExistentPath() { - let result = TestFilesHasher().hash(projectPath: "/nonexistent/path/xyz") - #expect(result.count == 64) - #expect(result.allSatisfy { $0.isHexDigit }) + #expect(before["Tests/FooTests.swift"] != after["Tests/FooTests.swift"]) + #expect(before["Tests/BarTests.swift"] == after["Tests/BarTests.swift"]) } - @Test("Given only non-test swift files exist, when hash called, then same hash as empty project") - func hashIgnoresNonTestSwiftFiles() throws { - let empty = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(empty) } - - let withSources = try FileHelpers.makeTemporaryDirectory() - defer { FileHelpers.cleanup(withSources) } + @Test("Given non-test files exist, when hashPerFile called, then they are excluded") + func hashPerFileExcludesNonTestFiles() throws { + let dir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(dir) } - let sourcesDir = withSources.appendingPathComponent("Sources") + let sourcesDir = dir.appendingPathComponent("Sources") try FileManager.default.createDirectory(at: sourcesDir, withIntermediateDirectories: true) try FileHelpers.write("let x = 1", named: "Foo.swift", in: sourcesDir) - let hasher = TestFilesHasher() - #expect(hasher.hash(projectPath: empty.path) == hasher.hash(projectPath: withSources.path)) + let testsDir = dir.appendingPathComponent("Tests") + try FileManager.default.createDirectory(at: testsDir, withIntermediateDirectories: true) + try FileHelpers.write("let t = 1", named: "FooTests.swift", in: testsDir) + + let result = TestFilesHasher().hashPerFile(projectPath: dir.path) + + #expect(result.count == 1) + #expect(result.keys.contains("Tests/FooTests.swift")) + } + + @Test("Given non-existent path, when hashPerFile called, then empty map is returned") + func hashPerFileReturnsEmptyForNonExistentPath() { + let result = TestFilesHasher().hashPerFile(projectPath: "/nonexistent/path/xyz") + + #expect(result.isEmpty) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/HtmlReporterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/HtmlReporterTests.swift index 8e6fd87..dd1c1ac 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/HtmlReporterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/HtmlReporterTests.swift @@ -14,7 +14,10 @@ struct HtmlReporterTests { let reporter = HtmlReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t"))], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .killed(by: "t")) + ], totalDuration: 1 ) @@ -34,7 +37,10 @@ struct HtmlReporterTests { let reporter = HtmlReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .survived) + ], totalDuration: 0 ) @@ -55,8 +61,10 @@ struct HtmlReporterTests { let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t")), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .killed(by: "t")), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .survived), ], totalDuration: 0 ) @@ -76,7 +84,10 @@ struct HtmlReporterTests { let outputPath = dir.appendingPathComponent("report.html").path let reporter = HtmlReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t"))], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .killed(by: "t")) + ], totalDuration: 0 ) @@ -96,8 +107,10 @@ struct HtmlReporterTests { let reporter = HtmlReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t")), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .killed(by: "t")), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .survived), ], totalDuration: 0 ) @@ -117,9 +130,12 @@ struct HtmlReporterTests { let reporter = HtmlReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t")), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 10, status: .killed(by: "t")), ], totalDuration: 0 ) @@ -140,8 +156,10 @@ struct HtmlReporterTests { let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", line: 20, status: .survived), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", line: 5, status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 20, column: 10, status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 5, column: 10, status: .survived), ], totalDuration: 0 ) @@ -155,29 +173,4 @@ struct HtmlReporterTests { #expect(line5Index != nil && line20Index != nil) #expect(line5Index! < line20Index!) } - - private func makeResult(filePath: String, status: ExecutionStatus) -> ExecutionResult { - makeResult(filePath: filePath, line: 3, status: status) - } - - private func makeResult(filePath: String, line: Int, status: ExecutionStatus) -> ExecutionResult { - ExecutionResult( - descriptor: MutantDescriptor( - id: "1", - filePath: filePath, - line: line, - column: 10, - utf8Offset: 0, - originalText: "+", - mutatedText: "-", - operatorIdentifier: "ArithmeticOperatorReplacement", - replacementKind: .binaryOperator, - description: "+ → -", - isSchematizable: false, - mutatedSourceContent: nil - ), - status: status, - testDuration: 0 - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/JsonReporterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/JsonReporterTests.swift index 0f73c64..4b21868 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/JsonReporterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/JsonReporterTests.swift @@ -15,7 +15,11 @@ struct JsonReporterTests { let reporter = JsonReporter(outputPath: outputPath, projectRoot: projectRoot) let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "Suite.test"))], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, + status: .killed(by: "Suite.test")) + ], totalDuration: 5 ) @@ -38,7 +42,10 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t"))], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .killed(by: "t")) + ], totalDuration: 0 ) @@ -61,7 +68,10 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -83,7 +93,11 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "MySuite.myTest"))], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, + status: .killed(by: "MySuite.myTest")) + ], totalDuration: 0 ) @@ -106,7 +120,10 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -130,7 +147,10 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -153,7 +173,10 @@ struct JsonReporterTests { let outputPath = dir.appendingPathComponent("mutation.json").path let reporter = JsonReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -173,25 +196,4 @@ struct JsonReporterTests { #expect(endColumn == startColumn + "+".count) } - - private func makeResult(filePath: String, status: ExecutionStatus) -> ExecutionResult { - ExecutionResult( - descriptor: MutantDescriptor( - id: "1", - filePath: filePath, - line: 3, - column: 24, - utf8Offset: 0, - originalText: "+", - mutatedText: "-", - operatorIdentifier: "ArithmeticOperatorReplacement", - replacementKind: .binaryOperator, - description: "+ → -", - isSchematizable: false, - mutatedSourceContent: nil - ), - status: status, - testDuration: 0 - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/RunnerSummaryTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/RunnerSummaryTests.swift index 1c6db8a..3c9fd0c 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/RunnerSummaryTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/RunnerSummaryTests.swift @@ -6,7 +6,17 @@ import Testing struct RunnerSummaryTests { @Test("Given results with all statuses, when properties accessed, then killed includes crashes") func groupsPartitionResultsByStatus() { - let summary = RunnerSummary(results: makeResults(), totalDuration: 10) + let summary = RunnerSummary( + results: [ + makeExecutionResult(status: .killed(by: "Suite.test")), + makeExecutionResult(status: .killedByCrash), + makeExecutionResult(status: .survived), + makeExecutionResult(status: .unviable), + makeExecutionResult(status: .timeout), + makeExecutionResult(status: .noCoverage), + ], + totalDuration: 10 + ) #expect(summary.killed.count == 2) #expect(summary.survived.count == 1) @@ -18,11 +28,11 @@ struct RunnerSummaryTests { @Test("Given killed and survived mutants, when score computed, then unviable is excluded from denominator") func scoreExcludesUnviableFromDenominator() { let results = [ - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .survived), - makeResult(status: .unviable), + makeExecutionResult(status: .killed(by: "Suite.test")), + makeExecutionResult(status: .killed(by: "Suite.test")), + makeExecutionResult(status: .killed(by: "Suite.test")), + makeExecutionResult(status: .survived), + makeExecutionResult(status: .unviable), ] let summary = RunnerSummary(results: results, totalDuration: 0) @@ -31,7 +41,7 @@ struct RunnerSummaryTests { @Test("Given no scoreable mutants, when score computed, then score is 100") func scoreIsHundredWhenDenominatorIsZero() { - let summary = RunnerSummary(results: [makeResult(status: .unviable)], totalDuration: 0) + let summary = RunnerSummary(results: [makeExecutionResult(status: .unviable)], totalDuration: 0) #expect(summary.score == 100.0) } @@ -39,9 +49,9 @@ struct RunnerSummaryTests { @Test("Given results from two files, when resultsByFile accessed, then results are grouped by file path") func resultsByFileGroupsByFilePath() { let results = [ - makeResult(filePath: "/a/Foo.swift", status: .survived), - makeResult(filePath: "/a/Foo.swift", status: .killed(by: "t")), - makeResult(filePath: "/a/Bar.swift", status: .survived), + makeExecutionResult(filePath: "/a/Foo.swift", status: .survived), + makeExecutionResult(filePath: "/a/Foo.swift", status: .killed(by: "t")), + makeExecutionResult(filePath: "/a/Bar.swift", status: .survived), ] let summary = RunnerSummary(results: results, totalDuration: 0) @@ -49,35 +59,20 @@ struct RunnerSummaryTests { #expect(summary.resultsByFile["/a/Bar.swift"]?.count == 1) } - private func makeResults() -> [ExecutionResult] { - [ - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .killedByCrash), - makeResult(status: .survived), - makeResult(status: .unviable), - makeResult(status: .timeout), - makeResult(status: .noCoverage), - ] - } + @Test("Given mixed cached and fresh results, when score computed, then score reflects combined state") + func scoreFromMixedCachedAndFreshResults() { + let cachedKilled = makeExecutionResult(status: .killed(by: "T1")) + let cachedSurvived = makeExecutionResult(status: .survived) + let freshKilled = makeExecutionResult(status: .killed(by: "T2")) + let freshSurvived = makeExecutionResult(status: .survived) - private func makeResult(filePath: String = "/tmp/Foo.swift", status: ExecutionStatus) -> ExecutionResult { - ExecutionResult(descriptor: makeDescriptor(filePath: filePath), status: status, testDuration: 0) - } - - private func makeDescriptor(filePath: String = "/tmp/Foo.swift") -> MutantDescriptor { - MutantDescriptor( - id: "m0", - filePath: filePath, - line: 1, - column: 1, - utf8Offset: 0, - originalText: "+", - mutatedText: "-", - operatorIdentifier: "ArithmeticOperatorReplacement", - replacementKind: .binaryOperator, - description: "+ → -", - isSchematizable: false, - mutatedSourceContent: nil + let summary = RunnerSummary( + results: [cachedKilled, cachedSurvived, freshKilled, freshSurvived], + totalDuration: 5 ) + + #expect(summary.killed.count == 2) + #expect(summary.survived.count == 2) + #expect(summary.score == 50.0) } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/SonarReporterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/SonarReporterTests.swift index 25358f3..9ac5c47 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/SonarReporterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/SonarReporterTests.swift @@ -16,10 +16,14 @@ struct SonarReporterTests { let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .noCoverage), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .killed(by: "t")), - makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .unviable), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .noCoverage), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .killed(by: "t")), + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .unviable), ], totalDuration: 0 ) @@ -41,7 +45,10 @@ struct SonarReporterTests { let outputPath = dir.appendingPathComponent("sonar.json").path let reporter = SonarReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -62,7 +69,10 @@ struct SonarReporterTests { let outputPath = dir.appendingPathComponent("sonar.json").path let reporter = SonarReporter(outputPath: outputPath, projectRoot: "/abs/MyApp") let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .noCoverage)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .noCoverage) + ], totalDuration: 0 ) @@ -84,7 +94,10 @@ struct SonarReporterTests { let projectRoot = "/abs/MyApp" let reporter = SonarReporter(outputPath: outputPath, projectRoot: projectRoot) let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/MyApp/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult( + id: "1", filePath: "/abs/MyApp/Sources/Calc.swift", line: 3, column: 24, status: .survived) + ], totalDuration: 0 ) @@ -97,25 +110,4 @@ struct SonarReporterTests { #expect(location?["filePath"] as? String == "/Sources/Calc.swift") } - - private func makeResult(filePath: String, status: ExecutionStatus) -> ExecutionResult { - ExecutionResult( - descriptor: MutantDescriptor( - id: "1", - filePath: filePath, - line: 3, - column: 24, - utf8Offset: 0, - originalText: "+", - mutatedText: "-", - operatorIdentifier: "ArithmeticOperatorReplacement", - replacementKind: .binaryOperator, - description: "+ → -", - isSchematizable: false, - mutatedSourceContent: nil - ), - status: status, - testDuration: 0 - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift b/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift index abe1bd0..fac6223 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Reporting/TextReporterTests.swift @@ -8,10 +8,10 @@ struct TextReporterTests { func formatContainsScoreAndCounts() { let summary = RunnerSummary( results: [ - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .killed(by: "Suite.test")), - makeResult(status: .survived), - makeResult(status: .unviable), + makeExecutionResult(line: 3, column: 5, status: .killed(by: "Suite.test")), + makeExecutionResult(line: 3, column: 5, status: .killed(by: "Suite.test")), + makeExecutionResult(line: 3, column: 5, status: .survived), + makeExecutionResult(line: 3, column: 5, status: .unviable), ], totalDuration: 12.5 ) @@ -28,7 +28,7 @@ struct TextReporterTests { @Test("Given survived mutants, when format called, then survived section lists file and operator") func formatListsSurvivedMutants() { let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/Sources/Foo.swift", status: .survived)], + results: [makeExecutionResult(filePath: "/abs/Sources/Foo.swift", line: 3, column: 5, status: .survived)], totalDuration: 0 ) @@ -41,7 +41,8 @@ struct TextReporterTests { @Test("Given only unviable mutants, when format called, then survived section is absent") func formatOmitsSurvivedSectionWhenNone() { - let summary = RunnerSummary(results: [makeResult(status: .unviable)], totalDuration: 0) + let summary = RunnerSummary( + results: [makeExecutionResult(line: 3, column: 5, status: .unviable)], totalDuration: 0) let output = TextReporter().format(summary) @@ -51,7 +52,9 @@ struct TextReporterTests { @Test("Given results by file, when format called, then results by file section is present") func formatContainsResultsByFile() { let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/Sources/Calc.swift", status: .killed(by: "t"))], + results: [ + makeExecutionResult(filePath: "/abs/Sources/Calc.swift", line: 3, column: 5, status: .killed(by: "t")) + ], totalDuration: 0 ) @@ -64,7 +67,7 @@ struct TextReporterTests { @Test("Given projectRoot set, when format called, then file paths are shown relative to root") func formatShowsRelativePaths() { let summary = RunnerSummary( - results: [makeResult(filePath: "/proj/Sources/Calc.swift", status: .survived)], + results: [makeExecutionResult(filePath: "/proj/Sources/Calc.swift", line: 3, column: 5, status: .survived)], totalDuration: 0 ) @@ -77,7 +80,9 @@ struct TextReporterTests { @Test("Given path outside project root, when format called, then full path is shown unchanged") func formatShowsFullPathWhenOutsideRoot() { let summary = RunnerSummary( - results: [makeResult(filePath: "/other/Sources/Calc.swift", status: .survived)], + results: [ + makeExecutionResult(filePath: "/other/Sources/Calc.swift", line: 3, column: 5, status: .survived) + ], totalDuration: 0 ) @@ -89,7 +94,7 @@ struct TextReporterTests { @Test("Given a summary, when report called, then output is printed to stdout") func reportPrintsToStdout() async { let summary = RunnerSummary( - results: [makeResult(filePath: "/tmp/Foo.swift", status: .killed(by: "t"))], + results: [makeExecutionResult(filePath: "/tmp/Foo.swift", line: 3, column: 5, status: .killed(by: "t"))], totalDuration: 1.0 ) @@ -103,7 +108,7 @@ struct TextReporterTests { @Test("Given timeout mutant, when format called, then timeout count appears in results by file") func formatShowsTimeoutInResultsByFile() { let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/Sources/Foo.swift", status: .timeout)], + results: [makeExecutionResult(filePath: "/abs/Sources/Foo.swift", line: 3, column: 5, status: .timeout)], totalDuration: 0 ) @@ -116,7 +121,7 @@ struct TextReporterTests { @Test("Given killedByCrash mutant, when format called, then killed count includes crash") func formatCountsKilledByCrashAsKilled() { let summary = RunnerSummary( - results: [makeResult(status: .killedByCrash)], + results: [makeExecutionResult(line: 3, column: 5, status: .killedByCrash)], totalDuration: 0 ) @@ -129,8 +134,8 @@ struct TextReporterTests { func twoSurvivedMutantsAreSortedInOutput() { let summary = RunnerSummary( results: [ - makeResult(filePath: "/abs/Sources/Z.swift", status: .survived), - makeResult(filePath: "/abs/Sources/A.swift", status: .survived), + makeExecutionResult(filePath: "/abs/Sources/Z.swift", line: 3, column: 5, status: .survived), + makeExecutionResult(filePath: "/abs/Sources/A.swift", line: 3, column: 5, status: .survived), ], totalDuration: 0 ) @@ -147,7 +152,7 @@ struct TextReporterTests { @Test("Given duration over 60 seconds, when format called, then duration is shown in minutes and seconds") func formatShowsDurationInMinutes() { let summary = RunnerSummary( - results: [makeResult(status: .killed(by: "t"))], + results: [makeExecutionResult(line: 3, column: 5, status: .killed(by: "t"))], totalDuration: 1825.4 ) @@ -159,7 +164,7 @@ struct TextReporterTests { @Test("Given noCoverage mutant, when format called, then it appears in survived section") func noCoverageAppearsInSurvivedSection() { let summary = RunnerSummary( - results: [makeResult(filePath: "/abs/Foo.swift", status: .noCoverage)], + results: [makeExecutionResult(filePath: "/abs/Foo.swift", line: 3, column: 5, status: .noCoverage)], totalDuration: 0 ) @@ -168,28 +173,4 @@ struct TextReporterTests { #expect(output.contains("Survived mutants:")) #expect(output.contains("/abs/Foo.swift")) } - - private func makeResult( - filePath: String = "/tmp/Foo.swift", - status: ExecutionStatus - ) -> ExecutionResult { - ExecutionResult( - descriptor: MutantDescriptor( - id: "m0", - filePath: filePath, - line: 3, - column: 5, - utf8Offset: 0, - originalText: "+", - mutatedText: "-", - operatorIdentifier: "ArithmeticOperatorReplacement", - replacementKind: .binaryOperator, - description: "+ → -", - isSchematizable: false, - mutatedSourceContent: nil - ), - status: status, - testDuration: 0 - ) - } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift index d00aa96..8e3c66a 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift @@ -434,33 +434,4 @@ struct SandboxFactoryTests { #expect(!FileManager.default.fileExists(atPath: sandbox.rootURL.path)) } - private func swiftLintPbxprojContent() -> String { - """ - - - - - archiveVersion - 1 - objects - - AABBCC - - isa - PBXShellScriptBuildPhase - shellScript - swiftlint lint --strict - - DDEEFF - - isa - PBXShellScriptBuildPhase - shellScript - echo hello - - - - - """ - } }