@@ -216,6 +216,11 @@ struct TestCommandOptions: ParsableArguments {
216
216
help: " Enable code coverage. " )
217
217
var enableCodeCoverage : Bool = false
218
218
219
+ /// Launch tests under LLDB debugger.
220
+ @Flag ( name: . customLong( " debugger " ) ,
221
+ help: " Launch tests under LLDB debugger. " )
222
+ var shouldLaunchInLLDB : Bool = false
223
+
219
224
/// Configure test output.
220
225
@Option ( help: ArgumentHelp ( " " , visibility: . hidden) )
221
226
public var testOutput : TestOutput = . default
@@ -280,8 +285,17 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
280
285
281
286
var results = [ TestRunner . Result] ( )
282
287
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
+
283
297
// Run XCTest.
284
- if options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
298
+ if !options . shouldLaunchInLLDB && options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
285
299
// Validate XCTest is available on Darwin-based systems. If it's not available and we're hitting this code
286
300
// path, that means the developer must have explicitly passed --enable-xctest (or the toolchain is
287
301
// corrupt, I suppose.)
@@ -351,7 +365,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
351
365
}
352
366
353
367
// 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) {
355
369
lazy var testEntryPointPath = testProducts. lazy. compactMap ( \. testEntryPointPath) . first
356
370
if options. testLibraryOptions. isExplicitlyEnabled ( . swiftTesting, swiftCommandState: swiftCommandState) || testEntryPointPath == nil {
357
371
results. append (
@@ -474,27 +488,200 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
474
488
}
475
489
}
476
490
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
+
477
667
private func runTestProducts(
478
668
_ testProducts: [ BuiltTestProduct ] ,
479
669
additionalArguments: [ String ] ,
480
670
productsBuildParameters: BuildParameters ,
481
671
swiftCommandState: SwiftCommandState ,
482
672
library: TestingLibrary
483
673
) 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.
485
675
var additionalArguments = additionalArguments
486
676
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
496
684
}
497
- additionalArguments += commandLineArguments
498
685
499
686
if var xunitPath = options. xUnitOutput {
500
687
if options. testLibraryOptions. isEnabled ( . xctest, swiftCommandState: swiftCommandState) {
@@ -667,6 +854,11 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
667
854
///
668
855
/// - Throws: if a command argument is invalid
669
856
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
+
670
862
// Validation for --num-workers.
671
863
if let workers = options. numberOfWorkers {
672
864
// The --num-worker option should be called with --parallel. Since
@@ -690,6 +882,36 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
690
882
}
691
883
}
692
884
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
+
693
915
public init ( ) { }
694
916
}
695
917
0 commit comments