Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b79d43b
feat: add XcodeProcessLauncher with v1.0.0 behavior
ericodx Apr 3, 2026
0446f4e
feat: add SPMProcessLauncher with aggressive child cleanup
ericodx Apr 3, 2026
d7e673c
refactor: remove launchCapturingDeferred and CapturedOutput from Proc…
ericodx Apr 3, 2026
96f9ef4
test: remove launchCapturingDeferred from MockProcessLauncher
ericodx Apr 3, 2026
12e1288
refactor: remove cleanup field from TestLaunchResult
ericodx Apr 3, 2026
5d945c3
refactor: revert to launchCapturing in execution stages
ericodx Apr 3, 2026
ea3aa91
refactor: inject launcher by ProjectType in composition root
ericodx Apr 3, 2026
4991e3a
test: use explicit launchers in integration tests
ericodx Apr 3, 2026
d3d1d24
refactor: remove generic ProcessLauncher
ericodx Apr 3, 2026
d8d5f2d
test: split ProcessLauncherTests into XcodeProcessLauncher and SPMPro…
ericodx Apr 3, 2026
93a174e
fix: release simulator slot before parsing results
ericodx Apr 4, 2026
0891c77
feat: add TestingFramework enum
ericodx Apr 4, 2026
4212c28
feat: add testingFramework to BuildOptions
ericodx Apr 4, 2026
b307eba
feat: parse --testing-framework CLI flag
ericodx Apr 4, 2026
d35f3b8
test: parse --testing-framework CLI flag
ericodx Apr 4, 2026
4883c8e
feat: resolve testingFramework and force concurrency=1 for xctest
ericodx Apr 4, 2026
54ac101
test: resolve testingFramework and force concurrency=1 for xctest
ericodx Apr 4, 2026
1429d37
feat: auto-detect testing framework in ProjectDetector
ericodx Apr 4, 2026
ebfd24f
feat: include testingFramework in init template
ericodx Apr 4, 2026
162992f
test: include testingFramework in init template
ericodx Apr 4, 2026
ec4c052
feat: split default timeout by platform (120s Xcode, 30s SPM)
ericodx Apr 5, 2026
b449d6f
test: split default timeout by platform (120s Xcode, 30s SPM)
ericodx Apr 5, 2026
e1acd96
feat: add InfiniteLoopFilter for while/repeat-while bodies
ericodx Apr 5, 2026
a3bfd42
test: add InfiniteLoopFilter for while/repeat-while bodies
ericodx Apr 5, 2026
62b8301
fix: remove debug prints from execution stages
ericodx Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/SwiftMutationTesting/CLI/CommandLineParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct CommandLineParser: Sendable {
var timeout: Double?
var concurrency: Int?
var noCache = false
var testingFramework: String?
var output: String?
var htmlOutput: String?
var sonarOutput: String?
Expand Down Expand Up @@ -66,7 +67,8 @@ struct CommandLineParser: Sendable {
testTarget: flags.testTarget,
timeout: flags.timeout,
concurrency: flags.concurrency,
noCache: flags.noCache
noCache: flags.noCache,
testingFramework: flags.testingFramework
),
reporting: .init(
output: flags.output,
Expand Down Expand Up @@ -120,6 +122,9 @@ struct CommandLineParser: Sendable {
case "--no-cache":
values.noCache = true

case "--testing-framework":
values.testingFramework = try nextValue(for: flag, at: &index, in: arguments)

case "--output":
values.output = try nextValue(for: flag, at: &index, in: arguments)

Expand Down
3 changes: 2 additions & 1 deletion Sources/SwiftMutationTesting/CLI/HelpText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ enum HelpText {
OPTIONS:
--scheme <scheme> Xcode scheme to build and test (Xcode projects only)
--destination <destination> xcodebuild destination specifier (Xcode projects only)
--testing-framework <fw> Testing framework: xctest or swift-testing (default: swift-testing)
--target <test-target> Test target name
--timeout <seconds> Per-mutant test timeout in seconds (default: 120)
--timeout <seconds> Per-mutant test timeout in seconds (default: 120 Xcode, 30 SPM)
--concurrency <n> Number of parallel test workers (default: CPUs - 1)
--no-cache Disable the result cache
--output <json-path> Write mutation report JSON to path
Expand Down
5 changes: 4 additions & 1 deletion Sources/SwiftMutationTesting/CLI/ParsedArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ struct ParsedArguments: Sendable {
testTarget: String? = nil,
timeout: Double? = nil,
concurrency: Int? = nil,
noCache: Bool = false
noCache: Bool = false,
testingFramework: String? = nil
) {
self.scheme = scheme
self.destination = destination
self.testTarget = testTarget
self.timeout = timeout
self.concurrency = concurrency
self.noCache = noCache
self.testingFramework = testingFramework
}

var scheme: String?
Expand All @@ -48,6 +50,7 @@ struct ParsedArguments: Sendable {
var timeout: Double?
var concurrency: Int?
var noCache: Bool
var testingFramework: String?
}

struct ReportingOptions: Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ struct ConfigurationFileWriter: Sendable {
scheme: scheme,
allSchemes: allSchemes,
destination: destination,
testTarget: project.testTarget
testTarget: project.testTarget,
testingFramework: project.testingFramework
)
case .spm(let testTargets):
return generateSPMContent(testTargets: testTargets, testTarget: project.testTarget)
Expand All @@ -31,7 +32,8 @@ struct ConfigurationFileWriter: Sendable {
scheme: String?,
allSchemes: [String],
destination: String,
testTarget: String?
testTarget: String?,
testingFramework: TestingFramework
) -> String {
var lines: [String] = []

Expand All @@ -51,6 +53,10 @@ struct ConfigurationFileWriter: Sendable {

lines.append("destination: \(destination)")
lines.append("")
lines.append("# Testing framework: xctest or swift-testing (default: swift-testing)")
lines.append("# When xctest is selected, concurrency is forced to 1 for deterministic results")
lines.append("testingFramework: \(testingFramework.rawValue)")
lines.append("")

if let testTarget {
lines.append("# Limit test execution to a specific target (recommended when the project has UI tests)")
Expand All @@ -65,7 +71,11 @@ struct ConfigurationFileWriter: Sendable {
lines.append("timeout: 120")
lines.append("")
lines.append("# Number of parallel workers (default: max(1, CPU count - 1))")
lines.append("concurrency: 4")
if testingFramework == .xctest {
lines.append("concurrency: 1")
} else {
lines.append("concurrency: 4")
}
lines.append("")
lines.append("# Disable result cache (re-runs all mutants on every execution)")
lines.append("# noCache: true")
Expand Down Expand Up @@ -110,8 +120,8 @@ struct ConfigurationFileWriter: Sendable {
}

lines.append("")
lines.append("# Per-mutant test timeout in seconds (default: 120)")
lines.append("timeout: 120")
lines.append("# Per-mutant test timeout in seconds (default: 30 for SPM)")
lines.append("timeout: 30")
lines.append("")
lines.append("# Disable result cache (re-runs all mutants on every execution)")
lines.append("# noCache: true")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ struct ConfigurationResolver: Sendable {
fileValues: [String: String]
) throws -> RunnerConfiguration {
let projectPath = resolvedPath(cliArguments.projectPath)
let timeout = resolvedTimeout(cli: cliArguments, fileValues: fileValues)
let concurrency = resolvedConcurrency(cli: cliArguments, fileValues: fileValues)

guard concurrency >= 1 else {
Expand All @@ -19,14 +18,25 @@ struct ConfigurationResolver: Sendable {
projectPath: projectPath
)

let testingFramework = try resolvedTestingFramework(cli: cliArguments, fileValues: fileValues)
let timeout = resolvedTimeout(cli: cliArguments, fileValues: fileValues, projectType: projectType)

let effectiveConcurrency: Int
if case .xcode = projectType, testingFramework == .xctest {
effectiveConcurrency = 1
} else {
effectiveConcurrency = concurrency
}

return RunnerConfiguration(
projectPath: projectPath,
build: .init(
projectType: projectType,
testTarget: cliArguments.build.testTarget ?? fileValues["testTarget"],
timeout: timeout,
concurrency: concurrency,
noCache: cliArguments.build.noCache || fileValues["noCache"]?.lowercased() == "true"
concurrency: effectiveConcurrency,
noCache: cliArguments.build.noCache || fileValues["noCache"]?.lowercased() == "true",
testingFramework: testingFramework
),
reporting: .init(
output: cliArguments.reporting.output ?? fileValues["output"],
Expand Down Expand Up @@ -75,10 +85,15 @@ struct ConfigurationResolver: Sendable {
return FileManager.default.fileExists(atPath: packageURL.path)
}

private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String]) -> Double {
private func resolvedTimeout(cli: ParsedArguments, fileValues: [String: String], projectType: ProjectType) -> Double
{
if let timeout = cli.build.timeout { return timeout }
if let timeout = fileValues["timeout"].flatMap(Double.init) { return timeout }
return RunnerConfiguration.defaultTimeout

return switch projectType {
case .xcode: RunnerConfiguration.defaultXcodeTimeout
case .spm: RunnerConfiguration.defaultSPMTimeout
}
}

private func resolvedConcurrency(cli: ParsedArguments, fileValues: [String: String]) -> Int {
Expand All @@ -87,6 +102,21 @@ struct ConfigurationResolver: Sendable {
return RunnerConfiguration.defaultConcurrency
}

private func resolvedTestingFramework(cli: ParsedArguments, fileValues: [String: String]) throws -> TestingFramework
{
let raw = cli.build.testingFramework ?? fileValues["testingFramework"]

guard let raw else {
return .swiftTesting
}

guard let framework = TestingFramework(rawValue: raw) else {
throw UsageError(message: "--testing-framework must be 'xctest' or 'swift-testing'")
}

return framework
}

private func resolveOperators(cli: ParsedArguments, fileValues: [String: String]) -> [String] {
if !cli.filter.operators.isEmpty {
return cli.filter.operators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ struct DetectedProject: Sendable {

static let empty = DetectedProject(
kind: .xcode(scheme: nil, allSchemes: [], destination: "platform=macOS"),
testTarget: nil
testTarget: nil,
testingFramework: .swiftTesting
)

let kind: Kind
let testTarget: String?
var testingFramework: TestingFramework = .swiftTesting

var scheme: String? {
guard case .xcode(let xScheme, _, _) = kind else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,24 @@ struct ProjectDetector: Sendable {
let (schemes, projectName, testTarget) = await listProject(
container: container, workingDirectory: projectURL)
let destination = await detectDestination(in: projectURL)
let framework = detectTestingFramework(at: projectURL, testTarget: testTarget)
return DetectedProject(
kind: .xcode(
scheme: selectScheme(from: schemes, projectName: projectName),
allSchemes: schemes,
destination: destination
),
testTarget: testTarget
testTarget: testTarget,
testingFramework: framework
)
}

if FileManager.default.fileExists(atPath: projectURL.appendingPathComponent("Package.swift").path) {
let testTargets = await listSPMTestTargets(in: projectURL)
return DetectedProject(
kind: .spm(testTargets: testTargets),
testTarget: testTargets.first
testTarget: testTargets.first,
testingFramework: .swiftTesting
)
}

Expand Down Expand Up @@ -223,6 +226,40 @@ struct ProjectDetector: Sendable {
return nil
}

private func detectTestingFramework(at projectURL: URL, testTarget: String?) -> TestingFramework {
let searchURL: URL
if let testTarget {
let targetURL = projectURL.appendingPathComponent(testTarget)
searchURL = FileManager.default.fileExists(atPath: targetURL.path) ? targetURL : projectURL
} else {
searchURL = projectURL
}

guard let enumerator = FileManager.default.enumerator(
at: searchURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else {
return .swiftTesting
}

var hasXCTest = false
var hasSwiftTesting = false

while let url = enumerator.nextObject() as? URL {
guard url.pathExtension == "swift" else { continue }
guard let content = try? String(contentsOf: url, encoding: .utf8) else { continue }

if content.contains("import XCTest") { hasXCTest = true }
if content.contains("import Testing") { hasSwiftTesting = true }

if hasXCTest && hasSwiftTesting { break }
}

if hasXCTest && !hasSwiftTesting { return .xctest }
return .swiftTesting
}

private func runtimeVersion(from key: String) -> (Int, Int) {
let parts = key.components(separatedBy: "-")
guard parts.count >= 2,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Foundation

struct RunnerConfiguration: Sendable {
static let defaultTimeout: Double = 120.0
static let defaultXcodeTimeout: Double = 120.0
static let defaultSPMTimeout: Double = 30.0
static let defaultConcurrency: Int = max(1, ProcessInfo.processInfo.processorCount - 1)

let projectPath: String
Expand All @@ -15,6 +16,7 @@ struct RunnerConfiguration: Sendable {
var timeout: Double
var concurrency: Int
var noCache: Bool
var testingFramework: TestingFramework = .swiftTesting
}

struct ReportingOptions: Sendable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
enum TestingFramework: String, Sendable {
case xctest
case swiftTesting = "swift-testing"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SwiftSyntax

struct InfiniteLoopBodyExtractor: Sendable {
func extractLoopBodyRanges(from syntax: SourceFileSyntax) -> [Range<AbsolutePosition>] {
let visitor = InfiniteLoopBodyVisitor()
visitor.walk(syntax)
return visitor.loopBodyRanges
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import SwiftSyntax

final class InfiniteLoopBodyVisitor: SyntaxVisitor {

init() {
super.init(viewMode: .sourceAccurate)
}

private(set) var loopBodyRanges: [Range<AbsolutePosition>] = []

override func visit(_ node: WhileStmtSyntax) -> SyntaxVisitorContinueKind {
loopBodyRanges.append(node.body.position ..< node.body.endPosition)
return .visitChildren
}

override func visit(_ node: RepeatStmtSyntax) -> SyntaxVisitorContinueKind {
loopBodyRanges.append(node.body.position ..< node.body.endPosition)
return .visitChildren
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import SwiftSyntax

struct InfiniteLoopFilter: Sendable {

private static let riskyOperators: Set<String> = [
"ArithmeticOperatorReplacement",
"RemoveSideEffects",
]

func filter(
_ mutationPoints: [MutationPoint],
loopBodyRanges: [Range<AbsolutePosition>]
) -> [MutationPoint] {
guard !loopBodyRanges.isEmpty else {
return mutationPoints
}

return mutationPoints.filter { point in
guard Self.riskyOperators.contains(point.operatorIdentifier) else {
return true
}

let position = AbsolutePosition(utf8Offset: point.utf8Offset)
return !loopBodyRanges.contains { $0.contains(position) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ struct MutantDiscoveryStage: Sendable {
func run(sources: [ParsedSource]) async -> [MutationPoint] {
let extractor = SuppressionAnnotationExtractor()
let filter = SuppressionFilter()
let loopExtractor = InfiniteLoopBodyExtractor()
let loopFilter = InfiniteLoopFilter()

let allMutations = await withTaskGroup(of: [MutationPoint].self) { group in
for source in sources {
group.addTask { self.mutationPoints(for: source, extractor: extractor, filter: filter) }
group.addTask {
self.mutationPoints(
for: source,
extractor: extractor,
filter: filter,
loopExtractor: loopExtractor,
loopFilter: loopFilter
)
}
}

var collected: [MutationPoint] = []
Expand All @@ -31,10 +41,14 @@ struct MutantDiscoveryStage: Sendable {
private func mutationPoints(
for source: ParsedSource,
extractor: SuppressionAnnotationExtractor,
filter: SuppressionFilter
filter: SuppressionFilter,
loopExtractor: InfiniteLoopBodyExtractor,
loopFilter: InfiniteLoopFilter
) -> [MutationPoint] {
let suppressedRanges = extractor.extractSuppressedRanges(from: source.syntax)
let loopBodyRanges = loopExtractor.extractLoopBodyRanges(from: source.syntax)
let mutations = operators.flatMap { $0.mutations(in: source) }
return filter.filter(mutations, suppressedRanges: suppressedRanges)
let afterSuppression = filter.filter(mutations, suppressedRanges: suppressedRanges)
return loopFilter.filter(afterSuppression, loopBodyRanges: loopBodyRanges)
}
}
Loading