@@ -216,6 +216,11 @@ struct TestCommandOptions: ParsableArguments {
216216 help: " Enable code coverage. " )
217217 var enableCodeCoverage : Bool = false
218218
219+ /// Launch tests under LLDB debugger.
220+ @Flag ( name: . customLong( " debugger " ) ,
221+ help: " Launch tests under LLDB debugger. " )
222+ var shouldLaunchInLLDB : Bool = false
223+
219224 /// Configure test output.
220225 @Option ( help: ArgumentHelp ( " " , visibility: . hidden) )
221226 public var testOutput : TestOutput = . default
@@ -280,8 +285,17 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
280285
281286 var results = [ TestRunner . Result] ( )
282287
288+ if options. shouldLaunchInLLDB {
289+ let result = try await runTestProductsWithLLDB (
290+ testProducts,
291+ productsBuildParameters: buildParameters,
292+ swiftCommandState: swiftCommandState
293+ )
294+ results. append ( result)
295+ }
296+
283297 // Run XCTest.
284- if options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
298+ if !options . shouldLaunchInLLDB && options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
285299 // Validate XCTest is available on Darwin-based systems. If it's not available and we're hitting this code
286300 // path, that means the developer must have explicitly passed --enable-xctest (or the toolchain is
287301 // corrupt, I suppose.)
@@ -351,7 +365,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
351365 }
352366
353367 // Run Swift Testing (parallel or not, it has a single entry point.)
354- if options. testLibraryOptions. isEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) {
368+ if !options . shouldLaunchInLLDB && options. testLibraryOptions. isEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) {
355369 lazy var testEntryPointPath = testProducts. lazy. compactMap ( \. testEntryPointPath) . first
356370 if options. testLibraryOptions. isExplicitlyEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) || testEntryPointPath == nil {
357371 results. append (
@@ -474,27 +488,200 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
474488 }
475489 }
476490
491+ /// Runs test products under LLDB debugger for interactive debugging.
492+ ///
493+ /// This method handles debugging for enabled testing libraries:
494+ /// 1. If both XCTest and Swift Testing are enabled, prompts user to choose or runs both in separate sessions
495+ /// 2. Validates that exactly one test product is available for debugging
496+ /// 3. Creates a DebugTestRunner and launches LLDB with the test binary
497+ ///
498+ /// - Parameters:
499+ /// - testProducts: The built test products
500+ /// - productsBuildParameters: Build parameters for the products
501+ /// - swiftCommandState: The Swift command state
502+ /// - Returns: The test result (typically .success since LLDB takes over)
503+ private func runTestProductsWithLLDB(
504+ _ testProducts: [ BuiltTestProduct ] ,
505+ productsBuildParameters: BuildParameters ,
506+ swiftCommandState: SwiftCommandState
507+ ) async throws -> TestRunner . Result {
508+ // Validate that we have exactly one test product for debugging
509+ guard testProducts. count == 1 else {
510+ if testProducts. isEmpty {
511+ throw StringError ( " No test products found for debugging " )
512+ } else {
513+ let productNames = testProducts. map { $0. productName } . joined ( separator: " , " )
514+ throw StringError ( " Multiple test products found ( \( productNames) ). Specify a single target with --filter when using --debugger " )
515+ }
516+ }
517+
518+ let testProduct = testProducts [ 0 ]
519+ let toolchain = try swiftCommandState. getTargetToolchain ( )
520+
521+ // Determine which testing libraries are enabled
522+ let xctestEnabled = options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState)
523+ let swiftTestingEnabled = options. testLibraryOptions. isEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) &&
524+ ( options. testLibraryOptions. isExplicitlyEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) ||
525+ testProduct. testEntryPointPath == nil )
526+
527+ // Create a list of testing libraries to run in sequence, checking for actual tests
528+ var librariesToRun : [ TestingLibrary ] = [ ]
529+ var skippedLibraries : [ ( TestingLibrary , String ) ] = [ ]
530+
531+ // Only add XCTest if it's enabled AND has tests to run
532+ if xctestEnabled {
533+ // Always check for XCTest tests by getting test suites
534+ let testSuites = try TestingSupport . getTestSuites (
535+ in: testProducts,
536+ swiftCommandState: swiftCommandState,
537+ enableCodeCoverage: options. enableCodeCoverage,
538+ shouldSkipBuilding: options. sharedOptions. shouldSkipBuilding,
539+ experimentalTestOutput: options. enableExperimentalTestOutput,
540+ sanitizers: globalOptions. build. sanitizers
541+ )
542+ let filteredTests = try testSuites
543+ . filteredTests ( specifier: options. testCaseSpecifier)
544+ . skippedTests ( specifier: options. skippedTests ( fileSystem: swiftCommandState. fileSystem) )
545+
546+ if !filteredTests. isEmpty {
547+ librariesToRun. append ( . xctest)
548+ } else {
549+ skippedLibraries. append ( ( . xctest, " no XCTest tests found " ) )
550+ }
551+ }
552+
553+ if swiftTestingEnabled {
554+ librariesToRun. append ( . swiftTesting)
555+ }
556+
557+ // Ensure we have at least one library to run
558+ guard !librariesToRun. isEmpty else {
559+ if !skippedLibraries. isEmpty {
560+ let skippedMessages = skippedLibraries. map { library, reason in
561+ let libraryName = library == . xctest ? " XCTest " : " Swift Testing "
562+ return " \( libraryName) : \( reason) "
563+ }
564+ throw StringError ( " No testing libraries have tests to debug. Skipped: \( skippedMessages. joined ( separator: " , " ) ) " )
565+ }
566+ throw StringError ( " No testing libraries are enabled for debugging " )
567+ }
568+
569+ try await runTestLibrariesWithLLDB (
570+ testProduct: testProduct,
571+ target: DebuggableTestSession (
572+ targets: librariesToRun. map {
573+ DebuggableTestSession . Target (
574+ library: $0,
575+ additionalArgs: try additionalLLDBArguments ( for: $0, testProducts: testProducts, swiftCommandState: swiftCommandState) ,
576+ bundlePath: testBundlePath ( for: $0, testProduct: testProduct)
577+ )
578+ }
579+ ) ,
580+ testProducts: testProducts,
581+ productsBuildParameters: productsBuildParameters,
582+ swiftCommandState: swiftCommandState,
583+ toolchain: toolchain
584+ )
585+
586+ // Clean up Python script file after all sessions complete
587+ // (Breakpoint file cleanup is handled by DebugTestRunner based on SessionState)
588+ if librariesToRun. count > 1 {
589+ let tempDir = try swiftCommandState. fileSystem. tempDirectory
590+ let pythonScriptFile = tempDir. appending ( " save_breakpoints.py " )
591+
592+ if swiftCommandState. fileSystem. exists ( pythonScriptFile) {
593+ try ? swiftCommandState. fileSystem. removeFileTree ( pythonScriptFile)
594+ }
595+ }
596+
597+ return . success
598+ }
599+
600+ private func additionalLLDBArguments( for library: TestingLibrary , testProducts: [ BuiltTestProduct ] , swiftCommandState: SwiftCommandState ) throws -> [ String ] {
601+ // Determine test binary path and arguments based on the testing library
602+ switch library {
603+ case . xctest:
604+ let ( xctestArgs, _) = try xctestArgs ( for: testProducts, swiftCommandState: swiftCommandState)
605+ return xctestArgs
606+
607+ case . swiftTesting:
608+ let commandLineArguments = CommandLine . arguments. dropFirst ( )
609+ var swiftTestingArgs = [ " --testing-library " , " swift-testing " , " --enable-swift-testing " ]
610+
611+ if let separatorIndex = commandLineArguments. firstIndex ( of: " -- " ) {
612+ // Only pass arguments after the "--" separator
613+ swiftTestingArgs += Array ( commandLineArguments. dropFirst ( separatorIndex + 1 ) )
614+ }
615+ return swiftTestingArgs
616+ }
617+ }
618+
619+ private func testBundlePath( for library: TestingLibrary , testProduct: BuiltTestProduct ) -> AbsolutePath {
620+ switch library {
621+ case . xctest:
622+ testProduct. bundlePath
623+ case . swiftTesting:
624+ testProduct. binaryPath
625+ }
626+ }
627+
628+ /// Runs a single testing library under LLDB debugger.
629+ ///
630+ /// - Parameters:
631+ /// - testProduct: The test product to debug
632+ /// - library: The testing library to run
633+ /// - testProducts: All built test products (for XCTest args generation)
634+ /// - productsBuildParameters: Build parameters for the products
635+ /// - swiftCommandState: The Swift command state
636+ /// - toolchain: The toolchain to use
637+ /// - sessionState: The debugging session state for breakpoint persistence
638+ private func runTestLibrariesWithLLDB(
639+ testProduct: BuiltTestProduct ,
640+ target: DebuggableTestSession ,
641+ testProducts: [ BuiltTestProduct ] ,
642+ productsBuildParameters: BuildParameters ,
643+ swiftCommandState: SwiftCommandState ,
644+ toolchain: UserToolchain
645+ ) async throws {
646+ // Create and launch the debug test runner
647+ let debugRunner = DebugTestRunner (
648+ target: target,
649+ buildParameters: productsBuildParameters,
650+ toolchain: toolchain,
651+ testEnv: try TestingSupport . constructTestEnvironment (
652+ toolchain: toolchain,
653+ destinationBuildParameters: productsBuildParameters,
654+ sanitizers: globalOptions. build. sanitizers,
655+ library: . xctest // TODO
656+ ) ,
657+ cancellator: swiftCommandState. cancellator,
658+ fileSystem: swiftCommandState. fileSystem,
659+ observabilityScope: swiftCommandState. observabilityScope,
660+ verbose: globalOptions. logging. verbose
661+ )
662+
663+ // Launch LLDB using AsyncProcess with proper input/output forwarding
664+ try debugRunner. run ( )
665+ }
666+
477667 private func runTestProducts(
478668 _ testProducts: [ BuiltTestProduct ] ,
479669 additionalArguments: [ String ] ,
480670 productsBuildParameters: BuildParameters ,
481671 swiftCommandState: SwiftCommandState ,
482672 library: TestingLibrary
483673 ) async throws -> TestRunner . Result {
484- // Pass through all arguments from the command line to Swift Testing.
674+ // Pass through arguments that come after "--" to Swift Testing.
485675 var additionalArguments = additionalArguments
486676 if library == . swiftTesting {
487- // Reconstruct the arguments list. If an xUnit path was specified, remove it.
488- var commandLineArguments = [ String] ( )
489- var originalCommandLineArguments = CommandLine . arguments. dropFirst ( ) . makeIterator ( )
490- while let arg = originalCommandLineArguments. next ( ) {
491- if arg == " --xunit-output " {
492- _ = originalCommandLineArguments. next ( )
493- } else {
494- commandLineArguments. append ( arg)
495- }
677+ // Only pass arguments that come after the "--" separator to Swift Testing
678+ let allCommandLineArguments = CommandLine . arguments. dropFirst ( )
679+
680+ if let separatorIndex = allCommandLineArguments. firstIndex ( of: " -- " ) {
681+ // Only pass arguments after the "--" separator
682+ let testArguments = Array ( allCommandLineArguments. dropFirst ( separatorIndex + 1 ) )
683+ additionalArguments += testArguments
496684 }
497- additionalArguments += commandLineArguments
498685
499686 if var xunitPath = options. xUnitOutput {
500687 if options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
@@ -667,6 +854,11 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
667854 ///
668855 /// - Throws: if a command argument is invalid
669856 private func validateArguments( swiftCommandState: SwiftCommandState ) throws {
857+ // Validation for --debugger first, since it affects other validations.
858+ if options. shouldLaunchInLLDB {
859+ try validateLLDBCompatibility ( swiftCommandState: swiftCommandState)
860+ }
861+
670862 // Validation for --num-workers.
671863 if let workers = options. numberOfWorkers {
672864 // The --num-worker option should be called with --parallel. Since
@@ -690,6 +882,36 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
690882 }
691883 }
692884
885+ /// Validates that --debugger is compatible with other provided arguments
886+ ///
887+ /// - Throws: if --debugger is used with incompatible flags
888+ private func validateLLDBCompatibility( swiftCommandState: SwiftCommandState ) throws {
889+ // --debugger cannot be used with release configuration
890+ let configuration = options. globalOptions. build. configuration ?? swiftCommandState. preferredBuildConfiguration
891+ if configuration == . release {
892+ throw StringError ( " --debugger cannot be used with release configuration (debugging requires debug symbols) " )
893+ }
894+
895+ // --debugger cannot be used with parallel testing
896+ if options. shouldRunInParallel {
897+ throw StringError ( " --debugger cannot be used with --parallel (debugging requires sequential execution) " )
898+ }
899+
900+ // --debugger cannot be used with --num-workers (which requires --parallel anyway)
901+ if options. numberOfWorkers != nil {
902+ throw StringError ( " --debugger cannot be used with --num-workers (debugging requires sequential execution) " )
903+ }
904+
905+ // --debugger cannot be used with information-only modes that exit early
906+ if options. _deprecated_shouldListTests {
907+ throw StringError ( " --debugger cannot be used with --list-tests (use 'swift test list' for listing tests) " )
908+ }
909+
910+ if options. shouldPrintCodeCovPath {
911+ throw StringError ( " --debugger cannot be used with --show-codecov-path (debugging session cannot show paths) " )
912+ }
913+ }
914+
693915 public init ( ) { }
694916}
695917
0 commit comments