diff --git a/Sources/SwiftMutationTesting/Build/BuildArtifact.swift b/Sources/SwiftMutationTesting/Build/BuildArtifact.swift index f580cf1..e9ab4c5 100644 --- a/Sources/SwiftMutationTesting/Build/BuildArtifact.swift +++ b/Sources/SwiftMutationTesting/Build/BuildArtifact.swift @@ -2,6 +2,6 @@ import Foundation struct BuildArtifact: Sendable { let derivedDataPath: String - let xctestrunURL: URL - let plist: XCTestRunPlist + let xctestrunURL: URL? + let plist: XCTestRunPlist? } diff --git a/Sources/SwiftMutationTesting/Build/BuildStage.swift b/Sources/SwiftMutationTesting/Build/BuildStage.swift index 0d636c7..62f027f 100644 --- a/Sources/SwiftMutationTesting/Build/BuildStage.swift +++ b/Sources/SwiftMutationTesting/Build/BuildStage.swift @@ -54,6 +54,33 @@ struct BuildStage: Sendable { ) } + func buildSPM( + sandbox: Sandbox, + testTarget: String?, + timeout: Double + ) async throws -> BuildArtifact { + var arguments = ["build", "--build-tests"] + + if let testTarget { + arguments += ["--target", testTarget] + } + + let exitCode = try await launcher.launch( + executableURL: URL(fileURLWithPath: "/usr/bin/swift"), + arguments: arguments, + workingDirectoryURL: sandbox.rootURL, + timeout: timeout + ) + + guard exitCode == 0 else { throw BuildError.compilationFailed } + + return BuildArtifact( + derivedDataPath: sandbox.rootURL.appendingPathComponent(".build").path, + xctestrunURL: nil, + plist: nil + ) + } + private func findXcworkspace(in directory: URL) -> URL? { let items = (try? FileManager.default.contentsOfDirectory( diff --git a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift index ee1d1c3..9ee6b36 100644 --- a/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift +++ b/Sources/SwiftMutationTesting/Execution/TestExecutionStage.swift @@ -48,7 +48,11 @@ struct TestExecutionStage: Sendable { return result } - let plistData = context.artifact.plist.activating(mutant.id) + guard let plist = context.artifact.plist else { + return ExecutionResult(descriptor: mutant, status: .unviable, testDuration: 0) + } + + let plistData = plist.activating(mutant.id) let slot = try await context.pool.acquire() let launched: TestLaunchResult do { @@ -80,8 +84,10 @@ struct TestExecutionStage: Sendable { slot: SimulatorSlot, in context: TestExecutionContext ) async throws -> TestLaunchResult { - let xctestrunURL = context.artifact.xctestrunURL.deletingLastPathComponent() - .appendingPathComponent("\(UUID().uuidString).xctestrun") + let baseURL = + context.artifact.xctestrunURL?.deletingLastPathComponent() + ?? context.sandbox.rootURL + let xctestrunURL = baseURL.appendingPathComponent("\(UUID().uuidString).xctestrun") let xcresultPath = context.sandbox.rootURL .appendingPathComponent("\(UUID().uuidString).xcresult").path diff --git a/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift b/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift index da4e17d..abe9afb 100644 --- a/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift +++ b/Sources/SwiftMutationTesting/Sandbox/SandboxFactory.swift @@ -268,8 +268,9 @@ struct SandboxFactory: Sendable { let sourcesURL = sandboxURL.appendingPathComponent("Sources") if FileManager.default.fileExists(atPath: sourcesURL.path) { + let targetURL = firstSourcesTargetDirectory(in: sourcesURL) ?? sourcesURL try content.write( - to: sourcesURL.appendingPathComponent("__SMTSupport.swift"), + to: targetURL.appendingPathComponent("__SMTSupport.swift"), atomically: true, encoding: .utf8 ) @@ -290,4 +291,18 @@ struct SandboxFactory: Sendable { try (existing + "\n" + content).write(to: sandboxFileURL, atomically: true, encoding: .utf8) } + + private func firstSourcesTargetDirectory(in sourcesURL: URL) -> URL? { + let items = + (try? FileManager.default.contentsOfDirectory( + at: sourcesURL, + includingPropertiesForKeys: [.isDirectoryKey] + )) ?? [] + + return + items + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .first + } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift index 508e3b5..94ef1be 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Build/BuildStageTests.swift @@ -31,7 +31,7 @@ struct BuildStageTests { ) #expect(artifact.derivedDataPath == projectDir.appendingPathComponent(".xmr-derived-data").path) - #expect(artifact.xctestrunURL.lastPathComponent == "App.xctestrun") + #expect(artifact.xctestrunURL?.lastPathComponent == "App.xctestrun") } @Test("Given build failure, when build called, then throws compilationFailed") @@ -77,7 +77,7 @@ struct BuildStageTests { sandbox: sandbox, scheme: "App", destination: "platform=macOS", timeout: 60 ) - #expect(artifact.xctestrunURL.lastPathComponent == "App.xctestrun") + #expect(artifact.xctestrunURL?.lastPathComponent == "App.xctestrun") } @Test("Given xcodeproj in sandbox, when build called, then project flag is passed") @@ -105,7 +105,7 @@ struct BuildStageTests { sandbox: sandbox, scheme: "App", destination: "platform=macOS", timeout: 60 ) - #expect(artifact.xctestrunURL.lastPathComponent == "App.xctestrun") + #expect(artifact.xctestrunURL?.lastPathComponent == "App.xctestrun") } @Test("Given xctestrun file with invalid plist data, when build called, then throws xctestrunNotFound") @@ -147,4 +147,47 @@ struct BuildStageTests { ) } } + + @Test("Given successful SPM build, when buildSPM called, then returns artifact with nil xctestrunURL and plist") + func spmBuildReturnsArtifactWithNilXctestrunAndPlist() async throws { + let projectDir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(projectDir) } + + let sandbox = Sandbox(rootURL: projectDir) + let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 0)) + + let artifact = try await stage.buildSPM(sandbox: sandbox, testTarget: nil, timeout: 60) + + #expect(artifact.derivedDataPath == projectDir.appendingPathComponent(".build").path) + #expect(artifact.xctestrunURL == nil) + #expect(artifact.plist == nil) + } + + @Test("Given SPM build failure, when buildSPM called, then throws compilationFailed") + func spmBuildFailureThrowsCompilationFailed() async throws { + let projectDir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(projectDir) } + + let sandbox = Sandbox(rootURL: projectDir) + let stage = BuildStage(launcher: MockProcessLauncher(exitCode: 1)) + + await #expect(throws: BuildError.compilationFailed) { + try await stage.buildSPM(sandbox: sandbox, testTarget: nil, timeout: 60) + } + } + + @Test("Given SPM build with test target, when buildSPM called, then target flag is included in arguments") + func spmBuildWithTestTargetIncludesTargetFlag() async throws { + let projectDir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(projectDir) } + + let launcher = MockProcessLauncher(exitCode: 0) + let sandbox = Sandbox(rootURL: projectDir) + let stage = BuildStage(launcher: launcher) + + let artifact = try await stage.buildSPM(sandbox: sandbox, testTarget: "MyLibTests", timeout: 60) + + #expect(artifact.xctestrunURL == nil) + #expect(artifact.plist == nil) + } } diff --git a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift index 0f615f7..1ccaa18 100644 --- a/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift +++ b/Tests/SwiftMutationTestingTests/Unit/Sandbox/SandboxFactoryTests.swift @@ -55,6 +55,63 @@ struct SandboxFactoryTests { #expect(content == "let support = true") } + @Test( + "Given SPM project with target subdir, when sandbox created, then __SMTSupport.swift is in first target dir" + ) + func injectsSupportFileIntoFirstSPMTargetDirectory() async throws { + let projectDir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(projectDir) } + + let targetDir = projectDir.appendingPathComponent("Sources/MyLib") + try FileManager.default.createDirectory(at: targetDir, withIntermediateDirectories: true) + try FileHelpers.write("original content", named: "File.swift", in: targetDir) + let filePath = targetDir.appendingPathComponent("File.swift").path + + let schematized = SchematizedFile(originalPath: filePath, schematizedContent: "schematized content") + let sandbox = try await factory.create( + projectPath: projectDir.path, + schematizedFiles: [schematized], + supportFileContent: "let support = true" + ) + defer { try? sandbox.cleanup() } + + let supportURL = sandbox.rootURL.appendingPathComponent("Sources/MyLib/__SMTSupport.swift") + let rootLevelURL = sandbox.rootURL.appendingPathComponent("Sources/__SMTSupport.swift") + let content = try String(contentsOf: supportURL, encoding: .utf8) + + #expect(content == "let support = true") + #expect(!FileManager.default.fileExists(atPath: rootLevelURL.path)) + } + + @Test( + "Given SPM multiple targets, when sandbox created, then __SMTSupport.swift goes into first alphabetical dir" + ) + func injectsSupportFileIntoFirstAlphabeticalSPMTargetDirectory() async throws { + let projectDir = try FileHelpers.makeTemporaryDirectory() + defer { FileHelpers.cleanup(projectDir) } + + let alphaDir = projectDir.appendingPathComponent("Sources/Alpha") + let betaDir = projectDir.appendingPathComponent("Sources/Beta") + try FileManager.default.createDirectory(at: alphaDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: betaDir, withIntermediateDirectories: true) + try FileHelpers.write("let a = 1", named: "Alpha.swift", in: alphaDir) + let filePath = alphaDir.appendingPathComponent("Alpha.swift").path + + let schematized = SchematizedFile(originalPath: filePath, schematizedContent: "let a = 2") + let sandbox = try await factory.create( + projectPath: projectDir.path, + schematizedFiles: [schematized], + supportFileContent: "let support = true" + ) + defer { try? sandbox.cleanup() } + + let supportInAlpha = sandbox.rootURL.appendingPathComponent("Sources/Alpha/__SMTSupport.swift") + let supportInBeta = sandbox.rootURL.appendingPathComponent("Sources/Beta/__SMTSupport.swift") + + #expect(FileManager.default.fileExists(atPath: supportInAlpha.path)) + #expect(!FileManager.default.fileExists(atPath: supportInBeta.path)) + } + @Test("Given Xcode project, when sandbox created, then support content is appended to first schematized file") func injectsSupportContentIntoFirstSchematizedFileForXcodeProject() async throws { let projectDir = try FileHelpers.makeTemporaryDirectory()